mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-13 20:19:41 +02:00
feat(entrypoint): add inbound mTLS profiles for HTTPS
Add root-level inbound_mtls_profiles combining optional system CAs with PEM CA files, and entrypoint.inbound_mtls_profile to require client certificates on every HTTPS connection. Route-level inbound_mtls_profile is allowed only without a global profile; per-handshake TLS picks ClientCAs from SNI, and requests fail with 421 when Host and SNI would select different mTLS routes. Compile pools at init (SetInboundMTLSProfiles from state.initEntrypoint) and reject unknown profile refs or mixed global-plus-route configuration. Extend config.example.yml and package READMEs; add entrypoint and config tests for TLS mutation, handshakes, and validation.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
93
internal/config/inbound_mtls_validation_test.go
Normal file
93
internal/config/inbound_mtls_validation_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
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) {}
|
||||
func (s *stubState) IterProviders() iter.Seq2[string, types.RouteProvider] {
|
||||
return func(func(string, types.RouteProvider) bool) {}
|
||||
}
|
||||
func (s *stubState) NumProviders() int { return 0 }
|
||||
func (s *stubState) StartProviders() error { return nil }
|
||||
func (s *stubState) FlushTmpLog() {}
|
||||
func (s *stubState) StartAPIServers() {}
|
||||
func (s *stubState) StartMetrics() {}
|
||||
|
||||
var _ config.State = (*stubState)(nil)
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"iter"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -322,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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" yaml:"inbound_mtls_profiles,omitempty"`
|
||||
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"`
|
||||
|
||||
@@ -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`](../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 "<name>" 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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func (srv *httpServer) Listen(addr string, proto HTTPProto) error {
|
||||
case HTTPProtoHTTPS:
|
||||
opts.HTTPSAddr = addr
|
||||
opts.CertProvider = autocert.FromCtx(srv.ep.task.Context())
|
||||
opts.TLSConfigMutator = srv.mutateServerTLSConfig
|
||||
}
|
||||
|
||||
task := srv.ep.task.Subtask("http_server", false)
|
||||
@@ -116,8 +117,11 @@ 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 err != nil:
|
||||
http.Error(w, err.Error(), http.StatusMisdirectedRequest)
|
||||
return
|
||||
case route != nil:
|
||||
r = routes.WithRouteContext(r, route)
|
||||
if srv.ep.middleware != nil {
|
||||
@@ -134,6 +138,50 @@ func (srv *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
errSecureRouteRequiresSNI = errors.New("secure route requires matching TLS SNI")
|
||||
errSecureRouteMisdirected = errors.New("secure route host must match TLS SNI")
|
||||
)
|
||||
|
||||
func (srv *httpServer) resolveRequestRoute(req *http.Request) (types.HTTPRoute, error) {
|
||||
hostRoute := srv.FindRoute(req.Host)
|
||||
if req.TLS == nil || srv.ep.cfg.InboundMTLSProfile != "" || len(srv.ep.inboundMTLSProfiles) == 0 {
|
||||
return hostRoute, nil
|
||||
}
|
||||
|
||||
serverName := req.TLS.ServerName
|
||||
if serverName == "" {
|
||||
if pool := srv.resolveInboundMTLSProfileForRoute(hostRoute); pool != nil {
|
||||
return nil, errSecureRouteRequiresSNI
|
||||
}
|
||||
return hostRoute, nil
|
||||
}
|
||||
|
||||
sniRoute := srv.FindRoute(serverName)
|
||||
if pool := srv.resolveInboundMTLSProfileForRoute(sniRoute); pool != nil {
|
||||
if !sameHTTPRoute(hostRoute, sniRoute) {
|
||||
return nil, errSecureRouteMisdirected
|
||||
}
|
||||
return sniRoute, nil
|
||||
}
|
||||
|
||||
if pool := srv.resolveInboundMTLSProfileForRoute(hostRoute); pool != nil {
|
||||
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 {
|
||||
|
||||
154
internal/entrypoint/inbound_mtls.go
Normal file
154
internal/entrypoint/inbound_mtls.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"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 nil, 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
|
||||
}
|
||||
|
||||
return compiled, errs.Error()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if systemPool != nil {
|
||||
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
|
||||
}
|
||||
if pool := srv.resolveInboundMTLSProfileForRoute(nil); pool != nil {
|
||||
return applyInboundMTLSProfile(base, pool)
|
||||
}
|
||||
if len(srv.ep.inboundMTLSProfiles) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
cfg := base.Clone()
|
||||
cfg.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
if pool := srv.resolveInboundMTLSProfileForServerName(hello.ServerName); pool != nil {
|
||||
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) *x509.CertPool {
|
||||
if serverName == "" || srv.ep.inboundMTLSProfiles == nil {
|
||||
return nil
|
||||
}
|
||||
route := srv.FindRoute(serverName)
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
return srv.resolveInboundMTLSProfileForRoute(route)
|
||||
}
|
||||
|
||||
func (srv *httpServer) resolveInboundMTLSProfileForRoute(route types.HTTPRoute) *x509.CertPool {
|
||||
if srv.ep.inboundMTLSProfiles == nil {
|
||||
return nil
|
||||
}
|
||||
if globalRef := srv.ep.cfg.InboundMTLSProfile; globalRef != "" {
|
||||
return srv.ep.inboundMTLSProfiles[globalRef]
|
||||
}
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
if ref := route.InboundMTLSProfileRef(); ref != "" {
|
||||
return srv.ep.inboundMTLSProfiles[ref]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
401
internal/entrypoint/inbound_mtls_test.go
Normal file
401
internal/entrypoint/inbound_mtls_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"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) {}
|
||||
func (r *fakeHTTPRoute) MarshalZerologObject(*zerolog.Event) {}
|
||||
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) {}
|
||||
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) {}
|
||||
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 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 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 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"})
|
||||
autocert.SetCtx(task.GetTestTask(t), provider)
|
||||
require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{
|
||||
"global": {CAFiles: []string{caPath}},
|
||||
}))
|
||||
|
||||
listenAddr := reserveTCPAddr(t)
|
||||
addHTTPRouteAt(t, ep, "app1", "", listenAddr)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
closeTestServers(t, ep)
|
||||
}
|
||||
|
||||
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)
|
||||
ep.SetFindRouteDomains([]string{".example.com"})
|
||||
autocert.SetCtx(task.GetTestTask(t), provider)
|
||||
require.NoError(t, ep.SetInboundMTLSProfiles(map[string]types.InboundMTLSProfile{
|
||||
"route": {CAFiles: []string{caPath}},
|
||||
}))
|
||||
|
||||
listenAddr := reserveTCPAddr(t)
|
||||
addHTTPRouteAt(t, ep, "secure-app", "route", listenAddr)
|
||||
addHTTPRouteAt(t, ep, "open-app", "", listenAddr)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
closeTestServers(t, ep)
|
||||
}
|
||||
|
||||
func addHTTPRouteAt(t *testing.T, ep *Entrypoint, alias, profile, listenAddr string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, ep.StartAddRoute(newFakeHTTPRouteAt(t, alias, profile, "https://"+listenAddr)))
|
||||
}
|
||||
|
||||
func closeTestServers(t *testing.T, ep *Entrypoint) {
|
||||
t.Helper()
|
||||
for _, srv := range ep.servers.Range {
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func reserveTCPAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := ln.Addr().String()
|
||||
require.NoError(t, ln.Close())
|
||||
return addr
|
||||
}
|
||||
|
||||
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) {}
|
||||
func (p *staticCertProvider) ObtainCertAll() error { return nil }
|
||||
func (p *staticCertProvider) ForceExpiryAll() bool { return false }
|
||||
func (p *staticCertProvider) WaitRenewalDone(context.Context) bool { return true }
|
||||
@@ -42,6 +42,7 @@ type Route struct {
|
||||
|
||||
// Route rules and middleware
|
||||
HTTPConfig
|
||||
InboundMTLSProfile string
|
||||
PathPatterns []string
|
||||
Rules rules.Rules
|
||||
RuleFile string
|
||||
@@ -61,6 +62,24 @@ 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
internal/types/inbound_mtls.go
Normal file
15
internal/types/inbound_mtls.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package types
|
||||
|
||||
import "errors"
|
||||
|
||||
type InboundMTLSProfile struct {
|
||||
UseSystemCAs bool `json:"use_system_cas,omitempty"`
|
||||
CAFiles []string `json:"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
|
||||
}
|
||||
34
internal/types/inbound_mtls_test.go
Normal file
34
internal/types/inbound_mtls_test.go
Normal file
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type (
|
||||
HomepageItem() homepage.Item
|
||||
DisplayName() string
|
||||
ContainerInfo() *Container
|
||||
InboundMTLSProfileRef() string
|
||||
|
||||
GetAgent() *agentpool.Agent
|
||||
|
||||
|
||||
Reference in New Issue
Block a user