From 04e118c081ef84a987906e38eef6b8cd3179dce3 Mon Sep 17 00:00:00 2001 From: yusing Date: Wed, 15 Jan 2025 09:06:54 +0800 Subject: [PATCH 1/7] api: enrich provider statistifcs --- internal/config/query.go | 17 ++++---- internal/route/http.go | 7 +++ internal/route/provider/provider.go | 24 ---------- internal/route/provider/stats.go | 68 +++++++++++++++++++++++++++++ internal/route/route.go | 2 + internal/route/stream.go | 8 ++++ internal/route/types/route.go | 3 ++ 7 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 internal/route/provider/stats.go diff --git a/internal/config/query.go b/internal/config/query.go index fe2755ca..9d68ca02 100644 --- a/internal/config/query.go +++ b/internal/config/query.go @@ -25,21 +25,22 @@ func (cfg *Config) DumpProviders() map[string]*provider.Provider { } func (cfg *Config) Statistics() map[string]any { - nTotalStreams := 0 - nTotalRPs := 0 + var rps, streams provider.RouteStats + var total uint16 providerStats := make(map[string]provider.ProviderStats) cfg.providers.RangeAll(func(_ string, p *provider.Provider) { stats := p.Statistics() providerStats[p.ShortName()] = stats - - nTotalRPs += stats.NumRPs - nTotalStreams += stats.NumStreams + rps.AddOther(stats.RPs) + streams.AddOther(stats.Streams) + total += stats.RPs.Total + stats.Streams.Total }) return map[string]any{ - "num_total_streams": nTotalStreams, - "num_total_reverse_proxies": nTotalRPs, - "providers": providerStats, + "total": total, + "reverse_proxies": rps, + "streams": streams, + "providers": providerStats, } } diff --git a/internal/route/http.go b/internal/route/http.go index 054421fd..d47a2078 100755 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -180,6 +180,13 @@ func (r *HTTPRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handler.ServeHTTP(w, req) } +func (r *HTTPRoute) Health() health.Status { + if r.HealthMon != nil { + return r.HealthMon.Status() + } + return health.StatusUnknown +} + func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) { var lb *loadbalancer.LoadBalancer cfg := r.Raw.LoadBalance diff --git a/internal/route/provider/provider.go b/internal/route/provider/provider.go index d7702a47..f473d124 100644 --- a/internal/route/provider/provider.go +++ b/internal/route/provider/provider.go @@ -10,7 +10,6 @@ import ( E "github.com/yusing/go-proxy/internal/error" R "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route/provider/types" - route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" W "github.com/yusing/go-proxy/internal/watcher" "github.com/yusing/go-proxy/internal/watcher/events" @@ -33,11 +32,6 @@ type ( NewWatcher() W.Watcher Logger() *zerolog.Logger } - ProviderStats struct { - NumRPs int `json:"num_reverse_proxies"` - NumStreams int `json:"num_streams"` - Type types.ProviderType `json:"type"` - } ) const ( @@ -154,21 +148,3 @@ func (p *Provider) LoadRoutes() E.Error { func (p *Provider) NumRoutes() int { return p.routes.Size() } - -func (p *Provider) Statistics() ProviderStats { - numRPs := 0 - numStreams := 0 - p.routes.RangeAll(func(_ string, r *R.Route) { - switch r.Type { - case route.RouteTypeReverseProxy: - numRPs++ - case route.RouteTypeStream: - numStreams++ - } - }) - return ProviderStats{ - NumRPs: numRPs, - NumStreams: numStreams, - Type: p.t, - } -} diff --git a/internal/route/provider/stats.go b/internal/route/provider/stats.go new file mode 100644 index 00000000..276a1402 --- /dev/null +++ b/internal/route/provider/stats.go @@ -0,0 +1,68 @@ +package provider + +import ( + R "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route/provider/types" + route "github.com/yusing/go-proxy/internal/route/types" + "github.com/yusing/go-proxy/internal/watcher/health" +) + +type ( + RouteStats struct { + Total uint16 `json:"total"` + NumHealthy uint16 `json:"healthy"` + NumUnhealthy uint16 `json:"unhealthy"` + NumNapping uint16 `json:"napping"` + NumError uint16 `json:"error"` + NumUnknown uint16 `json:"unknown"` + } + ProviderStats struct { + Total uint16 `json:"total"` + RPs RouteStats `json:"reverse_proxies"` + Streams RouteStats `json:"streams"` + Type types.ProviderType `json:"type"` + } +) + +func (stats *RouteStats) Add(r *R.Route) { + stats.Total++ + switch r.Health() { + case health.StatusHealthy: + stats.NumHealthy++ + case health.StatusUnhealthy: + stats.NumUnhealthy++ + case health.StatusNapping: + stats.NumNapping++ + case health.StatusError: + stats.NumError++ + default: + stats.NumUnknown++ + } +} + +func (stats *RouteStats) AddOther(other RouteStats) { + stats.Total += other.Total + stats.NumHealthy += other.NumHealthy + stats.NumUnhealthy += other.NumUnhealthy + stats.NumNapping += other.NumNapping + stats.NumError += other.NumError + stats.NumUnknown += other.NumUnknown +} + +func (p *Provider) Statistics() ProviderStats { + var rps, streams RouteStats + p.routes.RangeAll(func(_ string, r *R.Route) { + switch r.Type { + case route.RouteTypeReverseProxy: + rps.Add(r) + case route.RouteTypeStream: + streams.Add(r) + } + }) + return ProviderStats{ + Total: rps.Total + streams.Total, + RPs: rps, + Streams: streams, + Type: p.t, + } +} diff --git a/internal/route/route.go b/internal/route/route.go index 003b912e..569d727a 100755 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -11,6 +11,7 @@ import ( "github.com/yusing/go-proxy/internal/task" 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" ) type ( @@ -28,6 +29,7 @@ type ( task.TaskFinisher String() string TargetURL() url.URL + Health() health.Status } RawEntry = types.RawEntry RawEntries = types.RawEntries diff --git a/internal/route/stream.go b/internal/route/stream.go index 1713fb5d..3fc2bdff 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -116,6 +116,14 @@ func (r *StreamRoute) Finish(reason any) { r.task.Finish(reason) } + +func (r *StreamRoute) Health() health.Status { + if r.HealthMon != nil { + return r.HealthMon.Status() + } + return health.StatusUnknown +} + func (r *StreamRoute) acceptConnections() { defer r.task.Finish("listener closed") diff --git a/internal/route/types/route.go b/internal/route/types/route.go index d44a812e..4b56b307 100644 --- a/internal/route/types/route.go +++ b/internal/route/types/route.go @@ -4,15 +4,18 @@ import ( "net/http" net "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/watcher/health" ) type ( HTTPRoute interface { Entry http.Handler + Health() health.Status } StreamRoute interface { Entry net.Stream + Health() health.Status } ) From 26d259b9529dbc277e7a24835f95b10babd5c34c Mon Sep 17 00:00:00 2001 From: yusing Date: Wed, 15 Jan 2025 09:10:26 +0800 Subject: [PATCH 2/7] fix: docker monitor now uses container status --- internal/watcher/health/monitor/docker.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/watcher/health/monitor/docker.go b/internal/watcher/health/monitor/docker.go index dd84dbe2..bdb7307f 100644 --- a/internal/watcher/health/monitor/docker.go +++ b/internal/watcher/health/monitor/docker.go @@ -29,6 +29,19 @@ func (mon *DockerHealthMonitor) CheckHealth() (result *health.HealthCheckResult, if err != nil { return mon.fallback.CheckHealth() } + status := cont.State.Status + switch status { + case "dead", "exited", "paused", "restarting", "removing": + return &health.HealthCheckResult{ + Healthy: false, + Detail: "container is " + status, + }, nil + case "created": + return &health.HealthCheckResult{ + Healthy: false, + Detail: "container is not started", + }, nil + } if cont.State.Health == nil { return mon.fallback.CheckHealth() } From 589b3a7a1337d23acc220b882affa7dbf7916ab7 Mon Sep 17 00:00:00 2001 From: Yuzerion Date: Sun, 19 Jan 2025 00:37:17 +0800 Subject: [PATCH 3/7] Feat/auto schemas (#48) * use auto generated schemas * go version bump and dependencies upgrade * clarify some error messages --------- Co-authored-by: yusing --- .gitignore | 3 +- .vscode/settings.example.json | 4 +- Dockerfile | 4 +- Makefile | 26 +- go.mod | 22 +- go.sum | 40 +- internal/api/v1/schema.go | 2 +- internal/common/constants.go | 10 +- internal/docker/container_test.go | 43 + internal/docker/idlewatcher/watcher.go | 3 + .../http/reverseproxy/reverse_proxy_mod.go | 19 +- internal/route/provider/all_fields.yaml | 1 + schema/access_log.json | 103 -- schema/config.schema.json | 464 ------- schema/providers.schema.json | 290 ---- schemas/config.schema.json | 1228 +++++++++++++++++ schemas/config/access_log.ts | 66 + schemas/config/autocert.ts | 91 ++ schemas/config/config.ts | 52 + schemas/config/entrypoint.ts | 47 + schemas/config/homepage.ts | 7 + schemas/config/notification.ts | 67 + schemas/config/providers.ts | 46 + schemas/docker.ts | 7 + schemas/docker_routes.schema.json | 1198 ++++++++++++++++ schemas/middleware_compose.schema.json | 364 +++++ schemas/middlewares/middleware_compose.ts | 3 + schemas/middlewares/middlewares.ts | 149 ++ schemas/providers/healthcheck.ts | 33 + schemas/providers/homepage.ts | 36 + schemas/providers/idlewatcher.ts | 41 + schemas/providers/loadbalance.ts | 44 + schemas/providers/routes.ts | 114 ++ schemas/routes.schema.json | 1123 +++++++++++++++ schemas/types.ts | 111 ++ 35 files changed, 4958 insertions(+), 903 deletions(-) create mode 100644 internal/docker/container_test.go delete mode 100644 schema/access_log.json delete mode 100644 schema/config.schema.json delete mode 100644 schema/providers.schema.json create mode 100644 schemas/config.schema.json create mode 100644 schemas/config/access_log.ts create mode 100644 schemas/config/autocert.ts create mode 100644 schemas/config/config.ts create mode 100644 schemas/config/entrypoint.ts create mode 100644 schemas/config/homepage.ts create mode 100644 schemas/config/notification.ts create mode 100644 schemas/config/providers.ts create mode 100644 schemas/docker.ts create mode 100644 schemas/docker_routes.schema.json create mode 100644 schemas/middleware_compose.schema.json create mode 100644 schemas/middlewares/middleware_compose.ts create mode 100644 schemas/middlewares/middlewares.ts create mode 100644 schemas/providers/healthcheck.ts create mode 100644 schemas/providers/homepage.ts create mode 100644 schemas/providers/idlewatcher.ts create mode 100644 schemas/providers/loadbalance.ts create mode 100644 schemas/providers/routes.ts create mode 100644 schemas/routes.schema.json create mode 100644 schemas/types.ts diff --git a/.gitignore b/.gitignore index b579b1fc..d3c2ff12 100755 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ compose.yml config certs config*/ +!schemas/** certs*/ bin/ error_pages/ @@ -25,4 +26,4 @@ todo.md .aider* mtrace.json .env -test.Dockerfile +test.Dockerfile \ No newline at end of file diff --git a/.vscode/settings.example.json b/.vscode/settings.example.json index 02f732c4..628682bc 100644 --- a/.vscode/settings.example.json +++ b/.vscode/settings.example.json @@ -1,10 +1,10 @@ { "yaml.schemas": { - "https://github.com/yusing/go-proxy/raw/v0.8/schema/config.schema.json": [ + "https://github.com/yusing/go-proxy/raw/v0.8/schemas/config.schema.json": [ "config.example.yml", "config.yml" ], - "https://github.com/yusing/go-proxy/raw/v0.8/schema/providers.schema.json": [ + "https://github.com/yusing/go-proxy/raw/v0.8/schemas/routes.schema.json": [ "providers.example.yml" ] } diff --git a/Dockerfile b/Dockerfile index 864e79ff..de9677b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Builder -FROM golang:1.23.4-alpine AS builder +FROM golang:1.23.5-alpine AS builder HEALTHCHECK NONE # package version does not matter @@ -51,7 +51,7 @@ COPY config.example.yml /app/config/config.yml COPY --from=builder /etc/ssl/certs /etc/ssl/certs # copy schema -COPY schema /app/schema +COPY schemas/config.schema.json schemas/routes.schema.json schemas/middleware_compose.schema.json /app/schemas/ ENV DOCKER_HOST=unix:///var/run/docker.sock ENV GODOXY_DEBUG=0 diff --git a/Makefile b/Makefile index 94d0ce9f..83b4e26f 100755 --- a/Makefile +++ b/Makefile @@ -70,4 +70,28 @@ push-docker-io: build-docker: docker build -t godoxy-nightly \ - --build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" . \ No newline at end of file + --build-arg VERSION="${VERSION}-nightly-${BUILD_DATE}" . + +gen-schema-single: + typescript-json-schema --noExtraProps --required --skipLibCheck --tsNodeRegister=true -o schemas/${OUT} schemas/${IN} ${CLASS} + +gen-schema: + 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 + +push-github: + git push origin $(shell git rev-parse --abbrev-ref HEAD) \ No newline at end of file diff --git a/go.mod b/go.mod index c821eb1b..6bfd2037 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/yusing/go-proxy -go 1.23.4 +go 1.23.5 require ( github.com/PuerkitoBio/goquery v1.10.1 github.com/coder/websocket v1.8.12 github.com/coreos/go-oidc/v3 v3.12.0 - github.com/docker/cli v27.4.1+incompatible - github.com/docker/docker v27.4.1+incompatible + github.com/docker/cli v27.5.0+incompatible + github.com/docker/docker v27.5.0+incompatible github.com/fsnotify/fsnotify v1.8.0 github.com/go-acme/lego/v4 v4.21.0 - github.com/go-playground/validator/v10 v10.23.0 + github.com/go-playground/validator/v10 v10.24.0 github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gotify/server/v2 v2.6.1 @@ -32,7 +32,7 @@ require ( github.com/beorn7/perks v1.0.1 // 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.113.0 // indirect + github.com/cloudflare/cloudflare-go v0.114.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -61,21 +61,21 @@ require ( github.com/ovh/go-ovh v1.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.61.0 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.30.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/tools v0.29.0 // indirect - google.golang.org/protobuf v1.36.2 // indirect + google.golang.org/protobuf v1.36.3 // 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 684d562e..760e067e 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/cloudflare-go v0.113.0 h1:qnOXmA6RbgZ4rg5gNBK5QGk0Pzbv8pnUYV3C4+8CU6w= -github.com/cloudflare/cloudflare-go v0.113.0/go.mod h1:Dlm4BAnycHc0i8yLxQZb9b+OlMwYOAoDJsUOEFgpVvo= +github.com/cloudflare/cloudflare-go v0.114.0 h1:ucoti4/7Exo0XQ+rzpn1H+IfVVe++zgiM+tyKtf0HUA= +github.com/cloudflare/cloudflare-go v0.114.0/go.mod h1:O7fYfFfA6wKqKFn2QIR9lhj7FDw6VQCGOY6hd2TBtd0= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= @@ -27,10 +27,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI= -github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= -github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM= +github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v27.5.0+incompatible h1:um++2NcQtGRTz5eEgO6aJimo6/JxrTXC941hd05JO6U= +github.com/docker/docker v27.5.0+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= @@ -56,8 +56,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= -github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 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.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= @@ -126,8 +126,8 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+ github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= -github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= @@ -150,20 +150,20 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 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/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -271,8 +271,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= -google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/api/v1/schema.go b/internal/api/v1/schema.go index 86b732d8..cf5bffe3 100644 --- a/internal/api/v1/schema.go +++ b/internal/api/v1/schema.go @@ -14,7 +14,7 @@ func GetSchemaFile(w http.ResponseWriter, r *http.Request) { if filename == "" { U.RespondError(w, U.ErrMissingKey("filename"), http.StatusBadRequest) } - content, err := os.ReadFile(path.Join(common.SchemaBasePath, filename)) + content, err := os.ReadFile(path.Join(common.SchemasBasePath, filename)) if err != nil { U.HandleErr(w, r, err) return diff --git a/internal/common/constants.go b/internal/common/constants.go index c637a177..d00964a7 100644 --- a/internal/common/constants.go +++ b/internal/common/constants.go @@ -25,9 +25,9 @@ const ( MiddlewareComposeBasePath = ConfigBasePath + "/middlewares" - SchemaBasePath = "schema" - ConfigSchemaPath = SchemaBasePath + "/config.schema.json" - FileProviderSchemaPath = SchemaBasePath + "/providers.schema.json" + SchemasBasePath = "schemas" + ConfigSchemaPath = SchemasBasePath + "/config.schema.json" + FileProviderSchemaPath = SchemasBasePath + "/providers.schema.json" ComposeFileName = "compose.yml" ComposeExampleFileName = "compose.example.yml" @@ -37,7 +37,7 @@ const ( var RequiredDirectories = []string{ ConfigBasePath, - SchemaBasePath, + SchemasBasePath, ErrorPagesBasePath, MiddlewareComposeBasePath, } @@ -49,7 +49,7 @@ const ( HealthCheckTimeoutDefault = 5 * time.Second WakeTimeoutDefault = "30s" - StopTimeoutDefault = "10s" + StopTimeoutDefault = "30s" StopMethodDefault = "stop" ) diff --git a/internal/docker/container_test.go b/internal/docker/container_test.go new file mode 100644 index 00000000..4ecd475e --- /dev/null +++ b/internal/docker/container_test.go @@ -0,0 +1,43 @@ +package docker + +import ( + "testing" + + "github.com/docker/docker/api/types" + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestContainerExplicit(t *testing.T) { + tests := []struct { + name string + labels map[string]string + isExplicit bool + }{ + { + name: "explicit", + labels: map[string]string{ + "proxy.aliases": "foo", + }, + isExplicit: true, + }, + { + name: "explicit2", + labels: map[string]string{ + "proxy.idle_timeout": "1s", + }, + isExplicit: true, + }, + { + name: "not explicit", + labels: map[string]string{}, + isExplicit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := FromDocker(&types.Container{Names: []string{"test"}, State: "test", Labels: tt.labels}, "") + ExpectEqual(t, c.IsExplicit, tt.isExplicit) + }) + } +} diff --git a/internal/docker/idlewatcher/watcher.go b/internal/docker/idlewatcher/watcher.go index 8451880f..878e4c91 100644 --- a/internal/docker/idlewatcher/watcher.go +++ b/internal/docker/idlewatcher/watcher.go @@ -294,6 +294,9 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) { 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.StopMethod) default: w.LogReason("container stopped", "idle timeout") diff --git a/internal/net/http/reverseproxy/reverse_proxy_mod.go b/internal/net/http/reverseproxy/reverse_proxy_mod.go index 0bb0d4bb..7d823d67 100644 --- a/internal/net/http/reverseproxy/reverse_proxy_mod.go +++ b/internal/net/http/reverseproxy/reverse_proxy_mod.go @@ -12,6 +12,7 @@ package reverseproxy import ( "bytes" "context" + "crypto/tls" "errors" "fmt" "io" @@ -207,13 +208,25 @@ func copyHeader(dst, src http.Header) { } func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, r *http.Request, err error, writeHeader bool) { + reqURL := r.Host + r.RequestURI switch { case errors.Is(err, context.Canceled), - errors.Is(err, io.EOF): - logger.Debug().Err(err).Str("url", r.URL.String()).Msg("http proxy error") + errors.Is(err, io.EOF), + errors.Is(err, context.DeadlineExceeded): + logger.Debug().Err(err).Str("url", reqURL).Msg("http proxy error") default: - logger.Err(err).Str("url", r.URL.String()).Msg("http proxy error") + var recordErr tls.RecordHeaderError + if errors.As(err, &recordErr) { + logger.Error(). + Str("url", reqURL). + Msgf(`scheme was likely misconfigured as https, + try setting "proxy.%s.scheme" back to "http"`, p.TargetName) + logging.Err(err).Msg("underlying error") + } else { + logger.Err(err).Str("url", reqURL).Msg("http proxy error") + } } + if writeHeader { rw.WriteHeader(http.StatusInternalServerError) } diff --git a/internal/route/provider/all_fields.yaml b/internal/route/provider/all_fields.yaml index 1d812b49..7af53fbb 100644 --- a/internal/route/provider/all_fields.yaml +++ b/internal/route/provider/all_fields.yaml @@ -2,6 +2,7 @@ example: # matching `example.y.z` scheme: http host: 10.0.0.254 port: 80 + no_tls_verify: true path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax - GET / # accept any GET request - POST /auth # for /auth and /auth/* accept only POST diff --git a/schema/access_log.json b/schema/access_log.json deleted file mode 100644 index e19ec13d..00000000 --- a/schema/access_log.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "$id": "https://github.com/yusing/go-proxy/raw/v0.8/schema/access_log.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Access log configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "path": { - "title": "Access log path", - "type": "string" - }, - "format": { - "title": "Access log format", - "type": "string", - "enum": [ - "common", - "combined", - "json" - ] - }, - "buffer_size": { - "title": "Access log buffer size in bytes", - "type": "integer", - "minimum": 1 - }, - "filters": { - "title": "Access log filters", - "type": "object", - "additionalProperties": false, - "properties": { - "cidr": { - "title": "CIDR filter", - "$ref": "#/$defs/access_log_filters" - }, - "status_codes": { - "title": "Status code filter", - "$ref": "#/$defs/access_log_filters" - }, - "method": { - "title": "Method filter", - "$ref": "#/$defs/access_log_filters" - }, - "headers": { - "title": "Header filter", - "$ref": "#/$defs/access_log_filters" - }, - "host": { - "title": "Host filter", - "$ref": "#/$defs/access_log_filters" - } - } - }, - "fields": { - "title": "Access log fields", - "type": "object", - "additionalProperties": false, - "properties": { - "headers": { - "title": "Headers field", - "$ref": "#/$defs/access_log_fields" - }, - "query": { - "title": "Query field", - "$ref": "#/$defs/access_log_fields" - }, - "cookies": { - "title": "Cookies field", - "$ref": "#/$defs/access_log_fields" - } - } - } - }, - "$defs": { - "access_log_filters": { - "type": "object", - "additionalProperties": false, - "properties": { - "negative": { - "type": "boolean" - }, - "values": { - "type": "array" - } - } - }, - "access_log_fields": { - "type": "object", - "additionalProperties": false, - "properties": { - "default": { - "enum": [ - "keep", - "redact", - "drop" - ] - }, - "config": { - "type": "object" - } - } - } - } -} \ No newline at end of file diff --git a/schema/config.schema.json b/schema/config.schema.json deleted file mode 100644 index c01a938f..00000000 --- a/schema/config.schema.json +++ /dev/null @@ -1,464 +0,0 @@ -{ - "$id": "https://github.com/yusing/go-proxy/raw/v0.8/schema/config.schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GoDoxy config file", - "properties": { - "autocert": { - "title": "Autocert configuration", - "type": "object", - "properties": { - "email": { - "title": "ACME Email", - "type": "string", - "format": "email" - }, - "domains": { - "title": "Cert Domains", - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "cert_path": { - "title": "path of cert file to load/store", - "default": "certs/cert.crt", - "markdownDescription": "default: `certs/cert.crt`,", - "type": "string" - }, - "key_path": { - "title": "path of key file to load/store", - "default": "certs/priv.key", - "markdownDescription": "default: `certs/priv.key`", - "type": "string" - }, - "acme_key_path": { - "title": "path of acme key file to load/store", - "default": "certs/acme.key", - "markdownDescription": "default: `certs/acme.key`", - "type": "string" - }, - "provider": { - "title": "DNS Challenge Provider", - "default": "local", - "type": "string", - "enum": [ - "local", - "cloudflare", - "clouddns", - "duckdns", - "ovh" - ] - }, - "options": { - "title": "Provider specific options", - "type": "object" - } - }, - "allOf": [ - { - "if": { - "not": { - "properties": { - "provider": { - "const": "local" - } - } - } - }, - "then": { - "required": [ - "email", - "domains", - "provider", - "options" - ] - } - }, - { - "if": { - "properties": { - "provider": { - "const": "cloudflare" - } - } - }, - "then": { - "properties": { - "options": { - "required": [ - "auth_token" - ], - "additionalProperties": false, - "properties": { - "auth_token": { - "description": "Cloudflare API Token with Zone Scope", - "type": "string" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "const": "clouddns" - } - } - }, - "then": { - "properties": { - "options": { - "required": [ - "client_id", - "email", - "password" - ], - "additionalProperties": false, - "properties": { - "client_id": { - "description": "CloudDNS Client ID", - "type": "string" - }, - "email": { - "description": "CloudDNS Email", - "type": "string" - }, - "password": { - "description": "CloudDNS Password", - "type": "string" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "const": "duckdns" - } - } - }, - "then": { - "properties": { - "options": { - "required": [ - "token" - ], - "additionalProperties": false, - "properties": { - "token": { - "description": "DuckDNS Token", - "type": "string" - } - } - } - } - } - }, - { - "if": { - "properties": { - "provider": { - "const": "ovh" - } - } - }, - "then": { - "properties": { - "options": { - "required": [ - "application_secret", - "consumer_key" - ], - "additionalProperties": false, - "oneOf": [ - { - "required": [ - "application_key" - ] - }, - { - "required": [ - "oauth2_config" - ] - } - ], - "properties": { - "api_endpoint": { - "description": "OVH API endpoint", - "default": "ovh-eu", - "anyOf": [ - { - "enum": [ - "ovh-eu", - "ovh-ca", - "ovh-us", - "kimsufi-eu", - "kimsufi-ca", - "soyoustart-eu", - "soyoustart-ca" - ] - }, - { - "type": "string", - "format": "uri" - } - ] - }, - "application_secret": { - "description": "OVH Application Secret", - "type": "string" - }, - "consumer_key": { - "description": "OVH Consumer Key", - "type": "string" - }, - "application_key": { - "description": "OVH Application Key", - "type": "string" - }, - "oauth2_config": { - "description": "OVH OAuth2 config", - "type": "object", - "additionalProperties": false, - "properties": { - "client_id": { - "description": "OVH Client ID", - "type": "string" - }, - "client_secret": { - "description": "OVH Client Secret", - "type": "string" - } - }, - "required": [ - "client_id", - "client_secret" - ] - } - } - } - } - } - } - ] - }, - "providers": { - "title": "Proxy providers configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "include": { - "title": "Proxy providers configuration files", - "description": "relative path to 'config'", - "type": "array", - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+\\.(yml|yaml)$", - "patternErrorMessage": "Invalid file name" - } - }, - "docker": { - "title": "Docker provider configuration", - "description": "docker clients (name-address pairs)", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9-_]+$": { - "type": "string", - "examples": [ - "unix:///var/run/docker.sock", - "tcp://127.0.0.1:2375", - "ssh://user@host:port" - ], - "oneOf": [ - { - "const": "$DOCKER_HOST", - "description": "Use DOCKER_HOST environment variable" - }, - { - "pattern": "^unix://.+$", - "description": "A Unix socket for local Docker communication." - }, - { - "pattern": "^ssh://.+$", - "description": "An SSH connection to a remote Docker host." - }, - { - "pattern": "^fd://.+$", - "description": "A file descriptor for Docker communication." - }, - { - "pattern": "^tcp://.+$", - "description": "A TCP connection to a remote Docker host." - } - ] - } - } - }, - "notification": { - "description": "Notification provider configuration", - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "provider" - ], - "properties": { - "name": { - "type": "string", - "description": "Notifier name" - }, - "provider": { - "description": "Notifier provider", - "type": "string", - "enum": [ - "gotify", - "webhook" - ] - } - }, - "oneOf": [ - { - "description": "Gotify configuration", - "additionalProperties": false, - "properties": { - "name": {}, - "provider": { - "const": "gotify" - }, - "url": { - "description": "Gotify URL", - "type": "string" - }, - "token": { - "description": "Gotify token", - "type": "string" - } - }, - "required": [ - "url", - "token" - ] - }, - { - "description": "Webhook configuration", - "additionalProperties": false, - "properties": { - "name": {}, - "provider": { - "const": "webhook" - }, - "url": { - "description": "Webhook URL", - "type": "string" - }, - "token": { - "description": "Webhook bearer token", - "type": "string" - }, - "template": { - "description": "Webhook template", - "type": "string", - "enum": [ - "discord" - ] - }, - "payload": { - "description": "Webhook payload", - "type": "string", - "format": "json" - }, - "method": { - "description": "Webhook request method", - "type": "string", - "enum": [ - "GET", - "POST", - "PUT" - ] - }, - "mime_type": { - "description": "Webhook NIME type", - "type": "string" - }, - "color_mode": { - "description": "Webhook color mode", - "type": "string", - "enum": [ - "hex", - "dec" - ] - } - }, - "required": [ - "url" - ] - } - ] - } - } - } - }, - "match_domains": { - "title": "Domains to match", - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "homepage": { - "title": "Homepage configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "use_default_categories": { - "title": "Use default categories", - "type": "boolean" - } - } - }, - "entrypoint": { - "title": "Entrypoint configuration", - "type": "object", - "additionalProperties": false, - "properties": { - "middlewares": { - "title": "Entrypoint middlewares", - "type": "array", - "items": { - "type": "object", - "required": [ - "use" - ], - "properties": { - "use": { - "type": "string", - "description": "Middleware to use" - } - } - } - }, - "access_log": { - "$ref": "https://github.com/yusing/go-proxy/raw/v0.8/schema/access_log.json" - } - } - }, - "timeout_shutdown": { - "title": "Shutdown timeout (in seconds)", - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false, - "required": [ - "providers" - ] -} \ No newline at end of file diff --git a/schema/providers.schema.json b/schema/providers.schema.json deleted file mode 100644 index bf123ab8..00000000 --- a/schema/providers.schema.json +++ /dev/null @@ -1,290 +0,0 @@ -{ - "$id": "https://github.com/yusing/go-proxy/raw/v0.8/schema/providers.schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "GoDoxy standalone include file", - "oneOf": [ - { - "type": "object" - }, - { - "type": "null" - } - ], - "patternProperties": { - ".+": { - "title": "Proxy entry", - "type": "object", - "properties": { - "scheme": { - "title": "Proxy scheme", - "oneOf": [ - { - "type": "string", - "enum": [ - "http", - "https", - "tcp", - "udp", - "tcp:tcp", - "udp:udp", - "tcp:udp", - "udp:tcp" - ] - }, - { - "type": "null", - "description": "Auto detect base on port format" - } - ] - }, - "host": { - "default": "localhost", - "anyOf": [ - { - "type": "null", - "title": "localhost (default)" - }, - { - "type": "string", - "format": "ipv4", - "title": "ipv4 address" - }, - { - "type": "string", - "format": "ipv6", - "title": "ipv6 address" - }, - { - "type": "string", - "format": "hostname", - "title": "hostname" - } - ], - "title": "Proxy host (ipv4/6 / hostname)" - }, - "port": {}, - "no_tls_verify": {}, - "path_patterns": {}, - "middlewares": {}, - "homepage": { - "title": "Dashboard config", - "type": "object", - "additionalProperties": false, - "properties": { - "show": { - "title": "Show on dashboard", - "type": "boolean", - "default": true - }, - "name": { - "title": "Display name", - "type": "string" - }, - "icon": { - "title": "Display icon", - "type": "string", - "oneOf": [ - { - "pattern": "^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1$", - "title": "Icon from walkxcode/dashboard-icons" - }, - { - "pattern": "^https?://", - "title": "Absolute URI", - "format": "uri" - }, - { - "pattern": "^@target/", - "title": "Relative URI to target" - } - ] - }, - "url": { - "title": "App URL override", - "type": "string", - "format": "uri", - "pattern": "^https?://" - }, - "category": { - "title": "Category", - "type": "string" - }, - "description": { - "title": "Description", - "type": "string" - }, - "widget_config": { - "title": "Widget config", - "type": "object" - } - } - }, - "load_balance": { - "type": "object", - "additionalProperties": false, - "properties": { - "link": { - "type": "string", - "title": "Name and subdomain of load-balancer" - }, - "mode": { - "enum": [ - "round_robin", - "least_conn", - "ip_hash" - ], - "title": "Load-balance mode", - "default": "roundrobin" - }, - "weight": { - "type": "integer", - "title": "Reserved for future use", - "minimum": 0, - "maximum": 100 - }, - "options": { - "type": "object", - "title": "load-balance mode specific options" - } - } - }, - "healthcheck": { - "type": "object", - "additionalProperties": false, - "properties": { - "disable": { - "type": "boolean", - "default": false, - "title": "Disable healthcheck" - }, - "path": { - "type": "string", - "title": "Healthcheck path", - "default": "/", - "format": "uri-reference", - "description": "should start with `/`" - }, - "use_get": { - "type": "boolean", - "title": "Use GET instead of HEAD", - "default": false - }, - "interval": { - "type": "string", - "title": "healthcheck Interval", - "pattern": "^([0-9]+(ms|s|m|h))+$", - "default": "5s", - "description": "e.g. 5s, 1m, 2h, 3m30s" - } - } - }, - "access_log": { - "$ref": "https://github.com/yusing/go-proxy/raw/v0.8/schema/access_log.json" - } - }, - "additionalProperties": false, - "allOf": [ - { - "if": { - "properties": { - "scheme": { - "anyOf": [ - { - "enum": [ - "http", - "https" - ] - }, - { - "type": "null" - } - ] - } - } - }, - "then": { - "properties": { - "port": { - "title": "Proxy port", - "markdownDescription": "From **0** to **65535**", - "oneOf": [ - { - "type": "string", - "pattern": "^\\d{1,5}$", - "patternErrorMessage": "`port` must be a number" - }, - { - "type": "integer", - "minimum": 0, - "maximum": 65535 - } - ] - }, - "path_patterns": { - "title": "Path patterns", - "type": "array", - "markdownDescription": "See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux", - "items": { - "type": "string", - "pattern": "^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\/)?(\\/[^\\s]*)$", - "patternErrorMessage": "invalid path pattern" - } - }, - "middlewares": { - "type": "object" - } - } - }, - "else": { - "properties": { - "port": { - "markdownDescription": "`listening port:proxy port` or `listening port:service name`", - "type": "string", - "pattern": "^[0-9]+:[0-9a-z]+$", - "patternErrorMessage": "invalid syntax" - }, - "no_tls_verify": { - "not": true - }, - "path_patterns": { - "not": true - }, - "middlewares": { - "not": true - } - }, - "required": [ - "port" - ] - } - }, - { - "if": { - "properties": { - "scheme": { - "const": "https" - } - } - }, - "then": { - "properties": { - "no_tls_verify": { - "title": "Disable TLS verification for https proxy", - "type": "boolean", - "default": false - } - } - }, - "else": { - "properties": { - "no_tls_verify": { - "not": true - } - } - } - } - ] - } - }, - "additionalProperties": false -} \ No newline at end of file diff --git a/schemas/config.schema.json b/schemas/config.schema.json new file mode 100644 index 00000000..d944ced2 --- /dev/null +++ b/schemas/config.schema.json @@ -0,0 +1,1228 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "AccessLogFieldMode": { + "enum": [ + "drop", + "keep", + "redact" + ], + "type": "string" + }, + "AccessLogFormat": { + "enum": [ + "combined", + "common", + "json" + ], + "type": "string" + }, + "AutocertConfig": { + "anyOf": [ + { + "$ref": "#/definitions/LocalOptions" + }, + { + "$ref": "#/definitions/CloudflareOptions" + }, + { + "$ref": "#/definitions/CloudDNSOptions" + }, + { + "$ref": "#/definitions/DuckDNSOptions" + }, + { + "$ref": "#/definitions/OVHOptionsWithAppKey" + }, + { + "$ref": "#/definitions/OVHOptionsWithOAuth2Config" + } + ] + }, + "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" + } + ] + }, + "CloudDNSOptions": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "client_id", + "email", + "password" + ], + "type": "object" + }, + "provider": { + "const": "clouddns", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "options", + "provider" + ], + "type": "object" + }, + "CloudflareOptions": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "auth_token": { + "type": "string" + } + }, + "required": [ + "auth_token" + ], + "type": "object" + }, + "provider": { + "const": "cloudflare", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "options", + "provider" + ], + "type": "object" + }, + "DuckDNSOptions": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "provider": { + "const": "duckdns", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "options", + "provider" + ], + "type": "object" + }, + "GotifyConfig": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "provider": { + "const": "gotify", + "type": "string" + }, + "token": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "provider", + "token", + "url" + ], + "type": "object" + }, + "LocalOptions": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "provider": { + "const": "local", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "provider" + ], + "type": "object" + }, + "MiddlewareComposeMap": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "CustomErrorPage", + "ErrorPage", + "customErrorPage", + "custom_error_page", + "errorPage", + "error_page" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "RedirectHTTP", + "redirectHTTP", + "redirect_http" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "SetXForwarded", + "setXForwarded", + "set_x_forwarded" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "HideXForwarded", + "hideXForwarded", + "hide_x_forwarded" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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" + }, + "use": { + "enum": [ + "CIDRWhitelist", + "cidrWhitelist", + "cidr_whitelist" + ], + "type": "string" + } + }, + "required": [ + "allow", + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "recursive": { + "default": false, + "description": "Recursively resolve the IP", + "type": "boolean" + }, + "use": { + "enum": [ + "cloudflareRealIp", + "cloudflare_real_ip" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + }, + "use": { + "enum": [ + "ModifyRequest", + "Request", + "modifyRequest", + "modify_request", + "request" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + }, + "use": { + "enum": [ + "ModifyResponse", + "Response", + "modifyResponse", + "modify_response", + "response" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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" + }, + "use": { + "enum": [ + "OIDC", + "oidc" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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": { + "default": "1s", + "description": "Duration of the rate limit", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "use": { + "enum": [ + "RateLimit", + "rateLimit", + "rate_limit" + ], + "type": "string" + } + }, + "required": [ + "average", + "burst", + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "items": { + "$ref": "#/definitions/CIDR" + }, + "type": "array" + }, + "header": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "recursive": { + "default": false, + "description": "Recursive resolve the IP", + "type": "boolean" + }, + "use": { + "enum": [ + "RealIP", + "realIP", + "real_ip" + ], + "type": "string" + } + }, + "required": [ + "from", + "use" + ], + "type": "object" + } + ] + }, + "OVHEndpoint": { + "enum": [ + "kimsufi-ca", + "kimsufi-eu", + "ovh-ca", + "ovh-eu", + "ovh-us", + "soyoustart-ca", + "soyoustart-eu" + ], + "type": "string" + }, + "OVHOptionsWithAppKey": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "api_endpoint": { + "$ref": "#/definitions/OVHEndpoint" + }, + "application_key": { + "type": "string" + }, + "application_secret": { + "type": "string" + }, + "consumer_key": { + "type": "string" + } + }, + "required": [ + "application_key", + "application_secret", + "consumer_key" + ], + "type": "object" + }, + "provider": { + "const": "ovh", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "options", + "provider" + ], + "type": "object" + }, + "OVHOptionsWithOAuth2Config": { + "additionalProperties": false, + "properties": { + "cert_path": { + "type": "string" + }, + "domains": { + "items": { + "pattern": "^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "type": "array" + }, + "email": { + "format": "email", + "type": "string" + }, + "key_path": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "api_endpoint": { + "$ref": "#/definitions/OVHEndpoint" + }, + "application_secret": { + "type": "string" + }, + "consumer_key": { + "type": "string" + }, + "oauth2_config": { + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + } + }, + "required": [ + "client_id", + "client_secret" + ], + "type": "object" + } + }, + "required": [ + "application_secret", + "consumer_key", + "oauth2_config" + ], + "type": "object" + }, + "provider": { + "const": "ovh", + "type": "string" + } + }, + "required": [ + "domains", + "email", + "options", + "provider" + ], + "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" + } + ] + }, + "WebhookColorMode": { + "enum": [ + "dec", + "hex" + ], + "type": "string" + }, + "WebhookConfig": { + "additionalProperties": false, + "properties": { + "color_mode": { + "$ref": "#/definitions/WebhookColorMode", + "default": "hex", + "description": "Webhook color mode" + }, + "method": { + "$ref": "#/definitions/WebhookMethod", + "default": "POST", + "description": "Webhook method" + }, + "mime_type": { + "$ref": "#/definitions/WebhookMimeType", + "default": "application/json", + "description": "Webhook mime type" + }, + "name": { + "type": "string" + }, + "payload": { + "description": "Webhook message (usally JSON),\nrequired when template is not defined", + "type": "string" + }, + "provider": { + "const": "webhook", + "type": "string" + }, + "template": { + "const": "discord", + "default": "discord", + "description": "Webhook template", + "type": "string" + }, + "token": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "provider", + "url" + ], + "type": "object" + }, + "WebhookMethod": { + "enum": [ + "GET", + "POST", + "PUT" + ], + "type": "string" + }, + "WebhookMimeType": { + "enum": [ + "application/json", + "application/x-www-form-urlencoded", + "text/plain" + ], + "type": "string" + } + }, + "properties": { + "autocert": { + "$ref": "#/definitions/AutocertConfig", + "description": "Optional autocert configuration", + "examples": [ + { + "provider": "local" + }, + { + "domains": [ + "example.com" + ], + "email": "abc@gmail", + "options": { + "auth_token": "c1234565789-abcdefghijklmnopqrst" + }, + "provider": "cloudflare" + }, + { + "domains": [ + "example.com" + ], + "email": "abc@gmail", + "options": { + "client_id": "c1234565789", + "email": "abc@gmail", + "password": "password" + }, + "provider": "clouddns" + } + ] + }, + "entrypoint": { + "additionalProperties": false, + "properties": { + "access_log": { + "additionalProperties": false, + "description": "Entrypoint access log configuration", + "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": { + "type": "string" + }, + "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": { + "format": "uri-reference", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "middlewares": { + "description": "Entrypoint middleware configuration", + "examples": [ + { + "use": "RedirectHTTP" + }, + { + "allow": [ + "127.0.0.1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "message": "Forbidden", + "status": 403, + "use": "CIDRWhitelist" + } + ], + "items": { + "$ref": "#/definitions/MiddlewareComposeMap" + }, + "type": "array" + } + }, + "required": [ + "middlewares" + ], + "type": "object" + }, + "homepage": { + "additionalProperties": false, + "properties": { + "use_default_categories": { + "default": true, + "description": "Use default app categories (uses docker image name)", + "type": "boolean" + } + }, + "required": [ + "use_default_categories" + ], + "type": "object" + }, + "match_domains": { + "description": "Optional list of domains to match", + "examples": [ + "example.com", + "*.example.com" + ], + "items": { + "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + }, + "minItems": 1, + "type": "array" + }, + "providers": { + "additionalProperties": false, + "properties": { + "docker": { + "additionalProperties": { + "type": "string" + }, + "description": "Name-value mapping of docker hosts to retrieve routes from", + "examples": [ + { + "local": "$DOCKER_HOST" + }, + { + "remote": "tcp://10.0.2.1:2375" + }, + { + "remote2": "ssh://root:1234@10.0.2.2" + } + ], + "items": { + "pattern": "^((\\w+://)[^\\s]+)|\\$DOCKER_HOST$" + }, + "minProperties": 1, + "type": "object" + }, + "include": { + "description": "List of route definition files to include", + "examples": [ + "file1.yml", + "file2.yml" + ], + "items": { + "pattern": "^[\\w\\d\\-_]+\\.(yaml|yml)$" + }, + "minItems": 1, + "type": "array" + }, + "notification": { + "description": "List of notification providers", + "examples": [ + { + "name": "gotify", + "provider": "gotify", + "token": "abcd", + "url": "https://gotify.domain.tld" + }, + { + "name": "discord", + "provider": "webhook", + "template": "discord", + "url": "https://discord.com/api/webhooks/1234/abcd" + } + ], + "items": { + "anyOf": [ + { + "$ref": "#/definitions/GotifyConfig" + }, + { + "$ref": "#/definitions/WebhookConfig" + } + ] + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "timeout_shutdown": { + "default": 3, + "description": "Optional timeout before shutdown", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "providers" + ], + "type": "object" +} + diff --git a/schemas/config/access_log.ts b/schemas/config/access_log.ts new file mode 100644 index 00000000..1438afb6 --- /dev/null +++ b/schemas/config/access_log.ts @@ -0,0 +1,66 @@ +import { CIDR, HTTPHeader, HTTPMethod, StatusCodeRange, URI } from "../types"; + +export const ACCESS_LOG_FORMATS = ["combined", "common", "json"] as const; + +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; + /* The path to the access log file. */ + path: URI; + /* The access log filters. */ + filters?: AccessLogFilters; + /* The access log fields. */ + fields?: AccessLogFields; +}; + +export type AccessLogFilter = { + /** Whether the filter is negative. + * + * @default false + */ + negative?: boolean; + /* The values to filter. */ + values: T[]; +}; + +export type AccessLogFilters = { + /* Status code filter. */ + status_code?: AccessLogFilter; + /* Method filter. */ + method?: AccessLogFilter; + /* Host filter. */ + host?: AccessLogFilter; + /* Header filter. */ + headers?: AccessLogFilter; + /* CIDR filter. */ + cidr?: AccessLogFilter; +}; + +export const ACCESS_LOG_FIELD_MODES = ["keep", "drop", "redact"] as const; +export type AccessLogFieldMode = (typeof ACCESS_LOG_FIELD_MODES)[number]; + +export type AccessLogField = { + default?: AccessLogFieldMode; + config: { + [key: string]: AccessLogFieldMode; + }; +}; + +export type AccessLogFields = { + header?: AccessLogField; + query?: AccessLogField; + cookie?: AccessLogField; +}; diff --git a/schemas/config/autocert.ts b/schemas/config/autocert.ts new file mode 100644 index 00000000..dde342fb --- /dev/null +++ b/schemas/config/autocert.ts @@ -0,0 +1,91 @@ +import { DomainOrWildcards as DomainsOrWildcards, Email } from "../types"; + +export const AUTOCERT_PROVIDERS = [ + "local", + "cloudflare", + "clouddns", + "duckdns", + "ovh", +] as const; + +export type AutocertProvider = (typeof AUTOCERT_PROVIDERS)[number]; + +export type AutocertConfig = + | LocalOptions + | CloudflareOptions + | CloudDNSOptions + | DuckDNSOptions + | OVHOptionsWithAppKey + | OVHOptionsWithOAuth2Config; + +export interface AutocertConfigBase { + /* ACME email */ + email: Email; + /* ACME domains */ + domains: DomainsOrWildcards; + /* ACME certificate path */ + cert_path?: string; + /* ACME key path */ + key_path?: string; +} + +export interface LocalOptions extends AutocertConfigBase { + provider: "local"; +} + +export interface CloudflareOptions extends AutocertConfigBase { + provider: "cloudflare"; + options: { auth_token: string }; +} + +export interface CloudDNSOptions extends AutocertConfigBase { + provider: "clouddns"; + options: { + client_id: string; + email: Email; + password: string; + }; +} + + +export interface DuckDNSOptions extends AutocertConfigBase { + provider: "duckdns"; + options: { + token: string; + }; +} + +export const OVH_ENDPOINTS = [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +] as const; + +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; + }; +} + +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; + }; + }; +} diff --git a/schemas/config/config.ts b/schemas/config/config.ts new file mode 100644 index 00000000..9b539220 --- /dev/null +++ b/schemas/config/config.ts @@ -0,0 +1,52 @@ +import { DomainNames } from "../types"; +import { AutocertConfig } from "./autocert"; +import { EntrypointConfig } from "./entrypoint"; +import { HomepageConfig } from "./homepage"; +import { Providers } from "./providers"; + +export type Config = { + /** Optional autocert configuration + * + * @examples require(".").autocertExamples + */ + autocert?: AutocertConfig; + /* Optional entrypoint configuration */ + entrypoint?: EntrypointConfig; + /* Providers configuration (include file, docker, notification) */ + providers: Providers; + /** Optional list of domains to match + * + * @minItems 1 + * @examples require(".").matchDomainsExamples + */ + match_domains?: DomainNames; + /* Optional homepage configuration */ + homepage?: HomepageConfig; + /** + * Optional timeout before shutdown + * @default 3 + * @minimum 1 + */ + timeout_shutdown?: number; +}; + +export const autocertExamples = [ + { provider: "local" }, + { + provider: "cloudflare", + email: "abc@gmail", + domains: ["example.com"], + options: { auth_token: "c1234565789-abcdefghijklmnopqrst" }, + }, + { + provider: "clouddns", + email: "abc@gmail", + domains: ["example.com"], + options: { + client_id: "c1234565789", + email: "abc@gmail", + password: "password", + }, + }, +]; +export const matchDomainsExamples = ["example.com", "*.example.com"] as const; diff --git a/schemas/config/entrypoint.ts b/schemas/config/entrypoint.ts new file mode 100644 index 00000000..a5121a83 --- /dev/null +++ b/schemas/config/entrypoint.ts @@ -0,0 +1,47 @@ +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; +}; + +export const accessLogExamples = [ + { + path: "/var/log/access.log", + format: "combined", + filters: { + status_codes: { + values: ["200-299"], + }, + }, + fields: { + headers: { + default: "keep", + config: { + foo: "redact", + }, + }, + }, + }, +] as const; + +export const middlewaresExamples = [ + { + use: "RedirectHTTP", + }, + { + use: "CIDRWhitelist", + allow: ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], + status: 403, + message: "Forbidden", + }, +] as const; diff --git a/schemas/config/homepage.ts b/schemas/config/homepage.ts new file mode 100644 index 00000000..31f783b9 --- /dev/null +++ b/schemas/config/homepage.ts @@ -0,0 +1,7 @@ +export type HomepageConfig = { + /** + * Use default app categories (uses docker image name) + * @default true + */ + use_default_categories: boolean; +}; diff --git a/schemas/config/notification.ts b/schemas/config/notification.ts new file mode 100644 index 00000000..aa90ffca --- /dev/null +++ b/schemas/config/notification.ts @@ -0,0 +1,67 @@ +import { URL } from "../types"; + +export const NOTIFICATION_PROVIDERS = ["webhook", "gotify"] as const; + +export type NotificationProvider = (typeof NOTIFICATION_PROVIDERS)[number]; + +export type NotificationConfig = { + /* Name of the notification provider */ + name: string; + /* URL of the notification provider */ + url: URL; +}; + +export interface GotifyConfig extends NotificationConfig { + provider: "gotify"; + /* Gotify token */ + token: string; +} + +export const WEBHOOK_TEMPLATES = ["discord"] as const; +export const WEBHOOK_METHODS = ["POST", "GET", "PUT"] as const; +export const WEBHOOK_MIME_TYPES = [ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", +] as const; +export const WEBHOOK_COLOR_MODES = ["hex", "dec"] as const; + +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; + /* Webhook token */ + 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.ts b/schemas/config/providers.ts new file mode 100644 index 00000000..e049927c --- /dev/null +++ b/schemas/config/providers.ts @@ -0,0 +1,46 @@ +import { URI, URL } from "../types"; +import { GotifyConfig, 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 + * @items.pattern ^((\w+://)[^\s]+)|\$DOCKER_HOST$ + */ + docker?: { [name: string]: URL }; + /** List of notification providers + * + * @minItems 1 + * @examples require(".").notificationExamples + */ + notification?: (WebhookConfig | GotifyConfig)[]; +}; + +export const includeExamples = ["file1.yml", "file2.yml"] as const; +export const dockerExamples = [ + { local: "$DOCKER_HOST" }, + { remote: "tcp://10.0.2.1:2375" }, + { remote2: "ssh://root:1234@10.0.2.2" }, +] as const; +export const notificationExamples = [ + { + name: "gotify", + provider: "gotify", + url: "https://gotify.domain.tld", + token: "abcd", + }, + { + name: "discord", + provider: "webhook", + template: "discord", + url: "https://discord.com/api/webhooks/1234/abcd", + }, +] as const; diff --git a/schemas/docker.ts b/schemas/docker.ts new file mode 100644 index 00000000..80684401 --- /dev/null +++ b/schemas/docker.ts @@ -0,0 +1,7 @@ +import { IdleWatcherConfig } from "./providers/idlewatcher"; +import { Route } from "./providers/routes"; + +//FIXME: fix this +export type DockerRoutes = { + [key: string]: Route & IdleWatcherConfig; +}; diff --git a/schemas/docker_routes.schema.json b/schemas/docker_routes.schema.json new file mode 100644 index 00000000..dd1f1abe --- /dev/null +++ b/schemas/docker_routes.schema.json @@ -0,0 +1,1198 @@ +{ + "$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": { + "type": "string" + }, + "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": { + "format": "uri-reference", + "type": "string" + } + }, + "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": { + "default": "5s", + "description": "Healthcheck interval", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "path": { + "default": "/", + "description": "Healthcheck path", + "format": "uri-reference", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Healthcheck timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "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": { + "type": "string" + }, + "name": { + "type": "string" + }, + "show": { + "default": true, + "description": "Whether show in dashboard", + "type": "boolean" + }, + "url": { + "format": "uri", + "type": "string" + }, + "widget_config": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, + "host": { + "default": "localhost", + "description": "Proxy host", + "type": "string" + }, + "idle_timeout": { + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "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": { + "type": "string" + }, + "type": "array" + }, + "port": { + "default": 80, + "description": "Proxy port", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "scheme": { + "$ref": "#/definitions/ProxyScheme", + "default": "http", + "description": "Proxy scheme" + }, + "start_endpoint": { + "format": "uri-reference", + "type": "string" + }, + "stop_method": { + "$ref": "#/definitions/StopMethod", + "default": "stop", + "description": "Stop method" + }, + "stop_signal": { + "$ref": "#/definitions/Signal" + }, + "stop_timeout": { + "default": "10s", + "description": "Stop timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "wake_timeout": { + "default": "30s", + "description": "Wake timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + } + }, + "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": { + "default": "5s", + "description": "Healthcheck interval", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "path": { + "default": "/", + "description": "Healthcheck path", + "format": "uri-reference", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Healthcheck timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "use_get": { + "default": false, + "description": "Use GET instead of HEAD", + "type": "boolean" + } + }, + "type": "object" + }, + "host": { + "default": "localhost", + "description": "Stream host", + "type": "string" + }, + "idle_timeout": { + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "port": { + "pattern": "^\\d+:\\d+$", + "type": "string" + }, + "scheme": { + "$ref": "#/definitions/StreamScheme", + "default": "tcp", + "description": "Stream scheme" + }, + "start_endpoint": { + "format": "uri-reference", + "type": "string" + }, + "stop_method": { + "$ref": "#/definitions/StopMethod", + "default": "stop", + "description": "Stop method" + }, + "stop_signal": { + "$ref": "#/definitions/Signal" + }, + "stop_timeout": { + "default": "10s", + "description": "Stop timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "wake_timeout": { + "default": "30s", + "description": "Wake timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + } + }, + "required": [ + "port", + "scheme" + ], + "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" + } + ] + }, + "LoadBalanceConfig": { + "additionalProperties": false, + "anyOf": [ + { + "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": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "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": { + "use": { + "pattern": "^.*@file$", + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "$ref": "#/definitions/{error_page:Omit;errorPage:Omit;ErrorPage:Omit;custom_error_page:Omit;customErrorPage:Omit;CustomErrorPage:Omit;}" + }, + { + "$ref": "#/definitions/{redirect_http:Omit;redirectHTTP:Omit;RedirectHTTP:Omit;}" + }, + { + "$ref": "#/definitions/{set_x_forwarded:Omit;setXForwarded:Omit;SetXForwarded:Omit;}" + }, + { + "$ref": "#/definitions/{hide_x_forwarded:Omit;hideXForwarded:Omit;HideXForwarded:Omit;}" + }, + { + "$ref": "#/definitions/{cidr_whitelist:Omit;cidrWhitelist:Omit;CIDRWhitelist:Omit;}" + }, + { + "$ref": "#/definitions/{cloudflare_real_ip:Omit;cloudflareRealIp:Omit;}" + }, + { + "$ref": "#/definitions/{request:Omit;Request:Omit;modify_request:Omit;modifyRequest:Omit;ModifyRequest:Omit;}" + }, + { + "$ref": "#/definitions/{response:Omit;Response:Omit;modify_response:Omit;modifyResponse:Omit;ModifyResponse:Omit;}" + }, + { + "$ref": "#/definitions/{oidc:Omit;OIDC:Omit;}" + }, + { + "$ref": "#/definitions/{rate_limit:Omit;rateLimit:Omit;RateLimit:Omit;}" + }, + { + "$ref": "#/definitions/{real_ip:Omit;realIP:Omit;RealIP:Omit;}" + }, + { + "$ref": "#/definitions/{[x:`${string}@file`]:NullOrEmptyMap;}" + } + ] + }, + "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": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + } + }, + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + } + }, + "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": { + "default": "1s", + "description": "Duration of the rate limit", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + } + }, + "required": [ + "average", + "burst" + ], + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "properties": { + "from": { + "items": { + "$ref": "#/definitions/CIDR" + }, + "type": "array" + }, + "header": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "recursive": { + "default": false, + "description": "Recursive resolve the IP", + "type": "boolean" + } + }, + "required": [ + "from" + ], + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "type": "object" + }, + "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" + }, + "StreamScheme": { + "enum": [ + "tcp", + "udp" + ], + "type": "string" + }, + "{[x:`${string}@file`]:NullOrEmptyMap;}": { + "additionalProperties": false, + "type": "object" + }, + "{cidr_whitelist:Omit;cidrWhitelist:Omit;CIDRWhitelist:Omit;}": { + "additionalProperties": false, + "properties": { + "CIDRWhitelist": { + "$ref": "#/definitions/Omit" + }, + "cidrWhitelist": { + "$ref": "#/definitions/Omit" + }, + "cidr_whitelist": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "CIDRWhitelist", + "cidrWhitelist", + "cidr_whitelist" + ], + "type": "object" + }, + "{cloudflare_real_ip:Omit;cloudflareRealIp:Omit;}": { + "additionalProperties": false, + "properties": { + "cloudflareRealIp": { + "$ref": "#/definitions/Omit" + }, + "cloudflare_real_ip": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "cloudflareRealIp", + "cloudflare_real_ip" + ], + "type": "object" + }, + "{error_page:Omit;errorPage:Omit;ErrorPage:Omit;custom_error_page:Omit;customErrorPage:Omit;CustomErrorPage:Omit;}": { + "additionalProperties": false, + "properties": { + "CustomErrorPage": { + "$ref": "#/definitions/Omit" + }, + "ErrorPage": { + "$ref": "#/definitions/Omit" + }, + "customErrorPage": { + "$ref": "#/definitions/Omit" + }, + "custom_error_page": { + "$ref": "#/definitions/Omit" + }, + "errorPage": { + "$ref": "#/definitions/Omit" + }, + "error_page": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "CustomErrorPage", + "ErrorPage", + "customErrorPage", + "custom_error_page", + "errorPage", + "error_page" + ], + "type": "object" + }, + "{hide_x_forwarded:Omit;hideXForwarded:Omit;HideXForwarded:Omit;}": { + "additionalProperties": false, + "properties": { + "HideXForwarded": { + "$ref": "#/definitions/Omit" + }, + "hideXForwarded": { + "$ref": "#/definitions/Omit" + }, + "hide_x_forwarded": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "HideXForwarded", + "hideXForwarded", + "hide_x_forwarded" + ], + "type": "object" + }, + "{oidc:Omit;OIDC:Omit;}": { + "additionalProperties": false, + "properties": { + "OIDC": { + "$ref": "#/definitions/Omit" + }, + "oidc": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "OIDC", + "oidc" + ], + "type": "object" + }, + "{rate_limit:Omit;rateLimit:Omit;RateLimit:Omit;}": { + "additionalProperties": false, + "properties": { + "RateLimit": { + "$ref": "#/definitions/Omit" + }, + "rateLimit": { + "$ref": "#/definitions/Omit" + }, + "rate_limit": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RateLimit", + "rateLimit", + "rate_limit" + ], + "type": "object" + }, + "{real_ip:Omit;realIP:Omit;RealIP:Omit;}": { + "additionalProperties": false, + "properties": { + "RealIP": { + "$ref": "#/definitions/Omit" + }, + "realIP": { + "$ref": "#/definitions/Omit" + }, + "real_ip": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RealIP", + "realIP", + "real_ip" + ], + "type": "object" + }, + "{redirect_http:Omit;redirectHTTP:Omit;RedirectHTTP:Omit;}": { + "additionalProperties": false, + "properties": { + "RedirectHTTP": { + "$ref": "#/definitions/Omit" + }, + "redirectHTTP": { + "$ref": "#/definitions/Omit" + }, + "redirect_http": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RedirectHTTP", + "redirectHTTP", + "redirect_http" + ], + "type": "object" + }, + "{request:Omit;Request:Omit;modify_request:Omit;modifyRequest:Omit;ModifyRequest:Omit;}": { + "additionalProperties": false, + "properties": { + "ModifyRequest": { + "$ref": "#/definitions/Omit" + }, + "Request": { + "$ref": "#/definitions/Omit" + }, + "modifyRequest": { + "$ref": "#/definitions/Omit" + }, + "modify_request": { + "$ref": "#/definitions/Omit" + }, + "request": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "ModifyRequest", + "Request", + "modifyRequest", + "modify_request", + "request" + ], + "type": "object" + }, + "{response:Omit;Response:Omit;modify_response:Omit;modifyResponse:Omit;ModifyResponse:Omit;}": { + "additionalProperties": false, + "properties": { + "ModifyResponse": { + "$ref": "#/definitions/Omit" + }, + "Response": { + "$ref": "#/definitions/Omit" + }, + "modifyResponse": { + "$ref": "#/definitions/Omit" + }, + "modify_response": { + "$ref": "#/definitions/Omit" + }, + "response": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "ModifyResponse", + "Response", + "modifyResponse", + "modify_response", + "response" + ], + "type": "object" + }, + "{set_x_forwarded:Omit;setXForwarded:Omit;SetXForwarded:Omit;}": { + "additionalProperties": false, + "properties": { + "SetXForwarded": { + "$ref": "#/definitions/Omit" + }, + "setXForwarded": { + "$ref": "#/definitions/Omit" + }, + "set_x_forwarded": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "SetXForwarded", + "setXForwarded", + "set_x_forwarded" + ], + "type": "object" + } + }, + "type": "object" +} + diff --git a/schemas/middleware_compose.schema.json b/schemas/middleware_compose.schema.json new file mode 100644 index 00000000..557f7a8a --- /dev/null +++ b/schemas/middleware_compose.schema.json @@ -0,0 +1,364 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "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" + } + ] + }, + "MiddlewareComposeMap": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "CustomErrorPage", + "ErrorPage", + "customErrorPage", + "custom_error_page", + "errorPage", + "error_page" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "RedirectHTTP", + "redirectHTTP", + "redirect_http" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "SetXForwarded", + "setXForwarded", + "set_x_forwarded" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "use": { + "enum": [ + "HideXForwarded", + "hideXForwarded", + "hide_x_forwarded" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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" + }, + "use": { + "enum": [ + "CIDRWhitelist", + "cidrWhitelist", + "cidr_whitelist" + ], + "type": "string" + } + }, + "required": [ + "allow", + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "recursive": { + "default": false, + "description": "Recursively resolve the IP", + "type": "boolean" + }, + "use": { + "enum": [ + "cloudflareRealIp", + "cloudflare_real_ip" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + }, + "use": { + "enum": [ + "ModifyRequest", + "Request", + "modifyRequest", + "modify_request", + "request" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + }, + "use": { + "enum": [ + "ModifyResponse", + "Response", + "modifyResponse", + "modify_response", + "response" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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" + }, + "use": { + "enum": [ + "OIDC", + "oidc" + ], + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "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": { + "default": "1s", + "description": "Duration of the rate limit", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "use": { + "enum": [ + "RateLimit", + "rateLimit", + "rate_limit" + ], + "type": "string" + } + }, + "required": [ + "average", + "burst", + "use" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "items": { + "$ref": "#/definitions/CIDR" + }, + "type": "array" + }, + "header": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "recursive": { + "default": false, + "description": "Recursive resolve the IP", + "type": "boolean" + }, + "use": { + "enum": [ + "RealIP", + "realIP", + "real_ip" + ], + "type": "string" + } + }, + "required": [ + "from", + "use" + ], + "type": "object" + } + ] + }, + "StatusCode": { + "anyOf": [ + { + "pattern": "^[0-9]*$", + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "items": { + "$ref": "#/definitions/MiddlewareComposeMap" + }, + "type": "array" +} + diff --git a/schemas/middlewares/middleware_compose.ts b/schemas/middlewares/middleware_compose.ts new file mode 100644 index 00000000..0bfae2d3 --- /dev/null +++ b/schemas/middlewares/middleware_compose.ts @@ -0,0 +1,3 @@ +import { MiddlewareComposeMap } from "./middlewares"; + +export type MiddlewareCompose = MiddlewareComposeMap[]; \ No newline at end of file diff --git a/schemas/middlewares/middlewares.ts b/schemas/middlewares/middlewares.ts new file mode 100644 index 00000000..483fde2d --- /dev/null +++ b/schemas/middlewares/middlewares.ts @@ -0,0 +1,149 @@ +import * as types from "../types"; + +export type MiddlewareComposeObjectRef = `${string}@file`; + +export type KeyOptMapping = { + [key in T["use"]]: Omit; +} | { use: MiddlewareComposeObjectRef }; + +export type MiddlewaresMap = ( + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | KeyOptMapping + | { [key in MiddlewareComposeObjectRef]: types.NullOrEmptyMap } +); + +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"; +}; + +export type RedirectHTTP = { + use: "redirect_http" | "redirectHTTP" | "RedirectHTTP"; +}; + +export type SetXForwarded = { + use: "set_x_forwarded" | "setXForwarded" | "SetXForwarded"; +}; +export type HideXForwarded = { + use: "hide_x_forwarded" | "hideXForwarded" | "HideXForwarded"; +}; + +export type CIDRWhitelist = { + use: "cidr_whitelist" | "cidrWhitelist" | "CIDRWhitelist"; + /* Allowed CIDRs/IPs */ + 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" | "cloudflare_real_ip"; + /** 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[]; +}; + +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[]; +}; + +export type OIDC = { + 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; +}; + +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; +}; diff --git a/schemas/providers/healthcheck.ts b/schemas/providers/healthcheck.ts new file mode 100644 index 00000000..fc2853ed --- /dev/null +++ b/schemas/providers/healthcheck.ts @@ -0,0 +1,33 @@ +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; +}; diff --git a/schemas/providers/homepage.ts b/schemas/providers/homepage.ts new file mode 100644 index 00000000..20e499c3 --- /dev/null +++ b/schemas/providers/homepage.ts @@ -0,0 +1,36 @@ +import { URL } from "../types"; + +/** + * @additionalProperties false + */ +export type HomepageConfig = { + /** Whether show in dashboard + * + * @default true + */ + show?: boolean; + /* Display name on dashboard */ + name?: string; + /* Display icon on dashboard */ + icon?: URL | WalkxcodeIcon | TargetRelativeIconPath; + /* App description */ + description?: string; + /* Override url */ + url?: URL; + /* App category */ + category?: string; + /* Widget config */ + widget_config?: { + [key: string]: any; + }; +}; + +/** + * @pattern ^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1$ + */ +export type WalkxcodeIcon = string; + +/** + * @pattern ^@target/.+$ + */ +export type TargetRelativeIconPath = string; diff --git a/schemas/providers/idlewatcher.ts b/schemas/providers/idlewatcher.ts new file mode 100644 index 00000000..df24bbfa --- /dev/null +++ b/schemas/providers/idlewatcher.ts @@ -0,0 +1,41 @@ +import { Duration, URI } from "../types"; + +export const STOP_METHODS = ["pause", "stop", "kill"] as const; +export type StopMethod = (typeof STOP_METHODS)[number]; + +export const STOP_SIGNALS = [ + "", + "SIGINT", + "SIGTERM", + "SIGHUP", + "SIGQUIT", + "INT", + "TERM", + "HUP", + "QUIT", +] as const; +export type Signal = (typeof STOP_SIGNALS)[number]; + +export type IdleWatcherConfig = { + /* Idle timeout */ + idle_timeout?: Duration; + /** Wake timeout + * + * @default 30s + */ + wake_timeout?: Duration; + /** Stop timeout + * + * @default 10s + */ + stop_timeout?: Duration; + /** Stop method + * + * @default stop + */ + stop_method?: StopMethod; + /* Stop signal */ + stop_signal?: Signal; + /* Start endpoint (any path can wake the container if not specified) */ + start_endpoint?: URI; +}; diff --git a/schemas/providers/loadbalance.ts b/schemas/providers/loadbalance.ts new file mode 100644 index 00000000..4d29fdf3 --- /dev/null +++ b/schemas/providers/loadbalance.ts @@ -0,0 +1,44 @@ +import { RealIP } from "../middlewares/middlewares"; + +export const LOAD_BALANCE_MODES = [ + "round_robin", + "least_conn", + "ip_hash", +] as const; +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; +}; + +export type LoadBalanceConfig = LoadBalanceConfigBase & + ( + | {} // linking other routes + | RoundRobinLoadBalanceConfig + | LeastConnLoadBalanceConfig + | IPHashLoadBalanceConfig + ); + +export type IPHashLoadBalanceConfig = { + mode: "ip_hash"; + /** Real IP config, header to get client IP from */ + config: RealIP; +}; + +export type LeastConnLoadBalanceConfig = { + mode: "least_conn"; +}; + +export type RoundRobinLoadBalanceConfig = { + mode: "round_robin"; +}; diff --git a/schemas/providers/routes.ts b/schemas/providers/routes.ts new file mode 100644 index 00000000..ac5d969d --- /dev/null +++ b/schemas/providers/routes.ts @@ -0,0 +1,114 @@ +import { AccessLogConfig } from "../config/access_log"; +import { accessLogExamples } from "../config/entrypoint"; +import { MiddlewaresMap } from "../middlewares/middlewares"; +import { Hostname, IPv4, IPv6, PathPattern, Port, StreamPort } from "../types"; +import { HealthcheckConfig } from "./healthcheck"; +import { HomepageConfig } from "./homepage"; +import { LoadBalanceConfig } from "./loadbalance"; +export const PROXY_SCHEMES = ["http", "https"] as const; +export const STREAM_SCHEMES = ["tcp", "udp"] as const; + +export type ProxyScheme = (typeof PROXY_SCHEMES)[number]; +export type StreamScheme = (typeof STREAM_SCHEMES)[number]; + +export type Route = ReverseProxyRoute | StreamRoute; +export type Routes = { + [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; + /** 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 StreamRoute = { + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + /** Stream scheme + * + * @default tcp + */ + scheme: StreamScheme; + /** Stream host + * + * @default localhost + */ + host?: Hostname | IPv4 | IPv6; + /* Stream port */ + port: StreamPort; + /** Healthcheck config */ + healthcheck?: HealthcheckConfig; +}; + +export const homepageExamples = [ + { + name: "Sonarr", + icon: "png/sonarr.png", + category: "Arr suite", + }, + { + name: "App", + icon: "@target/favicon.ico", + }, +]; + +export const loadBalanceExamples = [ + { + link: "flaresolverr", + mode: "round_robin", + }, + { + link: "service.domain.com", + mode: "ip_hash", + config: { + header: "X-Real-IP", + }, + }, +]; + +export { accessLogExamples }; diff --git a/schemas/routes.schema.json b/schemas/routes.schema.json new file mode 100644 index 00000000..e533ef26 --- /dev/null +++ b/schemas/routes.schema.json @@ -0,0 +1,1123 @@ +{ + "$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" + } + ] + }, + "LoadBalanceConfig": { + "additionalProperties": false, + "anyOf": [ + { + "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": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "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": { + "use": { + "pattern": "^.*@file$", + "type": "string" + } + }, + "required": [ + "use" + ], + "type": "object" + }, + { + "$ref": "#/definitions/{error_page:Omit;errorPage:Omit;ErrorPage:Omit;custom_error_page:Omit;customErrorPage:Omit;CustomErrorPage:Omit;}" + }, + { + "$ref": "#/definitions/{redirect_http:Omit;redirectHTTP:Omit;RedirectHTTP:Omit;}" + }, + { + "$ref": "#/definitions/{set_x_forwarded:Omit;setXForwarded:Omit;SetXForwarded:Omit;}" + }, + { + "$ref": "#/definitions/{hide_x_forwarded:Omit;hideXForwarded:Omit;HideXForwarded:Omit;}" + }, + { + "$ref": "#/definitions/{cidr_whitelist:Omit;cidrWhitelist:Omit;CIDRWhitelist:Omit;}" + }, + { + "$ref": "#/definitions/{cloudflare_real_ip:Omit;cloudflareRealIp:Omit;}" + }, + { + "$ref": "#/definitions/{request:Omit;Request:Omit;modify_request:Omit;modifyRequest:Omit;ModifyRequest:Omit;}" + }, + { + "$ref": "#/definitions/{response:Omit;Response:Omit;modify_response:Omit;modifyResponse:Omit;ModifyResponse:Omit;}" + }, + { + "$ref": "#/definitions/{oidc:Omit;OIDC:Omit;}" + }, + { + "$ref": "#/definitions/{rate_limit:Omit;rateLimit:Omit;RateLimit:Omit;}" + }, + { + "$ref": "#/definitions/{real_ip:Omit;realIP:Omit;RealIP:Omit;}" + }, + { + "$ref": "#/definitions/{[x:`${string}@file`]:NullOrEmptyMap;}" + } + ] + }, + "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": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + } + }, + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "properties": { + "add_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Add HTTP headers", + "type": "object" + }, + "hide_headers": { + "description": "Hide HTTP headers", + "items": { + "type": "string" + }, + "type": "array" + }, + "set_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Set HTTP headers", + "type": "object" + } + }, + "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": { + "default": "1s", + "description": "Duration of the rate limit", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + } + }, + "required": [ + "average", + "burst" + ], + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "properties": { + "from": { + "items": { + "$ref": "#/definitions/CIDR" + }, + "type": "array" + }, + "header": { + "default": "X-Real-IP", + "description": "Header to get the client IP from", + "pattern": "^[a-zA-Z0-9\\-]+$", + "type": "string" + }, + "recursive": { + "default": false, + "description": "Recursive resolve the IP", + "type": "boolean" + } + }, + "required": [ + "from" + ], + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "type": "object" + }, + "Omit": { + "additionalProperties": false, + "type": "object" + }, + "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": { + "type": "string" + }, + "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": { + "format": "uri-reference", + "type": "string" + } + }, + "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": { + "default": "5s", + "description": "Healthcheck interval", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "path": { + "default": "/", + "description": "Healthcheck path", + "format": "uri-reference", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Healthcheck timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "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": { + "type": "string" + }, + "name": { + "type": "string" + }, + "show": { + "default": true, + "description": "Whether show in dashboard", + "type": "boolean" + }, + "url": { + "format": "uri", + "type": "string" + }, + "widget_config": { + "additionalProperties": {}, + "type": "object" + } + }, + "type": "object" + }, + "host": { + "default": "localhost", + "description": "Proxy host", + "type": "string" + }, + "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": { + "type": "string" + }, + "type": "array" + }, + "port": { + "default": 80, + "description": "Proxy port", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "scheme": { + "$ref": "#/definitions/ProxyScheme", + "default": "http", + "description": "Proxy 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": { + "default": "5s", + "description": "Healthcheck interval", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "path": { + "default": "/", + "description": "Healthcheck path", + "format": "uri-reference", + "type": "string" + }, + "timeout": { + "default": "5s", + "description": "Healthcheck timeout", + "pattern": "^([0-9]+(ms|s|m|h))+$", + "type": "string" + }, + "use_get": { + "default": false, + "description": "Use GET instead of HEAD", + "type": "boolean" + } + }, + "type": "object" + }, + "host": { + "default": "localhost", + "description": "Stream host", + "type": "string" + }, + "port": { + "pattern": "^\\d+:\\d+$", + "type": "string" + }, + "scheme": { + "$ref": "#/definitions/StreamScheme", + "default": "tcp", + "description": "Stream scheme" + } + }, + "required": [ + "port", + "scheme" + ], + "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" + } + ] + }, + "StreamScheme": { + "enum": [ + "tcp", + "udp" + ], + "type": "string" + }, + "{[x:`${string}@file`]:NullOrEmptyMap;}": { + "additionalProperties": false, + "type": "object" + }, + "{cidr_whitelist:Omit;cidrWhitelist:Omit;CIDRWhitelist:Omit;}": { + "additionalProperties": false, + "properties": { + "CIDRWhitelist": { + "$ref": "#/definitions/Omit" + }, + "cidrWhitelist": { + "$ref": "#/definitions/Omit" + }, + "cidr_whitelist": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "CIDRWhitelist", + "cidrWhitelist", + "cidr_whitelist" + ], + "type": "object" + }, + "{cloudflare_real_ip:Omit;cloudflareRealIp:Omit;}": { + "additionalProperties": false, + "properties": { + "cloudflareRealIp": { + "$ref": "#/definitions/Omit" + }, + "cloudflare_real_ip": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "cloudflareRealIp", + "cloudflare_real_ip" + ], + "type": "object" + }, + "{error_page:Omit;errorPage:Omit;ErrorPage:Omit;custom_error_page:Omit;customErrorPage:Omit;CustomErrorPage:Omit;}": { + "additionalProperties": false, + "properties": { + "CustomErrorPage": { + "$ref": "#/definitions/Omit" + }, + "ErrorPage": { + "$ref": "#/definitions/Omit" + }, + "customErrorPage": { + "$ref": "#/definitions/Omit" + }, + "custom_error_page": { + "$ref": "#/definitions/Omit" + }, + "errorPage": { + "$ref": "#/definitions/Omit" + }, + "error_page": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "CustomErrorPage", + "ErrorPage", + "customErrorPage", + "custom_error_page", + "errorPage", + "error_page" + ], + "type": "object" + }, + "{hide_x_forwarded:Omit;hideXForwarded:Omit;HideXForwarded:Omit;}": { + "additionalProperties": false, + "properties": { + "HideXForwarded": { + "$ref": "#/definitions/Omit" + }, + "hideXForwarded": { + "$ref": "#/definitions/Omit" + }, + "hide_x_forwarded": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "HideXForwarded", + "hideXForwarded", + "hide_x_forwarded" + ], + "type": "object" + }, + "{oidc:Omit;OIDC:Omit;}": { + "additionalProperties": false, + "properties": { + "OIDC": { + "$ref": "#/definitions/Omit" + }, + "oidc": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "OIDC", + "oidc" + ], + "type": "object" + }, + "{rate_limit:Omit;rateLimit:Omit;RateLimit:Omit;}": { + "additionalProperties": false, + "properties": { + "RateLimit": { + "$ref": "#/definitions/Omit" + }, + "rateLimit": { + "$ref": "#/definitions/Omit" + }, + "rate_limit": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RateLimit", + "rateLimit", + "rate_limit" + ], + "type": "object" + }, + "{real_ip:Omit;realIP:Omit;RealIP:Omit;}": { + "additionalProperties": false, + "properties": { + "RealIP": { + "$ref": "#/definitions/Omit" + }, + "realIP": { + "$ref": "#/definitions/Omit" + }, + "real_ip": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RealIP", + "realIP", + "real_ip" + ], + "type": "object" + }, + "{redirect_http:Omit;redirectHTTP:Omit;RedirectHTTP:Omit;}": { + "additionalProperties": false, + "properties": { + "RedirectHTTP": { + "$ref": "#/definitions/Omit" + }, + "redirectHTTP": { + "$ref": "#/definitions/Omit" + }, + "redirect_http": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "RedirectHTTP", + "redirectHTTP", + "redirect_http" + ], + "type": "object" + }, + "{request:Omit;Request:Omit;modify_request:Omit;modifyRequest:Omit;ModifyRequest:Omit;}": { + "additionalProperties": false, + "properties": { + "ModifyRequest": { + "$ref": "#/definitions/Omit" + }, + "Request": { + "$ref": "#/definitions/Omit" + }, + "modifyRequest": { + "$ref": "#/definitions/Omit" + }, + "modify_request": { + "$ref": "#/definitions/Omit" + }, + "request": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "ModifyRequest", + "Request", + "modifyRequest", + "modify_request", + "request" + ], + "type": "object" + }, + "{response:Omit;Response:Omit;modify_response:Omit;modifyResponse:Omit;ModifyResponse:Omit;}": { + "additionalProperties": false, + "properties": { + "ModifyResponse": { + "$ref": "#/definitions/Omit" + }, + "Response": { + "$ref": "#/definitions/Omit" + }, + "modifyResponse": { + "$ref": "#/definitions/Omit" + }, + "modify_response": { + "$ref": "#/definitions/Omit" + }, + "response": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "ModifyResponse", + "Response", + "modifyResponse", + "modify_response", + "response" + ], + "type": "object" + }, + "{set_x_forwarded:Omit;setXForwarded:Omit;SetXForwarded:Omit;}": { + "additionalProperties": false, + "properties": { + "SetXForwarded": { + "$ref": "#/definitions/Omit" + }, + "setXForwarded": { + "$ref": "#/definitions/Omit" + }, + "set_x_forwarded": { + "$ref": "#/definitions/Omit" + } + }, + "required": [ + "SetXForwarded", + "setXForwarded", + "set_x_forwarded" + ], + "type": "object" + } + }, + "type": "object" +} + diff --git a/schemas/types.ts b/schemas/types.ts new file mode 100644 index 00000000..2ed18d57 --- /dev/null +++ b/schemas/types.ts @@ -0,0 +1,111 @@ +/** + * @type "null" + */ +export interface Null {} +export type Nullable = T | Null; +export type NullOrEmptyMap = {} | Null; + +export const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "CONNECT", + "HEAD", + "OPTIONS", + "TRACE", +] as const; + +export type HTTPMethod = (typeof HTTP_METHODS)[number]; +/** + * HTTP Header + * @pattern ^[a-zA-Z0-9\-]+$ + */ +export type HTTPHeader = string; + +/** + * HTTP Query + * @pattern ^[a-zA-Z0-9\-_]+$ + */ +export type HTTPQuery = string; +/** + * HTTP Cookie + * @pattern ^[a-zA-Z0-9\-_]+$ + */ +export type HTTPCookie = string; + +export type StatusCode = number | `${number}`; +export type StatusCodeRange = number | `${number}` | `${number}-${number}`; + +/** + * @items.pattern ^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ + */ +export type DomainNames = string[]; +/** + * @items.pattern ^(\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ + */ +export type DomainOrWildcards = string[]; +/** + * @format hostname + */ +export type Hostname = string; +/** + * @format ipv4 + */ +export type IPv4 = string; +/** + * @format ipv6 + */ +export type IPv6 = string; + +/* CIDR / IPv4 / IPv6 */ +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 + * @maximum 65535 + */ +export type Port = number; + +/** + * @pattern ^\d+:\d+$ + */ +export type StreamPort = string; + +/** + * @format email + */ +export type Email = string; + +/** + * @format uri + */ +export type URL = string; + +/** + * @format uri-reference + */ +export type URI = string; + +/** + * @pattern ^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\/)?(\\/[^\\s]*)$ + */ +export type PathPattern = string; + +/** + * @pattern ^([0-9]+(ms|s|m|h))+$ + */ +export type Duration = string; + +/** + * @format date-time + */ +export type DateTime = string; From b253dce7e19a74623ef3b3e1d40e9ea4f591df2d Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 19 Jan 2025 04:32:50 +0800 Subject: [PATCH 4/7] cleanup some loadbalancer code --- internal/net/http/loadbalancer/ip_hash.go | 4 +- internal/net/http/loadbalancer/least_conn.go | 12 +-- .../net/http/loadbalancer/loadbalancer.go | 94 ++++++++++++------- .../http/loadbalancer/loadbalancer_test.go | 27 +++--- internal/net/http/loadbalancer/round_robin.go | 6 +- internal/net/http/loadbalancer/types.go | 2 +- .../net/http/loadbalancer/types/server.go | 76 +++++++++------ internal/watcher/health/health_checker.go | 8 +- 8 files changed, 136 insertions(+), 93 deletions(-) diff --git a/internal/net/http/loadbalancer/ip_hash.go b/internal/net/http/loadbalancer/ip_hash.go index cbb6ab04..384f7cfc 100644 --- a/internal/net/http/loadbalancer/ip_hash.go +++ b/internal/net/http/loadbalancer/ip_hash.go @@ -31,7 +31,7 @@ func (lb *LoadBalancer) newIPHash() impl { return impl } -func (impl *ipHash) OnAddServer(srv *Server) { +func (impl *ipHash) OnAddServer(srv Server) { impl.mu.Lock() defer impl.mu.Unlock() @@ -48,7 +48,7 @@ func (impl *ipHash) OnAddServer(srv *Server) { impl.pool = append(impl.pool, srv) } -func (impl *ipHash) OnRemoveServer(srv *Server) { +func (impl *ipHash) OnRemoveServer(srv Server) { impl.mu.Lock() defer impl.mu.Unlock() diff --git a/internal/net/http/loadbalancer/least_conn.go b/internal/net/http/loadbalancer/least_conn.go index 3363915f..7130e424 100644 --- a/internal/net/http/loadbalancer/least_conn.go +++ b/internal/net/http/loadbalancer/least_conn.go @@ -9,21 +9,21 @@ import ( type leastConn struct { *LoadBalancer - nConn F.Map[*Server, *atomic.Int64] + nConn F.Map[Server, *atomic.Int64] } func (lb *LoadBalancer) newLeastConn() impl { return &leastConn{ LoadBalancer: lb, - nConn: F.NewMapOf[*Server, *atomic.Int64](), + nConn: F.NewMapOf[Server, *atomic.Int64](), } } -func (impl *leastConn) OnAddServer(srv *Server) { +func (impl *leastConn) OnAddServer(srv Server) { impl.nConn.Store(srv, new(atomic.Int64)) } -func (impl *leastConn) OnRemoveServer(srv *Server) { +func (impl *leastConn) OnRemoveServer(srv Server) { impl.nConn.Delete(srv) } @@ -31,14 +31,14 @@ func (impl *leastConn) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.R srv := srvs[0] minConn, ok := impl.nConn.Load(srv) if !ok { - impl.l.Error().Msgf("[BUG] server %s not found", srv.Name) + impl.l.Error().Msgf("[BUG] server %s not found", srv.Name()) http.Error(rw, "Internal error", http.StatusInternalServerError) } for i := 1; i < len(srvs); i++ { nConn, ok := impl.nConn.Load(srvs[i]) if !ok { - impl.l.Error().Msgf("[BUG] server %s not found", srv.Name) + impl.l.Error().Msgf("[BUG] server %s not found", srv.Name()) http.Error(rw, "Internal error", http.StatusInternalServerError) } if nConn.Load() < minConn.Load() { diff --git a/internal/net/http/loadbalancer/loadbalancer.go b/internal/net/http/loadbalancer/loadbalancer.go index bea55b6b..c4b8d71e 100644 --- a/internal/net/http/loadbalancer/loadbalancer.go +++ b/internal/net/http/loadbalancer/loadbalancer.go @@ -20,8 +20,8 @@ import ( type ( impl interface { ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) - OnAddServer(srv *Server) - OnRemoveServer(srv *Server) + OnAddServer(srv Server) + OnRemoveServer(srv Server) } LoadBalancer struct { @@ -61,7 +61,7 @@ func (lb *LoadBalancer) Start(parent task.Parent) E.Error { }) lb.task.OnFinished("cleanup", func() { if lb.impl != nil { - lb.pool.RangeAll(func(k string, v *Server) { + lb.pool.RangeAll(func(k string, v Server) { lb.impl.OnRemoveServer(v) }) } @@ -90,7 +90,7 @@ func (lb *LoadBalancer) updateImpl() { default: // should happen in test only lb.impl = lb.newRoundRobin() } - lb.pool.RangeAll(func(_ string, srv *Server) { + lb.pool.RangeAll(func(_ string, srv Server) { lb.impl.OnAddServer(srv) }) } @@ -120,44 +120,44 @@ func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *Config) { } } -func (lb *LoadBalancer) AddServer(srv *Server) { +func (lb *LoadBalancer) AddServer(srv Server) { lb.poolMu.Lock() defer lb.poolMu.Unlock() - if lb.pool.Has(srv.Name) { - old, _ := lb.pool.Load(srv.Name) - lb.sumWeight -= old.Weight + if lb.pool.Has(srv.Name()) { + old, _ := lb.pool.Load(srv.Name()) + lb.sumWeight -= old.Weight() lb.impl.OnRemoveServer(old) } - lb.pool.Store(srv.Name, srv) - lb.sumWeight += srv.Weight + lb.pool.Store(srv.Name(), srv) + lb.sumWeight += srv.Weight() lb.rebalance() lb.impl.OnAddServer(srv) lb.l.Debug(). Str("action", "add"). - Str("server", srv.Name). + Str("server", srv.Name()). Msgf("%d servers available", lb.pool.Size()) } -func (lb *LoadBalancer) RemoveServer(srv *Server) { +func (lb *LoadBalancer) RemoveServer(srv Server) { lb.poolMu.Lock() defer lb.poolMu.Unlock() - if !lb.pool.Has(srv.Name) { + if !lb.pool.Has(srv.Name()) { return } - lb.pool.Delete(srv.Name) + lb.pool.Delete(srv.Name()) - lb.sumWeight -= srv.Weight + lb.sumWeight -= srv.Weight() lb.rebalance() lb.impl.OnRemoveServer(srv) lb.l.Debug(). Str("action", "remove"). - Str("server", srv.Name). + Str("server", srv.Name()). Msgf("%d servers left", lb.pool.Size()) if lb.pool.Size() == 0 { @@ -178,13 +178,14 @@ 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) { - s.Weight = weightEach + lb.pool.RangeAll(func(_ string, s Server) { + w := weightEach lb.sumWeight += weightEach if remainder > 0 { - s.Weight++ + w++ remainder-- } + s.SetWeight(w) }) return } @@ -193,25 +194,25 @@ func (lb *LoadBalancer) rebalance() { scaleFactor := float64(maxWeight) / float64(lb.sumWeight) lb.sumWeight = 0 - lb.pool.RangeAll(func(_ string, s *Server) { - s.Weight = Weight(float64(s.Weight) * scaleFactor) - lb.sumWeight += s.Weight + lb.pool.RangeAll(func(_ string, s Server) { + s.SetWeight(Weight(float64(s.Weight()) * scaleFactor)) + lb.sumWeight += s.Weight() }) delta := maxWeight - lb.sumWeight if delta == 0 { return } - lb.pool.Range(func(_ string, s *Server) bool { + lb.pool.Range(func(_ string, s Server) bool { if delta == 0 { return false } if delta > 0 { - s.Weight++ + s.SetWeight(s.Weight() + 1) lb.sumWeight++ delta-- } else { - s.Weight-- + s.SetWeight(s.Weight() - 1) lb.sumWeight-- delta++ } @@ -229,22 +230,20 @@ func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { // wake all servers for _, srv := range srvs { if err := srv.TryWake(); err != nil { - lb.l.Warn().Err(err).Str("server", srv.Name).Msg("failed to wake server") + lb.l.Warn().Err(err). + Str("server", srv.Name()). + Msg("failed to wake server") } } } lb.impl.ServeHTTP(srvs, rw, r) } -func (lb *LoadBalancer) Uptime() time.Duration { - return time.Since(lb.startTime) -} - // MarshalJSON implements health.HealthMonitor. func (lb *LoadBalancer) MarshalJSON() ([]byte, error) { extra := make(map[string]any) - lb.pool.RangeAll(func(k string, v *Server) { - extra[v.Name] = v.HealthMonitor() + lb.pool.RangeAll(func(k string, v Server) { + extra[v.Name()] = v }) return (&monitor.JSONRepresentation{ @@ -269,20 +268,43 @@ func (lb *LoadBalancer) Status() health.Status { if lb.pool.Size() == 0 { return health.StatusUnknown } - if len(lb.availServers()) == 0 { + + isHealthy := true + lb.pool.Range(func(_ string, srv Server) bool { + if srv.Status().Bad() { + isHealthy = false + return false + } + return true + }) + if !isHealthy { return health.StatusUnhealthy } return health.StatusHealthy } +// Uptime implements health.HealthMonitor. +func (lb *LoadBalancer) Uptime() time.Duration { + return time.Since(lb.startTime) +} + +// Latency implements health.HealthMonitor. +func (lb *LoadBalancer) Latency() time.Duration { + var sum time.Duration + lb.pool.RangeAll(func(_ string, srv Server) { + sum += srv.Latency() + }) + return sum +} + // String implements health.HealthMonitor. func (lb *LoadBalancer) String() string { return lb.Name() } -func (lb *LoadBalancer) availServers() []*Server { - avail := make([]*Server, 0, lb.pool.Size()) - lb.pool.RangeAll(func(_ string, srv *Server) { +func (lb *LoadBalancer) availServers() []Server { + avail := make([]Server, 0, lb.pool.Size()) + lb.pool.RangeAll(func(_ string, srv Server) { if srv.Status().Good() { avail = append(avail, srv) } diff --git a/internal/net/http/loadbalancer/loadbalancer_test.go b/internal/net/http/loadbalancer/loadbalancer_test.go index 234130c8..349565ee 100644 --- a/internal/net/http/loadbalancer/loadbalancer_test.go +++ b/internal/net/http/loadbalancer/loadbalancer_test.go @@ -3,6 +3,7 @@ package loadbalancer import ( "testing" + "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -12,31 +13,31 @@ func TestRebalance(t *testing.T) { t.Run("zero", func(t *testing.T) { lb := New(new(loadbalance.Config)) for range 10 { - lb.AddServer(&Server{}) + lb.AddServer(types.TestNewServer(0)) } lb.rebalance() ExpectEqual(t, lb.sumWeight, maxWeight) }) t.Run("less", func(t *testing.T) { lb := New(new(loadbalance.Config)) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .1)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .2)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .3)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .2)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .1)}) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) lb.rebalance() // t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " "))) ExpectEqual(t, lb.sumWeight, maxWeight) }) t.Run("more", func(t *testing.T) { lb := New(new(loadbalance.Config)) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .1)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .2)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .3)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .4)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .3)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .2)}) - lb.AddServer(&Server{Weight: loadbalance.Weight(float64(maxWeight) * .1)}) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .4)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .3)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .2)) + lb.AddServer(types.TestNewServer(float64(maxWeight) * .1)) lb.rebalance() // t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " "))) ExpectEqual(t, lb.sumWeight, maxWeight) diff --git a/internal/net/http/loadbalancer/round_robin.go b/internal/net/http/loadbalancer/round_robin.go index 494c21e0..09d67706 100644 --- a/internal/net/http/loadbalancer/round_robin.go +++ b/internal/net/http/loadbalancer/round_robin.go @@ -9,9 +9,9 @@ type roundRobin struct { index atomic.Uint32 } -func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} } -func (lb *roundRobin) OnAddServer(srv *Server) {} -func (lb *roundRobin) OnRemoveServer(srv *Server) {} +func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} } +func (lb *roundRobin) OnAddServer(srv Server) {} +func (lb *roundRobin) OnRemoveServer(srv Server) {} func (lb *roundRobin) ServeHTTP(srvs Servers, rw http.ResponseWriter, r *http.Request) { index := lb.index.Add(1) % uint32(len(srvs)) diff --git a/internal/net/http/loadbalancer/types.go b/internal/net/http/loadbalancer/types.go index aa603697..36b45ad3 100644 --- a/internal/net/http/loadbalancer/types.go +++ b/internal/net/http/loadbalancer/types.go @@ -6,7 +6,7 @@ import ( type ( Server = types.Server - Servers = types.Servers + Servers = []types.Server Pool = types.Pool Weight = types.Weight Config = types.Config diff --git a/internal/net/http/loadbalancer/types/server.go b/internal/net/http/loadbalancer/types/server.go index c6b95377..b3df394e 100644 --- a/internal/net/http/loadbalancer/types/server.go +++ b/internal/net/http/loadbalancer/types/server.go @@ -2,7 +2,6 @@ package types import ( "net/http" - "time" idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" "github.com/yusing/go-proxy/internal/net/types" @@ -12,51 +11,72 @@ import ( ) type ( - Server struct { + server struct { _ U.NoCopy - Name string - URL types.URL - Weight Weight + name string + url types.URL + weight Weight - handler http.Handler - healthMon health.HealthMonitor + http.Handler `json:"-"` + health.HealthMonitor } - Servers = []*Server - Pool = F.Map[string, *Server] + + Server interface { + http.Handler + health.HealthMonitor + Name() string + URL() types.URL + Weight() Weight + SetWeight(Weight) + TryWake() error + } + + Pool = F.Map[string, Server] ) var NewServerPool = F.NewMap[Pool] -func NewServer(name string, url types.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) *Server { - srv := &Server{ - Name: name, - URL: url, - Weight: weight, - handler: handler, - healthMon: healthMon, +func NewServer(name string, url types.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { + srv := &server{ + name: name, + url: url, + weight: weight, + Handler: handler, + HealthMonitor: healthMon, } return srv } -func (srv *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - srv.handler.ServeHTTP(rw, r) +func TestNewServer[T ~int | ~float32 | ~float64](weight T) Server { + srv := &server{ + weight: Weight(weight), + } + return srv } -func (srv *Server) String() string { - return srv.Name +func (srv *server) Name() string { + return srv.name } -func (srv *Server) Status() health.Status { - return srv.healthMon.Status() +func (srv *server) URL() types.URL { + return srv.url } -func (srv *Server) Uptime() time.Duration { - return srv.healthMon.Uptime() +func (srv *server) Weight() Weight { + return srv.weight } -func (srv *Server) TryWake() error { - waker, ok := srv.handler.(idlewatcher.Waker) +func (srv *server) SetWeight(weight Weight) { + srv.weight = weight +} + +func (srv *server) String() string { + return srv.name +} + +func (srv *server) TryWake() error { + waker, ok := srv.Handler.(idlewatcher.Waker) if ok { if err := waker.Wake(); err != nil { return err @@ -64,7 +84,3 @@ func (srv *Server) TryWake() error { } return nil } - -func (srv *Server) HealthMonitor() health.HealthMonitor { - return srv.healthMon -} diff --git a/internal/watcher/health/health_checker.go b/internal/watcher/health/health_checker.go index d8866169..0bc0414d 100644 --- a/internal/watcher/health/health_checker.go +++ b/internal/watcher/health/health_checker.go @@ -15,13 +15,17 @@ type ( Detail string Latency time.Duration } + WithHealthInfo interface { + Status() Status + Uptime() time.Duration + Latency() time.Duration + } HealthMonitor interface { task.TaskStarter task.TaskFinisher fmt.Stringer json.Marshaler - Status() Status - Uptime() time.Duration + WithHealthInfo Name() string } HealthChecker interface { From fe7740f1b0165a3779be9a577e706c63539a07d1 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 19 Jan 2025 04:33:55 +0800 Subject: [PATCH 5/7] api: cleanup websocket code --- internal/api/v1/stats.go | 43 ++--------------------- internal/api/v1/utils/ws.go | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 internal/api/v1/utils/ws.go diff --git a/internal/api/v1/stats.go b/internal/api/v1/stats.go index 0d9617bb..e86c8dec 100644 --- a/internal/api/v1/stats.go +++ b/internal/api/v1/stats.go @@ -1,14 +1,12 @@ package v1 import ( - "context" "net/http" "time" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" U "github.com/yusing/go-proxy/internal/api/v1/utils" - "github.com/yusing/go-proxy/internal/common" config "github.com/yusing/go-proxy/internal/config/types" "github.com/yusing/go-proxy/internal/utils/strutils" ) @@ -18,46 +16,9 @@ func Stats(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { } func StatsWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { - var originPats []string - - localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"} - - if len(cfg.Value().MatchDomains) == 0 { - U.LogWarn(r).Msg("no match domains configured, accepting websocket API request from all origins") - originPats = []string{"*"} - } else { - originPats = make([]string, len(cfg.Value().MatchDomains)) - for i, domain := range cfg.Value().MatchDomains { - originPats[i] = "*" + domain - } - originPats = append(originPats, localAddresses...) - } - if common.IsDebug { - originPats = []string{"*"} - } - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - OriginPatterns: originPats, + U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error { + return wsjson.Write(r.Context(), conn, getStats(cfg)) }) - if err != nil { - U.LogError(r).Err(err).Msg("failed to upgrade websocket") - return - } - /* trunk-ignore(golangci-lint/errcheck) */ - defer conn.CloseNow() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for range ticker.C { - stats := getStats(cfg) - if err := wsjson.Write(ctx, conn, stats); err != nil { - U.LogError(r).Msg("failed to write JSON") - return - } - } } var startTime = time.Now() diff --git a/internal/api/v1/utils/ws.go b/internal/api/v1/utils/ws.go new file mode 100644 index 00000000..28db66bb --- /dev/null +++ b/internal/api/v1/utils/ws.go @@ -0,0 +1,68 @@ +package utils + +import ( + "net/http" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/yusing/go-proxy/internal/common" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/logging" +) + +func warnNoMatchDomains() { + logging.Warn().Msg("no match domains configured, accepting websocket API request from all origins") +} + +var warnNoMatchDomainOnce sync.Once + +func InitiateWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { + var originPats []string + + localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"} + + if len(cfg.Value().MatchDomains) == 0 { + warnNoMatchDomainOnce.Do(warnNoMatchDomains) + originPats = []string{"*"} + } else { + originPats = make([]string, len(cfg.Value().MatchDomains)) + for i, domain := range cfg.Value().MatchDomains { + originPats[i] = "*" + domain + } + originPats = append(originPats, localAddresses...) + } + if common.IsDebug { + originPats = []string{"*"} + } + return websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: originPats, + }) +} + +func PeriodicWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request, interval time.Duration, do func(conn *websocket.Conn) error) { + conn, err := InitiateWS(cfg, w, r) + if err != nil { + HandleErr(w, r, err) + return + } + /* trunk-ignore(golangci-lint/errcheck) */ + defer conn.CloseNow() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-cfg.Context().Done(): + return + case <-r.Context().Done(): + return + case <-ticker.C: + if err := do(conn); err != nil { + HandleErr(w, r, err) + return + } + } + } +} From 1adba050654f7d564f063c7574c932a35c22cf30 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 19 Jan 2025 04:34:20 +0800 Subject: [PATCH 6/7] api: add /v1/health/ws for health bubbles on dashboard --- internal/api/handler.go | 1 + internal/api/v1/health.go | 18 +++++++++++ internal/config/config.go | 5 +++ internal/config/types/config.go | 3 ++ internal/docker/idlewatcher/waker.go | 5 +++ .../net/http/loadbalancer/types/server.go | 2 +- internal/route/http.go | 9 ++---- internal/route/provider/stats.go | 7 +++- internal/route/route.go | 4 +-- internal/route/routes/query.go | 32 +++++++++++++++++-- internal/route/stream.go | 8 ++--- internal/route/types/route.go | 10 +++--- internal/watcher/health/monitor/monitor.go | 8 +++++ 13 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 internal/api/v1/health.go diff --git a/internal/api/handler.go b/internal/api/handler.go index cab7a1fb..71ed616d 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -37,6 +37,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { mux.HandleFunc("GET", "/v1/schema/{filename...}", v1.GetSchemaFile) mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats)) mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS)) + mux.HandleFunc("GET", "/v1/health/ws", useCfg(cfg, v1.HealthWS)) mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(favicon.GetFavIcon)) return mux } diff --git a/internal/api/v1/health.go b/internal/api/v1/health.go new file mode 100644 index 00000000..82fe9a50 --- /dev/null +++ b/internal/api/v1/health.go @@ -0,0 +1,18 @@ +package v1 + +import ( + "net/http" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + U "github.com/yusing/go-proxy/internal/api/v1/utils" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/route/routes" +) + +func HealthWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { + U.PeriodicWS(cfg, w, r, 1*time.Second, func(conn *websocket.Conn) error { + return wsjson.Write(r.Context(), conn, routes.HealthMap()) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6a18c233..66fc0b2c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "context" "os" "strconv" "strings" @@ -146,6 +147,10 @@ func (cfg *Config) Task() *task.Task { return cfg.task } +func (cfg *Config) Context() context.Context { + return cfg.task.Context() +} + func (cfg *Config) Start() { cfg.StartAutoCert() cfg.StartProxyProviders() diff --git a/internal/config/types/config.go b/internal/config/types/config.go index 95903370..ca4a0ac0 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -1,6 +1,8 @@ package types import ( + "context" + "github.com/yusing/go-proxy/internal/net/http/accesslog" "github.com/yusing/go-proxy/internal/utils" @@ -31,6 +33,7 @@ type ( Value() *Config Reload() E.Error Statistics() map[string]any + Context() context.Context } ) diff --git a/internal/docker/idlewatcher/waker.go b/internal/docker/idlewatcher/waker.go index c2c34c87..6a657d92 100644 --- a/internal/docker/idlewatcher/waker.go +++ b/internal/docker/idlewatcher/waker.go @@ -117,6 +117,11 @@ 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 { status := w.getStatusUpdateReady() diff --git a/internal/net/http/loadbalancer/types/server.go b/internal/net/http/loadbalancer/types/server.go index b3df394e..db10dcfa 100644 --- a/internal/net/http/loadbalancer/types/server.go +++ b/internal/net/http/loadbalancer/types/server.go @@ -28,7 +28,7 @@ type ( Name() string URL() types.URL Weight() Weight - SetWeight(Weight) + SetWeight(weight Weight) TryWake() error } diff --git a/internal/route/http.go b/internal/route/http.go index d47a2078..b2b87a38 100755 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -30,7 +30,7 @@ type ( HealthMon health.HealthMonitor `json:"health,omitempty"` loadBalancer *loadbalancer.LoadBalancer - server *loadbalancer.Server + server loadbalancer.Server handler http.Handler rp *reverseproxy.ReverseProxy @@ -180,11 +180,8 @@ func (r *HTTPRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handler.ServeHTTP(w, req) } -func (r *HTTPRoute) Health() health.Status { - if r.HealthMon != nil { - return r.HealthMon.Status() - } - return health.StatusUnknown +func (r *HTTPRoute) HealthMonitor() health.HealthMonitor { + return r.HealthMon } func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) { diff --git a/internal/route/provider/stats.go b/internal/route/provider/stats.go index 276a1402..f62b84f2 100644 --- a/internal/route/provider/stats.go +++ b/internal/route/provider/stats.go @@ -26,7 +26,12 @@ type ( func (stats *RouteStats) Add(r *R.Route) { stats.Total++ - switch r.Health() { + mon := r.HealthMonitor() + if mon == nil { + stats.NumUnknown++ + return + } + switch mon.Status() { case health.StatusHealthy: stats.NumHealthy++ case health.StatusUnhealthy: diff --git a/internal/route/route.go b/internal/route/route.go index 569d727a..220f5dba 100755 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -11,7 +11,6 @@ import ( "github.com/yusing/go-proxy/internal/task" 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" ) type ( @@ -24,12 +23,11 @@ type ( Routes = F.Map[string, *Route] impl interface { - entry.Entry + types.Route task.TaskStarter task.TaskFinisher String() string TargetURL() url.URL - Health() health.Status } RawEntry = types.RawEntry RawEntries = types.RawEntries diff --git a/internal/route/routes/query.go b/internal/route/routes/query.go index 6554d65c..c06a22df 100644 --- a/internal/route/routes/query.go +++ b/internal/route/routes/query.go @@ -2,6 +2,7 @@ package routes import ( "strings" + "time" "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/route/entry" @@ -10,6 +11,33 @@ import ( "github.com/yusing/go-proxy/internal/utils/strutils" ) +func getHealthInfo(r route.Route) map[string]string { + mon := r.HealthMonitor() + if mon == nil { + return map[string]string{ + "status": "unknown", + "uptime": "n/a", + "latency": "n/a", + } + } + return map[string]string{ + "status": mon.Status().String(), + "uptime": mon.Uptime().Round(time.Second).String(), + "latency": mon.Latency().Round(time.Microsecond).String(), + } +} + +func HealthMap() map[string]map[string]string { + healthMap := make(map[string]map[string]string) + httpRoutes.RangeAll(func(alias string, r route.HTTPRoute) { + healthMap[alias] = getHealthInfo(r) + }) + streamRoutes.RangeAll(func(alias string, r route.StreamRoute) { + healthMap[alias] = getHealthInfo(r) + }) + return healthMap +} + func HomepageConfig(useDefaultCategories bool) homepage.Config { hpCfg := homepage.NewHomePageConfig() GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { @@ -77,8 +105,8 @@ func HomepageConfig(useDefaultCategories bool) homepage.Config { return hpCfg } -func RoutesByAlias(typeFilter ...route.RouteType) map[string]any { - rts := make(map[string]any) +func RoutesByAlias(typeFilter ...route.RouteType) map[string]route.Route { + rts := make(map[string]route.Route) if len(typeFilter) == 0 || typeFilter[0] == "" { typeFilter = []route.RouteType{route.RouteTypeReverseProxy, route.RouteTypeStream} } diff --git a/internal/route/stream.go b/internal/route/stream.go index 3fc2bdff..1660fa68 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -116,12 +116,8 @@ func (r *StreamRoute) Finish(reason any) { r.task.Finish(reason) } - -func (r *StreamRoute) Health() health.Status { - if r.HealthMon != nil { - return r.HealthMon.Status() - } - return health.StatusUnknown +func (r *StreamRoute) HealthMonitor() health.HealthMonitor { + return r.HealthMon } func (r *StreamRoute) acceptConnections() { diff --git a/internal/route/types/route.go b/internal/route/types/route.go index 4b56b307..b607e314 100644 --- a/internal/route/types/route.go +++ b/internal/route/types/route.go @@ -8,14 +8,16 @@ import ( ) type ( - HTTPRoute interface { + Route interface { Entry + HealthMonitor() health.HealthMonitor + } + HTTPRoute interface { + Route http.Handler - Health() health.Status } StreamRoute interface { - Entry + Route net.Stream - Health() health.Status } ) diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index 25d710e1..bb284972 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -142,6 +142,14 @@ func (mon *monitor) Uptime() time.Duration { return time.Since(mon.startTime) } +// Latency implements HealthMonitor. +func (mon *monitor) Latency() time.Duration { + if mon.lastResult == nil { + return 0 + } + return mon.lastResult.Latency +} + // Name implements HealthMonitor. func (mon *monitor) Name() string { parts := strutils.SplitRune(mon.service, '/') From 0fad7b3411db50ebbb7ace4a81662d13fb305042 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 19 Jan 2025 13:45:16 +0800 Subject: [PATCH 7/7] feat: experimental memory logger and logs api for WebUI --- cmd/main.go | 8 ++ internal/api/handler.go | 1 + internal/api/v1/mem_logger.go | 161 ++++++++++++++++++++++++++++++++++ internal/common/env.go | 3 + internal/logging/logging.go | 6 +- 5 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 internal/api/v1/mem_logger.go diff --git a/cmd/main.go b/cmd/main.go index c1a88eba..e2417472 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "io" "log" "os" "os/signal" @@ -9,6 +10,7 @@ import ( "time" "github.com/yusing/go-proxy/internal" + v1 "github.com/yusing/go-proxy/internal/api/v1" "github.com/yusing/go-proxy/internal/api/v1/auth" "github.com/yusing/go-proxy/internal/api/v1/query" "github.com/yusing/go-proxy/internal/common" @@ -24,6 +26,12 @@ import ( var rawLogger = log.New(os.Stdout, "", 0) func main() { + var out io.Writer = os.Stdout + if common.EnableLogStreaming { + out = io.MultiWriter(out, v1.MemLogger()) + } + logging.InitLogger(out) + args := common.GetArgs() switch args.Command { diff --git a/internal/api/handler.go b/internal/api/handler.go index 71ed616d..5e121616 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -38,6 +38,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats)) mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS)) mux.HandleFunc("GET", "/v1/health/ws", useCfg(cfg, v1.HealthWS)) + mux.HandleFunc("GET", "/v1/logs/ws", useCfg(cfg, v1.LogsWS())) mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(favicon.GetFavIcon)) return mux } diff --git a/internal/api/v1/mem_logger.go b/internal/api/v1/mem_logger.go new file mode 100644 index 00000000..434690af --- /dev/null +++ b/internal/api/v1/mem_logger.go @@ -0,0 +1,161 @@ +package v1 + +import ( + "bytes" + "context" + "io" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/yusing/go-proxy/internal/api/v1/utils" + "github.com/yusing/go-proxy/internal/common" + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/task" + F "github.com/yusing/go-proxy/internal/utils/functional" +) + +type logEntryRange struct { + Start, End int +} + +type memLogger struct { + bytes.Buffer + sync.Mutex + connChans F.Map[chan *logEntryRange, struct{}] +} + +const ( + maxMemLogSize = 16 * 1024 + truncateSize = maxMemLogSize / 2 + initialWriteChunkSize = 4 * 1024 +) + +var memLoggerInstance = &memLogger{ + connChans: F.NewMapOf[chan *logEntryRange, struct{}](), +} + +func init() { + if !common.EnableLogStreaming { + return + } + memLoggerInstance.Grow(maxMemLogSize) + + if common.DebugMemLogger { + ticker := time.NewTicker(1 * time.Second) + + go func() { + defer ticker.Stop() + + for { + select { + case <-task.RootContextCanceled(): + return + case <-ticker.C: + logging.Info().Msgf("mem logger size: %d, active conns: %d", + memLoggerInstance.Len(), + memLoggerInstance.connChans.Size()) + } + } + }() + } +} + +func LogsWS() func(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) { + return memLoggerInstance.ServeHTTP +} + +func MemLogger() io.Writer { + return memLoggerInstance +} + +func (m *memLogger) Write(p []byte) (n int, err error) { + m.Lock() + + if m.Len() > maxMemLogSize { + m.Truncate(truncateSize) + } + + pos := m.Buffer.Len() + n = len(p) + _, err = m.Buffer.Write(p) + if err != nil { + m.Unlock() + return + } + + if m.connChans.Size() > 0 { + m.Unlock() + timeout := time.NewTimer(1 * time.Second) + defer timeout.Stop() + + m.connChans.Range(func(ch chan *logEntryRange, _ struct{}) bool { + select { + case ch <- &logEntryRange{pos, pos + n}: + return true + case <-timeout.C: + logging.Warn().Msg("mem logger: timeout logging to channel") + return false + } + }) + return + } + + m.Unlock() + return +} + +func (m *memLogger) ServeHTTP(config config.ConfigInstance, w http.ResponseWriter, r *http.Request) { + conn, err := utils.InitiateWS(config, w, r) + if err != nil { + utils.HandleErr(w, r, err) + return + } + + logCh := make(chan *logEntryRange) + m.connChans.Store(logCh, struct{}{}) + + /* trunk-ignore(golangci-lint/errcheck) */ + defer func() { + _ = conn.CloseNow() + m.connChans.Delete(logCh) + close(logCh) + }() + + if err := m.wsInitial(r.Context(), conn); err != nil { + utils.HandleErr(w, r, err) + return + } + + m.wsStreamLog(r.Context(), conn, logCh) +} + +func (m *memLogger) writeBytes(ctx context.Context, conn *websocket.Conn, b []byte) error { + return conn.Write(ctx, websocket.MessageText, b) +} + +func (m *memLogger) wsInitial(ctx context.Context, conn *websocket.Conn) error { + m.Lock() + defer m.Unlock() + + return m.writeBytes(ctx, conn, m.Buffer.Bytes()) +} + +func (m *memLogger) wsStreamLog(ctx context.Context, conn *websocket.Conn, ch <-chan *logEntryRange) { + for { + select { + case <-ctx.Done(): + return + case logRange := <-ch: + m.Lock() + msg := m.Buffer.Bytes()[logRange.Start:logRange.End] + err := m.writeBytes(ctx, conn, msg) + m.Unlock() + if err != nil { + return + } + } + } +} diff --git a/internal/common/env.go b/internal/common/env.go index b8f9a09a..0cc17553 100644 --- a/internal/common/env.go +++ b/internal/common/env.go @@ -19,6 +19,9 @@ var ( IsTrace = GetEnvBool("TRACE", false) && IsDebug IsProduction = !IsTest && !IsDebug + EnableLogStreaming = GetEnvBool("LOG_STREAMING", true) + DebugMemLogger = GetEnvBool("DEBUG_MEM_LOGGER", false) && EnableLogStreaming + ProxyHTTPAddr, ProxyHTTPHost, ProxyHTTPPort, diff --git a/internal/logging/logging.go b/internal/logging/logging.go index dee54dde..6ee9f649 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -2,7 +2,7 @@ package logging import ( - "os" + "io" "strings" "github.com/rs/zerolog" @@ -12,7 +12,7 @@ import ( var logger zerolog.Logger -func init() { +func InitLogger(out io.Writer) { var timeFmt string var level zerolog.Level var exclude []string @@ -35,7 +35,7 @@ func init() { logger = zerolog.New( zerolog.ConsoleWriter{ - Out: os.Stderr, + Out: out, TimeFormat: timeFmt, FieldsExclude: exclude, FormatMessage: func(msgI interface{}) string { // pad spaces for each line