diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml index 9cf35f0e3..b5ec9f2df 100644 --- a/gateway/configs/config-template.toml +++ b/gateway/configs/config-template.toml @@ -252,11 +252,26 @@ key_path = "./listener-certs/default-listener.key" minimum_protocol_version = "TLS1_2" maximum_protocol_version = "TLS1_3" ciphers = "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA" +# Comma-separated ECDH curves for the TLS key exchange, most preferred first. +# Defaults to the hybrid post-quantum group X25519MLKEM768 (FIPS 203 ML-KEM-768 +# + X25519) followed by classical curves X25519 and P-256, so key exchange +# degrades gracefully to classical for peers that don't yet support the +# hybrid group. An unsupported curve name is rejected by Envoy when it +# applies the resulting config, not by this file. +ecdh_curves = "X25519MLKEM768,X25519,P-256" [router.upstream.tls] minimum_protocol_version = "TLS1_2" maximum_protocol_version = "TLS1_3" ciphers = "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA" +# Comma-separated ECDH curves for the TLS key exchange, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit per-deployment opt-in once the deployed Envoy/BoringSSL build is +# confirmed to support it -- an already-running Envoy that doesn't recognize +# the curve name will NACK this config and keep serving its last-known-good +# state instead of picking up any further changes. +ecdh_curves = "X25519,P-256" trusted_cert_path = "/etc/ssl/certs/ca-certificates.crt" custom_certs_path = "./certificates" verify_host_name = true @@ -336,6 +351,42 @@ allowed_ips = ["*", "127.0.0.1"] # Service port. enabled = false +[policy_engine.admin.tls] +# Starts a second, TLS-only admin listener on `port` below, serving the same +# routes (/health, /xds_sync_status, /config_dump when enabled) as the +# plaintext listener above. Off by default: no certificate is provisioned by +# default, and the plaintext listener keeps working either way. +enabled = false +port = 9004 +cert_path = "./listener-certs/default-listener.crt" +key_path = "./listener-certs/default-listener.key" +# TLS version bounds, one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same +# vocabulary as router.downstream_tls/upstream_tls above for consistency +# within this file, though enforced by a different TLS stack (Go's own +# crypto/tls here, Envoy/BoringSSL there). +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +# Comma-separated Go crypto/tls cipher suite names (e.g. +# "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which suites this +# listener negotiates. Empty by default -- Go's own secure default set/order +# applies. Only affects TLS 1.2 and below; TLS 1.3 suite selection is fixed +# and not configurable in Go's crypto/tls. Note this is a different naming +# scheme than router.downstream_tls's `ciphers` above (OpenSSL/BoringSSL +# names like "ECDHE-ECDSA-AES128-GCM-SHA256") -- see crypto/tls.CipherSuites +# for the names this listener accepts. +ciphers = "" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. +# Classical curves only by default. A hybrid post-quantum group (e.g. +# "X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be prepended as an +# explicit opt-in once the clients reaching this listener are confirmed to +# support it. This listener is served by this process's own Go crypto/tls +# (1.23+ implements X25519MLKEM768 natively), not pushed as xDS config to a +# separate Envoy process, so enabling it here does not carry the "already- +# running peer NACKs the update" risk documented on router.downstream_tls's +# ecdh_curves above -- TLS 1.3 negotiation just falls back to a later +# classical entry in this same list for a client that doesn't offer it. +ecdh_curves = "X25519,P-256" + [policy_engine.admin.pprof] # Go runtime profiling (net/http/pprof) served on the admin server, off by default. # When profiling, also restrict admin.allowed_ips or reach it via port-forward. diff --git a/gateway/docker-compose.yaml b/gateway/docker-compose.yaml index f9d117bff..b837c1921 100644 --- a/gateway/docker-compose.yaml +++ b/gateway/docker-compose.yaml @@ -62,6 +62,7 @@ services: # Policy Engine - "9002:9002" # Admin API - "9003:9003" # Metrics + - "9004:9004" # Health env_file: - path: api-platform.env required: true @@ -69,6 +70,7 @@ services: volumes: - ./configs/config.toml:/etc/policy-engine/config.toml:ro - ./configs/llm-pricing/model_prices.json:/etc/policy-engine/llm-pricing/model_prices.json:ro + - ./gateway-controller/listener-certs:/etc/policy-engine/listener-certs:ro networks: - gateway-network diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index 289f35897..cd9d8cea5 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -564,7 +564,15 @@ type UpstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` - TrustedCertPath string `koanf:"trusted_cert_path"` + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. + EcdhCurves string `koanf:"ecdh_curves"` + TrustedCertPath string `koanf:"trusted_cert_path"` CustomCertsPath string `koanf:"custom_certs_path"` // Directory containing custom trusted certificates VerifyHostName bool `koanf:"verify_host_name"` DisableSslVerification bool `koanf:"disable_ssl_verification"` @@ -594,6 +602,14 @@ type DownstreamTLS struct { MinimumProtocolVersion string `koanf:"minimum_protocol_version"` MaximumProtocolVersion string `koanf:"maximum_protocol_version"` Ciphers string `koanf:"ciphers"` + // EcdhCurves is a comma-separated list of ECDH curves (e.g. "X25519,P-256"), most preferred + // first. Defaults to classical curves only — a hybrid post-quantum group (e.g. + // "X25519MLKEM768") can be added as the first preference, but only as an explicit opt-in per + // deployment: an already-running Envoy instance that doesn't recognize the curve name will + // NACK the xDS update and keep serving its last-known-good config, silently freezing that + // instance out of any further config changes until the operator fixes it. Confirm the + // deployed Envoy/BoringSSL build supports the group before enabling it. + EcdhCurves string `koanf:"ecdh_curves"` } // VHostsConfig for vhosts configuration @@ -989,6 +1005,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", + EcdhCurves: "X25519,P-256", }, GatewayHost: "*", Upstream: RouterUpstream{ @@ -996,6 +1013,7 @@ func defaultConfig() *Config { MinimumProtocolVersion: "TLS1_2", MaximumProtocolVersion: "TLS1_3", Ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,AES128-GCM-SHA256,AES128-SHA,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,AES256-GCM-SHA384,AES256-SHA", + EcdhCurves: "X25519,P-256", TrustedCertPath: "/etc/ssl/certs/ca-certificates.crt", CustomCertsPath: "./certificates", VerifyHostName: true, @@ -1570,6 +1588,11 @@ func (c *Config) validateTLSConfig() error { } } + // Validate ECDH curves + if err := validateEcdhCurves("router.upstream.tls.ecdh_curves", c.Router.Upstream.TLS.EcdhCurves); err != nil { + return err + } + // Validate trusted cert path if SSL verification is enabled if !c.Router.Upstream.TLS.DisableSslVerification && c.Router.Upstream.TLS.TrustedCertPath == "" { return fmt.Errorf("router.upstream.tls.trusted_cert_path is required when SSL verification is enabled") @@ -1667,6 +1690,36 @@ func (c *Config) validateDownstreamTLSConfig() error { } } + // Validate ECDH curves + if err := validateEcdhCurves("router.downstream_tls.ecdh_curves", c.Router.DownstreamTLS.EcdhCurves); err != nil { + return err + } + + return nil +} + +// validateEcdhCurves validates the format of a comma-separated ecdh_curves config value. +// It deliberately does not check curve names against a fixed allowlist — the set of curves +// Envoy/BoringSSL accepts (including newer hybrid post-quantum groups such as +// "X25519MLKEM768") evolves independently of this codebase, so Envoy itself is the source of +// truth for which names are valid; an unsupported name is rejected when Envoy applies the +// resulting listener/cluster config. fieldPath is used to prefix any returned error. +func validateEcdhCurves(fieldPath, curves string) error { + if curves == "" { + return nil + } + // Basic validation: ensure curves don't contain invalid characters + if strings.Contains(curves, constants.CipherInvalidChars1) || strings.Contains(curves, constants.CipherInvalidChars2) { + return fmt.Errorf("%s contains invalid characters (use comma-separated values)", fieldPath) + } + if strings.TrimSpace(curves) == "" { + return fmt.Errorf("%s cannot be empty or whitespace only", fieldPath) + } + for _, curve := range strings.Split(curves, constants.CipherSuiteSeparator) { + if strings.TrimSpace(curve) == "" { + return fmt.Errorf("%s cannot contain an empty curve name (use comma-separated values)", fieldPath) + } + } return nil } diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go index d70567181..08ce6bc63 100644 --- a/gateway/gateway-controller/pkg/config/config_test.go +++ b/gateway/gateway-controller/pkg/config/config_test.go @@ -944,6 +944,37 @@ func TestConfig_ValidateTLSCiphers(t *testing.T) { } } +func TestConfig_ValidateUpstreamTLSEcdhCurves(t *testing.T) { + tests := []struct { + name string + curves string + wantErr bool + errContains string + }{ + {name: "Valid single curve", curves: "X25519", wantErr: false}, + {name: "Valid multiple curves", curves: "X25519,P-256", wantErr: false}, + {name: "Empty curves", curves: "", wantErr: false}, + {name: "Curves with spaces", curves: "X25519, P-256", wantErr: false}, + {name: "Hybrid post-quantum curve", curves: "X25519MLKEM768,SecP256r1MLKEM768,X25519,P-256", wantErr: false}, + {name: "Empty entry", curves: "X25519,,P-256", wantErr: true, errContains: "empty curve name"}, + {name: "Invalid separator character", curves: "X25519;P-256", wantErr: true, errContains: "invalid characters"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Router.Upstream.TLS.EcdhCurves = tt.curves + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestConfig_ValidateTLSTrustedCertPath(t *testing.T) { cfg := validConfig() cfg.Router.Upstream.TLS.DisableSslVerification = false @@ -1724,6 +1755,42 @@ func TestConfig_ValidateDownstreamTLSConfig(t *testing.T) { } } +func TestConfig_ValidateDownstreamTLSEcdhCurves(t *testing.T) { + tests := []struct { + name string + curves string + wantErr bool + errContains string + }{ + {name: "Valid single curve", curves: "P-256", wantErr: false}, + {name: "Valid multiple curves", curves: "X25519,P-256,P-384,P-521", wantErr: false}, + {name: "Empty curves", curves: "", wantErr: false}, + {name: "Hybrid post-quantum curve", curves: "X25519MLKEM768,X25519,P-256", wantErr: false}, + {name: "Empty entry", curves: ",X25519", wantErr: true, errContains: "empty curve name"}, + {name: "Invalid separator character", curves: "X25519|P-256", wantErr: true, errContains: "invalid characters"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Router.HTTPSEnabled = true + cfg.Router.HTTPSPort = 8443 + cfg.Router.DownstreamTLS.CertPath = "/path/to/cert.pem" + cfg.Router.DownstreamTLS.KeyPath = "/path/to/key.pem" + cfg.Router.DownstreamTLS.MinimumProtocolVersion = constants.TLSVersion12 + cfg.Router.DownstreamTLS.MaximumProtocolVersion = constants.TLSVersion13 + cfg.Router.DownstreamTLS.EcdhCurves = tt.curves + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestConfig_Validate_CompleteValidConfig(t *testing.T) { cfg := validConfig() err := cfg.Validate() @@ -1744,6 +1811,19 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, time.Duration(0), hcm.RequestHeadersTimeout, "default request_headers_timeout should be 0s (disabled)") assert.Equal(t, 5*time.Minute, hcm.StreamIdleTimeout, "default stream_idle_timeout should be 5m") assert.Equal(t, time.Hour, hcm.IdleTimeout, "default idle_timeout should be 1h") + + // TLS 1.2-1.3 must be available by default on both upstream and downstream. + // ECDH curves default to classical only (X25519, P-256): prepending a hybrid + // post-quantum group is an explicit per-deployment opt-in (see the EcdhCurves + // field doc), not a default, because an already-running Envoy instance that + // doesn't recognize the curve name would NACK the xDS update rather than + // picking up the change. + assert.Equal(t, "TLS1_2", cfg.Router.DownstreamTLS.MinimumProtocolVersion) + assert.Equal(t, "TLS1_3", cfg.Router.DownstreamTLS.MaximumProtocolVersion) + assert.Equal(t, "X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) + assert.Equal(t, "TLS1_2", cfg.Router.Upstream.TLS.MinimumProtocolVersion) + assert.Equal(t, "TLS1_3", cfg.Router.Upstream.TLS.MaximumProtocolVersion) + assert.Equal(t, "X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) } func TestLoadConfig_HCMTimeouts(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index ae27e6832..4a0960e1f 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -2293,6 +2293,7 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string t.routerConfig.Upstream.TLS.MaximumProtocolVersion, ), CipherSuites: t.parseCipherSuites(t.routerConfig.Upstream.TLS.Ciphers), + EcdhCurves: t.parseCipherSuites(t.routerConfig.Upstream.TLS.EcdhCurves), }, }, } @@ -2438,6 +2439,12 @@ func (t *Translator) createDownstreamTLSContext() (*tlsv3.DownstreamTlsContext, cipherSuites = t.parseCipherSuites(t.routerConfig.DownstreamTLS.Ciphers) } + // Parse ECDH curves + var ecdhCurves []string + if t.routerConfig.DownstreamTLS.EcdhCurves != "" { + ecdhCurves = t.parseCipherSuites(t.routerConfig.DownstreamTLS.EcdhCurves) + } + // Create downstream TLS context downstreamTLSContext := &tlsv3.DownstreamTlsContext{ CommonTlsContext: &tlsv3.CommonTlsContext{ @@ -2450,6 +2457,7 @@ func (t *Translator) createDownstreamTLSContext() (*tlsv3.DownstreamTlsContext, t.routerConfig.DownstreamTLS.MaximumProtocolVersion, ), CipherSuites: cipherSuites, + EcdhCurves: ecdhCurves, }, AlpnProtocols: []string{constants.ALPNProtocolHTTP2, constants.ALPNProtocolHTTP11}, }, @@ -2474,7 +2482,8 @@ func (t *Translator) createTLSProtocolVersion(version string) tlsv3.TlsParameter } } -// parseCipherSuites splits and trims cipher suite string into array +// parseCipherSuites splits and trims a comma-separated string into an array. +// Used for both cipher suite lists and ECDH curve lists. func (t *Translator) parseCipherSuites(ciphers string) []string { if ciphers == "" { return nil diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 46321f7cd..3d0f6d1d0 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -2352,6 +2352,7 @@ func TestTranslator_CreateSDSCluster(t *testing.T) { func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { logger := createTestLogger() routerCfg := testRouterConfig() + routerCfg.Upstream.TLS.EcdhCurves = "X25519,P-256" cfg := testConfig() translator := NewTranslator(logger, routerCfg, nil, cfg) @@ -2359,6 +2360,7 @@ func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { tlsContext := translator.createUpstreamTLSContext(nil, "example.com") assert.NotNil(t, tlsContext) assert.Equal(t, "example.com", tlsContext.Sni) + assert.Equal(t, []string{"X25519", "P-256"}, tlsContext.CommonTlsContext.TlsParams.EcdhCurves) // Test with certificate certPem := []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") diff --git a/gateway/gateway-runtime/Dockerfile b/gateway/gateway-runtime/Dockerfile index bf6e30faf..401539edb 100644 --- a/gateway/gateway-runtime/Dockerfile +++ b/gateway/gateway-runtime/Dockerfile @@ -21,7 +21,7 @@ # Envoy base image version — referenced by all stages that derive from envoyproxy/envoy. # Update here to bump Envoy across python-deps, debug, and production stages. -ARG ENVOY_VERSION=v1.38.3 +ARG ENVOY_VERSION=v1.39.0 # Stage 1: Builder Base FROM --platform=$BUILDPLATFORM golang:1.26.5-bookworm AS builder-base diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/server.go b/gateway/gateway-runtime/policy-engine/internal/admin/server.go index ef377f635..88abf3fa6 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/server.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/server.go @@ -20,6 +20,7 @@ package admin import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -37,6 +38,7 @@ import ( type Server struct { cfg *config.AdminConfig httpServer *http.Server + tlsServer *http.Server // nil unless cfg.TLS.Enabled } // NewServer creates a new admin server @@ -69,14 +71,80 @@ func NewServer(cfg *config.AdminConfig, k *kernel.Kernel, reg *registry.PolicyRe ReadHeaderTimeout: 30 * time.Second, } + // TLS listener is additive: served alongside, not instead of, the + // plaintext listener above, on the same mux — every route keeps the same + // IP-allowlist/config_dump gating regardless of which listener it's + // reached through. Config validation (Config.Validate) already rejects a + // bad EcdhCurves/Ciphers/protocol-version value before this ever runs in + // production, so a parse failure here can only come from a caller that + // bypassed validation — fail safe by leaving the TLS listener disabled + // rather than panicking. + var tlsServer *http.Server + if cfg.TLS.Enabled { + tlsConfig, err := buildAdminTLSConfig(&cfg.TLS) + if err != nil { + slog.Error("invalid admin.tls config, admin TLS listener disabled", "error", err) + } else { + tlsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.TLS.Port), + Handler: mux, + ReadHeaderTimeout: 30 * time.Second, + TLSConfig: tlsConfig, + } + } + } + return &Server{ cfg: cfg, httpServer: httpServer, + tlsServer: tlsServer, } } -// Start starts the admin HTTP server +// buildAdminTLSConfig translates an AdminTLSConfig into a tls.Config: bounded +// protocol version range, an optional cipher-suite restriction (TLS 1.2 and +// below only — TLS 1.3 suite selection isn't configurable in Go's +// crypto/tls), and the ECDH/group preference list, PQC hybrid group included +// when the operator has opted in. +func buildAdminTLSConfig(cfg *config.AdminTLSConfig) (*tls.Config, error) { + if err := config.ValidateAdminTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseAdminTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseAdminTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseAdminCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseAdminEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} + +// Start starts the admin HTTP server(s): the plaintext listener always, and — +// when configured — the TLS listener in the background alongside it. Blocks +// on the plaintext listener, matching the previous single-listener behavior +// callers already depend on. func (s *Server) Start(ctx context.Context) error { + if s.tlsServer != nil { + go func() { + slog.InfoContext(ctx, "Starting admin TLS HTTP server", "port", s.cfg.TLS.Port) + if err := s.tlsServer.ListenAndServeTLS(s.cfg.TLS.CertPath, s.cfg.TLS.KeyPath); err != nil && err != http.ErrServerClosed { + slog.ErrorContext(ctx, "Admin TLS server error", "error", err) + } + }() + } + slog.InfoContext(ctx, "Starting admin HTTP server", "port", s.cfg.Port, "allowed_ips", s.cfg.AllowedIPs) @@ -88,10 +156,16 @@ func (s *Server) Start(ctx context.Context) error { return nil } -// Stop gracefully stops the admin HTTP server +// Stop gracefully stops the admin HTTP server(s) func (s *Server) Stop(ctx context.Context) error { slog.InfoContext(ctx, "Stopping admin HTTP server") - return s.httpServer.Shutdown(ctx) + err := s.httpServer.Shutdown(ctx) + if s.tlsServer != nil { + if tlsErr := s.tlsServer.Shutdown(ctx); tlsErr != nil && err == nil { + err = tlsErr + } + } + return err } // configDumpEnabledMiddleware gates /config_dump behind an explicit enable flag diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go b/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go index 2ae838c62..21c5494e8 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/server_test.go @@ -20,10 +20,20 @@ package admin import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" + "math/big" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -35,6 +45,42 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" ) +// generateSelfSignedCert writes a self-signed ECDSA cert/key pair for +// "localhost" to certPath/keyPath, for exercising the admin TLS listener in +// tests without depending on any repo-committed certificate material. +func generateSelfSignedCert(t *testing.T, certPath, keyPath string) { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes})) + + keyBytes, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + + keyOut, err := os.Create(keyPath) + require.NoError(t, err) + defer keyOut.Close() + require.NoError(t, pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})) +} + // ============================================================================= // NewServer Tests // ============================================================================= @@ -356,3 +402,292 @@ func TestIPWhitelistMiddleware_PreservesRequestPath(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, "/config_dump", capturedPath) } + +// ============================================================================= +// TLS Listener Tests +// ============================================================================= + +// TestServer_TLSListener verifies the admin API is reachable over the +// additional TLS listener, using the PQC hybrid group first in the +// preference list, while the plaintext listener keeps serving unchanged. +func TestServer_TLSListener(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + require.NotNil(t, server.tlsServer) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + // The plaintext listener is unaffected by enabling TLS. + plainResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", plainPort)) + require.NoError(t, err) + plainResp.Body.Close() + assert.Equal(t, http.StatusOK, plainResp.StatusCode) + + // The TLS listener serves the same routes, negotiating the hybrid + // PQC group when the client offers it. + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519}, + }, + }, + } + tlsResp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer tlsResp.Body.Close() + assert.Equal(t, http.StatusOK, tlsResp.StatusCode) + require.NotNil(t, tlsResp.TLS) + assert.Equal(t, tls.X25519MLKEM768, tlsResp.TLS.CurveID) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_ClassicalFallback verifies a client that doesn't +// offer the PQC hybrid group still completes the handshake against the same +// listener, falling back to the classical curve later in the preference list. +func TestServer_TLSListener_ClassicalFallback(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + CurvePreferences: []tls.CurveID{tls.CurveP256}, // no PQC support offered + }, + }, + } + resp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, resp.TLS) + assert.Equal(t, tls.CurveP256, resp.TLS.CurveID) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_InvalidEcdhCurves verifies an invalid curve name +// disables the TLS listener rather than panicking — config validation +// (Config.Validate) is the real gate and already rejects this in production. +func TestServer_TLSListener_InvalidEcdhCurves(t *testing.T) { + cfg := &config.AdminConfig{ + Port: getFreePort(t), + AllowedIPs: []string{"127.0.0.1"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: getFreePort(t), + CertPath: "/nonexistent/cert.pem", + KeyPath: "/nonexistent/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + assert.Nil(t, server.tlsServer) +} + +// TestServer_TLSListener_MinimumVersionEnforced verifies a client offering +// only a protocol version below MinimumProtocolVersion is rejected by the +// handshake rather than silently downgrading. +func TestServer_TLSListener_MinimumVersionEnforced(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + // A client capped at TLS 1.1 cannot complete the handshake against a + // listener whose floor is TLS 1.2. + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + MinVersion: tls.VersionTLS10, + MaxVersion: tls.VersionTLS11, + }, + }, + } + _, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + assert.Error(t, err) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} + +// TestServer_TLSListener_CipherRestriction verifies a configured Ciphers +// list actually constrains which TLS 1.2 suite gets negotiated. +func TestServer_TLSListener_CipherRestriction(t *testing.T) { + plainPort := getFreePort(t) + tlsPort := getFreePort(t) + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "admin.crt") + keyPath := filepath.Join(tmpDir, "admin.key") + generateSelfSignedCert(t, certPath, keyPath) + + cfg := &config.AdminConfig{ + Port: plainPort, + AllowedIPs: []string{"127.0.0.1", "*"}, + TLS: config.AdminTLSConfig{ + Enabled: true, + Port: tlsPort, + CertPath: certPath, + KeyPath: keyPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_2", // pin to 1.2 so CipherSuites governs selection + Ciphers: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + EcdhCurves: "X25519,P-256", + }, + } + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + + server := NewServer(cfg, k, reg, &mockXDSSyncProvider{version: "pc-v11"}, nil, nil) + require.NotNil(t, server.tlsServer) + + ctx := context.Background() + errChan := make(chan error, 1) + go func() { errChan <- server.Start(ctx) }() + time.Sleep(100 * time.Millisecond) + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed cert + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, // offered but not configured server-side + }, + }, + }, + } + resp, err := httpsClient.Get(fmt.Sprintf("https://127.0.0.1:%d/health", tlsPort)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, resp.TLS) + assert.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), resp.TLS.CipherSuite) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, server.Stop(stopCtx)) + + select { + case startErr := <-errChan: + assert.NoError(t, startErr) + case <-time.After(2 * time.Second): + t.Fatal("Server did not stop within timeout") + } +} diff --git a/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go new file mode 100644 index 000000000..5f94e5ae6 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// adminEcdhCurvesByName maps the names accepted in AdminTLSConfig.EcdhCurves +// to Go's crypto/tls group identifiers. X25519MLKEM768 is the FIPS 203 +// ML-KEM-768 + X25519 hybrid group, implemented natively by Go 1.23+. +var adminEcdhCurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseAdminEcdhCurves parses a comma-separated EcdhCurves preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the tls.CurveID slice consumed by +// tls.Config.CurvePreferences. Used both to fail config validation closed on +// an unrecognized curve name and to build the admin TLS listener's config. +func ParseAdminEcdhCurves(raw string) ([]tls.CurveID, error) { + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := adminEcdhCurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported ecdh curve %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one ecdh curve") + } + return curves, nil +} + +// adminTLSVersionByName maps the version strings accepted in +// AdminTLSConfig.MinimumProtocolVersion/MaximumProtocolVersion to Go's +// crypto/tls version identifiers. Same vocabulary ("TLS1_2", etc.) as the +// router's downstream_tls/upstream_tls for consistency within this shared +// config file, even though the two are enforced by different TLS stacks +// (Envoy/BoringSSL for the router, Go's own crypto/tls here). +var adminTLSVersionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// adminTLSVersionOrder ranks the version names above so a min > max +// combination can be rejected at validation time. +var adminTLSVersionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ValidateAdminTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. +func ValidateAdminTLSVersions(minVersion, maxVersion string) error { + if _, ok := adminTLSVersionByName[minVersion]; !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := adminTLSVersionByName[maxVersion]; !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if adminTLSVersionOrder[minVersion] > adminTLSVersionOrder[maxVersion] { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseAdminTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateAdminTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseAdminTLSVersion(name string) (version uint16, ok bool) { + version, ok = adminTLSVersionByName[name] + return version, ok +} + +// adminCipherSuiteByName is built from Go's own list of secure cipher suites +// (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) so an +// operator can only ever restrict to suites Go itself considers safe, never +// re-enable a weak one. Includes the three TLS 1.3 suite names for +// completeness, though Go does not apply CipherSuites to TLS 1.3 — TLS 1.3 +// suite selection is not configurable and always uses Go's own safe set. +var adminCipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseAdminCiphers parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. Only +// affects TLS 1.2 (and below) connections; TLS 1.3 ignores this field. +func ParseAdminCiphers(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := adminCipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit ciphers entirely to use Go's default set") + } + return suites, nil +} diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config.go b/gateway/gateway-runtime/policy-engine/internal/config/config.go index 6d964587c..65826d7d9 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config.go @@ -280,6 +280,67 @@ type AdminConfig struct { // ConfigDump gates the /config_dump endpoint served on this admin server. ConfigDump ConfigDumpConfig `koanf:"config_dump"` + + // TLS starts a second, TLS-only listener on TLS.Port serving the same + // routes as the plaintext listener on Port. Off by default. + TLS AdminTLSConfig `koanf:"tls"` +} + +// AdminTLSConfig holds configuration for an additional TLS listener for the +// admin HTTP server. It is served alongside — not instead of — the plaintext +// listener on AdminConfig.Port, so enabling it never breaks an existing +// plaintext deployment. +type AdminTLSConfig struct { + // Enabled starts the TLS listener on Port. Off by default: no certificate + // is provisioned by default, and the plaintext listener keeps working + // either way. + Enabled bool `koanf:"enabled"` + + // Port is the port for the TLS admin listener. Must differ from every + // other configured policy-engine port (admin.port, server.extproc_port, + // metrics.port). + Port int `koanf:"port"` + + // CertPath and KeyPath are the PEM-encoded server certificate and private + // key for the TLS listener. Required when Enabled. + CertPath string `koanf:"cert_path"` + KeyPath string `koanf:"key_path"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same + // vocabulary as the router's downstream_tls/upstream_tls for consistency + // within this shared config file. + MinimumProtocolVersion string `koanf:"minimum_protocol_version"` + MaximumProtocolVersion string `koanf:"maximum_protocol_version"` + + // Ciphers is a comma-separated list of Go crypto/tls cipher suite names + // (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), restricting which + // suites this listener will negotiate. Empty by default, meaning Go's own + // secure default set/order applies. Only affects TLS 1.2 and below — + // TLS 1.3 suite selection is not configurable in Go's crypto/tls. + // + // Note this is a different naming scheme than the router's ciphers field + // (OpenSSL/BoringSSL names like "ECDHE-ECDSA-AES128-GCM-SHA256"): this + // listener is served by Go's own crypto/tls, not Envoy, so it uses Go's + // canonical cipher suite names — see crypto/tls.CipherSuites for the + // supported list. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups, + // most preferred first (e.g. "X25519,P-256"). Classical curves only by + // default. A hybrid post-quantum group ("X25519MLKEM768", FIPS 203 + // ML-KEM-768 + X25519) can be prepended as an explicit opt-in once the + // clients that reach this listener are confirmed to support it. + // + // Unlike the router's EcdhCurves (gateway-controller/pkg/config), this + // listener is served directly by this process's own Go crypto/tls + // (1.23+ implements X25519MLKEM768 natively) rather than pushed as xDS + // config to a separate Envoy process, so enabling the hybrid group here + // carries none of the "already-running peer NACKs the update" risk + // documented on the router's EcdhCurves field — TLS 1.3 negotiation + // simply falls back to a later classical entry in this same list for a + // client that doesn't offer the hybrid group. + EcdhCurves string `koanf:"ecdh_curves"` } // ConfigDumpConfig gates the /config_dump endpoint served on the admin HTTP @@ -536,6 +597,14 @@ func defaultConfig() *Config { ConfigDump: ConfigDumpConfig{ Enabled: false, }, + TLS: AdminTLSConfig{ + Enabled: false, + Port: 9004, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, }, Metrics: MetricsConfig{ Enabled: false, @@ -672,6 +741,34 @@ func (c *Config) Validate() error { if len(c.PolicyEngine.Admin.AllowedIPs) == 0 { return fmt.Errorf("admin.allowed_ips cannot be empty when admin is enabled") } + + // Validate admin TLS config + if c.PolicyEngine.Admin.TLS.Enabled { + if c.PolicyEngine.Admin.TLS.Port <= 0 || c.PolicyEngine.Admin.TLS.Port > 65535 { + return fmt.Errorf("invalid admin.tls.port: %d (must be 1-65535)", c.PolicyEngine.Admin.TLS.Port) + } + if c.PolicyEngine.Admin.TLS.Port == c.PolicyEngine.Admin.Port { + return fmt.Errorf("admin.tls.port cannot be same as admin.port") + } + if c.PolicyEngine.Server.Mode == "tcp" && c.PolicyEngine.Admin.TLS.Port == c.PolicyEngine.Server.ExtProcPort { + return fmt.Errorf("admin.tls.port cannot be same as server.extproc_port") + } + if c.PolicyEngine.Admin.TLS.CertPath == "" { + return fmt.Errorf("admin.tls.cert_path is required when admin.tls.enabled") + } + if c.PolicyEngine.Admin.TLS.KeyPath == "" { + return fmt.Errorf("admin.tls.key_path is required when admin.tls.enabled") + } + if err := ValidateAdminTLSVersions(c.PolicyEngine.Admin.TLS.MinimumProtocolVersion, c.PolicyEngine.Admin.TLS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("admin.tls: %w", err) + } + if _, err := ParseAdminCiphers(c.PolicyEngine.Admin.TLS.Ciphers); err != nil { + return fmt.Errorf("admin.tls.ciphers: %w", err) + } + if _, err := ParseAdminEcdhCurves(c.PolicyEngine.Admin.TLS.EcdhCurves); err != nil { + return fmt.Errorf("admin.tls.ecdh_curves: %w", err) + } + } } // Validate metrics config @@ -686,6 +783,9 @@ func (c *Config) Validate() error { if c.PolicyEngine.Metrics.Port == c.PolicyEngine.Admin.Port { return fmt.Errorf("metrics.port cannot be same as admin.port") } + if c.PolicyEngine.Admin.TLS.Enabled && c.PolicyEngine.Metrics.Port == c.PolicyEngine.Admin.TLS.Port { + return fmt.Errorf("metrics.port cannot be same as admin.tls.port") + } } if c.PolicyEngine.RequestBody.MaxDecompressedBytes <= 0 { diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go index c9e9d51a4..a29c2b4e9 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "crypto/tls" "math" "os" "path/filepath" @@ -479,6 +480,232 @@ func TestValidate_AdminConfig(t *testing.T) { expectErr: true, errMsg: "admin.allowed_ips cannot be empty", }, + { + name: "admin TLS enabled - valid config", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - PQC hybrid group opt-in", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - restricted cipher suite list", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: false, + }, + { + name: "admin TLS enabled - invalid port zero", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 0, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "invalid admin.tls.port", + }, + { + name: "admin TLS enabled - port conflicts with admin.port", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9002, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.port cannot be same as admin.port", + }, + { + name: "admin TLS enabled - port conflicts with extproc port (TCP mode)", + setup: func(cfg *Config) { + cfg.PolicyEngine.Server.Mode = "tcp" + cfg.PolicyEngine.Server.ExtProcPort = 9001 + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9001, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.port cannot be same as server.extproc_port", + }, + { + name: "admin TLS enabled - missing cert path", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.cert_path is required", + }, + { + name: "admin TLS enabled - missing key path", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.key_path is required", + }, + { + name: "admin TLS enabled - missing minimum protocol version", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "minimum_protocol_version", + }, + { + name: "admin TLS enabled - minimum protocol version greater than maximum", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_3", + MaximumProtocolVersion: "TLS1_2", + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "cannot be greater than maximum_protocol_version", + }, + { + name: "admin TLS enabled - unsupported cipher suite", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "TLS_RSA_WITH_RC4_128_SHA", // insecure, deliberately excluded + EcdhCurves: "X25519,P-256", + } + }, + expectErr: true, + errMsg: "admin.tls.ciphers", + }, + { + name: "admin TLS enabled - unsupported ecdh curve", + setup: func(cfg *Config) { + cfg.PolicyEngine.Admin.Enabled = true + cfg.PolicyEngine.Admin.Port = 9002 + cfg.PolicyEngine.Admin.AllowedIPs = []string{"127.0.0.1"} + cfg.PolicyEngine.Admin.TLS = AdminTLSConfig{ + Enabled: true, + Port: 9004, + CertPath: "./certs/admin.crt", + KeyPath: "./certs/admin.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + } + }, + expectErr: true, + errMsg: "admin.tls.ecdh_curves", + }, } for _, tt := range tests { @@ -497,6 +724,129 @@ func TestValidate_AdminConfig(t *testing.T) { } } +// TestParseAdminEcdhCurves tests the ECDH curve preference parser used by +// AdminTLSConfig.EcdhCurves. +func TestParseAdminEcdhCurves(t *testing.T) { + t.Run("classical curves only", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves("X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("PQC hybrid group prepended", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves("X25519MLKEM768,X25519,P-256") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + curves, err := ParseAdminEcdhCurves(" X25519 , P-256 ") + require.NoError(t, err) + assert.Equal(t, []tls.CurveID{tls.X25519, tls.CurveP256}, curves) + }) + + t.Run("unsupported curve name rejected", func(t *testing.T) { + _, err := ParseAdminEcdhCurves("X25519,not-a-curve") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported ecdh curve") + }) + + t.Run("empty string rejected", func(t *testing.T) { + _, err := ParseAdminEcdhCurves("") + assert.Error(t, err) + }) +} + +// TestValidateAdminTLSVersions tests the min/max protocol version validation +// used by AdminTLSConfig. +func TestValidateAdminTLSVersions(t *testing.T) { + t.Run("valid TLS1_2 to TLS1_3 range", func(t *testing.T) { + assert.NoError(t, ValidateAdminTLSVersions("TLS1_2", "TLS1_3")) + }) + + t.Run("equal min and max", func(t *testing.T) { + assert.NoError(t, ValidateAdminTLSVersions("TLS1_2", "TLS1_2")) + }) + + t.Run("unrecognized minimum version", func(t *testing.T) { + err := ValidateAdminTLSVersions("bogus", "TLS1_3") + assert.Error(t, err) + assert.Contains(t, err.Error(), "minimum_protocol_version") + }) + + t.Run("unrecognized maximum version", func(t *testing.T) { + err := ValidateAdminTLSVersions("TLS1_2", "bogus") + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum_protocol_version") + }) + + t.Run("minimum greater than maximum", func(t *testing.T) { + err := ValidateAdminTLSVersions("TLS1_3", "TLS1_2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be greater than maximum_protocol_version") + }) +} + +// TestParseAdminTLSVersion tests the version-name to crypto/tls-identifier +// conversion used by AdminTLSConfig. +func TestParseAdminTLSVersion(t *testing.T) { + tests := []struct { + name string + version string + want uint16 + }{ + {"TLS1_0", "TLS1_0", tls.VersionTLS10}, + {"TLS1_1", "TLS1_1", tls.VersionTLS11}, + {"TLS1_2", "TLS1_2", tls.VersionTLS12}, + {"TLS1_3", "TLS1_3", tls.VersionTLS13}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseAdminTLSVersion(tt.version) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("unrecognized version", func(t *testing.T) { + _, ok := ParseAdminTLSVersion("bogus") + assert.False(t, ok) + }) +} + +// TestParseAdminCiphers tests the cipher-suite-name parser used by +// AdminTLSConfig.Ciphers. +func TestParseAdminCiphers(t *testing.T) { + t.Run("empty string is valid and means Go's defaults", func(t *testing.T) { + suites, err := ParseAdminCiphers("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("restricts to the named secure suites", func(t *testing.T) { + suites, err := ParseAdminCiphers("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("whitespace tolerated", func(t *testing.T) { + suites, err := ParseAdminCiphers(" TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 ") + require.NoError(t, err) + assert.Equal(t, []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, suites) + }) + + t.Run("insecure cipher suite rejected", func(t *testing.T) { + _, err := ParseAdminCiphers("TLS_RSA_WITH_RC4_128_SHA") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported or insecure cipher suite") + }) + + t.Run("unrecognized cipher suite name rejected", func(t *testing.T) { + _, err := ParseAdminCiphers("NOT_A_REAL_SUITE") + assert.Error(t, err) + }) +} + // TestValidate_MetricsConfig tests metrics configuration validation func TestValidate_MetricsConfig(t *testing.T) { tests := []struct {