feat(entrypoint): add inbound mTLS profiles for HTTPS (#220)

Introduce reusable `inbound_mtls_profiles` in root config and support
`entrypoint.inbound_mtls_profile` to require client certificates for all
HTTPS traffic on an entrypoint. Profiles can trust the system CA store,
custom PEM CA files, or both, and are compiled into TLS client-auth
pools during entrypoint initialization.

Also add route-scoped `inbound_mtls_profile` support for HTTP-based
routes when no global entrypoint profile is configured. Route-level mTLS
selection is driven by TLS SNI, preserves existing behavior for open and
unmatched hosts, and returns the intended 421 response when secure
requests omit SNI or when Host and SNI resolve to different routes.

Add validation for missing profile references and unsupported non-HTTP
route usage, update config and route documentation/examples, expand
inbound mTLS handshake and routing regression coverage, and bump
`goutils` for HTTPS listener test support.
This commit is contained in:
Yuzerion
2026-04-15 12:14:22 +08:00
committed by GitHub
parent 7b00a60f77
commit 31eea0a885
21 changed files with 1100 additions and 19 deletions

View File

@@ -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
#

Submodule goutils updated: 28c9d13ca8...8090ca8a21

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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"`

View File

@@ -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 "<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

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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")
)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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
})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}

View 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())
})
}

View File

@@ -37,6 +37,7 @@ type (
HomepageItem() homepage.Item
DisplayName() string
ContainerInfo() *Container
InboundMTLSProfileRef() string
GetAgent() *agentpool.Agent