diff --git a/.golangci.yml b/.golangci.yml index 7f37cca..d528cc0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,9 +9,6 @@ linters-settings: - fieldalignment gocyclo: min-complexity: 14 - goconst: - min-len: 3 - min-occurrences: 4 misspell: locale: US funlen: @@ -102,13 +99,14 @@ linters: - depguard # Not relevant - nakedret # Too strict - lll # Not relevant - - gocyclo # FIXME must be fixed + - gocyclo # must be fixed - gocognit # Too strict - nestif # Too many false-positive. - prealloc # Too many false-positive. - makezero # Not relevant - dupl # Too strict - gci # I don't care + - goconst # Too annoying - gosec # Too strict - gochecknoinits - gochecknoglobals diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d90c70f..1c1b720 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -2,12 +2,12 @@ # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml version: 0.1 cli: - version: 1.22.8 + version: 1.22.9 # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) plugins: sources: - id: trunk - ref: v1.6.6 + ref: v1.6.7 uri: https://github.com/trunk-io/plugins # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) runtimes: @@ -22,8 +22,8 @@ lint: - yamllint enabled: - hadolint@2.12.1-beta - - actionlint@1.7.6 - - checkov@3.2.352 + - actionlint@1.7.7 + - checkov@3.2.360 - git-diff-check - gofmt@1.20.4 - golangci-lint@1.63.4 @@ -32,7 +32,7 @@ lint: - prettier@3.4.2 - shellcheck@0.10.0 - shfmt@3.6.0 - - trufflehog@3.88.2 + - trufflehog@3.88.4 actions: disabled: - trunk-announce diff --git a/Makefile b/Makefile index a647a1c..f6b8521 100755 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ export GOOS = linux LDFLAGS = -X github.com/yusing/go-proxy/pkg.version=${VERSION} ifeq ($(trace), 1) + debug = 1 GODOXY_TRACE ?= 1 endif diff --git a/cmd/main.go b/cmd/main.go index ade6512..4352af6 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -35,9 +35,6 @@ func init() { } logging.InitLogger(out) // logging.AddHook(v1.GetMemLogger()) - internal.InitIconListCache() - homepage.InitOverridesConfig() - favicon.InitIconCache() } func main() { @@ -120,13 +117,17 @@ func main() { printJSON(cfg.Value()) return case common.CommandDebugListEntries: - printJSON(cfg.DumpEntries()) + printJSON(cfg.DumpRoutes()) return case common.CommandDebugListProviders: printJSON(cfg.DumpRouteProviders()) return } + go internal.InitIconListCache() + go homepage.InitOverridesConfig() + go favicon.InitIconCache() + cfg.Start(&config.StartServersOptions{ Proxy: true, }) diff --git a/internal/api/v1/auth/oidc_test.go b/internal/api/v1/auth/oidc_test.go index 4688dba..f249e8f 100644 --- a/internal/api/v1/auth/oidc_test.go +++ b/internal/api/v1/auth/oidc_test.go @@ -14,7 +14,6 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt/v5" "github.com/yusing/go-proxy/internal/common" - E "github.com/yusing/go-proxy/internal/error" "golang.org/x/oauth2" . "github.com/yusing/go-proxy/internal/utils/testing" @@ -227,7 +226,7 @@ func TestOIDCCallbackHandler(t *testing.T) { } if tt.wantStatus == http.StatusTemporaryRedirect { - setCookie := E.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie"))) + setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie"))) ExpectEqual(t, setCookie.Name, defaultAuth.TokenCookieName()) ExpectTrue(t, setCookie.Value != "") ExpectEqual(t, setCookie.Path, "/") diff --git a/internal/api/v1/auth/userpass_test.go b/internal/api/v1/auth/userpass_test.go index c43360e..9a9fbc4 100644 --- a/internal/api/v1/auth/userpass_test.go +++ b/internal/api/v1/auth/userpass_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - E "github.com/yusing/go-proxy/internal/error" . "github.com/yusing/go-proxy/internal/utils/testing" "golang.org/x/crypto/bcrypt" ) @@ -17,7 +16,7 @@ import ( func newMockUserPassAuth() *UserPassAuth { return &UserPassAuth{ username: "username", - pwdHash: E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)), + pwdHash: Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)), secret: []byte("abcdefghijklmnopqrstuvwxyz"), tokenTTL: time.Hour, } @@ -97,13 +96,13 @@ func TestUserPassLoginCallbackHandler(t *testing.T) { w := httptest.NewRecorder() req := &http.Request{ Host: "app.example.com", - Body: io.NopCloser(bytes.NewReader(E.Must(json.Marshal(tt.creds)))), + Body: io.NopCloser(bytes.NewReader(Must(json.Marshal(tt.creds)))), } auth.LoginCallbackHandler(w, req) if tt.wantErr { ExpectEqual(t, w.Code, http.StatusUnauthorized) } else { - setCookie := E.Must(http.ParseSetCookie(w.Header().Get("Set-Cookie"))) + setCookie := Must(http.ParseSetCookie(w.Header().Get("Set-Cookie"))) ExpectTrue(t, setCookie.Name == auth.TokenCookieName()) ExpectTrue(t, setCookie.Value != "") ExpectEqual(t, setCookie.Domain, "example.com") diff --git a/internal/api/v1/favicon/cache.go b/internal/api/v1/favicon/cache.go index 53acf81..d138854 100644 --- a/internal/api/v1/favicon/cache.go +++ b/internal/api/v1/favicon/cache.go @@ -29,6 +29,9 @@ const ( ) func InitIconCache() { + iconCacheMu.Lock() + defer iconCacheMu.Unlock() + err := utils.LoadJSONIfExist(common.IconCachePath, &iconCache) if err != nil { logging.Error().Err(err).Msg("failed to load icon cache") @@ -78,7 +81,7 @@ func pruneExpiredIconCache() { } func routeKey(r route.HTTPRoute) string { - return r.RawEntry().Provider + ":" + r.TargetName() + return r.ProviderName() + ":" + r.TargetName() } func PruneRouteIconCache(route route.HTTPRoute) { diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index 299ff8c..4c5d8f1 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -87,7 +87,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { } var result *fetchResult - hp := r.RawEntry().Homepage.GetOverride() + hp := r.HomepageConfig().GetOverride() if !hp.IsEmpty() && hp.Icon != nil { if hp.Icon.IconSource == homepage.IconSourceRelative { result = findIcon(r, req, hp.Icon.Value) @@ -189,7 +189,7 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult { } result := fetchIcon("png", sanitizeName(r.TargetName())) - cont := r.RawEntry().Container + cont := r.ContainerInfo() if !result.OK() && cont != nil { result = fetchIcon("png", sanitizeName(cont.ImageName)) } diff --git a/internal/config/query.go b/internal/config/query.go index c1899f6..70b101b 100644 --- a/internal/config/query.go +++ b/internal/config/query.go @@ -1,16 +1,15 @@ package config import ( - route "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route/provider" - "github.com/yusing/go-proxy/internal/route/types" ) -func (cfg *Config) DumpEntries() map[string]*types.RawEntry { - entries := make(map[string]*types.RawEntry) +func (cfg *Config) DumpRoutes() map[string]*route.Route { + entries := make(map[string]*route.Route) cfg.providers.RangeAll(func(_ string, p *provider.Provider) { p.RangeRoutes(func(alias string, r *route.Route) { - entries[alias] = r.Entry + entries[alias] = r }) }) return entries diff --git a/internal/docker/container.go b/internal/docker/container.go index 9b985ae..b8e341f 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -12,7 +12,7 @@ import ( ) type ( - PortMapping = map[string]types.Port + PortMapping = map[int]types.Port Container struct { _ U.NoCopy diff --git a/internal/docker/container_helper.go b/internal/docker/container_helper.go index ac1226c..0d6fbd6 100644 --- a/internal/docker/container_helper.go +++ b/internal/docker/container_helper.go @@ -44,7 +44,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping { if v.PublicPort == 0 { continue } - res[strutils.PortString(v.PublicPort)] = v + res[int(v.PublicPort)] = v } return res } @@ -52,7 +52,7 @@ func (c containerHelper) getPublicPortMapping() PortMapping { func (c containerHelper) getPrivatePortMapping() PortMapping { res := make(PortMapping) for _, v := range c.Ports { - res[strutils.PortString(v.PrivatePort)] = v + res[int(v.PrivatePort)] = v } return res } @@ -66,14 +66,6 @@ var databaseMPs = map[string]struct{}{ "/var/lib/rabbitmq": {}, } -var databasePrivPorts = map[uint16]struct{}{ - 5432: {}, // postgres - 3306: {}, // mysql, mariadb - 6379: {}, // redis - 11211: {}, // memcached - 27017: {}, // mongodb -} - func (c containerHelper) isDatabase() bool { for _, m := range c.Mounts { if _, ok := databaseMPs[m.Destination]; ok { @@ -82,7 +74,9 @@ func (c containerHelper) isDatabase() bool { } for _, v := range c.Ports { - if _, ok := databasePrivPorts[v.PrivatePort]; ok { + switch v.PrivatePort { + // postgres, mysql or mariadb, redis, memcached, mongodb + case 5432, 3306, 6379, 11211, 27017: return true } } diff --git a/internal/docker/idlewatcher/waker.go b/internal/docker/idlewatcher/waker.go index 6a657d9..9a3ca13 100644 --- a/internal/docker/idlewatcher/waker.go +++ b/internal/docker/idlewatcher/waker.go @@ -38,32 +38,32 @@ const ( // TODO: support stream -func newWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) { - hcCfg := entry.RawEntry().HealthCheck +func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, E.Error) { + hcCfg := route.HealthCheckConfig() hcCfg.Timeout = idleWakerCheckTimeout waker := &waker{ rp: rp, stream: stream, } - task := parent.Subtask("idlewatcher." + entry.TargetName()) - watcher, err := registerWatcher(task, entry, waker) + task := parent.Subtask("idlewatcher." + route.TargetName()) + watcher, err := registerWatcher(task, route, waker) if err != nil { return nil, E.Errorf("register watcher: %w", err) } switch { case rp != nil: - waker.hc = monitor.NewHTTPHealthChecker(entry.TargetURL(), hcCfg) + waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg) case stream != nil: - waker.hc = monitor.NewRawHealthChecker(entry.TargetURL(), hcCfg) + waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg) default: panic("both nil") } if common.PrometheusEnabled { m := metrics.GetServiceMetrics() - fqn := parent.Name() + "/" + entry.TargetName() + fqn := parent.Name() + "/" + route.TargetName() waker.metric = m.HealthStatus.With(metrics.HealthMetricLabels(fqn)) waker.metric.Set(float64(watcher.Status())) } @@ -71,12 +71,12 @@ func newWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReversePro } // lifetime should follow route provider. -func NewHTTPWaker(parent task.Parent, entry route.Entry, rp *reverseproxy.ReverseProxy) (Waker, E.Error) { - return newWaker(parent, entry, rp, nil) +func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, E.Error) { + return newWaker(parent, route, rp, nil) } -func NewStreamWaker(parent task.Parent, entry route.Entry, stream net.Stream) (Waker, E.Error) { - return newWaker(parent, entry, nil, stream) +func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, E.Error) { + return newWaker(parent, route, nil, stream) } // Start implements health.HealthMonitor. @@ -155,7 +155,7 @@ func (w *Watcher) getStatusUpdateReady() health.Status { // MarshalJSON implements health.HealthMonitor. func (w *Watcher) MarshalJSON() ([]byte, error) { - var url net.URL + var url *net.URL if w.hc.URL().Port() != "0" { url = w.hc.URL() } diff --git a/internal/docker/idlewatcher/watcher.go b/internal/docker/idlewatcher/watcher.go index f0860d1..bf35371 100644 --- a/internal/docker/idlewatcher/watcher.go +++ b/internal/docker/idlewatcher/watcher.go @@ -50,8 +50,8 @@ var ( const dockerReqTimeout = 3 * time.Second -func registerWatcher(watcherTask *task.Task, entry route.Entry, waker *waker) (*Watcher, error) { - cfg := entry.IdlewatcherConfig() +func registerWatcher(watcherTask *task.Task, route route.Route, waker *waker) (*Watcher, error) { + cfg := route.IdlewatcherConfig() if cfg.IdleTimeout == 0 { panic(errShouldNotReachHere) diff --git a/internal/entrypoint/entrypoint_test.go b/internal/entrypoint/entrypoint_test.go index 438bc1f..60f7659 100644 --- a/internal/entrypoint/entrypoint_test.go +++ b/internal/entrypoint/entrypoint_test.go @@ -9,7 +9,7 @@ import ( ) var ( - r route.HTTPRoute + r route.ReveseProxyRoute ep = NewEntrypoint() ) diff --git a/internal/error/utils.go b/internal/error/utils.go index 5d987fb..e4440c2 100644 --- a/internal/error/utils.go +++ b/internal/error/utils.go @@ -40,13 +40,6 @@ func From(err error) Error { return &baseError{err} } -func Must[T any](v T, err error) T { - if err != nil { - LogPanic("must failed", err) - } - return v -} - func Join(errors ...error) Error { n := 0 for _, err := range errors { diff --git a/internal/homepage/override_config.go b/internal/homepage/override_config.go index 2855b89..c8ac779 100644 --- a/internal/homepage/override_config.go +++ b/internal/homepage/override_config.go @@ -17,15 +17,17 @@ type OverrideConfig struct { mu sync.RWMutex } -var overrideConfigInstance *OverrideConfig +var overrideConfigInstance = &OverrideConfig{ + ItemOverrides: make(map[string]*ItemConfig), + DisplayOrder: make(map[string]int), + CategoryOrder: make(map[string]int), + ItemVisibility: make(map[string]bool), +} func InitOverridesConfig() { - overrideConfigInstance = &OverrideConfig{ - ItemOverrides: make(map[string]*ItemConfig), - DisplayOrder: make(map[string]int), - CategoryOrder: make(map[string]int), - ItemVisibility: make(map[string]bool), - } + overrideConfigInstance.mu.Lock() + defer overrideConfigInstance.mu.Unlock() + err := utils.LoadJSONIfExist(common.HomepageJSONConfigPath, overrideConfigInstance) if err != nil { logging.Error().Err(err).Msg("failed to load homepage overrides config") diff --git a/internal/list-icons.go b/internal/list-icons.go index f46a668..18f341c 100644 --- a/internal/list-icons.go +++ b/internal/list-icons.go @@ -52,6 +52,9 @@ const ( ) func InitIconListCache() { + iconsCahceMu.Lock() + defer iconsCahceMu.Unlock() + iconsCache = &Cache{ WalkxCode: make(IconsMap), Selfhst: make(IconsMap), diff --git a/internal/net/http/accesslog/access_logger_test.go b/internal/net/http/accesslog/access_logger_test.go index bac8db8..2398ca7 100644 --- a/internal/net/http/accesslog/access_logger_test.go +++ b/internal/net/http/accesslog/access_logger_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - E "github.com/yusing/go-proxy/internal/error" . "github.com/yusing/go-proxy/internal/net/http/accesslog" "github.com/yusing/go-proxy/internal/task" . "github.com/yusing/go-proxy/internal/utils/testing" @@ -30,7 +29,7 @@ const ( var ( testTask = task.RootTask("test", false) - testURL = E.Must(url.Parse("http://" + host + uri)) + testURL = Must(url.Parse("http://" + host + uri)) req = &http.Request{ RemoteAddr: remote, Method: method, diff --git a/internal/net/http/loadbalancer/types/server.go b/internal/net/http/loadbalancer/types/server.go index db10dcf..e0e7b5e 100644 --- a/internal/net/http/loadbalancer/types/server.go +++ b/internal/net/http/loadbalancer/types/server.go @@ -4,7 +4,7 @@ import ( "net/http" idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - "github.com/yusing/go-proxy/internal/net/types" + net "github.com/yusing/go-proxy/internal/net/types" U "github.com/yusing/go-proxy/internal/utils" F "github.com/yusing/go-proxy/internal/utils/functional" "github.com/yusing/go-proxy/internal/watcher/health" @@ -15,7 +15,7 @@ type ( _ U.NoCopy name string - url types.URL + url *net.URL weight Weight http.Handler `json:"-"` @@ -26,7 +26,7 @@ type ( http.Handler health.HealthMonitor Name() string - URL() types.URL + URL() *net.URL Weight() Weight SetWeight(weight Weight) TryWake() error @@ -37,7 +37,7 @@ type ( var NewServerPool = F.NewMap[Pool] -func NewServer(name string, url types.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { +func NewServer(name string, url *net.URL, weight Weight, handler http.Handler, healthMon health.HealthMonitor) Server { srv := &server{ name: name, url: url, @@ -59,7 +59,7 @@ func (srv *server) Name() string { return srv.name } -func (srv *server) URL() types.URL { +func (srv *server) URL() *net.URL { return srv.url } diff --git a/internal/net/http/middleware/metrics_logger/metrics_logger.go b/internal/net/http/middleware/metrics_logger/metrics_logger.go new file mode 100644 index 0000000..23a22f4 --- /dev/null +++ b/internal/net/http/middleware/metrics_logger/metrics_logger.go @@ -0,0 +1,44 @@ +package metricslogger + +import ( + "net" + "net/http" + + "github.com/yusing/go-proxy/internal/metrics" +) + +type MetricsLogger struct { + ServiceName string `json:"service_name"` +} + +func NewMetricsLogger(serviceName string) *MetricsLogger { + return &MetricsLogger{serviceName} +} + +func (m *MetricsLogger) GetHandler(next http.Handler) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + m.ServeHTTP(rw, req, next.ServeHTTP) + } +} + +func (m *MetricsLogger) ServeHTTP(rw http.ResponseWriter, req *http.Request, next http.HandlerFunc) { + visitorIP, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + visitorIP = req.RemoteAddr + } + + // req.RemoteAddr had been modified by middleware (if any) + lbls := &metrics.HTTPRouteMetricLabels{ + Service: m.ServiceName, + Method: req.Method, + Host: req.Host, + Visitor: visitorIP, + Path: req.URL.Path, + } + + next.ServeHTTP(newHTTPMetricLogger(rw, lbls), req) +} + +func (m *MetricsLogger) ResetMetrics() { + metrics.GetRouteMetrics().UnregisterService(m.ServiceName) +} diff --git a/internal/net/http/middleware/metrics_logger/metrics_response_writer.go b/internal/net/http/middleware/metrics_logger/metrics_response_writer.go new file mode 100644 index 0000000..3f9a689 --- /dev/null +++ b/internal/net/http/middleware/metrics_logger/metrics_response_writer.go @@ -0,0 +1,47 @@ +package metricslogger + +import ( + "net/http" + "time" + + "github.com/yusing/go-proxy/internal/metrics" +) + +type httpMetricLogger struct { + http.ResponseWriter + timestamp time.Time + labels *metrics.HTTPRouteMetricLabels +} + +// WriteHeader implements http.ResponseWriter. +func (l *httpMetricLogger) WriteHeader(status int) { + l.ResponseWriter.WriteHeader(status) + duration := time.Since(l.timestamp) + go func() { + m := metrics.GetRouteMetrics() + m.HTTPReqTotal.Inc() + m.HTTPReqElapsed.With(l.labels).Set(float64(duration.Milliseconds())) + + // ignore 1xx + switch { + case status >= 500: + m.HTTP5xx.With(l.labels).Inc() + case status >= 400: + m.HTTP4xx.With(l.labels).Inc() + case status >= 200: + m.HTTP2xx3xx.With(l.labels).Inc() + } + }() +} + +func (l *httpMetricLogger) Unwrap() http.ResponseWriter { + return l.ResponseWriter +} + +func newHTTPMetricLogger(w http.ResponseWriter, labels *metrics.HTTPRouteMetricLabels) *httpMetricLogger { + return &httpMetricLogger{ + ResponseWriter: w, + timestamp: time.Now(), + labels: labels, + } +} diff --git a/internal/net/http/middleware/middleware.go b/internal/net/http/middleware/middleware.go index a271972..b206d26 100644 --- a/internal/net/http/middleware/middleware.go +++ b/internal/net/http/middleware/middleware.go @@ -196,34 +196,6 @@ func (m *Middleware) ServeHTTP(next http.HandlerFunc, w http.ResponseWriter, r * next(w, r) } -// TODO: check conflict or duplicates. -func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, E.Error) { - middlewares := make([]*Middleware, 0, len(middlewaresMap)) - - errs := E.NewBuilder("middlewares compile error") - invalidOpts := E.NewBuilder("options compile error") - - for name, opts := range middlewaresMap { - m, err := Get(name) - if err != nil { - errs.Add(err) - continue - } - - m, err = m.New(opts) - if err != nil { - invalidOpts.Add(err.Subject(name)) - continue - } - middlewares = append(middlewares, m) - } - - if invalidOpts.HasError() { - errs.Add(invalidOpts.Error()) - } - return middlewares, errs.Error() -} - func PatchReverseProxy(rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) (err E.Error) { var middlewares []*Middleware middlewares, err = compileMiddlewares(middlewaresMap) diff --git a/internal/net/http/middleware/middleware_builder.go b/internal/net/http/middleware/middleware_builder.go index 8f5aabf..8ea5403 100644 --- a/internal/net/http/middleware/middleware_builder.go +++ b/internal/net/http/middleware/middleware_builder.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "sort" E "github.com/yusing/go-proxy/internal/error" "gopkg.in/yaml.v3" @@ -39,6 +40,43 @@ func BuildMiddlewaresFromYAML(source string, data []byte, eb *E.Builder) map[str return middlewares } +func compileMiddlewares(middlewaresMap map[string]OptionsRaw) ([]*Middleware, E.Error) { + middlewares := make([]*Middleware, 0, len(middlewaresMap)) + + errs := E.NewBuilder("middlewares compile error") + invalidOpts := E.NewBuilder("options compile error") + + for name, opts := range middlewaresMap { + m, err := Get(name) + if err != nil { + errs.Add(err) + continue + } + + m, err = m.New(opts) + if err != nil { + invalidOpts.Add(err.Subject(name)) + continue + } + middlewares = append(middlewares, m) + } + + if invalidOpts.HasError() { + errs.Add(invalidOpts.Error()) + } + sort.Sort(ByPriority(middlewares)) + return middlewares, errs.Error() +} + +func BuildMiddlewareFromMap(name string, middlewaresMap map[string]OptionsRaw) (*Middleware, E.Error) { + compiled, err := compileMiddlewares(middlewaresMap) + if err != nil { + return nil, err + } + return NewMiddlewareChain(name, compiled), nil +} + +// TODO: check conflict or duplicates. func BuildMiddlewareFromChainRaw(name string, defs []map[string]any) (*Middleware, E.Error) { chainErr := E.NewBuilder("") chain := make([]*Middleware, 0, len(defs)) diff --git a/internal/net/http/middleware/middleware_builder_test.go b/internal/net/http/middleware/middleware_builder_test.go index 914655a..2c9828c 100644 --- a/internal/net/http/middleware/middleware_builder_test.go +++ b/internal/net/http/middleware/middleware_builder_test.go @@ -16,7 +16,7 @@ func TestBuild(t *testing.T) { errs := E.NewBuilder("") middlewares := BuildMiddlewaresFromYAML("", testMiddlewareCompose, errs) ExpectNoError(t, errs.Error()) - E.Must(json.MarshalIndent(middlewares, "", " ")) + Must(json.MarshalIndent(middlewares, "", " ")) // t.Log(string(data)) // TODO: test } diff --git a/internal/net/http/middleware/test_utils.go b/internal/net/http/middleware/test_utils.go index 9c8ce3a..0adb1a5 100644 --- a/internal/net/http/middleware/test_utils.go +++ b/internal/net/http/middleware/test_utils.go @@ -12,6 +12,7 @@ import ( E "github.com/yusing/go-proxy/internal/error" "github.com/yusing/go-proxy/internal/net/http/reverseproxy" "github.com/yusing/go-proxy/internal/net/types" + . "github.com/yusing/go-proxy/internal/utils/testing" ) //go:embed test_data/sample_headers.json @@ -79,11 +80,11 @@ type TestResult struct { type testArgs struct { middlewareOpt OptionsRaw - upstreamURL types.URL + upstreamURL *types.URL realRoundTrip bool - reqURL types.URL + reqURL *types.URL reqMethod string headers http.Header body []byte @@ -94,14 +95,14 @@ type testArgs struct { } func (args *testArgs) setDefaults() { - if args.reqURL.Nil() { - args.reqURL = E.Must(types.ParseURL("https://example.com")) + if args.reqURL == nil { + args.reqURL = Must(types.ParseURL("https://example.com")) } if args.reqMethod == "" { args.reqMethod = http.MethodGet } - if args.upstreamURL.Nil() { - args.upstreamURL = E.Must(types.ParseURL("https://10.0.0.1:8443")) // dummy url, no actual effect + if args.upstreamURL == nil { + args.upstreamURL = Must(types.ParseURL("https://10.0.0.1:8443")) // dummy url, no actual effect } if args.respHeaders == nil { args.respHeaders = http.Header{} diff --git a/internal/net/http/reverseproxy/reverse_proxy_mod.go b/internal/net/http/reverseproxy/reverse_proxy_mod.go index 9ec6b3c..aeeb31d 100644 --- a/internal/net/http/reverseproxy/reverse_proxy_mod.go +++ b/internal/net/http/reverseproxy/reverse_proxy_mod.go @@ -23,12 +23,9 @@ import ( "net/url" "strings" "sync" - "time" "github.com/rs/zerolog" - "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/metrics" gphttp "github.com/yusing/go-proxy/internal/net/http" "github.com/yusing/go-proxy/internal/net/http/accesslog" "github.com/yusing/go-proxy/internal/net/types" @@ -96,38 +93,7 @@ type ReverseProxy struct { HandlerFunc http.HandlerFunc TargetName string - TargetURL types.URL -} - -type httpMetricLogger struct { - http.ResponseWriter - timestamp time.Time - labels *metrics.HTTPRouteMetricLabels -} - -// WriteHeader implements http.ResponseWriter. -func (l *httpMetricLogger) WriteHeader(status int) { - l.ResponseWriter.WriteHeader(status) - duration := time.Since(l.timestamp) - go func() { - m := metrics.GetRouteMetrics() - m.HTTPReqTotal.Inc() - m.HTTPReqElapsed.With(l.labels).Set(float64(duration.Milliseconds())) - - // ignore 1xx - switch { - case status >= 500: - m.HTTP5xx.With(l.labels).Inc() - case status >= 400: - m.HTTP4xx.With(l.labels).Inc() - case status >= 200: - m.HTTP2xx3xx.With(l.labels).Inc() - } - }() -} - -func (l *httpMetricLogger) Unwrap() http.ResponseWriter { - return l.ResponseWriter + TargetURL *types.URL } func singleJoiningSlash(a, b string) string { @@ -167,7 +133,7 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) { // URLs to the scheme, host, and base path provided in target. If the // target's path is "/base" and the incoming request was for "/dir", // the target request will be for /base/dir. -func NewReverseProxy(name string, target types.URL, transport http.RoundTripper) *ReverseProxy { +func NewReverseProxy(name string, target *types.URL, transport http.RoundTripper) *ReverseProxy { if transport == nil { panic("nil transport") } @@ -181,15 +147,11 @@ func NewReverseProxy(name string, target types.URL, transport http.RoundTripper) return rp } -func (p *ReverseProxy) UnregisterMetrics() { - metrics.GetRouteMetrics().UnregisterService(p.TargetName) -} - func (p *ReverseProxy) rewriteRequestURL(req *http.Request) { targetQuery := p.TargetURL.RawQuery req.URL.Scheme = p.TargetURL.Scheme req.URL.Host = p.TargetURL.Host - req.URL.Path, req.URL.RawPath = joinURLPath(p.TargetURL.URL, req.URL) + req.URL.Path, req.URL.RawPath = joinURLPath(&p.TargetURL.URL, req.URL) if targetQuery == "" || req.URL.RawQuery == "" { req.URL.RawQuery = targetQuery + req.URL.RawQuery } else { @@ -255,28 +217,6 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } func (p *ReverseProxy) handler(rw http.ResponseWriter, req *http.Request) { - visitorIP, _, err := net.SplitHostPort(req.RemoteAddr) - if err != nil { - visitorIP = req.RemoteAddr - } - - if common.PrometheusEnabled { - t := time.Now() - // req.RemoteAddr had been modified by middleware (if any) - lbls := &metrics.HTTPRouteMetricLabels{ - Service: p.TargetName, - Method: req.Method, - Host: req.Host, - Visitor: visitorIP, - Path: req.URL.Path, - } - rw = &httpMetricLogger{ - ResponseWriter: rw, - timestamp: t, - labels: lbls, - } - } - transport := p.Transport ctx := req.Context() @@ -360,7 +300,11 @@ func (p *ReverseProxy) handler(rw http.ResponseWriter, req *http.Request) { // separated list and fold multiple headers into one. prior, ok := outreq.Header[gphttp.HeaderXForwardedFor] omit := ok && prior == nil // Issue 38079: nil now means don't populate the header - xff := visitorIP + + xff, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + xff = req.RemoteAddr + } if len(prior) > 0 { xff = strings.Join(prior, ", ") + ", " + xff } diff --git a/internal/net/types/url.go b/internal/net/types/url.go index eb984fa..a704813 100644 --- a/internal/net/types/url.go +++ b/internal/net/types/url.go @@ -2,13 +2,16 @@ package types import ( urlPkg "net/url" + + "github.com/yusing/go-proxy/internal/utils" ) type URL struct { - *urlPkg.URL + _ utils.NoCopy + urlPkg.URL } -func MustParseURL(url string) URL { +func MustParseURL(url string) *URL { u, err := ParseURL(url) if err != nil { panic(err) @@ -16,40 +19,38 @@ func MustParseURL(url string) URL { return u } -func ParseURL(url string) (URL, error) { - u, err := urlPkg.Parse(url) +func ParseURL(url string) (*URL, error) { + u := &URL{} + return u, u.Parse(url) +} + +func NewURL(url *urlPkg.URL) *URL { + return &URL{URL: *url} +} + +func (u *URL) Parse(url string) error { + uu, err := urlPkg.Parse(url) if err != nil { - return URL{}, err + return err } - return URL{URL: u}, nil + u.URL = *uu + return nil } -func NewURL(url *urlPkg.URL) URL { - return URL{url} -} - -func (u URL) Nil() bool { - return u.URL == nil -} - -func (u URL) String() string { - if u.URL == nil { +func (u *URL) String() string { + if u == nil { return "nil" } return u.URL.String() } -func (u URL) MarshalJSON() (text []byte, err error) { - if u.URL == nil { +func (u *URL) MarshalJSON() (text []byte, err error) { + if u == nil { return []byte("null"), nil } return []byte("\"" + u.URL.String() + "\""), nil } -func (u URL) Equals(other *URL) bool { - return u.URL == other.URL || u.String() == other.String() -} - -func (u URL) JoinPath(path string) URL { - return URL{u.URL.JoinPath(path)} +func (u *URL) Equals(other *URL) bool { + return u.String() == other.String() } diff --git a/internal/notif/dispatcher.go b/internal/notif/dispatcher.go index c8f4c67..c8431b2 100644 --- a/internal/notif/dispatcher.go +++ b/internal/notif/dispatcher.go @@ -83,6 +83,9 @@ func (disp *Dispatcher) start() { } func (disp *Dispatcher) dispatch(msg *LogMessage) { + if true { + return + } task := disp.task.Subtask("dispatcher") defer task.Finish("notif dispatched") diff --git a/internal/route/entry/entry.go b/internal/route/entry/entry.go deleted file mode 100644 index 67f3872..0000000 --- a/internal/route/entry/entry.go +++ /dev/null @@ -1,62 +0,0 @@ -package entry - -import ( - E "github.com/yusing/go-proxy/internal/error" - route "github.com/yusing/go-proxy/internal/route/types" -) - -type Entry = route.Entry - -func ValidateEntry(m *route.RawEntry) (Entry, E.Error) { - scheme, err := route.NewScheme(m.Scheme) - if err != nil { - return nil, E.From(err) - } - - var entry Entry - errs := E.NewBuilder("entry validation failed") - if scheme.IsStream() { - entry = validateStreamEntry(m, errs) - } else { - entry = validateRPEntry(m, scheme, errs) - } - if errs.HasError() { - return nil, errs.Error() - } - if !UseHealthCheck(entry) && (UseLoadBalance(entry) || UseIdleWatcher(entry)) { - return nil, E.New("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled") - } - return entry, nil -} - -func IsDocker(entry Entry) bool { - iw := entry.IdlewatcherConfig() - return iw != nil && iw.ContainerID != "" -} - -func IsZeroPort(entry Entry) bool { - return entry.TargetURL().Port() == "0" -} - -func ShouldNotServe(entry Entry) bool { - return IsZeroPort(entry) && !UseIdleWatcher(entry) -} - -func UseLoadBalance(entry Entry) bool { - lb := entry.RawEntry().LoadBalance - return lb != nil && lb.Link != "" -} - -func UseIdleWatcher(entry Entry) bool { - iw := entry.IdlewatcherConfig() - return iw != nil && iw.IdleTimeout > 0 -} - -func UseHealthCheck(entry Entry) bool { - hc := entry.RawEntry().HealthCheck - return hc != nil && !hc.Disable -} - -func UseAccessLog(entry Entry) bool { - return entry.RawEntry().AccessLog != nil -} diff --git a/internal/route/entry/reverse_proxy.go b/internal/route/entry/reverse_proxy.go deleted file mode 100644 index f76fb71..0000000 --- a/internal/route/entry/reverse_proxy.go +++ /dev/null @@ -1,61 +0,0 @@ -package entry - -import ( - "fmt" - "net/url" - - "github.com/yusing/go-proxy/internal/docker" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - E "github.com/yusing/go-proxy/internal/error" - net "github.com/yusing/go-proxy/internal/net/types" - route "github.com/yusing/go-proxy/internal/route/types" -) - -type ReverseProxyEntry struct { // real model after validation - Raw *route.RawEntry `json:"raw"` - URL net.URL `json:"url"` - - /* Docker only */ - Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` -} - -func (rp *ReverseProxyEntry) TargetName() string { - return rp.Raw.Alias -} - -func (rp *ReverseProxyEntry) TargetURL() net.URL { - return rp.URL -} - -func (rp *ReverseProxyEntry) RawEntry() *route.RawEntry { - return rp.Raw -} - -func (rp *ReverseProxyEntry) IdlewatcherConfig() *idlewatcher.Config { - return rp.Idlewatcher -} - -func validateRPEntry(m *route.RawEntry, s route.Scheme, errs *E.Builder) *ReverseProxyEntry { - cont := m.Container - if cont == nil { - cont = docker.DummyContainer - } - - if m.LoadBalance != nil && m.LoadBalance.Link == "" { - m.LoadBalance = nil - } - - port := E.Collect(errs, route.ValidatePort, m.Port) - url := E.Collect(errs, url.Parse, fmt.Sprintf("%s://%s:%d", s, m.Host, port)) - iwCfg := E.Collect(errs, idlewatcher.ValidateConfig, cont) - - if errs.HasError() { - return nil - } - - return &ReverseProxyEntry{ - Raw: m, - URL: net.NewURL(url), - Idlewatcher: iwCfg, - } -} diff --git a/internal/route/entry/stream.go b/internal/route/entry/stream.go deleted file mode 100644 index 313321d..0000000 --- a/internal/route/entry/stream.go +++ /dev/null @@ -1,65 +0,0 @@ -package entry - -import ( - "fmt" - - "github.com/yusing/go-proxy/internal/docker" - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - E "github.com/yusing/go-proxy/internal/error" - net "github.com/yusing/go-proxy/internal/net/types" - route "github.com/yusing/go-proxy/internal/route/types" -) - -type StreamEntry struct { - Raw *route.RawEntry `json:"raw"` - - Scheme route.StreamScheme `json:"scheme"` - URL net.URL `json:"url"` - ListenURL net.URL `json:"listening_url"` - Port route.StreamPort `json:"port,omitempty"` - - /* Docker only */ - Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` -} - -func (s *StreamEntry) TargetName() string { - return s.Raw.Alias -} - -func (s *StreamEntry) TargetURL() net.URL { - return s.URL -} - -func (s *StreamEntry) RawEntry() *route.RawEntry { - return s.Raw -} - -func (s *StreamEntry) IdlewatcherConfig() *idlewatcher.Config { - return s.Idlewatcher -} - -func validateStreamEntry(m *route.RawEntry, errs *E.Builder) *StreamEntry { - cont := m.Container - if cont == nil { - cont = docker.DummyContainer - } - - port := E.Collect(errs, route.ValidateStreamPort, m.Port) - scheme := E.Collect(errs, route.ValidateStreamScheme, m.Scheme) - url := E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", scheme.ProxyScheme, m.Host, port.ProxyPort)) - listenURL := E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://:%d", scheme.ListeningScheme, port.ListeningPort)) - idleWatcherCfg := E.Collect(errs, idlewatcher.ValidateConfig, cont) - - if errs.HasError() { - return nil - } - - return &StreamEntry{ - Raw: m, - Scheme: *scheme, - URL: url, - ListenURL: listenURL, - Port: port, - Idlewatcher: idleWatcherCfg, - } -} diff --git a/internal/route/fileserver.go b/internal/route/fileserver.go new file mode 100644 index 0000000..8d45c4d --- /dev/null +++ b/internal/route/fileserver.go @@ -0,0 +1,134 @@ +package route + +import ( + "net/http" + "path" + "path/filepath" + + "github.com/yusing/go-proxy/internal/common" + gphttp "github.com/yusing/go-proxy/internal/net/http" + "github.com/yusing/go-proxy/internal/net/http/accesslog" + "github.com/yusing/go-proxy/internal/net/http/middleware" + metricslogger "github.com/yusing/go-proxy/internal/net/http/middleware/metrics_logger" + "github.com/yusing/go-proxy/internal/route/routes" + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/watcher/health" + "github.com/yusing/go-proxy/internal/watcher/health/monitor" + + E "github.com/yusing/go-proxy/internal/error" +) + +type ( + FileServer struct { + *Route + + Health *monitor.FileServerHealthMonitor `json:"health"` + + task *task.Task + middleware *middleware.Middleware + handler http.Handler + accessLogger *accesslog.AccessLogger + } +) + +func handler(root string) http.Handler { + return http.FileServer(http.Dir(root)) +} + +func NewFileServer(base *Route) (*FileServer, E.Error) { + s := &FileServer{Route: base} + + s.Root = filepath.Clean(s.Root) + if !path.IsAbs(s.Root) { + return nil, E.New("`root` must be an absolute path") + } + + s.handler = handler(s.Root) + + if len(s.Middlewares) > 0 { + mid, err := middleware.BuildMiddlewareFromMap(s.Alias, s.Middlewares) + if err != nil { + return nil, err + } + s.middleware = mid + } + + return s, nil +} + +// Start implements task.TaskStarter. +func (s *FileServer) Start(parent task.Parent) E.Error { + s.task = parent.Subtask("fileserver."+s.TargetName(), false) + + pathPatterns := s.PathPatterns + switch { + case len(pathPatterns) == 0: + case len(pathPatterns) == 1 && pathPatterns[0] == "/": + default: + mux := gphttp.NewServeMux() + patErrs := E.NewBuilder("invalid path pattern(s)") + for _, p := range pathPatterns { + patErrs.Add(mux.Handle(p, s.handler)) + } + if err := patErrs.Error(); err != nil { + s.task.Finish(err) + return err + } + s.handler = mux + } + + if s.middleware != nil { + s.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.middleware.ServeHTTP(s.handler.ServeHTTP, w, r) + }) + } + + if s.UseAccessLog() { + var err error + s.accessLogger, err = accesslog.NewFileAccessLogger(s.task, s.AccessLog) + if err != nil { + s.task.Finish(err) + return E.Wrap(err) + } + } + + if common.PrometheusEnabled { + metricsLogger := metricslogger.NewMetricsLogger(s.TargetName()) + s.handler = metricsLogger.GetHandler(s.handler) + s.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics) + } + + if s.UseHealthCheck() { + s.Health = monitor.NewFileServerHealthMonitor(s.TargetName(), s.HealthCheck, s.Root) + if err := s.Health.Start(s.task); err != nil { + return err + } + } + + routes.SetHTTPRoute(s.TargetName(), s) + s.task.OnCancel("entrypoint_remove_route", func() { + routes.DeleteHTTPRoute(s.TargetName()) + }) + return nil +} + +func (s *FileServer) Task() *task.Task { + return s.task +} + +// Finish implements task.TaskFinisher. +func (s *FileServer) Finish(reason any) { + s.task.Finish(reason) +} + +// ServeHTTP implements http.Handler. +func (s *FileServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s.handler.ServeHTTP(w, req) + if s.accessLogger != nil { + s.accessLogger.Log(req, req.Response) + } +} + +func (s *FileServer) HealthMonitor() health.HealthMonitor { + return s.Health +} diff --git a/internal/route/fileserver_test.go b/internal/route/fileserver_test.go new file mode 100644 index 0000000..f93e523 --- /dev/null +++ b/internal/route/fileserver_test.go @@ -0,0 +1,122 @@ +//nolint:gofumpt +package route + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestPathTraversalAttack(t *testing.T) { + tmp := t.TempDir() + root := filepath.Join(tmp, "static") + if err := os.Mkdir(root, 0755); err != nil { + t.Fatalf("Failed to create root directory: %v", err) + } + + // Create a file inside the root + validPath := "test.txt" + validContent := "test content" + if err := os.WriteFile(filepath.Join(root, validPath), []byte(validContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // create one at .. + secretFile := "secret.txt" + if err := os.WriteFile(filepath.Join(tmp, secretFile), []byte(validContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + traversals := []string{ + "../", + "./../", + "./.././", + "..%2f", + ".%2f..%2f", + ".%2f%2e%2e", + ".%2e", + ".%2e/", + ".%2e%2f", + "%2e.", + "%2e%2e", + } + + for _, traversal := range traversals { + traversals = append(traversals, "%2f"+traversal) + traversals = append(traversals, traversal+"%2f") + traversals = append(traversals, "%2f"+traversal+"%2f") + traversals = append(traversals, "/"+traversal) + traversals = append(traversals, traversal+"/") + traversals = append(traversals, "/"+traversal+"/") + } + + // Setup the FileServer + fs, err := NewFileServer(&Route{Root: root}) + if err != nil { + t.Fatalf("Failed to create FileServer: %v", err) + } + + // Create a test server with the handler + ts := httptest.NewServer(fs.handler) + defer ts.Close() + + // Test valid path + t.Run("valid path", func(t *testing.T) { + validURL := ts.URL + "/" + validPath + resp, err := http.Get(validURL) + if err != nil { + t.Errorf("Error making request to %s: %v", validURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected 200 OK, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Errorf("Error reading response body: %v", err) + } + + if string(body) != validContent { + t.Errorf("Expected %q, got %q", validContent, string(body)) + } + }) + + // Test ../ path + // tsURL := Must(url.Parse(ts.URL)) + for _, traversal := range traversals { + p := traversal + secretFile + t.Run(p, func(t *testing.T) { + u := &url.URL{Scheme: "http", Host: ts.Listener.Addr().String(), Path: p} + resp, err := http.DefaultClient.Do(&http.Request{ + Method: http.MethodGet, + URL: u, + }) + if err != nil { + t.Errorf("Error making request to %s: %v", p, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status 404 or 400, got %d in url %s", resp.StatusCode, u.String()) + } + + u = Must(url.Parse(ts.URL + "/" + p)) + resp, err = http.DefaultClient.Do(&http.Request{ + Method: http.MethodGet, + URL: u, + }) + if err != nil { + t.Errorf("Error making request to %s: %v", u.String(), err) + } + defer resp.Body.Close() + }) + } +} diff --git a/internal/route/provider/docker.go b/internal/route/provider/docker.go index df4466d..37a96c4 100755 --- a/internal/route/provider/docker.go +++ b/internal/route/provider/docker.go @@ -3,7 +3,6 @@ package provider import ( "fmt" "strconv" - "strings" "github.com/docker/docker/client" "github.com/rs/zerolog" @@ -62,15 +61,13 @@ func (p *DockerProvider) NewWatcher() watcher.Watcher { } func (p *DockerProvider) loadRoutesImpl() (route.Routes, E.Error) { - routes := route.NewRoutes() - entries := route.NewProxyEntries() - containers, err := docker.ListContainers(p.dockerHost) if err != nil { - return routes, E.From(err) + return nil, E.From(err) } errs := E.NewBuilder("") + routes := make(route.Routes) for _, c := range containers { container := docker.FromDocker(&c, p.dockerHost) @@ -78,47 +75,35 @@ func (p *DockerProvider) loadRoutesImpl() (route.Routes, E.Error) { continue } - newEntries, err := p.entriesFromContainerLabels(container) + newEntries, err := p.routesFromContainerLabels(container) if err != nil { errs.Add(err.Subject(container.ContainerName)) } - // although err is not nil - // there may be some valid entries in `en` - dups := entries.MergeFrom(newEntries) - // add the duplicate proxy entries to the error - dups.RangeAll(func(k string, v *route.RawEntry) { - errs.Addf("duplicated alias %s", k) - }) + for k, v := range newEntries { + if routes.Contains(k) { + errs.Addf("duplicated alias %s", k) + } else { + routes[k] = v + } + } } - routes, err = route.FromEntries(p.ShortName(), entries) - errs.Add(err) - return routes, errs.Error() } -func (p *DockerProvider) shouldIgnore(container *docker.Container) bool { - return container.IsExcluded || - !container.IsExplicit && p.IsExplicitOnly() || - !container.IsExplicit && container.IsDatabase || - strings.HasSuffix(container.ContainerName, "-old") -} - // Returns a list of proxy entries for a container. // Always non-nil. -func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) (entries route.RawEntries, _ E.Error) { - entries = route.NewProxyEntries() - - if p.shouldIgnore(container) { - return +func (p *DockerProvider) routesFromContainerLabels(container *docker.Container) (route.Routes, E.Error) { + if !container.IsExplicit && p.IsExplicitOnly() { + return nil, nil } + routes := make(route.Routes, len(container.Aliases)) + // init entries map for all aliases for _, a := range container.Aliases { - entries.Store(a, &route.RawEntry{ - Alias: a, - Container: container, - }) + routes[a] = &route.Route{} + routes[a].Metadata.Container = container } errs := E.NewBuilder("label errors") @@ -170,32 +155,29 @@ func (p *DockerProvider) entriesFromContainerLabels(container *docker.Container) } // init entry if not exist - en, ok := entries.Load(alias) + r, ok := routes[alias] if !ok { - en = &route.RawEntry{ - Alias: alias, - Container: container, - } - entries.Store(alias, en) + r = &route.Route{} + r.Metadata.Container = container + routes[alias] = r } // deserialize map into entry object - err := U.Deserialize(entryMap, en) + err := U.Deserialize(entryMap, r) if err != nil { errs.Add(err.Subject(alias)) } else { - entries.Store(alias, en) + routes[alias] = r } } if wildcardProps != nil { - entries.Range(func(alias string, re *route.RawEntry) bool { + for _, re := range routes { if err := U.Deserialize(wildcardProps, re); err != nil { errs.Add(err.Subject(docker.WildcardAlias)) - return false + break } - return true - }) + } } - return entries, errs.Error() + return routes, errs.Error() } diff --git a/internal/route/provider/docker_labels_test.go b/internal/route/provider/docker_labels_test.go index 7c9b0cb..d9e400b 100644 --- a/internal/route/provider/docker_labels_test.go +++ b/internal/route/provider/docker_labels_test.go @@ -20,7 +20,7 @@ func TestParseDockerLabels(t *testing.T) { labels := make(map[string]string) ExpectNoError(t, yaml.Unmarshal(testDockerLabelsYAML, &labels)) - routes, err := provider.entriesFromContainerLabels( + routes, err := provider.routesFromContainerLabels( docker.FromDocker(&types.Container{ Names: []string{"container"}, Labels: labels, @@ -31,6 +31,6 @@ func TestParseDockerLabels(t *testing.T) { }, "/var/run/docker.sock"), ) ExpectNoError(t, err) - ExpectTrue(t, routes.Has("app")) - ExpectTrue(t, routes.Has("app1")) + ExpectTrue(t, routes.Contains("app")) + ExpectTrue(t, routes.Contains("app1")) } diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index cc8d3bb..aa8a385 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -9,9 +9,7 @@ import ( "github.com/docker/docker/client" "github.com/yusing/go-proxy/internal/common" D "github.com/yusing/go-proxy/internal/docker" - E "github.com/yusing/go-proxy/internal/error" "github.com/yusing/go-proxy/internal/route" - "github.com/yusing/go-proxy/internal/route/entry" T "github.com/yusing/go-proxy/internal/route/types" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -23,7 +21,7 @@ const ( testDockerIP = "172.17.0.123" ) -func makeEntries(cont *types.Container, dockerHostIP ...string) route.RawEntries { +func makeRoutes(cont *types.Container, dockerHostIP ...string) route.Routes { var p DockerProvider var host string if len(dockerHostIP) > 0 { @@ -32,11 +30,11 @@ func makeEntries(cont *types.Container, dockerHostIP ...string) route.RawEntries host = client.DefaultDockerHost } p.name = "test" - entries := E.Must(p.entriesFromContainerLabels(D.FromDocker(cont, host))) - entries.RangeAll(func(k string, v *route.RawEntry) { - v.Finalize() - }) - return entries + routes := Must(p.routesFromContainerLabels(D.FromDocker(cont, host))) + for _, r := range routes { + r.Finalize() + } + return routes } func TestExplicitOnly(t *testing.T) { @@ -66,7 +64,7 @@ func TestApplyLabel(t *testing.T) { "prop4": "value4", }, } - entries := makeEntries(&types.Container{ + entries := makeRoutes(&types.Container{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a,b", @@ -91,9 +89,9 @@ func TestApplyLabel(t *testing.T) { }, }) - a, ok := entries.Load("a") + a, ok := entries["a"] ExpectTrue(t, ok) - b, ok := entries.Load("b") + b, ok := entries["b"] ExpectTrue(t, ok) ExpectEqual(t, a.Scheme, "https") @@ -102,8 +100,8 @@ func TestApplyLabel(t *testing.T) { ExpectEqual(t, a.Host, "app") ExpectEqual(t, b.Host, "app") - ExpectEqual(t, a.Port, "4567") - ExpectEqual(t, b.Port, "4567") + ExpectEqual(t, a.Port.Proxy, 4567) + ExpectEqual(t, b.Port.Proxy, 4567) ExpectTrue(t, a.NoTLSVerify) ExpectTrue(t, b.NoTLSVerify) @@ -139,7 +137,7 @@ func TestApplyLabel(t *testing.T) { } func TestApplyLabelWithAlias(t *testing.T) { - entries := makeEntries(&types.Container{ + entries := makeRoutes(&types.Container{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -150,23 +148,23 @@ func TestApplyLabelWithAlias(t *testing.T) { "proxy.c.scheme": "https", }, }) - a, ok := entries.Load("a") + a, ok := entries["a"] ExpectTrue(t, ok) - b, ok := entries.Load("b") + b, ok := entries["b"] ExpectTrue(t, ok) - c, ok := entries.Load("c") + c, ok := entries["c"] ExpectTrue(t, ok) ExpectEqual(t, a.Scheme, "http") - ExpectEqual(t, a.Port, "3333") + ExpectEqual(t, a.Port.Proxy, 3333) ExpectEqual(t, a.NoTLSVerify, true) ExpectEqual(t, b.Scheme, "http") - ExpectEqual(t, b.Port, "1234") + ExpectEqual(t, b.Port.Proxy, 1234) ExpectEqual(t, c.Scheme, "https") } func TestApplyLabelWithRef(t *testing.T) { - entries := makeEntries(&types.Container{ + entries := makeRoutes(&types.Container{ Names: dummyNames, State: "running", Labels: map[string]string{ @@ -178,19 +176,19 @@ func TestApplyLabelWithRef(t *testing.T) { "proxy.#3.scheme": "https", }, }) - a, ok := entries.Load("a") + a, ok := entries["a"] ExpectTrue(t, ok) - b, ok := entries.Load("b") + b, ok := entries["b"] ExpectTrue(t, ok) - c, ok := entries.Load("c") + c, ok := entries["c"] ExpectTrue(t, ok) ExpectEqual(t, a.Scheme, "http") ExpectEqual(t, a.Host, "localhost") - ExpectEqual(t, a.Port, "4444") - ExpectEqual(t, b.Port, "9999") + ExpectEqual(t, a.Port.Proxy, 4444) + ExpectEqual(t, b.Port.Proxy, 9999) ExpectEqual(t, c.Scheme, "https") - ExpectEqual(t, c.Port, "1111") + ExpectEqual(t, c.Port.Proxy, 1111) } func TestApplyLabelWithRefIndexError(t *testing.T) { @@ -204,7 +202,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { }, }, "") var p DockerProvider - _, err := p.entriesFromContainerLabels(c) + _, err := p.routesFromContainerLabels(c) ExpectError(t, ErrAliasRefIndexOutOfRange, err) c = D.FromDocker(&types.Container{ @@ -215,7 +213,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) { "proxy.#0.host": "localhost", }, }, "") - _, err = p.entriesFromContainerLabels(c) + _, err = p.routesFromContainerLabels(c) ExpectError(t, ErrAliasRefIndexOutOfRange, err) } @@ -229,17 +227,17 @@ func TestDynamicAliases(t *testing.T) { }, } - entries := makeEntries(c) + entries := makeRoutes(c) - raw, ok := entries.Load("app1") + r, ok := entries["app1"] ExpectTrue(t, ok) - ExpectEqual(t, raw.Scheme, "http") - ExpectEqual(t, raw.Port, "1234") + ExpectEqual(t, r.Scheme, "http") + ExpectEqual(t, r.Port.Proxy, 1234) - raw, ok = entries.Load("app1_backend") + r, ok = entries["app1_backend"] ExpectTrue(t, ok) - ExpectEqual(t, raw.Scheme, "http") - ExpectEqual(t, raw.Port, "5678") + ExpectEqual(t, r.Scheme, "http") + ExpectEqual(t, r.Port.Proxy, 5678) } func TestDisableHealthCheck(t *testing.T) { @@ -251,22 +249,22 @@ func TestDisableHealthCheck(t *testing.T) { "proxy.a.port": "1234", }, } - raw, ok := makeEntries(c).Load("a") + r, ok := makeRoutes(c)["a"] ExpectTrue(t, ok) - ExpectEqual(t, raw.HealthCheck, nil) + ExpectFalse(t, r.UseHealthCheck()) } func TestPublicIPLocalhost(t *testing.T) { c := &types.Container{Names: dummyNames, State: "running"} - raw, ok := makeEntries(c).Load("a") + r, ok := makeRoutes(c)["a"] ExpectTrue(t, ok) - ExpectEqual(t, raw.Container.PublicIP, "127.0.0.1") - ExpectEqual(t, raw.Host, raw.Container.PublicIP) + ExpectEqual(t, r.Container.PublicIP, "127.0.0.1") + ExpectEqual(t, r.Host, r.Container.PublicIP) } func TestPublicIPRemote(t *testing.T) { c := &types.Container{Names: dummyNames, State: "running"} - raw, ok := makeEntries(c, testIP).Load("a") + raw, ok := makeRoutes(c, testIP)["a"] ExpectTrue(t, ok) ExpectEqual(t, raw.Container.PublicIP, testIP) ExpectEqual(t, raw.Host, raw.Container.PublicIP) @@ -283,10 +281,10 @@ func TestPrivateIPLocalhost(t *testing.T) { }, }, } - raw, ok := makeEntries(c).Load("a") + r, ok := makeRoutes(c)["a"] ExpectTrue(t, ok) - ExpectEqual(t, raw.Container.PrivateIP, testDockerIP) - ExpectEqual(t, raw.Host, raw.Container.PrivateIP) + ExpectEqual(t, r.Container.PrivateIP, testDockerIP) + ExpectEqual(t, r.Host, r.Container.PrivateIP) } func TestPrivateIPRemote(t *testing.T) { @@ -301,11 +299,11 @@ func TestPrivateIPRemote(t *testing.T) { }, }, } - raw, ok := makeEntries(c, testIP).Load("a") + r, ok := makeRoutes(c, testIP)["a"] ExpectTrue(t, ok) - ExpectEqual(t, raw.Container.PrivateIP, "") - ExpectEqual(t, raw.Container.PublicIP, testIP) - ExpectEqual(t, raw.Host, raw.Container.PublicIP) + ExpectEqual(t, r.Container.PrivateIP, "") + ExpectEqual(t, r.Container.PublicIP, testIP) + ExpectEqual(t, r.Host, r.Container.PublicIP) } func TestStreamDefaultValues(t *testing.T) { @@ -328,59 +326,58 @@ func TestStreamDefaultValues(t *testing.T) { } t.Run("local", func(t *testing.T) { - raw, ok := makeEntries(cont).Load("a") + r, ok := makeRoutes(cont)["a"] ExpectTrue(t, ok) - en := E.Must(entry.ValidateEntry(raw)) - a := ExpectType[*entry.StreamEntry](t, en) - ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp")) - ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp")) - ExpectEqual(t, a.URL.Hostname(), privIP) - ExpectEqual(t, a.Port.ListeningPort, 0) - ExpectEqual(t, a.Port.ProxyPort, T.Port(privPort)) + ExpectNoError(t, r.Validate()) + ExpectEqual(t, r.Scheme, T.Scheme("udp")) + ExpectEqual(t, r.TargetURL().Hostname(), privIP) + ExpectEqual(t, r.Port.Listening, 0) + ExpectEqual(t, r.Port.Proxy, int(privPort)) }) t.Run("remote", func(t *testing.T) { - raw, ok := makeEntries(cont, testIP).Load("a") + r, ok := makeRoutes(cont, testIP)["a"] ExpectTrue(t, ok) - en := E.Must(entry.ValidateEntry(raw)) - a := ExpectType[*entry.StreamEntry](t, en) - ExpectEqual(t, a.Scheme.ListeningScheme, T.Scheme("udp")) - ExpectEqual(t, a.Scheme.ProxyScheme, T.Scheme("udp")) - ExpectEqual(t, a.URL.Hostname(), testIP) - ExpectEqual(t, a.Port.ListeningPort, 0) - ExpectEqual(t, a.Port.ProxyPort, T.Port(pubPort)) + ExpectNoError(t, r.Validate()) + ExpectEqual(t, r.Scheme, T.Scheme("udp")) + ExpectEqual(t, r.TargetURL().Hostname(), testIP) + ExpectEqual(t, r.Port.Listening, 0) + ExpectEqual(t, r.Port.Proxy, int(pubPort)) }) } func TestExplicitExclude(t *testing.T) { - _, ok := makeEntries(&types.Container{ + r, ok := makeRoutes(&types.Container{ Names: dummyNames, Labels: map[string]string{ D.LabelAliases: "a", D.LabelExclude: "true", "proxy.a.no_tls_verify": "true", }, - }, "").Load("a") - ExpectFalse(t, ok) + }, "")["a"] + ExpectTrue(t, ok) + ExpectTrue(t, r.ShouldExclude()) } func TestImplicitExcludeDatabase(t *testing.T) { t.Run("mount path detection", func(t *testing.T) { - _, ok := makeEntries(&types.Container{ + r, ok := makeRoutes(&types.Container{ Names: dummyNames, Mounts: []types.MountPoint{ {Source: "/data", Destination: "/var/lib/postgresql/data"}, }, - }).Load("a") - ExpectFalse(t, ok) + })["a"] + ExpectTrue(t, ok) + ExpectTrue(t, r.ShouldExclude()) }) t.Run("exposed port detection", func(t *testing.T) { - _, ok := makeEntries(&types.Container{ + r, ok := makeRoutes(&types.Container{ Names: dummyNames, Ports: []types.Port{ {Type: "tcp", PrivatePort: 5432, PublicPort: 5432}, }, - }).Load("a") - ExpectFalse(t, ok) + })["a"] + ExpectTrue(t, ok) + ExpectTrue(t, r.ShouldExclude()) }) } diff --git a/internal/route/provider/event_handler.go b/internal/route/provider/event_handler.go index 45eb1eb..9e132d5 100644 --- a/internal/route/provider/event_handler.go +++ b/internal/route/provider/event_handler.go @@ -4,7 +4,6 @@ import ( "github.com/yusing/go-proxy/internal/common" E "github.com/yusing/go-proxy/internal/error" "github.com/yusing/go-proxy/internal/route" - "github.com/yusing/go-proxy/internal/route/entry" "github.com/yusing/go-proxy/internal/route/provider/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher" @@ -31,10 +30,10 @@ func (p *Provider) newEventHandler() *EventHandler { func (handler *EventHandler) Handle(parent task.Parent, events []watcher.Event) { oldRoutes := handler.provider.routes - newRoutes, err := handler.provider.loadRoutesImpl() + newRoutes, err := handler.provider.loadRoutes() if err != nil { handler.errs.Add(err) - if newRoutes.Size() == 0 { + if len(newRoutes) == 0 { return } } @@ -47,34 +46,32 @@ func (handler *EventHandler) Handle(parent task.Parent, events []watcher.Event) E.LogDebug(eventsLog.About(), eventsLog.Error(), handler.provider.Logger()) oldRoutesLog := E.NewBuilder("old routes") - oldRoutes.RangeAllParallel(func(k string, r *route.Route) { + for k := range oldRoutes { oldRoutesLog.Adds(k) - }) + } E.LogDebug(oldRoutesLog.About(), oldRoutesLog.Error(), handler.provider.Logger()) newRoutesLog := E.NewBuilder("new routes") - newRoutes.RangeAllParallel(func(k string, r *route.Route) { + for k := range newRoutes { newRoutesLog.Adds(k) - }) + } E.LogDebug(newRoutesLog.About(), newRoutesLog.Error(), handler.provider.Logger()) } - oldRoutes.RangeAll(func(k string, oldr *route.Route) { - newr, ok := newRoutes.Load(k) + for k, oldr := range oldRoutes { + newr, ok := newRoutes[k] switch { case !ok: handler.Remove(oldr) case handler.matchAny(events, newr): handler.Update(parent, oldr, newr) - case entry.ShouldNotServe(newr): - handler.Remove(oldr) } - }) - newRoutes.RangeAll(func(k string, newr *route.Route) { - if !(oldRoutes.Has(k) || entry.ShouldNotServe(newr)) { + } + for k, newr := range newRoutes { + if _, ok := oldRoutes[k]; !ok { handler.Add(parent, newr) } - }) + } } func (handler *EventHandler) matchAny(events []watcher.Event, route *route.Route) bool { @@ -89,8 +86,8 @@ func (handler *EventHandler) matchAny(events []watcher.Event, route *route.Route func (handler *EventHandler) match(event watcher.Event, route *route.Route) bool { switch handler.provider.GetType() { case types.ProviderTypeDocker: - return route.Entry.Container.ContainerID == event.ActorID || - route.Entry.Container.ContainerName == event.ActorName + return route.Container.ContainerID == event.ActorID || + route.Container.ContainerName == event.ActorName case types.ProviderTypeFile: return true } @@ -103,14 +100,14 @@ func (handler *EventHandler) Add(parent task.Parent, route *route.Route) { if err != nil { handler.errs.Add(err.Subject("add")) } else { - handler.added.Adds(route.Entry.Alias) + handler.added.Adds(route.Alias) } } func (handler *EventHandler) Remove(route *route.Route) { route.Finish("route removed") - handler.provider.routes.Delete(route.Entry.Alias) - handler.removed.Adds(route.Entry.Alias) + delete(handler.provider.routes, route.Alias) + handler.removed.Adds(route.Alias) } func (handler *EventHandler) Update(parent task.Parent, oldRoute *route.Route, newRoute *route.Route) { @@ -119,7 +116,7 @@ func (handler *EventHandler) Update(parent task.Parent, oldRoute *route.Route, n if err != nil { handler.errs.Add(err.Subject("update")) } else { - handler.updated.Adds(newRoute.Entry.Alias) + handler.updated.Adds(newRoute.Alias) } } diff --git a/internal/route/provider/file.go b/internal/route/provider/file.go index a2a59cf..8d2e208 100644 --- a/internal/route/provider/file.go +++ b/internal/route/provider/file.go @@ -33,16 +33,13 @@ func FileProviderImpl(filename string) (ProviderImpl, error) { return impl, nil } -func validate(provider string, data []byte) (route.Routes, E.Error) { - entries, err := utils.DeserializeYAMLMap[*route.RawEntry](data) - if err != nil { - return route.NewRoutes(), err - } - return route.FromEntries(provider, entries) +func validate(data []byte) (routes route.Routes, err E.Error) { + err = utils.DeserializeYAML(data, &routes) + return } func Validate(data []byte) (err E.Error) { - _, err = validate("", data) + _, err = validate(data) return } @@ -63,14 +60,15 @@ func (p *FileProvider) Logger() *zerolog.Logger { } func (p *FileProvider) loadRoutesImpl() (route.Routes, E.Error) { - routes := route.NewRoutes() - data, err := os.ReadFile(p.path) if err != nil { - return routes, E.From(err) + return nil, E.Wrap(err) } - - return validate(p.ShortName(), data) + routes, err := validate(data) + if err != nil && len(routes) == 0 { + return nil, E.Wrap(err) + } + return routes, E.Wrap(err) } func (p *FileProvider) NewWatcher() W.Watcher { diff --git a/internal/route/provider/file_test.go b/internal/route/provider/file_test.go index cf15c8d..756095c 100644 --- a/internal/route/provider/file_test.go +++ b/internal/route/provider/file_test.go @@ -12,6 +12,6 @@ import ( var testAllFieldsYAML []byte func TestFile(t *testing.T) { - _, err := validate("", testAllFieldsYAML) + _, err := validate(testAllFieldsYAML) ExpectNoError(t, err) } diff --git a/internal/route/provider/provider.go b/internal/route/provider/provider.go index f473d12..6e94b28 100644 --- a/internal/route/provider/provider.go +++ b/internal/route/provider/provider.go @@ -8,7 +8,7 @@ import ( "github.com/rs/zerolog" E "github.com/yusing/go-proxy/internal/error" - R "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route" "github.com/yusing/go-proxy/internal/route/provider/types" "github.com/yusing/go-proxy/internal/task" W "github.com/yusing/go-proxy/internal/watcher" @@ -17,10 +17,10 @@ import ( type ( Provider struct { - ProviderImpl `json:"-"` + ProviderImpl t types.ProviderType - routes R.Routes + routes route.Routes watcher W.Watcher } @@ -28,7 +28,7 @@ type ( fmt.Stringer ShortName() string IsExplicitOnly() bool - loadRoutesImpl() (R.Routes, E.Error) + loadRoutesImpl() (route.Routes, E.Error) NewWatcher() W.Watcher Logger() *zerolog.Logger } @@ -41,10 +41,7 @@ const ( var ErrEmptyProviderName = errors.New("empty provider name") func newProvider(t types.ProviderType) *Provider { - return &Provider{ - t: t, - routes: R.NewRoutes(), - } + return &Provider{t: t} } func NewFileProvider(filename string) (p *Provider, err error) { @@ -84,13 +81,13 @@ func (p *Provider) MarshalText() ([]byte, error) { return []byte(p.String()), nil } -func (p *Provider) startRoute(parent task.Parent, r *R.Route) E.Error { +func (p *Provider) startRoute(parent task.Parent, r *route.Route) E.Error { err := r.Start(parent) if err != nil { - return err.Subject(r.Entry.Alias) + delete(p.routes, r.Alias) + return err.Subject(r.Alias) } - - p.routes.Store(r.Entry.Alias, r) + p.routes[r.Alias] = r return nil } @@ -98,11 +95,10 @@ func (p *Provider) startRoute(parent task.Parent, r *R.Route) E.Error { func (p *Provider) Start(parent task.Parent) E.Error { t := parent.Subtask("provider."+p.String(), false) - // routes and event queue will stop on config reload - errs := p.routes.CollectErrorsParallel( - func(alias string, r *R.Route) error { - return p.startRoute(t, r) - }) + errs := E.NewBuilder("routes error") + for _, r := range p.routes { + errs.Add(p.startRoute(t, r)) + } eventQueue := events.NewEventQueue( t.Subtask("event_queue", false), @@ -119,32 +115,52 @@ func (p *Provider) Start(parent task.Parent) E.Error { ) eventQueue.Start(p.watcher.Events(t.Context())) - if err := E.Join(errs...); err != nil { + if err := errs.Error(); err != nil { return err.Subject(p.String()) } return nil } -func (p *Provider) RangeRoutes(do func(string, *R.Route)) { - p.routes.RangeAll(do) +func (p *Provider) RangeRoutes(do func(string, *route.Route)) { + for alias, r := range p.routes { + do(alias, r) + } } -func (p *Provider) GetRoute(alias string) (*R.Route, bool) { - return p.routes.Load(alias) +func (p *Provider) GetRoute(alias string) (r *route.Route, ok bool) { + r, ok = p.routes[alias] + return } -func (p *Provider) LoadRoutes() E.Error { - var err E.Error - p.routes, err = p.loadRoutesImpl() - if p.routes.Size() > 0 { - return err +func (p *Provider) loadRoutes() (routes route.Routes, err E.Error) { + routes, err = p.loadRoutesImpl() + if err != nil && len(routes) == 0 { + return route.Routes{}, err } - if err == nil { - return nil + errs := E.NewBuilder("routes error") + errs.Add(err) + // check for exclusion + // set alias and provider, then validate + for alias, r := range routes { + r.Alias = alias + r.Provider = p.ShortName() + if err := r.Validate(); err != nil { + errs.Add(err.Subject(alias)) + delete(routes, alias) + continue + } + if r.ShouldExclude() { + delete(routes, alias) + } } - return err + return routes, errs.Error() +} + +func (p *Provider) LoadRoutes() (err E.Error) { + p.routes, err = p.loadRoutes() + return } func (p *Provider) NumRoutes() int { - return p.routes.Size() + return len(p.routes) } diff --git a/internal/route/provider/stats.go b/internal/route/provider/stats.go index f62b84f..ebc2b57 100644 --- a/internal/route/provider/stats.go +++ b/internal/route/provider/stats.go @@ -56,14 +56,14 @@ func (stats *RouteStats) AddOther(other RouteStats) { func (p *Provider) Statistics() ProviderStats { var rps, streams RouteStats - p.routes.RangeAll(func(_ string, r *R.Route) { - switch r.Type { - case route.RouteTypeReverseProxy: + for _, r := range p.routes { + switch r.Type() { + case route.RouteTypeHTTP: rps.Add(r) case route.RouteTypeStream: streams.Add(r) } - }) + } return ProviderStats{ Total: rps.Total + streams.Total, RPs: rps, diff --git a/internal/route/http.go b/internal/route/reverse_proxy.go similarity index 60% rename from internal/route/http.go rename to internal/route/reverse_proxy.go index d3d045a..a80b94b 100755 --- a/internal/route/http.go +++ b/internal/route/reverse_proxy.go @@ -3,7 +3,6 @@ package route import ( "net/http" - "github.com/rs/zerolog" "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/docker" @@ -15,37 +14,33 @@ import ( "github.com/yusing/go-proxy/internal/net/http/loadbalancer" loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" "github.com/yusing/go-proxy/internal/net/http/middleware" + metricslogger "github.com/yusing/go-proxy/internal/net/http/middleware/metrics_logger" "github.com/yusing/go-proxy/internal/net/http/reverseproxy" - "github.com/yusing/go-proxy/internal/route/entry" "github.com/yusing/go-proxy/internal/route/routes" - route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" ) type ( - HTTPRoute struct { - *entry.ReverseProxyEntry + ReveseProxyRoute struct { + *Route HealthMon health.HealthMonitor `json:"health,omitempty"` loadBalancer *loadbalancer.LoadBalancer - server loadbalancer.Server handler http.Handler rp *reverseproxy.ReverseProxy task *task.Task - - l zerolog.Logger } ) // var globalMux = http.NewServeMux() // TODO: support regex subdomain matching. -func NewHTTPRoute(entry *entry.ReverseProxyEntry) (impl, E.Error) { +func NewReverseProxyRoute(base *Route) (*ReveseProxyRoute, E.Error) { trans := gphttp.DefaultTransport - httpConfig := entry.Raw.HTTPConfig + httpConfig := base.HTTPConfig if httpConfig.NoTLSVerify { trans = gphttp.DefaultTransportNoTLS @@ -55,65 +50,57 @@ func NewHTTPRoute(entry *entry.ReverseProxyEntry) (impl, E.Error) { trans.ResponseHeaderTimeout = httpConfig.ResponseHeaderTimeout } - service := entry.TargetName() - rp := reverseproxy.NewReverseProxy(service, entry.URL, trans) + service := base.TargetName() + rp := reverseproxy.NewReverseProxy(service, base.ProxyURL, trans) - if len(entry.Raw.Middlewares) > 0 { - err := middleware.PatchReverseProxy(rp, entry.Raw.Middlewares) + if len(base.Middlewares) > 0 { + err := middleware.PatchReverseProxy(rp, base.Middlewares) if err != nil { return nil, err } } - r := &HTTPRoute{ - ReverseProxyEntry: entry, - rp: rp, - l: logging.With(). - Str("type", entry.URL.Scheme). - Str("name", service). - Logger(), + r := &ReveseProxyRoute{ + Route: base, + rp: rp, } return r, nil } -func (r *HTTPRoute) String() string { +func (r *ReveseProxyRoute) String() string { return r.TargetName() } // Start implements task.TaskStarter. -func (r *HTTPRoute) Start(parent task.Parent) E.Error { - if entry.ShouldNotServe(r) { - return nil - } - +func (r *ReveseProxyRoute) Start(parent task.Parent) E.Error { r.task = parent.Subtask("http."+r.TargetName(), false) switch { - case entry.UseIdleWatcher(r): - waker, err := idlewatcher.NewHTTPWaker(parent, r.ReverseProxyEntry, r.rp) + case r.UseIdleWatcher(): + waker, err := idlewatcher.NewHTTPWaker(parent, r, r.rp) if err != nil { r.task.Finish(err) return err } r.handler = waker r.HealthMon = waker - case entry.UseHealthCheck(r): - if entry.IsDocker(r) { + case r.UseHealthCheck(): + if r.IsDocker() { client, err := docker.ConnectClient(r.Idlewatcher.DockerHost) if err == nil { - fallback := monitor.NewHTTPHealthChecker(r.rp.TargetURL, r.Raw.HealthCheck) - r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.Raw.HealthCheck, fallback) + fallback := monitor.NewHTTPHealthChecker(r.rp.TargetURL, r.HealthCheck) + r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.HealthCheck, fallback) r.task.OnCancel("close_docker_client", client.Close) } } if r.HealthMon == nil { - r.HealthMon = monitor.NewHTTPHealthMonitor(r.rp.TargetURL, r.Raw.HealthCheck) + r.HealthMon = monitor.NewHTTPHealthMonitor(r.rp.TargetURL, r.HealthCheck) } } - if entry.UseAccessLog(r) { + if r.UseAccessLog() { var err error - r.rp.AccessLogger, err = accesslog.NewFileAccessLogger(r.task, r.Raw.AccessLog) + r.rp.AccessLogger, err = accesslog.NewFileAccessLogger(r.task, r.AccessLog) if err != nil { r.task.Finish(err) return E.From(err) @@ -121,7 +108,7 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { } if r.handler == nil { - pathPatterns := r.Raw.PathPatterns + pathPatterns := r.PathPatterns switch { case len(pathPatterns) == 0: r.handler = r.rp @@ -130,7 +117,7 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { default: logging.Warn(). Str("route", r.TargetName()). - Msg("`path_patterns` is deprecated. Use `rules` instead.") + Msg("`path_patterns` for reverse proxy is deprecated. Use `rules` instead.") mux := gphttp.NewServeMux() patErrs := E.NewBuilder("invalid path pattern(s)") for _, p := range pathPatterns { @@ -144,17 +131,23 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { } } - if len(r.Raw.Rules) > 0 { - r.handler = r.Raw.Rules.BuildHandler(r.TargetName(), r.handler) + if len(r.Rules) > 0 { + r.handler = r.Rules.BuildHandler(r.TargetName(), r.handler) } if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { - E.LogWarn("health monitor error", err, &r.l) + return err } } - if entry.UseLoadBalance(r) { + if common.PrometheusEnabled { + metricsLogger := metricslogger.NewMetricsLogger(r.TargetName()) + r.handler = metricsLogger.GetHandler(r.handler) + r.task.OnCancel("reset_metrics", metricsLogger.ResetMetrics) + } + + if r.UseLoadBalance() { r.addToLoadBalancer(parent) } else { routes.SetHTTPRoute(r.TargetName(), r) @@ -163,55 +156,47 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { }) } - if common.PrometheusEnabled { - r.task.OnCancel("metrics_cleanup", r.rp.UnregisterMetrics) - } - r.task.OnCancel("reset_favicon", func() { favicon.PruneRouteIconCache(r) }) return nil } // Task implements task.TaskStarter. -func (r *HTTPRoute) Task() *task.Task { +func (r *ReveseProxyRoute) Task() *task.Task { return r.task } // Finish implements task.TaskFinisher. -func (r *HTTPRoute) Finish(reason any) { +func (r *ReveseProxyRoute) Finish(reason any) { r.task.Finish(reason) } -func (r *HTTPRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { +func (r *ReveseProxyRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handler.ServeHTTP(w, req) } -func (r *HTTPRoute) HealthMonitor() health.HealthMonitor { +func (r *ReveseProxyRoute) HealthMonitor() health.HealthMonitor { return r.HealthMon } -func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) { +func (r *ReveseProxyRoute) addToLoadBalancer(parent task.Parent) { var lb *loadbalancer.LoadBalancer - cfg := r.Raw.LoadBalance + cfg := r.LoadBalance l, ok := routes.GetHTTPRoute(cfg.Link) - var linked *HTTPRoute + var linked *ReveseProxyRoute if ok { - linked = l.(*HTTPRoute) + linked = l.(*ReveseProxyRoute) lb = linked.loadBalancer lb.UpdateConfigIfNeeded(cfg) - if linked.Raw.Homepage.IsEmpty() && !r.Raw.Homepage.IsEmpty() { - linked.Raw.Homepage = r.Raw.Homepage + if linked.Homepage.IsEmpty() && !r.Homepage.IsEmpty() { + linked.Homepage = r.Homepage } } else { lb = loadbalancer.New(cfg) - if err := lb.Start(parent); err != nil { - panic(err) // should always return nil - } - linked = &HTTPRoute{ - ReverseProxyEntry: &entry.ReverseProxyEntry{ - Raw: &route.RawEntry{ - Alias: cfg.Link, - Homepage: r.Raw.Homepage, - }, + _ = lb.Start(parent) // always return nil + linked = &ReveseProxyRoute{ + Route: &Route{ + Alias: cfg.Link, + Homepage: r.Homepage, }, HealthMon: lb, loadBalancer: lb, @@ -220,9 +205,10 @@ func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) { routes.SetHTTPRoute(cfg.Link, linked) } r.loadBalancer = lb - r.server = loadbalance.NewServer(r.task.Name(), r.rp.TargetURL, r.Raw.LoadBalance.Weight, r.handler, r.HealthMon) - lb.AddServer(r.server) + + server := loadbalance.NewServer(r.task.Name(), r.rp.TargetURL, r.LoadBalance.Weight, r.handler, r.HealthMon) + lb.AddServer(server) r.task.OnCancel("lb_remove_server", func() { - lb.RemoveServer(r.server) + lb.RemoveServer(server) }) } diff --git a/internal/route/route.go b/internal/route/route.go old mode 100755 new mode 100644 index 0e04530..868e612 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -1,104 +1,367 @@ package route import ( + "fmt" + "strconv" "strings" "github.com/yusing/go-proxy/internal/docker" - E "github.com/yusing/go-proxy/internal/error" - url "github.com/yusing/go-proxy/internal/net/types" - "github.com/yusing/go-proxy/internal/route/entry" - "github.com/yusing/go-proxy/internal/route/types" + idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" + "github.com/yusing/go-proxy/internal/homepage" + "github.com/yusing/go-proxy/internal/logging" + net "github.com/yusing/go-proxy/internal/net/types" "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" + + dockertypes "github.com/docker/docker/api/types" + "github.com/yusing/go-proxy/internal/common" + E "github.com/yusing/go-proxy/internal/error" + "github.com/yusing/go-proxy/internal/net/http/accesslog" + loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" + "github.com/yusing/go-proxy/internal/route/rules" + "github.com/yusing/go-proxy/internal/route/types" + "github.com/yusing/go-proxy/internal/utils" ) type ( Route struct { - _ U.NoCopy - impl - Type types.RouteType - Entry *RawEntry - } - Routes = F.Map[string, *Route] + _ utils.NoCopy - impl interface { - types.Route - task.TaskStarter - task.TaskFinisher - String() string - TargetURL() url.URL + Alias string `json:"alias"` + Scheme types.Scheme `json:"scheme,omitempty"` + Host string `json:"host,omitempty"` + Port types.Port `json:"port,omitempty"` + Root string `json:"root,omitempty"` + + types.HTTPConfig + PathPatterns []string `json:"path_patterns,omitempty"` + Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"` + HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` + LoadBalance *loadbalance.Config `json:"load_balance,omitempty"` + Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"` + Homepage *homepage.Item `json:"homepage,omitempty"` + AccessLog *accesslog.Config `json:"access_log,omitempty"` + + Metadata `deserialize:"-"` } - RawEntry = types.RawEntry - RawEntries = types.RawEntries + + Metadata struct { + /* Docker only */ + Container *docker.Container `json:"container,omitempty"` + Provider string `json:"provider,omitempty"` + + // private fields + LisURL *net.URL `json:"lurl,omitempty"` + ProxyURL *net.URL `json:"purl,omitempty"` + Idlewatcher *idlewatcher.Config `json:"idlewatcher,omitempty"` + + impl types.Route + isValidated bool + } + Routes map[string]*Route ) -// function alias. -var ( - NewRoutes = F.NewMap[Routes] - NewProxyEntries = types.NewProxyEntries -) - -func (rt *Route) Container() *docker.Container { - if rt.Entry.Container == nil { - return docker.DummyContainer - } - return rt.Entry.Container +func (r Routes) Contains(alias string) bool { + _, ok := r[alias] + return ok } -func NewRoute(raw *RawEntry) (*Route, E.Error) { - raw.Finalize() - en, err := entry.ValidateEntry(raw) - if err != nil { - return nil, err +func (r *Route) Validate() (err E.Error) { + if r.isValidated { + return nil } + r.isValidated = true + r.Finalize() - var t types.RouteType - var rt impl + errs := E.NewBuilder("entry validation failed") - switch e := en.(type) { - case *entry.StreamEntry: - t = types.RouteTypeStream - rt, err = NewStreamRoute(e) - case *entry.ReverseProxyEntry: - t = types.RouteTypeReverseProxy - rt, err = NewHTTPRoute(e) + switch r.Scheme { + case types.SchemeFileServer: + r.impl, err = NewFileServer(r) + case types.SchemeHTTP, types.SchemeHTTPS: + if r.Port.Listening != 0 { + errs.Addf("unexpected listening port for %s scheme", r.Scheme) + } + fallthrough + case types.SchemeTCP, types.SchemeUDP: + r.LisURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Listening)) + fallthrough default: - panic("bug: should not reach here") + if r.Port.Proxy == 0 && !r.IsDocker() { + errs.Adds("missing proxy port") + } + if r.LoadBalance != nil && r.LoadBalance.Link == "" { + r.LoadBalance = nil + } + r.ProxyURL = E.Collect(errs, net.ParseURL, fmt.Sprintf("%s://%s:%d", r.Scheme, r.Host, r.Port.Proxy)) + r.Idlewatcher = E.Collect(errs, idlewatcher.ValidateConfig, r.Container) } - if err != nil { - return nil, err + + if !r.UseHealthCheck() && (r.UseLoadBalance() || r.UseIdleWatcher()) { + errs.Adds("healthCheck.disable cannot be true when loadbalancer or idlewatcher is enabled") } - return &Route{ - impl: rt, - Type: t, - Entry: raw, - }, nil + + if errs.HasError() { + return errs.Error() + } + + switch r.Scheme { + case types.SchemeFileServer: + r.impl, err = NewFileServer(r) + case types.SchemeHTTP, types.SchemeHTTPS: + r.impl, err = NewReverseProxyRoute(r) + case types.SchemeTCP, types.SchemeUDP: + r.impl, err = NewStreamRoute(r) + default: + panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias)) + } + + return err } -func FromEntries(provider string, entries RawEntries) (Routes, E.Error) { - b := E.NewBuilder("errors in routes") +func (r *Route) Start(parent task.Parent) (err E.Error) { + if r.impl == nil { + return E.New("route not initialized") + } + return r.impl.Start(parent) +} - routes := NewRoutes() - entries.RangeAllParallel(func(alias string, en *RawEntry) { - if en == nil { - en = new(RawEntry) - } - en.Alias = alias - en.Provider = provider - if strings.HasPrefix(alias, "x-") { // x properties - return - } - r, err := NewRoute(en) +func (r *Route) Finish(reason any) { + if r.impl == nil { + return + } + r.impl.Finish(reason) + r.impl = nil +} + +func (r *Route) Started() bool { + return r.impl != nil +} + +func (r *Route) ProviderName() string { + return r.Provider +} + +func (r *Route) TargetName() string { + return r.Alias +} + +func (r *Route) TargetURL() *net.URL { + return r.ProxyURL +} + +func (r *Route) Type() types.RouteType { + switch r.Scheme { + case types.SchemeHTTP, types.SchemeHTTPS, types.SchemeFileServer: + return types.RouteTypeHTTP + case types.SchemeTCP, types.SchemeUDP: + return types.RouteTypeStream + } + panic(fmt.Errorf("unexpected scheme %s for alias %s", r.Scheme, r.Alias)) +} + +func (r *Route) HealthMonitor() health.HealthMonitor { + return r.impl.HealthMonitor() +} + +func (r *Route) IdlewatcherConfig() *idlewatcher.Config { + return r.Idlewatcher +} + +func (r *Route) HealthCheckConfig() *health.HealthCheckConfig { + return r.HealthCheck +} + +func (r *Route) LoadBalanceConfig() *loadbalance.Config { + return r.LoadBalance +} + +func (r *Route) HomepageConfig() *homepage.Item { + return r.Homepage +} + +func (r *Route) ContainerInfo() *docker.Container { + return r.Container +} + +func (r *Route) IsDocker() bool { + if r.Container == nil { + return false + } + return r.Container.ContainerID != "" +} + +func (r *Route) IsZeroPort() bool { + return r.Port.Proxy == 0 +} + +func (r *Route) ShouldExclude() bool { + if r.Container != nil { switch { - case err != nil: - b.Add(err.Subject(alias)) - case entry.ShouldNotServe(r): - return - default: - routes.Store(alias, r) + case r.Container.IsExcluded: + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: explicitly excluded") + return true + case r.IsZeroPort() && !r.UseIdleWatcher(): + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: zero port and no idle watcher") + return true + case r.Container.IsDatabase && !r.Container.IsExplicit: + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: database") + return true + case strings.HasPrefix(r.Container.ContainerName, "buildx_"): + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: buildx prefix") + return true } - }) - - return routes, b.Error() + } else if r.IsZeroPort() { + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: zero port") + return true + } + if strings.HasPrefix(r.Alias, "x-") || + strings.HasSuffix(r.Alias, "-old") { + logging.Debug().Str("container", r.Container.ContainerName).Msg("container excluded: alias") + return true + } + return false +} + +func (r *Route) UseLoadBalance() bool { + return r.LoadBalance != nil && r.LoadBalance.Link != "" +} + +func (r *Route) UseIdleWatcher() bool { + return r.Idlewatcher != nil && r.Idlewatcher.IdleTimeout > 0 +} + +func (r *Route) UseHealthCheck() bool { + return !r.HealthCheck.Disable +} + +func (r *Route) UseAccessLog() bool { + return r.AccessLog != nil +} + +func (r *Route) Finalize() { + isDocker := r.Container != nil + cont := r.Container + + if r.Host == "" { + switch { + case !isDocker: + r.Host = "localhost" + case cont.PrivateIP != "": + r.Host = cont.PrivateIP + case cont.PublicIP != "": + r.Host = cont.PublicIP + } + } + + lp, pp := r.Port.Listening, r.Port.Proxy + + if isDocker { + if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok { + if pp == 0 { + pp = port + } + if r.Scheme == "" { + r.Scheme = "tcp" + } + } else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok { + if pp == 0 { + pp = port + } + if r.Scheme == "" { + r.Scheme = "http" + } + } + } + + if pp == 0 { + switch { + case r.Scheme == "https": + pp = 443 + case !isDocker: + pp = 80 + default: + pp = lowestPort(cont.PrivatePortMapping) + if pp == 0 { + pp = lowestPort(cont.PublicPortMapping) + } + } + } + + if isDocker { + // replace private port with public port if using public IP. + if r.Host == cont.PublicIP { + if p, ok := cont.PrivatePortMapping[pp]; ok { + pp = int(p.PublicPort) + } + } + // replace public port with private port if using private IP. + if r.Host == cont.PrivateIP { + if p, ok := cont.PublicPortMapping[pp]; ok { + pp = int(p.PrivatePort) + } + } + + if r.Scheme == "" { + switch { + case r.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp": + r.Scheme = "udp" + case r.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp": + r.Scheme = "udp" + } + } + } + + if r.Scheme == "" { + switch { + case lp != 0: + r.Scheme = "tcp" + case strings.HasSuffix(strconv.Itoa(pp), "443"): + r.Scheme = "https" + default: // assume its http + r.Scheme = "http" + } + } + + r.Port.Listening, r.Port.Proxy = lp, pp + + if r.HealthCheck == nil { + r.HealthCheck = health.DefaultHealthConfig + } + + if !r.HealthCheck.Disable { + if r.HealthCheck.Interval == 0 { + r.HealthCheck.Interval = common.HealthCheckIntervalDefault + } + if r.HealthCheck.Timeout == 0 { + r.HealthCheck.Timeout = common.HealthCheckTimeoutDefault + } + } + + if isDocker && cont.IdleTimeout != "" { + if cont.WakeTimeout == "" { + cont.WakeTimeout = common.WakeTimeoutDefault + } + if cont.StopTimeout == "" { + cont.StopTimeout = common.StopTimeoutDefault + } + if cont.StopMethod == "" { + cont.StopMethod = common.StopMethodDefault + } + } + + if r.Homepage.IsEmpty() { + r.Homepage = homepage.NewItem(r.Alias) + } +} + +func lowestPort(ports map[int]dockertypes.Port) (res int) { + cmp := (uint16)(65535) + for port, v := range ports { + if v.PrivatePort < cmp { + cmp = v.PrivatePort + res = port + } + } + return } diff --git a/internal/route/routes/routequery/query.go b/internal/route/routes/routequery/query.go index d92d7d3..33a2070 100644 --- a/internal/route/routes/routequery/query.go +++ b/internal/route/routes/routequery/query.go @@ -6,7 +6,6 @@ import ( "github.com/yusing/go-proxy/internal" "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/route/entry" provider "github.com/yusing/go-proxy/internal/route/provider/types" "github.com/yusing/go-proxy/internal/route/routes" route "github.com/yusing/go-proxy/internal/route/types" @@ -44,15 +43,15 @@ func HomepageCategories() []string { check := make(map[string]struct{}) categories := make([]string, 0) routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { - en := r.RawEntry() - if en.Homepage.IsEmpty() || en.Homepage.Category == "" { + homepage := r.HomepageConfig() + if homepage.IsEmpty() || homepage.Category == "" { return } - if _, ok := check[en.Homepage.Category]; ok { + if _, ok := check[homepage.Category]; ok { return } - check[en.Homepage.Category] = struct{}{} - categories = append(categories, en.Homepage.Category) + check[homepage.Category] = struct{}{} + categories = append(categories, homepage.Category) }) return categories } @@ -61,8 +60,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st hpCfg := homepage.NewHomePageConfig() routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { - en := r.RawEntry() - item := en.Homepage + item := r.HomepageConfig() if item.IsEmpty() { item = homepage.NewItem(alias) @@ -78,7 +76,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } item.Alias = alias - item.Provider = r.RawEntry().Provider + item.Provider = r.ProviderName() if providerFilter != "" && item.Provider != providerFilter { return @@ -86,7 +84,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st if item.Name == "" { reference := r.TargetName() - cont := r.RawEntry().Container + cont := r.ContainerInfo() if cont != nil { reference = cont.ImageName } @@ -104,8 +102,9 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } if useDefaultCategories { - if en.Container != nil && item.Category == "" { - if category, ok := homepage.PredefinedCategories[en.Container.ImageName]; ok { + container := r.ContainerInfo() + if container != nil && item.Category == "" { + if category, ok := homepage.PredefinedCategories[container.ImageName]; ok { item.Category = category } } @@ -122,12 +121,12 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } switch { - case entry.IsDocker(r): + case r.IsDocker(): if item.Category == "" { item.Category = "Docker" } item.SourceType = string(provider.ProviderTypeDocker) - case entry.UseLoadBalance(r): + case r.UseLoadBalance(): if item.Category == "" { item.Category = "Load-balanced" } @@ -148,11 +147,11 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st 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} + typeFilter = []route.RouteType{route.RouteTypeHTTP, route.RouteTypeStream} } for _, t := range typeFilter { switch t { - case route.RouteTypeReverseProxy: + case route.RouteTypeHTTP: routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { rts[alias] = r }) diff --git a/internal/route/rules/do.go b/internal/route/rules/do.go index f5edfb3..978a9e9 100644 --- a/internal/route/rules/do.go +++ b/internal/route/rules/do.go @@ -30,7 +30,8 @@ const ( CommandSet = "set" CommandAdd = "add" CommandRemove = "remove" - CommandBypass = "bypass" + CommandPass = "pass" + CommandPassAlt = "bypass" ) var commands = map[string]struct { @@ -94,7 +95,7 @@ var commands = map[string]struct { }, validate: validateURL, build: func(args any) CommandHandler { - target := args.(types.URL).String() + target := args.(*types.URL).String() return ReturningCommand(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, http.StatusTemporaryRedirect) }) @@ -159,7 +160,7 @@ var commands = map[string]struct { }, validate: validateAbsoluteURL, build: func(args any) CommandHandler { - target := args.(types.URL) + target := args.(*types.URL) if target.Scheme == "" { target.Scheme = "http" } @@ -231,7 +232,7 @@ func (cmd *Command) Parse(v string) error { return err } - if directive == CommandBypass { + if directive == CommandPass || directive == CommandPassAlt { if len(args) != 0 { return ErrInvalidArguments.Subject(directive) } diff --git a/internal/route/rules/on_test.go b/internal/route/rules/on_test.go index fb7af45..a27fa1a 100644 --- a/internal/route/rules/on_test.go +++ b/internal/route/rules/on_test.go @@ -212,7 +212,7 @@ func TestOnCorrectness(t *testing.T) { }, { name: "basic_auth_correct", - checker: "basic_auth user " + string(E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))), + checker: "basic_auth user " + string(Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))), input: &http.Request{ Header: http.Header{ "Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:password"))}, // "user:password" @@ -222,7 +222,7 @@ func TestOnCorrectness(t *testing.T) { }, { name: "basic_auth_incorrect", - checker: "basic_auth user " + string(E.Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))), + checker: "basic_auth user " + string(Must(bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost))), input: &http.Request{ Header: http.Header{ "Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("user:incorrect"))}, // "user:wrong" diff --git a/internal/route/stream.go b/internal/route/stream.go index c94d946..c132aa7 100755 --- a/internal/route/stream.go +++ b/internal/route/stream.go @@ -10,8 +10,8 @@ import ( E "github.com/yusing/go-proxy/internal/error" "github.com/yusing/go-proxy/internal/logging" net "github.com/yusing/go-proxy/internal/net/types" - "github.com/yusing/go-proxy/internal/route/entry" "github.com/yusing/go-proxy/internal/route/routes" + route "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher/health" "github.com/yusing/go-proxy/internal/watcher/health/monitor" @@ -19,7 +19,7 @@ import ( // TODO: support stream load balance. type StreamRoute struct { - *entry.StreamEntry + *Route net.Stream `json:"-"` @@ -30,16 +30,13 @@ type StreamRoute struct { l zerolog.Logger } -func NewStreamRoute(entry *entry.StreamEntry) (impl, E.Error) { +func NewStreamRoute(base *Route) (route.Route, E.Error) { // TODO: support non-coherent scheme - if !entry.Scheme.IsCoherent() { - return nil, E.Errorf("unsupported scheme: %v -> %v", entry.Scheme.ListeningScheme, entry.Scheme.ProxyScheme) - } return &StreamRoute{ - StreamEntry: entry, + Route: base, l: logging.With(). - Str("type", string(entry.Scheme.ListeningScheme)). - Str("name", entry.TargetName()). + Str("type", string(base.Scheme)). + Str("name", base.TargetName()). Logger(), }, nil } @@ -50,10 +47,6 @@ func (r *StreamRoute) String() string { // Start implements task.TaskStarter. func (r *StreamRoute) Start(parent task.Parent) E.Error { - if entry.ShouldNotServe(r) { - return nil - } - r.task = parent.Subtask("stream." + r.TargetName()) r.Stream = NewStream(r) parent.OnCancel("finish", func() { @@ -61,25 +54,25 @@ func (r *StreamRoute) Start(parent task.Parent) E.Error { }) switch { - case entry.UseIdleWatcher(r): - waker, err := idlewatcher.NewStreamWaker(parent, r.StreamEntry, r.Stream) + case r.UseIdleWatcher(): + waker, err := idlewatcher.NewStreamWaker(parent, r, r.Stream) if err != nil { r.task.Finish(err) return err } r.Stream = waker r.HealthMon = waker - case entry.UseHealthCheck(r): - if entry.IsDocker(r) { - client, err := docker.ConnectClient(r.Idlewatcher.DockerHost) + case r.UseHealthCheck(): + if r.IsDocker() { + client, err := docker.ConnectClient(r.IdlewatcherConfig().DockerHost) if err == nil { - fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.Raw.HealthCheck) - r.HealthMon = monitor.NewDockerHealthMonitor(client, r.Idlewatcher.ContainerID, r.TargetName(), r.Raw.HealthCheck, fallback) + fallback := monitor.NewRawHealthChecker(r.TargetURL(), r.HealthCheck) + r.HealthMon = monitor.NewDockerHealthMonitor(client, r.IdlewatcherConfig().ContainerID, r.TargetName(), r.HealthCheck, fallback) r.task.OnCancel("close_docker_client", client.Close) } } if r.HealthMon == nil { - r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.Raw.HealthCheck) + r.HealthMon = monitor.NewRawHealthMonitor(r.TargetURL(), r.HealthCheck) } } @@ -88,9 +81,7 @@ func (r *StreamRoute) Start(parent task.Parent) E.Error { return E.From(err) } - r.l.Info(). - Int("port", int(r.Port.ListeningPort)). - Msg("listening") + r.l.Info().Int("port", r.Port.Listening).Msg("listening") if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { diff --git a/internal/route/stream_impl.go b/internal/route/stream_impl.go index 62321b1..316f6db 100644 --- a/internal/route/stream_impl.go +++ b/internal/route/stream_impl.go @@ -8,7 +8,6 @@ import ( "time" "github.com/yusing/go-proxy/internal/net/types" - T "github.com/yusing/go-proxy/internal/route/types" U "github.com/yusing/go-proxy/internal/utils" ) @@ -45,25 +44,25 @@ func (stream *Stream) Setup() error { ctx := stream.task.Context() - switch stream.Scheme.ListeningScheme { + switch stream.Scheme { case "tcp": - stream.targetAddr, err = net.ResolveTCPAddr("tcp", stream.URL.Host) + stream.targetAddr, err = net.ResolveTCPAddr("tcp", stream.ProxyURL.Host) if err != nil { return err } - tcpListener, err := lcfg.Listen(ctx, "tcp", stream.ListenURL.Host) + tcpListener, err := lcfg.Listen(ctx, "tcp", stream.LisURL.Host) if err != nil { return err } // in case ListeningPort was zero, get the actual port - stream.Port.ListeningPort = T.Port(tcpListener.Addr().(*net.TCPAddr).Port) + stream.Port.Listening = tcpListener.Addr().(*net.TCPAddr).Port stream.listener = types.NetListener(tcpListener) case "udp": - stream.targetAddr, err = net.ResolveUDPAddr("udp", stream.URL.Host) + stream.targetAddr, err = net.ResolveUDPAddr("udp", stream.ProxyURL.Host) if err != nil { return err } - udpListener, err := lcfg.ListenPacket(ctx, "udp", stream.ListenURL.Host) + udpListener, err := lcfg.ListenPacket(ctx, "udp", stream.LisURL.Host) if err != nil { return err } @@ -72,7 +71,7 @@ func (stream *Stream) Setup() error { udpListener.Close() return errors.New("udp listener is not *net.UDPConn") } - stream.Port.ListeningPort = T.Port(udpConn.LocalAddr().(*net.UDPAddr).Port) + stream.Port.Listening = udpConn.LocalAddr().(*net.UDPAddr).Port stream.listener = NewUDPForwarder(ctx, udpConn, stream.targetAddr) default: panic("should not reach here") diff --git a/internal/route/types/entry.go b/internal/route/types/entry.go deleted file mode 100644 index 27cb623..0000000 --- a/internal/route/types/entry.go +++ /dev/null @@ -1,13 +0,0 @@ -package types - -import ( - idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" - net "github.com/yusing/go-proxy/internal/net/types" -) - -type Entry interface { - TargetName() string - TargetURL() net.URL - RawEntry() *RawEntry - IdlewatcherConfig() *idlewatcher.Config -} diff --git a/internal/route/types/headers.go b/internal/route/types/headers.go deleted file mode 100644 index 2ec9ff8..0000000 --- a/internal/route/types/headers.go +++ /dev/null @@ -1,19 +0,0 @@ -package types - -import ( - "net/http" - - E "github.com/yusing/go-proxy/internal/error" - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -func ValidateHTTPHeaders(headers map[string]string) (http.Header, E.Error) { - h := make(http.Header) - for k, v := range headers { - vSplit := strutils.CommaSeperatedList(v) - for _, header := range vSplit { - h.Add(k, header) - } - } - return h, nil -} diff --git a/internal/route/types/http_config_test.go b/internal/route/types/http_config_test.go index bf8ccaf..69c324f 100644 --- a/internal/route/types/http_config_test.go +++ b/internal/route/types/http_config_test.go @@ -1,9 +1,11 @@ -package types +package types_test import ( "testing" "time" + . "github.com/yusing/go-proxy/internal/route" + "github.com/yusing/go-proxy/internal/route/types" "github.com/yusing/go-proxy/internal/utils" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -12,14 +14,14 @@ func TestHTTPConfigDeserialize(t *testing.T) { tests := []struct { name string input map[string]any - expected HTTPConfig + expected types.HTTPConfig }{ { name: "no_tls_verify", input: map[string]any{ "no_tls_verify": "true", }, - expected: HTTPConfig{ + expected: types.HTTPConfig{ NoTLSVerify: true, }, }, @@ -28,7 +30,7 @@ func TestHTTPConfigDeserialize(t *testing.T) { input: map[string]any{ "response_header_timeout": "1s", }, - expected: HTTPConfig{ + expected: types.HTTPConfig{ ResponseHeaderTimeout: 1 * time.Second, }, }, @@ -36,7 +38,7 @@ func TestHTTPConfigDeserialize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := RawEntry{} + cfg := Route{} err := utils.Deserialize(tt.input, &cfg) if err != nil { ExpectNoError(t, err) diff --git a/internal/route/types/port.go b/internal/route/types/port.go index dd433aa..89ce88f 100644 --- a/internal/route/types/port.go +++ b/internal/route/types/port.go @@ -7,37 +7,55 @@ import ( "github.com/yusing/go-proxy/internal/utils/strutils" ) -type Port int +type Port struct { + Listening int `json:"listening"` + Proxy int `json:"proxy"` +} -var ErrPortOutOfRange = E.New("port out of range") +var ( + ErrInvalidPortSyntax = E.New("invalid port syntax, expect [listening_port:]target_port") + ErrPortOutOfRange = E.New("port out of range") +) + +// Parse implements strutils.Parser. +func (p *Port) Parse(v string) (err error) { + parts := strutils.SplitRune(v, ':') + switch len(parts) { + case 1: + p.Listening = 0 + p.Proxy, err = strconv.Atoi(v) + case 2: + var err2 error + p.Listening, err = strconv.Atoi(parts[0]) + p.Proxy, err2 = strconv.Atoi(parts[1]) + err = E.Join(err, err2) + default: + return ErrInvalidPortSyntax.Subject(v) + } -func ValidatePort[String ~string](v String) (Port, error) { - p, err := strutils.Atoi(string(v)) if err != nil { - return ErrPort, err + return err } - return ValidatePortInt(p) -} -func ValidatePortInt[Int int | uint16](v Int) (Port, error) { - p := Port(v) - if !p.inBound() { - return ErrPort, ErrPortOutOfRange.Subject(strconv.Itoa(int(p))) + if p.Listening < MinPort || p.Listening > MaxPort { + return ErrPortOutOfRange.Subjectf("%d", p.Listening) } - return p, nil + + if p.Proxy < MinPort || p.Proxy > MaxPort { + return ErrPortOutOfRange.Subjectf("%d", p.Proxy) + } + + return nil } -func (p Port) inBound() bool { - return p >= MinPort && p <= MaxPort -} - -func (p Port) String() string { - return strconv.Itoa(int(p)) +func (p *Port) String() string { + if p.Listening == 0 { + return strconv.Itoa(p.Proxy) + } + return strconv.Itoa(p.Listening) + ":" + strconv.Itoa(p.Proxy) } const ( MinPort = 0 MaxPort = 65535 - ErrPort = Port(-1) - NoPort = Port(0) ) diff --git a/internal/route/types/port_test.go b/internal/route/types/port_test.go new file mode 100644 index 0000000..12ca517 --- /dev/null +++ b/internal/route/types/port_test.go @@ -0,0 +1,106 @@ +package types + +import ( + "errors" + "strconv" + "testing" +) + +var invalidPorts = []string{ + "", + "123:", + "0:", + ":1234", + "qwerty", + "asdfgh:asdfgh", + "1234:asdfgh", +} + +var tooManyColonsPorts = []string{ + "1234:1234:1234", +} + +var outOfRangePorts = []string{ + "-1:1234", + "1234:-1", + "65536", + "0:65536", +} + +func TestPortInvalid(t *testing.T) { + tests := []struct { + name string + inputs []string + wantErr error + }{ + { + name: "invalid", + inputs: invalidPorts, + wantErr: strconv.ErrSyntax, + }, + + { + name: "too many colons", + inputs: tooManyColonsPorts, + wantErr: ErrInvalidPortSyntax, + }, + { + name: "out of range", + inputs: outOfRangePorts, + wantErr: ErrPortOutOfRange, + }, + } + + for _, tc := range tests { + for _, input := range tc.inputs { + t.Run(tc.name, func(t *testing.T) { + p := &Port{} + err := p.Parse(input) + if !errors.Is(err, tc.wantErr) { + t.Errorf("expected error %v, got %v", tc.wantErr, err) + } + }) + } + } +} + +func TestPortValid(t *testing.T) { + tests := []struct { + name string + inputs string + expect Port + }{ + { + name: "valid_lp", + inputs: "1234:5678", + expect: Port{ + Listening: 1234, + Proxy: 5678, + }, + }, + { + name: "valid_p", + inputs: "5678", + expect: Port{ + Listening: 0, + Proxy: 5678, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &Port{} + err := p.Parse(tc.inputs) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if p.Listening != tc.expect.Listening { + t.Errorf("expected listening port %d, got %d", tc.expect.Listening, p.Listening) + } + if p.Proxy != tc.expect.Proxy { + t.Errorf("expected proxy port %d, got %d", tc.expect.Proxy, p.Proxy) + } + }) + } +} diff --git a/internal/route/types/raw_entry.go b/internal/route/types/raw_entry.go deleted file mode 100644 index b82bf5f..0000000 --- a/internal/route/types/raw_entry.go +++ /dev/null @@ -1,221 +0,0 @@ -//nolint:goconst -package types - -import ( - "strconv" - "strings" - - "github.com/docker/docker/api/types" - "github.com/yusing/go-proxy/internal/common" - "github.com/yusing/go-proxy/internal/docker" - "github.com/yusing/go-proxy/internal/homepage" - "github.com/yusing/go-proxy/internal/logging" - "github.com/yusing/go-proxy/internal/net/http/accesslog" - loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" - "github.com/yusing/go-proxy/internal/route/rules" - U "github.com/yusing/go-proxy/internal/utils" - F "github.com/yusing/go-proxy/internal/utils/functional" - "github.com/yusing/go-proxy/internal/utils/strutils" - "github.com/yusing/go-proxy/internal/watcher/health" -) - -type ( - RawEntry struct { - _ U.NoCopy - - // raw entry object before validation - // loaded from docker labels or yaml file - Alias string `json:"alias"` - Scheme string `json:"scheme,omitempty"` - Host string `json:"host,omitempty"` - Port string `json:"port,omitempty"` - - HTTPConfig - PathPatterns []string `json:"path_patterns,omitempty"` - Rules rules.Rules `json:"rules,omitempty" validate:"omitempty,unique=Name"` - HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` - LoadBalance *loadbalance.Config `json:"load_balance,omitempty"` - Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"` - Homepage *homepage.Item `json:"homepage,omitempty"` - AccessLog *accesslog.Config `json:"access_log,omitempty"` - - /* Docker only */ - Container *docker.Container `json:"container,omitempty"` - Provider string `json:"provider,omitempty"` - - finalized bool - } - - RawEntries = F.Map[string, *RawEntry] -) - -var NewProxyEntries = F.NewMapOf[string, *RawEntry] - -func (e *RawEntry) Finalize() { - if e.finalized { - return - } - - isDocker := e.Container != nil - cont := e.Container - if !isDocker { - cont = docker.DummyContainer - } - - if e.Host == "" { - switch { - case cont.PrivateIP != "": - e.Host = cont.PrivateIP - case cont.PublicIP != "": - e.Host = cont.PublicIP - case !isDocker: - e.Host = "localhost" - } - } - - lp, pp, extra := e.splitPorts() - - if port, ok := common.ServiceNamePortMapTCP[cont.ImageName]; ok { - if pp == "" { - pp = strconv.Itoa(port) - } - if e.Scheme == "" { - e.Scheme = "tcp" - } - } else if port, ok := common.ImageNamePortMap[cont.ImageName]; ok { - if pp == "" { - pp = strconv.Itoa(port) - } - if e.Scheme == "" { - e.Scheme = "http" - } - } else if pp == "" && e.Scheme == "https" { - pp = "443" - } else if pp == "" { - if p := lowestPort(cont.PrivatePortMapping); p != "" { - pp = p - } else if p := lowestPort(cont.PublicPortMapping); p != "" { - pp = p - } else if !isDocker { - pp = "80" - } else { - logging.Debug().Msg("no port found for " + e.Alias) - } - } - - // replace private port with public port if using public IP. - if e.Host == cont.PublicIP { - if p, ok := cont.PrivatePortMapping[pp]; ok { - pp = strutils.PortString(p.PublicPort) - } - } - // replace public port with private port if using private IP. - if e.Host == cont.PrivateIP { - if p, ok := cont.PublicPortMapping[pp]; ok { - pp = strutils.PortString(p.PrivatePort) - } - } - - if e.Scheme == "" && isDocker { - switch { - case e.Host == cont.PublicIP && cont.PublicPortMapping[pp].Type == "udp": - e.Scheme = "udp" - case e.Host == cont.PrivateIP && cont.PrivatePortMapping[pp].Type == "udp": - e.Scheme = "udp" - } - } - - if e.Scheme == "" { - switch { - case lp != "": - e.Scheme = "tcp" - case strings.HasSuffix(pp, "443"): - e.Scheme = "https" - default: // assume its http - e.Scheme = "http" - } - } - - if e.HealthCheck == nil { - e.HealthCheck = new(health.HealthCheckConfig) - } - - if e.HealthCheck.Disable { - e.HealthCheck = nil - } else { - if e.HealthCheck.Interval == 0 { - e.HealthCheck.Interval = common.HealthCheckIntervalDefault - } - if e.HealthCheck.Timeout == 0 { - e.HealthCheck.Timeout = common.HealthCheckTimeoutDefault - } - } - - if cont.IdleTimeout != "" { - if cont.WakeTimeout == "" { - cont.WakeTimeout = common.WakeTimeoutDefault - } - if cont.StopTimeout == "" { - cont.StopTimeout = common.StopTimeoutDefault - } - if cont.StopMethod == "" { - cont.StopMethod = common.StopMethodDefault - } - } - - e.Port = joinPorts(lp, pp, extra) - - if e.Port == "" || e.Host == "" { - if lp != "" { - e.Port = lp + ":0" - } else { - e.Port = "0" - } - } - - if e.Homepage.IsEmpty() { - e.Homepage = homepage.NewItem(e.Alias) - } - - e.finalized = true -} - -func (e *RawEntry) splitPorts() (lp string, pp string, extra string) { - portSplit := strutils.SplitRune(e.Port, ':') - if len(portSplit) == 1 { - pp = portSplit[0] - } else { - lp = portSplit[0] - pp = portSplit[1] - if len(portSplit) > 2 { - extra = strutils.JoinRune(portSplit[2:], ':') - } - } - return -} - -func joinPorts(lp string, pp string, extra string) string { - s := make([]string, 0, 3) - if lp != "" { - s = append(s, lp) - } - if pp != "" { - s = append(s, pp) - } - if extra != "" { - s = append(s, extra) - } - return strutils.JoinRune(s, ':') -} - -func lowestPort(ports map[string]types.Port) string { - var cmp uint16 - var res string - for port, v := range ports { - if v.PrivatePort < cmp || cmp == 0 { - cmp = v.PrivatePort - res = port - } - } - return res -} diff --git a/internal/route/types/route.go b/internal/route/types/route.go index b607e31..32fcda1 100644 --- a/internal/route/types/route.go +++ b/internal/route/types/route.go @@ -3,14 +3,39 @@ package types import ( "net/http" + "github.com/yusing/go-proxy/internal/docker" + idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types" + "github.com/yusing/go-proxy/internal/homepage" net "github.com/yusing/go-proxy/internal/net/types" + "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/watcher/health" + + loadbalance "github.com/yusing/go-proxy/internal/net/http/loadbalancer/types" ) type ( + //nolint:interfacebloat // this is for avoiding circular imports Route interface { - Entry + task.TaskStarter + task.TaskFinisher + ProviderName() string + TargetName() string + TargetURL() *net.URL HealthMonitor() health.HealthMonitor + + Started() bool + + IdlewatcherConfig() *idlewatcher.Config + HealthCheckConfig() *health.HealthCheckConfig + LoadBalanceConfig() *loadbalance.Config + HomepageConfig() *homepage.Item + ContainerInfo() *docker.Container + + IsDocker() bool + UseLoadBalance() bool + UseIdleWatcher() bool + UseHealthCheck() bool + UseAccessLog() bool } HTTPRoute interface { Route diff --git a/internal/route/types/route_type.go b/internal/route/types/route_type.go index f5357db..6d13d17 100644 --- a/internal/route/types/route_type.go +++ b/internal/route/types/route_type.go @@ -3,6 +3,6 @@ package types type RouteType string const ( - RouteTypeStream RouteType = "stream" - RouteTypeReverseProxy RouteType = "reverse_proxy" + RouteTypeStream RouteType = "stream" + RouteTypeHTTP RouteType = "http" ) diff --git a/internal/route/types/scheme.go b/internal/route/types/scheme.go index b2266c8..6830c6e 100644 --- a/internal/route/types/scheme.go +++ b/internal/route/types/scheme.go @@ -8,16 +8,22 @@ type Scheme string var ErrInvalidScheme = E.New("invalid scheme") -func NewScheme(s string) (Scheme, error) { +const ( + SchemeHTTP Scheme = "http" + SchemeHTTPS Scheme = "https" + SchemeTCP Scheme = "tcp" + SchemeUDP Scheme = "udp" + SchemeFileServer Scheme = "fileserver" +) + +func (s Scheme) Validate() E.Error { switch s { - case "http", "https", "tcp", "udp": - return Scheme(s), nil + case SchemeHTTP, SchemeHTTPS, + SchemeTCP, SchemeUDP, SchemeFileServer: + return nil } - return "", ErrInvalidScheme.Subject(s) + return ErrInvalidScheme.Subject(string(s)) } -func (s Scheme) IsHTTP() bool { return s == "http" } -func (s Scheme) IsHTTPS() bool { return s == "https" } -func (s Scheme) IsTCP() bool { return s == "tcp" } -func (s Scheme) IsUDP() bool { return s == "udp" } -func (s Scheme) IsStream() bool { return s.IsTCP() || s.IsUDP() } +func (s Scheme) IsReverseProxy() bool { return s == SchemeHTTP || s == SchemeHTTPS } +func (s Scheme) IsStream() bool { return s == SchemeTCP || s == SchemeUDP } diff --git a/internal/route/types/stream_port.go b/internal/route/types/stream_port.go deleted file mode 100644 index 9cb6ae9..0000000 --- a/internal/route/types/stream_port.go +++ /dev/null @@ -1,34 +0,0 @@ -package types - -import ( - E "github.com/yusing/go-proxy/internal/error" - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -type StreamPort struct { - ListeningPort Port `json:"listening"` - ProxyPort Port `json:"proxy"` -} - -var ErrStreamPortTooManyColons = E.New("too many colons") - -func ValidateStreamPort(p string) (StreamPort, error) { - split := strutils.SplitRune(p, ':') - - switch len(split) { - case 1: - split = []string{"0", split[0]} - case 2: - break - default: - return StreamPort{}, ErrStreamPortTooManyColons.Subject(p) - } - - listeningPort, lErr := ValidatePort(split[0]) - proxyPort, pErr := ValidatePort(split[1]) - if err := E.Join(lErr, pErr); err != nil { - return StreamPort{}, err - } - - return StreamPort{listeningPort, proxyPort}, nil -} diff --git a/internal/route/types/stream_port_test.go b/internal/route/types/stream_port_test.go deleted file mode 100644 index 6154749..0000000 --- a/internal/route/types/stream_port_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package types - -import ( - "strconv" - "testing" - - . "github.com/yusing/go-proxy/internal/utils/testing" -) - -var validPorts = []string{ - "1234:5678", - "0:2345", - "2345", -} - -var invalidPorts = []string{ - "", - "123:", - "0:", - ":1234", - "qwerty", - "asdfgh:asdfgh", - "1234:asdfgh", -} - -var outOfRangePorts = []string{ - "-1:1234", - "1234:-1", - "65536", - "0:65536", -} - -var tooManyColonsPorts = []string{ - "1234:1234:1234", -} - -func TestStreamPort(t *testing.T) { - for _, port := range validPorts { - _, err := ValidateStreamPort(port) - ExpectNoError(t, err) - } - for _, port := range invalidPorts { - _, err := ValidateStreamPort(port) - ExpectError2(t, port, strconv.ErrSyntax, err) - } - for _, port := range outOfRangePorts { - _, err := ValidateStreamPort(port) - ExpectError2(t, port, ErrPortOutOfRange, err) - } - for _, port := range tooManyColonsPorts { - _, err := ValidateStreamPort(port) - ExpectError2(t, port, ErrStreamPortTooManyColons, err) - } -} diff --git a/internal/route/types/stream_scheme.go b/internal/route/types/stream_scheme.go deleted file mode 100644 index 6f37161..0000000 --- a/internal/route/types/stream_scheme.go +++ /dev/null @@ -1,42 +0,0 @@ -package types - -import ( - E "github.com/yusing/go-proxy/internal/error" - "github.com/yusing/go-proxy/internal/utils/strutils" -) - -type StreamScheme struct { - ListeningScheme Scheme `json:"listening"` - ProxyScheme Scheme `json:"proxy"` -} - -func ValidateStreamScheme(s string) (*StreamScheme, error) { - ss := &StreamScheme{} - parts := strutils.SplitRune(s, ':') - if len(parts) == 1 { - parts = []string{s, s} - } else if len(parts) != 2 { - return nil, ErrInvalidScheme.Subject(s) - } - - var lErr, pErr error - ss.ListeningScheme, lErr = NewScheme(parts[0]) - ss.ProxyScheme, pErr = NewScheme(parts[1]) - - if err := E.Join(lErr, pErr); err != nil { - return nil, err - } - - return ss, nil -} - -func (s StreamScheme) String() string { - return string(s.ListeningScheme) + " -> " + string(s.ProxyScheme) -} - -// IsCoherent checks if the ListeningScheme and ProxyScheme of the StreamScheme are equal. -// -// It returns a boolean value indicating whether the ListeningScheme and ProxyScheme are equal. -func (s StreamScheme) IsCoherent() bool { - return s.ListeningScheme == s.ProxyScheme -} diff --git a/internal/route/types/stream_scheme_test.go b/internal/route/types/stream_scheme_test.go deleted file mode 100644 index 43f8010..0000000 --- a/internal/route/types/stream_scheme_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package types - -import ( - "testing" - - . "github.com/yusing/go-proxy/internal/utils/testing" -) - -var ( - validStreamSchemes = []string{ - "tcp:tcp", - "tcp:udp", - "udp:tcp", - "udp:udp", - "tcp", - "udp", - } - - invalidStreamSchemes = []string{ - "tcp:tcp:", - "tcp:", - ":udp:", - ":udp", - "top", - } -) - -func TestNewStreamScheme(t *testing.T) { - for _, s := range validStreamSchemes { - _, err := ValidateStreamScheme(s) - ExpectNoError(t, err) - } - for _, s := range invalidStreamSchemes { - _, err := ValidateStreamScheme(s) - ExpectError(t, ErrInvalidScheme, err) - } -} diff --git a/internal/utils/serialization.go b/internal/utils/serialization.go index 548ee5f..6830efd 100644 --- a/internal/utils/serialization.go +++ b/internal/utils/serialization.go @@ -31,6 +31,13 @@ var ( ErrUnknownField = E.New("unknown field") ) +var ( + tagDeserialize = "deserialize" // `deserialize:"-"` to exclude from deserialization + tagJSON = "json" // share between Deserialize and json.Marshal + tagValidate = "validate" // uses go-playground/validator + tagAliases = "aliases" // declare aliases for fields +) + var mapUnmarshalerType = reflect.TypeFor[MapUnmarshaller]() var defaultValues = functional.NewMapOf[reflect.Type, func() any]() @@ -67,6 +74,9 @@ func extractFields(t reflect.Type) (all, anonymous []reflect.StructField) { if !field.IsExported() { continue } + if field.Tag.Get(tagDeserialize) == "-" { + continue + } if field.Anonymous { f1, f2 := extractFields(field.Type) fields = append(fields, f1...) @@ -97,6 +107,33 @@ func ValidateWithFieldTags(s any) E.Error { return errs.Error() } +func ValidateWithCustomValidator(v reflect.Value) E.Error { + isStruct := false + for { + switch v.Kind() { + case reflect.Pointer, reflect.Interface: + if v.IsNil() { + return E.Errorf("validate: v is %w", ErrNilValue) + } + if validate, ok := v.Interface().(CustomValidator); ok { + return validate.Validate() + } + if isStruct { + return nil + } + v = v.Elem() + case reflect.Struct: + if !v.CanAddr() { + return nil + } + v = v.Addr() + isStruct = true + default: + return nil + } + } +} + func dive(dst reflect.Value) (v reflect.Value, t reflect.Type, err E.Error) { dstT := dst.Type() for { @@ -186,7 +223,7 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) { } for _, field := range fields { var key string - if jsonTag, ok := field.Tag.Lookup("json"); ok { + if jsonTag, ok := field.Tag.Lookup(tagJSON); ok { if jsonTag == "-" { continue } @@ -198,10 +235,10 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) { mapping[key] = dstV.FieldByName(field.Name) if !hasValidateTag { - _, hasValidateTag = field.Tag.Lookup("validate") + _, hasValidateTag = field.Tag.Lookup(tagValidate) } - aliases, ok := field.Tag.Lookup("aliases") + aliases, ok := field.Tag.Lookup(tagAliases) if ok { for _, alias := range strutils.CommaSeperatedList(aliases) { mapping[alias] = dstV.FieldByName(field.Name) @@ -220,34 +257,28 @@ func Deserialize(src SerializedObject, dst any) (err E.Error) { } if hasValidateTag { errs.Add(ValidateWithFieldTags(dstV.Interface())) - } else { - if dstV.CanAddr() { - dstV = dstV.Addr() - } - if validator, ok := dstV.Interface().(CustomValidator); ok { - errs.Add(validator.Validate()) - } + } + if err := ValidateWithCustomValidator(dstV); err != nil { + errs.Add(err) } return errs.Error() case reflect.Map: - if dstV.IsNil() { - dstV.Set(reflect.MakeMap(dstT)) - } - for k := range src { + for k, v := range src { mapVT := dstT.Elem() tmp := New(mapVT).Elem() - err := Convert(reflect.ValueOf(src[k]), tmp) - if err == nil { - dstV.SetMapIndex(reflect.ValueOf(k), tmp) - } else { + err := Convert(reflect.ValueOf(v), tmp) + if err != nil { errs.Add(err.Subject(k)) + continue + } + if err := ValidateWithCustomValidator(tmp.Addr()); err != nil { + errs.Add(err.Subject(k)) + } else { + dstV.SetMapIndex(reflect.ValueOf(k), tmp) } } - if dstV.CanAddr() { - dstV = dstV.Addr() - } - if validator, ok := dstV.Interface().(CustomValidator); ok { - errs.Add(validator.Validate()) + if err := ValidateWithCustomValidator(dstV); err != nil { + errs.Add(err) } return errs.Error() default: diff --git a/internal/utils/testing/testing.go b/internal/utils/testing/testing.go index b95ba7d..fcd9e2e 100644 --- a/internal/utils/testing/testing.go +++ b/internal/utils/testing/testing.go @@ -16,7 +16,10 @@ func init() { } } -func IgnoreError[Result any](r Result, _ error) Result { +func Must[Result any](r Result, err error) Result { + if err != nil { + panic(err) + } return r } diff --git a/internal/watcher/health/config.go b/internal/watcher/health/config.go index 4896e3e..f81e118 100644 --- a/internal/watcher/health/config.go +++ b/internal/watcher/health/config.go @@ -2,6 +2,8 @@ package health import ( "time" + + "github.com/yusing/go-proxy/internal/common" ) type HealthCheckConfig struct { @@ -11,3 +13,8 @@ type HealthCheckConfig struct { Interval time.Duration `json:"interval" validate:"omitempty,min=1s"` Timeout time.Duration `json:"timeout" validate:"omitempty,min=1s"` } + +var DefaultHealthConfig = &HealthCheckConfig{ + Interval: common.HealthCheckIntervalDefault, + Timeout: common.HealthCheckTimeoutDefault, +} diff --git a/internal/watcher/health/monitor/fileserver.go b/internal/watcher/health/monitor/fileserver.go new file mode 100644 index 0000000..e62e392 --- /dev/null +++ b/internal/watcher/health/monitor/fileserver.go @@ -0,0 +1,36 @@ +package monitor + +import ( + "os" + "time" + + "github.com/yusing/go-proxy/internal/watcher/health" +) + +type FileServerHealthMonitor struct { + *monitor + path string +} + +func NewFileServerHealthMonitor(alias string, config *health.HealthCheckConfig, path string) *FileServerHealthMonitor { + mon := &FileServerHealthMonitor{path: path} + mon.monitor = newMonitor(nil, config, mon.CheckHealth) + mon.service = alias + return mon +} + +func (s *FileServerHealthMonitor) CheckHealth() (*health.HealthCheckResult, error) { + start := time.Now() + _, err := os.Stat(s.path) + + detail := "" + if err != nil { + detail = err.Error() + } + + return &health.HealthCheckResult{ + Healthy: err == nil, + Latency: time.Since(start), + Detail: detail, + }, nil +} diff --git a/internal/watcher/health/monitor/http.go b/internal/watcher/health/monitor/http.go index 5b33629..cfd5645 100644 --- a/internal/watcher/health/monitor/http.go +++ b/internal/watcher/health/monitor/http.go @@ -26,7 +26,7 @@ var pinger = &http.Client{ }, } -func NewHTTPHealthMonitor(url types.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { +func NewHTTPHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *HTTPHealthMonitor { mon := new(HTTPHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) if config.UseGet { @@ -37,7 +37,7 @@ func NewHTTPHealthMonitor(url types.URL, config *health.HealthCheckConfig) *HTTP return mon } -func NewHTTPHealthChecker(url types.URL, config *health.HealthCheckConfig) health.HealthChecker { +func NewHTTPHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker { return NewHTTPHealthMonitor(url, config) } diff --git a/internal/watcher/health/monitor/json.go b/internal/watcher/health/monitor/json.go index b43dc9f..1ebdca5 100644 --- a/internal/watcher/health/monitor/json.go +++ b/internal/watcher/health/monitor/json.go @@ -5,7 +5,7 @@ import ( "strconv" "time" - "github.com/yusing/go-proxy/internal/net/types" + net "github.com/yusing/go-proxy/internal/net/types" "github.com/yusing/go-proxy/internal/utils/strutils" "github.com/yusing/go-proxy/internal/watcher/health" ) @@ -19,7 +19,7 @@ type JSONRepresentation struct { Latency time.Duration LastSeen time.Time Detail string - URL types.URL + URL *net.URL Extra map[string]any } diff --git a/internal/watcher/health/monitor/monitor.go b/internal/watcher/health/monitor/monitor.go index be6aae6..32a2fd0 100644 --- a/internal/watcher/health/monitor/monitor.go +++ b/internal/watcher/health/monitor/monitor.go @@ -23,7 +23,7 @@ type ( monitor struct { service string config *health.HealthCheckConfig - url atomic.Value[types.URL] + url atomic.Value[*types.URL] status atomic.Value[health.Status] lastResult *health.HealthCheckResult @@ -39,7 +39,7 @@ type ( var ErrNegativeInterval = errors.New("negative interval") -func newMonitor(url types.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { +func newMonitor(url *types.URL, config *health.HealthCheckConfig, healthCheckFunc HealthCheckFunc) *monitor { mon := &monitor{ config: config, checkHealth: healthCheckFunc, @@ -118,12 +118,12 @@ func (mon *monitor) Finish(reason any) { } // UpdateURL implements HealthChecker. -func (mon *monitor) UpdateURL(url types.URL) { +func (mon *monitor) UpdateURL(url *types.URL) { mon.url.Store(url) } // URL implements HealthChecker. -func (mon *monitor) URL() types.URL { +func (mon *monitor) URL() *types.URL { return mon.url.Load() } @@ -205,7 +205,7 @@ func (mon *monitor) checkUpdateHealth() error { if !result.Healthy { extras.Add("Last Seen", strutils.FormatLastSeen(GetLastSeen(mon.service))) } - if !mon.url.Load().Nil() { + if mon.url.Load() != nil { extras.Add("Service URL", mon.url.Load().String()) } if result.Detail != "" { diff --git a/internal/watcher/health/monitor/raw.go b/internal/watcher/health/monitor/raw.go index af3381c..490cd22 100644 --- a/internal/watcher/health/monitor/raw.go +++ b/internal/watcher/health/monitor/raw.go @@ -15,7 +15,7 @@ type ( } ) -func NewRawHealthMonitor(url types.URL, config *health.HealthCheckConfig) *RawHealthMonitor { +func NewRawHealthMonitor(url *types.URL, config *health.HealthCheckConfig) *RawHealthMonitor { mon := new(RawHealthMonitor) mon.monitor = newMonitor(url, config, mon.CheckHealth) mon.dialer = &net.Dialer{ @@ -25,7 +25,7 @@ func NewRawHealthMonitor(url types.URL, config *health.HealthCheckConfig) *RawHe return mon } -func NewRawHealthChecker(url types.URL, config *health.HealthCheckConfig) health.HealthChecker { +func NewRawHealthChecker(url *types.URL, config *health.HealthCheckConfig) health.HealthChecker { return NewRawHealthMonitor(url, config) } diff --git a/internal/watcher/health/health_checker.go b/internal/watcher/health/types.go similarity index 92% rename from internal/watcher/health/health_checker.go rename to internal/watcher/health/types.go index 0bc0414..3ced2c0 100644 --- a/internal/watcher/health/health_checker.go +++ b/internal/watcher/health/types.go @@ -30,8 +30,8 @@ type ( } HealthChecker interface { CheckHealth() (result *HealthCheckResult, err error) - URL() types.URL + URL() *types.URL Config() *HealthCheckConfig - UpdateURL(url types.URL) + UpdateURL(url *types.URL) } ) diff --git a/package.json b/package.json index 3280652..d63a037 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "godoxy-schemas", - "version": "0.9.1-1", + "version": "0.9.2-2", "description": "JSON Schema and typescript types for GoDoxy configuration", "license": "MIT", "repository": { diff --git a/schemas/config.schema.json b/schemas/config.schema.json index ce88e23..52bb265 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -1 +1 @@ -{"$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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"key_path":{"type":"string"},"options":{"additionalProperties":false,"properties":{"client_id":{"type":"string"},"email":{"$ref":"#/definitions/Email"},"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"},"DomainName":{"additionalProperties":false,"pattern":"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$","properties":{},"type":"object"},"DomainOrWildcard":{"additionalProperties":false,"pattern":"^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$","properties":{},"type":"object"},"DuckDNSOptions":{"additionalProperties":false,"properties":{"cert_path":{"type":"string"},"domains":{"items":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"Email":{"format":"email","type":"string"},"GotifyConfig":{"additionalProperties":false,"properties":{"name":{"type":"string"},"provider":{"const":"gotify","type":"string"},"token":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","token","url"],"type":"object"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LocalOptions":{"additionalProperties":false,"properties":{"cert_path":{"type":"string"},"key_path":{"type":"string"},"options":{"properties":{},"type":"object"},"provider":{"const":"local","type":"string"}},"required":["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","cloudflareRealIp","cloudflare_real_ip"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"use":{"enum":["ModifyRequest","Request","modifyRequest","modify_request","request"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"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":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"},"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":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"}]},"NtfyConfig":{"additionalProperties":false,"properties":{"name":{"type":"string"},"provider":{"const":"ntfy","type":"string"},"style":{"type":"string"},"token":{"type":"string"},"topic":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","topic","url"],"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"}]},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"},"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":{"$ref":"#/definitions/WebhookTemplate","default":"discord","description":"Webhook template"},"token":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","url"],"type":"object"},"WebhookMethod":{"enum":["GET","POST","PUT"],"type":"string"},"WebhookMimeType":{"enum":["application/json","application/x-www-form-urlencoded","text/markdown","text/plain"],"type":"string"},"WebhookTemplate":{"enum":["","discord"],"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":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"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"}},"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":{"$ref":"#/definitions/DomainName"},"minItems":1,"type":"array"},"providers":{"additionalProperties":false,"properties":{"docker":{"additionalProperties":{"anyOf":[{"format":"uri","type":"string"},{"const":"$DOCKER_HOST","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"}],"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/NtfyConfig"},{"$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"} \ No newline at end of file +{"$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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"key_path":{"type":"string"},"options":{"additionalProperties":false,"properties":{"client_id":{"type":"string"},"email":{"$ref":"#/definitions/Email"},"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"},"DomainName":{"additionalProperties":false,"pattern":"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$","properties":{},"type":"object"},"DomainOrWildcard":{"additionalProperties":false,"pattern":"^(\\*\\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$","properties":{},"type":"object"},"DuckDNSOptions":{"additionalProperties":false,"properties":{"cert_path":{"type":"string"},"domains":{"items":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"Email":{"format":"email","type":"string"},"GotifyConfig":{"additionalProperties":false,"properties":{"name":{"type":"string"},"provider":{"const":"gotify","type":"string"},"token":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","token","url"],"type":"object"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LocalOptions":{"additionalProperties":false,"properties":{"cert_path":{"type":"string"},"key_path":{"type":"string"},"options":{"properties":{},"type":"object"},"provider":{"const":"local","type":"string"}},"required":["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","cloudflareRealIp","cloudflare_real_ip"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"use":{"enum":["ModifyRequest","Request","modifyRequest","modify_request","request"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"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":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"},"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":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"}]},"NtfyConfig":{"additionalProperties":false,"properties":{"name":{"type":"string"},"provider":{"const":"ntfy","type":"string"},"style":{"type":"string"},"token":{"type":"string"},"topic":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","topic","url"],"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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":{"$ref":"#/definitions/DomainOrWildcard"},"type":"array"},"email":{"$ref":"#/definitions/Email"},"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"}]},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"},"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":{"$ref":"#/definitions/WebhookTemplate","default":"discord","description":"Webhook template"},"token":{"type":"string"},"url":{"$ref":"#/definitions/URL"}},"required":["name","provider","url"],"type":"object"},"WebhookMethod":{"enum":["GET","POST","PUT"],"type":"string"},"WebhookMimeType":{"enum":["application/json","application/x-www-form-urlencoded","text/markdown","text/plain"],"type":"string"},"WebhookTemplate":{"enum":["","discord"],"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":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"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"}},"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":{"$ref":"#/definitions/DomainName"},"minItems":1,"type":"array"},"providers":{"additionalProperties":false,"properties":{"docker":{"additionalProperties":{"anyOf":[{"format":"uri","type":"string"},{"const":"$DOCKER_HOST","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"}],"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/NtfyConfig"},{"$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"} \ No newline at end of file diff --git a/schemas/docker_routes.schema.json b/schemas/docker_routes.schema.json index 9e33545..d4e29e1 100644 --- a/schemas/docker_routes.schema.json +++ b/schemas/docker_routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port","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"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"idle_timeout":{"$ref":"#/definitions/Duration"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"idle_timeout":{"$ref":"#/definitions/Duration"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"},"start_endpoint":{"$ref":"#/definitions/URI"},"stop_method":{"$ref":"#/definitions/StopMethod","default":"stop","description":"Stop method"},"stop_signal":{"$ref":"#/definitions/Signal"},"stop_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Stop timeout"},"wake_timeout":{"$ref":"#/definitions/Duration","default":"30s","description":"Wake timeout"}},"required":["port"],"type":"object"}]},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Signal":{"enum":["","HUP","INT","QUIT","SIGHUP","SIGINT","SIGQUIT","SIGTERM","TERM"],"type":"string"},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StopMethod":{"enum":["kill","pause","stop"],"type":"string"},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file diff --git a/schemas/middleware_compose.schema.json b/schemas/middleware_compose.schema.json index 0f5a903..8a03e7e 100644 --- a/schemas/middleware_compose.schema.json +++ b/schemas/middleware_compose.schema.json @@ -1 +1 @@ -{"$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"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-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","cloudflareRealIp","cloudflare_real_ip"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"use":{"enum":["ModifyRequest","Request","modifyRequest","modify_request","request"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"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":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"},"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":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]}},"items":{"$ref":"#/definitions/MiddlewareComposeMap"},"type":"array"} \ No newline at end of file +{"$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"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-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","cloudflareRealIp","cloudflare_real_ip"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"use":{"enum":["ModifyRequest","Request","modifyRequest","modify_request","request"],"type":"string"}},"required":["use"],"type":"object"},{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"},"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":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"},"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":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]}},"items":{"$ref":"#/definitions/MiddlewareComposeMap"},"type":"array"} \ No newline at end of file diff --git a/schemas/middlewares/middlewares.d.ts b/schemas/middlewares/middlewares.d.ts index eac1840..bda0de9 100644 --- a/schemas/middlewares/middlewares.d.ts +++ b/schemas/middlewares/middlewares.d.ts @@ -65,6 +65,8 @@ export type ModifyRequest = { }; /** Hide HTTP headers */ hide_headers?: types.HTTPHeader[]; + /** Add prefix to request URL */ + add_prefix?: string; }; export type ModifyResponse = { use: "response" | "Response" | "modify_response" | "modifyResponse" | "ModifyResponse"; diff --git a/schemas/middlewares/middlewares.ts b/schemas/middlewares/middlewares.ts index 8454ba8..cae78fa 100644 --- a/schemas/middlewares/middlewares.ts +++ b/schemas/middlewares/middlewares.ts @@ -117,6 +117,8 @@ export type ModifyRequest = { add_headers?: { [key: types.HTTPHeader]: string }; /** Hide HTTP headers */ hide_headers?: types.HTTPHeader[]; + /** Add prefix to request URL */ + add_prefix?: string; }; export type ModifyResponse = { diff --git a/schemas/providers/routes.d.ts b/schemas/providers/routes.d.ts index 5bf0c6c..1a5564b 100644 --- a/schemas/providers/routes.d.ts +++ b/schemas/providers/routes.d.ts @@ -9,7 +9,7 @@ export declare const PROXY_SCHEMES: readonly ["http", "https"]; export declare const STREAM_SCHEMES: readonly ["tcp", "udp"]; export type ProxyScheme = (typeof PROXY_SCHEMES)[number]; export type StreamScheme = (typeof STREAM_SCHEMES)[number]; -export type Route = ReverseProxyRoute | StreamRoute; +export type Route = ReverseProxyRoute | FileServerRoute | StreamRoute; export type Routes = { [key: string]: Route; }; @@ -65,6 +65,31 @@ export type ReverseProxyRoute = { */ access_log?: AccessLogConfig; }; +export type FileServerRoute = { + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + scheme: "fileserver"; + root: string; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; +}; export type StreamRoute = { /** Alias (subdomain or FDN) * @minLength 1 @@ -74,7 +99,7 @@ export type StreamRoute = { * * @default tcp */ - scheme: StreamScheme; + scheme?: StreamScheme; /** Stream host * * @default localhost diff --git a/schemas/providers/routes.ts b/schemas/providers/routes.ts index 26abb08..86d4375 100644 --- a/schemas/providers/routes.ts +++ b/schemas/providers/routes.ts @@ -11,7 +11,7 @@ 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 Route = ReverseProxyRoute | FileServerRoute | StreamRoute; export type Routes = { [key: string]: Route; }; @@ -69,6 +69,33 @@ export type ReverseProxyRoute = { access_log?: AccessLogConfig; }; +export type FileServerRoute = { + /** Alias (subdomain or FDN) + * @minLength 1 + */ + alias?: string; + scheme: "fileserver"; + /* File server root path */ + root: string; + /** Path patterns (only patterns that match will be proxied). + * + * See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux + */ + path_patterns?: PathPattern[]; + /** Middlewares */ + middlewares?: MiddlewaresMap; + /** Homepage config + * + * @examples require(".").homepageExamples + */ + homepage?: HomepageConfig; + /** Access log config + * + * @examples require(".").accessLogExamples + */ + access_log?: AccessLogConfig; +} + export type StreamRoute = { /** Alias (subdomain or FDN) * @minLength 1 @@ -78,7 +105,7 @@ export type StreamRoute = { * * @default tcp */ - scheme: StreamScheme; + scheme?: StreamScheme; /** Stream host * * @default localhost diff --git a/schemas/routes.schema.json b/schemas/routes.schema.json index a3349d1..cf9104d 100644 --- a/schemas/routes.schema.json +++ b/schemas/routes.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port","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"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":{"$ref":"#/definitions/Route"},"definitions":{"AccessLogFieldMode":{"enum":["drop","keep","redact"],"type":"string"},"AccessLogFormat":{"enum":["combined","common","json"],"type":"string"},"CIDR":{"anyOf":[{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*$","type":"string"},{"pattern":"^.*:.*:.*:.*:.*:.*:.*:.*$","type":"string"},{"pattern":"^[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*/[0-9]*$","type":"string"},{"pattern":"^::[0-9]*$","type":"string"},{"pattern":"^.*::/[0-9]*$","type":"string"},{"pattern":"^.*:.*::/[0-9]*$","type":"string"}]},"Duration":{"pattern":"^([0-9]+(ms|s|m|h))+$","type":"string"},"HTTPHeader":{"description":"HTTP Header","pattern":"^[a-zA-Z0-9\\-]+$","type":"string"},"LoadBalanceConfig":{"anyOf":[{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"round_robin","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"least_conn","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["link","mode"],"type":"object"},{"additionalProperties":false,"properties":{"config":{"additionalProperties":false,"description":"Real IP config, header to get client IP from","properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"},"use":{"enum":["RealIP","realIP","real_ip"],"type":"string"}},"required":["from","use"],"type":"object"},"link":{"description":"Alias (subdomain or FDN) of load-balancer","minLength":1,"type":"string"},"mode":{"const":"ip_hash","type":"string"},"weight":{"description":"Load-balance weight (reserved for future use)","maximum":100,"minimum":0,"type":"number"}},"required":["config","link","mode"],"type":"object"}]},"MiddlewaresMap":{"anyOf":[{"additionalProperties":false,"properties":{"CIDRWhitelist":{"$ref":"#/definitions/Omit"},"CloudflareRealIP":{"$ref":"#/definitions/Omit"},"CustomErrorPage":{"$ref":"#/definitions/Omit"},"ErrorPage":{"$ref":"#/definitions/Omit"},"HideXForwarded":{"$ref":"#/definitions/Omit"},"ModifyRequest":{"$ref":"#/definitions/Omit"},"ModifyResponse":{"$ref":"#/definitions/Omit"},"OIDC":{"$ref":"#/definitions/Omit"},"RateLimit":{"$ref":"#/definitions/Omit"},"RealIP":{"$ref":"#/definitions/Omit"},"RedirectHTTP":{"$ref":"#/definitions/Omit"},"Request":{"$ref":"#/definitions/Omit"},"Response":{"$ref":"#/definitions/Omit"},"SetXForwarded":{"$ref":"#/definitions/Omit"},"cidrWhitelist":{"$ref":"#/definitions/Omit"},"cidr_whitelist":{"$ref":"#/definitions/Omit"},"cloudflareRealIp":{"$ref":"#/definitions/Omit"},"cloudflare_real_ip":{"$ref":"#/definitions/Omit"},"customErrorPage":{"$ref":"#/definitions/Omit"},"custom_error_page":{"$ref":"#/definitions/Omit"},"errorPage":{"$ref":"#/definitions/Omit"},"error_page":{"$ref":"#/definitions/Omit"},"hideXForwarded":{"$ref":"#/definitions/Omit"},"hide_x_forwarded":{"$ref":"#/definitions/Omit"},"modifyRequest":{"$ref":"#/definitions/Omit"},"modifyResponse":{"$ref":"#/definitions/Omit"},"modify_request":{"$ref":"#/definitions/Omit"},"modify_response":{"$ref":"#/definitions/Omit"},"oidc":{"$ref":"#/definitions/Omit"},"rateLimit":{"$ref":"#/definitions/Omit"},"rate_limit":{"$ref":"#/definitions/Omit"},"realIP":{"$ref":"#/definitions/Omit"},"real_ip":{"$ref":"#/definitions/Omit"},"redirectHTTP":{"$ref":"#/definitions/Omit"},"redirect_http":{"$ref":"#/definitions/Omit"},"request":{"$ref":"#/definitions/Omit"},"response":{"$ref":"#/definitions/Omit"},"setXForwarded":{"$ref":"#/definitions/Omit"},"set_x_forwarded":{"$ref":"#/definitions/Omit"}},"type":"object"},{"type":"object"}]},"Omit":{"additionalProperties":false,"properties":{"allow":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"message":{"default":"IP not allowed","description":"Error message when blocked","type":"string"},"status":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked (alias of status_code)"},"status_code":{"$ref":"#/definitions/StatusCode","default":403,"description":"HTTP status code when blocked"}},"required":["allow"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"recursive":{"default":false,"description":"Recursively resolve the IP","type":"boolean"}},"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"add_prefix":{"description":"Add prefix to request URL","type":"string"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"add_headers":{"additionalProperties":false,"description":"Add HTTP headers","items":{"type":"string"},"type":"array"},"hide_headers":{"description":"Hide HTTP headers","items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"},"set_headers":{"additionalProperties":false,"description":"Set HTTP headers","items":{"type":"string"},"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"allowed_groups":{"description":"Allowed groups","items":{"type":"string"},"minItems":1,"type":"array"},"allowed_users":{"description":"Allowed users","items":{"type":"string"},"minItems":1,"type":"array"}},"type":"object"},"Omit":{"additionalProperties":false,"properties":{"average":{"description":"Average number of requests allowed in a period","type":"number"},"burst":{"description":"Maximum number of requests allowed in a period","type":"number"},"period":{"$ref":"#/definitions/Duration","default":"1s","description":"Duration of the rate limit"}},"required":["average","burst"],"type":"object"},"Omit":{"additionalProperties":false,"properties":{"from":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"},"header":{"$ref":"#/definitions/HTTPHeader","default":"X-Real-IP","description":"Header to get the client IP from"},"recursive":{"default":false,"description":"Recursive resolve the IP","type":"boolean"}},"required":["from"],"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"Omit":{"additionalProperties":false,"type":"object"},"PathPattern":{"pattern":"^(?:([A-Z]+) )?(?:([a-zA-Z0-9.-]+)\\\\/)?(\\\\/[^\\\\s]*)$","type":"string"},"Port":{"maximum":65535,"minimum":0,"type":"integer"},"ProxyScheme":{"enum":["http","https"],"type":"string"},"Route":{"anyOf":[{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Proxy host"},"load_balance":{"$ref":"#/definitions/LoadBalanceConfig","description":"Load balance config"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"no_tls_verify":{"default":false,"description":"Skip TLS verification","type":"boolean"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"port":{"$ref":"#/definitions/Port","default":80,"description":"Proxy port"},"response_header_timeout":{"$ref":"#/definitions/Duration","default":"60s","description":"Response header timeout"},"scheme":{"$ref":"#/definitions/ProxyScheme","default":"http","description":"Proxy scheme"}},"type":"object"},{"additionalProperties":false,"properties":{"access_log":{"additionalProperties":false,"description":"Access log config","examples":[{"fields":{"headers":{"config":{"foo":"redact"},"default":"keep"}},"filters":{"status_codes":{"values":["200-299"]}},"format":"combined","path":"/var/log/access.log"}],"properties":{"buffer_size":{"default":65536,"description":"The size of the buffer.","minimum":0,"type":"integer"},"fields":{"additionalProperties":false,"properties":{"cookie":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"header":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"},"query":{"additionalProperties":false,"properties":{"config":{"additionalProperties":{"enum":["drop","keep","redact"],"type":"string"},"type":"object"},"default":{"$ref":"#/definitions/AccessLogFieldMode"}},"required":["config"],"type":"object"}},"type":"object"},"filters":{"additionalProperties":false,"properties":{"cidr":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/CIDR"},"type":"array"}},"required":["values"],"type":"object"},"headers":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/HTTPHeader"},"type":"array"}},"required":["values"],"type":"object"},"host":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"method":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"enum":["CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","TRACE"],"type":"string"},"type":"array"}},"required":["values"],"type":"object"},"status_code":{"additionalProperties":false,"properties":{"negative":{"default":false,"description":"Whether the filter is negative.","type":"boolean"},"values":{"items":{"$ref":"#/definitions/StatusCodeRange"},"type":"array"}},"required":["values"],"type":"object"}},"type":"object"},"format":{"$ref":"#/definitions/AccessLogFormat","default":"combined","description":"The format of the access log."},"path":{"$ref":"#/definitions/URI"}},"required":["path"],"type":"object"},"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"homepage":{"additionalProperties":false,"description":"Homepage config","examples":[{"category":"Arr suite","icon":"png/sonarr.png","name":"Sonarr"},{"icon":"@target/favicon.ico","name":"App"}],"properties":{"category":{"type":"string"},"description":{"type":"string"},"icon":{"anyOf":[{"format":"uri","type":"string"},{"description":"Walkxcode icon","pattern":"^(png|svg|webp)\\/[\\w\\d\\-_]+\\.\\1","type":"string"},{"pattern":"^@selfhst/.*\\..*$","type":"string"},{"pattern":"^@walkxcode/.*\\..*$","type":"string"},{"pattern":"^@target/.*$","type":"string"},{"pattern":"^/.*$","type":"string"}]},"name":{"type":"string"},"show":{"default":true,"description":"Whether show in dashboard","type":"boolean"},"url":{"$ref":"#/definitions/URL"},"widget_config":{"additionalProperties":{},"type":"object"}},"type":"object"},"middlewares":{"$ref":"#/definitions/MiddlewaresMap","description":"Middlewares"},"path_patterns":{"description":"Path patterns (only patterns that match will be proxied).\n\nSee https://pkg.go.dev/net/http#hdr-Patterns-ServeMux","items":{"$ref":"#/definitions/PathPattern"},"type":"array"},"root":{"type":"string"},"scheme":{"const":"fileserver","type":"string"}},"required":["root","scheme"],"type":"object"},{"additionalProperties":false,"properties":{"alias":{"description":"Alias (subdomain or FDN)","minLength":1,"type":"string"},"healthcheck":{"additionalProperties":false,"description":"Healthcheck config","properties":{"disable":{"default":false,"description":"Disable healthcheck","type":"boolean"},"interval":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck interval"},"path":{"$ref":"#/definitions/URI","default":"/","description":"Healthcheck path"},"timeout":{"$ref":"#/definitions/Duration","default":"5s","description":"Healthcheck timeout"},"use_get":{"default":false,"description":"Use GET instead of HEAD","type":"boolean"}},"type":"object"},"host":{"anyOf":[{"format":"hostname","type":"string"},{"format":"ipv4","type":"string"},{"format":"ipv6","type":"string"}],"default":"localhost","description":"Stream host"},"port":{"$ref":"#/definitions/StreamPort"},"scheme":{"$ref":"#/definitions/StreamScheme","default":"tcp","description":"Stream scheme"}},"required":["port"],"type":"object"}]},"StatusCode":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"type":"number"}]},"StatusCodeRange":{"anyOf":[{"pattern":"^[0-9]*$","type":"string"},{"pattern":"^[0-9]*-[0-9]*$","type":"string"},{"type":"number"}]},"StreamPort":{"pattern":"^\\d+:\\d+$","type":"string"},"StreamScheme":{"enum":["tcp","udp"],"type":"string"},"URI":{"format":"uri-reference","type":"string"},"URL":{"format":"uri","type":"string"}},"type":"object"} \ No newline at end of file