diff --git a/internal/entrypoint/README.md b/internal/entrypoint/README.md index ba09d271..7cc10787 100644 --- a/internal/entrypoint/README.md +++ b/internal/entrypoint/README.md @@ -11,6 +11,7 @@ The entrypoint package implements the primary HTTP handler that receives all inc - Domain-based route lookup with subdomain support - Short link (`go/` domain) handling - Middleware chain application +- Route-specific promotion of middleware overlays into matching entrypoint middleware - Access logging for all requests - Configurable not-found handling - Per-domain route resolution @@ -102,6 +103,45 @@ type Config struct { } ``` +### Entrypoint middleware bypass overlays + +For HTTP routes, the entrypoint can compile a route-specific effective middleware chain when a route contributes a local middleware entry whose name matches an existing entrypoint middleware and whose options include `bypass`. + +Behavior is intentionally narrow: + +- only `bypass` is promoted in v1 +- promotion is **append-only** +- the entrypoint middleware must already exist +- if no matching entrypoint middleware exists, route-local behavior stays unchanged +- when the route-local middleware entry is bypass-only, it is consumed after promotion so the same middleware is not evaluated twice + +Example: + +```yaml +entrypoint: + middlewares: + - use: oidc + +routes: + app: + middlewares: + oidc: + bypass: + - path glob("/public/*") +``` + +This behaves as if the entrypoint middleware for that route had: + +```yaml +entrypoint: + middlewares: + - use: oidc + bypass: + - route app & path glob("/public/*") +``` + +Pre-existing entrypoint bypass rules remain active; route bypass rules are added on top. + `InboundMTLSProfile` references a named root-level inbound mTLS profile and enables Go's built-in client-certificate verification (`tls.RequireAndVerifyClientCert`) for all HTTPS traffic on the entrypoint. - When configured, route-level inbound mTLS overrides are not supported. diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 0119de2b..cdf58652 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -2,6 +2,7 @@ package entrypoint import ( "crypto/x509" + "maps" "net/http" "strings" "sync/atomic" @@ -132,14 +133,27 @@ func (ep *Entrypoint) SetFindRouteDomains(domains []string) { func (ep *Entrypoint) SetMiddlewares(mws []map[string]any) error { if len(mws) == 0 { ep.middleware = nil + ep.cfg.Middlewares = nil + for _, srv := range ep.servers.Range { + srv.resetRouteEntrypointOverlays() + } return nil } + tmpMiddlewares := make([]map[string]any, len(mws)) + for i, mw := range mws { + tmpMiddlewares[i] = maps.Clone(mw) + } + mid, err := middleware.BuildMiddlewareFromChainRaw("entrypoint", mws) if err != nil { return err } ep.middleware = mid + ep.cfg.Middlewares = tmpMiddlewares + for _, srv := range ep.servers.Range { + srv.resetRouteEntrypointOverlays() + } log.Debug().Msg("entrypoint middleware loaded") return nil diff --git a/internal/entrypoint/entrypoint_overlay_test.go b/internal/entrypoint/entrypoint_overlay_test.go new file mode 100644 index 00000000..8d5a5a9d --- /dev/null +++ b/internal/entrypoint/entrypoint_overlay_test.go @@ -0,0 +1,70 @@ +package entrypoint + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/types" +) + +func TestSetMiddlewaresInvalidatesRouteOverlayCache(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + srv := newTestHTTPServer(t, ep) + route := newFakeHTTPRoute(t, "test-route", "") + route.routeMiddlewares = map[string]types.LabelMap{ + "redirectHTTP": { + "bypass": "- path /health\n", + }, + } + route.handler = func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNoContent) + } + srv.AddRoute(route) + + require.NoError(t, ep.SetMiddlewares([]map[string]any{{ + "use": "redirectHTTP", + }})) + + first := httptest.NewRecorder() + srv.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "http://test-route/private", nil)) + require.Equal(t, http.StatusPermanentRedirect, first.Code) + + require.NoError(t, ep.SetMiddlewares([]map[string]any{{ + "use": "response", + "set_headers": map[string]string{ + "X-Overlay-Reloaded": "true", + }, + }})) + + second := httptest.NewRecorder() + srv.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "http://test-route/private", nil)) + require.Equal(t, http.StatusNoContent, second.Code) + require.Equal(t, "true", second.Header().Get("X-Overlay-Reloaded")) +} + +func TestServeHTTPHidesEntrypointOverlayCompilationErrors(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + srv := newTestHTTPServer(t, ep) + route := newFakeHTTPRoute(t, "test-route", "") + route.routeMiddlewares = map[string]types.LabelMap{ + "redirectHTTP": { + "bypass": "not-a-valid-bypass", + }, + } + route.handler = func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNoContent) + } + srv.AddRoute(route) + + require.NoError(t, ep.SetMiddlewares([]map[string]any{{ + "use": "redirectHTTP", + }})) + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://test-route/", nil)) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.Equal(t, "internal server error\n", rec.Body.String()) +} diff --git a/internal/entrypoint/http_server.go b/internal/entrypoint/http_server.go index d614b318..5bc4d92d 100644 --- a/internal/entrypoint/http_server.go +++ b/internal/entrypoint/http_server.go @@ -6,7 +6,9 @@ import ( "net" "net/http" "strings" + "sync/atomic" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog/log" acl "github.com/yusing/godoxy/internal/acl/types" autocert "github.com/yusing/godoxy/internal/autocert/types" @@ -36,6 +38,20 @@ type httpServer struct { addr string routes *pool.Pool[types.HTTPRoute] + + routeEntrypointOverlays atomic.Pointer[xsync.Map[string, *routeEntrypointOverlay]] +} + +type routeEntrypointOverlay struct { + middleware *middleware.Middleware + consumedBypass map[string]struct{} + consumedMiddlewares map[string]struct{} +} + +var errNoRouteEntrypointOverlay = errors.New("no route entrypoint overlay") + +func newRouteEntrypointOverlayMap() *xsync.Map[string, *routeEntrypointOverlay] { + return xsync.NewMap[string, *routeEntrypointOverlay]() } type HTTPProto string @@ -50,7 +66,9 @@ func NewHTTPServer(ep *Entrypoint) HTTPServer { } func newHTTPServer(ep *Entrypoint) *httpServer { - return &httpServer{ep: ep} + srv := &httpServer{ep: ep} + srv.resetRouteEntrypointOverlays() + return srv } // Listen starts the server and stop when entrypoint is stopped. @@ -102,10 +120,12 @@ func (srv *httpServer) Close() { func (srv *httpServer) AddRoute(route types.HTTPRoute) { srv.routes.Add(route) + srv.routeEntrypointOverlayMap().Delete(route.Key()) } func (srv *httpServer) DelRoute(route types.HTTPRoute) { srv.routes.Del(route) + srv.routeEntrypointOverlayMap().Delete(route.Key()) } func (srv *httpServer) FindRoute(s string) types.HTTPRoute { @@ -135,10 +155,32 @@ func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { return case route != nil: r = routes.WithRouteContext(r, route) - if srv.ep.middleware != nil { - srv.ep.middleware.ServeHTTP(route.ServeHTTP, w, r) + entrypointMiddleware := srv.ep.middleware + next := route.ServeHTTP + if entrypointMiddleware != nil { + overlay, err := srv.getRouteEntrypointOverlay(route) + if err != nil && !errors.Is(err, errNoRouteEntrypointOverlay) { + log.Err(err).Str("route", route.Name()).Msg("failed to compile route-specific entrypoint middleware") + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if overlay != nil { + entrypointMiddleware = overlay.middleware + if len(overlay.consumedBypass) > 0 || len(overlay.consumedMiddlewares) > 0 { + next = func(w http.ResponseWriter, req *http.Request) { + route.ServeHTTP(w, middleware.WithConsumedRouteOverlays( + req, + overlay.consumedBypass, + overlay.consumedMiddlewares, + )) + } + } + } + } + if entrypointMiddleware != nil { + entrypointMiddleware.ServeHTTP(next, w, r) } else { - route.ServeHTTP(w, r) + next(w, r) } case srv.tryHandleShortLink(w, r): return @@ -149,6 +191,71 @@ func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (srv *httpServer) getRouteEntrypointOverlay(route types.HTTPRoute) (*routeEntrypointOverlay, error) { + if srv.ep.middleware == nil || len(srv.ep.cfg.Middlewares) == 0 { + return nil, errNoRouteEntrypointOverlay + } + overlays := srv.routeEntrypointOverlayMap() + var buildErr error + overlay, _ := overlays.LoadOrCompute(route.Key(), func() (*routeEntrypointOverlay, bool) { + computed, err := srv.compileRouteEntrypointOverlay(route) + if err != nil { + buildErr = err + return nil, true + } + return computed, false + }) + if buildErr != nil { + return nil, buildErr + } + if overlay.middleware == nil { + return nil, errNoRouteEntrypointOverlay + } + return overlay, nil +} + +func (srv *httpServer) routeEntrypointOverlayMap() *xsync.Map[string, *routeEntrypointOverlay] { + overlays := srv.routeEntrypointOverlays.Load() + if overlays != nil { + return overlays + } + overlays = newRouteEntrypointOverlayMap() + if srv.routeEntrypointOverlays.CompareAndSwap(nil, overlays) { + return overlays + } + return srv.routeEntrypointOverlays.Load() +} + +func (srv *httpServer) resetRouteEntrypointOverlays() { + srv.routeEntrypointOverlays.Store(newRouteEntrypointOverlayMap()) +} + +func (srv *httpServer) compileRouteEntrypointOverlay(route types.HTTPRoute) (*routeEntrypointOverlay, error) { + routeMiddlewareMap := route.RouteMiddlewares() + if len(routeMiddlewareMap) == 0 { + return &routeEntrypointOverlay{}, nil + } + + compiled, err := middleware.BuildEntrypointRouteOverlay( + "entrypoint", + srv.ep.cfg.Middlewares, + route.Name(), + routeMiddlewareMap, + ) + if err != nil { + if errors.Is(err, middleware.ErrNoEntrypointRouteOverlay) { + return &routeEntrypointOverlay{}, nil + } + return nil, err + } + + return &routeEntrypointOverlay{ + middleware: compiled.Middleware, + consumedBypass: compiled.ConsumedBypass, + consumedMiddlewares: compiled.ConsumedMiddlewares, + }, nil +} + func (srv *httpServer) resolveRequestRoute(req *http.Request) (types.HTTPRoute, error) { hostRoute := srv.FindRoute(req.Host) // Skip per-route mTLS resolution if no TLS or a global mTLS profile is configured diff --git a/internal/entrypoint/inbound_mtls_test.go b/internal/entrypoint/inbound_mtls_test.go index d3bc5eb5..8cb309a5 100644 --- a/internal/entrypoint/inbound_mtls_test.go +++ b/internal/entrypoint/inbound_mtls_test.go @@ -31,6 +31,8 @@ type fakeHTTPRoute struct { name string inboundMTLSProfile string listenURL *nettypes.URL + routeMiddlewares map[string]types.LabelMap + handler http.HandlerFunc task *task.Task } @@ -88,10 +90,13 @@ func (r *fakeHTTPRoute) UseLoadBalance() bool { return false } func (r *fakeHTTPRoute) UseIdleWatcher() bool { return false } func (r *fakeHTTPRoute) UseHealthCheck() bool { return false } func (r *fakeHTTPRoute) UseAccessLog() bool { return false } -func (r *fakeHTTPRoute) ServeHTTP(http.ResponseWriter, *http.Request) { - // no-op: test stub +func (r *fakeHTTPRoute) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if r.handler != nil { + r.handler(w, req) + } } -func (r *fakeHTTPRoute) InboundMTLSProfileRef() string { return r.inboundMTLSProfile } +func (r *fakeHTTPRoute) InboundMTLSProfileRef() string { return r.inboundMTLSProfile } +func (r *fakeHTTPRoute) RouteMiddlewares() map[string]types.LabelMap { return r.routeMiddlewares } func newTestHTTPServer(t *testing.T, ep *Entrypoint) *httpServer { t.Helper() @@ -106,6 +111,7 @@ func newTestHTTPServer(t *testing.T, ep *Entrypoint) *httpServer { addr: common.ProxyHTTPAddr, routes: pool.New[types.HTTPRoute]("test-http-routes", "test-http-routes"), } + srv.resetRouteEntrypointOverlays() ep.servers.Store(common.ProxyHTTPAddr, srv) return srv } diff --git a/internal/net/gphttp/middleware/README.md b/internal/net/gphttp/middleware/README.md index 6ed57ed7..39d689e7 100644 --- a/internal/net/gphttp/middleware/README.md +++ b/internal/net/gphttp/middleware/README.md @@ -11,6 +11,7 @@ This package implements a flexible HTTP middleware system for GoDoxy. Middleware - **Middleware Chaining**: Compose multiple middleware in priority order - **YAML Composition**: Define middleware chains in configuration files - **Bypass Rules**: Skip middleware based on request properties +- **Entrypoint Overlay Promotion**: Promote route-local middleware entries with `bypass` into matching entrypoint middleware for HTTP routes - **Dynamic Loading**: Load middleware definitions from files at runtime Response body rewriting is only applied to unencoded, text-like content types (for example `text/*`, JSON, YAML, XML). Response status and headers can always be modified. @@ -140,6 +141,42 @@ type Bypass []rules.RuleOn func (b Bypass) ShouldBypass(w http.ResponseWriter, r *http.Request) bool ``` +For HTTP routes, any route-local middleware entry that sets `bypass` and matches an existing entrypoint middleware name contributes an overlay: its bypass rules are promoted into the effective entrypoint middleware for that route. + +Semantics: + +- route-local middleware entries may be promoted when they include `bypass`; only the bypass portion is promoted in v1 +- promoted rules are qualified as `route & ` +- existing entrypoint bypass rules are preserved and the route rules are appended +- if the route-local middleware entry is **bypass-only**, it is consumed so the same middleware is not evaluated twice +- if the route-local middleware entry contains additional options, only the bypass portion is consumed; the rest of the route-local middleware still executes normally +- if no matching entrypoint middleware exists, route-local middleware behavior is unchanged + +Example: + +```yaml +entrypoint: + middlewares: + - use: oidc + +routes: + app: + middlewares: + oidc: + bypass: + - path glob("/public/*") +``` + +Effective behavior for route `app` is equivalent to: + +```yaml +entrypoint: + middlewares: + - use: oidc + bypass: + - route app & path glob("/public/*") +``` + ## Available Middleware | Name | Type | Description | @@ -247,6 +284,8 @@ if err != nil { } ``` +`PatchReverseProxy` still handles route-local middleware in the normal way. Entrypoint overlay promotion happens earlier, at entrypoint request dispatch time, where the server has both the resolved route and the raw entrypoint middleware definitions available. + ### Bypass Rules ```go diff --git a/internal/net/gphttp/middleware/bypass.go b/internal/net/gphttp/middleware/bypass.go index c598dff2..ce880601 100644 --- a/internal/net/gphttp/middleware/bypass.go +++ b/internal/net/gphttp/middleware/bypass.go @@ -82,6 +82,9 @@ func (c *checkBypass) shouldModReqBypass(w http.ResponseWriter, r *http.Request) return true } } + if isRouteBypassPromoted(r, c.name) { + return false + } return c.bypass.ShouldBypass(w, r) } @@ -99,6 +102,9 @@ func (c *checkBypass) shouldModResBypass(resp *http.Response) bool { return true } } + if isRouteBypassPromoted(resp.Request, c.name) { + return false + } return c.bypass.ShouldBypass(httputils.ResponseAsRW(resp), resp.Request) } @@ -106,6 +112,9 @@ func (c *checkBypass) shouldModResBypass(resp *http.Response) bool { // // Returns true if the request is not done, false otherwise. func (c *checkBypass) before(w http.ResponseWriter, r *http.Request) (proceedNext bool) { + if isRouteMiddlewareConsumed(r, c.name) { + return true + } if c.modReq == nil || c.shouldModReqBypass(w, r) { return true } @@ -115,6 +124,9 @@ func (c *checkBypass) before(w http.ResponseWriter, r *http.Request) (proceedNex // modifyResponse modifies the response if the response should be modified. func (c *checkBypass) modifyResponse(resp *http.Response) error { + if isRouteMiddlewareConsumed(resp.Request, c.name) { + return nil + } if c.modRes == nil || c.shouldModResBypass(resp) { return nil } diff --git a/internal/net/gphttp/middleware/bypass_test.go b/internal/net/gphttp/middleware/bypass_test.go index 631c55ff..d87c0c5b 100644 --- a/internal/net/gphttp/middleware/bypass_test.go +++ b/internal/net/gphttp/middleware/bypass_test.go @@ -10,10 +10,13 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/entrypoint" . "github.com/yusing/godoxy/internal/net/gphttp/middleware" "github.com/yusing/godoxy/internal/route" routeTypes "github.com/yusing/godoxy/internal/route/types" + "github.com/yusing/godoxy/internal/types" "github.com/yusing/goutils/http/reverseproxy" expect "github.com/yusing/goutils/testing" ) @@ -266,3 +269,150 @@ func TestEntrypointBypassRoute(t *testing.T) { expect.Equal(t, recorder.Body.String(), "test") expect.Equal(t, recorder.Header().Get("Test-Header"), "test-value") } + +func TestEntrypointPromotesRouteBypassOverlay(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("test")) + })) + defer srv.Close() + + targetURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + host, port, err := net.SplitHostPort(targetURL.Host) + require.NoError(t, err) + + portInt, err := strconv.Atoi(port) + require.NoError(t, err) + + entry := entrypoint.NewTestEntrypoint(t, nil) + _, err = route.NewStartedTestRoute(t, &route.Route{ + Alias: "test-route", + Scheme: routeTypes.SchemeHTTP, + Host: host, + Port: routeTypes.Port{ + Listening: 1000, + Proxy: portInt, + }, + Middlewares: map[string]types.LabelMap{ + "redirectHTTP": { + "bypass": ` +- path glob(/public/*) +`[1:], + }, + }, + }) + require.NoError(t, err) + + err = entry.SetMiddlewares([]map[string]any{ + { + "use": "redirectHTTP", + "bypass": []string{"path /health"}, + }, + }) + require.NoError(t, err) + + server, ok := entry.GetServer(":1000") + require.True(t, ok, "server not found") + + tests := []struct { + name string + path string + expectStatus int + expectBody string + expectLoc string + }{ + { + name: "existing_entrypoint_bypass_still_applies", + path: "/health", + expectStatus: http.StatusOK, + expectBody: "test", + }, + { + name: "route_bypass_is_promoted_to_entrypoint", + path: "/public/index.html", + expectStatus: http.StatusOK, + expectBody: "test", + }, + { + name: "non_matching_path_still_redirects", + path: "/private", + expectStatus: http.StatusPermanentRedirect, + expectLoc: "https://test-route.example.com/private", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://test-route.example.com"+test.path, nil) + server.ServeHTTP(recorder, req) + assert.Equal(t, test.expectStatus, recorder.Code) + if test.expectBody != "" { + assert.Equal(t, test.expectBody, recorder.Body.String()) + } + assert.Equal(t, test.expectLoc, recorder.Header().Get("Location")) + }) + } +} + +func TestRouteBypassWithoutMatchingEntrypointMiddlewareKeepsCurrentBehavior(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("test")) + })) + defer srv.Close() + + targetURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + host, port, err := net.SplitHostPort(targetURL.Host) + require.NoError(t, err) + + portInt, err := strconv.Atoi(port) + require.NoError(t, err) + + entry := entrypoint.NewTestEntrypoint(t, nil) + _, err = route.NewStartedTestRoute(t, &route.Route{ + Alias: "test-route", + Scheme: routeTypes.SchemeHTTP, + Host: host, + Port: routeTypes.Port{ + Listening: 1000, + Proxy: portInt, + }, + Middlewares: map[string]types.LabelMap{ + "redirectHTTP": { + "bypass": ` +- path glob(/public/*) +`[1:], + }, + }, + }) + require.NoError(t, err) + + require.NoError(t, entry.SetMiddlewares([]map[string]any{{ + "use": "response", + "set_headers": map[string]string{ + "X-Entrypoint-Overlay": "true", + }, + }})) + + server, ok := entry.GetServer(":1000") + require.True(t, ok, "server not found") + + t.Run("bypass_still_works_without_matching_entrypoint_middleware", func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://test-route.example.com/public/index.html", nil) + server.ServeHTTP(recorder, req) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, "test", recorder.Body.String()) + }) + + t.Run("route_middleware_still_redirects_for_non_matching_paths", func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://test-route.example.com/private", nil) + server.ServeHTTP(recorder, req) + assert.Equal(t, http.StatusPermanentRedirect, recorder.Code) + assert.Equal(t, "https://test-route.example.com/private", recorder.Header().Get("Location")) + }) +} diff --git a/internal/net/gphttp/middleware/entrypoint_overlay.go b/internal/net/gphttp/middleware/entrypoint_overlay.go new file mode 100644 index 00000000..e1bac3e4 --- /dev/null +++ b/internal/net/gphttp/middleware/entrypoint_overlay.go @@ -0,0 +1,210 @@ +package middleware + +import ( + "errors" + "fmt" + "maps" + "slices" + + "github.com/yusing/godoxy/internal/route/rules" + "github.com/yusing/godoxy/internal/serialization" + strutils "github.com/yusing/goutils/strings" +) + +type EntrypointRouteOverlay struct { + Middleware *Middleware + ConsumedBypass map[string]struct{} + ConsumedMiddlewares map[string]struct{} +} + +type bypassOnlyField struct { + Bypass Bypass `json:"bypass"` +} + +var ErrNoEntrypointRouteOverlay = errors.New("no entrypoint route overlay") + +// BuildEntrypointRouteOverlay promotes route-level bypass rules into a copy of the entrypoint middleware +// chain. For each route middleware entry in routeMiddlewares that sets "bypass", it finds the entrypoint +// definition with the same "use" name (case-insensitive, snake-agnostic) and appends those rules after +// qualifying them with the route (each rule becomes "route & "). +// +// name is the logical chain name passed to [BuildMiddlewareFromChainRaw]. +// +// It returns [ErrNoEntrypointRouteOverlay] when entrypointDefs or routeMiddlewares is empty, or when no +// route bypass was merged into any entrypoint definition. On success, ConsumedBypass lists normalized +// middleware names whose bypass was applied; ConsumedMiddlewares lists names whose route options contained +// only "bypass", so downstream handling can treat those overlay-only route entries as fully satisfied. +// Route middleware entries with additional options still run at route scope after promotion. +// +// Errors wrap parse/merge failures for bypass values or route qualification. +func BuildEntrypointRouteOverlay( + name string, + entrypointDefs []map[string]any, + routeName string, + routeMiddlewares map[string]OptionsRaw, +) (*EntrypointRouteOverlay, error) { + if len(entrypointDefs) == 0 || len(routeMiddlewares) == 0 { + return nil, ErrNoEntrypointRouteOverlay + } + + effectiveDefs := cloneMiddlewareDefs(entrypointDefs) + var consumedBypass map[string]struct{} + var consumedMiddlewares map[string]struct{} + promotedAny := false + + for routeMiddlewareName, routeOpts := range routeMiddlewares { + promotedBypass, ok, err := buildPromotedRouteBypass(routeName, routeMiddlewareName, routeOpts) + if err != nil { + return nil, err + } + if !ok { + continue + } + + matched, err := mergePromotedBypassIntoEffectiveDefs(effectiveDefs, routeMiddlewareName, promotedBypass) + if err != nil { + return nil, err + } + if !matched { + continue + } + + promotedAny = true + consumedBypass, consumedMiddlewares = recordPromotedRouteOverlayConsumption( + consumedBypass, + consumedMiddlewares, + routeMiddlewareName, + routeOpts, + ) + } + + if !promotedAny { + return nil, ErrNoEntrypointRouteOverlay + } + + mid, err := BuildMiddlewareFromChainRaw(name, effectiveDefs) + if err != nil { + return nil, err + } + return &EntrypointRouteOverlay{ + Middleware: mid, + ConsumedBypass: consumedBypass, + ConsumedMiddlewares: consumedMiddlewares, + }, nil +} + +func buildPromotedRouteBypass(routeName, routeMiddlewareName string, routeOpts OptionsRaw) (Bypass, bool, error) { + routeBypass, ok, err := parseBypassValue(routeOpts["bypass"]) + if err != nil { + return nil, false, fmt.Errorf("route middleware %q bypass: %w", routeMiddlewareName, err) + } + if !ok || len(routeBypass) == 0 { + return nil, false, nil + } + + promotedBypass, err := qualifyBypassWithRoute(routeName, routeBypass) + if err != nil { + return nil, false, fmt.Errorf("route middleware %q bypass promotion: %w", routeMiddlewareName, err) + } + return promotedBypass, true, nil +} + +func mergePromotedBypassIntoEffectiveDefs(effectiveDefs []map[string]any, routeMiddlewareName string, promotedBypass Bypass) (bool, error) { + normalizedRouteMiddlewareName := strutils.ToLowerNoSnake(routeMiddlewareName) + matched := false + for i, def := range effectiveDefs { + use, _ := def["use"].(string) + if strutils.ToLowerNoSnake(use) != normalizedRouteMiddlewareName { + continue + } + + mergedBypass, err := appendBypassValue(def["bypass"], promotedBypass) + if err != nil { + return false, fmt.Errorf("entrypoint middleware %q bypass merge: %w", use, err) + } + + clonedDef := maps.Clone(def) + clonedDef["bypass"] = mergedBypass + effectiveDefs[i] = clonedDef + matched = true + } + return matched, nil +} + +func recordPromotedRouteOverlayConsumption( + consumedBypass map[string]struct{}, + consumedMiddlewares map[string]struct{}, + routeMiddlewareName string, + routeOpts OptionsRaw, +) (map[string]struct{}, map[string]struct{}) { + normalizedName := strutils.ToLowerNoSnake(routeMiddlewareName) + if consumedBypass == nil { + consumedBypass = make(map[string]struct{}) + } + consumedBypass[normalizedName] = struct{}{} + + if !isBypassOnlyOptions(routeOpts) { + return consumedBypass, consumedMiddlewares + } + if consumedMiddlewares == nil { + consumedMiddlewares = make(map[string]struct{}) + } + consumedMiddlewares[normalizedName] = struct{}{} + return consumedBypass, consumedMiddlewares +} + +func cloneMiddlewareDefs(defs []map[string]any) []map[string]any { + cloned := make([]map[string]any, len(defs)) + for i, def := range defs { + // Shallow clone is intentional: overlay promotion only replaces the top-level + // bypass field and leaves nested option values untouched. + cloned[i] = maps.Clone(def) + } + return cloned +} + +func appendBypassValue(existing any, promoted Bypass) (Bypass, error) { + current, ok, err := parseBypassValue(existing) + if err != nil { + return nil, err + } + if !ok { + return slices.Clone(promoted), nil + } + return append(slices.Clone(current), promoted...), nil +} + +func parseBypassValue(raw any) (Bypass, bool, error) { + if raw == nil { + return nil, false, nil + } + var dst bypassOnlyField + if err := serialization.MapUnmarshalValidate(map[string]any{"bypass": raw}, &dst); err != nil { + return nil, true, err + } + return dst.Bypass, true, nil +} + +func qualifyBypassWithRoute(routeName string, bypass Bypass) (Bypass, error) { + qualified := make(Bypass, len(bypass)) + for i, rule := range bypass { + var routeQualified rules.RuleOn + if err := routeQualified.Parse(fmt.Sprintf("route %s & %s", routeName, rule.String())); err != nil { + return nil, err + } + qualified[i] = routeQualified + } + return qualified, nil +} + +func isBypassOnlyOptions(opts OptionsRaw) bool { + if len(opts) == 0 { + return false + } + for key := range opts { + if strutils.ToLowerNoSnake(key) != "bypass" { + return false + } + } + return true +} diff --git a/internal/net/gphttp/middleware/entrypoint_overlay_test.go b/internal/net/gphttp/middleware/entrypoint_overlay_test.go new file mode 100644 index 00000000..c5fb8df2 --- /dev/null +++ b/internal/net/gphttp/middleware/entrypoint_overlay_test.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/route/routes" + "github.com/yusing/godoxy/internal/route/rules" +) + +func TestBuildEntrypointRouteOverlayReturnsSentinelWhenNoPromotionOccurs(t *testing.T) { + t.Run("no_matching_entrypoint_middleware", func(t *testing.T) { + overlay, err := BuildEntrypointRouteOverlay( + "entrypoint", + []map[string]any{{ + "use": "response", + }}, + "test-route", + map[string]OptionsRaw{ + "redirectHTTP": { + "bypass": []string{"path /health"}, + }, + }, + ) + + require.Nil(t, overlay) + require.ErrorIs(t, err, ErrNoEntrypointRouteOverlay) + }) + + t.Run("empty_route_middlewares", func(t *testing.T) { + overlay, err := BuildEntrypointRouteOverlay( + "entrypoint", + []map[string]any{{ + "use": "response", + }}, + "test-route", + nil, + ) + + require.Nil(t, overlay) + require.ErrorIs(t, err, ErrNoEntrypointRouteOverlay) + }) +} + +func TestBuildEntrypointRouteOverlayPromotesRouteBypass(t *testing.T) { + overlay, err := BuildEntrypointRouteOverlay( + "entrypoint", + []map[string]any{{ + "use": "redirectHTTP", + }}, + "test-route", + map[string]OptionsRaw{ + "redirectHTTP": { + "bypass": []string{"path /health"}, + }, + }, + ) + + require.NoError(t, err) + require.NotNil(t, overlay) + require.NotNil(t, overlay.Middleware) + require.Contains(t, overlay.ConsumedBypass, "redirecthttp") + require.Contains(t, overlay.ConsumedMiddlewares, "redirecthttp") +} + +func TestBuildEntrypointRouteOverlayKeepsNonBypassRouteMiddlewareActive(t *testing.T) { + overlay, err := BuildEntrypointRouteOverlay( + "entrypoint", + []map[string]any{{ + "use": "redirectHTTP", + }}, + "test-route", + map[string]OptionsRaw{ + "redirectHTTP": { + "bypass": []string{"path /health"}, + "redirectHTTP": "https://example.com", + }, + }, + ) + + require.NoError(t, err) + require.NotNil(t, overlay) + require.NotNil(t, overlay.Middleware) + require.Contains(t, overlay.ConsumedBypass, "redirecthttp") + require.Empty(t, overlay.ConsumedMiddlewares) +} + +func TestQualifyBypassWithRoutePreservesCompositeRuleSemantics(t *testing.T) { + var composite rules.RuleOn + require.NoError(t, composite.Parse("path /health | path /status")) + + qualified, err := qualifyBypassWithRoute("test-route", Bypass{composite}) + require.NoError(t, err) + require.Len(t, qualified, 1) + + matches := func(path, routeName string) bool { + req := httptest.NewRequest("GET", "http://example.com"+path, nil) + if routeName != "" { + req = routes.WithRouteContext(req, fakeMiddlewareHTTPRoute{name: routeName}) + } + return qualified[0].Check(httptest.NewRecorder(), req) + } + + require.True(t, matches("/health", "test-route")) + require.True(t, matches("/status", "test-route")) + require.False(t, matches("/health", "other-route")) + require.False(t, matches("/metrics", "test-route")) +} diff --git a/internal/net/gphttp/middleware/route_overlay_context.go b/internal/net/gphttp/middleware/route_overlay_context.go new file mode 100644 index 00000000..72ca19a5 --- /dev/null +++ b/internal/net/gphttp/middleware/route_overlay_context.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "context" + "net/http" + + strutils "github.com/yusing/goutils/strings" +) + +type routeOverlayConsumptionContextKey struct{} + +type routeOverlayConsumption struct { + bypass map[string]struct{} + middlewares map[string]struct{} +} + +var routeOverlayConsumptionKey routeOverlayConsumptionContextKey + +func WithConsumedRouteOverlays( + r *http.Request, + bypass map[string]struct{}, + middlewares map[string]struct{}, +) *http.Request { + if len(bypass) == 0 && len(middlewares) == 0 { + return r + } + return r.WithContext(context.WithValue(r.Context(), routeOverlayConsumptionKey, routeOverlayConsumption{ + bypass: bypass, + middlewares: middlewares, + })) +} + +func isRouteBypassPromoted(r *http.Request, middlewareName string) bool { + return routeOverlayConsumed(r, middlewareName, func(consumption routeOverlayConsumption) map[string]struct{} { + return consumption.bypass + }) +} + +func isRouteMiddlewareConsumed(r *http.Request, middlewareName string) bool { + return routeOverlayConsumed(r, middlewareName, func(consumption routeOverlayConsumption) map[string]struct{} { + return consumption.middlewares + }) +} + +func routeOverlayConsumed( + r *http.Request, + middlewareName string, + selectSet func(routeOverlayConsumption) map[string]struct{}, +) bool { + if r == nil { + return false + } + consumption, ok := r.Context().Value(routeOverlayConsumptionKey).(routeOverlayConsumption) + if !ok { + return false + } + _, ok = selectSet(consumption)[strutils.ToLowerNoSnake(middlewareName)] + return ok +} diff --git a/internal/net/gphttp/middleware/route_overlay_context_test.go b/internal/net/gphttp/middleware/route_overlay_context_test.go new file mode 100644 index 00000000..1b36dd34 --- /dev/null +++ b/internal/net/gphttp/middleware/route_overlay_context_test.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + "github.com/yusing/godoxy/internal/agentpool" + "github.com/yusing/godoxy/internal/homepage" + nettypes "github.com/yusing/godoxy/internal/net/types" + "github.com/yusing/godoxy/internal/route/routes" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/task" +) + +func TestWithConsumedRouteOverlaysPreservesExistingRequestContext(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + req = routes.WithRouteContext(req, fakeMiddlewareHTTPRoute{name: "test-route"}) + + req = WithConsumedRouteOverlays(req, map[string]struct{}{ + "redirecthttp": {}, + }, map[string]struct{}{ + "oidc": {}, + }) + + require.Equal(t, "test-route", routes.TryGetUpstreamName(req)) + require.True(t, isRouteBypassPromoted(req, "redirectHTTP")) + require.True(t, isRouteMiddlewareConsumed(req, "oidc")) + require.False(t, isRouteBypassPromoted(req, "forwardauth")) + require.False(t, isRouteMiddlewareConsumed(req, "forwardauth")) +} + +func TestWithConsumedRouteOverlaysReturnsNewRequestWhenOverlayIsPresent(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + updated := WithConsumedRouteOverlays(req, map[string]struct{}{"redirecthttp": {}}, nil) + + require.NotEqual(t, req, updated) + require.True(t, isRouteBypassPromoted(updated, "redirectHTTP")) + require.False(t, isRouteBypassPromoted(req, "redirectHTTP")) +} + +type fakeMiddlewareHTTPRoute struct { + name string +} + +func (r fakeMiddlewareHTTPRoute) Key() string { return r.name } +func (r fakeMiddlewareHTTPRoute) Name() string { return r.name } +func (r fakeMiddlewareHTTPRoute) Start(task.Parent) error { return nil } +func (r fakeMiddlewareHTTPRoute) Task() *task.Task { return nil } +func (r fakeMiddlewareHTTPRoute) Finish(any) {} +func (r fakeMiddlewareHTTPRoute) MarshalZerologObject(*zerolog.Event) {} +func (r fakeMiddlewareHTTPRoute) ProviderName() string { return "" } +func (r fakeMiddlewareHTTPRoute) GetProvider() types.RouteProvider { return nil } +func (r fakeMiddlewareHTTPRoute) ListenURL() *nettypes.URL { return nil } +func (r fakeMiddlewareHTTPRoute) TargetURL() *nettypes.URL { return nil } +func (r fakeMiddlewareHTTPRoute) HealthMonitor() types.HealthMonitor { return nil } +func (r fakeMiddlewareHTTPRoute) SetHealthMonitor(types.HealthMonitor) {} +func (r fakeMiddlewareHTTPRoute) References() []string { return nil } +func (r fakeMiddlewareHTTPRoute) ShouldExclude() bool { return false } +func (r fakeMiddlewareHTTPRoute) Started() <-chan struct{} { return nil } +func (r fakeMiddlewareHTTPRoute) IdlewatcherConfig() *types.IdlewatcherConfig { return nil } +func (r fakeMiddlewareHTTPRoute) HealthCheckConfig() types.HealthCheckConfig { + return types.HealthCheckConfig{} +} +func (r fakeMiddlewareHTTPRoute) LoadBalanceConfig() *types.LoadBalancerConfig { + return nil +} +func (r fakeMiddlewareHTTPRoute) HomepageItem() homepage.Item { return homepage.Item{} } +func (r fakeMiddlewareHTTPRoute) DisplayName() string { return r.name } +func (r fakeMiddlewareHTTPRoute) ContainerInfo() *types.Container { return nil } +func (r fakeMiddlewareHTTPRoute) InboundMTLSProfileRef() string { return "" } +func (r fakeMiddlewareHTTPRoute) RouteMiddlewares() map[string]types.LabelMap { return nil } +func (r fakeMiddlewareHTTPRoute) GetAgent() *agentpool.Agent { return nil } +func (r fakeMiddlewareHTTPRoute) IsDocker() bool { return false } +func (r fakeMiddlewareHTTPRoute) IsAgent() bool { return false } +func (r fakeMiddlewareHTTPRoute) UseLoadBalance() bool { return false } +func (r fakeMiddlewareHTTPRoute) UseIdleWatcher() bool { return false } +func (r fakeMiddlewareHTTPRoute) UseHealthCheck() bool { return false } +func (r fakeMiddlewareHTTPRoute) UseAccessLog() bool { return false } +func (r fakeMiddlewareHTTPRoute) ServeHTTP(http.ResponseWriter, *http.Request) {} diff --git a/internal/route/README.md b/internal/route/README.md index 389b6ef0..b9b653b1 100644 --- a/internal/route/README.md +++ b/internal/route/README.md @@ -62,6 +62,17 @@ type Route struct { } ``` +`Middlewares` stores route-local raw middleware options, including Docker-label-derived maps such as: + +```yaml +middlewares: + oidc: + bypass: + - path glob("/public/*") +``` + +For HTTP routes, this map is also the source used by entrypoint overlay promotion when a route-local `bypass` targets a middleware already configured on the entrypoint. + `InboundMTLSProfile` references a named root-level inbound mTLS profile for this route. - It is only honored when no global `entrypoint.inbound_mtls_profile` is configured. diff --git a/internal/route/provider/README.md b/internal/route/provider/README.md index 56bcafd0..3d1c9241 100644 --- a/internal/route/provider/README.md +++ b/internal/route/provider/README.md @@ -196,6 +196,16 @@ labels: do: pass ``` +Route-local middleware bypass overlays use the existing per-route middleware label shape. For example, to add a bypass exception to an entrypoint-level `oidc` middleware for route `app1`: + +```yaml +labels: + proxy.app1.middlewares.oidc.bypass: | + - path glob("/public/*") +``` + +If the entrypoint already has `use: oidc`, the route-local bypass is promoted into the effective entrypoint middleware for `app1`. If the entrypoint does not have `oidc`, this remains a normal route-local middleware definition. + ### File Provider Configuration ```yaml diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index 50dc59f4..748bfe37 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -135,6 +135,58 @@ func TestApplyLabel(t *testing.T) { expect.Equal(t, a.HealthCheck.Interval, 10*time.Second) } +func TestApplyLabelParsesMiddlewareBypassOverlay(t *testing.T) { + entries := makeRoutes(&container.Summary{ + Names: dummyNames, + Labels: map[string]string{ + D.LabelAliases: "a", + "proxy.a.scheme": "http", + "proxy.a.port": "4567", + "proxy.a.middlewares.oidc.bypass": "\n- path glob(/public/*)\n- path /health"[1:], + }, + }) + + a, ok := entries["a"] + expect.True(t, ok) + expect.Equal(t, a.Middlewares, map[string]map[string]any{ + "oidc": { + "bypass": "- path glob(/public/*)\n- path /health", + }, + }) +} + +func TestApplyLabelWithMixedObjectAndFlatMiddlewareFields(t *testing.T) { + entries := makeRoutes(&container.Summary{ + Names: dummyNames, + State: "running", + Labels: map[string]string{ + "proxy.universal.port": "8080", + "proxy.universal.middlewares.oidc": "allowed_groups: [everyone]", + "proxy.universal.middlewares.oidc.bypass": "- path glob(/geheimenvan/*)", + "proxy.universal.middlewares.oidc.priority": "5", + }, + }) + + universal, ok := entries["universal"] + expect.True(t, ok) + expect.Equal(t, universal.Port.Proxy, 8080) + + oidc, ok := universal.Middlewares["oidc"] + expect.True(t, ok) + + allowedGroups, ok := oidc["allowed_groups"].([]any) + expect.True(t, ok) + expect.Equal(t, allowedGroups, []any{"everyone"}) + + bypass, ok := oidc["bypass"].(string) + expect.True(t, ok) + expect.Equal(t, bypass, "- path glob(/geheimenvan/*)") + + priority, ok := oidc["priority"].(string) + expect.True(t, ok) + expect.Equal(t, priority, "5") +} + func TestApplyLabelWithAlias(t *testing.T) { entries := makeRoutes(&container.Summary{ Names: dummyNames, diff --git a/internal/route/route.go b/internal/route/route.go index d34cf3e2..92f77dbc 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "net" "net/url" "os" @@ -134,6 +135,10 @@ func (r Routes) Contains(alias string) bool { return ok } +func (r *Route) RouteMiddlewares() map[string]types.LabelMap { + return maps.Clone(r.Middlewares) +} + func (r *Route) Validate() error { // wait for alias to be set if r.Alias == "" { diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 21eefc6f..d447fa0a 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -206,6 +206,21 @@ func TestRouteApplyingHealthCheckDefaults(t *testing.T) { require.Equal(t, 10*time.Second, hc.Timeout) } +func TestRouteMiddlewaresReturnsClone(t *testing.T) { + original := types.LabelMap{"bypass": []string{"path /health"}} + r := &Route{ + Middlewares: map[string]types.LabelMap{ + "redirectHTTP": original, + }, + } + + got := r.RouteMiddlewares() + require.Equal(t, r.Middlewares, got) + + delete(got, "redirectHTTP") + require.Contains(t, r.Middlewares, "redirectHTTP") +} + func TestRouteBindField(t *testing.T) { t.Run("TCPSchemeWithCustomBind", func(t *testing.T) { r := &Route{ diff --git a/internal/types/routes.go b/internal/types/routes.go index ca934d0a..f12d5b63 100644 --- a/internal/types/routes.go +++ b/internal/types/routes.go @@ -38,6 +38,8 @@ type ( DisplayName() string ContainerInfo() *Container InboundMTLSProfileRef() string + // RouteMiddlewares returns a copy of the route middlewares + RouteMiddlewares() map[string]LabelMap GetAgent() *agentpool.Agent