From 663a107c06a168ad8ce238c1564724574b4af4b4 Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 24 Apr 2025 15:02:31 +0800 Subject: [PATCH] merge: main branch --- Makefile | 67 ++-- agent/pkg/handler/check_health.go | 9 +- agent/pkg/handler/proxy_http.go | 5 +- cmd/main.go | 4 +- go.mod | 27 +- go.sum | 65 ++-- internal/api/v1/dockerapi/info.go | 2 +- internal/api/v1/dockerapi/logs.go | 6 +- internal/api/v1/favicon/cache.go | 8 +- internal/api/v1/favicon/favicon.go | 15 +- internal/api/v1/health.go | 6 +- internal/api/v1/list.go | 12 +- internal/api/v1/new_agent.go | 3 +- internal/docker/container.go | 104 +++--- internal/docker/container_helper.go | 2 +- internal/docker/container_test.go | 2 +- internal/docker/idlewatcher/container.go | 60 ---- internal/docker/idlewatcher/state.go | 39 --- internal/docker/idlewatcher/types/config.go | 110 ------- internal/docker/idlewatcher/waker.go | 181 ---------- internal/docker/idlewatcher/watcher.go | 279 ---------------- internal/docker/inspect.go | 28 -- internal/docker/list_containers.go | 2 +- internal/entrypoint/entrypoint.go | 13 +- internal/entrypoint/entrypoint_test.go | 32 +- internal/idlewatcher/common.go | 13 + internal/idlewatcher/debug.go | 40 +++ .../handle_http.go} | 46 +-- .../handle_stream.go} | 43 +-- internal/idlewatcher/health.go | 122 +++++++ .../idlewatcher/html/loading_page.html | 0 .../{docker => }/idlewatcher/loading_page.go | 4 +- internal/idlewatcher/provider/docker.go | 90 +++++ internal/idlewatcher/provider/proxmox.go | 129 ++++++++ internal/idlewatcher/state.go | 44 +++ internal/idlewatcher/types/config.go | 128 ++++++++ .../idlewatcher/types/config_test.go | 9 +- .../idlewatcher/types/container_status.go | 14 + internal/idlewatcher/types/provider.go | 19 ++ .../{docker => }/idlewatcher/types/waker.go | 2 +- internal/idlewatcher/watcher.go | 310 ++++++++++++++++++ internal/jsonstore/jsonstore.go | 2 +- internal/jsonstore/jsonstore_test.go | 4 +- internal/metrics/uptime/uptime.go | 9 +- .../net/gphttp/loadbalancer/loadbalancer.go | 114 +++---- internal/net/gphttp/loadbalancer/types.go | 1 - .../net/gphttp/loadbalancer/types/server.go | 7 +- internal/net/tcp.go | 18 + internal/proxmox/client.go | 68 ++++ internal/proxmox/config.go | 69 ++++ internal/proxmox/lxc.go | 236 +++++++++++++ internal/proxmox/lxc_test.go | 40 +++ internal/proxmox/node.go | 50 +++ internal/route/fileserver.go | 8 +- internal/route/provider/docker_labels_test.go | 6 +- internal/route/provider/docker_test.go | 127 +++---- internal/route/reverse_proxy.go | 82 +---- internal/route/route.go | 179 +++++++--- internal/route/route_test.go | 89 +++-- .../route/routes/{routequery => }/query.go | 51 ++- internal/route/{types => routes}/route.go | 13 +- internal/route/routes/routes.go | 77 ++--- internal/route/stream.go | 41 +-- internal/route/types/http_config.go | 2 +- internal/route/types/http_config_test.go | 17 +- internal/route/types/port.go | 2 +- internal/route/types/port_test.go | 2 +- internal/route/types/route_type.go | 2 +- internal/route/types/scheme.go | 2 +- internal/utils/pool/pool.go | 65 ++++ internal/utils/pool/pool_debug.go | 15 + internal/utils/pool/pool_prod.go | 7 + internal/utils/testing/expect.go | 71 ++++ internal/utils/testing/sonic.go | 14 + internal/utils/testing/testing.go | 9 +- internal/watcher/events/events.go | 16 +- internal/watcher/health/{monitor => }/json.go | 23 +- .../watcher/health/monitor/agent_proxied.go | 9 +- internal/watcher/health/monitor/docker.go | 8 +- internal/watcher/health/monitor/http.go | 8 +- internal/watcher/health/monitor/monitor.go | 57 ++-- internal/watcher/health/monitor/raw.go | 8 +- internal/watcher/health/status.go | 28 -- internal/watcher/health/types.go | 12 +- schemas/Makefile | 33 ++ schemas/bun.lock | 14 +- schemas/config/access_log.d.ts | 80 +++-- schemas/config/autocert.d.ts | 114 ++++--- schemas/config/config.d.ts | 85 ++--- schemas/config/entrypoint.d.ts | 58 ++-- schemas/config/homepage.d.ts | 10 +- schemas/config/notification.d.ts | 91 ++--- schemas/config/providers.d.ts | 75 +++-- schemas/docker.d.ts | 2 +- schemas/docker_routes.schema.json | 2 +- schemas/index.d.ts | 21 +- schemas/middlewares/middlewares.d.ts | 257 +++++++++------ schemas/package.json | 19 +- schemas/providers/healthcheck.d.ts | 52 +-- schemas/providers/homepage.d.ts | 26 +- schemas/providers/idlewatcher.d.ts | 48 +-- schemas/providers/loadbalance.d.ts | 46 ++- schemas/providers/routes.d.ts | 238 +++++++------- schemas/providers/routes.ts | 14 +- schemas/routes.schema.json | 2 +- schemas/tsconfig.json | 12 +- schemas/types.d.ts | 20 +- 107 files changed, 3047 insertions(+), 2034 deletions(-) delete mode 100644 internal/docker/idlewatcher/container.go delete mode 100644 internal/docker/idlewatcher/state.go delete mode 100644 internal/docker/idlewatcher/types/config.go delete mode 100644 internal/docker/idlewatcher/waker.go delete mode 100644 internal/docker/idlewatcher/watcher.go delete mode 100644 internal/docker/inspect.go create mode 100644 internal/idlewatcher/common.go create mode 100644 internal/idlewatcher/debug.go rename internal/{docker/idlewatcher/waker_http.go => idlewatcher/handle_http.go} (69%) rename internal/{docker/idlewatcher/waker_stream.go => idlewatcher/handle_stream.go} (50%) create mode 100644 internal/idlewatcher/health.go rename internal/{docker => }/idlewatcher/html/loading_page.html (100%) rename internal/{docker => }/idlewatcher/loading_page.go (90%) create mode 100644 internal/idlewatcher/provider/docker.go create mode 100644 internal/idlewatcher/provider/proxmox.go create mode 100644 internal/idlewatcher/state.go create mode 100644 internal/idlewatcher/types/config.go rename internal/{docker => }/idlewatcher/types/config_test.go (75%) create mode 100644 internal/idlewatcher/types/container_status.go create mode 100644 internal/idlewatcher/types/provider.go rename internal/{docker => }/idlewatcher/types/waker.go (91%) create mode 100644 internal/idlewatcher/watcher.go create mode 100644 internal/net/tcp.go create mode 100644 internal/proxmox/client.go create mode 100644 internal/proxmox/config.go create mode 100644 internal/proxmox/lxc.go create mode 100644 internal/proxmox/lxc_test.go create mode 100644 internal/proxmox/node.go rename internal/route/routes/{routequery => }/query.go (66%) rename internal/route/{types => routes}/route.go (77%) create mode 100644 internal/utils/pool/pool.go create mode 100644 internal/utils/pool/pool_debug.go create mode 100644 internal/utils/pool/pool_prod.go create mode 100644 internal/utils/testing/expect.go create mode 100644 internal/utils/testing/sonic.go rename internal/watcher/health/{monitor => }/json.go (72%) create mode 100644 schemas/Makefile diff --git a/Makefile b/Makefile index f0a640f..1d44c2e 100755 --- a/Makefile +++ b/Makefile @@ -27,18 +27,16 @@ endif ifeq ($(debug), 1) CGO_ENABLED = 0 GODOXY_DEBUG = 1 - BUILD_FLAGS += -gcflags=all='-N -l' -endif - -ifeq ($(pprof), 1) + BUILD_FLAGS += -gcflags=all='-N -l' -tags debug +else ifeq ($(pprof), 1) CGO_ENABLED = 1 GORACE = log_path=logs/pprof strip_path_prefix=$(shell pwd)/ halt_on_error=1 - BUILD_FLAGS = -tags pprof + BUILD_FLAGS += -tags pprof VERSION := ${VERSION}-pprof else CGO_ENABLED = 0 LDFLAGS += -s -w - BUILD_FLAGS = -pgo=auto -tags production + BUILD_FLAGS += -pgo=auto -tags production endif BUILD_FLAGS += -ldflags='$(LDFLAGS)' @@ -52,6 +50,14 @@ export GODEBUG export GORACE export BUILD_FLAGS +ifeq ($(shell id -u), 0) + SETCAP_CMD = setcap +else + SETCAP_CMD = sudo setcap +endif + +.PHONY: debug + test: GODOXY_TEST=1 go test ./internal/... @@ -61,14 +67,17 @@ get: build: mkdir -p bin go build ${BUILD_FLAGS} -o bin/${NAME} ${CMD_PATH} - if [ $(shell id -u) -eq 0 ]; \ - then setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \ - else sudo setcap CAP_NET_BIND_SERVICE=+eip bin/${NAME}; \ - fi + + # CAP_NET_BIND_SERVICE: permission for binding to :80 and :443 + $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep bin/${NAME} run: [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${CMD_PATH} +debug: + make NAME="godoxy-test" debug=1 build + sh -c 'HTTP_ADDR=:81 HTTPS_ADDR=:8443 API_ADDR=:8899 DEBUG=1 bin/godoxy-test' + mtrace: bin/godoxy debug-ls-mtrace > mtrace.json @@ -90,43 +99,5 @@ cloc: link-binary: ln -s /app/${NAME} bin/run -# To generate schema -# comment out this part from typescript-json-schema.js#L884 -# -# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) { -# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string"); -# } - -gen-schema-single: - bun --bun run typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS} - # minify - python3 -c "import json; f=open('schemas/${OUT}', 'r'); j=json.load(f); f.close(); f=open('schemas/${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));" - -gen-schema: - cd schemas && bun --bun tsc - make IN=config/config.ts \ - CLASS=Config \ - OUT=config.schema.json \ - gen-schema-single - make IN=providers/routes.ts \ - CLASS=Routes \ - OUT=routes.schema.json \ - gen-schema-single - make IN=middlewares/middleware_compose.ts \ - CLASS=MiddlewareCompose \ - OUT=middleware_compose.schema.json \ - gen-schema-single - make IN=docker.ts \ - CLASS=DockerRoutes \ - OUT=docker_routes.schema.json \ - gen-schema-single - cd .. - -publish-schema: - cd schemas && bun publish && cd .. - -update-schema-generator: - pnpm up -g typescript-json-schema - push-github: git push origin $(shell git rev-parse --abbrev-ref HEAD) \ No newline at end of file diff --git a/agent/pkg/handler/check_health.go b/agent/pkg/handler/check_health.go index f656453..ceddc99 100644 --- a/agent/pkg/handler/check_health.go +++ b/agent/pkg/handler/check_health.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) @@ -44,11 +43,11 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - result, err = monitor.NewHTTPHealthChecker(types.NewURL(&url.URL{ + result, err = monitor.NewHTTPHealthMonitor(&url.URL{ Scheme: scheme, Host: host, Path: path, - }), defaultHealthConfig).CheckHealth() + }, defaultHealthConfig).CheckHealth() case "tcp", "udp": host := query.Get("host") if host == "" { @@ -63,10 +62,10 @@ func CheckHealth(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - result, err = monitor.NewRawHealthChecker(types.NewURL(&url.URL{ + result, err = monitor.NewRawHealthMonitor(&url.URL{ Scheme: scheme, Host: host, - }), defaultHealthConfig).CheckHealth() + }, defaultHealthConfig).CheckHealth() } if err != nil { diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go index 712f261..3e80b27 100644 --- a/agent/pkg/handler/proxy_http.go +++ b/agent/pkg/handler/proxy_http.go @@ -13,13 +13,12 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" "github.com/yusing/go-proxy/internal/net/types" - "github.com/yusing/go-proxy/internal/utils/strutils" ) func ProxyHTTP(w http.ResponseWriter, r *http.Request) { host := r.Header.Get(agentproxy.HeaderXProxyHost) - isHTTPS := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS)) - skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify)) + isHTTPS, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS)) + skipTLSVerify, _ := strconv.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify)) responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout)) if err != nil { responseHeaderTimeout = 0 diff --git a/cmd/main.go b/cmd/main.go index 07ba910..ee08b9e 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,7 +17,7 @@ import ( "github.com/yusing/go-proxy/internal/metrics/systeminfo" "github.com/yusing/go-proxy/internal/metrics/uptime" "github.com/yusing/go-proxy/internal/net/gphttp/middleware" - "github.com/yusing/go-proxy/internal/route/routes/routequery" + "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/pkg" ) @@ -117,7 +117,7 @@ func main() { switch args.Command { case common.CommandListRoutes: cfg.StartProxyProviders() - printJSON(routequery.RoutesByAlias()) + printJSON(routes.ByAlias()) return case common.CommandListConfigs: printJSON(cfg.Value()) diff --git a/go.mod b/go.mod index 3c249a4..1f7d20e 100644 --- a/go.mod +++ b/go.mod @@ -32,45 +32,47 @@ replace github.com/coreos/go-oidc/v3 => github.com/godoxy-app/go-oidc/v3 v3.14.2 require ( github.com/bytedance/sonic v1.13.2 github.com/docker/cli v28.1.1+incompatible - github.com/docker/go-connections v0.5.0 + github.com/luthermonson/go-proxmox v0.2.2 github.com/stretchr/testify v1.10.0 ) +replace github.com/docker/docker => github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e + require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/goterm v1.0.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/cloudflare-go v0.115.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/diskfs/go-diskfs v1.5.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/djherbis/times v1.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.2 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jinzhu/copier v0.3.4 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/magefile/mage v1.14.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/miekg/dns v1.1.65 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nrdcg/porkbun v0.4.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -82,16 +84,14 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/mod v0.24.0 // indirect @@ -100,5 +100,4 @@ require ( golang.org/x/tools v0.32.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index b46df07..de4e892 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= @@ -8,6 +8,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -31,20 +33,22 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diskfs/go-diskfs v1.5.0 h1:0SANkrab4ifiZBytk380gIesYh5Gc+3i40l7qsrYP4s= +github.com/diskfs/go-diskfs v1.5.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= -github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= +github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= @@ -53,7 +57,6 @@ github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -69,11 +72,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e h1:LEbMtJ6loEubxetD+Aw8+1x0rShor5iMoy9WuFQ8hN8= +github.com/godoxy-app/docker v0.0.0-20250418000134-7af8fd7b079e/go.mod h1:3tMTnTkH7IN5smn7PX83XdmRnNj4Nw2/Pt8GgReqnKM= github.com/godoxy-app/go-oidc/v3 v3.14.2 h1:y1sosR6N7IpMiREM8I8w68zrUhh5P0Hg+6wERmuhFAc= github.com/godoxy-app/go-oidc/v3 v3.14.2/go.mod h1:ZRZLrEz7MmMe1kRzRsYqYmWKN2EHlPVGn71GMbrLLt4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -88,12 +95,20 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotify/server/v2 v2.6.1 h1:Kf7v5fzBxzELzZa/jonWfwJMkqYqh1LBzBpCmt5QIAI= github.com/gotify/server/v2 v2.6.1/go.mod h1:Dk8HLyTVDqmXM8YEg6tjROBen6mxyHZFRggJFHTwZLc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI= +github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -114,6 +129,10 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/luthermonson/go-proxmox v0.2.2 h1:BZ7VEj302wxw2i/EwTcyEiBzQib8teocB2SSkLHyySY= +github.com/luthermonson/go-proxmox v0.2.2/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -131,8 +150,8 @@ github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -145,8 +164,12 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw= github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= +github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -169,8 +192,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -186,6 +209,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -195,20 +220,16 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= @@ -267,8 +288,10 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -335,6 +358,6 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/api/v1/dockerapi/info.go b/internal/api/v1/dockerapi/info.go index 373895d..a8bd08c 100644 --- a/internal/api/v1/dockerapi/info.go +++ b/internal/api/v1/dockerapi/info.go @@ -25,7 +25,7 @@ func (d *dockerInfo) MarshalJSON() ([]byte, error) { }, "images": d.Images, "n_cpu": d.NCPU, - "memory": strutils.FormatByteSizeWithUnit(d.MemTotal), + "memory": strutils.FormatByteSize(d.MemTotal), }) } diff --git a/internal/api/v1/dockerapi/logs.go b/internal/api/v1/dockerapi/logs.go index 0bec4c6..86a82da 100644 --- a/internal/api/v1/dockerapi/logs.go +++ b/internal/api/v1/dockerapi/logs.go @@ -2,6 +2,7 @@ package dockerapi import ( "net/http" + "strconv" "github.com/coder/websocket" "github.com/docker/docker/api/types/container" @@ -9,15 +10,14 @@ import ( "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" - "github.com/yusing/go-proxy/internal/utils/strutils" ) func Logs(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() server := r.PathValue("server") containerID := r.PathValue("container") - stdout := strutils.ParseBool(query.Get("stdout")) - stderr := strutils.ParseBool(query.Get("stderr")) + stdout, _ := strconv.ParseBool(query.Get("stdout")) + stderr, _ := strconv.ParseBool(query.Get("stderr")) since := query.Get("from") until := query.Get("to") levels := query.Get("levels") // TODO: implement levels diff --git a/internal/api/v1/favicon/cache.go b/internal/api/v1/favicon/cache.go index 5391d53..515fc94 100644 --- a/internal/api/v1/favicon/cache.go +++ b/internal/api/v1/favicon/cache.go @@ -7,7 +7,7 @@ import ( "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/jsonstore" "github.com/yusing/go-proxy/internal/logging" - route "github.com/yusing/go-proxy/internal/route/types" + "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" ) @@ -52,11 +52,11 @@ func pruneExpiredIconCache() { } } -func routeKey(r route.HTTPRoute) string { - return r.ProviderName() + ":" + r.TargetName() +func routeKey(r routes.HTTPRoute) string { + return r.ProviderName() + ":" + r.Name() } -func PruneRouteIconCache(route route.HTTPRoute) { +func PruneRouteIconCache(route routes.HTTPRoute) { iconCache.Delete(routeKey(route)) } diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index ce38b1e..c3b5b6b 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -17,7 +17,6 @@ import ( "github.com/yusing/go-proxy/internal/logging" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/route/routes" - route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -83,7 +82,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { } // try with route.Homepage.Icon - r, ok := routes.GetHTTPRoute(alias) + r, ok := routes.HTTP.Get(alias) if !ok { gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound) return @@ -185,13 +184,13 @@ func fetchIcon(filetype, filename string) *fetchResult { return fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype)) } -func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult { +func findIcon(r routes.HTTPRoute, req *http.Request, uri string) *fetchResult { key := routeKey(r) if result := loadIconCache(key); result != nil { return result } - result := fetchIcon("png", sanitizeName(r.TargetName())) + result := fetchIcon("png", sanitizeName(r.Name())) cont := r.ContainerInfo() if !result.OK() && cont != nil { result = fetchIcon("png", sanitizeName(cont.Image.Name)) @@ -206,7 +205,7 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult { return result } -func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *fetchResult { +func findIconSlow(r routes.HTTPRoute, req *http.Request, uri string, depth int) *fetchResult { ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout")) defer cancel() newReq := req.WithContext(ctx) @@ -214,7 +213,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) * u, err := url.ParseRequestURI(strutils.SanitizeURI(uri)) if err != nil { logging.Error().Err(err). - Str("route", r.TargetName()). + Str("route", r.Name()). Str("path", uri). Msg("failed to parse uri") return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "cannot parse uri"} @@ -252,7 +251,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) * doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data)) if err != nil { logging.Error().Err(err). - Str("route", r.TargetName()). + Str("route", r.Name()). Msg("failed to parse html") return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"} } @@ -269,7 +268,7 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) * dataURI, err := dataurl.DecodeString(href) if err != nil { logging.Error().Err(err). - Str("route", r.TargetName()). + Str("route", r.Name()). Msg("failed to decode favicon") return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"} } diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go index 3379022..2528c3f 100644 --- a/internal/api/v1/health.go +++ b/internal/api/v1/health.go @@ -9,15 +9,15 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" - "github.com/yusing/go-proxy/internal/route/routes/routequery" + "github.com/yusing/go-proxy/internal/route/routes" ) func Health(w http.ResponseWriter, r *http.Request) { if httpheaders.IsWebsocket(r.Header) { gpwebsocket.Periodic(w, r, 1*time.Second, func(conn *websocket.Conn) error { - return wsjson.Write(r.Context(), conn, routequery.HealthMap()) + return wsjson.Write(r.Context(), conn, routes.HealthMap()) }) } else { - gphttp.RespondJSON(w, r, routequery.HealthMap()) + gphttp.RespondJSON(w, r, routes.HealthMap()) } } diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index ecbce0d..918daea 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -11,7 +11,7 @@ import ( config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/middleware" - "github.com/yusing/go-proxy/internal/route/routes/routequery" + "github.com/yusing/go-proxy/internal/route/routes" route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils" @@ -47,7 +47,7 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { gphttp.RespondJSON(w, r, route) } case ListRoutes: - gphttp.RespondJSON(w, r, routequery.RoutesByAlias(route.RouteType(r.FormValue("type")))) + gphttp.RespondJSON(w, r, routes.ByAlias(route.RouteType(r.FormValue("type")))) case ListFiles: listFiles(w, r) case ListMiddlewares: @@ -57,11 +57,11 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { case ListMatchDomains: gphttp.RespondJSON(w, r, cfg.Value().MatchDomains) case ListHomepageConfig: - gphttp.RespondJSON(w, r, routequery.HomepageConfig(r.FormValue("category"), r.FormValue("provider"))) + gphttp.RespondJSON(w, r, routes.HomepageConfig(r.FormValue("category"), r.FormValue("provider"))) case ListRouteProviders: gphttp.RespondJSON(w, r, cfg.RouteProviderList()) case ListHomepageCategories: - gphttp.RespondJSON(w, r, routequery.HomepageCategories()) + gphttp.RespondJSON(w, r, routes.HomepageCategories()) case ListIcons: limit, err := strconv.Atoi(r.FormValue("limit")) if err != nil { @@ -87,9 +87,9 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { // otherwise, return a single Route with alias which or nil if not found. func listRoute(which string) any { if which == "" || which == "all" { - return routequery.RoutesByAlias() + return routes.ByAlias() } - routes := routequery.RoutesByAlias() + routes := routes.ByAlias() route, ok := routes[which] if !ok { return nil diff --git a/internal/api/v1/new_agent.go b/internal/api/v1/new_agent.go index e4d67e8..bf30dbd 100644 --- a/internal/api/v1/new_agent.go +++ b/internal/api/v1/new_agent.go @@ -14,7 +14,6 @@ import ( "github.com/yusing/go-proxy/agent/pkg/certs" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/net/gphttp" - "github.com/yusing/go-proxy/internal/utils/strutils" ) func NewAgent(w http.ResponseWriter, r *http.Request) { @@ -56,7 +55,7 @@ func NewAgent(w http.ResponseWriter, r *http.Request) { return } - nightly := strutils.ParseBool(q.Get("nightly")) + nightly, _ := strconv.ParseBool(q.Get("nightly")) var image string if nightly { image = agent.DockerImageNightly diff --git a/internal/docker/container.go b/internal/docker/container.go index 728a918..915f988 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -6,12 +6,13 @@ import ( "strings" "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" "github.com/yusing/go-proxy/agent/pkg/agent" config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/utils" U "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/utils/strutils" ) type ( @@ -26,7 +27,8 @@ type ( Agent *agent.AgentConfig `json:"agent"` - Labels map[string]string `json:"-"` + Labels map[string]string `json:"-"` + IdlewatcherConfig *idlewatcher.Config `json:"idlewatcher_config"` Mounts []string `json:"mounts"` @@ -35,16 +37,10 @@ type ( PublicHostname string `json:"public_hostname"` PrivateHostname string `json:"private_hostname"` - Aliases []string `json:"aliases"` - IsExcluded bool `json:"is_excluded"` - IsExplicit bool `json:"is_explicit"` - IdleTimeout string `json:"idle_timeout,omitempty"` - WakeTimeout string `json:"wake_timeout,omitempty"` - StopMethod string `json:"stop_method,omitempty"` - StopTimeout string `json:"stop_timeout,omitempty"` // stop_method = "stop" only - StopSignal string `json:"stop_signal,omitempty"` // stop_method = "stop" | "kill" only - StartEndpoint string `json:"start_endpoint,omitempty"` - Running bool `json:"running"` + Aliases []string `json:"aliases"` + IsExcluded bool `json:"is_excluded"` + IsExplicit bool `json:"is_explicit"` + Running bool `json:"running"` } ContainerImage struct { Author string `json:"author,omitempty"` @@ -55,7 +51,7 @@ type ( var DummyContainer = new(Container) -func FromDocker(c *container.Summary, dockerHost string) (res *Container) { +func FromDocker(c *container.SummaryTrimmed, dockerHost string) (res *Container) { isExplicit := false helper := containerHelper{c} for lbl := range c.Labels { @@ -65,6 +61,8 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) { delete(c.Labels, lbl) } } + + isExcluded, _ := strconv.ParseBool(helper.getDeleteLabel(LabelExclude)) res = &Container{ DockerHost: dockerHost, Image: helper.parseImage(), @@ -78,16 +76,10 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) { PublicPortMapping: helper.getPublicPortMapping(), PrivatePortMapping: helper.getPrivatePortMapping(), - Aliases: helper.getAliases(), - IsExcluded: strutils.ParseBool(helper.getDeleteLabel(LabelExclude)), - IsExplicit: isExplicit, - IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout), - WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout), - StopMethod: helper.getDeleteLabel(LabelStopMethod), - StopTimeout: helper.getDeleteLabel(LabelStopTimeout), - StopSignal: helper.getDeleteLabel(LabelStopSignal), - StartEndpoint: helper.getDeleteLabel(LabelStartEndpoint), - Running: c.Status == "running" || c.State == "running", + Aliases: helper.getAliases(), + IsExcluded: isExcluded, + IsExplicit: isExplicit, + Running: c.Status == "running" || c.State == "running", } if agent.IsDockerHostAgent(dockerHost) { @@ -100,44 +92,10 @@ func FromDocker(c *container.Summary, dockerHost string) (res *Container) { res.setPrivateHostname(helper) res.setPublicHostname() + res.loadDeleteIdlewatcherLabels(helper) return } -func FromInspectResponse(json container.InspectResponse, dockerHost string) *Container { - ports := make([]container.Port, 0) - for k, bindings := range json.NetworkSettings.Ports { - proto, privPortStr := nat.SplitProtoPort(string(k)) - privPort, _ := strconv.ParseUint(privPortStr, 10, 16) - ports = append(ports, container.Port{ - PrivatePort: uint16(privPort), - Type: proto, - }) - for _, v := range bindings { - pubPort, _ := strconv.ParseUint(v.HostPort, 10, 16) - ports = append(ports, container.Port{ - IP: v.HostIP, - PublicPort: uint16(pubPort), - PrivatePort: uint16(privPort), - Type: proto, - }) - } - } - cont := FromDocker(&container.Summary{ - ID: json.ID, - Names: []string{strings.TrimPrefix(json.Name, "/")}, - Image: json.Image, - Ports: ports, - Labels: json.Config.Labels, - State: json.State.Status, - Status: json.State.Status, - Mounts: json.Mounts, - NetworkSettings: &container.NetworkSettingsSummary{ - Networks: json.NetworkSettings.Networks, - }, - }, dockerHost) - return cont -} - func (c *Container) IsBlacklisted() bool { return c.Image.IsBlacklisted() || c.isDatabase() } @@ -200,3 +158,31 @@ func (c *Container) setPrivateHostname(helper containerHelper) { return } } + +func (c *Container) loadDeleteIdlewatcherLabels(helper containerHelper) { + cfg := map[string]any{ + "idle_timeout": helper.getDeleteLabel(LabelIdleTimeout), + "wake_timeout": helper.getDeleteLabel(LabelWakeTimeout), + "stop_method": helper.getDeleteLabel(LabelStopMethod), + "stop_timeout": helper.getDeleteLabel(LabelStopTimeout), + "stop_signal": helper.getDeleteLabel(LabelStopSignal), + "start_endpoint": helper.getDeleteLabel(LabelStartEndpoint), + } + // set only if idlewatcher is enabled + idleTimeout := cfg["idle_timeout"] + if idleTimeout != "" { + idwCfg := &idlewatcher.Config{ + Docker: &idlewatcher.DockerConfig{ + DockerHost: c.DockerHost, + ContainerID: c.ContainerID, + ContainerName: c.ContainerName, + }, + } + err := utils.Deserialize(cfg, idwCfg) + if err != nil { + gperr.LogWarn("invalid idlewatcher config", gperr.PrependSubject(c.ContainerName, err)) + } else { + c.IdlewatcherConfig = idwCfg + } + } +} diff --git a/internal/docker/container_helper.go b/internal/docker/container_helper.go index 59444c0..a7da34e 100644 --- a/internal/docker/container_helper.go +++ b/internal/docker/container_helper.go @@ -8,7 +8,7 @@ import ( ) type containerHelper struct { - *container.Summary + *container.SummaryTrimmed } // getDeleteLabel gets the value of a label and then deletes it from the container. diff --git a/internal/docker/container_test.go b/internal/docker/container_test.go index 753ebac..0e9b578 100644 --- a/internal/docker/container_test.go +++ b/internal/docker/container_test.go @@ -36,7 +36,7 @@ func TestContainerExplicit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := FromDocker(&container.Summary{Names: []string{"test"}, State: "test", Labels: tt.labels}, "") + c := FromDocker(&container.SummaryTrimmed{Names: []string{"test"}, State: "test", Labels: tt.labels}, "") ExpectEqual(t, c.IsExplicit, tt.isExplicit) }) } diff --git a/internal/docker/idlewatcher/container.go b/internal/docker/idlewatcher/container.go deleted file mode 100644 index 1d8fbd0..0000000 --- a/internal/docker/idlewatcher/container.go +++ /dev/null @@ -1,60 +0,0 @@ -package idlewatcher - -import ( - "context" - "errors" - - "github.com/docker/docker/api/types/container" -) - -type ( - containerMeta struct { - ContainerID, ContainerName string - } - containerState struct { - running bool - ready bool - err error - } -) - -func (w *Watcher) ContainerID() string { - return w.route.ContainerInfo().ContainerID -} - -func (w *Watcher) ContainerName() string { - return w.route.ContainerInfo().ContainerName -} - -func (w *Watcher) containerStop(ctx context.Context) error { - return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{ - Signal: string(w.Config().StopSignal), - Timeout: &w.Config().StopTimeout, - }) -} - -func (w *Watcher) containerPause(ctx context.Context) error { - return w.client.ContainerPause(ctx, w.ContainerID()) -} - -func (w *Watcher) containerKill(ctx context.Context) error { - return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal)) -} - -func (w *Watcher) containerUnpause(ctx context.Context) error { - return w.client.ContainerUnpause(ctx, w.ContainerID()) -} - -func (w *Watcher) containerStart(ctx context.Context) error { - return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{}) -} - -func (w *Watcher) containerStatus() (string, error) { - ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout")) - defer cancel() - json, err := w.client.ContainerInspect(ctx, w.ContainerID()) - if err != nil { - return "", err - } - return json.State.Status, nil -} diff --git a/internal/docker/idlewatcher/state.go b/internal/docker/idlewatcher/state.go deleted file mode 100644 index 939ec4e..0000000 --- a/internal/docker/idlewatcher/state.go +++ /dev/null @@ -1,39 +0,0 @@ -package idlewatcher - -func (w *Watcher) running() bool { - return w.state.Load().running -} - -func (w *Watcher) ready() bool { - return w.state.Load().ready -} - -func (w *Watcher) error() error { - return w.state.Load().err -} - -func (w *Watcher) setReady() { - w.state.Store(&containerState{ - running: true, - ready: true, - }) -} - -func (w *Watcher) setStarting() { - w.state.Store(&containerState{ - running: true, - ready: false, - }) -} - -func (w *Watcher) setNapping() { - w.setError(nil) -} - -func (w *Watcher) setError(err error) { - w.state.Store(&containerState{ - running: false, - ready: false, - err: err, - }) -} diff --git a/internal/docker/idlewatcher/types/config.go b/internal/docker/idlewatcher/types/config.go deleted file mode 100644 index 829b54f..0000000 --- a/internal/docker/idlewatcher/types/config.go +++ /dev/null @@ -1,110 +0,0 @@ -package types - -import ( - "errors" - "net/url" - "strings" - "time" - - "github.com/yusing/go-proxy/internal/docker" - "github.com/yusing/go-proxy/internal/gperr" -) - -type ( - Config struct { - IdleTimeout time.Duration `json:"idle_timeout,omitempty"` - WakeTimeout time.Duration `json:"wake_timeout,omitempty"` - StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument - StopMethod StopMethod `json:"stop_method,omitempty"` - StopSignal Signal `json:"stop_signal,omitempty"` - StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container - } - StopMethod string - Signal string -) - -const ( - StopMethodPause StopMethod = "pause" - StopMethodStop StopMethod = "stop" - StopMethodKill StopMethod = "kill" -) - -var validSignals = map[string]struct{}{ - "": {}, - "SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {}, - "INT": {}, "TERM": {}, "HUP": {}, "QUIT": {}, -} - -func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) { - if cont == nil || cont.IdleTimeout == "" { - return nil, nil - } - - errs := gperr.NewBuilder("invalid idlewatcher config") - - idleTimeout := gperr.Collect(errs, validateDurationPostitive, cont.IdleTimeout) - wakeTimeout := gperr.Collect(errs, validateDurationPostitive, cont.WakeTimeout) - stopTimeout := gperr.Collect(errs, validateDurationPostitive, cont.StopTimeout) - stopMethod := gperr.Collect(errs, validateStopMethod, cont.StopMethod) - signal := gperr.Collect(errs, validateSignal, cont.StopSignal) - startEndpoint := gperr.Collect(errs, validateStartEndpoint, cont.StartEndpoint) - - if errs.HasError() { - return nil, errs.Error() - } - - return &Config{ - IdleTimeout: idleTimeout, - WakeTimeout: wakeTimeout, - StopTimeout: int(stopTimeout.Seconds()), - StopMethod: stopMethod, - StopSignal: signal, - StartEndpoint: startEndpoint, - }, nil -} - -func validateDurationPostitive(value string) (time.Duration, error) { - d, err := time.ParseDuration(value) - if err != nil { - return 0, err - } - if d < 0 { - return 0, errors.New("duration must be positive") - } - return d, nil -} - -func validateSignal(s string) (Signal, error) { - if _, ok := validSignals[s]; ok { - return Signal(s), nil - } - return "", errors.New("invalid signal " + s) -} - -func validateStopMethod(s string) (StopMethod, error) { - sm := StopMethod(s) - switch sm { - case StopMethodPause, StopMethodStop, StopMethodKill: - return sm, nil - default: - return "", errors.New("invalid stop method " + s) - } -} - -func validateStartEndpoint(s string) (string, error) { - if s == "" { - return "", nil - } - // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 - // emulate browser and strip the '#' suffix prior to validation. see issue-#237 - if i := strings.Index(s, "#"); i > -1 { - s = s[:i] - } - if len(s) == 0 { - return "", errors.New("start endpoint must not be empty if defined") - } - if _, err := url.ParseRequestURI(s); err != nil { - return "", err - } - return s, nil -} diff --git a/internal/docker/idlewatcher/waker.go b/internal/docker/idlewatcher/waker.go deleted file mode 100644 index ccda00e..0000000 --- a/internal/docker/idlewatcher/waker.go +++ /dev/null @@ -1,181 +0,0 @@ -package idlewatcher - -import ( - "time" - - "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/metrics" - "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" - net "github.com/yusing/go-proxy/internal/net/types" - route "github.com/yusing/go-proxy/internal/route/types" - "github.com/yusing/go-proxy/internal/task" - U "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/watcher/health" - "github.com/yusing/go-proxy/internal/watcher/health/monitor" -) - -type ( - Waker = types.Waker - waker struct { - _ U.NoCopy - - rp *reverseproxy.ReverseProxy - stream net.Stream - hc health.HealthChecker - metric *metrics.Gauge - } -) - -const ( - idleWakerCheckInterval = 100 * time.Millisecond - idleWakerCheckTimeout = time.Second -) - -// TODO: support stream - -func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, gperr.Error) { - hcCfg := route.HealthCheckConfig() - hcCfg.Timeout = idleWakerCheckTimeout - - waker := &waker{ - rp: rp, - stream: stream, - } - watcher, err := registerWatcher(parent, route, waker) - if err != nil { - return nil, gperr.Errorf("register watcher: %w", err) - } - - switch { - case route.IsAgent(): - waker.hc = monitor.NewAgentProxiedMonitor(route.Agent(), hcCfg, monitor.AgentTargetFromURL(route.TargetURL())) - case rp != nil: - waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg) - case stream != nil: - waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg) - default: - panic("both nil") - } - - return watcher, nil -} - -// lifetime should follow route provider. -func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, gperr.Error) { - return newWaker(parent, route, rp, nil) -} - -func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, gperr.Error) { - return newWaker(parent, route, nil, stream) -} - -// Start implements health.HealthMonitor. -func (w *Watcher) Start(parent task.Parent) gperr.Error { - w.task.OnCancel("route_cleanup", func() { - parent.Finish(w.task.FinishCause()) - if w.metric != nil { - w.metric.Reset() - } - }) - return nil -} - -// Task implements health.HealthMonitor. -func (w *Watcher) Task() *task.Task { - return w.task -} - -// Finish implements health.HealthMonitor. -func (w *Watcher) Finish(reason any) { - if w.stream != nil { - w.stream.Close() - } -} - -// Name implements health.HealthMonitor. -func (w *Watcher) Name() string { - return w.String() -} - -// String implements health.HealthMonitor. -func (w *Watcher) String() string { - return w.ContainerName() -} - -// Uptime implements health.HealthMonitor. -func (w *Watcher) Uptime() time.Duration { - return 0 -} - -// Latency implements health.HealthMonitor. -func (w *Watcher) Latency() time.Duration { - return 0 -} - -// Status implements health.HealthMonitor. -func (w *Watcher) Status() health.Status { - state := w.state.Load() - if state.err != nil { - return health.StatusError - } - if state.ready { - return health.StatusHealthy - } - if state.running { - return health.StatusStarting - } - return health.StatusNapping -} - -func (w *Watcher) checkUpdateState() (ready bool, err error) { - // already ready - if w.ready() { - return true, nil - } - - if !w.running() { - return false, nil - } - - if w.metric != nil { - defer w.metric.Set(float64(w.Status())) - } - - // the new container info not yet updated - if w.hc.URL().Host == "" { - return false, nil - } - - res, err := w.hc.CheckHealth() - if err != nil { - w.setError(err) - return false, err - } - - if res.Healthy { - w.setReady() - return true, nil - } - w.setStarting() - return false, nil -} - -// MarshalJSON implements health.HealthMonitor. -func (w *Watcher) MarshalJSON() ([]byte, error) { - var url *net.URL - if w.hc.URL().Port() != "0" { - url = w.hc.URL() - } - var detail string - if err := w.error(); err != nil { - detail = err.Error() - } - return (&monitor.JSONRepresentation{ - Name: w.Name(), - Status: w.Status(), - Config: w.hc.Config(), - URL: url, - Detail: detail, - }).MarshalJSON() -} diff --git a/internal/docker/idlewatcher/watcher.go b/internal/docker/idlewatcher/watcher.go deleted file mode 100644 index a504153..0000000 --- a/internal/docker/idlewatcher/watcher.go +++ /dev/null @@ -1,279 +0,0 @@ -package idlewatcher - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/rs/zerolog" - "github.com/yusing/go-proxy/internal/docker" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/logging" - route "github.com/yusing/go-proxy/internal/route/types" - "github.com/yusing/go-proxy/internal/task" - U "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/utils/atomic" - "github.com/yusing/go-proxy/internal/watcher" - "github.com/yusing/go-proxy/internal/watcher/events" -) - -type ( - Watcher struct { - _ U.NoCopy - - zerolog.Logger - - *waker - - route route.Route - - client *docker.SharedClient - state atomic.Value[*containerState] - - stopByMethod StopCallback // send a docker command w.r.t. `stop_method` - ticker *time.Ticker - lastReset time.Time - task *task.Task - } - - StopCallback func() error -) - -var ( - watcherMap = make(map[string]*Watcher) - watcherMapMu sync.RWMutex - - errShouldNotReachHere = errors.New("should not reach here") -) - -const dockerReqTimeout = 3 * time.Second - -func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) { - cfg := route.IdlewatcherConfig() - cont := route.ContainerInfo() - key := cont.ContainerID - - watcherMapMu.Lock() - defer watcherMapMu.Unlock() - w, ok := watcherMap[key] - if !ok { - client, err := docker.NewClient(cont.DockerHost) - if err != nil { - return nil, err - } - - w = &Watcher{ - Logger: logging.With().Str("name", cont.ContainerName).Logger(), - client: client, - task: parent.Subtask("idlewatcher." + cont.ContainerName), - ticker: time.NewTicker(cfg.IdleTimeout), - } - } - - // FIXME: possible race condition here - w.waker = waker - w.route = route - w.ticker.Reset(cfg.IdleTimeout) - - if cont.Running { - w.setStarting() - } else { - w.setNapping() - } - - if !ok { - w.stopByMethod = w.getStopCallback() - watcherMap[key] = w - - go func() { - cause := w.watchUntilDestroy() - - watcherMapMu.Lock() - defer watcherMapMu.Unlock() - delete(watcherMap, key) - - w.ticker.Stop() - w.client.Close() - w.task.Finish(cause) - }() - } - - return w, nil -} - -func (w *Watcher) Config() *idlewatcher.Config { - return w.route.IdlewatcherConfig() -} - -func (w *Watcher) Wake() error { - return w.wakeIfStopped() -} - -// WakeDebug logs a debug message related to waking the container. -func (w *Watcher) WakeDebug() *zerolog.Event { - //nolint:zerologlint - return w.Debug().Str("action", "wake") -} - -func (w *Watcher) WakeTrace() *zerolog.Event { - //nolint:zerologlint - return w.Trace().Str("action", "wake") -} - -func (w *Watcher) WakeError(err error) { - w.Err(err).Str("action", "wake").Msg("error") -} - -func (w *Watcher) wakeIfStopped() error { - if w.running() { - return nil - } - - status, err := w.containerStatus() - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(w.task.Context(), w.Config().WakeTimeout) - defer cancel() - - // !Hard coded here since theres no constants from Docker API - switch status { - case "exited", "dead": - return w.containerStart(ctx) - case "paused": - return w.containerUnpause(ctx) - case "running": - return nil - default: - return gperr.Errorf("unexpected container status: %s", status) - } -} - -func (w *Watcher) getStopCallback() StopCallback { - var cb func(context.Context) error - switch w.Config().StopMethod { - case idlewatcher.StopMethodPause: - cb = w.containerPause - case idlewatcher.StopMethodStop: - cb = w.containerStop - case idlewatcher.StopMethodKill: - cb = w.containerKill - default: - panic(errShouldNotReachHere) - } - return func() error { - ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.Config().StopTimeout)*time.Second) - defer cancel() - return cb(ctx) - } -} - -func (w *Watcher) resetIdleTimer() { - w.Trace().Msg("reset idle timer") - w.ticker.Reset(w.Config().IdleTimeout) - w.lastReset = time.Now() -} - -func (w *Watcher) expires() time.Time { - return w.lastReset.Add(w.Config().IdleTimeout) -} - -func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) { - eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{ - Filters: watcher.NewDockerFilter( - watcher.DockerFilterContainer, - watcher.DockerFilterContainerNameID(w.route.ContainerInfo().ContainerID), - watcher.DockerFilterStart, - watcher.DockerFilterStop, - watcher.DockerFilterDie, - watcher.DockerFilterKill, - watcher.DockerFilterDestroy, - watcher.DockerFilterPause, - watcher.DockerFilterUnpause, - ), - }) - return -} - -// watchUntilDestroy waits for the container to be created, started, or unpaused, -// and then reset the idle timer. -// -// When the container is stopped, paused, -// or killed, the idle timer is stopped and the ContainerRunning flag is set to false. -// -// When the idle timer fires, the container is stopped according to the -// stop method. -// -// it exits only if the context is canceled, the container is destroyed, -// errors occurred on docker client, or route provider died (mainly caused by config reload). -func (w *Watcher) watchUntilDestroy() (returnCause error) { - eventCtx, eventCancel := context.WithCancel(w.task.Context()) - defer eventCancel() - - dockerWatcher := watcher.NewDockerWatcher(w.client.DaemonHost()) - dockerEventCh, dockerEventErrCh := w.getEventCh(eventCtx, dockerWatcher) - - for { - select { - case <-w.task.Context().Done(): - return w.task.FinishCause() - case err := <-dockerEventErrCh: - if !err.Is(context.Canceled) { - gperr.LogError("idlewatcher error", err, &w.Logger) - } - return err - case e := <-dockerEventCh: - switch { - case e.Action == events.ActionContainerDestroy: - w.setError(errors.New("container destroyed")) - w.Info().Str("reason", "container destroyed").Msg("watcher stopped") - return errors.New("container destroyed") - // create / start / unpause - case e.Action.IsContainerWake(): - w.setStarting() - w.resetIdleTimer() - w.Info().Msg("awaken") - case e.Action.IsContainerSleep(): // stop / pause / kil - w.setNapping() - w.resetIdleTimer() - w.ticker.Stop() - default: - w.Error().Msg("unexpected docker event: " + e.String()) - } - // container name changed should also change the container id - // if w.ContainerName != e.ActorName { - // w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName) - // w.ContainerName = e.ActorName - // } - // if w.ContainerID != e.ActorID { - // w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID) - // w.ContainerID = e.ActorID - // // recreate event stream - // eventCancel() - - // eventCtx, eventCancel = context.WithCancel(w.task.Context()) - // defer eventCancel() - // dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher) - // } - case <-w.ticker.C: - w.ticker.Stop() - if w.running() { - err := w.stopByMethod() - switch { - case errors.Is(err, context.Canceled): - continue - case err != nil: - if errors.Is(err, context.DeadlineExceeded) { - err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`") - } - w.Err(err).Msgf("container stop with method %q failed", w.Config().StopMethod) - default: - w.Info().Str("reason", "idle timeout").Msg("container stopped") - } - } - } - } -} diff --git a/internal/docker/inspect.go b/internal/docker/inspect.go deleted file mode 100644 index 8eb1413..0000000 --- a/internal/docker/inspect.go +++ /dev/null @@ -1,28 +0,0 @@ -package docker - -import ( - "context" - "errors" - "time" -) - -func Inspect(dockerHost string, containerID string) (*Container, error) { - client, err := NewClient(dockerHost) - if err != nil { - return nil, err - } - - defer client.Close() - return client.Inspect(containerID) -} - -func (c *SharedClient) Inspect(containerID string) (*Container, error) { - ctx, cancel := context.WithTimeoutCause(context.Background(), 3*time.Second, errors.New("docker container inspect timeout")) - defer cancel() - - json, err := c.ContainerInspect(ctx, containerID) - if err != nil { - return nil, err - } - return FromInspectResponse(json, c.key), nil -} diff --git a/internal/docker/list_containers.go b/internal/docker/list_containers.go index 517b59d..671f5cf 100644 --- a/internal/docker/list_containers.go +++ b/internal/docker/list_containers.go @@ -21,7 +21,7 @@ var listOptions = container.ListOptions{ All: true, } -func ListContainers(clientHost string) ([]container.Summary, error) { +func ListContainers(clientHost string) ([]container.SummaryTrimmed, error) { dockerClient, err := NewClient(clientHost) if err != nil { return nil, err diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 1a3a166..17dcf27 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -12,7 +12,6 @@ import ( "github.com/yusing/go-proxy/internal/net/gphttp/middleware" "github.com/yusing/go-proxy/internal/net/gphttp/middleware/errorpage" "github.com/yusing/go-proxy/internal/route/routes" - route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -20,7 +19,7 @@ import ( type Entrypoint struct { middleware *middleware.Middleware accessLogger *accesslog.AccessLogger - findRouteFunc func(host string) (route.HTTPRoute, error) + findRouteFunc func(host string) (routes.HTTPRoute, error) } var ErrNoSuchRoute = errors.New("no such route") @@ -108,7 +107,7 @@ func (ep *Entrypoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func findRouteAnyDomain(host string) (route.HTTPRoute, error) { +func findRouteAnyDomain(host string) (routes.HTTPRoute, error) { hostSplit := strutils.SplitRune(host, '.') target := hostSplit[0] @@ -118,19 +117,19 @@ func findRouteAnyDomain(host string) (route.HTTPRoute, error) { return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, target) } -func findRouteByDomains(domains []string) func(host string) (route.HTTPRoute, error) { - return func(host string) (route.HTTPRoute, error) { +func findRouteByDomains(domains []string) func(host string) (routes.HTTPRoute, error) { + return func(host string) (routes.HTTPRoute, error) { for _, domain := range domains { if strings.HasSuffix(host, domain) { target := strings.TrimSuffix(host, domain) - if r, ok := routes.GetHTTPRoute(target); ok { + if r, ok := routes.HTTP.Get(target); ok { return r, nil } } } // fallback to exact match - if r, ok := routes.GetHTTPRoute(host); ok { + if r, ok := routes.HTTP.Get(host); ok { return r, nil } return nil, fmt.Errorf("%w: %s", ErrNoSuchRoute, host) diff --git a/internal/entrypoint/entrypoint_test.go b/internal/entrypoint/entrypoint_test.go index 60f7659..eb98f44 100644 --- a/internal/entrypoint/entrypoint_test.go +++ b/internal/entrypoint/entrypoint_test.go @@ -5,37 +5,43 @@ import ( "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route/routes" - . "github.com/yusing/go-proxy/internal/utils/testing" + + expect "github.com/yusing/go-proxy/internal/utils/testing" ) -var ( - r route.ReveseProxyRoute - ep = NewEntrypoint() -) +var ep = NewEntrypoint() + +func addRoute(alias string) { + routes.HTTP.Add(&route.ReveseProxyRoute{ + Route: &route.Route{ + Alias: alias, + }, + }) +} func run(t *testing.T, match []string, noMatch []string) { t.Helper() - t.Cleanup(routes.TestClear) + t.Cleanup(routes.Clear) t.Cleanup(func() { ep.SetFindRouteDomains(nil) }) for _, test := range match { t.Run(test, func(t *testing.T) { found, err := ep.findRouteFunc(test) - ExpectNoError(t, err) - ExpectTrue(t, found == &r) + expect.NoError(t, err) + expect.NotNil(t, found) }) } for _, test := range noMatch { t.Run(test, func(t *testing.T) { _, err := ep.findRouteFunc(test) - ExpectError(t, ErrNoSuchRoute, err) + expect.ErrorIs(t, ErrNoSuchRoute, err) }) } } func TestFindRouteAnyDomain(t *testing.T) { - routes.SetHTTPRoute("app1", &r) + addRoute("app1") tests := []string{ "app1.com", @@ -66,7 +72,7 @@ func TestFindRouteExactHostMatch(t *testing.T) { } for _, test := range tests { - routes.SetHTTPRoute(test, &r) + addRoute(test) } run(t, tests, testsNoMatch) @@ -78,7 +84,7 @@ func TestFindRouteByDomains(t *testing.T) { ".sub.domain.com", }) - routes.SetHTTPRoute("app1", &r) + addRoute("app1") tests := []string{ "app1.domain.com", @@ -103,7 +109,7 @@ func TestFindRouteByDomainsExactMatch(t *testing.T) { ".sub.domain.com", }) - routes.SetHTTPRoute("app1.foo.bar", &r) + addRoute("app1.foo.bar") tests := []string{ "app1.foo.bar", // exact match diff --git a/internal/idlewatcher/common.go b/internal/idlewatcher/common.go new file mode 100644 index 0000000..6753237 --- /dev/null +++ b/internal/idlewatcher/common.go @@ -0,0 +1,13 @@ +package idlewatcher + +import "context" + +func (w *Watcher) cancelled(reqCtx context.Context) bool { + select { + case <-reqCtx.Done(): + w.l.Debug().AnErr("cause", context.Cause(reqCtx)).Msg("wake canceled") + return true + default: + return false + } +} diff --git a/internal/idlewatcher/debug.go b/internal/idlewatcher/debug.go new file mode 100644 index 0000000..a8aa7d3 --- /dev/null +++ b/internal/idlewatcher/debug.go @@ -0,0 +1,40 @@ +package idlewatcher + +import ( + "iter" + "strconv" + + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type watcherDebug struct { + *Watcher +} + +func (w watcherDebug) MarshalMap() map[string]any { + state := w.state.Load() + return map[string]any{ + "name": w.Name(), + "state": map[string]string{ + "status": string(state.status), + "ready": strconv.FormatBool(state.ready), + "err": fmtErr(state.err), + }, + "expires": strutils.FormatTime(w.expires()), + "last_reset": strutils.FormatTime(w.lastReset.Load()), + "config": w.cfg, + } +} + +func Watchers() iter.Seq2[string, watcherDebug] { + return func(yield func(string, watcherDebug) bool) { + watcherMapMu.RLock() + defer watcherMapMu.RUnlock() + + for k, w := range watcherMap { + if !yield(k, watcherDebug{w}) { + return + } + } + } +} diff --git a/internal/docker/idlewatcher/waker_http.go b/internal/idlewatcher/handle_http.go similarity index 69% rename from internal/docker/idlewatcher/waker_http.go rename to internal/idlewatcher/handle_http.go index 3965713..6d87f49 100644 --- a/internal/docker/idlewatcher/waker_http.go +++ b/internal/idlewatcher/handle_http.go @@ -42,20 +42,6 @@ func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } } -func (w *Watcher) cancelled(reqCtx context.Context, rw http.ResponseWriter) bool { - select { - case <-reqCtx.Done(): - w.WakeDebug().Str("cause", context.Cause(reqCtx).Error()).Msg("canceled") - return true - case <-w.task.Context().Done(): - w.WakeDebug().Str("cause", w.task.FinishCause().Error()).Msg("canceled") - http.Error(rw, "Service unavailable", http.StatusServiceUnavailable) - return true - default: - return false - } -} - func isFaviconPath(path string) bool { return path == "/favicon.ico" } @@ -70,13 +56,13 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN // handle favicon request if isFaviconPath(r.URL.Path) { - r.URL.RawQuery = "alias=" + w.route.TargetName() + r.URL.RawQuery = "alias=" + w.rp.TargetName favicon.GetFavIcon(rw, r) return false } // Check if start endpoint is configured and request path matches - if w.Config().StartEndpoint != "" && r.URL.Path != w.Config().StartEndpoint { + if w.cfg.StartEndpoint != "" && r.URL.Path != w.cfg.StartEndpoint { http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden) return false } @@ -95,44 +81,48 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN rw.Header().Add("Cache-Control", "must-revalidate") rw.Header().Add("Connection", "close") if _, err := rw.Write(body); err != nil { - w.Err(err).Msg("error writing http response") + return false } return false } - ctx, cancel := context.WithTimeoutCause(r.Context(), w.Config().WakeTimeout, errors.New("wake timeout")) + ctx, cancel := context.WithTimeoutCause(r.Context(), w.cfg.WakeTimeout, errors.New("wake timeout")) defer cancel() - if w.cancelled(ctx, rw) { + if w.cancelled(ctx) { + gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable) return false } - w.WakeTrace().Msg("signal received") + w.l.Trace().Msg("signal received") err := w.wakeIfStopped() if err != nil { - w.WakeError(err) - http.Error(rw, "Error waking container", http.StatusInternalServerError) + gphttp.ServerError(rw, r, err) return false } + var ready bool + for { - if w.cancelled(ctx, rw) { + w.resetIdleTimer() + + if w.cancelled(ctx) { + gphttp.ServerError(rw, r, context.Cause(ctx), http.StatusServiceUnavailable) return false } - ready, err := w.checkUpdateState() + w, ready, err = checkUpdateState(w.Key()) if err != nil { - http.Error(rw, "Error waking container", http.StatusInternalServerError) + gphttp.ServerError(rw, r, err) return false } if ready { - w.resetIdleTimer() if isCheckRedirect { - w.Debug().Msgf("redirecting to %s ...", w.hc.URL()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, redirecting") rw.WriteHeader(http.StatusOK) return false } - w.Debug().Msgf("passing through to %s ...", w.hc.URL()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through") return true } diff --git a/internal/docker/idlewatcher/waker_stream.go b/internal/idlewatcher/handle_stream.go similarity index 50% rename from internal/docker/idlewatcher/waker_stream.go rename to internal/idlewatcher/handle_stream.go index 22f55d3..50cf6e2 100644 --- a/internal/docker/idlewatcher/waker_stream.go +++ b/internal/idlewatcher/handle_stream.go @@ -3,11 +3,10 @@ package idlewatcher import ( "context" "errors" - "fmt" "net" "time" - "github.com/yusing/go-proxy/internal/net/types" + gpnet "github.com/yusing/go-proxy/internal/net/types" ) // Setup implements types.Stream. @@ -21,19 +20,19 @@ func (w *Watcher) Setup() error { } // Accept implements types.Stream. -func (w *Watcher) Accept() (conn types.StreamConn, err error) { +func (w *Watcher) Accept() (conn gpnet.StreamConn, err error) { conn, err = w.stream.Accept() if err != nil { return } if wakeErr := w.wakeFromStream(); wakeErr != nil { - w.WakeError(wakeErr) + w.l.Err(wakeErr).Msg("error waking container") } return } // Handle implements types.Stream. -func (w *Watcher) Handle(conn types.StreamConn) error { +func (w *Watcher) Handle(conn gpnet.StreamConn) error { if err := w.wakeFromStream(); err != nil { return err } @@ -53,35 +52,29 @@ func (w *Watcher) wakeFromStream() error { return nil } - w.WakeDebug().Msg("wake signal received") - wakeErr := w.wakeIfStopped() - if wakeErr != nil { - wakeErr = fmt.Errorf("%s failed: %w", w.String(), wakeErr) - w.WakeError(wakeErr) - return wakeErr + w.l.Debug().Msg("wake signal received") + err := w.wakeIfStopped() + if err != nil { + return err } - ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.Config().WakeTimeout, errors.New("wake timeout")) + ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.cfg.WakeTimeout, errors.New("wake timeout")) defer cancel() + var ready bool + for { - select { - case <-w.task.Context().Done(): - cause := w.task.FinishCause() - w.WakeDebug().Str("cause", cause.Error()).Msg("canceled") - return cause - case <-ctx.Done(): - cause := context.Cause(ctx) - w.WakeDebug().Str("cause", cause.Error()).Msg("timeout") - return cause - default: + if w.cancelled(ctx) { + return context.Cause(ctx) } - if ready, err := w.checkUpdateState(); err != nil { + w, ready, err = checkUpdateState(w.Key()) + if err != nil { return err - } else if ready { + } + if ready { w.resetIdleTimer() - w.Debug().Msg("container is ready, passing through to " + w.hc.URL().String()) + w.l.Debug().Stringer("url", w.hc.URL()).Msg("container is ready, passing through") return nil } diff --git a/internal/idlewatcher/health.go b/internal/idlewatcher/health.go new file mode 100644 index 0000000..ec305f7 --- /dev/null +++ b/internal/idlewatcher/health.go @@ -0,0 +1,122 @@ +package idlewatcher + +import ( + "errors" + "time" + + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/watcher/health" +) + +// Start implements health.HealthMonitor. +func (w *Watcher) Start(parent task.Parent) gperr.Error { + w.task.OnCancel("route_cleanup", func() { + parent.Finish(w.task.FinishCause()) + }) + return nil +} + +// Task implements health.HealthMonitor. +func (w *Watcher) Task() *task.Task { + return w.task +} + +// Finish implements health.HealthMonitor. +func (w *Watcher) Finish(reason any) { + if w.stream != nil { + w.stream.Close() + } +} + +// Name implements health.HealthMonitor. +func (w *Watcher) Name() string { + return w.cfg.ContainerName() +} + +// String implements health.HealthMonitor. +func (w *Watcher) String() string { + return w.Name() +} + +// Uptime implements health.HealthMonitor. +func (w *Watcher) Uptime() time.Duration { + return 0 +} + +// Latency implements health.HealthMonitor. +func (w *Watcher) Latency() time.Duration { + return 0 +} + +// Status implements health.HealthMonitor. +func (w *Watcher) Status() health.Status { + state := w.state.Load() + if state.err != nil { + return health.StatusError + } + if state.ready { + return health.StatusHealthy + } + if state.status == idlewatcher.ContainerStatusRunning { + return health.StatusStarting + } + return health.StatusNapping +} + +func checkUpdateState(key string) (w *Watcher, ready bool, err error) { + watcherMapMu.RLock() + w, ok := watcherMap[key] + if !ok { + watcherMapMu.RUnlock() + return nil, false, errors.New("watcher not found") + } + watcherMapMu.RUnlock() + + // already ready + if w.ready() { + return w, true, nil + } + + if !w.running() { + return w, false, nil + } + + // the new container info not yet updated + if w.hc.URL().Host == "" { + return w, false, nil + } + + res, err := w.hc.CheckHealth() + if err != nil { + w.setError(err) + return w, false, err + } + + if res.Healthy { + w.setReady() + return w, true, nil + } + w.setStarting() + return w, false, nil +} + +// MarshalMap implements health.HealthMonitor. +func (w *Watcher) MarshalMap() map[string]any { + url := w.hc.URL() + if url.Port() == "0" { + url = nil + } + var detail string + if err := w.error(); err != nil { + detail = err.Error() + } + return (&health.JSONRepresentation{ + Name: w.Name(), + Status: w.Status(), + Config: dummyHealthCheckConfig, + URL: url, + Detail: detail, + }).MarshalMap() +} diff --git a/internal/docker/idlewatcher/html/loading_page.html b/internal/idlewatcher/html/loading_page.html similarity index 100% rename from internal/docker/idlewatcher/html/loading_page.html rename to internal/idlewatcher/html/loading_page.html diff --git a/internal/docker/idlewatcher/loading_page.go b/internal/idlewatcher/loading_page.go similarity index 90% rename from internal/docker/idlewatcher/loading_page.go rename to internal/idlewatcher/loading_page.go index 3ece21e..7ddf6c0 100644 --- a/internal/docker/idlewatcher/loading_page.go +++ b/internal/idlewatcher/loading_page.go @@ -19,11 +19,11 @@ var loadingPage []byte var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage))) func (w *Watcher) makeLoadingPageBody() []byte { - msg := w.ContainerName() + " is starting..." + msg := w.cfg.ContainerName() + " is starting..." data := new(templateData) data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect - data.Title = w.route.HomepageItem().Name + data.Title = w.cfg.ContainerName() data.Message = msg buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect))) diff --git a/internal/idlewatcher/provider/docker.go b/internal/idlewatcher/provider/docker.go new file mode 100644 index 0000000..cd0b39a --- /dev/null +++ b/internal/idlewatcher/provider/docker.go @@ -0,0 +1,90 @@ +package provider + +import ( + "context" + + "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/internal/docker" + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/watcher" +) + +type DockerProvider struct { + client *docker.SharedClient + watcher *watcher.DockerWatcher + containerID string +} + +var startOptions = container.StartOptions{} + +func NewDockerProvider(dockerHost, containerID string) (idlewatcher.Provider, error) { + client, err := docker.NewClient(dockerHost) + if err != nil { + return nil, err + } + return &DockerProvider{ + client: client, + watcher: watcher.NewDockerWatcher(dockerHost), + containerID: containerID, + }, nil +} + +func (p *DockerProvider) ContainerPause(ctx context.Context) error { + return p.client.ContainerPause(ctx, p.containerID) +} + +func (p *DockerProvider) ContainerUnpause(ctx context.Context) error { + return p.client.ContainerUnpause(ctx, p.containerID) +} + +func (p *DockerProvider) ContainerStart(ctx context.Context) error { + return p.client.ContainerStart(ctx, p.containerID, startOptions) +} + +func (p *DockerProvider) ContainerStop(ctx context.Context, signal idlewatcher.Signal, timeout int) error { + return p.client.ContainerStop(ctx, p.containerID, container.StopOptions{ + Signal: string(signal), + Timeout: &timeout, + }) +} + +func (p *DockerProvider) ContainerKill(ctx context.Context, signal idlewatcher.Signal) error { + return p.client.ContainerKill(ctx, p.containerID, string(signal)) +} + +func (p *DockerProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) { + status, err := p.client.ContainerInspect(ctx, p.containerID) + if err != nil { + return idlewatcher.ContainerStatusError, err + } + switch status.State.Status { + case "running": + return idlewatcher.ContainerStatusRunning, nil + case "exited", "dead", "restarting": + return idlewatcher.ContainerStatusStopped, nil + case "paused": + return idlewatcher.ContainerStatusPaused, nil + } + return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(status.State.Status) +} + +func (p *DockerProvider) Watch(ctx context.Context) (eventCh <-chan watcher.Event, errCh <-chan gperr.Error) { + return p.watcher.EventsWithOptions(ctx, watcher.DockerListOptions{ + Filters: watcher.NewDockerFilter( + watcher.DockerFilterContainer, + watcher.DockerFilterContainerNameID(p.containerID), + watcher.DockerFilterStart, + watcher.DockerFilterStop, + watcher.DockerFilterDie, + watcher.DockerFilterKill, + watcher.DockerFilterDestroy, + watcher.DockerFilterPause, + watcher.DockerFilterUnpause, + ), + }) +} + +func (p *DockerProvider) Close() { + p.client.Close() +} diff --git a/internal/idlewatcher/provider/proxmox.go b/internal/idlewatcher/provider/proxmox.go new file mode 100644 index 0000000..7cbb02d --- /dev/null +++ b/internal/idlewatcher/provider/proxmox.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "strconv" + "time" + + "github.com/yusing/go-proxy/internal/gperr" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/proxmox" + "github.com/yusing/go-proxy/internal/watcher" + "github.com/yusing/go-proxy/internal/watcher/events" +) + +type ProxmoxProvider struct { + *proxmox.Node + vmid int + lxcName string + running bool +} + +const proxmoxStateCheckInterval = 1 * time.Second + +var ErrNodeNotFound = gperr.New("node not found in pool") + +func NewProxmoxProvider(nodeName string, vmid int) (idlewatcher.Provider, error) { + node, ok := proxmox.Nodes.Get(nodeName) + if !ok { + return nil, ErrNodeNotFound.Subject(nodeName). + Withf("available nodes: %s", proxmox.AvailableNodeNames()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + lxcName, err := node.LXCName(ctx, vmid) + if err != nil { + return nil, err + } + return &ProxmoxProvider{Node: node, vmid: vmid, lxcName: lxcName}, nil +} + +func (p *ProxmoxProvider) ContainerPause(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCSuspend) +} + +func (p *ProxmoxProvider) ContainerUnpause(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCResume) +} + +func (p *ProxmoxProvider) ContainerStart(ctx context.Context) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCStart) +} + +func (p *ProxmoxProvider) ContainerStop(ctx context.Context, _ idlewatcher.Signal, _ int) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) +} + +func (p *ProxmoxProvider) ContainerKill(ctx context.Context, _ idlewatcher.Signal) error { + return p.LXCAction(ctx, p.vmid, proxmox.LXCShutdown) +} + +func (p *ProxmoxProvider) ContainerStatus(ctx context.Context) (idlewatcher.ContainerStatus, error) { + status, err := p.LXCStatus(ctx, p.vmid) + if err != nil { + return idlewatcher.ContainerStatusError, err + } + switch status { + case proxmox.LXCStatusRunning: + return idlewatcher.ContainerStatusRunning, nil + case proxmox.LXCStatusStopped: + return idlewatcher.ContainerStatusStopped, nil + } + return idlewatcher.ContainerStatusError, idlewatcher.ErrUnexpectedContainerStatus.Subject(string(status)) +} + +func (p *ProxmoxProvider) Watch(ctx context.Context) (<-chan watcher.Event, <-chan gperr.Error) { + eventCh := make(chan watcher.Event) + errCh := make(chan gperr.Error) + + go func() { + defer close(eventCh) + defer close(errCh) + + var err error + p.running, err = p.LXCIsRunning(ctx, p.vmid) + if err != nil { + errCh <- gperr.Wrap(err) + return + } + + ticker := time.NewTicker(proxmoxStateCheckInterval) + defer ticker.Stop() + + event := watcher.Event{ + Type: events.EventTypeDocker, + ActorID: strconv.Itoa(p.vmid), + ActorName: p.lxcName, + } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + status, err := p.ContainerStatus(ctx) + if err != nil { + errCh <- gperr.Wrap(err) + return + } + running := status == idlewatcher.ContainerStatusRunning + if p.running != running { + p.running = running + if running { + event.Action = events.ActionContainerStart + } else { + event.Action = events.ActionContainerStop + } + eventCh <- event + } + } + } + }() + + return eventCh, errCh +} + +func (p *ProxmoxProvider) Close() { + // noop +} diff --git a/internal/idlewatcher/state.go b/internal/idlewatcher/state.go new file mode 100644 index 0000000..7db2d79 --- /dev/null +++ b/internal/idlewatcher/state.go @@ -0,0 +1,44 @@ +package idlewatcher + +import idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + +func (w *Watcher) running() bool { + return w.state.Load().status == idlewatcher.ContainerStatusRunning +} + +func (w *Watcher) ready() bool { + return w.state.Load().ready +} + +func (w *Watcher) error() error { + return w.state.Load().err +} + +func (w *Watcher) setReady() { + w.state.Store(&containerState{ + status: idlewatcher.ContainerStatusRunning, + ready: true, + }) +} + +func (w *Watcher) setStarting() { + w.state.Store(&containerState{ + status: idlewatcher.ContainerStatusRunning, + ready: false, + }) +} + +func (w *Watcher) setNapping(status idlewatcher.ContainerStatus) { + w.state.Store(&containerState{ + status: status, + ready: false, + }) +} + +func (w *Watcher) setError(err error) { + w.state.Store(&containerState{ + status: idlewatcher.ContainerStatusError, + ready: false, + err: err, + }) +} diff --git a/internal/idlewatcher/types/config.go b/internal/idlewatcher/types/config.go new file mode 100644 index 0000000..27d3c82 --- /dev/null +++ b/internal/idlewatcher/types/config.go @@ -0,0 +1,128 @@ +package idlewatcher + +import ( + "net/url" + "strconv" + "strings" + "time" + + "github.com/yusing/go-proxy/internal/gperr" +) + +type ( + Config struct { + Proxmox *ProxmoxConfig `json:"proxmox,omitempty"` + Docker *DockerConfig `json:"docker,omitempty"` + + IdleTimeout time.Duration `json:"idle_timeout" json_ext:"duration"` + WakeTimeout time.Duration `json:"wake_timeout" json_ext:"duration"` + StopTimeout time.Duration `json:"stop_timeout" json_ext:"duration"` + StopMethod StopMethod `json:"stop_method"` + StopSignal Signal `json:"stop_signal,omitempty"` + StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container + } + StopMethod string + Signal string + + DockerConfig struct { + DockerHost string `json:"docker_host" validate:"required"` + ContainerID string `json:"container_id" validate:"required"` + ContainerName string `json:"container_name" validate:"required"` + } + ProxmoxConfig struct { + Node string `json:"node" validate:"required"` + VMID int `json:"vmid" validate:"required"` + } +) + +const ( + WakeTimeoutDefault = 30 * time.Second + StopTimeoutDefault = 1 * time.Minute + + StopMethodPause StopMethod = "pause" + StopMethodStop StopMethod = "stop" + StopMethodKill StopMethod = "kill" +) + +func (c *Config) Key() string { + if c.Docker != nil { + return c.Docker.ContainerID + } + return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID) +} + +func (c *Config) ContainerName() string { + if c.Docker != nil { + return c.Docker.ContainerName + } + return "lxc " + strconv.Itoa(c.Proxmox.VMID) +} + +func (c *Config) Validate() gperr.Error { + if c.IdleTimeout == 0 { // no idle timeout means no idle watcher + return nil + } + errs := gperr.NewBuilder("idlewatcher config validation error") + errs.AddRange( + c.validateProvider(), + c.validateTimeouts(), + c.validateStopMethod(), + c.validateStopSignal(), + c.validateStartEndpoint(), + ) + return errs.Error() +} + +func (c *Config) validateProvider() error { + if c.Docker == nil && c.Proxmox == nil { + return gperr.New("missing idlewatcher provider config") + } + return nil +} + +func (c *Config) validateTimeouts() error { + if c.WakeTimeout == 0 { + c.WakeTimeout = WakeTimeoutDefault + } + if c.StopTimeout == 0 { + c.StopTimeout = StopTimeoutDefault + } + return nil +} + +func (c *Config) validateStopMethod() error { + switch c.StopMethod { + case "": + c.StopMethod = StopMethodStop + return nil + case StopMethodPause, StopMethodStop, StopMethodKill: + return nil + default: + return gperr.New("invalid stop method").Subject(string(c.StopMethod)) + } +} + +func (c *Config) validateStopSignal() error { + switch c.StopSignal { + case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP": + return nil + default: + return gperr.New("invalid stop signal").Subject(string(c.StopSignal)) + } +} + +func (c *Config) validateStartEndpoint() error { + if c.StartEndpoint == "" { + return nil + } + // checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195 + // emulate browser and strip the '#' suffix prior to validation. see issue-#237 + if i := strings.Index(c.StartEndpoint, "#"); i > -1 { + c.StartEndpoint = c.StartEndpoint[:i] + } + if len(c.StartEndpoint) == 0 { + return gperr.New("start endpoint must not be empty if defined") + } + _, err := url.ParseRequestURI(c.StartEndpoint) + return err +} diff --git a/internal/docker/idlewatcher/types/config_test.go b/internal/idlewatcher/types/config_test.go similarity index 75% rename from internal/docker/idlewatcher/types/config_test.go rename to internal/idlewatcher/types/config_test.go index 730c0c7..b9f3861 100644 --- a/internal/docker/idlewatcher/types/config_test.go +++ b/internal/idlewatcher/types/config_test.go @@ -1,9 +1,9 @@ -package types +package idlewatcher import ( "testing" - . "github.com/yusing/go-proxy/internal/utils/testing" + expect "github.com/yusing/go-proxy/internal/utils/testing" ) func TestValidateStartEndpoint(t *testing.T) { @@ -35,9 +35,10 @@ func TestValidateStartEndpoint(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - s, err := validateStartEndpoint(tc.input) + cfg := Config{StartEndpoint: tc.input} + err := cfg.validateStartEndpoint() if err == nil { - ExpectEqual(t, s, tc.input) + expect.Equal(t, cfg.StartEndpoint, tc.input) } if (err != nil) != tc.wantErr { t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr) diff --git a/internal/idlewatcher/types/container_status.go b/internal/idlewatcher/types/container_status.go new file mode 100644 index 0000000..0418255 --- /dev/null +++ b/internal/idlewatcher/types/container_status.go @@ -0,0 +1,14 @@ +package idlewatcher + +import "github.com/yusing/go-proxy/internal/gperr" + +type ContainerStatus string + +const ( + ContainerStatusError ContainerStatus = "error" + ContainerStatusRunning ContainerStatus = "running" + ContainerStatusPaused ContainerStatus = "paused" + ContainerStatusStopped ContainerStatus = "stopped" +) + +var ErrUnexpectedContainerStatus = gperr.New("unexpected container status") diff --git a/internal/idlewatcher/types/provider.go b/internal/idlewatcher/types/provider.go new file mode 100644 index 0000000..0df599f --- /dev/null +++ b/internal/idlewatcher/types/provider.go @@ -0,0 +1,19 @@ +package idlewatcher + +import ( + "context" + + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/watcher/events" +) + +type Provider interface { + ContainerPause(ctx context.Context) error + ContainerUnpause(ctx context.Context) error + ContainerStart(ctx context.Context) error + ContainerStop(ctx context.Context, signal Signal, timeout int) error + ContainerKill(ctx context.Context, signal Signal) error + ContainerStatus(ctx context.Context) (ContainerStatus, error) + Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error) + Close() +} diff --git a/internal/docker/idlewatcher/types/waker.go b/internal/idlewatcher/types/waker.go similarity index 91% rename from internal/docker/idlewatcher/types/waker.go rename to internal/idlewatcher/types/waker.go index 0048851..b06d5e1 100644 --- a/internal/docker/idlewatcher/types/waker.go +++ b/internal/idlewatcher/types/waker.go @@ -1,4 +1,4 @@ -package types +package idlewatcher import ( "net/http" diff --git a/internal/idlewatcher/watcher.go b/internal/idlewatcher/watcher.go new file mode 100644 index 0000000..c78aa6d --- /dev/null +++ b/internal/idlewatcher/watcher.go @@ -0,0 +1,310 @@ +package idlewatcher + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/idlewatcher/provider" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" + net "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/route/routes" + "github.com/yusing/go-proxy/internal/task" + U "github.com/yusing/go-proxy/internal/utils" + "github.com/yusing/go-proxy/internal/utils/atomic" + "github.com/yusing/go-proxy/internal/watcher/events" + "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/watcher/health/monitor" +) + +type ( + routeHelper struct { + rp *reverseproxy.ReverseProxy + stream net.Stream + hc health.HealthChecker + } + + containerState struct { + status idlewatcher.ContainerStatus + ready bool + err error + } + + Watcher struct { + _ U.NoCopy + routeHelper + + l zerolog.Logger + + cfg *idlewatcher.Config + + provider idlewatcher.Provider + + state atomic.Value[*containerState] + lastReset atomic.Value[time.Time] + + ticker *time.Ticker + task *task.Task + } + + StopCallback func() error +) + +const ContextKey = "idlewatcher.watcher" + +var ( + watcherMap = make(map[string]*Watcher) + watcherMapMu sync.RWMutex +) + +const ( + idleWakerCheckInterval = 100 * time.Millisecond + idleWakerCheckTimeout = time.Second +) + +var dummyHealthCheckConfig = &health.HealthCheckConfig{ + Interval: idleWakerCheckInterval, + Timeout: idleWakerCheckTimeout, +} + +var ( + causeReload = gperr.New("reloaded") + causeContainerDestroy = gperr.New("container destroyed") +) + +const reqTimeout = 3 * time.Second + +// TODO: fix stream type +func NewWatcher(parent task.Parent, r routes.Route) (*Watcher, error) { + cfg := r.IdlewatcherConfig() + key := cfg.Key() + + watcherMapMu.RLock() + // if the watcher already exists, finish it + w, exists := watcherMap[key] + if exists { + if w.cfg == cfg { + // same address, likely two routes from the same container + return w, nil + } + w.task.Finish(causeReload) + } + watcherMapMu.RUnlock() + + w = &Watcher{ + ticker: time.NewTicker(cfg.IdleTimeout), + cfg: cfg, + routeHelper: routeHelper{ + hc: monitor.NewMonitor(r), + }, + } + + var p idlewatcher.Provider + var providerType string + var err error + switch { + case cfg.Docker != nil: + p, err = provider.NewDockerProvider(cfg.Docker.DockerHost, cfg.Docker.ContainerID) + providerType = "docker" + default: + p, err = provider.NewProxmoxProvider(cfg.Proxmox.Node, cfg.Proxmox.VMID) + providerType = "proxmox" + } + + if err != nil { + return nil, err + } + w.provider = p + w.l = logging.With(). + Str("provider", providerType). + Str("container", cfg.ContainerName()). + Logger() + + switch r := r.(type) { + case routes.ReverseProxyRoute: + w.rp = r.ReverseProxy() + case routes.StreamRoute: + w.stream = r + default: + return nil, gperr.New("unexpected route type") + } + + ctx, cancel := context.WithTimeout(parent.Context(), reqTimeout) + defer cancel() + status, err := w.provider.ContainerStatus(ctx) + if err != nil { + w.provider.Close() + return nil, gperr.Wrap(err, "failed to get container status") + } + + switch p := w.provider.(type) { + case *provider.ProxmoxProvider: + shutdownTimeout := max(time.Second, cfg.StopTimeout-idleWakerCheckTimeout) + err = p.LXCSetShutdownTimeout(ctx, cfg.Proxmox.VMID, shutdownTimeout) + if err != nil { + w.l.Warn().Err(err).Msg("failed to set shutdown timeout") + } + } + + w.state.Store(&containerState{status: status}) + + w.task = parent.Subtask("idlewatcher."+r.Name(), true) + + watcherMapMu.Lock() + defer watcherMapMu.Unlock() + watcherMap[key] = w + go func() { + cause := w.watchUntilDestroy() + if cause.Is(causeContainerDestroy) || cause.Is(task.ErrProgramExiting) { + watcherMapMu.Lock() + defer watcherMapMu.Unlock() + delete(watcherMap, key) + w.l.Info().Msg("idlewatcher stopped") + } else if !cause.Is(causeReload) { + gperr.LogError("idlewatcher stopped unexpectedly", cause, &w.l) + } + + w.ticker.Stop() + w.provider.Close() + w.task.Finish(cause) + }() + if exists { + w.l.Info().Msg("idlewatcher reloaded") + } else { + w.l.Info().Msg("idlewatcher started") + } + return w, nil +} + +func (w *Watcher) Key() string { + return w.cfg.Key() +} + +func (w *Watcher) Wake() error { + return w.wakeIfStopped() +} + +func (w *Watcher) wakeIfStopped() error { + state := w.state.Load() + if state.status == idlewatcher.ContainerStatusRunning { + w.l.Debug().Msg("container is already running") + return nil + } + + ctx, cancel := context.WithTimeout(w.task.Context(), w.cfg.WakeTimeout) + defer cancel() + switch state.status { + case idlewatcher.ContainerStatusStopped: + w.l.Info().Msg("starting container") + return w.provider.ContainerStart(ctx) + case idlewatcher.ContainerStatusPaused: + w.l.Info().Msg("unpausing container") + return w.provider.ContainerUnpause(ctx) + default: + return gperr.Errorf("unexpected container status: %s", state.status) + } +} + +func (w *Watcher) stopByMethod() error { + if !w.running() { + return nil + } + + cfg := w.cfg + ctx, cancel := context.WithTimeout(w.task.Context(), cfg.StopTimeout) + defer cancel() + + switch cfg.StopMethod { + case idlewatcher.StopMethodPause: + return w.provider.ContainerPause(ctx) + case idlewatcher.StopMethodStop: + return w.provider.ContainerStop(ctx, cfg.StopSignal, int(cfg.StopTimeout.Seconds())) + case idlewatcher.StopMethodKill: + return w.provider.ContainerKill(ctx, cfg.StopSignal) + default: + return gperr.Errorf("unexpected stop method: %q", cfg.StopMethod) + } +} + +func (w *Watcher) resetIdleTimer() { + w.ticker.Reset(w.cfg.IdleTimeout) + w.lastReset.Store(time.Now()) +} + +func (w *Watcher) expires() time.Time { + if !w.running() { + return time.Time{} + } + return w.lastReset.Load().Add(w.cfg.IdleTimeout) +} + +// watchUntilDestroy waits for the container to be created, started, or unpaused, +// and then reset the idle timer. +// +// When the container is stopped, paused, +// or killed, the idle timer is stopped and the ContainerRunning flag is set to false. +// +// When the idle timer fires, the container is stopped according to the +// stop method. +// +// it exits only if the context is canceled, the container is destroyed, +// errors occurred on docker client, or route provider died (mainly caused by config reload). +func (w *Watcher) watchUntilDestroy() (returnCause gperr.Error) { + eventCh, errCh := w.provider.Watch(w.Task().Context()) + + for { + select { + case <-w.task.Context().Done(): + return gperr.Wrap(w.task.FinishCause()) + case err := <-errCh: + return err + case e := <-eventCh: + w.l.Debug().Stringer("action", e.Action).Msg("state changed") + if e.Action == events.ActionContainerDestroy { + return causeContainerDestroy + } + w.resetIdleTimer() + switch { + case e.Action.IsContainerStart(): // create / start / unpause + w.setStarting() + w.l.Info().Msg("awaken") + case e.Action.IsContainerStop(): // stop / kill / die + w.setNapping(idlewatcher.ContainerStatusStopped) + w.ticker.Stop() + case e.Action.IsContainerPause(): // pause + w.setNapping(idlewatcher.ContainerStatusPaused) + w.ticker.Stop() + default: + w.l.Error().Stringer("action", e.Action).Msg("unexpected container action") + } + case <-w.ticker.C: + w.ticker.Stop() + if w.running() { + err := w.stopByMethod() + switch { + case errors.Is(err, context.Canceled): + continue + case err != nil: + if errors.Is(err, context.DeadlineExceeded) { + err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`") + } + w.l.Err(err).Msgf("container stop with method %q failed", w.cfg.StopMethod) + default: + w.l.Info().Str("reason", "idle timeout").Msg("container stopped") + } + } + } + } +} + +func fmtErr(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/internal/jsonstore/jsonstore.go b/internal/jsonstore/jsonstore.go index db08414..e04fbc3 100644 --- a/internal/jsonstore/jsonstore.go +++ b/internal/jsonstore/jsonstore.go @@ -59,7 +59,7 @@ func save() error { defer stores.Unlock() errs := gperr.NewBuilder("failed to save data stores") for ns, store := range stores.m { - if err := utils.SaveJSON(filepath.Join(common.DataDir, string(ns)+".json"), &store, 0o644); err != nil { + if err := utils.SaveJSON(filepath.Join(storesPath, string(ns)+".json"), &store, 0o644); err != nil { errs.Add(err) } } diff --git a/internal/jsonstore/jsonstore_test.go b/internal/jsonstore/jsonstore_test.go index 5fe28a2..c2ffbb1 100644 --- a/internal/jsonstore/jsonstore_test.go +++ b/internal/jsonstore/jsonstore_test.go @@ -1,7 +1,6 @@ package jsonstore import ( - "path/filepath" "testing" ) @@ -14,8 +13,7 @@ func TestNewJSON(t *testing.T) { } func TestSaveLoad(t *testing.T) { - tmpDir := t.TempDir() - storesPath = filepath.Join(tmpDir, "data.json") + storesPath = t.TempDir() store := Store[string]("test") store.Store("a", "1") if err := save(); err != nil { diff --git a/internal/metrics/uptime/uptime.go b/internal/metrics/uptime/uptime.go index 3162e3d..a8f026e 100644 --- a/internal/metrics/uptime/uptime.go +++ b/internal/metrics/uptime/uptime.go @@ -11,14 +11,13 @@ import ( "github.com/yusing/go-proxy/internal/metrics/period" metricsutils "github.com/yusing/go-proxy/internal/metrics/utils" "github.com/yusing/go-proxy/internal/route/routes" - "github.com/yusing/go-proxy/internal/route/routes/routequery" "github.com/yusing/go-proxy/internal/watcher/health" ) type ( StatusByAlias struct { - Map map[string]*routequery.HealthInfoRaw `json:"statuses"` - Timestamp int64 `json:"timestamp"` + Map map[string]*routes.HealthInfoRaw `json:"statuses"` + Timestamp int64 `json:"timestamp"` } Status struct { Status health.Status `json:"status"` @@ -33,7 +32,7 @@ var Poller = period.NewPoller("uptime", getStatuses, aggregateStatuses) func getStatuses(ctx context.Context, _ *StatusByAlias) (*StatusByAlias, error) { return &StatusByAlias{ - Map: routequery.HealthInfo(), + Map: routes.HealthInfo(), Timestamp: time.Now().Unix(), }, nil } @@ -117,7 +116,7 @@ func (rs RouteStatuses) aggregate(limit int, offset int) Aggregated { "avg_latency": latency, "statuses": statuses, } - r, ok := routes.GetRoute(alias) + r, ok := routes.Get(alias) if ok { result[i]["display_name"] = r.HomepageConfig().Name } diff --git a/internal/net/gphttp/loadbalancer/loadbalancer.go b/internal/net/gphttp/loadbalancer/loadbalancer.go index 3d474cf..7487b89 100644 --- a/internal/net/gphttp/loadbalancer/loadbalancer.go +++ b/internal/net/gphttp/loadbalancer/loadbalancer.go @@ -1,6 +1,7 @@ package loadbalancer import ( + "fmt" "net/http" "sync" "time" @@ -10,10 +11,9 @@ import ( "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" - "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils/pool" "github.com/yusing/go-proxy/internal/watcher/health" - "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) // TODO: stats of each server. @@ -31,7 +31,7 @@ type ( task *task.Task - pool Pool + pool pool.Pool[Server] poolMu sync.Mutex sumWeight Weight @@ -46,7 +46,7 @@ const maxWeight Weight = 100 func New(cfg *Config) *LoadBalancer { lb := &LoadBalancer{ Config: new(Config), - pool: types.NewServerPool(), + pool: pool.New[Server]("loadbalancer." + cfg.Link), l: logging.With().Str("name", cfg.Link).Logger(), } lb.UpdateConfigIfNeeded(cfg) @@ -56,16 +56,14 @@ func New(cfg *Config) *LoadBalancer { // Start implements task.TaskStarter. func (lb *LoadBalancer) Start(parent task.Parent) gperr.Error { lb.startTime = time.Now() - lb.task = parent.Subtask("loadbalancer."+lb.Link, false) - parent.OnCancel("lb_remove_route", func() { - routes.DeleteHTTPRoute(lb.Link) - }) - lb.task.OnFinished("cleanup", func() { + lb.task = parent.Subtask("loadbalancer."+lb.Link, true) + lb.task.OnCancel("cleanup", func() { if lb.impl != nil { - lb.pool.RangeAll(func(k string, v Server) { - lb.impl.OnRemoveServer(v) - }) + for _, srv := range lb.pool.Iter { + lb.impl.OnRemoveServer(srv) + } } + lb.task.Finish(nil) }) return nil } @@ -91,9 +89,9 @@ func (lb *LoadBalancer) updateImpl() { default: // should happen in test only lb.impl = lb.newRoundRobin() } - lb.pool.RangeAll(func(_ string, srv Server) { + for _, srv := range lb.pool.Iter { lb.impl.OnAddServer(srv) - }) + } } func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) { @@ -125,12 +123,12 @@ func (lb *LoadBalancer) AddServer(srv Server) { lb.poolMu.Lock() defer lb.poolMu.Unlock() - if lb.pool.Has(srv.Key()) { // FIXME: this should be a warning - old, _ := lb.pool.Load(srv.Key()) + if old, ok := lb.pool.Get(srv.Key()); ok { // FIXME: this should be a warning lb.sumWeight -= old.Weight() lb.impl.OnRemoveServer(old) + lb.pool.Del(old) } - lb.pool.Store(srv.Key(), srv) + lb.pool.Add(srv) lb.sumWeight += srv.Weight() lb.rebalance() @@ -146,11 +144,11 @@ func (lb *LoadBalancer) RemoveServer(srv Server) { lb.poolMu.Lock() defer lb.poolMu.Unlock() - if !lb.pool.Has(srv.Key()) { + if _, ok := lb.pool.Get(srv.Key()); !ok { return } - lb.pool.Delete(srv.Key()) + lb.pool.Del(srv) lb.sumWeight -= srv.Weight() lb.rebalance() @@ -179,15 +177,15 @@ func (lb *LoadBalancer) rebalance() { if lb.sumWeight == 0 { // distribute evenly weightEach := maxWeight / Weight(poolSize) remainder := maxWeight % Weight(poolSize) - lb.pool.RangeAll(func(_ string, s Server) { + for _, srv := range lb.pool.Iter { w := weightEach lb.sumWeight += weightEach if remainder > 0 { w++ remainder-- } - s.SetWeight(w) - }) + srv.SetWeight(w) + } return } @@ -195,30 +193,29 @@ func (lb *LoadBalancer) rebalance() { scaleFactor := float64(maxWeight) / float64(lb.sumWeight) lb.sumWeight = 0 - lb.pool.RangeAll(func(_ string, s Server) { - s.SetWeight(Weight(float64(s.Weight()) * scaleFactor)) - lb.sumWeight += s.Weight() - }) + for _, srv := range lb.pool.Iter { + srv.SetWeight(Weight(float64(srv.Weight()) * scaleFactor)) + lb.sumWeight += srv.Weight() + } delta := maxWeight - lb.sumWeight if delta == 0 { return } - lb.pool.Range(func(_ string, s Server) bool { + for _, srv := range lb.pool.Iter { if delta == 0 { - return false + break } if delta > 0 { - s.SetWeight(s.Weight() + 1) + srv.SetWeight(srv.Weight() + 1) lb.sumWeight++ delta-- } else { - s.SetWeight(s.Weight() - 1) + srv.SetWeight(srv.Weight() - 1) lb.sumWeight-- delta++ } - return true - }) + } } func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { @@ -240,23 +237,26 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { lb.impl.ServeHTTP(srvs, rw, r) } -// MarshalJSON implements health.HealthMonitor. -func (lb *LoadBalancer) MarshalJSON() ([]byte, error) { +// MarshalMap implements health.HealthMonitor. +func (lb *LoadBalancer) MarshalMap() map[string]any { extra := make(map[string]any) - lb.pool.RangeAll(func(k string, v Server) { - extra[v.Key()] = v - }) + for _, srv := range lb.pool.Iter { + extra[srv.Key()] = srv + } - return (&monitor.JSONRepresentation{ + status, numHealthy := lb.status() + + return (&health.JSONRepresentation{ Name: lb.Name(), - Status: lb.Status(), + Status: status, + Detail: fmt.Sprintf("%d/%d servers are healthy", numHealthy, lb.pool.Size()), Started: lb.startTime, Uptime: lb.Uptime(), Extra: map[string]any{ "config": lb.Config, "pool": extra, }, - }).MarshalJSON() + }).MarshalMap() } // Name implements health.HealthMonitor. @@ -266,22 +266,26 @@ func (lb *LoadBalancer) Name() string { // Status implements health.HealthMonitor. func (lb *LoadBalancer) Status() health.Status { + status, _ := lb.status() + return status +} + +func (lb *LoadBalancer) status() (status health.Status, numHealthy int) { if lb.pool.Size() == 0 { - return health.StatusUnknown + return health.StatusUnknown, 0 } - isHealthy := true - lb.pool.Range(func(_ string, srv Server) bool { - if srv.Status().Bad() { - isHealthy = false - return false + // should be healthy if at least one server is healthy + numHealthy = 0 + for _, srv := range lb.pool.Iter { + if srv.Status().Good() { + numHealthy++ } - return true - }) - if !isHealthy { - return health.StatusUnhealthy } - return health.StatusHealthy + if numHealthy == 0 { + return health.StatusUnhealthy, numHealthy + } + return health.StatusHealthy, numHealthy } // Uptime implements health.HealthMonitor. @@ -292,9 +296,9 @@ func (lb *LoadBalancer) Uptime() time.Duration { // Latency implements health.HealthMonitor. func (lb *LoadBalancer) Latency() time.Duration { var sum time.Duration - lb.pool.RangeAll(func(_ string, srv Server) { + for _, srv := range lb.pool.Iter { sum += srv.Latency() - }) + } return sum } @@ -305,10 +309,10 @@ func (lb *LoadBalancer) String() string { func (lb *LoadBalancer) availServers() []Server { avail := make([]Server, 0, lb.pool.Size()) - lb.pool.RangeAll(func(_ string, srv Server) { + for _, srv := range lb.pool.Iter { if srv.Status().Good() { avail = append(avail, srv) } - }) + } return avail } diff --git a/internal/net/gphttp/loadbalancer/types.go b/internal/net/gphttp/loadbalancer/types.go index 2a83a8f..0322a09 100644 --- a/internal/net/gphttp/loadbalancer/types.go +++ b/internal/net/gphttp/loadbalancer/types.go @@ -7,7 +7,6 @@ import ( type ( Server = types.Server Servers = []types.Server - Pool = types.Pool Weight = types.Weight Config = types.Config Mode = types.Mode diff --git a/internal/net/gphttp/loadbalancer/types/server.go b/internal/net/gphttp/loadbalancer/types/server.go index 15abc0a..6d4c529 100644 --- a/internal/net/gphttp/loadbalancer/types/server.go +++ b/internal/net/gphttp/loadbalancer/types/server.go @@ -3,10 +3,9 @@ package types import ( "net/http" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" net "github.com/yusing/go-proxy/internal/net/types" U "github.com/yusing/go-proxy/internal/utils" - F "github.com/yusing/go-proxy/internal/utils/functional" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -32,12 +31,8 @@ type ( SetWeight(weight Weight) TryWake() error } - - Pool = F.Map[string, Server] ) -var NewServerPool = F.NewMap[Pool] - func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { srv := &server{ name: name, diff --git a/internal/net/tcp.go b/internal/net/tcp.go new file mode 100644 index 0000000..15b3374 --- /dev/null +++ b/internal/net/tcp.go @@ -0,0 +1,18 @@ +package netutils + +import ( + "context" + "fmt" + "net" +) + +// PingTCP "pings" the IP address using TCP. +func PingTCP(ctx context.Context, ip net.IP, port int) error { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", ip, port)) + if err != nil { + return err + } + conn.Close() + return nil +} diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go new file mode 100644 index 0000000..d218e01 --- /dev/null +++ b/internal/proxmox/client.go @@ -0,0 +1,68 @@ +package proxmox + +import ( + "context" + "fmt" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/utils/pool" +) + +type Client struct { + *proxmox.Client + proxmox.Cluster + Version *proxmox.Version +} + +var Clients = pool.New[*Client]("proxmox_clients") + +func NewClient(baseUrl string, opts ...proxmox.Option) *Client { + return &Client{Client: proxmox.NewClient(baseUrl, opts...)} +} + +func (c *Client) UpdateClusterInfo(ctx context.Context) (err error) { + c.Version, err = c.Client.Version(ctx) + if err != nil { + return err + } + // requires (/, Sys.Audit) + if err := c.Get(ctx, "/cluster/status", &c.Cluster); err != nil { + return err + } + for _, node := range c.Cluster.Nodes { + Nodes.Add(&Node{name: node.Name, id: node.ID, client: c.Client}) + } + return nil +} + +// Key implements pool.Object +func (c *Client) Key() string { + return c.Cluster.ID +} + +// Name implements pool.Object +func (c *Client) Name() string { + return c.Cluster.Name +} + +// MarshalMap implements pool.Object +func (c *Client) MarshalMap() map[string]any { + return map[string]any{ + "version": c.Version, + "cluster": map[string]any{ + "name": c.Cluster.Name, + "id": c.Cluster.ID, + "version": c.Cluster.Version, + "nodes": c.Cluster.Nodes, + "quorate": c.Cluster.Quorate, + }, + } +} + +func (c *Client) NumNodes() int { + return len(c.Cluster.Nodes) +} + +func (c *Client) String() string { + return fmt.Sprintf("%s (%s)", c.Cluster.Name, c.Cluster.ID) +} diff --git a/internal/proxmox/config.go b/internal/proxmox/config.go new file mode 100644 index 0000000..9d8f884 --- /dev/null +++ b/internal/proxmox/config.go @@ -0,0 +1,69 @@ +package proxmox + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + "strings" + "time" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/net/gphttp" +) + +type Config struct { + URL string `json:"url" validate:"required,url"` + + TokenID string `json:"token_id" validate:"required"` + Secret string `json:"secret" validate:"required"` + + NoTLSVerify bool `json:"no_tls_verify" yaml:"no_tls_verify,omitempty"` + + client *Client +} + +func (c *Config) Client() *Client { + if c.client == nil { + panic("proxmox client accessed before init") + } + return c.client +} + +func (c *Config) Init() gperr.Error { + var tr *http.Transport + if c.NoTLSVerify { + tr = gphttp.NewTransportWithTLSConfig(&tls.Config{ + InsecureSkipVerify: true, + }) + } else { + tr = gphttp.NewTransport() + } + + if strings.HasSuffix(c.URL, "/") { + c.URL = c.URL[:len(c.URL)-1] + } + if !strings.HasSuffix(c.URL, "/api2/json") { + c.URL += "/api2/json" + } + + opts := []proxmox.Option{ + proxmox.WithAPIToken(c.TokenID, c.Secret), + proxmox.WithHTTPClient(&http.Client{ + Transport: tr, + }), + } + c.client = NewClient(c.URL, opts...) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := c.client.UpdateClusterInfo(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return gperr.New("timeout fetching proxmox cluster info") + } + return gperr.New("failed to fetch proxmox cluster info").With(err) + } + return nil +} diff --git a/internal/proxmox/lxc.go b/internal/proxmox/lxc.go new file mode 100644 index 0000000..fde5b57 --- /dev/null +++ b/internal/proxmox/lxc.go @@ -0,0 +1,236 @@ +package proxmox + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/luthermonson/go-proxmox" +) + +type ( + LXCAction string + LXCStatus string + + statusOnly struct { + Status LXCStatus `json:"status"` + } + nameOnly struct { + Name string `json:"name"` + } +) + +const ( + LXCStart LXCAction = "start" + LXCShutdown LXCAction = "shutdown" + LXCSuspend LXCAction = "suspend" + LXCResume LXCAction = "resume" + LXCReboot LXCAction = "reboot" +) + +const ( + LXCStatusRunning LXCStatus = "running" + LXCStatusStopped LXCStatus = "stopped" + LXCStatusSuspended LXCStatus = "suspended" // placeholder, suspending lxc is experimental and the enum is undocumented +) + +const ( + proxmoxReqTimeout = 3 * time.Second + proxmoxTaskCheckInterval = 300 * time.Millisecond +) + +func (n *Node) LXCAction(ctx context.Context, vmid int, action LXCAction) error { + var upid proxmox.UPID + if err := n.client.Post(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", n.name, vmid, action), nil, &upid); err != nil { + return err + } + + task := proxmox.NewTask(upid, n.client) + checkTicker := time.NewTicker(proxmoxTaskCheckInterval) + defer checkTicker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-checkTicker.C: + if err := task.Ping(ctx); err != nil { + return err + } + if task.Status != proxmox.TaskRunning { + status, err := n.LXCStatus(ctx, vmid) + if err != nil { + return err + } + switch status { + case LXCStatusRunning: + if action == LXCStart { + return nil + } + case LXCStatusStopped: + if action == LXCShutdown { + return nil + } + case LXCStatusSuspended: + if action == LXCSuspend { + return nil + } + } + } + } + } +} + +func (n *Node) LXCName(ctx context.Context, vmid int) (string, error) { + var name nameOnly + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &name); err != nil { + return "", err + } + return name.Name, nil +} + +func (n *Node) LXCStatus(ctx context.Context, vmid int) (LXCStatus, error) { + var status statusOnly + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", n.name, vmid), &status); err != nil { + return "", err + } + return status.Status, nil +} + +func (n *Node) LXCIsRunning(ctx context.Context, vmid int) (bool, error) { + status, err := n.LXCStatus(ctx, vmid) + return status == LXCStatusRunning, err +} + +func (n *Node) LXCIsStopped(ctx context.Context, vmid int) (bool, error) { + status, err := n.LXCStatus(ctx, vmid) + return status == LXCStatusStopped, err +} + +func (n *Node) LXCSetShutdownTimeout(ctx context.Context, vmid int, timeout time.Duration) error { + return n.client.Put(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), map[string]interface{}{ + "startup": fmt.Sprintf("down=%.0f", timeout.Seconds()), + }, nil) +} + +func parseCIDR(s string) net.IP { + if s == "" { + return nil + } + ip, _, err := net.ParseCIDR(s) + if err != nil { + return nil + } + return checkIPPrivate(ip) +} + +func checkIPPrivate(ip net.IP) net.IP { + if ip == nil { + return nil + } + if ip.IsPrivate() { + return ip + } + return nil +} + +func getIPFromNet(s string) (res []net.IP) { // name:...,bridge:...,gw=..,ip=...,ip6=... + if s == "" { + return nil + } + var i4, i6 net.IP + cidrIndex := strings.Index(s, "ip=") + if cidrIndex != -1 { + cidrIndex += 3 + slash := strings.Index(s[cidrIndex:], "/") + if slash != -1 { + i4 = checkIPPrivate(net.ParseIP(s[cidrIndex : cidrIndex+slash])) + } else { + i4 = checkIPPrivate(net.ParseIP(s[cidrIndex:])) + } + } + cidr6Index := strings.Index(s, "ip6=") + if cidr6Index != -1 { + cidr6Index += 4 + slash := strings.Index(s[cidr6Index:], "/") + if slash != -1 { + i6 = checkIPPrivate(net.ParseIP(s[cidr6Index : cidr6Index+slash])) + } else { + i6 = checkIPPrivate(net.ParseIP(s[cidr6Index:])) + } + } + if i4 != nil { + res = append(res, i4) + } + if i6 != nil { + res = append(res, i6) + } + return res +} + +// LXCGetIPs returns the ip addresses of the container +// it first tries to get the ip addresses from the config +// if that fails, it gets the ip addresses from the interfaces +func (n *Node) LXCGetIPs(ctx context.Context, vmid int) (res []net.IP, err error) { + ips, err := n.LXCGetIPsFromConfig(ctx, vmid) + if err != nil { + return nil, err + } + if len(ips) > 0 { + return ips, nil + } + ips, err = n.LXCGetIPsFromInterfaces(ctx, vmid) + if err != nil { + return nil, err + } + return ips, nil +} + +// LXCGetIPsFromConfig returns the ip addresses of the container from the config +func (n *Node) LXCGetIPsFromConfig(ctx context.Context, vmid int) (res []net.IP, err error) { + type Config struct { + Net0 string `json:"net0"` + Net1 string `json:"net1"` + Net2 string `json:"net2"` + } + var cfg Config + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", n.name, vmid), &cfg); err != nil { + return nil, err + } + + res = append(res, getIPFromNet(cfg.Net0)...) + res = append(res, getIPFromNet(cfg.Net1)...) + res = append(res, getIPFromNet(cfg.Net2)...) + return res, nil +} + +// LXCGetIPsFromInterfaces returns the ip addresses of the container from the interfaces +// it will return nothing if the container is stopped +func (n *Node) LXCGetIPsFromInterfaces(ctx context.Context, vmid int) ([]net.IP, error) { + type Interface struct { + IPv4 string `json:"inet"` + IPv6 string `json:"inet6"` + Name string `json:"name"` + } + var res []Interface + if err := n.client.Get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/interfaces", n.name, vmid), &res); err != nil { + return nil, err + } + ips := make([]net.IP, 0) + for _, ip := range res { + if ip.Name == "lo" || + strings.HasPrefix(ip.Name, "br-") || + strings.HasPrefix(ip.Name, "veth") || + strings.HasPrefix(ip.Name, "docker") { + continue + } + if ip := parseCIDR(ip.IPv4); ip != nil { + ips = append(ips, ip) + } + if ip := parseCIDR(ip.IPv6); ip != nil { + ips = append(ips, ip) + } + } + return ips, nil +} diff --git a/internal/proxmox/lxc_test.go b/internal/proxmox/lxc_test.go new file mode 100644 index 0000000..a9ff13d --- /dev/null +++ b/internal/proxmox/lxc_test.go @@ -0,0 +1,40 @@ +package proxmox + +import ( + "net" + "reflect" + "testing" +) + +func TestGetIPFromNet(t *testing.T) { + testCases := []struct { + name string + input string + want []net.IP + }{ + { + name: "ipv4 only", + input: "name=eth0,bridge=vmbr0,gw=10.0.0.1,hwaddr=BC:24:11:10:88:97,ip=10.0.6.68/16,type=veth", + want: []net.IP{net.ParseIP("10.0.6.68")}, + }, + { + name: "ipv6 only, at the end", + input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48", + want: []net.IP{net.ParseIP("::ffff:a00:644")}, + }, + { + name: "both", + input: "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:10:88:97,gw=::ffff:a00:1,type=veth,ip6=::ffff:a00:644/48,ip=10.0.6.68/16", + want: []net.IP{net.ParseIP("10.0.6.68"), net.ParseIP("::ffff:a00:644")}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := getIPFromNet(tc.input) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("getIPFromNet(%q) = %s, want %s", tc.name, got, tc.want) + } + }) + } +} diff --git a/internal/proxmox/node.go b/internal/proxmox/node.go new file mode 100644 index 0000000..5ecd906 --- /dev/null +++ b/internal/proxmox/node.go @@ -0,0 +1,50 @@ +package proxmox + +import ( + "context" + "fmt" + "strings" + + "github.com/luthermonson/go-proxmox" + "github.com/yusing/go-proxy/internal/utils/pool" +) + +type Node struct { + name string + id string // likely node/ + client *proxmox.Client +} + +var Nodes = pool.New[*Node]("proxmox_nodes") + +func AvailableNodeNames() string { + var sb strings.Builder + for _, node := range Nodes.Iter { + sb.WriteString(node.name) + sb.WriteString(", ") + } + return sb.String()[:sb.Len()-2] +} + +func (n *Node) Key() string { + return n.name +} + +func (n *Node) Name() string { + return n.name +} + +func (n *Node) String() string { + return fmt.Sprintf("%s (%s)", n.name, n.id) +} + +func (n *Node) MarshalMap() map[string]any { + return map[string]any{ + "name": n.name, + "id": n.id, + } +} + +func (n *Node) Get(ctx context.Context, path string, v any) error { + return n.client.Get(ctx, path, v) +} diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go index ed75e9b..e2cf064 100644 --- a/internal/route/fileserver.go +++ b/internal/route/fileserver.go @@ -57,7 +57,7 @@ func NewFileServer(base *Route) (*FileServer, gperr.Error) { // Start implements task.TaskStarter. func (s *FileServer) Start(parent task.Parent) gperr.Error { - s.task = parent.Subtask("fileserver."+s.TargetName(), false) + s.task = parent.Subtask("fileserver."+s.Name(), false) pathPatterns := s.PathPatterns switch { @@ -92,7 +92,7 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { } if common.PrometheusEnabled { - metricsLogger := metricslogger.NewMetricsLogger(s.TargetName()) + metricsLogger := metricslogger.NewMetricsLogger(s.Name()) s.handler = metricsLogger.GetHandler(s.handler) s.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics) } @@ -104,9 +104,9 @@ func (s *FileServer) Start(parent task.Parent) gperr.Error { } } - routes.SetHTTPRoute(s.TargetName(), s) + routes.HTTP.Add(s) s.task.OnCancel("entrypoint_remove_route", func() { - routes.DeleteHTTPRoute(s.TargetName()) + routes.HTTP.Del(s) }) return nil } diff --git a/internal/route/provider/docker_labels_test.go b/internal/route/provider/docker_labels_test.go index d9e400b..1c275c1 100644 --- a/internal/route/provider/docker_labels_test.go +++ b/internal/route/provider/docker_labels_test.go @@ -3,7 +3,7 @@ package provider import ( "testing" - "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/internal/docker" . "github.com/yusing/go-proxy/internal/utils/testing" "gopkg.in/yaml.v3" @@ -21,11 +21,11 @@ func TestParseDockerLabels(t *testing.T) { ExpectNoError(t, yaml.Unmarshal(testDockerLabelsYAML, &labels)) routes, err := provider.routesFromContainerLabels( - docker.FromDocker(&types.Container{ + docker.FromDocker(&container.SummaryTrimmed{ Names: []string{"container"}, Labels: labels, State: "running", - Ports: []types.Port{ + Ports: []container.Port{ {Type: "tcp", PrivatePort: 1234, PublicPort: 1234}, }, }, "/var/run/docker.sock"), diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index 47a9090..309eff1 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -5,13 +5,13 @@ import ( "time" "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" - "github.com/yusing/go-proxy/internal/common" D "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/route" T "github.com/yusing/go-proxy/internal/route/types" . "github.com/yusing/go-proxy/internal/utils/testing" + expect "github.com/yusing/go-proxy/internal/utils/testing" ) var dummyNames = []string{"/a"} @@ -21,7 +21,7 @@ const ( testDockerIP = "172.17.0.123" ) -func makeRoutes(cont *types.Container, dockerHostIP ...string) route.Routes { +func makeRoutes(cont *container.SummaryTrimmed, dockerHostIP ...string) route.Routes { var p DockerProvider var host string if len(dockerHostIP) > 0 { @@ -64,15 +64,15 @@ func TestApplyLabel(t *testing.T) { }, }, } - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a,b", - D.LabelIdleTimeout: "", - D.LabelStopMethod: common.StopMethodDefault, + D.LabelIdleTimeout: "10s", + D.LabelStopMethod: "stop", D.LabelStopSignal: "SIGTERM", - D.LabelStopTimeout: common.StopTimeoutDefault, - D.LabelWakeTimeout: common.WakeTimeoutDefault, + D.LabelStopTimeout: "1h", + D.LabelWakeTimeout: "10s", "proxy.*.no_tls_verify": "true", "proxy.*.scheme": "https", "proxy.*.host": "app", @@ -88,54 +88,55 @@ func TestApplyLabel(t *testing.T) { }) a, ok := entries["a"] - ExpectTrue(t, ok) + expect.True(t, ok) b, ok := entries["b"] - ExpectTrue(t, ok) + expect.True(t, ok) - ExpectEqual(t, a.Scheme, "https") - ExpectEqual(t, b.Scheme, "https") + expect.Equal(t, a.Scheme, "https") + expect.Equal(t, b.Scheme, "https") - ExpectEqual(t, a.Host, "app") - ExpectEqual(t, b.Host, "app") + expect.Equal(t, a.Host, "app") + expect.Equal(t, b.Host, "app") - ExpectEqual(t, a.Port.Proxy, 4567) - ExpectEqual(t, b.Port.Proxy, 4567) + expect.Equal(t, a.Port.Proxy, 4567) + expect.Equal(t, b.Port.Proxy, 4567) - ExpectTrue(t, a.NoTLSVerify) - ExpectTrue(t, b.NoTLSVerify) + expect.True(t, a.NoTLSVerify) + expect.True(t, b.NoTLSVerify) - ExpectEqual(t, a.PathPatterns, pathPatternsExpect) - ExpectEqual(t, len(b.PathPatterns), 0) + expect.Equal(t, a.PathPatterns, pathPatternsExpect) + expect.Equal(t, len(b.PathPatterns), 0) - ExpectEqual(t, a.Middlewares, middlewaresExpect) - ExpectEqual(t, len(b.Middlewares), 0) + expect.Equal(t, a.Middlewares, middlewaresExpect) + expect.Equal(t, len(b.Middlewares), 0) - ExpectEqual(t, a.Container.IdleTimeout, "") - ExpectEqual(t, b.Container.IdleTimeout, "") + expect.NotNil(t, a.Container) + expect.NotNil(t, b.Container) + expect.NotNil(t, a.Container.IdlewatcherConfig) + expect.NotNil(t, b.Container.IdlewatcherConfig) - ExpectEqual(t, a.Container.StopTimeout, common.StopTimeoutDefault) - ExpectEqual(t, b.Container.StopTimeout, common.StopTimeoutDefault) + expect.Equal(t, a.Container.IdlewatcherConfig.IdleTimeout, 10*time.Second) + expect.Equal(t, b.Container.IdlewatcherConfig.IdleTimeout, 10*time.Second) + expect.Equal(t, a.Container.IdlewatcherConfig.StopTimeout, time.Hour) + expect.Equal(t, b.Container.IdlewatcherConfig.StopTimeout, time.Hour) + expect.Equal(t, a.Container.IdlewatcherConfig.StopMethod, "stop") + expect.Equal(t, b.Container.IdlewatcherConfig.StopMethod, "stop") + expect.Equal(t, a.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second) + expect.Equal(t, b.Container.IdlewatcherConfig.WakeTimeout, 10*time.Second) + expect.Equal(t, a.Container.IdlewatcherConfig.StopSignal, "SIGTERM") + expect.Equal(t, b.Container.IdlewatcherConfig.StopSignal, "SIGTERM") - ExpectEqual(t, a.Container.StopMethod, common.StopMethodDefault) - ExpectEqual(t, b.Container.StopMethod, common.StopMethodDefault) + expect.Equal(t, a.Homepage.Show, true) + expect.Equal(t, a.Homepage.Icon.Value, "png/adguard-home.png") + expect.Equal(t, a.Homepage.Icon.Extra.FileType, "png") + expect.Equal(t, a.Homepage.Icon.Extra.Name, "adguard-home") - ExpectEqual(t, a.Container.WakeTimeout, common.WakeTimeoutDefault) - ExpectEqual(t, b.Container.WakeTimeout, common.WakeTimeoutDefault) - - ExpectEqual(t, a.Container.StopSignal, "SIGTERM") - ExpectEqual(t, b.Container.StopSignal, "SIGTERM") - - ExpectEqual(t, a.Homepage.Show, true) - ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png") - ExpectEqual(t, a.Homepage.Icon.Extra.FileType, "png") - ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home") - - ExpectEqual(t, a.HealthCheck.Path, "/ping") - ExpectEqual(t, a.HealthCheck.Interval, 10*time.Second) + expect.Equal(t, a.HealthCheck.Path, "/ping") + expect.Equal(t, a.HealthCheck.Interval, 10*time.Second) } func TestApplyLabelWithAlias(t *testing.T) { - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -162,7 +163,7 @@ func TestApplyLabelWithAlias(t *testing.T) { } func TestApplyLabelWithRef(t *testing.T) { - entries := makeRoutes(&types.Container{ + entries := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -190,7 +191,7 @@ func TestApplyLabelWithRef(t *testing.T) { } func TestApplyLabelWithRefIndexError(t *testing.T) { - c := D.FromDocker(&types.Container{ + c := D.FromDocker(&container.SummaryTrimmed{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -204,7 +205,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { _, err := p.routesFromContainerLabels(c) ExpectError(t, ErrAliasRefIndexOutOfRange, err) - c = D.FromDocker(&types.Container{ + c = D.FromDocker(&container.SummaryTrimmed{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -217,7 +218,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { } func TestDynamicAliases(t *testing.T) { - c := &types.Container{ + c := &container.SummaryTrimmed{ Names: []string{"app1"}, State: "running", Labels: map[string]string{ @@ -240,7 +241,7 @@ func TestDynamicAliases(t *testing.T) { } func TestDisableHealthCheck(t *testing.T) { - c := &types.Container{ + c := &container.SummaryTrimmed{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -254,7 +255,7 @@ func TestDisableHealthCheck(t *testing.T) { } func TestPublicIPLocalhost(t *testing.T) { - c := &types.Container{Names: dummyNames, State: "running"} + c := &container.SummaryTrimmed{Names: dummyNames, State: "running"} r, ok := makeRoutes(c)["a"] ExpectTrue(t, ok) ExpectEqual(t, r.Container.PublicHostname, "127.0.0.1") @@ -262,7 +263,7 @@ func TestPublicIPLocalhost(t *testing.T) { } func TestPublicIPRemote(t *testing.T) { - c := &types.Container{Names: dummyNames, State: "running"} + c := &container.SummaryTrimmed{Names: dummyNames, State: "running"} raw, ok := makeRoutes(c, testIP)["a"] ExpectTrue(t, ok) ExpectEqual(t, raw.Container.PublicHostname, testIP) @@ -270,10 +271,10 @@ func TestPublicIPRemote(t *testing.T) { } func TestPrivateIPLocalhost(t *testing.T) { - c := &types.Container{ + c := &container.SummaryTrimmed{ Names: dummyNames, - NetworkSettings: &types.SummaryNetworkSettings{ - Networks: map[string]*network.EndpointSettings{ + NetworkSettings: &container.NetworkSettingsSummaryTrimmed{ + Networks: map[string]*struct{ IPAddress string }{ "network": { IPAddress: testDockerIP, }, @@ -287,11 +288,11 @@ func TestPrivateIPLocalhost(t *testing.T) { } func TestPrivateIPRemote(t *testing.T) { - c := &types.Container{ + c := &container.SummaryTrimmed{ Names: dummyNames, State: "running", - NetworkSettings: &types.SummaryNetworkSettings{ - Networks: map[string]*network.EndpointSettings{ + NetworkSettings: &container.NetworkSettingsSummaryTrimmed{ + Networks: map[string]*struct{ IPAddress string }{ "network": { IPAddress: testDockerIP, }, @@ -309,11 +310,11 @@ func TestStreamDefaultValues(t *testing.T) { privPort := uint16(1234) pubPort := uint16(4567) privIP := "172.17.0.123" - cont := &types.Container{ + cont := &container.SummaryTrimmed{ Names: []string{"a"}, State: "running", - NetworkSettings: &types.SummaryNetworkSettings{ - Networks: map[string]*network.EndpointSettings{ + NetworkSettings: &container.NetworkSettingsSummaryTrimmed{ + Networks: map[string]*struct{ IPAddress string }{ "network": { IPAddress: privIP, }, @@ -346,7 +347,7 @@ func TestStreamDefaultValues(t *testing.T) { } func TestExplicitExclude(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a", @@ -360,17 +361,17 @@ func TestExplicitExclude(t *testing.T) { func TestImplicitExcludeDatabase(t *testing.T) { t.Run("mount path detection", func(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, - Mounts: []types.MountPoint{ - {Source: "/data", Destination: "/var/lib/postgresql/data"}, + Mounts: []container.MountPointTrimmed{ + {Destination: "/var/lib/postgresql/data"}, }, })["a"] ExpectTrue(t, ok) ExpectTrue(t, r.ShouldExclude()) }) t.Run("exposed port detection", func(t *testing.T) { - r, ok := makeRoutes(&types.Container{ + r, ok := makeRoutes(&container.SummaryTrimmed{ Names: dummyNames, Ports: []types.Port{ {Type: "tcp", PrivatePort: 5432, PublicPort: 5432}, diff --git a/internal/route/reverse_proxy.go b/internal/route/reverse_proxy.go index ee1e1ae..6925719 100755 --- a/internal/route/reverse_proxy.go +++ b/internal/route/reverse_proxy.go @@ -8,10 +8,8 @@ import ( "github.com/yusing/go-proxy/agent/pkg/agentproxy" "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/common" - "github.com/yusing/go-proxy/internal/docker" - "github.com/yusing/go-proxy/internal/docker/idlewatcher" "github.com/yusing/go-proxy/internal/gperr" - "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/idlewatcher" gphttp "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/net/gphttp/accesslog" "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer" @@ -60,7 +58,7 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { } } - service := base.TargetName() + service := base.Name() rp := reverseproxy.NewReverseProxy(service, proxyURL, trans) if len(base.Middlewares) > 0 { @@ -91,38 +89,24 @@ func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, gperr.Error) { return r, nil } -func (r *ReveseProxyRoute) String() string { - return r.TargetName() -} - // Start implements task.TaskStarter. func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { - if existing, ok := routes.GetHTTPRoute(r.TargetName()); ok && !r.UseLoadBalance() { + if existing, ok := routes.HTTP.Get(r.Key()); ok && !r.UseLoadBalance() { return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) } - r.task = parent.Subtask("http."+r.TargetName(), false) + r.task = parent.Subtask("http."+r.Name(), false) switch { case r.UseIdleWatcher(): - waker, err := idlewatcher.NewHTTPWaker(parent, r, r.rp) + waker, err := idlewatcher.NewWatcher(parent, r) if err != nil { r.task.Finish(err) - return err + return gperr.Wrap(err) } r.handler = waker r.HealthMon = waker case r.UseHealthCheck(): - if r.IsDocker() { - client, err := docker.NewClient(r.Container.DockerHost) - if err == nil { - fallback := r.newHealthMonitor() - r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Container.ContainerID, r.TargetName(), r.HealthCheck, fallback) - r.task.OnCancel("close_docker_client", client.Close) - } - } - if r.HealthMon == nil { - r.HealthMon = r.newHealthMonitor() - } + r.HealthMon = monitor.NewMonitor(r) } if r.UseAccessLog() { @@ -134,32 +118,8 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { } } - if r.handler == nil { - pathPatterns := r.PathPatterns - switch { - case len(pathPatterns) == 0: - r.handler = r.rp - case len(pathPatterns) == 1 && pathPatterns[0] == "/": - r.handler = r.rp - default: - logging.Warn(). - Str("route", r.TargetName()). - Msg("`path_patterns` for reverse proxy is deprecated. Use `rules` instead.") - mux := gphttp.NewServeMux() - patErrs := gperr.NewBuilder("invalid path pattern(s)") - for _, p := range pathPatterns { - patErrs.Add(mux.HandleFunc(p, r.rp.HandlerFunc)) - } - if err := patErrs.Error(); err != nil { - r.task.Finish(err) - return err - } - r.handler = mux - } - } - if len(r.Rules) > 0 { - r.handler = r.Rules.BuildHandler(r.TargetName(), r.handler) + r.handler = r.Rules.BuildHandler(r.Name(), r.handler) } if r.HealthMon != nil { @@ -169,7 +129,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { } if common.PrometheusEnabled { - metricsLogger := metricslogger.NewMetricsLogger(r.TargetName()) + metricsLogger := metricslogger.NewMetricsLogger(r.Name()) r.handler = metricsLogger.GetHandler(r.handler) r.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics) } @@ -177,9 +137,9 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error { if r.UseLoadBalance() { r.addToLoadBalancer(parent) } else { - routes.SetHTTPRoute(r.TargetName(), r) - r.task.OnCancel("entrypoint_remove_route", func() { - routes.DeleteHTTPRoute(r.TargetName()) + routes.HTTP.Add(r) + r.task.OnFinished("entrypoint_remove_route", func() { + routes.HTTP.Del(r) }) } @@ -205,21 +165,10 @@ func (r *ReveseProxyRoute) HealthMonitor() health.HealthMonitor { return r.HealthMon } -func (r *ReveseProxyRoute) newHealthMonitor() interface { - health.HealthMonitor - health.HealthChecker -} { - if a := r.Agent(); a != nil { - target := monitor.AgentTargetFromURL(r.ProxyURL) - return monitor.NewAgentProxiedMonitor(a, r.HealthCheck, target) - } - return monitor.NewHTTPHealthMonitor(r.ProxyURL, r.HealthCheck) -} - func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { var lb *loadbalancer.LoadBalancer cfg := r.LoadBalance - l, ok := routes.GetHTTPRoute(cfg.Link) + l, ok := routes.HTTP.Get(cfg.Link) var linked *ReveseProxyRoute if ok { linked = l.(*ReveseProxyRoute) @@ -240,7 +189,10 @@ func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { loadBalancer: lb, handler: lb, } - routes.SetHTTPRoute(cfg.Link, linked) + routes.HTTP.Add(linked) + r.task.OnFinished("entrypoint_remove_route", func() { + routes.HTTP.Del(linked) + }) } r.loadBalancer = lb diff --git a/internal/route/route.go b/internal/route/route.go index e913925..f69bf17 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -1,17 +1,22 @@ package route import ( + "context" "fmt" "strings" + "time" "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/internal" "github.com/yusing/go-proxy/internal/docker" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/homepage" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" + "github.com/yusing/go-proxy/internal/logging" + netutils "github.com/yusing/go-proxy/internal/net" net "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/proxmox" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/watcher/health" @@ -20,8 +25,9 @@ import ( config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/net/gphttp/accesslog" loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" + "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/route/rules" - "github.com/yusing/go-proxy/internal/route/types" + route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/utils" ) @@ -30,12 +36,12 @@ type ( _ utils.NoCopy Alias string `json:"alias"` - Scheme types.Scheme `json:"scheme,omitempty"` + Scheme route.Scheme `json:"scheme,omitempty"` Host string `json:"host,omitempty"` - Port types.Port `json:"port,omitempty"` + Port route.Port `json:"port,omitempty"` Root string `json:"root,omitempty"` - types.HTTPConfig + route.HTTPConfig PathPatterns []string `json:"path_patterns,omitempty"` Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"` HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` @@ -44,6 +50,8 @@ type ( Homepage *homepage.ItemConfig `json:"homepage,omitempty"` AccessLog *accesslog.Config `json:"access_log,omitempty"` + Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` + Metadata `deserialize:"-"` } @@ -53,17 +61,18 @@ type ( Provider string `json:"provider,omitempty"` // private fields - LisURL *net.URL `json:"lurl,omitempty"` - ProxyURL *net.URL `json:"purl,omitempty"` - Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` + LisURL *net.URL `json:"lurl,omitempty"` + ProxyURL *net.URL `json:"purl,omitempty"` - impl types.Route + impl routes.Route isValidated bool lastError gperr.Error } Routes map[string]*Route ) +const DefaultHost = "localhost" + func (r Routes) Contains(alias string) bool { _, ok := r[alias] return ok @@ -76,12 +85,79 @@ func (r *Route) Validate() gperr.Error { r.isValidated = true r.Finalize() + if r.Idlewatcher != nil && r.Idlewatcher.Proxmox != nil { + node := r.Idlewatcher.Proxmox.Node + vmid := r.Idlewatcher.Proxmox.VMID + if node == "" { + return gperr.Errorf("node (proxmox node name) is required") + } + if vmid <= 0 { + return gperr.Errorf("vmid (lxc id) is required") + } + if r.Host == DefaultHost { + containerName := r.Idlewatcher.ContainerName() + // get ip addresses of the vmid + node, ok := proxmox.Nodes.Get(node) + if !ok { + return gperr.Errorf("proxmox node %s not found in pool", node) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ips, err := node.LXCGetIPs(ctx, vmid) + if err != nil { + return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err) + } + + if len(ips) == 0 { + return gperr.Multiline(). + Addf("no ip addresses found for %s", containerName). + Adds("make sure you have set static ip address for container instead of dhcp"). + Subject(containerName) + } + + l := logging.With().Str("container", containerName).Logger() + + l.Info().Msg("checking if container is running") + running, err := node.LXCIsRunning(ctx, vmid) + if err != nil { + return gperr.New("failed to check container state").With(err) + } + + if !running { + l.Info().Msg("starting container") + if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil { + return gperr.New("failed to start container").With(err) + } + } + + l.Info().Msgf("finding reachable ip addresses") + errs := gperr.NewBuilder("failed to find reachable ip addresses") + for _, ip := range ips { + if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil { + errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy)) + } else { + r.Host = ip.String() + l.Info().Msgf("using ip %s", r.Host) + break + } + } + if r.Host == DefaultHost { + return gperr.Multiline(). + Addf("no reachable ip addresses found, tried %d IPs", len(ips)). + With(errs.Error()). + Subject(containerName) + } + } + } + // return error if route is localhost: switch r.Host { case "localhost", "127.0.0.1": switch r.Port.Proxy { case common.ProxyHTTPPort, common.ProxyHTTPSPort, common.APIHTTPPort: - if r.Scheme.IsReverseProxy() || r.Scheme == types.SchemeTCP { + if r.Scheme.IsReverseProxy() || r.Scheme == route.SchemeTCP { return gperr.Errorf("localhost:%d is reserved for godoxy", r.Port.Proxy) } } @@ -89,29 +165,27 @@ func (r *Route) Validate() gperr.Error { errs := gperr.NewBuilder("entry validation failed") - var impl types.Route + var impl routes.Route var err gperr.Error - switch r.Scheme { - case types.SchemeFileServer: + if r.Scheme == route.SchemeFileServer { r.impl, err = NewFileServer(r) if err != nil { errs.Add(err) } - case types.SchemeHTTP, types.SchemeHTTPS: - if r.Port.Listening != 0 { - errs.Addf("unexpected listening port for %s scheme", r.Scheme) - } - fallthrough - case types.SchemeTCP, types.SchemeUDP: - r.LisURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening)) - fallthrough - default: - if r.LoadBalance != nil && r.LoadBalance.Link == "" { - r.LoadBalance = nil + r.ProxyURL = gperr.Collect(errs, net.ParseURL, "file://"+r.Root) + r.Host = "" + r.Port.Proxy = 0 + } else { + switch r.Scheme { + case route.SchemeHTTP, route.SchemeHTTPS: + if r.Port.Listening != 0 { + errs.Addf("unexpected listening port for %s scheme", r.Scheme) + } + case route.SchemeTCP, route.SchemeUDP: + r.LisURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", r.Scheme, r.Port.Listening)) } r.ProxyURL = gperr.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy)) - r.Idlewatcher = gperr.Collect(errs, idlewatcher.ValidateConfig, r.Container) } if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) { @@ -120,15 +194,15 @@ func (r *Route) Validate() gperr.Error { if errs.HasError() { r.lastError = errs.Error() - return r.lastError + return errs.Error() } switch r.Scheme { - case types.SchemeFileServer: + case route.SchemeFileServer: impl, err = NewFileServer(r) - case types.SchemeHTTP, types.SchemeHTTPS: + case route.SchemeHTTP, route.SchemeHTTPS: impl, err = NewReverseProxyRoute(r) - case types.SchemeTCP, types.SchemeUDP: + case route.SchemeTCP, route.SchemeUDP: impl, err = NewStreamRoute(r) default: panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias)) @@ -167,20 +241,33 @@ func (r *Route) ProviderName() string { return r.Provider } -func (r *Route) TargetName() string { - return r.Alias -} - func (r *Route) TargetURL() *net.URL { return r.ProxyURL } -func (r *Route) Type() types.RouteType { +func (r *Route) Reference() string { + if r.Container != nil { + return r.Container.Image.Name + } + return r.Alias +} + +// Name implements pool.Object. +func (r *Route) Name() string { + return r.Alias +} + +// Key implements pool.Object. +func (r *Route) Key() string { + return r.Alias +} + +func (r *Route) Type() route.RouteType { switch r.Scheme { - case types.SchemeHTTP, types.SchemeHTTPS, types.SchemeFileServer: - return types.RouteTypeHTTP - case types.SchemeTCP, types.SchemeUDP: - return types.RouteTypeStream + case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeFileServer: + return route.RouteTypeHTTP + case route.SchemeTCP, route.SchemeUDP: + return route.RouteTypeStream } panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias)) } @@ -301,7 +388,7 @@ func (r *Route) Finalize() { scheme, port, ok := getSchemePortByImageName(cont.Image.Name) if ok { if r.Scheme == "" { - r.Scheme = types.Scheme(scheme) + r.Scheme = route.Scheme(scheme) } if pp == 0 { pp = port @@ -311,7 +398,7 @@ func (r *Route) Finalize() { if scheme, port, ok := getSchemePortByAlias(r.Alias); ok { if r.Scheme == "" { - r.Scheme = types.Scheme(scheme) + r.Scheme = route.Scheme(scheme) } if pp == 0 { pp = port @@ -379,18 +466,6 @@ func (r *Route) Finalize() { r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault } } - - if isDocker && cont.IdleTimeout != "" { - if cont.WakeTimeout == "" { - cont.WakeTimeout = common.WakeTimeoutDefault - } - if cont.StopTimeout == "" { - cont.StopTimeout = common.StopTimeoutDefault - } - if cont.StopMethod == "" { - cont.StopMethod = common.StopMethodDefault - } - } } func (r *Route) FinalizeHomepageConfig() { diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 0df3c59..14bcf30 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -3,59 +3,45 @@ package route import ( "testing" - "github.com/stretchr/testify/require" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/docker" loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" - "github.com/yusing/go-proxy/internal/route/types" + route "github.com/yusing/go-proxy/internal/route/types" + expect "github.com/yusing/go-proxy/internal/utils/testing" "github.com/yusing/go-proxy/internal/watcher/health" ) func TestRouteValidate(t *testing.T) { - t.Run("AlreadyValidated", func(t *testing.T) { - r := &Route{ - Alias: "test", - Scheme: types.SchemeHTTP, - Host: "example.com", - Port: types.Port{Proxy: 80}, - Metadata: Metadata{ - isValidated: true, - }, - } - err := r.Validate() - require.NoError(t, err, "Validate should return nil for already validated route") - }) - t.Run("ReservedPort", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "localhost", - Port: types.Port{Proxy: common.ProxyHTTPPort}, + Port: route.Port{Proxy: common.ProxyHTTPPort}, } err := r.Validate() - require.Error(t, err, "Validate should return error for localhost with reserved port") - require.Contains(t, err.Error(), "reserved for godoxy") + expect.HasError(t, err, "Validate should return error for localhost with reserved port") + expect.ErrorContains(t, err, "reserved for godoxy") }) t.Run("ListeningPortWithHTTP", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "example.com", - Port: types.Port{Proxy: 80, Listening: 1234}, + Port: route.Port{Proxy: 80, Listening: 1234}, } err := r.Validate() - require.Error(t, err, "Validate should return error for HTTP scheme with listening port") - require.Contains(t, err.Error(), "unexpected listening port") + expect.HasError(t, err, "Validate should return error for HTTP scheme with listening port") + expect.ErrorContains(t, err, "unexpected listening port") }) t.Run("DisabledHealthCheckWithLoadBalancer", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, HealthCheck: &health.HealthCheckConfig{ Disable: true, }, @@ -64,53 +50,53 @@ func TestRouteValidate(t *testing.T) { }, // Minimal LoadBalance config with non-empty Link will be checked by UseLoadBalance } err := r.Validate() - require.Error(t, err, "Validate should return error for disabled healthcheck with loadbalancer") - require.Contains(t, err.Error(), "cannot disable healthcheck") + expect.HasError(t, err, "Validate should return error for disabled healthcheck with loadbalancer") + expect.ErrorContains(t, err, "cannot disable healthcheck") }) t.Run("FileServerScheme", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeFileServer, + Scheme: route.SchemeFileServer, Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, Root: "/tmp", // Root is required for file server } err := r.Validate() - require.NoError(t, err, "Validate should not return error for valid file server route") - require.NotNil(t, r.impl, "Impl should be initialized") + expect.NoError(t, err, "Validate should not return error for valid file server route") + expect.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("HTTPScheme", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, } err := r.Validate() - require.NoError(t, err, "Validate should not return error for valid HTTP route") - require.NotNil(t, r.impl, "Impl should be initialized") + expect.NoError(t, err, "Validate should not return error for valid HTTP route") + expect.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("TCPScheme", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeTCP, + Scheme: route.SchemeTCP, Host: "example.com", - Port: types.Port{Proxy: 80, Listening: 8080}, + Port: route.Port{Proxy: 80, Listening: 8080}, } err := r.Validate() - require.NoError(t, err, "Validate should not return error for valid TCP route") - require.NotNil(t, r.impl, "Impl should be initialized") + expect.NoError(t, err, "Validate should not return error for valid TCP route") + expect.NotNil(t, r.impl, "Impl should be initialized") }) t.Run("DockerContainer", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, Metadata: Metadata{ Container: &docker.Container{ ContainerID: "test-id", @@ -121,8 +107,8 @@ func TestRouteValidate(t *testing.T) { }, } err := r.Validate() - require.NoError(t, err, "Validate should not return error for valid docker container route") - require.NotNil(t, r.ProxyURL, "ProxyURL should be set") + expect.NoError(t, err, "Validate should not return error for valid docker container route") + expect.NotNil(t, r.ProxyURL, "ProxyURL should be set") }) t.Run("InvalidScheme", func(t *testing.T) { @@ -130,9 +116,9 @@ func TestRouteValidate(t *testing.T) { Alias: "test", Scheme: "invalid", Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, } - require.Panics(t, func() { + expect.Panics(t, func() { _ = r.Validate() }, "Validate should panic for invalid scheme") }) @@ -140,14 +126,13 @@ func TestRouteValidate(t *testing.T) { t.Run("ModifiedFields", func(t *testing.T) { r := &Route{ Alias: "test", - Scheme: types.SchemeHTTP, + Scheme: route.SchemeHTTP, Host: "example.com", - Port: types.Port{Proxy: 80}, + Port: route.Port{Proxy: 80}, } err := r.Validate() - require.NoError(t, err) - require.True(t, r.isValidated) - require.NotNil(t, r.ProxyURL) - require.NotNil(t, r.HealthCheck) + expect.NoError(t, err) + expect.NotNil(t, r.ProxyURL) + expect.NotNil(t, r.HealthCheck) }) } diff --git a/internal/route/routes/routequery/query.go b/internal/route/routes/query.go similarity index 66% rename from internal/route/routes/routequery/query.go rename to internal/route/routes/query.go index f1b3037..9500219 100644 --- a/internal/route/routes/routequery/query.go +++ b/internal/route/routes/query.go @@ -1,15 +1,14 @@ -package routequery +package routes import ( "time" "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/route/routes" route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/watcher/health" ) -func getHealthInfo(r route.Route) map[string]string { +func getHealthInfo(r Route) map[string]string { mon := r.HealthMonitor() if mon == nil { return map[string]string{ @@ -26,11 +25,11 @@ func getHealthInfo(r route.Route) map[string]string { } type HealthInfoRaw struct { - Status health.Status - Latency time.Duration + Status health.Status `json:"status,string"` + Latency time.Duration `json:"latency"` } -func getHealthInfoRaw(r route.Route) *HealthInfoRaw { +func getHealthInfoRaw(r Route) *HealthInfoRaw { mon := r.HealthMonitor() if mon == nil { return &HealthInfoRaw{ @@ -45,69 +44,69 @@ func getHealthInfoRaw(r route.Route) *HealthInfoRaw { } func HealthMap() map[string]map[string]string { - healthMap := make(map[string]map[string]string, routes.NumRoutes()) - routes.RangeRoutes(func(alias string, r route.Route) { + healthMap := make(map[string]map[string]string, NumRoutes()) + for alias, r := range Iter { healthMap[alias] = getHealthInfo(r) - }) + } return healthMap } func HealthInfo() map[string]*HealthInfoRaw { - healthMap := make(map[string]*HealthInfoRaw, routes.NumRoutes()) - routes.RangeRoutes(func(alias string, r route.Route) { + healthMap := make(map[string]*HealthInfoRaw, NumRoutes()) + for alias, r := range Iter { healthMap[alias] = getHealthInfoRaw(r) - }) + } return healthMap } func HomepageCategories() []string { check := make(map[string]struct{}) categories := make([]string, 0) - routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { + for _, r := range HTTP.Iter { item := r.HomepageConfig() if item == nil || item.Category == "" { - return + continue } if _, ok := check[item.Category]; ok { - return + continue } check[item.Category] = struct{}{} categories = append(categories, item.Category) - }) + } return categories } func HomepageConfig(categoryFilter, providerFilter string) homepage.Homepage { hp := make(homepage.Homepage) - routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { + for _, r := range HTTP.Iter { if providerFilter != "" && r.ProviderName() != providerFilter { - return + continue } item := r.HomepageItem() if categoryFilter != "" && item.Category != categoryFilter { - return + continue } hp.Add(item) - }) + } return hp } -func RoutesByAlias(typeFilter ...route.RouteType) map[string]route.Route { - rts := make(map[string]route.Route) +func ByAlias(typeFilter ...route.RouteType) map[string]Route { + rts := make(map[string]Route) if len(typeFilter) == 0 || typeFilter[0] == "" { typeFilter = []route.RouteType{route.RouteTypeHTTP, route.RouteTypeStream} } for _, t := range typeFilter { switch t { case route.RouteTypeHTTP: - routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { + for alias, r := range HTTP.Iter { rts[alias] = r - }) + } case route.RouteTypeStream: - routes.GetStreamRoutes().RangeAll(func(alias string, r route.StreamRoute) { + for alias, r := range Stream.Iter { rts[alias] = r - }) + } } } return rts diff --git a/internal/route/types/route.go b/internal/route/routes/route.go similarity index 77% rename from internal/route/types/route.go rename to internal/route/routes/route.go index 3011e03..13bd686 100644 --- a/internal/route/types/route.go +++ b/internal/route/routes/route.go @@ -1,17 +1,19 @@ -package types +package routes import ( "net/http" "github.com/yusing/go-proxy/agent/pkg/agent" "github.com/yusing/go-proxy/internal/docker" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" "github.com/yusing/go-proxy/internal/homepage" + idlewatcher "github.com/yusing/go-proxy/internal/idlewatcher/types" net "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils/pool" "github.com/yusing/go-proxy/internal/watcher/health" loadbalance "github.com/yusing/go-proxy/internal/net/gphttp/loadbalancer/types" + "github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy" ) type ( @@ -19,10 +21,11 @@ type ( Route interface { task.TaskStarter task.TaskFinisher + pool.Object ProviderName() string - TargetName() string TargetURL() *net.URL HealthMonitor() health.HealthMonitor + Reference() string Started() bool @@ -46,6 +49,10 @@ type ( Route http.Handler } + ReverseProxyRoute interface { + HTTPRoute + ReverseProxy() *reverseproxy.ReverseProxy + } StreamRoute interface { Route net.Stream diff --git a/internal/route/routes/routes.go b/internal/route/routes/routes.go index 40e8e74..1797025 100644 --- a/internal/route/routes/routes.go +++ b/internal/route/routes/routes.go @@ -1,78 +1,49 @@ package routes import ( - "github.com/yusing/go-proxy/internal/route/types" - F "github.com/yusing/go-proxy/internal/utils/functional" + "github.com/yusing/go-proxy/internal/utils/pool" ) var ( - httpRoutes = F.NewMapOf[string, types.HTTPRoute]() - streamRoutes = F.NewMapOf[string, types.StreamRoute]() + HTTP = pool.New[HTTPRoute]("http_routes") + Stream = pool.New[StreamRoute]("stream_routes") ) -func RangeRoutes(callback func(alias string, r types.Route)) { - httpRoutes.RangeAll(func(alias string, r types.HTTPRoute) { - callback(alias, r) - }) - streamRoutes.RangeAll(func(alias string, r types.StreamRoute) { - callback(alias, r) - }) +func Iter(yield func(alias string, r Route) bool) { + for k, r := range HTTP.Iter { + if !yield(k, r) { + break + } + } + for k, r := range Stream.Iter { + if !yield(k, r) { + break + } + } } func NumRoutes() int { - return httpRoutes.Size() + streamRoutes.Size() + return HTTP.Size() + Stream.Size() } -func GetHTTPRoutes() F.Map[string, types.HTTPRoute] { - return httpRoutes +func Clear() { + HTTP.Clear() + Stream.Clear() } -func GetStreamRoutes() F.Map[string, types.StreamRoute] { - return streamRoutes -} - -func GetHTTPRouteOrExact(alias, host string) (types.HTTPRoute, bool) { - r, ok := httpRoutes.Load(alias) +func GetHTTPRouteOrExact(alias, host string) (HTTPRoute, bool) { + r, ok := HTTP.Get(alias) if ok { return r, true } // try find with exact match - return httpRoutes.Load(host) + return HTTP.Get(host) } -func GetHTTPRoute(alias string) (types.HTTPRoute, bool) { - return httpRoutes.Load(alias) -} - -func GetStreamRoute(alias string) (types.StreamRoute, bool) { - return streamRoutes.Load(alias) -} - -func GetRoute(alias string) (types.Route, bool) { - r, ok := httpRoutes.Load(alias) +func Get(alias string) (Route, bool) { + r, ok := HTTP.Get(alias) if ok { return r, true } - return streamRoutes.Load(alias) -} - -func SetHTTPRoute(alias string, r types.HTTPRoute) { - httpRoutes.Store(alias, r) -} - -func SetStreamRoute(alias string, r types.StreamRoute) { - streamRoutes.Store(alias, r) -} - -func DeleteHTTPRoute(alias string) { - httpRoutes.Delete(alias) -} - -func DeleteStreamRoute(alias string) { - streamRoutes.Delete(alias) -} - -func TestClear() { - httpRoutes = F.NewMapOf[string, types.HTTPRoute]() - streamRoutes = F.NewMapOf[string, types.StreamRoute]() + return Stream.Get(alias) } diff --git a/internal/route/stream.go b/internal/route/stream.go index 259e9ba..b2809ff 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -5,13 +5,11 @@ import ( "errors" "github.com/rs/zerolog" - "github.com/yusing/go-proxy/internal/docker" - "github.com/yusing/go-proxy/internal/docker/idlewatcher" "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/idlewatcher" "github.com/yusing/go-proxy/internal/logging" net "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/route/routes" - route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" @@ -30,53 +28,36 @@ type StreamRoute struct { l zerolog.Logger } -func NewStreamRoute(base *Route) (route.Route, gperr.Error) { +func NewStreamRoute(base *Route) (routes.Route, gperr.Error) { // TODO: support non-coherent scheme return &StreamRoute{ Route: base, l: logging.With(). Str("type", string(base.Scheme)). - Str("name", base.TargetName()). + Str("name", base.Name()). Logger(), }, nil } -func (r *StreamRoute) String() string { - return "stream " + r.TargetName() -} - // Start implements task.TaskStarter. func (r *StreamRoute) Start(parent task.Parent) gperr.Error { - if existing, ok := routes.GetStreamRoute(r.TargetName()); ok { + if existing, ok := routes.Stream.Get(r.Key()); ok { return gperr.Errorf("route already exists: from provider %s and %s", existing.ProviderName(), r.ProviderName()) } - r.task = parent.Subtask("stream." + r.TargetName()) + r.task = parent.Subtask("stream." + r.Name()) r.Stream = NewStream(r) - parent.OnCancel("finish", func() { - r.task.Finish(nil) - }) switch { case r.UseIdleWatcher(): - waker, err := idlewatcher.NewStreamWaker(parent, r, r.Stream) + waker, err := idlewatcher.NewWatcher(parent, r) if err != nil { r.task.Finish(err) - return err + return gperr.Wrap(err, "idlewatcher error") } r.Stream = waker r.HealthMon = waker case r.UseHealthCheck(): - if r.IsDocker() { - client, err := docker.NewClient(r.Container.DockerHost) - if err == nil { - fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.HealthCheck) - r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Container.ContainerID, r.TargetName(), r.HealthCheck, fallback) - r.task.OnCancel("close_docker_client", client.Close) - } - } - if r.HealthMon == nil { - r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.HealthCheck) - } + r.HealthMon = monitor.NewMonitor(r) } if err := r.Stream.Setup(); err != nil { @@ -94,9 +75,9 @@ func (r *StreamRoute) Start(parent task.Parent) gperr.Error { go r.acceptConnections() - routes.SetStreamRoute(r.TargetName(), r) - r.task.OnCancel("entrypoint_remove_route", func() { - routes.DeleteStreamRoute(r.TargetName()) + routes.Stream.Add(r) + r.task.OnFinished("entrypoint_remove_route", func() { + routes.Stream.Del(r) }) return nil } diff --git a/internal/route/types/http_config.go b/internal/route/types/http_config.go index 3aba13d..881903d 100644 --- a/internal/route/types/http_config.go +++ b/internal/route/types/http_config.go @@ -1,4 +1,4 @@ -package types +package route import ( "time" diff --git a/internal/route/types/http_config_test.go b/internal/route/types/http_config_test.go index 54693d1..52d9d3a 100644 --- a/internal/route/types/http_config_test.go +++ b/internal/route/types/http_config_test.go @@ -1,27 +1,27 @@ -package types_test +package route_test import ( "testing" "time" . "github.com/yusing/go-proxy/internal/route" - "github.com/yusing/go-proxy/internal/route/types" + route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/utils" - . "github.com/yusing/go-proxy/internal/utils/testing" + expect "github.com/yusing/go-proxy/internal/utils/testing" ) func TestHTTPConfigDeserialize(t *testing.T) { tests := []struct { name string input map[string]any - expected types.HTTPConfig + expected route.HTTPConfig }{ { name: "no_tls_verify", input: map[string]any{ "no_tls_verify": "true", }, - expected: types.HTTPConfig{ + expected: route.HTTPConfig{ NoTLSVerify: true, }, }, @@ -30,7 +30,7 @@ func TestHTTPConfigDeserialize(t *testing.T) { input: map[string]any{ "response_header_timeout": "1s", }, - expected: types.HTTPConfig{ + expected: route.HTTPConfig{ ResponseHeaderTimeout: 1 * time.Second, }, }, @@ -39,11 +39,12 @@ func TestHTTPConfigDeserialize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Route{} + tt.input["host"] = "internal" err := utils.Deserialize(tt.input, &cfg) if err != nil { - ExpectNoError(t, err) + expect.NoError(t, err) } - ExpectEqual(t, cfg.HTTPConfig, tt.expected) + expect.Equal(t, cfg.HTTPConfig, tt.expected) }) } } diff --git a/internal/route/types/port.go b/internal/route/types/port.go index 57c08fd..e4839ac 100644 --- a/internal/route/types/port.go +++ b/internal/route/types/port.go @@ -1,4 +1,4 @@ -package types +package route import ( "strconv" diff --git a/internal/route/types/port_test.go b/internal/route/types/port_test.go index 12ca517..28b7d3e 100644 --- a/internal/route/types/port_test.go +++ b/internal/route/types/port_test.go @@ -1,4 +1,4 @@ -package types +package route import ( "errors" diff --git a/internal/route/types/route_type.go b/internal/route/types/route_type.go index 6d13d17..c0ac822 100644 --- a/internal/route/types/route_type.go +++ b/internal/route/types/route_type.go @@ -1,4 +1,4 @@ -package types +package route type RouteType string diff --git a/internal/route/types/scheme.go b/internal/route/types/scheme.go index cbb4ce2..5983539 100644 --- a/internal/route/types/scheme.go +++ b/internal/route/types/scheme.go @@ -1,4 +1,4 @@ -package types +package route import ( "github.com/yusing/go-proxy/internal/gperr" diff --git a/internal/utils/pool/pool.go b/internal/utils/pool/pool.go new file mode 100644 index 0000000..941b6e7 --- /dev/null +++ b/internal/utils/pool/pool.go @@ -0,0 +1,65 @@ +package pool + +import ( + "sort" + + "github.com/puzpuzpuz/xsync/v3" + "github.com/yusing/go-proxy/internal/logging" +) + +type ( + Pool[T Object] struct { + m *xsync.MapOf[string, T] + name string + } + Object interface { + Key() string + Name() string + } +) + +func New[T Object](name string) Pool[T] { + return Pool[T]{xsync.NewMapOf[string, T](), name} +} + +func (p Pool[T]) Name() string { + return p.name +} + +func (p Pool[T]) Add(obj T) { + p.checkExists(obj.Key()) + p.m.Store(obj.Key(), obj) + logging.Info().Msgf("%s: added %s", p.name, obj.Name()) +} + +func (p Pool[T]) Del(obj T) { + p.m.Delete(obj.Key()) + logging.Info().Msgf("%s: removed %s", p.name, obj.Name()) +} + +func (p Pool[T]) Get(key string) (T, bool) { + return p.m.Load(key) +} + +func (p Pool[T]) Size() int { + return p.m.Size() +} + +func (p Pool[T]) Clear() { + p.m.Clear() +} + +func (p Pool[T]) Iter(fn func(k string, v T) bool) { + p.m.Range(fn) +} + +func (p Pool[T]) Slice() []T { + slice := make([]T, 0, p.m.Size()) + for _, v := range p.m.Range { + slice = append(slice, v) + } + sort.Slice(slice, func(i, j int) bool { + return slice[i].Name() < slice[j].Name() + }) + return slice +} diff --git a/internal/utils/pool/pool_debug.go b/internal/utils/pool/pool_debug.go new file mode 100644 index 0000000..6ab84f6 --- /dev/null +++ b/internal/utils/pool/pool_debug.go @@ -0,0 +1,15 @@ +//go:build debug + +package pool + +import ( + "runtime/debug" + + "github.com/yusing/go-proxy/internal/logging" +) + +func (p Pool[T]) checkExists(key string) { + if _, ok := p.m.Load(key); ok { + logging.Warn().Msgf("%s: key %s already exists\nstacktrace: %s", p.name, key, string(debug.Stack())) + } +} diff --git a/internal/utils/pool/pool_prod.go b/internal/utils/pool/pool_prod.go new file mode 100644 index 0000000..5a5108a --- /dev/null +++ b/internal/utils/pool/pool_prod.go @@ -0,0 +1,7 @@ +//go:build !debug + +package pool + +func (p Pool[T]) checkExists(key string) { + // no-op in production +} diff --git a/internal/utils/testing/expect.go b/internal/utils/testing/expect.go new file mode 100644 index 0000000..a87ddaa --- /dev/null +++ b/internal/utils/testing/expect.go @@ -0,0 +1,71 @@ +package expect + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/yusing/go-proxy/internal/common" +) + +func init() { + if common.IsTest { + // force verbose output + os.Args = append([]string{os.Args[0], "-test.v"}, os.Args[1:]...) + } +} + +func Must[Result any](r Result, err error) Result { + if err != nil { + panic(err) + } + return r +} + +var ( + NoError = require.NoError + HasError = require.Error + True = require.True + False = require.False + Nil = require.Nil + NotNil = require.NotNil + ErrorContains = require.ErrorContains + Panics = require.Panics + Greater = require.Greater + Less = require.Less + GreaterOrEqual = require.GreaterOrEqual + LessOrEqual = require.LessOrEqual +) + +func ErrorIs(t *testing.T, expected error, err error, msgAndArgs ...any) { + t.Helper() + require.ErrorIs(t, err, expected, msgAndArgs...) +} + +func ErrorT[T error](t *testing.T, err error, msgAndArgs ...any) { + t.Helper() + var errAs T + require.ErrorAs(t, err, &errAs, msgAndArgs...) +} + +func Equal[T any](t *testing.T, got T, want T, msgAndArgs ...any) { + t.Helper() + require.EqualValues(t, want, got, msgAndArgs...) +} + +func NotEqual[T any](t *testing.T, got T, want T, msgAndArgs ...any) { + t.Helper() + require.NotEqual(t, want, got, msgAndArgs...) +} + +func Contains[T any](t *testing.T, got T, wants []T, msgAndArgs ...any) { + t.Helper() + require.Contains(t, wants, got, msgAndArgs...) +} + +func Type[T any](t *testing.T, got any, msgAndArgs ...any) (_ T) { + t.Helper() + _, ok := got.(T) + require.True(t, ok, msgAndArgs...) + return got.(T) +} diff --git a/internal/utils/testing/sonic.go b/internal/utils/testing/sonic.go new file mode 100644 index 0000000..9e989e2 --- /dev/null +++ b/internal/utils/testing/sonic.go @@ -0,0 +1,14 @@ +package expect + +import ( + "github.com/bytedance/sonic" + "github.com/yusing/go-proxy/internal/common" +) + +func init() { + if common.IsTest { + sonic.ConfigDefault = sonic.Config{ + SortMapKeys: true, + }.Froze() + } +} diff --git a/internal/utils/testing/testing.go b/internal/utils/testing/testing.go index 11c6d25..41bda6d 100644 --- a/internal/utils/testing/testing.go +++ b/internal/utils/testing/testing.go @@ -1,4 +1,4 @@ -package utils +package expect import ( "os" @@ -14,13 +14,6 @@ func init() { } } -func Must[Result any](r Result, err error) Result { - if err != nil { - panic(err) - } - return r -} - func ExpectNoError(t *testing.T, err error) { t.Helper() require.NoError(t, err) diff --git a/internal/watcher/events/events.go b/internal/watcher/events/events.go index 6b851d0..f20457a 100644 --- a/internal/watcher/events/events.go +++ b/internal/watcher/events/events.go @@ -36,8 +36,8 @@ const ( ActionForceReload - actionContainerWakeMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause - actionContainerSleepMask = ActionContainerKill | ActionContainerStop | ActionContainerPause | ActionContainerDie + actionContainerStartMask = ActionContainerCreate | ActionContainerStart | ActionContainerUnpause + actionContainerStopMask = ActionContainerKill | ActionContainerStop | ActionContainerDie ) const ( @@ -83,10 +83,14 @@ func (a Action) String() string { return actionNameMap[a] } -func (a Action) IsContainerWake() bool { - return a&actionContainerWakeMask != 0 +func (a Action) IsContainerStart() bool { + return a&actionContainerStartMask != 0 } -func (a Action) IsContainerSleep() bool { - return a&actionContainerSleepMask != 0 +func (a Action) IsContainerStop() bool { + return a&actionContainerStopMask != 0 +} + +func (a Action) IsContainerPause() bool { + return a == ActionContainerPause } diff --git a/internal/watcher/health/monitor/json.go b/internal/watcher/health/json.go similarity index 72% rename from internal/watcher/health/monitor/json.go rename to internal/watcher/health/json.go index 1ebdca5..9059f5c 100644 --- a/internal/watcher/health/monitor/json.go +++ b/internal/watcher/health/json.go @@ -1,34 +1,35 @@ -package monitor +package health import ( - "encoding/json" + "net/url" "strconv" "time" - net "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/internal/watcher/health" ) type JSONRepresentation struct { Name string - Config *health.HealthCheckConfig - Status health.Status + Config *HealthCheckConfig + Status Status Started time.Time Uptime time.Duration Latency time.Duration LastSeen time.Time Detail string - URL *net.URL + URL *url.URL Extra map[string]any } -func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) { - url := jsonRepr.URL.String() +func (jsonRepr *JSONRepresentation) MarshalMap() map[string]any { + var url string + if jsonRepr.URL != nil { + url = jsonRepr.URL.String() + } if url == "http://:0" { url = "" } - return json.Marshal(map[string]any{ + return map[string]any{ "name": jsonRepr.Name, "config": jsonRepr.Config, "started": jsonRepr.Started.Unix(), @@ -43,5 +44,5 @@ func (jsonRepr *JSONRepresentation) MarshalJSON() ([]byte, error) { "detail": jsonRepr.Detail, "url": url, "extra": jsonRepr.Extra, - }) + } } diff --git a/internal/watcher/health/monitor/agent_proxied.go b/internal/watcher/health/monitor/agent_proxied.go index 82ca0aa..67db85c 100644 --- a/internal/watcher/health/monitor/agent_proxied.go +++ b/internal/watcher/health/monitor/agent_proxied.go @@ -7,7 +7,6 @@ import ( "net/url" agentPkg "github.com/yusing/go-proxy/agent/pkg/agent" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -24,7 +23,7 @@ type ( } ) -func AgentTargetFromURL(url *types.URL) *AgentCheckHealthTarget { +func AgentTargetFromURL(url *url.URL) *AgentCheckHealthTarget { return &AgentCheckHealthTarget{ Scheme: url.Scheme, Host: url.Host, @@ -40,12 +39,12 @@ func (target *AgentCheckHealthTarget) buildQuery() string { return query.Encode() } -func (target *AgentCheckHealthTarget) displayURL() *types.URL { - return types.NewURL(&url.URL{ +func (target *AgentCheckHealthTarget) displayURL() *url.URL { + return &url.URL{ Scheme: target.Scheme, Host: target.Host, Path: target.Path, - }) + } } func NewAgentProxiedMonitor(agent *agentPkg.AgentConfig, config *health.HealthCheckConfig, target *AgentCheckHealthTarget) *AgentProxiedMonitor { diff --git a/internal/watcher/health/monitor/docker.go b/internal/watcher/health/monitor/docker.go index bdb7307..5bf71e3 100644 --- a/internal/watcher/health/monitor/docker.go +++ b/internal/watcher/health/monitor/docker.go @@ -1,9 +1,9 @@ package monitor import ( + "github.com/docker/docker/api/types/container" "github.com/yusing/go-proxy/internal/docker" - dockerTypes "github.com/docker/docker/api/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -25,7 +25,9 @@ func NewDockerHealthMonitor(client *docker.SharedClient, containerID, alias stri } func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) { - cont, err := mon.client.ContainerInspect(mon.task.Context(), mon.containerID) + ctx, cancel := mon.ContextWithTimeout("docker health check timed out") + defer cancel() + cont, err := mon.client.ContainerInspect(ctx, mon.containerID) if err != nil { return mon.fallback.CheckHealth() } @@ -46,7 +48,7 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, return mon.fallback.CheckHealth() } result = new(health.HealthCheckResult) - result.Healthy = cont.State.Health.Status == dockerTypes.Healthy + result.Healthy = cont.State.Health.Status == container.Healthy if len(cont.State.Health.Log) > 0 { lastLog := cont.State.Health.Log[len(cont.State.Health.Log)-1] result.Detail = lastLog.Output diff --git a/internal/watcher/health/monitor/http.go b/internal/watcher/health/monitor/http.go index cfd5645..ce95b23 100644 --- a/internal/watcher/health/monitor/http.go +++ b/internal/watcher/health/monitor/http.go @@ -4,9 +4,9 @@ import ( "crypto/tls" "errors" "net/http" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/pkg" ) @@ -26,7 +26,7 @@ var pinger = &http.Client{ }, } -func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { +func NewHTTPHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { mon := new(HTTPHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) if config.UseGet { @@ -37,10 +37,6 @@ func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTT return mon } -func NewHTTPHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker { - return NewHTTPHealthMonitor(url, config) -} - func (mon *HTTPHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) { ctx, cancel := mon.ContextWithTimeout("ping request timed out") defer cancel() diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index 7fa3420..1aa2b9d 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -4,13 +4,14 @@ import ( "context" "errors" "fmt" + "net/url" "time" + "github.com/yusing/go-proxy/internal/docker" "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/metrics" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/notif" + "github.com/yusing/go-proxy/internal/route/routes" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils/atomic" "github.com/yusing/go-proxy/internal/utils/strutils" @@ -22,7 +23,7 @@ type ( monitor struct { service string config *health.HealthCheckConfig - url atomic.Value[*types.URL] + url atomic.Value[*url.URL] status atomic.Value[health.Status] lastResult atomic.Value[*health.HealthCheckResult] @@ -30,15 +31,39 @@ type ( checkHealth HealthCheckFunc startTime time.Time - metric *metrics.Gauge - task *task.Task } ) var ErrNegativeInterval = errors.New("negative interval") -func newMonitor(url *types.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { +func NewMonitor(r routes.Route) health.HealthMonCheck { + var mon health.HealthMonCheck + if r.IsAgent() { + mon = NewAgentProxiedMonitor(r.Agent(), r.HealthCheckConfig(), AgentTargetFromURL(&r.TargetURL().URL)) + } else { + switch r := r.(type) { + case routes.HTTPRoute: + mon = NewHTTPHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig()) + case routes.StreamRoute: + mon = NewRawHealthMonitor(&r.TargetURL().URL, r.HealthCheckConfig()) + default: + logging.Panic().Msgf("unexpected route type: %T", r) + } + } + if r.IsDocker() { + cont := r.ContainerInfo() + client, err := docker.NewClient(cont.DockerHost) + if err != nil { + return mon + } + r.Task().OnCancel("close_docker_client", client.Close) + return NewDockerHealthMonitor(client, cont.ContainerID, r.Name(), r.HealthCheckConfig(), mon) + } + return mon +} + +func newMonitor(url *url.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { mon := &monitor{ config: config, checkHealth: healthCheckFunc, @@ -63,7 +88,7 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error { } mon.service = parent.Name() - mon.task = parent.Subtask("health_monitor") + mon.task = parent.Subtask("health_monitor", true) go func() { logger := logging.With().Str("name", mon.service).Logger() @@ -72,9 +97,6 @@ func (mon *monitor) Start(parent task.Parent) gperr.Error { if mon.status.Load() != health.StatusError { mon.status.Store(health.StatusUnknown) } - if mon.metric != nil { - mon.metric.Reset() - } mon.task.Finish(nil) }() @@ -113,12 +135,12 @@ func (mon *monitor) Finish(reason any) { } // UpdateURL implements HealthChecker. -func (mon *monitor) UpdateURL(url *types.URL) { +func (mon *monitor) UpdateURL(url *url.URL) { mon.url.Store(url) } // URL implements HealthChecker. -func (mon *monitor) URL() *types.URL { +func (mon *monitor) URL() *url.URL { return mon.url.Load() } @@ -157,8 +179,8 @@ func (mon *monitor) String() string { return mon.Name() } -// MarshalJSON implements json.Marshaler of HealthMonitor. -func (mon *monitor) MarshalJSON() ([]byte, error) { +// MarshalMap implements health.HealthMonitor. +func (mon *monitor) MarshalMap() map[string]any { res := mon.lastResult.Load() if res == nil { res = &health.HealthCheckResult{ @@ -166,7 +188,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) { } } - return (&JSONRepresentation{ + return (&health.JSONRepresentation{ Name: mon.service, Config: mon.config, Status: mon.status.Load(), @@ -176,7 +198,7 @@ func (mon *monitor) MarshalJSON() ([]byte, error) { LastSeen: GetLastSeen(mon.service), Detail: res.Detail, URL: mon.url.Load(), - }).MarshalJSON() + }).MarshalMap() } func (mon *monitor) checkUpdateHealth() error { @@ -230,9 +252,6 @@ func (mon *monitor) checkUpdateHealth() error { }) } } - if mon.metric != nil { - mon.metric.Set(float64(status)) - } return nil } diff --git a/internal/watcher/health/monitor/raw.go b/internal/watcher/health/monitor/raw.go index 838d47a..eef6004 100644 --- a/internal/watcher/health/monitor/raw.go +++ b/internal/watcher/health/monitor/raw.go @@ -2,9 +2,9 @@ package monitor import ( "net" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -15,7 +15,7 @@ type ( } ) -func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawHealthMonitor { +func NewRawHealthMonitor(url *url.URL, config *health.HealthCheckConfig) *RawHealthMonitor { mon := new(RawHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) mon.dialer = &net.Dialer{ @@ -25,10 +25,6 @@ func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawH return mon } -func NewRawHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker { - return NewRawHealthMonitor(url, config) -} - func (mon *RawHealthMonitor) CheckHealth() (result *health.HealthCheckResult, err error) { ctx, cancel := mon.ContextWithTimeout("ping request timed out") defer cancel() diff --git a/internal/watcher/health/status.go b/internal/watcher/health/status.go index 355dd70..105c5e7 100644 --- a/internal/watcher/health/status.go +++ b/internal/watcher/health/status.go @@ -1,7 +1,5 @@ package health -import "encoding/json" - type Status uint8 const ( @@ -35,32 +33,6 @@ func (s Status) String() string { } } -func (s Status) MarshalJSON() ([]byte, error) { - return []byte(`"` + s.String() + `"`), nil -} - -func (s *Status) UnmarshalJSON(data []byte) error { - var str string - if err := json.Unmarshal(data, &str); err != nil { - return err - } - switch str { - case "healthy": - *s = StatusHealthy - case "unhealthy": - *s = StatusUnhealthy - case "napping": - *s = StatusNapping - case "starting": - *s = StatusStarting - case "error": - *s = StatusError - default: - *s = StatusUnknown - } - return nil -} - func (s Status) Good() bool { return s&HealthyMask != 0 } diff --git a/internal/watcher/health/types.go b/internal/watcher/health/types.go index 4c8c0c5..fe89533 100644 --- a/internal/watcher/health/types.go +++ b/internal/watcher/health/types.go @@ -1,11 +1,10 @@ package health import ( - "encoding/json" "fmt" + "net/url" "time" - "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/task" ) @@ -24,14 +23,17 @@ type ( task.TaskStarter task.TaskFinisher fmt.Stringer - json.Marshaler WithHealthInfo Name() string } HealthChecker interface { CheckHealth() (result *HealthCheckResult, err error) - URL() *types.URL + URL() *url.URL Config() *HealthCheckConfig - UpdateURL(url *types.URL) + UpdateURL(url *url.URL) + } + HealthMonCheck interface { + HealthMonitor + HealthChecker } ) diff --git a/schemas/Makefile b/schemas/Makefile new file mode 100644 index 0000000..9ed26b3 --- /dev/null +++ b/schemas/Makefile @@ -0,0 +1,33 @@ +# To generate schema +# comment out this part from typescript-json-schema.js#L884 +# +# if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) { +# throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string"); +# } + +gen-schema-single: + bun -bun typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o "${OUT}" "${IN}" ${CLASS} + # minify + python3 -c "import json; f=open('${OUT}', 'r'); j=json.load(f); f.close(); f=open('${OUT}', 'w'); json.dump(j, f, separators=(',', ':'));" + +gen-schema: + bun -bun tsc + sed -i 's#"type": "module"#"type": "commonjs"#' package.json + make IN=config/config.ts \ + CLASS=Config \ + OUT=config.schema.json \ + gen-schema-single + make IN=providers/routes.ts \ + CLASS=Routes \ + OUT=routes.schema.json \ + gen-schema-single + make IN=middlewares/middleware_compose.ts \ + CLASS=MiddlewareCompose \ + OUT=middleware_compose.schema.json \ + gen-schema-single + make IN=docker.ts \ + CLASS=DockerRoutes \ + OUT=docker_routes.schema.json \ + gen-schema-single + sed -i 's#"type": "commonjs"#"type": "module"#' package.json + bun format:write \ No newline at end of file diff --git a/schemas/bun.lock b/schemas/bun.lock index e12e73c..d4c0376 100644 --- a/schemas/bun.lock +++ b/schemas/bun.lock @@ -2,10 +2,10 @@ "lockfileVersion": 1, "workspaces": { "": { - "name": "godoxy-types", + "name": "godoxy-schemas", "devDependencies": { - "prettier": "^3.4.2", - "typescript": "^5.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.3", "typescript-json-schema": "^0.65.1", }, }, @@ -29,9 +29,9 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@18.19.74", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A=="], + "@types/node": ["@types/node@18.19.86", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ=="], - "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], @@ -83,7 +83,7 @@ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="], + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -95,7 +95,7 @@ "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript-json-schema": ["typescript-json-schema@0.65.1", "", { "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { "typescript-json-schema": "bin/typescript-json-schema" } }, "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg=="], diff --git a/schemas/config/access_log.d.ts b/schemas/config/access_log.d.ts index 7d3c304..7f57c49 100644 --- a/schemas/config/access_log.d.ts +++ b/schemas/config/access_log.d.ts @@ -1,49 +1,57 @@ import { CIDR, HTTPHeader, HTTPMethod, StatusCodeRange, URI } from "../types"; -export declare const ACCESS_LOG_FORMATS: readonly ["combined", "common", "json"]; +export declare const ACCESS_LOG_FORMATS: readonly [ + "combined", + "common", + "json", +]; export type AccessLogFormat = (typeof ACCESS_LOG_FORMATS)[number]; export type AccessLogConfig = { - /** - * The size of the buffer. - * - * @minimum 0 - * @default 65536 - * @TJS-type integer - */ - buffer_size?: number; - /** The format of the access log. - * - * @default "combined" - */ - format?: AccessLogFormat; - path: URI; - filters?: AccessLogFilters; - fields?: AccessLogFields; + /** + * The size of the buffer. + * + * @minimum 0 + * @default 65536 + * @TJS-type integer + */ + buffer_size?: number; + /** The format of the access log. + * + * @default "combined" + */ + format?: AccessLogFormat; + path: URI; + filters?: AccessLogFilters; + fields?: AccessLogFields; }; export type AccessLogFilter = { - /** Whether the filter is negative. - * - * @default false - */ - negative?: boolean; - values: T[]; + /** Whether the filter is negative. + * + * @default false + */ + negative?: boolean; + values: T[]; }; export type AccessLogFilters = { - status_code?: AccessLogFilter; - method?: AccessLogFilter; - host?: AccessLogFilter; - headers?: AccessLogFilter; - cidr?: AccessLogFilter; + status_code?: AccessLogFilter; + method?: AccessLogFilter; + host?: AccessLogFilter; + headers?: AccessLogFilter; + cidr?: AccessLogFilter; }; -export declare const ACCESS_LOG_FIELD_MODES: readonly ["keep", "drop", "redact"]; +export declare const ACCESS_LOG_FIELD_MODES: readonly [ + "keep", + "drop", + "redact", +]; export type AccessLogFieldMode = (typeof ACCESS_LOG_FIELD_MODES)[number]; export type AccessLogField = { - default?: AccessLogFieldMode; - config: { - [key: string]: AccessLogFieldMode; - }; + default?: AccessLogFieldMode; + config: { + [key: string]: AccessLogFieldMode; + }; }; export type AccessLogFields = { - header?: AccessLogField; - query?: AccessLogField; - cookie?: AccessLogField; + header?: AccessLogField; + query?: AccessLogField; + cookie?: AccessLogField; }; diff --git a/schemas/config/autocert.d.ts b/schemas/config/autocert.d.ts index ac7630e..702f91e 100644 --- a/schemas/config/autocert.d.ts +++ b/schemas/config/autocert.d.ts @@ -1,66 +1,88 @@ import { DomainOrWildcard, Email } from "../types"; -export declare const AUTOCERT_PROVIDERS: readonly ["local", "cloudflare", "clouddns", "duckdns", "ovh", "porkbun"]; +export declare const AUTOCERT_PROVIDERS: readonly [ + "local", + "cloudflare", + "clouddns", + "duckdns", + "ovh", + "porkbun", +]; export type AutocertProvider = (typeof AUTOCERT_PROVIDERS)[number]; -export type AutocertConfig = LocalOptions | CloudflareOptions | CloudDNSOptions | DuckDNSOptions | OVHOptionsWithAppKey | OVHOptionsWithOAuth2Config | PorkbunOptions; +export type AutocertConfig = + | LocalOptions + | CloudflareOptions + | CloudDNSOptions + | DuckDNSOptions + | OVHOptionsWithAppKey + | OVHOptionsWithOAuth2Config + | PorkbunOptions; export interface AutocertConfigBase { - email: Email; - domains: DomainOrWildcard[]; - cert_path?: string; - key_path?: string; + email: Email; + domains: DomainOrWildcard[]; + cert_path?: string; + key_path?: string; } export interface LocalOptions { - provider: "local"; - cert_path?: string; - key_path?: string; - options?: {} | null; + provider: "local"; + cert_path?: string; + key_path?: string; + options?: {} | null; } export interface CloudflareOptions extends AutocertConfigBase { - provider: "cloudflare"; - options: { - auth_token: string; - }; + provider: "cloudflare"; + options: { + auth_token: string; + }; } export interface CloudDNSOptions extends AutocertConfigBase { - provider: "clouddns"; - options: { - client_id: string; - email: Email; - password: string; - }; + provider: "clouddns"; + options: { + client_id: string; + email: Email; + password: string; + }; } export interface DuckDNSOptions extends AutocertConfigBase { - provider: "duckdns"; - options: { - token: string; - }; + provider: "duckdns"; + options: { + token: string; + }; } export interface PorkbunOptions extends AutocertConfigBase { - provider: "porkbun"; - options: { - api_key: string; - secret_api_key: string; - }; + provider: "porkbun"; + options: { + api_key: string; + secret_api_key: string; + }; } -export declare const OVH_ENDPOINTS: readonly ["ovh-eu", "ovh-ca", "ovh-us", "kimsufi-eu", "kimsufi-ca", "soyoustart-eu", "soyoustart-ca"]; +export declare const OVH_ENDPOINTS: readonly [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +]; export type OVHEndpoint = (typeof OVH_ENDPOINTS)[number]; export interface OVHOptionsWithAppKey extends AutocertConfigBase { - provider: "ovh"; - options: { - application_secret: string; - consumer_key: string; - api_endpoint?: OVHEndpoint; - application_key: string; - }; + provider: "ovh"; + options: { + application_secret: string; + consumer_key: string; + api_endpoint?: OVHEndpoint; + application_key: string; + }; } export interface OVHOptionsWithOAuth2Config extends AutocertConfigBase { - provider: "ovh"; - options: { - application_secret: string; - consumer_key: string; - api_endpoint?: OVHEndpoint; - oauth2_config: { - client_id: string; - client_secret: string; - }; + provider: "ovh"; + options: { + application_secret: string; + consumer_key: string; + api_endpoint?: OVHEndpoint; + oauth2_config: { + client_id: string; + client_secret: string; }; + }; } diff --git a/schemas/config/config.d.ts b/schemas/config/config.d.ts index b753f41..e88116f 100644 --- a/schemas/config/config.d.ts +++ b/schemas/config/config.d.ts @@ -4,51 +4,58 @@ import { EntrypointConfig } from "./entrypoint"; import { HomepageConfig } from "./homepage"; import { Providers } from "./providers"; export type Config = { - /** Optional autocert configuration - * - * @examples require(".").autocertExamples - */ - autocert?: AutocertConfig; - entrypoint?: EntrypointConfig; - providers: Providers; - /** Optional list of domains to match - * - * @minItems 1 - * @examples require(".").matchDomainsExamples - */ - match_domains?: DomainName[]; - homepage?: HomepageConfig; - /** - * Optional timeout before shutdown - * @default 3 - * @minimum 1 - */ - timeout_shutdown?: number; + /** Optional autocert configuration + * + * @examples require(".").autocertExamples + */ + autocert?: AutocertConfig; + entrypoint?: EntrypointConfig; + providers: Providers; + /** Optional list of domains to match + * + * @minItems 1 + * @examples require(".").matchDomainsExamples + */ + match_domains?: DomainName[]; + homepage?: HomepageConfig; + /** + * Optional timeout before shutdown + * @default 3 + * @minimum 1 + */ + timeout_shutdown?: number; }; -export declare const autocertExamples: ({ - provider: string; - email?: undefined; - domains?: undefined; - options?: undefined; -} | { - provider: string; - email: string; - domains: string[]; - options: { +export declare const autocertExamples: ( + | { + provider: string; + email?: undefined; + domains?: undefined; + options?: undefined; + } + | { + provider: string; + email: string; + domains: string[]; + options: { auth_token: string; client_id?: undefined; email?: undefined; password?: undefined; - }; -} | { - provider: string; - email: string; - domains: string[]; - options: { + }; + } + | { + provider: string; + email: string; + domains: string[]; + options: { client_id: string; email: string; password: string; auth_token?: undefined; - }; -})[]; -export declare const matchDomainsExamples: readonly ["example.com", "*.example.com"]; + }; + } +)[]; +export declare const matchDomainsExamples: readonly [ + "example.com", + "*.example.com", +]; diff --git a/schemas/config/entrypoint.d.ts b/schemas/config/entrypoint.d.ts index d436665..7584dd6 100644 --- a/schemas/config/entrypoint.d.ts +++ b/schemas/config/entrypoint.d.ts @@ -1,39 +1,49 @@ import { MiddlewareCompose } from "../middlewares/middleware_compose"; import { AccessLogConfig } from "./access_log"; export type EntrypointConfig = { - /** Entrypoint middleware configuration - * - * @examples require(".").middlewaresExamples - */ - middlewares?: MiddlewareCompose; - /** Entrypoint access log configuration - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Entrypoint middleware configuration + * + * @examples require(".").middlewaresExamples + */ + middlewares?: MiddlewareCompose; + /** Entrypoint access log configuration + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; }; -export declare const accessLogExamples: readonly [{ +export declare const accessLogExamples: readonly [ + { readonly path: "/var/log/access.log"; readonly format: "combined"; readonly filters: { - readonly status_codes: { - readonly values: readonly ["200-299"]; - }; + readonly status_codes: { + readonly values: readonly ["200-299"]; + }; }; readonly fields: { - readonly headers: { - readonly default: "keep"; - readonly config: { - readonly foo: "redact"; - }; + readonly headers: { + readonly default: "keep"; + readonly config: { + readonly foo: "redact"; }; + }; }; -}]; -export declare const middlewaresExamples: readonly [{ + }, +]; +export declare const middlewaresExamples: readonly [ + { readonly use: "RedirectHTTP"; -}, { + }, + { readonly use: "CIDRWhitelist"; - readonly allow: readonly ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + readonly allow: readonly [ + "127.0.0.1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ]; readonly status: 403; readonly message: "Forbidden"; -}]; + }, +]; diff --git a/schemas/config/homepage.d.ts b/schemas/config/homepage.d.ts index 0ac498d..31f783b 100644 --- a/schemas/config/homepage.d.ts +++ b/schemas/config/homepage.d.ts @@ -1,7 +1,7 @@ export type HomepageConfig = { - /** - * Use default app categories (uses docker image name) - * @default true - */ - use_default_categories: boolean; + /** + * Use default app categories (uses docker image name) + * @default true + */ + use_default_categories: boolean; }; diff --git a/schemas/config/notification.d.ts b/schemas/config/notification.d.ts index 0cf8610..8bf6552 100644 --- a/schemas/config/notification.d.ts +++ b/schemas/config/notification.d.ts @@ -1,60 +1,69 @@ import { URL } from "../types"; -export declare const NOTIFICATION_PROVIDERS: readonly ["webhook", "gotify", "ntfy"]; +export declare const NOTIFICATION_PROVIDERS: readonly [ + "webhook", + "gotify", + "ntfy", +]; export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number]; export type NotificationConfig = { - name: string; - url: URL; + name: string; + url: URL; }; export interface GotifyConfig extends NotificationConfig { - provider: "gotify"; - token: string; + provider: "gotify"; + token: string; } export declare const NTFY_MSG_STYLES: string[]; export type NtfyStyle = (typeof NTFY_MSG_STYLES)[number]; export interface NtfyConfig extends NotificationConfig { - provider: "ntfy"; - topic: string; - token?: string; - style?: NtfyStyle; + provider: "ntfy"; + topic: string; + token?: string; + style?: NtfyStyle; } export declare const WEBHOOK_TEMPLATES: readonly ["", "discord"]; export declare const WEBHOOK_METHODS: readonly ["POST", "GET", "PUT"]; -export declare const WEBHOOK_MIME_TYPES: readonly ["application/json", "application/x-www-form-urlencoded", "text/plain", "text/markdown"]; +export declare const WEBHOOK_MIME_TYPES: readonly [ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", + "text/markdown", +]; export declare const WEBHOOK_COLOR_MODES: readonly ["hex", "dec"]; export type WebhookTemplate = (typeof WEBHOOK_TEMPLATES)[number]; export type WebhookMethod = (typeof WEBHOOK_METHODS)[number]; export type WebhookMimeType = (typeof WEBHOOK_MIME_TYPES)[number]; export type WebhookColorMode = (typeof WEBHOOK_COLOR_MODES)[number]; export interface WebhookConfig extends NotificationConfig { - provider: "webhook"; - /** - * Webhook template - * - * @default "discord" - */ - template?: WebhookTemplate; - token?: string; - /** - * Webhook message (usally JSON), - * required when template is not defined - */ - payload?: string; - /** - * Webhook method - * - * @default "POST" - */ - method?: WebhookMethod; - /** - * Webhook mime type - * - * @default "application/json" - */ - mime_type?: WebhookMimeType; - /** - * Webhook color mode - * - * @default "hex" - */ - color_mode?: WebhookColorMode; + provider: "webhook"; + /** + * Webhook template + * + * @default "discord" + */ + template?: WebhookTemplate; + token?: string; + /** + * Webhook message (usally JSON), + * required when template is not defined + */ + payload?: string; + /** + * Webhook method + * + * @default "POST" + */ + method?: WebhookMethod; + /** + * Webhook mime type + * + * @default "application/json" + */ + mime_type?: WebhookMimeType; + /** + * Webhook color mode + * + * @default "hex" + */ + color_mode?: WebhookColorMode; } diff --git a/schemas/config/providers.d.ts b/schemas/config/providers.d.ts index ad2816f..c91d083 100644 --- a/schemas/config/providers.d.ts +++ b/schemas/config/providers.d.ts @@ -1,51 +1,58 @@ import { URI, URL } from "../types"; import { GotifyConfig, NtfyConfig, WebhookConfig } from "./notification"; export type Providers = { - /** List of route definition files to include - * - * @minItems 1 - * @examples require(".").includeExamples - * @items.pattern ^[\w\d\-_]+\.(yaml|yml)$ - */ - include?: URI[]; - /** Name-value mapping of docker hosts to retrieve routes from - * - * @minProperties 1 - * @examples require(".").dockerExamples - */ - docker?: { - [name: string]: URL | "$DOCKER_HOST"; - }; - /** List of GoDoxy agents - * - * @minItems 1 - * @examples require(".").agentExamples - */ - agents?: `${string}:${number}`[]; - /** List of notification providers - * - * @minItems 1 - * @examples require(".").notificationExamples - */ - notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[]; + /** List of route definition files to include + * + * @minItems 1 + * @examples require(".").includeExamples + * @items.pattern ^[\w\d\-_]+\.(yaml|yml)$ + */ + include?: URI[]; + /** Name-value mapping of docker hosts to retrieve routes from + * + * @minProperties 1 + * @examples require(".").dockerExamples + */ + docker?: { + [name: string]: URL | "$DOCKER_HOST"; + }; + /** List of GoDoxy agents + * + * @minItems 1 + * @examples require(".").agentExamples + */ + agents?: `${string}:${number}`[]; + /** List of notification providers + * + * @minItems 1 + * @examples require(".").notificationExamples + */ + notification?: (WebhookConfig | GotifyConfig | NtfyConfig)[]; }; export declare const includeExamples: readonly ["file1.yml", "file2.yml"]; -export declare const dockerExamples: readonly [{ +export declare const dockerExamples: readonly [ + { readonly local: "$DOCKER_HOST"; -}, { + }, + { readonly remote: "tcp://10.0.2.1:2375"; -}, { + }, + { readonly remote2: "ssh://root:1234@10.0.2.2"; -}]; -export declare const notificationExamples: readonly [{ + }, +]; +export declare const notificationExamples: readonly [ + { readonly name: "gotify"; readonly provider: "gotify"; readonly url: "https://gotify.domain.tld"; readonly token: "abcd"; -}, { + }, + { readonly name: "discord"; readonly provider: "webhook"; readonly template: "discord"; readonly url: "https://discord.com/api/webhooks/1234/abcd"; -}]; + }, +]; export declare const agentExamples: readonly ["10.0.2.3:8890", "10.0.2.4:8890"]; diff --git a/schemas/docker.d.ts b/schemas/docker.d.ts index 3b3c06d..02884ad 100644 --- a/schemas/docker.d.ts +++ b/schemas/docker.d.ts @@ -1,5 +1,5 @@ import { IdleWatcherConfig } from "./providers/idlewatcher"; import { Route } from "./providers/routes"; export type DockerRoutes = { - [key: string]: Route & IdleWatcherConfig; + [key: string]: Route & IdleWatcherConfig; }; diff --git a/schemas/docker_routes.schema.json b/schemas/docker_routes.schema.json index 26fe7fb..32025ef 100644 --- a/schemas/docker_routes.schema.json +++ b/schemas/docker_routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"idle_timeout":{"$ref":"#/definitions/Duration"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port"],"type":"object"}]},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"idle_timeout":{"$ref":"#/definitions/Duration"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port"],"type":"object"}]},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file diff --git a/schemas/index.d.ts b/schemas/index.d.ts index 0582c30..f078530 100644 --- a/schemas/index.d.ts +++ b/schemas/index.d.ts @@ -16,4 +16,23 @@ import ConfigSchema from "./config.schema.json"; import DockerRoutesSchema from "./docker_routes.schema.json"; import MiddlewareComposeSchema from "./middleware_compose.schema.json"; import RoutesSchema from "./routes.schema.json"; -export { AccessLog, Autocert, Config, ConfigSchema, DockerRoutesSchema, Entrypoint, GoDoxy, Healthcheck, Homepage, IdleWatcher, LoadBalance, MiddlewareCompose, MiddlewareComposeSchema, Middlewares, Notification, Providers, Routes, RoutesSchema, }; +export { + AccessLog, + Autocert, + Config, + ConfigSchema, + DockerRoutesSchema, + Entrypoint, + GoDoxy, + Healthcheck, + Homepage, + IdleWatcher, + LoadBalance, + MiddlewareCompose, + MiddlewareComposeSchema, + Middlewares, + Notification, + Providers, + Routes, + RoutesSchema, +}; diff --git a/schemas/middlewares/middlewares.d.ts b/schemas/middlewares/middlewares.d.ts index c7ad0c5..4a0df0d 100644 --- a/schemas/middlewares/middlewares.d.ts +++ b/schemas/middlewares/middlewares.d.ts @@ -1,133 +1,186 @@ import * as types from "../types"; -export type KeyOptMapping = { - [key in T["use"]]?: Omit; + }, +> = { + [key in T["use"]]?: Omit; }; -export declare const ALL_MIDDLEWARES: readonly ["ErrorPage", "RedirectHTTP", "SetXForwarded", "HideXForwarded", "CIDRWhitelist", "CloudflareRealIP", "ModifyRequest", "ModifyResponse", "OIDC", "RateLimit", "RealIP"]; +export declare const ALL_MIDDLEWARES: readonly [ + "ErrorPage", + "RedirectHTTP", + "SetXForwarded", + "HideXForwarded", + "CIDRWhitelist", + "CloudflareRealIP", + "ModifyRequest", + "ModifyResponse", + "OIDC", + "RateLimit", + "RealIP", +]; /** * @type object * @patternProperties {"^.*@file$": {"type": "null"}} */ export type MiddlewareFileRef = { - [key: `${string}@file`]: null; + [key: `${string}@file`]: null; }; -export type MiddlewaresMap = (KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping & KeyOptMapping) | MiddlewareFileRef; -export type MiddlewareComposeMap = CustomErrorPage | RedirectHTTP | SetXForwarded | HideXForwarded | CIDRWhitelist | CloudflareRealIP | ModifyRequest | ModifyResponse | OIDC | RateLimit | RealIP; +export type MiddlewaresMap = + | (KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping & + KeyOptMapping) + | MiddlewareFileRef; +export type MiddlewareComposeMap = + | CustomErrorPage + | RedirectHTTP + | SetXForwarded + | HideXForwarded + | CIDRWhitelist + | CloudflareRealIP + | ModifyRequest + | ModifyResponse + | OIDC + | RateLimit + | RealIP; export type CustomErrorPage = { - use: "error_page" | "errorPage" | "ErrorPage" | "custom_error_page" | "customErrorPage" | "CustomErrorPage"; + use: + | "error_page" + | "errorPage" + | "ErrorPage" + | "custom_error_page" + | "customErrorPage" + | "CustomErrorPage"; }; export type RedirectHTTP = { - use: "redirect_http" | "redirectHTTP" | "RedirectHTTP"; - /** Bypass redirect */ - bypass?: { - /** Bypass redirect for user agents */ - user_agents?: string[]; - }; + use: "redirect_http" | "redirectHTTP" | "RedirectHTTP"; + /** Bypass redirect */ + bypass?: { + /** Bypass redirect for user agents */ + user_agents?: string[]; + }; }; export type SetXForwarded = { - use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded"; + use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded"; }; export type HideXForwarded = { - use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded"; + use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded"; }; export type CIDRWhitelist = { - use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist"; - allow: types.CIDR[]; - /** HTTP status code when blocked - * - * @default 403 - */ - status_code?: types.StatusCode; - /** HTTP status code when blocked (alias of status_code) - * - * @default 403 - */ - status?: types.StatusCode; - /** Error message when blocked - * - * @default "IP not allowed" - */ - message?: string; + use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist"; + allow: types.CIDR[]; + /** HTTP status code when blocked + * + * @default 403 + */ + status_code?: types.StatusCode; + /** HTTP status code when blocked (alias of status_code) + * + * @default 403 + */ + status?: types.StatusCode; + /** Error message when blocked + * + * @default "IP not allowed" + */ + message?: string; }; export type CloudflareRealIP = { - use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP"; - /** Recursively resolve the IP - * - * @default false - */ - recursive?: boolean; + use: "cloudflare_real_ip" | "cloudflareRealIp" | "CloudflareRealIP"; + /** Recursively resolve the IP + * + * @default false + */ + recursive?: boolean; }; export type ModifyRequest = { - use: "request" | "Request" | "modify_request" | "modifyRequest" | "ModifyRequest"; - /** Set HTTP headers */ - set_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Add HTTP headers */ - add_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Hide HTTP headers */ - hide_headers?: types.HTTPHeader[]; - /** Add prefix to request URL */ - add_prefix?: string; + use: + | "request" + | "Request" + | "modify_request" + | "modifyRequest" + | "ModifyRequest"; + /** Set HTTP headers */ + set_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Add HTTP headers */ + add_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Hide HTTP headers */ + hide_headers?: types.HTTPHeader[]; + /** Add prefix to request URL */ + add_prefix?: string; }; export type ModifyResponse = { - use: "response" | "Response" | "modify_response" | "modifyResponse" | "ModifyResponse"; - /** Set HTTP headers */ - set_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Add HTTP headers */ - add_headers?: { - [key: types.HTTPHeader]: string; - }; - /** Hide HTTP headers */ - hide_headers?: types.HTTPHeader[]; + use: + | "response" + | "Response" + | "modify_response" + | "modifyResponse" + | "ModifyResponse"; + /** Set HTTP headers */ + set_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Add HTTP headers */ + add_headers?: { + [key: types.HTTPHeader]: string; + }; + /** Hide HTTP headers */ + hide_headers?: types.HTTPHeader[]; }; export type OIDC = { - use: "oidc" | "OIDC"; - /** Allowed users - * - * @minItems 1 - */ - allowed_users?: string[]; - /** Allowed groups - * - * @minItems 1 - */ - allowed_groups?: string[]; + use: "oidc" | "OIDC"; + /** Allowed users + * + * @minItems 1 + */ + allowed_users?: string[]; + /** Allowed groups + * + * @minItems 1 + */ + allowed_groups?: string[]; }; export type RateLimit = { - use: "rate_limit" | "rateLimit" | "RateLimit"; - /** Average number of requests allowed in a period - * - * @min 1 - */ - average: number; - /** Maximum number of requests allowed in a period - * - * @min 1 - */ - burst: number; - /** Duration of the rate limit - * - * @default 1s - */ - period?: types.Duration; + use: "rate_limit" | "rateLimit" | "RateLimit"; + /** Average number of requests allowed in a period + * + * @min 1 + */ + average: number; + /** Maximum number of requests allowed in a period + * + * @min 1 + */ + burst: number; + /** Duration of the rate limit + * + * @default 1s + */ + period?: types.Duration; }; export type RealIP = { - use: "real_ip" | "realIP" | "RealIP"; - /** Header to get the client IP from - * - * @default "X-Real-IP" - */ - header?: types.HTTPHeader; - from: types.CIDR[]; - /** Recursive resolve the IP - * - * @default false - */ - recursive?: boolean; + use: "real_ip" | "realIP" | "RealIP"; + /** Header to get the client IP from + * + * @default "X-Real-IP" + */ + header?: types.HTTPHeader; + from: types.CIDR[]; + /** Recursive resolve the IP + * + * @default false + */ + recursive?: boolean; }; diff --git a/schemas/package.json b/schemas/package.json index 5a34c4f..1ca9a19 100644 --- a/schemas/package.json +++ b/schemas/package.json @@ -1,6 +1,6 @@ { "name": "godoxy-schemas", - "version": "0.10.0-3", + "version": "0.10.1-6", "description": "JSON Schema and typescript types for GoDoxy configuration", "license": "MIT", "repository": { @@ -8,9 +8,11 @@ "url": "https://github.com/yusing/godoxy" }, "files": [ - "schemas/", - "README.md", - "LICENSE" + "**/*.ts", + "**/*.js", + "*.schema.json", + "../README.md", + "../LICENSE" ], "type": "module", "main": "./index.ts", @@ -22,15 +24,14 @@ } }, "devDependencies": { - "prettier": "^3.4.2", - "typescript": "^5.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.3", "typescript-json-schema": "^0.65.1" }, "displayName": "GoDoxy Types", - "packageManager": "bun@1.2.0", + "packageManager": "bun@1.2.9", "publisher": "yusing", "scripts": { - "gen-schema": "make gen-schema", - "format:write": "prettier --write \"schemas/**/*.ts\" --cache" + "format:write": "prettier --write \"**/*.ts\" --cache" } } \ No newline at end of file diff --git a/schemas/providers/healthcheck.d.ts b/schemas/providers/healthcheck.d.ts index c819e54..05b6df5 100644 --- a/schemas/providers/healthcheck.d.ts +++ b/schemas/providers/healthcheck.d.ts @@ -3,30 +3,30 @@ import { Duration, URI } from "../types"; * @additionalProperties false */ export type HealthcheckConfig = { - /** Disable healthcheck - * - * @default false - */ - disable?: boolean; - /** Healthcheck path - * - * @default / - */ - path?: URI; - /** - * Use GET instead of HEAD - * - * @default false - */ - use_get?: boolean; - /** Healthcheck interval - * - * @default 5s - */ - interval?: Duration; - /** Healthcheck timeout - * - * @default 5s - */ - timeout?: Duration; + /** Disable healthcheck + * + * @default false + */ + disable?: boolean; + /** Healthcheck path + * + * @default / + */ + path?: URI; + /** + * Use GET instead of HEAD + * + * @default false + */ + use_get?: boolean; + /** Healthcheck interval + * + * @default 5s + */ + interval?: Duration; + /** Healthcheck timeout + * + * @default 5s + */ + timeout?: Duration; }; diff --git a/schemas/providers/homepage.d.ts b/schemas/providers/homepage.d.ts index db64be9..a51a129 100644 --- a/schemas/providers/homepage.d.ts +++ b/schemas/providers/homepage.d.ts @@ -3,19 +3,19 @@ import { URL } from "../types"; * @additionalProperties false */ export type HomepageConfig = { - /** Whether show in dashboard - * - * @default true - */ - show?: boolean; - name?: string; - icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath; - description?: string; - url?: URL; - category?: string; - widget_config?: { - [key: string]: any; - }; + /** Whether show in dashboard + * + * @default true + */ + show?: boolean; + name?: string; + icon?: URL | WalkxcodeIcon | ExternalIcon | TargetRelativeIconPath; + description?: string; + url?: URL; + category?: string; + widget_config?: { + [key: string]: any; + }; }; /** Walkxcode icon * diff --git a/schemas/providers/idlewatcher.d.ts b/schemas/providers/idlewatcher.d.ts index d9aed44..ed5daf5 100644 --- a/schemas/providers/idlewatcher.d.ts +++ b/schemas/providers/idlewatcher.d.ts @@ -1,25 +1,35 @@ import { Duration, URI } from "../types"; export declare const STOP_METHODS: readonly ["pause", "stop", "kill"]; export type StopMethod = (typeof STOP_METHODS)[number]; -export declare const STOP_SIGNALS: readonly ["", "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "INT", "TERM", "HUP", "QUIT"]; +export declare const STOP_SIGNALS: readonly [ + "", + "SIGINT", + "SIGTERM", + "SIGHUP", + "SIGQUIT", + "INT", + "TERM", + "HUP", + "QUIT", +]; export type Signal = (typeof STOP_SIGNALS)[number]; export type IdleWatcherConfig = { - idle_timeout?: Duration; - /** Wake timeout - * - * @default 30s - */ - wake_timeout?: Duration; - /** Stop timeout - * - * @default 30s - */ - stop_timeout?: Duration; - /** Stop method - * - * @default stop - */ - stop_method?: StopMethod; - stop_signal?: Signal; - start_endpoint?: URI; + idle_timeout?: Duration; + /** Wake timeout + * + * @default 30s + */ + wake_timeout?: Duration; + /** Stop timeout + * + * @default 30s + */ + stop_timeout?: Duration; + /** Stop method + * + * @default stop + */ + stop_method?: StopMethod; + stop_signal?: Signal; + start_endpoint?: URI; }; diff --git a/schemas/providers/loadbalance.d.ts b/schemas/providers/loadbalance.d.ts index 8ea4243..c2ccdcd 100644 --- a/schemas/providers/loadbalance.d.ts +++ b/schemas/providers/loadbalance.d.ts @@ -1,28 +1,38 @@ import { RealIP } from "../middlewares/middlewares"; -export declare const LOAD_BALANCE_MODES: readonly ["round_robin", "least_conn", "ip_hash"]; +export declare const LOAD_BALANCE_MODES: readonly [ + "round_robin", + "least_conn", + "ip_hash", +]; export type LoadBalanceMode = (typeof LOAD_BALANCE_MODES)[number]; export type LoadBalanceConfigBase = { - /** Alias (subdomain or FDN) of load-balancer - * - * @minLength 1 - */ - link: string; - /** Load-balance weight (reserved for future use) - * - * @minimum 0 - * @maximum 100 - */ - weight?: number; + /** Alias (subdomain or FDN) of load-balancer + * + * @minLength 1 + */ + link: string; + /** Load-balance weight (reserved for future use) + * + * @minimum 0 + * @maximum 100 + */ + weight?: number; }; -export type LoadBalanceConfig = LoadBalanceConfigBase & ({} | RoundRobinLoadBalanceConfig | LeastConnLoadBalanceConfig | IPHashLoadBalanceConfig); +export type LoadBalanceConfig = LoadBalanceConfigBase & + ( + | {} + | RoundRobinLoadBalanceConfig + | LeastConnLoadBalanceConfig + | IPHashLoadBalanceConfig + ); export type IPHashLoadBalanceConfig = { - mode: "ip_hash"; - /** Real IP config, header to get client IP from */ - config: RealIP; + mode: "ip_hash"; + /** Real IP config, header to get client IP from */ + config: RealIP; }; export type LeastConnLoadBalanceConfig = { - mode: "least_conn"; + mode: "least_conn"; }; export type RoundRobinLoadBalanceConfig = { - mode: "round_robin"; + mode: "round_robin"; }; diff --git a/schemas/providers/routes.d.ts b/schemas/providers/routes.d.ts index 1a5564b..4aaed50 100644 --- a/schemas/providers/routes.d.ts +++ b/schemas/providers/routes.d.ts @@ -1,7 +1,15 @@ import { AccessLogConfig } from "../config/access_log"; import { accessLogExamples } from "../config/entrypoint"; import { MiddlewaresMap } from "../middlewares/middlewares"; -import { Duration, Hostname, IPv4, IPv6, PathPattern, Port, StreamPort } from "../types"; +import { + Duration, + Hostname, + IPv4, + IPv6, + PathPattern, + Port, + StreamPort, +} from "../types"; import { HealthcheckConfig } from "./healthcheck"; import { HomepageConfig } from "./homepage"; import { LoadBalanceConfig } from "./loadbalance"; @@ -11,122 +19,130 @@ export type ProxyScheme = (typeof PROXY_SCHEMES)[number]; export type StreamScheme = (typeof STREAM_SCHEMES)[number]; export type Route = ReverseProxyRoute | FileServerRoute | StreamRoute; export type Routes = { - [key: string]: Route; + [key: string]: Route; }; export type ReverseProxyRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - /** Proxy scheme - * - * @default http - */ - scheme?: ProxyScheme; - /** Proxy host - * - * @default localhost - */ - host?: Hostname | IPv4 | IPv6; - /** Proxy port - * - * @default 80 - */ - port?: Port; - /** Skip TLS verification - * - * @default false - */ - no_tls_verify?: boolean; - /** Response header timeout - * - * @default 60s - */ - response_header_timeout?: Duration; - /** Path patterns (only patterns that match will be proxied). - * - * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux - */ - path_patterns?: PathPattern[]; - /** Healthcheck config */ - healthcheck?: HealthcheckConfig; - /** Load balance config */ - load_balance?: LoadBalanceConfig; - /** Middlewares */ - middlewares?: MiddlewaresMap; - /** Homepage config - * - * @examples require(".").homepageExamples - */ - homepage?: HomepageConfig; - /** Access log config - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + /** Proxy scheme + * + * @default http + */ + scheme?: ProxyScheme; + /** Proxy host + * + * @default localhost + */ + host?: Hostname | IPv4 | IPv6; + /** Proxy port + * + * @default 80 + */ + port?: Port; + /** Skip TLS verification + * + * @default false + */ + no_tls_verify?: boolean; + /** Response header timeout + * + * @default 60s + */ + response_header_timeout?: Duration; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; + /** Load balance config */ + load_balance?: LoadBalanceConfig; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; }; export type FileServerRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - scheme: "fileserver"; - root: string; - /** Path patterns (only patterns that match will be proxied). - * - * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux - */ - path_patterns?: PathPattern[]; - /** Middlewares */ - middlewares?: MiddlewaresMap; - /** Homepage config - * - * @examples require(".").homepageExamples - */ - homepage?: HomepageConfig; - /** Access log config - * - * @examples require(".").accessLogExamples - */ - access_log?: AccessLogConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + scheme: "fileserver"; + root: string; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; }; export type StreamRoute = { - /** Alias (subdomain or FDN) - * @minLength 1 - */ - alias?: string; - /** Stream scheme - * - * @default tcp - */ - scheme?: StreamScheme; - /** Stream host - * - * @default localhost - */ - host?: Hostname | IPv4 | IPv6; - port: StreamPort; - /** Healthcheck config */ - healthcheck?: HealthcheckConfig; + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + /** Stream scheme + * + * @default tcp + */ + scheme?: StreamScheme; + /** Stream host + * + * @default localhost + */ + host?: Hostname | IPv4 | IPv6; + port: StreamPort; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; }; -export declare const homepageExamples: ({ - name: string; - icon: string; - category: string; -} | { - name: string; - icon: string; - category?: undefined; -})[]; -export declare const loadBalanceExamples: ({ - link: string; - mode: string; - config?: undefined; -} | { - link: string; - mode: string; - config: { +export declare const homepageExamples: ( + | { + name: string; + icon: string; + category: string; + } + | { + name: string; + icon: string; + category?: undefined; + } +)[]; +export declare const loadBalanceExamples: ( + | { + link: string; + mode: string; + config?: undefined; + } + | { + link: string; + mode: string; + config: { header: string; - }; -})[]; + }; + } +)[]; export { accessLogExamples }; diff --git a/schemas/providers/routes.ts b/schemas/providers/routes.ts index 86d4375..29ab6d8 100644 --- a/schemas/providers/routes.ts +++ b/schemas/providers/routes.ts @@ -1,7 +1,15 @@ import { AccessLogConfig } from "../config/access_log"; import { accessLogExamples } from "../config/entrypoint"; import { MiddlewaresMap } from "../middlewares/middlewares"; -import { Duration, Hostname, IPv4, IPv6, PathPattern, Port, StreamPort } from "../types"; +import { + Duration, + Hostname, + IPv4, + IPv6, + PathPattern, + Port, + StreamPort, +} from "../types"; import { HealthcheckConfig } from "./healthcheck"; import { HomepageConfig } from "./homepage"; import { LoadBalanceConfig } from "./loadbalance"; @@ -94,7 +102,9 @@ export type FileServerRoute = { * @examples require(".").accessLogExamples */ access_log?: AccessLogConfig; -} + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; +}; export type StreamRoute = { /** Alias (subdomain or FDN) diff --git a/schemas/routes.schema.json b/schemas/routes.schema.json index 25a59bb..5c2be97 100644 --- a/schemas/routes.schema.json +++ b/schemas/routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"bypass":{"additionalProperties":false,"description":"Bypass redirect","properties":{"user_agents":{"description":"Bypass redirect for user agents","items":{"type":"string"},"type":"array"}},"type":"object"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file diff --git a/schemas/tsconfig.json b/schemas/tsconfig.json index 3a87871..5ac8e5d 100644 --- a/schemas/tsconfig.json +++ b/schemas/tsconfig.json @@ -5,12 +5,14 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "Node", - "strict": true, - "esModuleInterop": true, + "strict": false, + "esModuleInterop": false, "forceConsistentCasingInFileNames": true, - "allowJs": true, + "allowJs": false, "resolveJsonModule": true, - "declaration": true + "declaration": true, + "allowSyntheticDefaultImports": true }, - "include": ["."] + "include": ["**/*.ts"], + "exclude": ["node_modules"] } diff --git a/schemas/types.d.ts b/schemas/types.d.ts index 1031c77..f5390ae 100644 --- a/schemas/types.d.ts +++ b/schemas/types.d.ts @@ -4,7 +4,17 @@ export type Null = null; export type Nullable = T | Null; export type NullOrEmptyMap = {} | Null; -export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "HEAD", "OPTIONS", "TRACE"]; +export declare const HTTP_METHODS: readonly [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "CONNECT", + "HEAD", + "OPTIONS", + "TRACE", +]; export type HTTPMethod = (typeof HTTP_METHODS)[number]; /** * HTTP Header @@ -49,7 +59,13 @@ export type IPv4 = string & {}; * @type string */ export type IPv6 = string & {}; -export type CIDR = `${number}.${number}.${number}.${number}` | `${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}` | `${number}.${number}.${number}.${number}/${number}` | `::${number}` | `${string}::/${number}` | `${string}:${string}::/${number}`; +export type CIDR = + | `${number}.${number}.${number}.${number}` + | `${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}` + | `${number}.${number}.${number}.${number}/${number}` + | `::${number}` + | `${string}::/${number}` + | `${string}:${string}::/${number}`; /** * @type integer * @minimum 0