diff --git a/config.example.yml b/config.example.yml index a1eec288..8e7047c6 100644 --- a/config.example.yml +++ b/config.example.yml @@ -19,6 +19,17 @@ # 3. other providers, see https://docs.godoxy.dev/DNS-01-Providers +# Inbound mTLS profiles (optional) +# +# Reusable named profiles for inbound HTTPS client-certificate validation. +# A profile must trust either the system CA store, one or more CA files, or both. +# +# inbound_mtls_profiles: +# corp: +# use_system_cas: true +# ca_files: +# - /app/certs/corp-ca.pem + # Access Control # When enabled, it will be applied globally at connection level, # all incoming connections (web, tcp and udp) will be checked against the ACL rules. @@ -149,6 +160,11 @@ providers: # secret: aaaa-bbbb-cccc-dddd # no_tls_verify: true +# To relay the downstream client address to a TCP upstream, set +# `relay_proxy_protocol_header: true` on that specific TCP route in route +# configuration (for example, see providers.example.yml). UDP relay is not +# supported yet. + # Match domains # See https://docs.godoxy.dev/Certificates-and-domain-matching # diff --git a/goutils b/goutils index 635feb30..8090ca8a 160000 --- a/goutils +++ b/goutils @@ -1 +1 @@ -Subproject commit 635feb302e50a29f4705e829a1e087cd95699fc8 +Subproject commit 8090ca8a210cc08919d807e091b504fac4107662 diff --git a/internal/config/README.md b/internal/config/README.md index d73c6df7..9f8f329e 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -30,6 +30,7 @@ type Config struct { ACL *acl.Config AutoCert *autocert.Config Entrypoint entrypoint.Config + InboundMTLSProfiles map[string]types.InboundMTLSProfile Providers Providers MatchDomains []string Homepage homepage.Config @@ -71,6 +72,8 @@ type State interface { } ``` +`StartAPIServers` starts the authenticated API from `common.APIHTTPAddr` and, when `LOCAL_API_ADDR` is set, an additional **unauthenticated** local listener from `common.LocalAPIHTTPAddr`. That address is validated for loopback binds (with optional DNS resolution); non-loopback requires `LOCAL_API_ALLOW_NON_LOOPBACK` and logs a warning. See [`internal/api/v1/README.md`](../api/v1/README.md#configuration-surface). + ### Exported functions ```go diff --git a/internal/config/inbound_mtls_validation_test.go b/internal/config/inbound_mtls_validation_test.go new file mode 100644 index 00000000..e218b994 --- /dev/null +++ b/internal/config/inbound_mtls_validation_test.go @@ -0,0 +1,94 @@ +package config_test + +import ( + "context" + "iter" + "testing" + + "github.com/stretchr/testify/require" + config "github.com/yusing/godoxy/internal/config/types" + entrypointtypes "github.com/yusing/godoxy/internal/entrypoint/types" + routeimpl "github.com/yusing/godoxy/internal/route" + route "github.com/yusing/godoxy/internal/route/types" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/server" + "github.com/yusing/goutils/task" +) + +func TestRouteValidateInboundMTLSProfile(t *testing.T) { + prev := config.WorkingState.Load() + t.Cleanup(func() { + if prev != nil { + config.WorkingState.Store(prev) + } + }) + + t.Run("rejects unknown profile", func(t *testing.T) { + state := &stubState{cfg: &config.Config{ + InboundMTLSProfiles: map[string]types.InboundMTLSProfile{ + "known": {UseSystemCAs: true}, + }, + }} + config.WorkingState.Store(state) + + r := &routeimpl.Route{ + Alias: "test", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: route.Port{Proxy: 80}, + InboundMTLSProfile: "missing", + } + err := r.Validate() + require.Error(t, err) + require.ErrorContains(t, err, `inbound mTLS profile "missing" not found`) + }) + + t.Run("rejects route profile when global profile configured", func(t *testing.T) { + state := &stubState{cfg: &config.Config{ + InboundMTLSProfiles: map[string]types.InboundMTLSProfile{ + "corp": {UseSystemCAs: true}, + }, + }} + state.cfg.Entrypoint.InboundMTLSProfile = "corp" + config.WorkingState.Store(state) + + r := &routeimpl.Route{ + Alias: "test", + Scheme: route.SchemeHTTP, + Host: "example.com", + Port: route.Port{Proxy: 80}, + InboundMTLSProfile: "corp", + } + err := r.Validate() + require.Error(t, err) + require.ErrorContains(t, err, "route inbound_mtls_profile is not supported") + }) +} + +type stubState struct { + cfg *config.Config +} + +func (s *stubState) InitFromFile(string) error { return nil } +func (s *stubState) Init([]byte) error { return nil } +func (s *stubState) Task() *task.Task { return nil } +func (s *stubState) Context() context.Context { return context.Background() } +func (s *stubState) Value() *config.Config { return s.cfg } +func (s *stubState) Entrypoint() entrypointtypes.Entrypoint { return nil } +func (s *stubState) ShortLinkMatcher() config.ShortLinkMatcher { return nil } +func (s *stubState) AutoCertProvider() server.CertProvider { return nil } +func (s *stubState) LoadOrStoreProvider(string, types.RouteProvider) (types.RouteProvider, bool) { + return nil, false +} +func (s *stubState) DeleteProvider(string) { /* no-op: test stub */ } +func (s *stubState) IterProviders() iter.Seq2[string, types.RouteProvider] { + // no-op: returns empty iterator + return func(func(string, types.RouteProvider) bool) {} +} +func (s *stubState) NumProviders() int { return 0 } // no-op: test stub +func (s *stubState) StartProviders() error { return nil } // no-op: test stub +func (s *stubState) FlushTmpLog() { /* no-op: test stub */ } +func (s *stubState) StartAPIServers() { /* no-op: test stub */ } +func (s *stubState) StartMetrics() { /* no-op: test stub */ } + +var _ config.State = (*stubState)(nil) diff --git a/internal/config/state.go b/internal/config/state.go index 7805869e..245c7050 100644 --- a/internal/config/state.go +++ b/internal/config/state.go @@ -324,6 +324,7 @@ func (state *state) initEntrypoint() error { errs := gperr.NewBuilder("entrypoint error") errs.Add(state.entrypoint.SetMiddlewares(epCfg.Middlewares)) errs.Add(state.entrypoint.SetAccessLogger(state.task, epCfg.AccessLog)) + errs.Add(state.entrypoint.SetInboundMTLSProfiles(state.Config.InboundMTLSProfiles)) return errs.Error() } diff --git a/internal/config/types/config.go b/internal/config/types/config.go index 99dc9b52..0ba5e7df 100644 --- a/internal/config/types/config.go +++ b/internal/config/types/config.go @@ -19,14 +19,15 @@ import ( type ( Config struct { - ACL *acl.Config `json:"acl"` - AutoCert *autocert.Config `json:"autocert"` - Entrypoint entrypoint.Config `json:"entrypoint"` - Providers Providers `json:"providers"` - MatchDomains []string `json:"match_domains" validate:"domain_name"` - Homepage homepage.Config `json:"homepage"` - Defaults Defaults `json:"defaults"` - TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"` + ACL *acl.Config `json:"acl"` + AutoCert *autocert.Config `json:"autocert"` + Entrypoint entrypoint.Config `json:"entrypoint"` + InboundMTLSProfiles map[string]types.InboundMTLSProfile `json:"inbound_mtls_profiles"` + Providers Providers `json:"providers"` + MatchDomains []string `json:"match_domains" validate:"domain_name"` + Homepage homepage.Config `json:"homepage"` + Defaults Defaults `json:"defaults"` + TimeoutShutdown int `json:"timeout_shutdown" validate:"gte=0"` } Defaults struct { HealthCheck types.HealthCheckConfig `json:"healthcheck"` diff --git a/internal/entrypoint/README.md b/internal/entrypoint/README.md index ad810b84..ba09d271 100644 --- a/internal/entrypoint/README.md +++ b/internal/entrypoint/README.md @@ -93,6 +93,7 @@ type RWPoolLike[Route types.Route] interface { ```go type Config struct { SupportProxyProtocol bool `json:"support_proxy_protocol"` + InboundMTLSProfile string `json:"inbound_mtls_profile,omitempty"` Rules struct { NotFound rules.Rules `json:"not_found"` } `json:"rules"` @@ -101,6 +102,51 @@ type Config struct { } ``` +`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. +- Without a global profile, route-level inbound mTLS may still select profiles by TLS SNI. +- For a route that enforces client certificates, the route matched from the HTTP `Host` and the route matched from TLS SNI must be the same route (compared by route identity/key after `FindRoute`). That resolution is the entrypoint's route table, not DNS or any external name resolution. + +### Inbound mTLS profiles + +Root config provides reusable named inbound mTLS profiles via `config.Config.InboundMTLSProfiles`. Each profile is a [`types.InboundMTLSProfile`](internal/types/inbound_mtls.go): optional system trust roots plus zero or more PEM CA certificate files on disk (`ca_files`). `SetInboundMTLSProfiles` compiles those profiles into certificate pools, and the TLS server sets `ClientCAs` from the selected pool. + +PEM content is not embedded in YAML: list file paths under `ca_files`; each file should contain one or more PEM-encoded CA certificates. + +```yaml +inbound_mtls_profiles: + corp-clients: + use_system_cas: false + ca_files: + - /etc/godoxy/mtls/corp-root-ca.pem + - /etc/godoxy/mtls/corp-issuing-ca.pem + corp-plus-extra: + use_system_cas: true + ca_files: + - /etc/godoxy/mtls/private-intermediate.pem +``` + +Apply one profile to **all** HTTPS listeners by naming it on the entrypoint: + +```yaml +entrypoint: + inbound_mtls_profile: corp-clients +``` + +#### Security considerations + +- **Client certificates and chain verification** — The server requires a client certificate and verifies it with Go's TLS stack. The chain must build to one of the CAs in the selected pool (custom PEMs from `ca_files`, and optionally the OS trust store when `use_system_cas` is true). Leaf validity (time, EKU, and related checks) follows standard Go behavior for client-auth verification. +- **CA management and rotation** — CA material is read from the filesystem when profiles are compiled during config load / entrypoint setup. Updating trust for a running process requires a config reload or restart so the new PEM files are read. +- **CRL / OCSP revocation** — Go's standard inbound mTLS verification does not perform CRL or OCSP checks for client certificates, and GoDoxy does not add a custom revocation layer. +- **Misconfigured trust pools** — A pool that is too broad (for example `use_system_cas: true` with few constraints) can trust far more clients than intended. A pool that omits required intermediates can reject otherwise valid clients. + +#### Failure modes + +- **Invalid or unreadable CA material** — Missing files, non-PEM content, or PEM that does not parse as CA certificates cause profile compilation to fail. `SetInboundMTLSProfiles` returns collected per-profile errors. +- **Missing profile referenced by entrypoint** — If `entrypoint.inbound_mtls_profile` names a profile that is not present in `inbound_mtls_profiles`, initialization returns `entrypoint inbound mTLS profile "" not found`. +- **Client certificate validation failures** — Clients that omit a cert, present a cert that does not chain to the configured pool, or fail other TLS checks see a failed TLS handshake before HTTP handling starts. + ### Context Functions ```go diff --git a/internal/entrypoint/config.go b/internal/entrypoint/config.go index 82e3bf33..70fff882 100644 --- a/internal/entrypoint/config.go +++ b/internal/entrypoint/config.go @@ -8,7 +8,8 @@ import ( // Config defines the entrypoint configuration for proxy handling, // including proxy protocol support, routing rules, middlewares, and access logging. type Config struct { - SupportProxyProtocol bool `json:"support_proxy_protocol"` + SupportProxyProtocol bool `json:"support_proxy_protocol"` + InboundMTLSProfile string `json:"inbound_mtls_profile,omitempty"` Rules struct { NotFound rules.Rules `json:"not_found"` } `json:"rules"` diff --git a/internal/entrypoint/entrypoint.go b/internal/entrypoint/entrypoint.go index 40bd7c07..0119de2b 100644 --- a/internal/entrypoint/entrypoint.go +++ b/internal/entrypoint/entrypoint.go @@ -1,6 +1,7 @@ package entrypoint import ( + "crypto/x509" "net/http" "strings" "sync/atomic" @@ -41,6 +42,8 @@ type Entrypoint struct { httpPoolDisableLog atomic.Bool servers *xsync.Map[string, *httpServer] // listen addr -> server + + inboundMTLSProfiles map[string]*x509.CertPool } var _ entrypoint.Entrypoint = &Entrypoint{} @@ -62,13 +65,14 @@ func NewEntrypoint(parent task.Parent, cfg *Config) *Entrypoint { } ep := &Entrypoint{ - task: parent.Subtask("entrypoint", false), - cfg: cfg, - findRouteFunc: findRouteAnyDomain, - shortLinkMatcher: newShortLinkMatcher(), - streamRoutes: pool.New[types.StreamRoute]("stream_routes", "stream_routes"), - excludedRoutes: pool.New[types.Route]("excluded_routes", "excluded_routes"), - servers: xsync.NewMap[string, *httpServer](), + task: parent.Subtask("entrypoint", false), + cfg: cfg, + findRouteFunc: findRouteAnyDomain, + shortLinkMatcher: newShortLinkMatcher(), + streamRoutes: pool.New[types.StreamRoute]("stream_routes", "stream_routes"), + excludedRoutes: pool.New[types.Route]("excluded_routes", "excluded_routes"), + servers: xsync.NewMap[string, *httpServer](), + inboundMTLSProfiles: make(map[string]*x509.CertPool), } return ep } diff --git a/internal/entrypoint/errors.go b/internal/entrypoint/errors.go new file mode 100644 index 00000000..eb6495d1 --- /dev/null +++ b/internal/entrypoint/errors.go @@ -0,0 +1,8 @@ +package entrypoint + +import "errors" + +var ( + errSecureRouteRequiresSNI = errors.New("secure route requires matching TLS SNI") + errSecureRouteMisdirected = errors.New("secure route host must match TLS SNI") +) diff --git a/internal/entrypoint/http_server.go b/internal/entrypoint/http_server.go index a5880eb6..d614b318 100644 --- a/internal/entrypoint/http_server.go +++ b/internal/entrypoint/http_server.go @@ -3,6 +3,7 @@ package entrypoint import ( "errors" "fmt" + "net" "net/http" "strings" @@ -54,6 +55,10 @@ func newHTTPServer(ep *Entrypoint) *httpServer { // Listen starts the server and stop when entrypoint is stopped. func (srv *httpServer) Listen(addr string, proto HTTPProto) error { + return srv.listen(addr, proto, nil) +} + +func (srv *httpServer) listen(addr string, proto HTTPProto, listener net.Listener) error { if srv.addr != "" { return errors.New("server already started") } @@ -68,9 +73,12 @@ func (srv *httpServer) Listen(addr string, proto HTTPProto) error { switch proto { case HTTPProtoHTTP: opts.HTTPAddr = addr + opts.HTTPListener = listener case HTTPProtoHTTPS: opts.HTTPSAddr = addr + opts.HTTPSListener = listener opts.CertProvider = autocert.FromCtx(srv.ep.task.Context()) + opts.TLSConfigMutator = srv.mutateServerTLSConfig } task := srv.ep.task.Subtask("http_server", false) @@ -116,8 +124,15 @@ func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { }() } - route := srv.ep.findRouteFunc(srv.routes, r.Host) + route, err := srv.resolveRequestRoute(r) switch { + case errors.Is(err, errSecureRouteRequiresSNI), errors.Is(err, errSecureRouteMisdirected): + http.Error(w, err.Error(), http.StatusMisdirectedRequest) + return + case err != nil: + log.Err(err).Msg("failed to resolve HTTP route") + http.Error(w, "internal server error", http.StatusInternalServerError) + return case route != nil: r = routes.WithRouteContext(r, route) if srv.ep.middleware != nil { @@ -134,6 +149,55 @@ func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +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 + if req.TLS == nil || srv.ep.cfg.InboundMTLSProfile != "" { + return hostRoute, nil + } + + _, hostSecure, err := srv.resolveInboundMTLSProfileForRoute(hostRoute) + if err != nil { + return nil, err + } + + serverName := req.TLS.ServerName + if serverName == "" { + if hostSecure { + return nil, errSecureRouteRequiresSNI + } + return hostRoute, nil + } + + sniRoute := srv.FindRoute(serverName) + _, sniSecure, err := srv.resolveInboundMTLSProfileForRoute(sniRoute) + if err != nil { + return nil, err + } + if sniSecure { + if !sameHTTPRoute(hostRoute, sniRoute) { + return nil, errSecureRouteMisdirected + } + return sniRoute, nil + } + + if hostSecure { + return nil, errSecureRouteMisdirected + } + return hostRoute, nil +} + +func sameHTTPRoute(left, right types.HTTPRoute) bool { + switch { + case left == nil || right == nil: + return left == right + case left == right: + return true + default: + return left.Key() == right.Key() + } +} + func (srv *httpServer) tryHandleShortLink(w http.ResponseWriter, r *http.Request) (handled bool) { host := r.Host if before, _, ok := strings.Cut(host, ":"); ok { diff --git a/internal/entrypoint/inbound_mtls.go b/internal/entrypoint/inbound_mtls.go new file mode 100644 index 00000000..c2f943c3 --- /dev/null +++ b/internal/entrypoint/inbound_mtls.go @@ -0,0 +1,184 @@ +package entrypoint + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + + "github.com/rs/zerolog/log" + "github.com/yusing/godoxy/internal/types" + gperr "github.com/yusing/goutils/errs" +) + +func compileInboundMTLSProfiles(profiles map[string]types.InboundMTLSProfile) (map[string]*x509.CertPool, error) { + if len(profiles) == 0 { + return map[string]*x509.CertPool{}, nil + } + + compiled := make(map[string]*x509.CertPool, len(profiles)) + errs := gperr.NewBuilder("inbound mTLS profiles error") + + for name, profile := range profiles { + if err := profile.Validate(); err != nil { + errs.AddSubjectf(err, "profiles.%s", name) + continue + } + + pool, err := buildInboundMTLSCAPool(profile) + if err != nil { + errs.AddSubjectf(err, "profiles.%s", name) + continue + } + compiled[name] = pool + } + + if err := errs.Error(); err != nil { + return nil, err + } + return compiled, nil +} + +func buildInboundMTLSCAPool(profile types.InboundMTLSProfile) (*x509.CertPool, error) { + var pool *x509.CertPool + + if profile.UseSystemCAs { + systemPool, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + pool = systemPool + } + if pool == nil { + pool = x509.NewCertPool() + } + + for _, file := range profile.CAFiles { + data, err := os.ReadFile(file) + if err != nil { + return nil, gperr.PrependSubject(err, file) + } + if !pool.AppendCertsFromPEM(data) { + return nil, gperr.PrependSubject(errors.New("failed to parse CA certificates"), file) + } + } + + return pool, nil +} + +func (ep *Entrypoint) SetInboundMTLSProfiles(profiles map[string]types.InboundMTLSProfile) error { + compiled, err := compileInboundMTLSProfiles(profiles) + if err != nil { + return err + } + if profileRef := ep.cfg.InboundMTLSProfile; profileRef != "" { + if _, ok := compiled[profileRef]; !ok { + return fmt.Errorf("entrypoint inbound mTLS profile %q not found", profileRef) + } + } + ep.inboundMTLSProfiles = compiled + return nil +} + +func (srv *httpServer) mutateServerTLSConfig(base *tls.Config) *tls.Config { + if base == nil { + return base + } + + pool, enabled, err := srv.resolveInboundMTLSProfileForGlobal() + switch { + case err != nil: + log.Err(err).Msg("inbound mTLS: failed to resolve global profile, falling back to per-route mTLS") + case enabled: + return applyInboundMTLSProfile(base, pool) + } + + cfg := base.Clone() + cfg.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + pool, enabled, err := srv.resolveInboundMTLSProfileForServerName(hello.ServerName, false) + if err != nil { + return nil, err + } + if enabled { + return applyInboundMTLSProfile(base, pool), nil + } + return cloneTLSConfig(base), nil + } + return cfg +} + +func applyInboundMTLSProfile(base *tls.Config, pool *x509.CertPool) *tls.Config { + cfg := cloneTLSConfig(base) + cfg.ClientAuth = tls.RequireAndVerifyClientCert + cfg.ClientCAs = pool + return cfg +} + +func cloneTLSConfig(base *tls.Config) *tls.Config { + cfg := base.Clone() + cfg.GetConfigForClient = nil + return cfg +} + +func ValidateInboundMTLSProfileRef(profileRef, globalProfile string, profiles map[string]types.InboundMTLSProfile) error { + if profileRef == "" { + return nil + } + if globalProfile != "" { + return errors.New("route inbound_mtls_profile is not supported when entrypoint.inbound_mtls_profile is configured") + } + if _, ok := profiles[profileRef]; !ok { + return fmt.Errorf("inbound mTLS profile %q not found", profileRef) + } + return nil +} + +func (srv *httpServer) resolveInboundMTLSProfileForServerName(serverName string, allowGlobal bool) (pool *x509.CertPool, enabled bool, err error) { + if serverName == "" { + if allowGlobal { + return srv.resolveInboundMTLSProfileForGlobal() + } + return nil, false, nil + } + + pool, enabled, err = srv.resolveInboundMTLSProfileForRoute(srv.FindRoute(serverName)) + if err != nil { + return nil, false, err + } + if enabled || !allowGlobal { + return pool, enabled, nil + } + return srv.resolveInboundMTLSProfileForGlobal() +} + +func (srv *httpServer) resolveInboundMTLSProfileForRoute(route types.HTTPRoute) (pool *x509.CertPool, enabled bool, err error) { + if route == nil { + return nil, false, nil + } + if ref := route.InboundMTLSProfileRef(); ref != "" { + if p, ok := srv.lookupInboundMTLSProfile(ref); ok { + return p, true, nil + } + return nil, false, fmt.Errorf("route %q inbound mTLS profile %q not found", route.Name(), ref) + } + return nil, false, nil +} + +func (srv *httpServer) resolveInboundMTLSProfileForGlobal() (pool *x509.CertPool, enabled bool, err error) { + if globalRef := srv.ep.cfg.InboundMTLSProfile; globalRef != "" { + if p, ok := srv.lookupInboundMTLSProfile(globalRef); ok { + return p, true, nil + } + return nil, false, fmt.Errorf("entrypoint inbound mTLS profile %q not found", globalRef) + } + return nil, false, nil +} + +func (srv *httpServer) lookupInboundMTLSProfile(ref string) (*x509.CertPool, bool) { + if len(srv.ep.inboundMTLSProfiles) == 0 { // nil or empty map + return nil, false + } + pool, ok := srv.ep.inboundMTLSProfiles[ref] + return pool, ok +} diff --git a/internal/entrypoint/inbound_mtls_test.go b/internal/entrypoint/inbound_mtls_test.go new file mode 100644 index 00000000..d3bc5eb5 --- /dev/null +++ b/internal/entrypoint/inbound_mtls_test.go @@ -0,0 +1,550 @@ +package entrypoint + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + agentcert "github.com/yusing/godoxy/agent/pkg/agent" + "github.com/yusing/godoxy/internal/agentpool" + autocert "github.com/yusing/godoxy/internal/autocert/types" + "github.com/yusing/godoxy/internal/common" + "github.com/yusing/godoxy/internal/homepage" + nettypes "github.com/yusing/godoxy/internal/net/types" + "github.com/yusing/godoxy/internal/types" + "github.com/yusing/goutils/pool" + "github.com/yusing/goutils/task" +) + +type fakeHTTPRoute struct { + key string + name string + inboundMTLSProfile string + listenURL *nettypes.URL + task *task.Task +} + +func newFakeHTTPRoute(t *testing.T, alias, profile string) *fakeHTTPRoute { + return newFakeHTTPRouteAt(t, alias, profile, "https://:1000") +} + +func newFakeHTTPRouteAt(t *testing.T, alias, profile, listenURL string) *fakeHTTPRoute { + t.Helper() + + return &fakeHTTPRoute{ + key: alias, + name: alias, + inboundMTLSProfile: profile, + listenURL: nettypes.MustParseURL(listenURL), + task: task.GetTestTask(t), + } +} + +func (r *fakeHTTPRoute) Key() string { return r.key } +func (r *fakeHTTPRoute) Name() string { return r.name } +func (r *fakeHTTPRoute) Start(task.Parent) error { return nil } +func (r *fakeHTTPRoute) Task() *task.Task { return r.task } +func (r *fakeHTTPRoute) Finish(any) { + // no-op: test stub +} +func (r *fakeHTTPRoute) MarshalZerologObject(*zerolog.Event) { + // no-op: test stub +} +func (r *fakeHTTPRoute) ProviderName() string { return "" } +func (r *fakeHTTPRoute) GetProvider() types.RouteProvider { return nil } +func (r *fakeHTTPRoute) ListenURL() *nettypes.URL { return r.listenURL } +func (r *fakeHTTPRoute) TargetURL() *nettypes.URL { return nil } +func (r *fakeHTTPRoute) HealthMonitor() types.HealthMonitor { return nil } +func (r *fakeHTTPRoute) SetHealthMonitor(types.HealthMonitor) { + // no-op: test stub +} +func (r *fakeHTTPRoute) References() []string { return nil } +func (r *fakeHTTPRoute) ShouldExclude() bool { return false } +func (r *fakeHTTPRoute) Started() <-chan struct{} { return nil } +func (r *fakeHTTPRoute) IdlewatcherConfig() *types.IdlewatcherConfig { return nil } +func (r *fakeHTTPRoute) HealthCheckConfig() types.HealthCheckConfig { return types.HealthCheckConfig{} } +func (r *fakeHTTPRoute) LoadBalanceConfig() *types.LoadBalancerConfig { + return nil +} +func (r *fakeHTTPRoute) HomepageItem() homepage.Item { return homepage.Item{} } +func (r *fakeHTTPRoute) DisplayName() string { return r.name } +func (r *fakeHTTPRoute) ContainerInfo() *types.Container { + return nil +} +func (r *fakeHTTPRoute) GetAgent() *agentpool.Agent { return nil } +func (r *fakeHTTPRoute) IsDocker() bool { return false } +func (r *fakeHTTPRoute) IsAgent() bool { return false } +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) InboundMTLSProfileRef() string { return r.inboundMTLSProfile } + +func newTestHTTPServer(t *testing.T, ep *Entrypoint) *httpServer { + t.Helper() + + srv, ok := ep.servers.Load(common.ProxyHTTPAddr) + if ok { + return srv + } + + srv = &httpServer{ + ep: ep, + addr: common.ProxyHTTPAddr, + routes: pool.New[types.HTTPRoute]("test-http-routes", "test-http-routes"), + } + ep.servers.Store(common.ProxyHTTPAddr, srv) + return srv +} + +func TestMutateServerTLSConfigWithGlobalProfile(t *testing.T) { + ep := NewTestEntrypoint(t, &Config{InboundMTLSProfile: "global"}) + srv := newTestHTTPServer(t, ep) + require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "global": {UseSystemCAs: true}, + })) + + base := &tls.Config{MinVersion: tls.VersionTLS12} + mutated := srv.mutateServerTLSConfig(base) + + require.Equal(t, tls.RequireAndVerifyClientCert, mutated.ClientAuth) + require.NotNil(t, mutated.ClientCAs) + require.Nil(t, mutated.GetConfigForClient) +} + +func TestMutateServerTLSConfigWithoutProfilesKeepsTLSOpen(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + srv := newTestHTTPServer(t, ep) + require.NoError(t, ep.SetInboundMTLSProfiles(nil)) + + base := &tls.Config{MinVersion: tls.VersionTLS12} + mutated := srv.mutateServerTLSConfig(base) + + require.Zero(t, mutated.ClientAuth) + require.Nil(t, mutated.ClientCAs) + require.NotNil(t, mutated.GetConfigForClient) + + cfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{}) + require.NoError(t, err) + require.Zero(t, cfg.ClientAuth) + require.Nil(t, cfg.ClientCAs) + require.Nil(t, cfg.GetConfigForClient) +} + +func TestMutateServerTLSConfigWithRouteProfiles(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + ep.SetFindRouteDomains([]string{".example.com"}) + srv := newTestHTTPServer(t, ep) + srv.AddRoute(newFakeHTTPRoute(t, "secure-app", "route")) + srv.AddRoute(newFakeHTTPRoute(t, "open-app", "")) + require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "route": {UseSystemCAs: true}, + })) + + base := &tls.Config{MinVersion: tls.VersionTLS12} + mutated := srv.mutateServerTLSConfig(base) + + require.Zero(t, mutated.ClientAuth) + require.Nil(t, mutated.ClientCAs) + require.NotNil(t, mutated.GetConfigForClient) + + secureCfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "secure-app.example.com"}) + require.NoError(t, err) + require.Equal(t, tls.RequireAndVerifyClientCert, secureCfg.ClientAuth) + require.NotNil(t, secureCfg.ClientCAs) + require.Nil(t, secureCfg.GetConfigForClient) + + openCfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "open-app.example.com"}) + require.NoError(t, err) + require.Zero(t, openCfg.ClientAuth) + require.Nil(t, openCfg.ClientCAs) + require.Nil(t, openCfg.GetConfigForClient) + + unknownCfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "unknown.example.com"}) + require.NoError(t, err) + require.Zero(t, unknownCfg.ClientAuth) + require.Nil(t, unknownCfg.ClientCAs) + require.Nil(t, unknownCfg.GetConfigForClient) +} + +func TestMutateServerTLSConfigFallsBackToRouteProfilesAfterGlobalLookupError(t *testing.T) { + ep := NewTestEntrypoint(t, &Config{InboundMTLSProfile: "missing"}) + ep.SetFindRouteDomains([]string{".example.com"}) + srv := newTestHTTPServer(t, ep) + srv.AddRoute(newFakeHTTPRoute(t, "secure-app", "route")) + ep.inboundMTLSProfiles = map[string]*x509.CertPool{ + "route": x509.NewCertPool(), + } + + base := &tls.Config{MinVersion: tls.VersionTLS12} + mutated := srv.mutateServerTLSConfig(base) + + require.NotNil(t, mutated.GetConfigForClient) + + secureCfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "secure-app.example.com"}) + require.NoError(t, err) + require.Equal(t, tls.RequireAndVerifyClientCert, secureCfg.ClientAuth) + require.NotNil(t, secureCfg.ClientCAs) + require.Nil(t, secureCfg.GetConfigForClient) + + openCfg, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "open-app.example.com"}) + require.NoError(t, err) + require.Zero(t, openCfg.ClientAuth) + require.Nil(t, openCfg.ClientCAs) + require.Nil(t, openCfg.GetConfigForClient) +} + +func TestSetInboundMTLSProfilesRejectsUnknownGlobalProfile(t *testing.T) { + ep := NewTestEntrypoint(t, &Config{InboundMTLSProfile: "missing"}) + err := ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "known": {UseSystemCAs: true}, + }) + require.Error(t, err) + require.ErrorContains(t, err, `entrypoint inbound mTLS profile "missing" not found`) +} + +func TestSetInboundMTLSProfilesRejectsBadCAFile(t *testing.T) { + ep := NewTestEntrypoint(t, &Config{InboundMTLSProfile: "broken"}) + err := ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "broken": {CAFiles: []string{filepath.Join(t.TempDir(), "missing.pem")}}, + }) + require.Error(t, err) + require.ErrorContains(t, err, "missing.pem") +} + +func TestCompileInboundMTLSProfilesReturnsNilMapOnError(t *testing.T) { + compiled, err := compileInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "ok": {UseSystemCAs: true}, + "bad": {CAFiles: []string{filepath.Join(t.TempDir(), "missing.pem")}}, + }) + require.Nil(t, compiled) + require.Error(t, err) + require.ErrorContains(t, err, "missing.pem") +} + +func TestMutateServerTLSConfigRejectsUnknownRouteProfile(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + ep.SetFindRouteDomains([]string{".example.com"}) + srv := newTestHTTPServer(t, ep) + srv.AddRoute(newFakeHTTPRoute(t, "secure-app", "missing")) + + base := &tls.Config{MinVersion: tls.VersionTLS12} + mutated := srv.mutateServerTLSConfig(base) + + _, err := mutated.GetConfigForClient(&tls.ClientHelloInfo{ServerName: "secure-app.example.com"}) + require.Error(t, err) + require.ErrorContains(t, err, `route "secure-app" inbound mTLS profile "missing" not found`) +} + +func TestResolveRequestRouteRejectsUnknownRouteProfile(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + ep.SetFindRouteDomains([]string{".example.com"}) + srv := newTestHTTPServer(t, ep) + srv.AddRoute(newFakeHTTPRoute(t, "secure-app", "missing")) + + req := httptest.NewRequest(http.MethodGet, "https://secure-app.example.com", nil) + req.Host = "secure-app.example.com" + req.TLS = &tls.ConnectionState{ServerName: "secure-app.example.com"} + + route, err := srv.resolveRequestRoute(req) + require.Nil(t, route) + require.Error(t, err) + require.ErrorContains(t, err, `route "secure-app" inbound mTLS profile "missing" not found`) +} + +func TestResolveRequestRouteLeavesOpenAndUnknownHostsUnchanged(t *testing.T) { + ep := NewTestEntrypoint(t, nil) + ep.SetFindRouteDomains([]string{".example.com"}) + srv := newTestHTTPServer(t, ep) + srv.AddRoute(newFakeHTTPRoute(t, "secure-app", "route")) + srv.AddRoute(newFakeHTTPRoute(t, "open-app", "")) + require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "route": {UseSystemCAs: true}, + })) + + t.Run("open host stays open", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://open-app.example.com", nil) + req.Host = "open-app.example.com" + req.TLS = &tls.ConnectionState{ServerName: "open-app.example.com"} + + route, err := srv.resolveRequestRoute(req) + require.NoError(t, err) + require.NotNil(t, route) + require.Equal(t, "open-app", route.Name()) + }) + + t.Run("unknown host falls through", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://unknown.example.com", nil) + req.Host = "unknown.example.com" + req.TLS = &tls.ConnectionState{ServerName: "unknown.example.com"} + + route, err := srv.resolveRequestRoute(req) + require.NoError(t, err) + require.Nil(t, route) + }) +} + +func TestInboundMTLSGlobalHandshake(t *testing.T) { + ca, srv, client, err := agentcert.NewAgent() + require.NoError(t, err) + + serverCert, err := srv.ToTLSCert() + require.NoError(t, err) + clientCert, err := client.ToTLSCert() + require.NoError(t, err) + + caPath := writeTempFile(t, "ca.pem", ca.Cert) + provider := &staticCertProvider{cert: serverCert} + + ep := NewTestEntrypoint(t, &Config{InboundMTLSProfile: "global"}) + t.Cleanup(func() { + closeTestServers(t, ep) + }) + autocert.SetCtx(task.GetTestTask(t), provider) + require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "global": {CAFiles: []string{caPath}}, + })) + + listener, releaseListener := reserveTCPAddr(t) + listenAddr := listener.Addr().String() + addHTTPRouteAt(t, ep, "app1", "", listenAddr, listener) + releaseListener() + + t.Run("trusted client succeeds", func(t *testing.T) { + resp, err := doHTTPSRequest(listenAddr, "app1.example.com", &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{*clientCert}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + }) + + t.Run("missing client cert fails handshake", func(t *testing.T) { + _, err := doHTTPSRequest(listenAddr, "app1.example.com", &tls.Config{ + InsecureSkipVerify: true, + }) + require.Error(t, err) + }) + + t.Run("wrong client cert fails handshake", func(t *testing.T) { + _, _, badClient, err := agentcert.NewAgent() + require.NoError(t, err) + badClientCert, err := badClient.ToTLSCert() + require.NoError(t, err) + + _, err = doHTTPSRequest(listenAddr, "app1.example.com", &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{*badClientCert}, + }) + require.Error(t, err) + }) +} + +func TestInboundMTLSRouteScopedHandshake(t *testing.T) { + ca, srv, client, err := agentcert.NewAgent() + require.NoError(t, err) + + serverCert, err := srv.ToTLSCert() + require.NoError(t, err) + clientCert, err := client.ToTLSCert() + require.NoError(t, err) + + caPath := writeTempFile(t, "ca.pem", ca.Cert) + provider := &staticCertProvider{cert: serverCert} + + ep := NewTestEntrypoint(t, nil) + t.Cleanup(func() { + closeTestServers(t, ep) + }) + ep.SetFindRouteDomains([]string{".example.com"}) + autocert.SetCtx(task.GetTestTask(t), provider) + require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{ + "route": {CAFiles: []string{caPath}}, + })) + + listener, releaseListener := reserveTCPAddr(t) + listenAddr := listener.Addr().String() + addHTTPRouteAt(t, ep, "secure-app", "route", listenAddr, listener) + releaseListener() + addHTTPRouteAt(t, ep, "open-app", "", listenAddr, nil) + + t.Run("secure route requires client cert when sni matches", func(t *testing.T) { + _, err := doHTTPSRequest(listenAddr, "secure-app.example.com", &tls.Config{ + InsecureSkipVerify: true, + }) + require.Error(t, err) + }) + + t.Run("secure route accepts trusted client cert", func(t *testing.T) { + resp, err := doHTTPSRequest(listenAddr, "secure-app.example.com", &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{*clientCert}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + }) + + t.Run("open route without client cert succeeds", func(t *testing.T) { + resp, err := doHTTPSRequest(listenAddr, "open-app.example.com", &tls.Config{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + }) + + t.Run("secure route rejects requests without sni", func(t *testing.T) { + resp, tlsConn, err := doHTTPSRequestWithServerName(listenAddr, "secure-app.example.com", "", &tls.Config{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { _ = tlsConn.Close() }() + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode) + }) + + t.Run("secure route rejects host and sni mismatch without cert", func(t *testing.T) { + resp, tlsConn, err := doHTTPSRequestWithServerName(listenAddr, "secure-app.example.com", "open-app.example.com", &tls.Config{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { _ = tlsConn.Close() }() + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode) + }) + + t.Run("open route rejects host and sni mismatch when sni selects secure route", func(t *testing.T) { + resp, tlsConn, err := doHTTPSRequestWithServerName(listenAddr, "open-app.example.com", "secure-app.example.com", &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{*clientCert}, + }) + require.NoError(t, err) + defer func() { _ = tlsConn.Close() }() + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode) + }) +} + +func addHTTPRouteAt(t *testing.T, ep *Entrypoint, alias, profile, listenAddr string, listener net.Listener) { + t.Helper() + + route := newFakeHTTPRouteAt(t, alias, profile, "https://"+listenAddr) + if listener == nil { + require.NoError(t, ep.StartAddRoute(route)) + return + } + require.NoError(t, ep.addHTTPRouteWithListener(route, listenAddr, HTTPProtoHTTPS, listener)) +} + +func closeTestServers(t *testing.T, ep *Entrypoint) { + t.Helper() + for _, srv := range ep.servers.Range { + srv.Close() + } +} + +func reserveTCPAddr(t *testing.T) (net.Listener, func()) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + owned := true + t.Cleanup(func() { + if owned { + _ = ln.Close() + } + }) + return ln, func() { + owned = false + } +} + +func writeTempFile(t *testing.T, name string, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +func doHTTPSRequest(addr, host string, tlsConfig *tls.Config) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, "https://"+addr, nil) + if err != nil { + return nil, err + } + req.Host = host + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: cloneTLSConfigWithServerName(tlsConfig, host), + }, + } + return client.Do(req) +} + +// doHTTPSRequestWithServerName sends GET https://addr/ with HTTP Host set to host and TLS +// ServerName set to serverName (SNI may differ from Host). The returned connection stays open +// until the caller closes it after finishing with resp (typically close resp.Body first, then +// the tls connection). +func doHTTPSRequestWithServerName(addr, host, serverName string, tlsConfig *tls.Config) (*http.Response, io.Closer, error) { + conn, err := tls.Dial("tcp", addr, cloneTLSConfigWithServerName(tlsConfig, serverName)) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequest(http.MethodGet, "https://"+addr, nil) + if err != nil { + _ = conn.Close() + return nil, nil, err + } + req.Host = host + if err := req.Write(conn); err != nil { + _ = conn.Close() + return nil, nil, err + } + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + _ = conn.Close() + return nil, nil, err + } + return resp, conn, nil +} + +func cloneTLSConfigWithServerName(cfg *tls.Config, serverName string) *tls.Config { + if cfg == nil { + cfg = &tls.Config{} + } + cloned := cfg.Clone() + cloned.ServerName = serverName + return cloned +} + +type staticCertProvider struct { + cert *tls.Certificate +} + +func (p *staticCertProvider) GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.cert, nil +} +func (p *staticCertProvider) GetCertInfos() ([]autocert.CertInfo, error) { return nil, nil } +func (p *staticCertProvider) ScheduleRenewalAll(task.Parent) { + // no-op: test stub +} +func (p *staticCertProvider) ObtainCertAll() error { return nil } +func (p *staticCertProvider) ForceExpiryAll() bool { return false } +func (p *staticCertProvider) WaitRenewalDone(context.Context) bool { return true } diff --git a/internal/entrypoint/routes.go b/internal/entrypoint/routes.go index 53219e88..9346c3f8 100644 --- a/internal/entrypoint/routes.go +++ b/internal/entrypoint/routes.go @@ -113,10 +113,14 @@ func (ep *Entrypoint) AddHTTPRoute(route types.HTTPRoute) error { } func (ep *Entrypoint) addHTTPRoute(route types.HTTPRoute, addr string, proto HTTPProto) error { + return ep.addHTTPRouteWithListener(route, addr, proto, nil) +} + +func (ep *Entrypoint) addHTTPRouteWithListener(route types.HTTPRoute, addr string, proto HTTPProto, listener net.Listener) error { var err error srv, _ := ep.servers.LoadOrCompute(addr, func() (newSrv *httpServer, cancel bool) { newSrv = newHTTPServer(ep) - err = newSrv.Listen(addr, proto) + err = newSrv.listen(addr, proto, listener) cancel = err != nil return }) diff --git a/internal/route/README.md b/internal/route/README.md index 5e8c402a..389b6ef0 100644 --- a/internal/route/README.md +++ b/internal/route/README.md @@ -42,6 +42,7 @@ type Route struct { // Route rules and middleware HTTPConfig + InboundMTLSProfile string PathPatterns []string Rules rules.Rules RuleFile string @@ -61,6 +62,25 @@ type Route struct { } ``` +`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. +- It is only valid for HTTP-based routes. +- If set on a non-HTTP route (tcp, udp, fileserver), route validation fails. +- Route-scoped inbound mTLS is selected by TLS SNI. +- Requests for secured routes must resolve to the same route by both HTTP `Host` and TLS SNI. +- If the profile name does not exist, route validation fails. + +Example route fragment: + +```yaml +alias: secure-api +host: api.example.com +scheme: https +port: 443 +inbound_mtls_profile: corp-clients +``` + ```go type Scheme string diff --git a/internal/route/provider/all_fields.yaml b/internal/route/provider/all_fields.yaml index 009ac0bd..dc7cf57c 100644 --- a/internal/route/provider/all_fields.yaml +++ b/internal/route/provider/all_fields.yaml @@ -10,6 +10,7 @@ example: # matching `example.y.z` no_tls_verify: true disable_compression: false response_header_timeout: 30s + inbound_mtls_profile: corp # optional, only supported when no global entrypoint inbound_mtls_profile is configured; selected by TLS SNI and Host/SNI must resolve to the same route ssl_server_name: "" # empty uses target hostname, "off" disables SNI ssl_trusted_certificate: /etc/ssl/certs/ca-certificates.crt ssl_certificate: /etc/ssl/client.crt diff --git a/internal/route/route.go b/internal/route/route.go index b477f62e..d34cf3e2 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -19,6 +19,7 @@ import ( "github.com/yusing/godoxy/internal/agentpool" config "github.com/yusing/godoxy/internal/config/types" "github.com/yusing/godoxy/internal/docker" + entrypointimpl "github.com/yusing/godoxy/internal/entrypoint" entrypoint "github.com/yusing/godoxy/internal/entrypoint/types" "github.com/yusing/godoxy/internal/health/monitor" "github.com/yusing/godoxy/internal/homepage" @@ -54,6 +55,7 @@ type ( Index string `json:"index,omitempty"` // Index file to serve for single-page app mode route.HTTPConfig + InboundMTLSProfile string `json:"inbound_mtls_profile,omitempty"` PathPatterns []string `json:"path_patterns,omitempty" extensions:"x-nullable"` Rules rules.Rules `json:"rules,omitempty" extensions:"x-nullable"` RuleFile string `json:"rule_file,omitempty" extensions:"x-nullable"` @@ -171,8 +173,23 @@ func (r *Route) validate() error { } } + if workingState := config.WorkingState.Load(); workingState != nil { + cfg := workingState.Value() + if err := entrypointimpl.ValidateInboundMTLSProfileRef(r.InboundMTLSProfile, cfg.Entrypoint.InboundMTLSProfile, cfg.InboundMTLSProfiles); err != nil { + return err + } + } + r.Finalize() + if r.InboundMTLSProfile != "" { + switch r.Scheme { + case route.SchemeHTTP, route.SchemeHTTPS, route.SchemeH2C, route.SchemeFileServer: + default: + return errors.New("inbound_mtls_profile is only supported for HTTP-based routes") + } + } + r.started = make(chan struct{}) // close the channel when the route is destroyed (if not closed yet). runtime.AddCleanup(r, func(ch chan struct{}) { @@ -767,6 +784,10 @@ func (r *Route) ContainerInfo() *types.Container { return r.Container } +func (r *Route) InboundMTLSProfileRef() string { + return r.InboundMTLSProfile +} + func (r *Route) IsDocker() bool { if r.Container == nil { return false diff --git a/internal/route/route_test.go b/internal/route/route_test.go index c5c7ab4e..21eefc6f 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -91,6 +91,19 @@ func TestRouteValidate(t *testing.T) { require.ErrorContains(t, err, "relay_proxy_protocol_header is only supported for tcp routes") }) + t.Run("InboundMTLSProfileHTTPOnly", func(t *testing.T) { + r := &Route{ + Alias: "test-udp-mtls", + Scheme: route.SchemeUDP, + Host: "127.0.0.1", + Port: route.Port{Proxy: 53, Listening: 53}, + InboundMTLSProfile: "corp", + } + err := r.Validate() + require.Error(t, err, "Validate should reject inbound mTLS on non-HTTP routes") + require.ErrorContains(t, err, "inbound_mtls_profile is only supported for HTTP-based routes") + }) + t.Run("DockerContainer", func(t *testing.T) { r := &Route{ Alias: "test", diff --git a/internal/types/inbound_mtls.go b/internal/types/inbound_mtls.go new file mode 100644 index 00000000..9340d414 --- /dev/null +++ b/internal/types/inbound_mtls.go @@ -0,0 +1,15 @@ +package types + +import "errors" + +type InboundMTLSProfile struct { + UseSystemCAs bool `json:"use_system_cas,omitempty" yaml:"use_system_cas,omitempty"` + CAFiles []string `json:"ca_files,omitempty" yaml:"ca_files,omitempty" validate:"omitempty,dive,filepath"` +} + +func (cfg InboundMTLSProfile) Validate() error { + if !cfg.UseSystemCAs && len(cfg.CAFiles) == 0 { + return errors.New("at least one trust source is required for inbound mTLS profile") + } + return nil +} diff --git a/internal/types/inbound_mtls_test.go b/internal/types/inbound_mtls_test.go new file mode 100644 index 00000000..bf016046 --- /dev/null +++ b/internal/types/inbound_mtls_test.go @@ -0,0 +1,34 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInboundMTLSProfileValidate(t *testing.T) { + t.Run("requires at least one trust source", func(t *testing.T) { + var profile InboundMTLSProfile + err := profile.Validate() + require.Error(t, err) + require.ErrorContains(t, err, "trust source") + }) + + t.Run("system CA only", func(t *testing.T) { + profile := InboundMTLSProfile{UseSystemCAs: true} + require.NoError(t, profile.Validate()) + }) + + t.Run("CA file only", func(t *testing.T) { + profile := InboundMTLSProfile{CAFiles: []string{"/tmp/ca.pem"}} + require.NoError(t, profile.Validate()) + }) + + t.Run("system CA and CA files", func(t *testing.T) { + profile := InboundMTLSProfile{ + UseSystemCAs: true, + CAFiles: []string{"/tmp/ca.pem"}, + } + require.NoError(t, profile.Validate()) + }) +} diff --git a/internal/types/routes.go b/internal/types/routes.go index c0238015..ca934d0a 100644 --- a/internal/types/routes.go +++ b/internal/types/routes.go @@ -37,6 +37,7 @@ type ( HomepageItem() homepage.Item DisplayName() string ContainerInfo() *Container + InboundMTLSProfileRef() string GetAgent() *agentpool.Agent