Compare commits

..

16 Commits

Author SHA1 Message Date
yusing
090b73d287 fixed tcp/udp I/O, deadlock, nil dereference; improved docker watcher, idlewatcher, loading page 2024-09-23 00:49:46 +08:00
yusing
96bce79e4b changed env GOPROXY_*_PORT to GOPROXY_*_ADDR, changed api server default to listen on localhost only, readme update 2024-09-22 06:06:24 +08:00
yusing
d9fd399e43 fix stuck loading in some scenerios for ls-* command line options 2024-09-22 05:01:36 +08:00
yusing
46281aa3b0 renamed ProxyEntry to RawEntry to avoid confusion with src/proxy/entry.go 2024-09-22 04:13:42 +08:00
yusing
d39b68bfd8 fixed possible resource leak 2024-09-22 04:11:02 +08:00
yusing
a11ce46028 added some docker compose examples; fixed defaults to wrong host; updated watcher behavior to retry connection every 3 secs until success or until cancelled 2024-09-22 04:00:08 +08:00
yusing
6388d9d44d fixed outputing error in ls-config, ls-routes, etc. 2024-09-21 18:47:38 +08:00
yusing
69361aea1b fixed host set to localhost even on remote docker, fixed one error in provider causing all routes not to load 2024-09-21 18:23:20 +08:00
yusing
26e2154c64 fixed startup crash for file provider 2024-09-21 17:22:17 +08:00
Yuzerion
a29bf880bc Update docker.md
Too sleepy...
2024-09-21 16:08:11 +08:00
Yuzerion
1f6d03bdbb Update compose.example.yml 2024-09-21 16:07:12 +08:00
Yuzerion
4a7d898b8e Update docker.md 2024-09-21 16:06:32 +08:00
Yuzerion
521b694aec Update docker.md 2024-09-21 15:56:39 +08:00
yusing
a351de7441 github CI fix attempt 2024-09-21 14:32:52 +08:00
yusing
ab2dc26b76 fixing udp stream listening on wrong port 2024-09-21 14:18:29 +08:00
yusing
9a81b13b67 fixing tcp/udp error on closing 2024-09-21 13:40:20 +08:00
48 changed files with 989 additions and 593 deletions

View File

@@ -15,7 +15,7 @@ jobs:
tags: ${{ github.ref_name }}
- name: Tag as latest
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-')
run: |
docker tag ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest

6
.gitignore vendored
View File

@@ -1,7 +1,7 @@
compose.yml
config/
certs/
config*/
certs*/
bin/
templates/codemirror/
@@ -13,6 +13,6 @@ log/
go.work.sum
!src/config/
!src/**/
todo.md

View File

@@ -54,7 +54,9 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
2. Setup `go-proxy` [See here](docs/docker.md)
3. Configure `go-proxy`
3. Setup `docker-socket-proxy` (see [example](docs/docker_socket_proxy.md) other machine that is running docker (if any)
4. Configure `go-proxy`
- with text editor (e.g. Visual Studio Code)
- or with web config editor via `http://gp.y.z`
@@ -74,10 +76,13 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
### Environment variables
| Environment Variable | Description | Default | Values |
| ------------------------------ | ------------------------- | ------- | ------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | disable schema validation | `false` | boolean |
| `GOPROXY_DEBUG` | enable debug behaviors | `false` | boolean |
| Environment Variable | Description | Default | Values |
| ------------------------------ | ------------------------------------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | disable schema validation | `false` | boolean |
| `GOPROXY_DEBUG` | enable debug behaviors | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http server listening address | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https server listening address (if enabled) | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api server listening address | `127.0.0.1:8888` | `[host]:port` |
### Use JSON Schema in VSCode
@@ -121,8 +126,6 @@ See [providers.example.yml](providers.example.yml) for examples
## Known issues
- Cert "renewal" is actually obtaining a new cert instead of renewing the existing one
- `autocert` config is not hot-reloadable
[🔼Back to top](#table-of-content)

View File

@@ -6,7 +6,7 @@
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
一個輕量化、易用且[高效](docs/benchmark_result.md)的反向代理工具
一個輕量化、易用且[高效](docs/benchmark_result.md)的反向代理和端口轉發工具
## 目錄
@@ -72,10 +72,13 @@
### 環境變量
| 環境變量 | 描述 | 默認 | 值 |
| ------------------------------ | ---------------- | ------- | ------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | 禁用 schema 驗證 | `false` | boolean |
| `GOPROXY_DEBUG` | 啟用調試輸出 | `false` | boolean |
| 環境變量 | 描述 | 默認 | 格式 |
| ------------------------------ | ---------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | 禁用 schema 驗證 | `false` | boolean |
| `GOPROXY_DEBUG` | 啟用調試輸出 | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http 收聽地址 | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https 收聽地址 | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api 收聽地址 | `127.0.0.1:8888` | `[host]:port` |
### VSCode 中使用 JSON Schema
@@ -119,8 +122,6 @@ providers:
## 已知問題
- 證書“更新”實際上是獲取新證書而不是更新現有證書
- `autocert` 配置不能熱重載
[🔼 返回頂部](#目錄)

View File

@@ -18,7 +18,7 @@ services:
# (Optional) change this to your timezone to get correct log timestamp
TZ: ETC/UTC
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
# (Optional) choose one of below to enable https

View File

@@ -95,7 +95,7 @@
| `proxy.stop_timeout` | time to wait for stop command | `10s` | `number[unit]...` |
| `proxy.stop_signal` | signal sent to container for `stop` and `kill` methods | docker's default | `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and those without **SIG** prefix |
| `proxy.<alias>.<field>` | set field for specific alias | N/A | N/A |
| `proxy.$<index>.<field>` | set field for specific alias at index (started from **0**) | N/A | N/A |
| `proxy.$<index>.<field>` | set field for specific alias at index (starting from **1**) | N/A | N/A |
| `proxy.*.<field>` | set field for all aliases | N/A | N/A |
### Fields
@@ -190,6 +190,7 @@ service_a:
nginx-2: # Option 2
...
container_name: nginx-2
network_mode: host
labels:
proxy.nginx-2.port: 80
```
@@ -214,6 +215,8 @@ service_a:
## Docker compose examples
More examples in [here](examples/)
```yaml
volumes:
adg-work:
@@ -237,7 +240,7 @@ services:
ports:
- 80
- 3000
- 53
- 53/udp
mc:
image: itzg/minecraft-server
tty: true
@@ -247,7 +250,6 @@ services:
ports:
- 25565
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
@@ -259,8 +261,8 @@ services:
container_name: pal
stop_grace_period: 30s
ports:
- 8211
- 27015
- 8211/udp
- 27015/udp
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
@@ -285,7 +287,7 @@ services:
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/run/docker.sock:/var/run/docker.sock
go-proxy-frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
@@ -293,7 +295,7 @@ services:
network_mode: host
labels:
- proxy.aliases=gp
- proxy.gp.port=8888
- proxy.gp.port=3000
depends_on:
- go-proxy
```
@@ -306,8 +308,8 @@ services:
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server
- `yourdomain.com:8211`: palworld server
- `yourdomain.com:2000`: adguard dns (udp)
- `yourdomain.com:20001`: minecraft server
- `yourdomain.com:20002`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -0,0 +1,40 @@
## Docker Socket Proxy
For docker client on other machine, set this up, then add `name: tcp://<machine_ip>:2375` to `config.yml` under `docker` section
```yml
# compose.yml on remote machine (e.g. server1)
docker-proxy:
container_name: docker-proxy
image: tecnativa/docker-socket-proxy
privileged: true
environment:
- ALLOW_START=1
- ALLOW_STOP=1
- ALLOW_RESTARTS=1
- CONTAINERS=1
- EVENTS=1
- PING=1
- POST=1
- VERSION=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: always
ports:
- 2375:2375
# or more secure
- <machine_ip>:2375:2375
```
```yml
# config.yml on go-proxy machine
autocert:
... # your config
providers:
include:
...
docker:
...
server1: tcp://<machine_ip>:2375
```

16
examples/microbin.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
app:
container_name: microbin
cpu_shares: 10
deploy:
resources:
limits:
memory: 256M
env_file: .env
image: docker.i.sh/danielszabo99/microbin:latest
ports:
- 8080
restart: unless-stopped
volumes:
- ./data:/app/microbin_data
# microbin.domain.tld

16
examples/siyuan.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
main:
image: b3log/siyuan:v3.1.0
container_name: siyuan
command:
- --workspace=/siyuan/workspace/
- --accessAuthCode=<some password>
user: 1000:1000
volumes:
- ./workspace:/siyuan/workspace
restart: unless-stopped
environment:
- TZ=Asia/Hong_Kong
ports:
- 6806
# siyuan.domain.tld

View File

@@ -36,7 +36,7 @@ func IsStreamHealthy(scheme, address string) bool {
}
func ReloadServer() E.NestedError {
resp, err := HttpClient.Post(fmt.Sprintf("http://localhost%v/reload", common.APIHTTPPort), "", nil)
resp, err := HttpClient.Post(fmt.Sprintf("http://localhost%v/reload", common.APIHTTPAddr), "", nil)
if err != nil {
return E.From(err)
}

View File

@@ -59,7 +59,7 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
defer b.To(&res)
if p.cfg.Provider == ProviderLocal {
b.Addf("provider is set to %q", ProviderLocal)
b.Addf("provider is set to %q", ProviderLocal).WithSeverity(E.SeverityWarning)
return
}

29
src/autocert/setup.go Normal file
View File

@@ -0,0 +1,29 @@
package autocert
import (
"context"
"os"
E "github.com/yusing/go-proxy/error"
)
func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
if err = p.LoadCert(); err != nil {
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
return err
}
logger.Debug("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err.Warn()
}
}
go p.ScheduleRenewal(ctx)
for _, expiry := range p.GetExpiries() {
logger.Infof("certificate expire on %s", expiry)
break
}
return nil
}

View File

@@ -12,12 +12,13 @@ type Args struct {
}
const (
CommandStart = ""
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandStart = ""
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandDebugListProviders = "debug-ls-providers"
)
var ValidCommands = []string{
@@ -27,6 +28,7 @@ var ValidCommands = []string{
CommandListRoutes,
CommandReload,
CommandDebugListEntries,
CommandDebugListProviders,
}
func GetArgs() Args {

View File

@@ -30,19 +30,13 @@ const (
)
const (
SchemaBasePath = "schema/"
ConfigSchemaPath = SchemaBasePath + "config.schema.json"
ProvidersSchemaPath = SchemaBasePath + "providers.schema.json"
SchemaBasePath = "schema/"
ConfigSchemaPath = SchemaBasePath + "config.schema.json"
FileProviderSchemaPath = SchemaBasePath + "providers.schema.json"
)
const DockerHostFromEnv = "$DOCKER_HOST"
const (
ProxyHTTPPort = ":80"
ProxyHTTPSPort = ":443"
APIHTTPPort = ":8888"
)
var WellKnownHTTPPorts = map[uint16]bool{
80: true,
8000: true,

View File

@@ -6,9 +6,22 @@ import (
U "github.com/yusing/go-proxy/utils"
)
var NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
var IsDebug = getEnvBool("GOPROXY_DEBUG")
var (
NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
IsDebug = getEnvBool("GOPROXY_DEBUG")
ProxyHTTPAddr = getEnv("GOPROXY_HTTP_ADDR", ":80")
ProxyHTTPSAddr = getEnv("GOPROXY_HTTPS_ADDR", ":443")
APIHTTPAddr = getEnv("GOPROXY_API_ADDR", "127.0.0.1:8888")
)
func getEnvBool(key string) bool {
return U.ParseBool(os.Getenv(key))
}
func getEnv(key string, defaultValue string) string {
value, ok := os.LookupEnv(key)
if !ok {
value = defaultValue
}
return value
}

View File

@@ -14,6 +14,7 @@ import (
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
W "github.com/yusing/go-proxy/watcher"
"github.com/yusing/go-proxy/watcher/events"
"gopkg.in/yaml.v3"
)
@@ -94,7 +95,7 @@ func (cfg *Config) WatchChanges() {
case <-cfg.watcherCtx.Done():
return
case event := <-eventCh:
if event.Action.IsDelete() {
if event.Action == events.ActionFileDeleted {
cfg.stopProviders()
} else {
cfg.reloadReq <- struct{}{}
@@ -107,71 +108,6 @@ func (cfg *Config) WatchChanges() {
}()
}
func (cfg *Config) FindRoute(alias string) R.Route {
return F.MapFind(cfg.proxyProviders,
func(p *PR.Provider) (R.Route, bool) {
if route, ok := p.GetRoute(alias); ok {
return route, true
}
return nil, false
},
)
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
obj, err := U.Serialize(r)
if err.HasError() {
cfg.l.Error(err)
return
}
obj["provider"] = p.GetName()
obj["type"] = string(r.Type())
routes[alias] = obj
})
return routes
}
func (cfg *Config) Statistics() map[string]any {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]any)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
s, ok := providerStats[p.GetName()]
if !ok {
s = make(map[string]int)
}
stats := s.(map[string]int)
switch r.Type() {
case R.RouteTypeStream:
stats["num_streams"]++
nTotalStreams++
case R.RouteTypeReverseProxy:
stats["num_reverse_proxies"]++
nTotalRPs++
default:
panic("bug: should not reach here")
}
})
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
}
}
func (cfg *Config) DumpEntries() map[string]*M.ProxyEntry {
entries := make(map[string]*M.ProxyEntry)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
entries[alias] = r.Entry()
})
return entries
}
func (cfg *Config) forEachRoute(do func(alias string, r R.Route, p *PR.Provider)) {
cfg.proxyProviders.RangeAll(func(_ string, p *PR.Provider) {
p.RangeRoutes(func(a string, r R.Route) {
@@ -238,20 +174,28 @@ func (cfg *Config) loadProviders(providers *M.ProxyProviders) (res E.NestedError
defer b.To(&res)
for _, filename := range providers.Files {
p := PR.NewFileProvider(filename)
p, err := PR.NewFileProvider(filename)
if err != nil {
b.Add(err.Subject(filename))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes())
b.Add(p.LoadRoutes().Subject(filename))
}
for name, dockerHost := range providers.Docker {
p := PR.NewDockerProvider(name, dockerHost)
p, err := PR.NewDockerProvider(name, dockerHost)
if err != nil {
b.Add(err.Subjectf("%s (%s)", name, dockerHost))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes())
b.Add(p.LoadRoutes().Subject(dockerHost))
}
return
}
func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) {
errors := E.NewBuilder("cannot %s these providers", action)
errors := E.NewBuilder("errors in %s these providers", action)
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
if err := do(p); err.HasError() {

82
src/config/query.go Normal file
View File

@@ -0,0 +1,82 @@
package config
import (
M "github.com/yusing/go-proxy/models"
PR "github.com/yusing/go-proxy/proxy/provider"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
)
func (cfg *Config) DumpEntries() map[string]*M.RawEntry {
entries := make(map[string]*M.RawEntry)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
entries[alias] = r.Entry()
})
return entries
}
func (cfg *Config) DumpProviders() map[string]*PR.Provider {
entries := make(map[string]*PR.Provider)
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
entries[name] = p
})
return entries
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
obj, err := U.Serialize(r)
if err.HasError() {
cfg.l.Error(err)
return
}
obj["provider"] = p.GetName()
obj["type"] = string(r.Type())
routes[alias] = obj
})
return routes
}
func (cfg *Config) Statistics() map[string]any {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]any)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
s, ok := providerStats[p.GetName()]
if !ok {
s = make(map[string]int)
}
stats := s.(map[string]int)
switch r.Type() {
case R.RouteTypeStream:
stats["num_streams"]++
nTotalStreams++
case R.RouteTypeReverseProxy:
stats["num_reverse_proxies"]++
nTotalRPs++
default:
panic("bug: should not reach here")
}
})
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
}
}
func (cfg *Config) FindRoute(alias string) R.Route {
return F.MapFind(cfg.proxyProviders,
func(p *PR.Provider) (R.Route, bool) {
if route, ok := p.GetRoute(alias); ok {
return route, true
}
return nil, false
},
)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
)
type Client struct {
@@ -20,9 +21,22 @@ type Client struct {
l logrus.FieldLogger
}
func ParseDockerHostname(host string) (string, E.NestedError) {
switch host {
case common.DockerHostFromEnv, "":
return "localhost", nil
}
url, err := E.Check(client.ParseHostURL(host))
if err != nil {
return "", E.Invalid("host", host).With(err)
}
return url.Hostname(), nil
}
func (c Client) DaemonHostname() string {
url, _ := client.ParseHostURL(c.DaemonHost())
return url.Hostname()
// DaemonHost should always return a valid host
hostname, _ := ParseDockerHostname(c.DaemonHost())
return hostname
}
func (c Client) Connected() bool {
@@ -35,9 +49,7 @@ func (c *Client) Close() error {
return nil
}
clientMapMu.Lock()
defer clientMapMu.Unlock()
delete(clientMap, c.key)
clientMap.Delete(c.key)
client := c.Client
c.Client = nil
@@ -65,7 +77,7 @@ func ConnectClient(host string) (Client, E.NestedError) {
defer clientMapMu.Unlock()
// check if client exists
if client, ok := clientMap[host]; ok {
if client, ok := clientMap.Load(host); ok {
client.refCount.Add(1)
return client, nil
}
@@ -116,23 +128,22 @@ func ConnectClient(host string) (Client, E.NestedError) {
c.refCount.Add(1)
c.l.Debugf("client connected")
clientMap[host] = c
return clientMap[host], nil
clientMap.Store(host, c)
return c, nil
}
func CloseAllClients() {
clientMapMu.Lock()
defer clientMapMu.Unlock()
for _, client := range clientMap {
client.Close()
}
clientMap = make(map[string]Client)
clientMap.RangeAll(func(_ string, c Client) {
c.Client.Close()
})
clientMap.Clear()
logger.Debug("closed all clients")
}
var (
clientMap map[string]Client = make(map[string]Client)
clientMapMu sync.Mutex
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
clientMapMu sync.Mutex
clientOptEnvHost = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #fff;
background-color: #212121;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
/* Spinner Styles */
.spinner {
width: 120px;
height: 120px;
border: 16px solid #333;
border-radius: 50%;
border-top: 16px solid #66d9ef;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center;
}
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
}
/* Message Styles */
.message {
font-size: 24px;
font-weight: bold;
padding-left: 32px;
text-align: center;
}
</style>
</head>
<body>
<script>
window.onload = async function () {
let result = await fetch(window.location.href, {
headers: {
{{ range $key, $value := .RequestHeaders }}
'{{ $key }}' : {{ $value }}
{{ end }}
},
}).then((resp) => resp.text())
.catch((err) => {
document.getElementById("message").innerText = err;
});
if (result) {
document.documentElement.innerHTML = result
}
};
</script>
<div class="{{.SpinnerClass}}"></div>
<div class="message">{{.Message}}</div>
</body>
</html>

View File

@@ -0,0 +1,93 @@
package idlewatcher
import (
"bytes"
_ "embed"
"fmt"
"io"
"net/http"
"strings"
"text/template"
)
type templateData struct {
Title string
Message string
RequestHeaders http.Header
SpinnerClass string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = func() *template.Template {
tmpl, err := template.New("loading").Parse(string(loadingPage))
if err != nil {
panic(err)
}
return tmpl
}()
const (
htmlContentType = "text/html; charset=utf-8"
errPrefix = "\u1000"
headerGoProxyTargetURL = "X-GoProxy-Target"
headerContentType = "Content-Type"
spinnerClassSpinner = "spinner"
spinnerClassErrorSign = "error"
)
func (w *watcher) makeSuccResp(redirectURL string, resp *http.Response) (*http.Response, error) {
h := make(http.Header)
h.Set("Location", redirectURL)
h.Set("Content-Length", "0")
h.Set(headerContentType, htmlContentType)
return &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Header: h,
Body: http.NoBody,
TLS: resp.TLS,
}, nil
}
func (w *watcher) makeErrResp(errFmt string, args ...any) (*http.Response, error) {
return w.makeResp(errPrefix+errFmt, args...)
}
func (w *watcher) makeResp(format string, args ...any) (*http.Response, error) {
msg := fmt.Sprintf(format, args...)
data := new(templateData)
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
data.Message = strings.ReplaceAll(data.Message, " ", "&ensp;")
data.RequestHeaders = make(http.Header)
data.RequestHeaders.Add(headerGoProxyTargetURL, "window.location.href")
if strings.HasPrefix(data.Message, errPrefix) {
data.Message = strings.TrimLeft(data.Message, errPrefix)
data.SpinnerClass = spinnerClassErrorSign
} else {
data.SpinnerClass = spinnerClassSpinner
}
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen
panic(err)
}
return &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
headerContentType: {htmlContentType},
"Cache-Control": {
"no-cache",
"no-store",
"must-revalidate",
},
},
Body: io.NopCloser(buf),
ContentLength: int64(buf.Len()),
}, nil
}

View File

@@ -1,6 +1,10 @@
package idlewatcher
import "net/http"
import (
"context"
"net/http"
"time"
)
type (
roundTripper struct {
@@ -12,3 +16,63 @@ type (
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt.patched(req)
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
// target site is ready, passthrough
if w.ready.Load() {
return origRoundTrip(req)
}
// wake the container
w.wakeCh <- struct{}{}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
return w.makeResp(
"%s is starting... Please wait",
w.ContainerName,
)
}
w.l.Debug("serving event")
// stream request
rtDone := make(chan *http.Response, 1)
ctx, cancel := context.WithTimeout(req.Context(), w.WakeTimeout)
defer cancel()
// loop original round trip until success in a goroutine
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.ctx.Done():
return
default:
resp, err := origRoundTrip(req)
if err == nil {
w.ready.Store(true)
rtDone <- resp
return
}
time.Sleep(time.Millisecond * 200)
}
}
}()
for {
select {
case resp := <-rtDone:
return w.makeSuccResp(targetUrl, resp)
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)
}
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
case <-w.ctx.Done():
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
}
}
}

View File

@@ -1,9 +1,7 @@
package idlewatcher
import (
"bytes"
"context"
"io"
"net/http"
"sync"
"sync/atomic"
@@ -16,33 +14,45 @@ import (
P "github.com/yusing/go-proxy/proxy"
PT "github.com/yusing/go-proxy/proxy/fields"
W "github.com/yusing/go-proxy/watcher"
event "github.com/yusing/go-proxy/watcher/events"
)
type watcher struct {
*P.ReverseProxyEntry
client D.Client
refCount atomic.Int32
stopByMethod StopCallback
wakeCh chan struct{}
wakeDone chan E.NestedError
running atomic.Bool
ctx context.Context
cancel context.CancelFunc
l logrus.FieldLogger
}
type (
watcher struct {
*P.ReverseProxyEntry
client D.Client
ready atomic.Bool // whether the site is ready to accept connection
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
wakeCh chan struct{}
wakeDone chan E.NestedError
ctx context.Context
cancel context.CancelFunc
refCount *sync.WaitGroup
l logrus.FieldLogger
}
WakeDone <-chan error
WakeFunc func() WakeDone
StopCallback func() E.NestedError
)
var (
mainLoopCtx context.Context
mainLoopCancel context.CancelFunc
mainLoopWg sync.WaitGroup
watcherMap = make(map[string]*watcher)
watcherMapMu sync.Mutex
newWatcherCh = make(chan *watcher)
logger = logrus.WithField("module", "idle_watcher")
)
func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
failure := E.Failure("idle_watcher register")
@@ -67,12 +77,12 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
w := &watcher{
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}, 1),
wakeDone: make(chan E.NestedError, 1),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)
w.running.Store(entry.ContainerRunning)
w.stopByMethod = w.getStopCallback()
watcherMap[w.ContainerName] = w
@@ -84,20 +94,9 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
return w, nil
}
// If the container is not registered, this is no-op
func Unregister(containerName string) {
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
if w, ok := watcherMap[containerName]; ok {
if w.refCount.Add(-1) > 0 {
return
}
if w.cancel != nil {
w.cancel()
}
w.client.Close()
delete(watcherMap, containerName)
w.refCount.Add(-1)
}
}
@@ -107,8 +106,6 @@ func Start() {
mainLoopCtx, mainLoopCancel = context.WithCancel(context.Background())
defer mainLoopWg.Wait()
for {
select {
case <-mainLoopCtx.Done():
@@ -117,8 +114,11 @@ func Start() {
w.l.Debug("registered")
mainLoopWg.Add(1)
go func() {
w.watch()
Unregister(w.ContainerName)
w.watchUntilCancel()
w.refCount.Wait() // wait for 0 ref count
w.client.Close()
delete(watcherMap, w.ContainerName)
w.l.Debug("unregistered")
mainLoopWg.Done()
}()
@@ -137,31 +137,6 @@ func (w *watcher) PatchRoundTripper(rtp http.RoundTripper) roundTripper {
}}
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
w.wakeCh <- struct{}{}
if w.running.Load() {
return origRoundTrip(req)
}
timeout := time.After(w.WakeTimeout)
for {
if w.running.Load() {
return origRoundTrip(req)
}
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case err := <-w.wakeDone:
if err != nil {
return nil, err.Error()
}
case <-timeout:
return getLoadingResponse(), nil
}
}
}
func (w *watcher) containerStop() error {
return w.client.ContainerStop(w.ctx, w.ContainerName, container.StopOptions{
Signal: string(w.StopSignal),
@@ -205,7 +180,6 @@ func (w *watcher) wakeIfStopped() E.NestedError {
case "paused":
return E.From(w.containerUnpause())
case "running":
w.running.Store(true)
return nil
default:
return E.Unexpected("container state", status)
@@ -236,15 +210,12 @@ func (w *watcher) getStopCallback() StopCallback {
}
}
func (w *watcher) watch() {
watcherCtx, watcherCancel := context.WithCancel(context.Background())
w.ctx = watcherCtx
w.cancel = watcherCancel
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
func (w *watcher) watchUntilCancel() {
defer close(w.wakeCh)
w.ctx, w.cancel = context.WithCancel(context.Background())
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
Filters: W.NewDockerFilter(
W.DockerFilterContainer,
@@ -265,7 +236,7 @@ func (w *watcher) watch() {
select {
case <-mainLoopCtx.Done():
w.cancel()
case <-watcherCtx.Done():
case <-w.ctx.Done():
w.l.Debug("stopped")
return
case err := <-dockerEventErrCh:
@@ -273,16 +244,18 @@ func (w *watcher) watch() {
w.l.Error(E.FailWith("docker watcher", err))
}
case e := <-dockerEventCh:
switch e.Action {
case event.ActionDockerStartUnpause:
w.running.Store(true)
w.l.Infof("%s %s", e.ActorName, e.Action)
case event.ActionDockerStopPause:
w.running.Store(false)
w.l.Infof("%s %s", e.ActorName, e.Action)
switch {
// create / start / unpause
case e.Action.IsContainerWake():
ticker.Reset(w.IdleTimeout)
w.l.Info(e)
default: // stop / pause / kill
ticker.Stop()
w.ready.Store(false)
w.l.Info(e)
}
case <-ticker.C:
w.l.Debug("timeout")
w.l.Debug("idle timeout")
ticker.Stop()
if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod))
@@ -301,57 +274,3 @@ func (w *watcher) watch() {
}
}
}
func getLoadingResponse() *http.Response {
return &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
"Content-Type": {"text/html"},
"Cache-Control": {
"no-cache",
"no-store",
"must-revalidate",
},
},
Body: io.NopCloser(bytes.NewReader((loadingPage))),
ContentLength: int64(len(loadingPage)),
}
}
var (
mainLoopCtx context.Context
mainLoopCancel context.CancelFunc
mainLoopWg sync.WaitGroup
watcherMap = make(map[string]*watcher)
watcherMapMu sync.Mutex
newWatcherCh = make(chan *watcher)
logger = logrus.WithField("module", "idle_watcher")
loadingPage = []byte(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading...</title>
</head>
<body>
<script>
window.onload = function() {
setTimeout(function() {
window.location.reload()
}, 1000)
// fetch(window.location.href)
// .then(resp => resp.text())
// .then(data => { document.body.innerHTML = data; })
// .catch(err => { document.body.innerHTML = 'Error: ' + err; });
};
</script>
<h1>Container is starting... Please wait</h1>
</body>
</html>
`[1:])
)

View File

@@ -25,6 +25,7 @@ func NewBuilder(format string, args ...any) Builder {
func (b Builder) Add(err NestedError) Builder {
if err != nil {
b.Lock()
// TODO: if err severity is higher than b.severity, update b.severity
b.errors = append(b.errors, err)
b.Unlock()
}

View File

@@ -18,8 +18,8 @@ type (
)
const (
SeverityFatal Severity = iota
SeverityWarning
SeverityWarning Severity = iota
SeverityFatal
)
func From(err error) NestedError {

View File

@@ -20,7 +20,7 @@ func Failure(what string) NestedError {
}
func FailedWhy(what string, why string) NestedError {
return errorf("%s %w because %s", what, ErrFailure, why)
return Failure(what).With(why)
}
func FailWith(what string, err any) NestedError {

View File

@@ -3,11 +3,14 @@ package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"os/signal"
"reflect"
"runtime"
"strings"
"sync"
"syscall"
"time"
@@ -23,36 +26,36 @@ import (
R "github.com/yusing/go-proxy/route"
"github.com/yusing/go-proxy/server"
F "github.com/yusing/go-proxy/utils/functional"
W "github.com/yusing/go-proxy/watcher"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
args := common.GetArgs()
l := logrus.WithField("module", "main")
onShutdown := F.NewSlice[func()]()
if common.IsDebug {
logrus.SetLevel(logrus.DebugLevel)
}
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
DisableLevelTruncation: true,
FullTimestamp: true,
ForceColors: true,
TimestampFormat: "01-02 15:04:05",
})
if args.Command != common.CommandStart {
logrus.SetOutput(io.Discard)
} else {
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
DisableLevelTruncation: true,
FullTimestamp: true,
TimestampFormat: "01-02 15:04:05",
})
}
if args.Command == common.CommandReload {
if err := apiUtils.ReloadServer(); err.HasError() {
l.Fatal(err)
log.Fatal(err)
}
log.Print("ok")
return
}
onShutdown := F.NewSlice[func()]()
// exit if only validate config
if args.Command == common.CommandValidate {
data, err := os.ReadFile(common.ConfigPath)
@@ -60,39 +63,38 @@ func main() {
err = config.Validate(data).Error()
}
if err != nil {
l.Fatal("config error: ", err)
log.Fatal("config error: ", err)
}
l.Printf("config OK")
log.Print("config OK")
return
}
cfg, err := config.Load()
if err.IsFatal() {
l.Fatal(err)
log.Fatal(err)
}
if args.Command == common.CommandListConfigs {
switch args.Command {
case common.CommandListConfigs:
printJSON(cfg.Value())
return
case common.CommandListRoutes:
printJSON(cfg.RoutesByAlias())
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpProviders())
return
}
cfg.StartProxyProviders()
if args.Command == common.CommandListRoutes {
printJSON(cfg.RoutesByAlias())
return
}
if args.Command == common.CommandDebugListEntries {
printJSON(cfg.DumpEntries())
return
}
if err.HasError() {
l.Warn(err)
}
W.InitFileWatcherHelper()
cfg.WatchChanges()
onShutdown.Add(docker.CloseAllClients)
@@ -106,25 +108,14 @@ func main() {
autocert := cfg.GetAutoCertProvider()
if autocert != nil {
if err = autocert.LoadCert(); err.HasError() {
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
l.Error(err)
}
l.Debug("obtaining cert due to error loading cert")
if err = autocert.ObtainCert(); err.HasError() {
l.Warn(err)
}
}
if err.NoError() {
ctx, certRenewalCancel := context.WithCancel(context.Background())
go autocert.ScheduleRenewal(ctx)
onShutdown.Add(certRenewalCancel)
}
for _, expiry := range autocert.GetExpiries() {
l.Infof("certificate expire on %s", expiry)
break
ctx, cancel := context.WithCancel(context.Background())
if err = autocert.Setup(ctx); err != nil && err.IsWarning() {
cancel()
l.Warn(err)
} else if err.IsFatal() {
l.Fatal(err)
} else {
onShutdown.Add(cancel)
}
} else {
l.Info("autocert not configured")
@@ -133,15 +124,15 @@ func main() {
proxyServer := server.InitProxyServer(server.Options{
Name: "proxy",
CertProvider: autocert,
HTTPPort: common.ProxyHTTPPort,
HTTPSPort: common.ProxyHTTPSPort,
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: http.HandlerFunc(R.ProxyHandler),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
apiServer := server.InitAPIServer(server.Options{
Name: "api",
CertProvider: autocert,
HTTPPort: common.APIHTTPPort,
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(cfg),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
@@ -165,7 +156,9 @@ func main() {
wg.Add(onShutdown.Size())
onShutdown.ForEach(func(f func()) {
go func() {
l.Debugf("waiting for %s to complete...", funcName(f))
f()
l.Debugf("%s done", funcName(f))
wg.Done()
}()
})
@@ -174,14 +167,23 @@ func main() {
close(done)
}()
timeout := time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second)
select {
case <-done:
logrus.Info("shutdown complete")
case <-time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second):
case <-timeout:
logrus.Info("timeout waiting for shutdown")
onShutdown.ForEach(func(f func()) {
l.Warnf("%s() is still running", funcName(f))
})
}
}
func funcName(f func()) string {
parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), "/go-proxy/")
return parts[len(parts)-1]
}
func printJSON(obj any) {
j, err := E.Check(json.Marshal(obj))
if err.HasError() {

View File

@@ -10,7 +10,9 @@ import (
)
type (
ProxyEntry struct { // raw entry object before validation
RawEntry struct {
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
@@ -24,12 +26,16 @@ type (
*D.ProxyProperties `yaml:"-" json:"proxy_properties"`
}
ProxyEntries = F.Map[string, *ProxyEntry]
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *ProxyEntry]
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) SetDefaults() {
if e.ProxyProperties == nil {
e.ProxyProperties = &D.ProxyProperties{}
}
func (e *ProxyEntry) SetDefaults() {
if e.Scheme == "" {
switch {
case strings.ContainsRune(e.Port, ':'):

View File

@@ -43,7 +43,7 @@ func (rp *ReverseProxyEntry) UseIdleWatcher() bool {
return rp.IdleTimeout > 0 && rp.DockerHost != ""
}
func ValidateEntry(m *M.ProxyEntry) (any, E.NestedError) {
func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
m.SetDefaults()
scheme, err := T.NewScheme(m.Scheme)
if err.HasError() {
@@ -63,7 +63,7 @@ func ValidateEntry(m *M.ProxyEntry) (any, E.NestedError) {
return entry, nil
}
func validateRPEntry(m *M.ProxyEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
var stopTimeOut time.Duration
host, err := T.ValidateHost(m.Host)
@@ -121,7 +121,7 @@ func validateRPEntry(m *M.ProxyEntry, s T.Scheme, b E.Builder) *ReverseProxyEntr
}
}
func validateStreamEntry(m *M.ProxyEntry, b E.Builder) *StreamEntry {
func validateStreamEntry(m *M.RawEntry, b E.Builder) *StreamEntry {
host, err := T.ValidateHost(m.Host)
b.Add(err)

View File

@@ -1,6 +1,7 @@
package fields
import (
"fmt"
"strings"
"github.com/yusing/go-proxy/common"
@@ -15,7 +16,7 @@ type StreamPort struct {
func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
split := strings.Split(p, ":")
if len(split) != 2 {
return StreamPort{}, E.Invalid("stream port", p).With("should be in 'x:y' format")
return StreamPort{}, E.Invalid("stream port", fmt.Sprintf("%q", p)).With("should be in 'x:y' format")
}
listeningPort, err := ValidatePort(split[0])

View File

@@ -32,7 +32,7 @@ func ValidateStreamScheme(s string) (ss *StreamScheme, err E.NestedError) {
}
func (s StreamScheme) String() string {
return fmt.Sprintf("%s -> %s", s.ListeningScheme, s.ProxyScheme)
return fmt.Sprintf("%s:%s", s.ListeningScheme, s.ProxyScheme)
}
// IsCoherent checks if the ListeningScheme and ProxyScheme of the StreamScheme are equal.

View File

@@ -1,6 +1,7 @@
package provider
import (
"fmt"
"regexp"
"strconv"
"strings"
@@ -18,8 +19,16 @@ type DockerProvider struct {
var AliasRefRegex = regexp.MustCompile(`\$\d+`)
func DockerProviderImpl(dockerHost string) ProviderImpl {
return &DockerProvider{dockerHost: dockerHost}
func DockerProviderImpl(dockerHost string) (ProviderImpl, E.NestedError) {
hostname, err := D.ParseDockerHostname(dockerHost)
if err.HasError() {
return nil, err
}
return &DockerProvider{dockerHost: dockerHost, hostname: hostname}, nil
}
func (p *DockerProvider) String() string {
return fmt.Sprintf("docker:%s", p.dockerHost)
}
func (p *DockerProvider) NewWatcher() W.Watcher {
@@ -27,6 +36,7 @@ func (p *DockerProvider) NewWatcher() W.Watcher {
}
func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
routes = R.NewRoutes()
entries := M.NewProxyEntries()
info, err := D.GetClientInfo(p.dockerHost, true)
@@ -50,12 +60,12 @@ func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
// there may be some valid entries in `en`
dups := entries.MergeFrom(newEntries)
// add the duplicate proxy entries to the error
dups.RangeAll(func(k string, v *M.ProxyEntry) {
dups.RangeAll(func(k string, v *M.RawEntry) {
errors.Addf("duplicate alias %s", k)
})
}
entries.RangeAll(func(_ string, e *M.ProxyEntry) {
entries.RangeAll(func(_ string, e *M.RawEntry) {
e.DockerHost = p.dockerHost
})
@@ -91,7 +101,7 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
entries, err := p.entriesFromContainerLabels(cont)
b.Add(err)
entries.RangeAll(func(alias string, entry *M.ProxyEntry) {
entries.RangeAll(func(alias string, entry *M.RawEntry) {
if routes.Has(alias) {
b.Add(E.AlreadyExist("alias", alias))
} else {
@@ -110,12 +120,12 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
// Returns a list of proxy entries for a container.
// Always non-nil
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.ProxyEntries, E.NestedError) {
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.RawEntries, E.NestedError) {
entries := M.NewProxyEntries()
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &M.ProxyEntry{
entries.Store(a, &M.RawEntry{
Alias: a,
Host: p.hostname,
ProxyProperties: container.ProxyProperties,
@@ -140,7 +150,7 @@ func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.Pr
entryPortSplit := strings.Split(entry.Port, ":")
if len(entryPortSplit) == 2 && entryPortSplit[1] == containerPort {
entryPortSplit[1] = publicPort
} else if entryPortSplit[0] == containerPort {
} else if len(entryPortSplit) == 1 && entryPortSplit[0] == containerPort {
entryPortSplit[0] = publicPort
}
entry.Port = strings.Join(entryPortSplit, ":")
@@ -151,7 +161,7 @@ func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.Pr
return entries, errors.Build().Subject(container.ContainerName)
}
func (p *DockerProvider) applyLabel(container D.Container, entries M.ProxyEntries, key, val string) (res E.NestedError) {
func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries, key, val string) (res E.NestedError) {
b := E.NewBuilder("errors in label %s", key)
defer b.To(&res)
@@ -164,7 +174,7 @@ func (p *DockerProvider) applyLabel(container D.Container, entries M.ProxyEntrie
}
if lbl.Target == D.WildcardAlias {
// apply label for all aliases
entries.RangeAll(func(a string, e *M.ProxyEntry) {
entries.RangeAll(func(a string, e *M.RawEntry) {
if err = D.ApplyLabel(e, lbl); err.HasError() {
b.Add(err.Subject(lbl.Target))
}

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/common"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
@@ -51,6 +52,12 @@ X_Custom_Header2: value3
Names: dummyNames,
Labels: map[string]string{
D.LableAliases: "a,b",
D.LabelIdleTimeout: common.IdleTimeoutDefault,
D.LabelStopMethod: common.StopMethodDefault,
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
"proxy.*.port": "4567",
@@ -73,8 +80,8 @@ X_Custom_Header2: value3
ExpectEqual(t, a.Port, "4567")
ExpectEqual(t, b.Port, "4567")
ExpectEqual(t, a.NoTLSVerify, true)
ExpectEqual(t, b.NoTLSVerify, false)
ExpectTrue(t, a.NoTLSVerify)
ExpectTrue(t, b.NoTLSVerify)
ExpectDeepEqual(t, a.PathPatterns, pathPatternsExpect)
ExpectEqual(t, len(b.PathPatterns), 0)
@@ -84,6 +91,21 @@ X_Custom_Header2: value3
ExpectDeepEqual(t, a.HideHeaders, hideHeadersExpect)
ExpectEqual(t, len(b.HideHeaders), 0)
ExpectEqual(t, a.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, b.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, a.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, b.StopTimeout, common.StopTimeoutDefault)
ExpectEqual(t, a.StopMethod, common.StopMethodDefault)
ExpectEqual(t, b.StopMethod, common.StopMethodDefault)
ExpectEqual(t, a.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, b.WakeTimeout, common.WakeTimeoutDefault)
ExpectEqual(t, a.StopSignal, "SIGTERM")
ExpectEqual(t, b.StopSignal, "SIGTERM")
}
func TestApplyLabel(t *testing.T) {

View File

@@ -1,6 +1,7 @@
package provider
import (
"errors"
"os"
"path"
@@ -17,15 +18,28 @@ type FileProvider struct {
path string
}
func FileProviderImpl(filename string) ProviderImpl {
return &FileProvider{
func FileProviderImpl(filename string) (ProviderImpl, E.NestedError) {
impl := &FileProvider{
fileName: filename,
path: path.Join(common.ConfigBasePath, filename),
}
_, err := os.Stat(impl.path)
switch {
case err == nil:
return impl, nil
case errors.Is(err, os.ErrNotExist):
return nil, E.NotExist("file", impl.path)
default:
return nil, E.UnexpectedError(err)
}
}
func Validate(data []byte) E.NestedError {
return U.ValidateYaml(U.GetSchema(common.ProvidersSchemaPath), data)
return U.ValidateYaml(U.GetSchema(common.FileProviderSchemaPath), data)
}
func (p FileProvider) String() string {
return p.fileName
}
func (p FileProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult) {
@@ -52,6 +66,8 @@ func (p FileProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult)
}
func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
routes = R.NewRoutes()
b := E.NewBuilder("file %q validation failure", p.fileName)
defer b.To(&res)

View File

@@ -2,7 +2,6 @@ package provider
import (
"context"
"fmt"
"path"
"github.com/sirupsen/logrus"
@@ -13,7 +12,7 @@ import (
type (
Provider struct {
ProviderImpl
ProviderImpl `json:"-"`
name string
t ProviderType
@@ -27,8 +26,10 @@ type (
}
ProviderImpl interface {
NewWatcher() W.Watcher
// even returns error, routes must be non-nil
LoadRoutesImpl() (R.Routes, E.NestedError)
OnEvent(event W.Event, routes R.Routes) EventResult
String() string
}
ProviderType string
EventResult struct {
@@ -53,19 +54,25 @@ func newProvider(name string, t ProviderType) *Provider {
return p
}
func NewFileProvider(filename string) *Provider {
func NewFileProvider(filename string) (p *Provider, err E.NestedError) {
name := path.Base(filename)
p := newProvider(name, ProviderTypeFile)
p.ProviderImpl = FileProviderImpl(filename)
p = newProvider(name, ProviderTypeFile)
p.ProviderImpl, err = FileProviderImpl(filename)
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return p
return
}
func NewDockerProvider(name string, dockerHost string) *Provider {
p := newProvider(name, ProviderTypeDocker)
p.ProviderImpl = DockerProviderImpl(dockerHost)
func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.NestedError) {
p = newProvider(name, ProviderTypeDocker)
p.ProviderImpl, err = DockerProviderImpl(dockerHost)
if err != nil {
return nil, err
}
p.watcher = p.NewWatcher()
return p
return
}
func (p *Provider) GetName() string {
@@ -76,8 +83,9 @@ func (p *Provider) GetType() ProviderType {
return p.t
}
func (p *Provider) String() string {
return fmt.Sprintf("%s-%s", p.t, p.name)
// to work with json marshaller
func (p *Provider) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *Provider) StartAllRoutes() (res E.NestedError) {
@@ -85,7 +93,6 @@ func (p *Provider) StartAllRoutes() (res E.NestedError) {
defer errors.To(&res)
// start watcher no matter load success or not
p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background())
go p.watchEvents()
nStarted := 0
@@ -136,15 +143,17 @@ func (p *Provider) GetRoute(alias string) (R.Route, bool) {
}
func (p *Provider) LoadRoutes() E.NestedError {
routes, err := p.LoadRoutesImpl()
if err != nil {
var err E.NestedError
p.routes, err = p.LoadRoutesImpl()
if p.routes.Size() > 0 {
p.l.Infof("loaded %d routes", p.routes.Size())
return err
}
p.routes = routes
return nil
return E.FailWith("loading routes", err)
}
func (p *Provider) watchEvents() {
p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background())
events, errs := p.watcher.Events(p.watcherCtx)
l := p.l.WithField("module", "watcher")
@@ -152,21 +161,15 @@ func (p *Provider) watchEvents() {
select {
case <-p.watcherCtx.Done():
return
case event, ok := <-events:
if !ok { // channel closed
return
}
case event := <-events:
res := p.OnEvent(event, p.routes)
l.Infof("%s event %q", event.Type, event)
l.Infof("%d route added, %d routes removed", res.nAdded, res.nRemoved)
if res.err.HasError() {
l.Error(res.err)
}
case err, ok := <-errs:
if !ok {
return
}
if err.Is(context.Canceled) {
case err := <-errs:
if err == nil || err.Is(context.Canceled) {
continue
}
l.Errorf("watcher error: %s", err)

View File

@@ -232,7 +232,7 @@ func NewReverseProxy(target *url.URL, transport http.RoundTripper, entry *Revers
}
return &ReverseProxy{Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
pr.SetXForwarded()
// pr.SetXForwarded()
setHeaders(pr.Out)
hideHeaders(pr.Out)
}, Transport: transport}
@@ -348,9 +348,9 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
outreq.Header.Del("Forwarded")
// outreq.Header.Del("X-Forwarded-For")
// outreq.Header.Del("X-Forwarded-Host")
// outreq.Header.Del("X-Forwarded-Proto")
outreq.Header.Del("X-Forwarded-For")
outreq.Header.Del("X-Forwarded-Host")
outreq.Header.Del("X-Forwarded-Proto")
pr := &ProxyRequest{
In: req,

View File

@@ -4,5 +4,5 @@ import (
"time"
)
const udpBufferSize = 1500
const udpBufferSize = 8192
const streamStopListenTimeout = 1 * time.Second

View File

@@ -37,19 +37,23 @@ type (
)
func NewHTTPRoute(entry *P.ReverseProxyEntry) (*HTTPRoute, E.NestedError) {
var trans http.RoundTripper
var trans *http.Transport
var regIdleWatcher func() E.NestedError
var unregIdleWatcher func()
if entry.NoTLSVerify {
trans = transportNoTLS
trans = transportNoTLS.Clone()
} else {
trans = transport
trans = transport.Clone()
}
rp := P.NewReverseProxy(entry.URL, trans, entry)
if entry.UseIdleWatcher() {
// allow time for response header up to `WakeTimeout`
if entry.WakeTimeout > trans.ResponseHeaderTimeout {
trans.ResponseHeaderTimeout = entry.WakeTimeout
}
regIdleWatcher = func() E.NestedError {
watcher, err := idlewatcher.Register(entry)
if err.HasError() {
@@ -114,6 +118,7 @@ func (r *HTTPRoute) Stop() E.NestedError {
if r.unregIdleWatcher != nil {
r.unregIdleWatcher()
r.unregIdleWatcher = nil
}
r.mux = nil
@@ -151,13 +156,13 @@ func findMux(host string) (*http.ServeMux, E.NestedError) {
}
var (
defaultDialer = net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
MaxIdleConnsPerHost: 1000,
}
transportNoTLS = func() *http.Transport {

View File

@@ -13,7 +13,7 @@ import (
type (
Route interface {
RouteImpl
Entry() *M.ProxyEntry
Entry() *M.RawEntry
Type() RouteType
URL() *url.URL
}
@@ -28,7 +28,7 @@ type (
route struct {
RouteImpl
type_ RouteType
entry *M.ProxyEntry
entry *M.RawEntry
}
)
@@ -40,7 +40,7 @@ const (
// function alias
var NewRoutes = F.NewMapOf[string, Route]
func NewRoute(en *M.ProxyEntry) (Route, E.NestedError) {
func NewRoute(en *M.RawEntry) (Route, E.NestedError) {
rt, err := P.ValidateEntry(en)
if err.HasError() {
return nil, err
@@ -61,7 +61,7 @@ func NewRoute(en *M.ProxyEntry) (Route, E.NestedError) {
return &route{RouteImpl: rt.(RouteImpl), entry: en, type_: t}, err
}
func (rt *route) Entry() *M.ProxyEntry {
func (rt *route) Entry() *M.RawEntry {
return rt.entry
}
@@ -74,11 +74,11 @@ func (rt *route) URL() *url.URL {
return url
}
func FromEntries(entries M.ProxyEntries) (Routes, E.NestedError) {
func FromEntries(entries M.RawEntries) (Routes, E.NestedError) {
b := E.NewBuilder("errors in routes")
routes := NewRoutes()
entries.RangeAll(func(alias string, entry *M.ProxyEntry) {
entries.RangeAll(func(alias string, entry *M.RawEntry) {
entry.Alias = alias
r, err := NewRoute(entry)
if err.HasError() {

View File

@@ -1,6 +1,8 @@
package route
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -15,8 +17,10 @@ type StreamRoute struct {
P.StreamEntry
StreamImpl `json:"-"`
wg sync.WaitGroup
stopCh chan struct{}
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
connCh chan any
started atomic.Bool
l logrus.FieldLogger
@@ -36,8 +40,7 @@ func NewStreamRoute(entry *P.StreamEntry) (*StreamRoute, E.NestedError) {
}
base := &StreamRoute{
StreamEntry: *entry,
wg: sync.WaitGroup{},
connCh: make(chan any),
connCh: make(chan any, 100),
}
if entry.Scheme.ListeningScheme.IsTCP() {
base.StreamImpl = NewTCPRoute(base)
@@ -54,9 +57,9 @@ func (r *StreamRoute) String() string {
func (r *StreamRoute) Start() E.NestedError {
if r.started.Load() {
return E.Invalid("state", "already started")
return nil
}
r.stopCh = make(chan struct{}, 1)
r.ctx, r.cancel = context.WithCancel(context.Background())
r.wg.Wait()
if err := r.Setup(); err != nil {
return E.FailWith("setup", err)
@@ -70,10 +73,10 @@ func (r *StreamRoute) Start() E.NestedError {
func (r *StreamRoute) Stop() E.NestedError {
if !r.started.Load() {
return E.Invalid("state", "not started")
return nil
}
l := r.l
close(r.stopCh)
r.cancel()
r.CloseListeners()
done := make(chan struct{}, 1)
@@ -82,13 +85,16 @@ func (r *StreamRoute) Stop() E.NestedError {
close(done)
}()
select {
case <-done:
l.Info("stopped listening")
case <-time.After(streamStopListenTimeout):
l.Error("timed out waiting for connections")
timeout := time.After(streamStopListenTimeout)
for {
select {
case <-done:
l.Debug("stopped listening")
return nil
case <-timeout:
return E.FailedWhy("stop", "timed out")
}
}
return nil
}
func (r *StreamRoute) grAcceptConnections() {
@@ -96,13 +102,13 @@ func (r *StreamRoute) grAcceptConnections() {
for {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
default:
conn, err := r.Accept()
if err != nil {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
default:
r.l.Error(err)
@@ -119,12 +125,12 @@ func (r *StreamRoute) grHandleConnections() {
for {
select {
case <-r.stopCh:
case <-r.ctx.Done():
return
case conn := <-r.connCh:
go func() {
err := r.Handle(conn)
if err != nil {
if err != nil && !errors.Is(err, context.Canceled) {
r.l.Error(err)
}
}()

View File

@@ -12,19 +12,20 @@ import (
const tcpDialTimeout = 5 * time.Second
type Pipes []*U.BidirectionalPipe
type (
Pipes []U.BidirectionalPipe
type TCPRoute struct {
*StreamRoute
listener net.Listener
pipe Pipes
mu sync.Mutex
}
TCPRoute struct {
*StreamRoute
listener net.Listener
pipe Pipes
mu sync.Mutex
}
)
func NewTCPRoute(base *StreamRoute) StreamImpl {
return &TCPRoute{
StreamRoute: base,
listener: nil,
pipe: make(Pipes, 0),
}
}
@@ -47,7 +48,7 @@ func (route *TCPRoute) Handle(c any) error {
defer clientConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout)
ctx, cancel := context.WithTimeout(route.ctx, tcpDialTimeout)
defer cancel()
serverAddr := fmt.Sprintf("%s:%v", route.Host, route.Port.ProxyPort)
@@ -58,17 +59,12 @@ func (route *TCPRoute) Handle(c any) error {
return err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
route.mu.Lock()
defer route.mu.Unlock()
pipe := U.NewBidirectionalPipe(pipeCtx, clientConn, serverConn)
pipe := U.NewBidirectionalPipe(route.ctx, clientConn, serverConn)
route.pipe = append(route.pipe, pipe)
route.mu.Unlock()
return pipe.Start()
}
@@ -78,9 +74,4 @@ func (route *TCPRoute) CloseListeners() {
}
route.listener.Close()
route.listener = nil
for _, pipe := range route.pipe {
if err := pipe.Stop(); err != nil {
route.l.Error(err)
}
}
}

View File

@@ -1,42 +1,42 @@
package route
import (
"context"
"fmt"
"io"
"net"
"sync"
"github.com/yusing/go-proxy/utils"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
)
type UDPRoute struct {
*StreamRoute
type (
UDPRoute struct {
*StreamRoute
connMap UDPConnMap
connMapMutex sync.Mutex
connMap UDPConnMap
listeningConn *net.UDPConn
targetAddr *net.UDPAddr
}
listeningConn *net.UDPConn
targetAddr *net.UDPAddr
}
UDPConn struct {
src *net.UDPConn
dst *net.UDPConn
U.BidirectionalPipe
}
UDPConnMap = F.Map[string, *UDPConn]
)
type UDPConn struct {
src *net.UDPConn
dst *net.UDPConn
*utils.BidirectionalPipe
}
type UDPConnMap map[string]*UDPConn
var NewUDPConnMap = F.NewMapOf[string, *UDPConn]
func NewUDPRoute(base *StreamRoute) StreamImpl {
return &UDPRoute{
StreamRoute: base,
connMap: make(UDPConnMap),
connMap: NewUDPConnMap(),
}
}
func (route *UDPRoute) Setup() error {
laddr, err := net.ResolveUDPAddr(string(route.Scheme.ListeningScheme), fmt.Sprintf(":%v", route.Port.ProxyPort))
laddr, err := net.ResolveUDPAddr(string(route.Scheme.ListeningScheme), fmt.Sprintf(":%v", route.Port.ListeningPort))
if err != nil {
return err
}
@@ -70,33 +70,24 @@ func (route *UDPRoute) Accept() (any, error) {
}
key := srcAddr.String()
conn, ok := route.connMap[key]
conn, ok := route.connMap.Load(key)
if !ok {
route.connMapMutex.Lock()
if conn, ok = route.connMap[key]; !ok {
srcConn, err := net.DialUDP("udp", nil, srcAddr)
if err != nil {
return nil, err
}
dstConn, err := net.DialUDP("udp", nil, route.targetAddr)
if err != nil {
srcConn.Close()
return nil, err
}
pipeCtx, pipeCancel := context.WithCancel(context.Background())
go func() {
<-route.stopCh
pipeCancel()
}()
conn = &UDPConn{
srcConn,
dstConn,
utils.NewBidirectionalPipe(pipeCtx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}),
}
route.connMap[key] = conn
srcConn, err := net.DialUDP("udp", nil, srcAddr)
if err != nil {
return nil, err
}
route.connMapMutex.Unlock()
dstConn, err := net.DialUDP("udp", nil, route.targetAddr)
if err != nil {
srcConn.Close()
return nil, err
}
conn = &UDPConn{
srcConn,
dstConn,
U.NewBidirectionalPipe(route.ctx, sourceRWCloser{in, dstConn}, sourceRWCloser{in, srcConn}),
}
route.connMap.Store(key, conn)
}
_, err = conn.dst.Write(buffer[:nRead])
@@ -112,15 +103,15 @@ func (route *UDPRoute) CloseListeners() {
route.listeningConn.Close()
route.listeningConn = nil
}
for _, conn := range route.connMap {
route.connMap.RangeAll(func(_ string, conn *UDPConn) {
if err := conn.src.Close(); err != nil {
route.l.Errorf("error closing src conn: %s", err)
}
if err := conn.dst.Close(); err != nil {
route.l.Error("error closing dst conn: %s", err)
}
}
route.connMap = make(UDPConnMap)
})
route.connMap.Clear()
}
type sourceRWCloser struct {

View File

@@ -22,11 +22,9 @@ type server struct {
}
type Options struct {
Name string
// port (with leading colon)
HTTPPort string
// port (with leading colon)
HTTPSPort string
Name string
HTTPAddr string
HTTPSAddr string
CertProvider *autocert.Provider
RedirectToHTTPS bool
Handler http.Handler
@@ -55,22 +53,22 @@ func NewServer(opt Options) (s *server) {
certAvailable = err == nil
}
if certAvailable && opt.RedirectToHTTPS && opt.HTTPSPort != "" {
httpHandler = redirectToTLSHandler(opt.HTTPSPort)
if certAvailable && opt.RedirectToHTTPS && opt.HTTPSAddr != "" {
httpHandler = redirectToTLSHandler(opt.HTTPSAddr)
} else {
httpHandler = opt.Handler
}
if opt.HTTPPort != "" {
if opt.HTTPAddr != "" {
httpSer = &http.Server{
Addr: opt.HTTPPort,
Addr: opt.HTTPAddr,
Handler: httpHandler,
ErrorLog: logger,
}
}
if certAvailable && opt.HTTPSPort != "" {
if certAvailable && opt.HTTPSAddr != "" {
httpsSer = &http.Server{
Addr: opt.HTTPSPort,
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: logger,
TLSConfig: &tls.Config{

View File

@@ -1,15 +0,0 @@
package utils
import (
"os"
"path"
)
func FileOK(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func FileName(p string) string {
return path.Base(p)
}

View File

@@ -3,9 +3,10 @@ package utils
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"sync/atomic"
"syscall"
E "github.com/yusing/go-proxy/error"
)
@@ -16,15 +17,19 @@ type (
Path string
}
ReadCloser struct {
ctx context.Context
r io.ReadCloser
closed atomic.Bool
ContextReader struct {
ctx context.Context
io.Reader
}
ContextWriter struct {
ctx context.Context
io.Writer
}
Pipe struct {
r ReadCloser
w io.WriteCloser
r ContextReader
w ContextWriter
ctx context.Context
cancel context.CancelFunc
}
@@ -35,48 +40,48 @@ type (
}
)
func (r *ReadCloser) Read(p []byte) (int, error) {
func (r *ContextReader) Read(p []byte) (int, error) {
select {
case <-r.ctx.Done():
return 0, r.ctx.Err()
default:
return r.r.Read(p)
return r.Reader.Read(p)
}
}
func (r *ReadCloser) Close() error {
if r.closed.Load() {
return nil
func (w *ContextWriter) Write(p []byte) (int, error) {
select {
case <-w.ctx.Done():
return 0, w.ctx.Err()
default:
return w.Writer.Write(p)
}
r.closed.Store(true)
return r.r.Close()
}
func NewPipe(ctx context.Context, r io.ReadCloser, w io.WriteCloser) *Pipe {
ctx, cancel := context.WithCancel(ctx)
_, cancel := context.WithCancel(ctx)
return &Pipe{
r: ReadCloser{ctx: ctx, r: r},
w: w,
r: ContextReader{ctx: ctx, Reader: r},
w: ContextWriter{ctx: ctx, Writer: w},
ctx: ctx,
cancel: cancel,
}
}
func (p *Pipe) Start() error {
return Copy(p.ctx, p.w, &p.r)
func (p *Pipe) Start() (err error) {
err = Copy(&p.w, &p.r)
switch {
case
// NOTE: ignoring broken pipe and connection reset by peer
errors.Is(err, syscall.EPIPE),
errors.Is(err, syscall.ECONNRESET):
return nil
}
return err
}
func (p *Pipe) Stop() error {
p.cancel()
return E.JoinE("error stopping pipe", p.r.Close(), p.w.Close()).Error()
}
func (p *Pipe) Write(b []byte) (int, error) {
return p.w.Write(b)
}
func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) *BidirectionalPipe {
return &BidirectionalPipe{
func NewBidirectionalPipe(ctx context.Context, rw1 io.ReadWriteCloser, rw2 io.ReadWriteCloser) BidirectionalPipe {
return BidirectionalPipe{
pSrcDst: NewPipe(ctx, rw1, rw2),
pDstSrc: NewPipe(ctx, rw2, rw1),
}
@@ -89,7 +94,7 @@ func NewBidirectionalPipeIntermediate(ctx context.Context, listener io.ReadClose
}
}
func (p *BidirectionalPipe) Start() error {
func (p BidirectionalPipe) Start() error {
errCh := make(chan error, 2)
go func() {
errCh <- p.pSrcDst.Start()
@@ -97,20 +102,11 @@ func (p *BidirectionalPipe) Start() error {
go func() {
errCh <- p.pDstSrc.Start()
}()
for err := range errCh {
if err != nil {
return err
}
}
return nil
return E.JoinE("bidirectional pipe error", <-errCh, <-errCh).Error()
}
func (p *BidirectionalPipe) Stop() error {
return E.JoinE("error stopping pipe", p.pSrcDst.Stop(), p.pDstSrc.Stop()).Error()
}
func Copy(ctx context.Context, dst io.WriteCloser, src io.ReadCloser) error {
_, err := io.Copy(dst, &ReadCloser{ctx: ctx, r: src})
func Copy(dst *ContextWriter, src *ContextReader) error {
_, err := io.Copy(dst, src)
return err
}

View File

@@ -4,13 +4,10 @@ import (
"github.com/santhosh-tekuri/jsonschema"
)
var schemaCompiler = func() *jsonschema.Compiler {
c := jsonschema.NewCompiler()
c.Draft = jsonschema.Draft7
return c
}()
var schemaStorage = make(map[string]*jsonschema.Schema)
var (
schemaCompiler = jsonschema.NewCompiler()
schemaStorage = make(map[string]*jsonschema.Schema)
)
func GetSchema(path string) *jsonschema.Schema {
if schema, ok := schemaStorage[path]; ok {

View File

@@ -2,6 +2,7 @@ package watcher
import (
"context"
"fmt"
"time"
docker_events "github.com/docker/docker/api/types/events"
@@ -32,6 +33,8 @@ var (
DockerFilterUnpause = filters.Arg("event", string(docker_events.ActionUnPause))
NewDockerFilter = filters.NewArgs
dockerWatcherRetryInterval = 3 * time.Second
)
func DockerrFilterContainerName(name string) filters.KeyValuePair {
@@ -39,11 +42,19 @@ func DockerrFilterContainerName(name string) filters.KeyValuePair {
}
func NewDockerWatcher(host string) DockerWatcher {
return DockerWatcher{host: host, FieldLogger: logrus.WithField("module", "docker_watcher")}
return DockerWatcher{
host: host,
FieldLogger: (logrus.
WithField("module", "docker_watcher").
WithField("host", host))}
}
func NewDockerWatcherWithClient(client D.Client) DockerWatcher {
return DockerWatcher{client: client, FieldLogger: logrus.WithField("module", "docker_watcher")}
return DockerWatcher{
client: client,
FieldLogger: (logrus.
WithField("module", "docker_watcher").
WithField("host", client.DaemonHost()))}
}
func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) {
@@ -53,30 +64,43 @@ func (w DockerWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.Neste
func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerListOptions) (<-chan Event, <-chan E.NestedError) {
eventCh := make(chan Event)
errCh := make(chan E.NestedError)
started := make(chan struct{})
eventsCtx, eventsCancel := context.WithCancel(ctx)
go func() {
defer close(eventCh)
defer close(errCh)
defer func() {
if w.client.Connected() {
w.client.Close()
}
}()
if !w.client.Connected() {
var err E.NestedError
for range 3 {
attempts := 0
for {
w.client, err = D.ConnectClient(w.host)
if err != nil {
defer w.client.Close()
if err == nil {
break
}
time.Sleep(1 * time.Second)
}
if err.HasError() {
errCh <- E.FailWith("docker connection", err)
return
attempts++
errCh <- E.FailWith(fmt.Sprintf("docker connection attempt #%d", attempts), err)
select {
case <-ctx.Done():
return
default:
time.Sleep(dockerWatcherRetryInterval)
}
}
}
cEventCh, cErrCh := w.client.Events(ctx, options)
started <- struct{}{}
w.Debugf("client connected")
cEventCh, cErrCh := w.client.Events(eventsCtx, options)
w.Debugf("watcher started")
for {
select {
@@ -108,15 +132,14 @@ func (w DockerWatcher) EventsWithOptions(ctx context.Context, options DockerList
case <-ctx.Done():
return
default:
if D.IsErrConnectionFailed(err) {
time.Sleep(100 * time.Millisecond)
cEventCh, cErrCh = w.client.Events(ctx, options)
}
eventsCancel()
time.Sleep(dockerWatcherRetryInterval)
eventsCtx, eventsCancel = context.WithCancel(ctx)
cEventCh, cErrCh = w.client.Events(ctx, options)
}
}
}
}()
<-started
return eventCh, errCh
}

View File

@@ -14,36 +14,64 @@ type (
ActorAttributes map[string]string
Action Action
}
Action string
Action uint16
EventType string
)
const (
ActionFileModified Action = "modified"
ActionFileCreated Action = "created"
ActionFileDeleted Action = "deleted"
ActionFileModified Action = (1 << iota)
ActionFileCreated
ActionFileDeleted
ActionDockerStartUnpause Action = "start"
ActionDockerStopPause Action = "stop"
ActionContainerCreate
ActionContainerStart
ActionContainerUnpause
ActionContainerKill
ActionContainerStop
ActionContainerPause
ActionContainerDie
actionContainerWakeMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause
actionContainerSleepMask = ActionContainerKill | ActionContainerStop | ActionContainerPause | ActionContainerDie
)
const (
EventTypeDocker EventType = "docker"
EventTypeFile EventType = "file"
)
var DockerEventMap = map[dockerEvents.Action]Action{
dockerEvents.ActionCreate: ActionDockerStartUnpause,
dockerEvents.ActionStart: ActionDockerStartUnpause,
dockerEvents.ActionPause: ActionDockerStartUnpause,
dockerEvents.ActionDie: ActionDockerStopPause,
dockerEvents.ActionStop: ActionDockerStopPause,
dockerEvents.ActionUnPause: ActionDockerStopPause,
dockerEvents.ActionKill: ActionDockerStopPause,
dockerEvents.ActionCreate: ActionContainerCreate,
dockerEvents.ActionStart: ActionContainerStart,
dockerEvents.ActionUnPause: ActionContainerUnpause,
dockerEvents.ActionKill: ActionContainerKill,
dockerEvents.ActionStop: ActionContainerStop,
dockerEvents.ActionPause: ActionContainerPause,
dockerEvents.ActionDie: ActionContainerDie,
}
var dockerActionNameMap = func() (m map[Action]string) {
m = make(map[Action]string, len(DockerEventMap))
for k, v := range DockerEventMap {
m[v] = string(k)
}
return
}()
func (e Event) String() string {
return fmt.Sprintf("%s %s", e.ActorName, e.Action)
}
func (a Action) IsDelete() bool {
return a == ActionFileDeleted
func (a Action) String() string {
return dockerActionNameMap[a]
}
func (a Action) IsContainerWake() bool {
return a&actionContainerWakeMask != 0
}
func (a Action) IsContainerSleep() bool {
return a&actionContainerSleepMask != 0
}

View File

@@ -20,11 +20,10 @@ func NewFileWatcher(filename string) Watcher {
}
func (f *fileWatcher) Events(ctx context.Context) (<-chan Event, <-chan E.NestedError) {
if fwHelper == nil {
fwHelper = newFileWatcherHelper(common.ConfigBasePath)
}
return fwHelper.Add(ctx, f)
}
func InitFileWatcherHelper() {
fwHelper = newFileWatcherHelper(common.ConfigBasePath)
}
var fwHelper *fileWatcherHelper