diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index dcee5ace3b..4bcc0c66fb 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -44,6 +44,7 @@ import ( "github.com/wso2/api-platform/common/eventhub" commonmodels "github.com/wso2/api-platform/common/models" "github.com/wso2/api-platform/common/webhooksecret" + "github.com/wso2/api-platform/httpkit/httpclient" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/adminserver" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers" @@ -460,7 +461,12 @@ func main() { serverOpts := []policyxds.ServerOption{policyxds.WithOnFirstConnect(policyEngineConnected)} if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS(cfg.Controller.PolicyServer.TLS.CertFile, cfg.Controller.PolicyServer.TLS.KeyFile)) + policyXDSTLSConfig, err := coreconfig.BuildXDSServerTLSConfig(cfg.Controller.PolicyServer.TLS) + if err != nil { + log.Error("invalid policy_server.tls config, refusing to start policy xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + serverOpts = append(serverOpts, policyxds.WithMTLS(policyXDSTLSConfig, cfg.Controller.PolicyServer.TLS.AllowedClientIdentities)) } policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, webhookSecretSnapshotManager, cfg.Controller.PolicyServer.Port, log, serverOpts...) go func() { @@ -481,7 +487,21 @@ func main() { policyValidator := coreconfig.NewPolicyValidator(policyDefinitions) validator.SetPolicyValidator(policyValidator) - apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) + // Build the single shared outbound *http.Client used by every control-plane / + // platform-API call this process makes, built once here and injected into every + // constructor below instead of each one building (or caching) its own. + httpClientCfg, err := coreconfig.BuildHTTPClientConfig(cfg.Controller.HTTPClient, cfg.Controller.ControlPlane.InsecureSkipVerify) + if err != nil { + log.Error("Invalid controller.http_client configuration", slog.Any("error", err)) + os.Exit(1) + } + httpClient, err := httpclient.New(httpClientCfg) + if err != nil { + log.Error("Failed to build HTTP client", slog.Any("error", err)) + os.Exit(1) + } + + apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService, httpClient) mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) @@ -489,7 +509,7 @@ func main() { cfg.Controller.ControlPlane, log, configStore, db, snapshotManager, validator, &cfg.Router, apiKeyXDSManager, apiKeyStore, &cfg.APIKey, policyManager, cfg, policyDefinitions, lazyResourceXDSManager, templateDefinitions, subscriptionSnapshotManager, eventHubInstance, - secretsService, webhookSecretStore, webhookSecretSnapshotManager, + secretsService, webhookSecretStore, webhookSecretSnapshotManager, httpClient, ) cpClient.SetControlPlaneEventGatewayHooks(controlplanehooks.Hooks{}) if err := cpClient.Start(); err != nil { @@ -499,8 +519,6 @@ func main() { llmSvc.SetControlPlanePusher(cpClient, cfg.Controller.ControlPlane.DeploymentSyncEnabled) mcpSvc.SetControlPlanePusher(cpClient, cfg.Controller.ControlPlane.DeploymentSyncEnabled) - httpClient := &http.Client{Timeout: 10 * time.Second} - restAPIService := restapi.NewRestAPIService( configStore, db, snapshotManager, policyManager, apiSvc, apiKeyXDSManager, cpClient, &cfg.Router, cfg, httpClient, coreconfig.NewParser(), validator, log, @@ -544,11 +562,15 @@ func main() { os.Exit(1) } - apiServer := handlers.NewAPIServer( + apiServer, err := handlers.NewAPIServer( configStore, db, snapshotManager, policyManager, lazyResourceXDSManager, log, cpClient, policyDefinitions, templateDefinitions, validator, apiKeyXDSManager, cfg, eventHubInstance, - subscriptionSnapshotManager, secretsService, restAPIService, + subscriptionSnapshotManager, secretsService, restAPIService, httpClient, ) + if err != nil { + log.Error("Failed to create API server", slog.Any("error", err)) + os.Exit(1) + } eventGatewayHandler := handler.NewWebSubServer(handler.Deps{ Store: configStore, @@ -627,7 +649,7 @@ func main() { var controllerAdminServer *adminserver.Server if cfg.Controller.AdminServer.Enabled { adminAuthz := authenticators.AuthorizationMiddleware( - commonmodels.AuthConfig{ResourceRoles: adminResourceRoles()}, log) + commonmodels.AuthConfig{ResourceRoles: adminResourceRoles(), HTTPClient: httpClient}, log) adminProtect := func(next http.Handler) http.Handler { return authMiddleWare(adminAuthz(next)) } @@ -810,9 +832,16 @@ func generateAuthConfig(cfg *coreconfig.Config) (commonmodels.AuthConfig, error) }, nil } +// adminResourceRoles builds the deny-by-default scope map for the controller +// admin/debug server (config_dump, xds_sync_status, pprof), gating every +// endpoint except the public health probe behind the "admin" role. Mirrors +// gateway-controller's core main.go adminResourceRoles implementation. func adminResourceRoles() map[string][]string { const adminRole = "admin" + // prefixed builds a resource key of the form " " + // matching the versioned routes registered via HandlerWithOptions(BaseURL=AdminAPIBasePath), + // mirroring the prefixed helper in generateAuthConfig. prefixed := func(methodAndPath string) string { idx := strings.Index(methodAndPath, " ") if idx < 0 { @@ -826,8 +855,10 @@ func adminResourceRoles() map[string][]string { "GET /xds_sync_status": {adminRole}, } - // pprof endpoints are registered directly on the mux rather than under a - // BaseURL, so their pattern carries no method and no base-path prefix. + // pprof endpoints are registered directly on the mux (not via a BaseURL), so + // their r.Pattern carries no method and no base-path prefix — map them as-is. + // Only registered when admin_server.pprof is enabled, but mapping them + // unconditionally is harmless and keeps them admin-gated when it is. pprofRoles := map[string][]string{ "/debug/pprof/": {adminRole}, "/debug/pprof/cmdline": {adminRole}, @@ -836,8 +867,8 @@ func adminResourceRoles() map[string][]string { "/debug/pprof/trace": {adminRole}, } - // The admin API is served on both the versioned and the legacy unprefixed - // paths, so both keys are needed for the authz middleware to match. + // Populate both the versioned and legacy (unprefixed) keys for each relative + // route so the authz middleware matches either form, exactly as generateAuthConfig does. resourceRoles := make(map[string][]string, len(relativeRoles)*2+len(pprofRoles)) for methodAndPath, roles := range relativeRoles { resourceRoles[prefixed(methodAndPath)] = roles diff --git a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go index 33cfac5226..84e7096eed 100644 --- a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go +++ b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go @@ -51,6 +51,7 @@ func registerConnectors(registry *connectors.Registry, cfg *config.Config) { DeliveryConcurrency: cfg.WebSub.DeliveryConcurrency, RuntimeID: cfg.RuntimeID, ConsumerGroupPrefix: cfg.Kafka.ConsumerGroupPrefix, + HTTPClient: cfg.HTTPClient, }) }) diff --git a/event-gateway/gateway-runtime/configs/config.toml b/event-gateway/gateway-runtime/configs/config.toml index 177bd31058..2f538c2c1a 100644 --- a/event-gateway/gateway-runtime/configs/config.toml +++ b/event-gateway/gateway-runtime/configs/config.toml @@ -18,6 +18,17 @@ websub_https_port = 8443 websub_tls_enabled = true websub_tls_cert_file = "/etc/event-gateway/tls/default-listener.crt" websub_tls_key_file = "/etc/event-gateway/tls/default-listener.key" +# Inbound TLS tuning for the WebSub-HTTPS listener. All four fields are optional — +# empty uses Go's crypto/tls defaults — but min/max version must both be set or both +# left empty. Same vocabulary as http_client.tls above. +websub_tls_min_version = "TLS1_2" +websub_tls_max_version = "TLS1_3" +# Hybrid PQC group listed first with classical fallbacks after — a peer that doesn't +# yet support X25519MLKEM768 still succeeds via the later entries. +websub_tls_curve_preferences = "X25519MLKEM768,X25519,P-256" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. +# Empty uses Go's own default secure set. +websub_tls_cipher_suites = "" websocket_port = 8081 # HTTPS port for WebSocket server (used when TLS is enabled for WebBrokerApi) websocket_https_port = 8444 @@ -25,6 +36,11 @@ websocket_https_port = 8444 websocket_tls_enabled = true websocket_tls_cert_file = "/etc/event-gateway/tls/default-listener.crt" websocket_tls_key_file = "/etc/event-gateway/tls/default-listener.key" +# Inbound TLS tuning for the WebSocket-HTTPS listener — same shape as websub_tls_* above. +websocket_tls_min_version = "TLS1_2" +websocket_tls_max_version = "TLS1_3" +websocket_tls_curve_preferences = "X25519MLKEM768,X25519,P-256" +websocket_tls_cipher_suites = "" admin_port = 9002 metrics_port = 9003 @@ -59,6 +75,87 @@ subscriptions_topic_name = "__subscriptions" # config_file = "" # chains_file = "" +# Shared HTTP client configuration for WebSub's outbound calls to subscriber-supplied +# CallbackURLs: intent verification (subscribe/unsubscribe challenge — see +# internal/connectors/receiver/websub/verification.go) and message delivery (see +# .../delivery.go). Both clients share this same pooling/TLS/proxy/SSRF posture; each +# keeps its own existing timeout (websub.verification_timeout_seconds above for +# verification; a fixed 30s delivery timeout for delivery) which always overrides +# http_client.timeouts.overall below for that client. +# +# SSRF guarding is ALWAYS enabled for both clients and is not configurable off: every +# CallbackURL dialed here is tenant/subscriber-supplied, exactly the scenario +# ssrf-prevention.md targets. netguard.PermitPrivateBlockMetadata() is applied +# unconditionally in code (private/loopback backends stay reachable — in-cluster +# subscribers are the common case — while link-local/metadata/unspecified/multicast +# destinations are refused at dial time). Only the redirect/scheme knobs below +# (http_client.ssrf) are configurable. + +[http_client.pooling] +max_idle_conns = 100 +max_idle_conns_per_host = 10 +max_conns_per_host = 100 +idle_conn_timeout = "90s" +keep_alive = "30s" +disable_keep_alives = false +# HTTP/2 is off by default: this client always sets a custom TLS config, which makes Go's +# Transport conservatively disable HTTP/2 unless re-enabled here — only opt in after +# considering the connection-coalescing caveat in httpclient.PoolingConfig.EnableHTTP2's doc. +enable_http2 = false + +[http_client.timeouts] +# Common default only: the Verifier and Deliverer each override this with their own +# existing per-call-site timeout — see this section's header comment above. +overall = "30s" +dial = "10s" +tls_handshake = "10s" +response_header = "10s" +expect_continue = "1s" +# 0 = package default (10MiB); a negative value disables the response-size bound entirely. +max_response_bytes = 0 + +[http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. Hybrid PQC group +# listed first with classical fallbacks after — a peer that doesn't yet support +# X25519MLKEM768 still succeeds via the later entries. +curve_preferences = "X25519MLKEM768,X25519,P-256" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. Empty +# uses Go's own default secure set. +cipher_suites = "" +# PEM CA bundle / mTLS client cert+key for the outbound call to the subscriber callback. +# Empty uses the system root pool and no client certificate. +root_ca_file = "" +client_cert_file = "" +client_key_file = "" + +[http_client.proxy] +# "none" (default) | "environment" (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) | "url" +mode = "none" +url = "" +username = "" +password = "" +no_proxy = [] +# Required ("delegated" or "manual_connect") whenever mode != "none" — SSRF guarding is +# always enabled for this client, see this section's header comment above. +egress = "" + +[http_client.proxy.tls] +# Configures a SEPARATE TLS handshake to an https:// proxy itself, decoupled from +# http_client.tls above (which always governs the origin handshake). +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +insecure_skip_verify = false + +[http_client.ssrf] +# SSRF guarding itself is always enabled in code (not configurable off — see this +# section's header comment above); only the redirect/scheme knobs are exposed here. +# 0 uses netguard's own default (5 redirects); empty allowed_schemes defaults to {"https"}. +max_redirects = 0 +allowed_schemes = [] + [logging] level = "info" format = "text" diff --git a/event-gateway/gateway-runtime/go.mod b/event-gateway/gateway-runtime/go.mod index 73d0fa5982..4c7824a7cb 100644 --- a/event-gateway/gateway-runtime/go.mod +++ b/event-gateway/gateway-runtime/go.mod @@ -15,6 +15,7 @@ require ( github.com/wso2/api-platform/common v0.0.0 github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine v0.0.0-00010101000000-000000000000 github.com/wso2/api-platform/sdk/core v0.4.0 + github.com/wso2/api-platform/httpkit v0.0.0-local google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -46,7 +47,6 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect - github.com/wso2/api-platform/httpkit v0.0.0-local // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect diff --git a/event-gateway/gateway-runtime/internal/config/config.go b/event-gateway/gateway-runtime/internal/config/config.go index a5aaf4666a..26ad4c2541 100644 --- a/event-gateway/gateway-runtime/internal/config/config.go +++ b/event-gateway/gateway-runtime/internal/config/config.go @@ -24,11 +24,14 @@ import ( "os" "strconv" "strings" + "time" "github.com/knadh/koanf/parsers/toml/v2" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" + + "github.com/wso2/api-platform/httpkit/tlsconfig" ) // Config is the top-level runtime configuration for the event gateway. @@ -39,24 +42,33 @@ type Config struct { PolicyEngine PolicyEngineConfig `koanf:"policy_engine"` ControlPlane ControlPlaneConfig `koanf:"controlplane"` Logging LoggingConfig `koanf:"logging"` + HTTPClient HTTPClientConfig `koanf:"http_client"` RuntimeID string `koanf:"runtime_id"` } // ServerConfig holds HTTP/WS server settings. type ServerConfig struct { - WebSubEnabled bool `koanf:"websub_enabled"` - WebSubHTTPPort int `koanf:"websub_http_port"` - WebSubHTTPSPort int `koanf:"websub_https_port"` - WebSubTLSEnabled bool `koanf:"websub_tls_enabled"` - WebSubTLSCertFile string `koanf:"websub_tls_cert_file"` - WebSubTLSKeyFile string `koanf:"websub_tls_key_file"` - WebSocketPort int `koanf:"websocket_port"` - WebSocketHTTPSPort int `koanf:"websocket_https_port"` - WebSocketTLSEnabled bool `koanf:"websocket_tls_enabled"` - WebSocketTLSCertFile string `koanf:"websocket_tls_cert_file"` - WebSocketTLSKeyFile string `koanf:"websocket_tls_key_file"` - AdminPort int `koanf:"admin_port"` - MetricsPort int `koanf:"metrics_port"` + WebSubEnabled bool `koanf:"websub_enabled"` + WebSubHTTPPort int `koanf:"websub_http_port"` + WebSubHTTPSPort int `koanf:"websub_https_port"` + WebSubTLSEnabled bool `koanf:"websub_tls_enabled"` + WebSubTLSCertFile string `koanf:"websub_tls_cert_file"` + WebSubTLSKeyFile string `koanf:"websub_tls_key_file"` + WebSubTLSMinVersion string `koanf:"websub_tls_min_version"` + WebSubTLSMaxVersion string `koanf:"websub_tls_max_version"` + WebSubTLSCipherSuites string `koanf:"websub_tls_cipher_suites"` + WebSubTLSCurvePreferences string `koanf:"websub_tls_curve_preferences"` + WebSocketPort int `koanf:"websocket_port"` + WebSocketHTTPSPort int `koanf:"websocket_https_port"` + WebSocketTLSEnabled bool `koanf:"websocket_tls_enabled"` + WebSocketTLSCertFile string `koanf:"websocket_tls_cert_file"` + WebSocketTLSKeyFile string `koanf:"websocket_tls_key_file"` + WebSocketTLSMinVersion string `koanf:"websocket_tls_min_version"` + WebSocketTLSMaxVersion string `koanf:"websocket_tls_max_version"` + WebSocketTLSCipherSuites string `koanf:"websocket_tls_cipher_suites"` + WebSocketTLSCurvePreferences string `koanf:"websocket_tls_curve_preferences"` + AdminPort int `koanf:"admin_port"` + MetricsPort int `koanf:"metrics_port"` } // KafkaConfig holds Kafka connection settings. @@ -103,6 +115,113 @@ type LoggingConfig struct { Format string `koanf:"format"` } +// HTTPClientConfig configures the shared outbound *http.Client used by WebSub's +// subscribe/unsubscribe intent verification (Verifier, see +// internal/connectors/receiver/websub/verification.go) and message delivery +// (Deliverer, see .../delivery.go). Both call sites dial a tenant/subscriber-supplied +// CallbackURL and share the identical pooling/TLS/proxy/SSRF posture, so a single +// [http_client] TOML section configures both; each call site still keeps its own +// existing timeout parameter/field (Verifier's `timeout` argument, sourced from +// websub.verification_timeout_seconds; Deliverer's own delivery timeout), which always +// overrides Timeouts.Overall below for that specific client — see BuildHTTPClientConfig. +// +// This mirrors the TOML-expressible subset of +// github.com/wso2/api-platform/httpkit/httpclient.Config (see gateway-controller's +// pkg/config.HTTPClientConfig for the reference shape this intentionally mirrors +// field-for-field, aside from SSRF below). Go-only callback hooks +// (GetClientCertificate, VerifyPeerCertificate, VerifyConnection, ConnectHeader) and a +// pre-built *x509.CertPool have no TOML shape and are not represented here. +// +// Unlike gateway-controller's HTTPClientConfig, SSRF protection is NOT configurable +// off: every CallbackURL dialed by either client is tenant/subscriber-supplied — +// exactly the scenario ssrf-prevention.md targets — so SSRF.Enabled=true and +// netguard.PermitPrivateBlockMetadata() are hardcoded in BuildHTTPClientConfig rather +// than sourced from this struct. Only the redirect/scheme knobs netguard exposes are +// configurable, via HTTPClientSSRFConfig. +type HTTPClientConfig struct { + Pooling HTTPClientPoolingConfig `koanf:"pooling"` + Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` + TLS HTTPClientTLSConfig `koanf:"tls"` + Proxy HTTPClientProxyConfig `koanf:"proxy"` + SSRF HTTPClientSSRFConfig `koanf:"ssrf"` +} + +// HTTPClientPoolingConfig mirrors httpclient.PoolingConfig. +type HTTPClientPoolingConfig struct { + MaxIdleConns int `koanf:"max_idle_conns"` + MaxIdleConnsPerHost int `koanf:"max_idle_conns_per_host"` + MaxConnsPerHost int `koanf:"max_conns_per_host"` + IdleConnTimeout time.Duration `koanf:"idle_conn_timeout"` + KeepAlive time.Duration `koanf:"keep_alive"` + DisableKeepAlives bool `koanf:"disable_keep_alives"` + // EnableHTTP2 opts into HTTP/2. See httpclient.PoolingConfig.EnableHTTP2's doc comment + // on the HTTP/2 connection-coalescing caveat before enabling. + EnableHTTP2 bool `koanf:"enable_http2"` +} + +// HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. Overall is only a common +// default shared by both call sites — see HTTPClientConfig's doc comment for why each +// call site's own existing timeout always takes precedence over it. +type HTTPClientTimeoutsConfig struct { + Overall time.Duration `koanf:"overall"` + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + MaxResponseBytes int64 `koanf:"max_response_bytes"` // 0 = package default (10MiB); negative disables the bound +} + +// HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig. +type HTTPClientTLSConfig struct { + MinVersion string `koanf:"min_version"` // one of "TLS1_0".."TLS1_3" + MaxVersion string `koanf:"max_version"` // one of "TLS1_0".."TLS1_3" + CipherSuites string `koanf:"cipher_suites"` // comma-separated Go crypto/tls cipher suite names; TLS 1.2 and below only + CurvePreferences string `koanf:"curve_preferences"` // comma-separated, e.g. "X25519MLKEM768,X25519,P-256" + RootCAFile string `koanf:"root_ca_file"` // PEM CA bundle; empty uses the system root pool + ClientCertFile string `koanf:"client_cert_file"` // mTLS to the callback endpoint; both cert and key must be set together + ClientKeyFile string `koanf:"client_key_file"` +} + +// HTTPClientProxyConfig mirrors the TOML-expressible subset of httpclient.ProxyConfig. +type HTTPClientProxyConfig struct { + // Mode selects how the proxy is determined: "none" (default), "environment" + // (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), or "url" (URL/Username/Password/NoProxy below). + Mode string `koanf:"mode"` + URL string `koanf:"url"` + Username string `koanf:"username"` + Password string `koanf:"password"` + NoProxy []string `koanf:"no_proxy"` // exact host, ".suffix", or CIDR entries; only used when mode == "url" + + TLS HTTPClientProxyTLSConfig `koanf:"tls"` + + // Egress states how origin-destination SSRF risk is handled when a forward proxy is + // also configured: "delegated" (trust the proxy's own egress controls) or + // "manual_connect" (validate the origin locally before ever issuing CONNECT). Must be + // set explicitly whenever Mode != "none" — SSRF guarding is always enabled for this + // client (see HTTPClientConfig's doc comment), unlike gateway-controller where this + // is only required when SSRF is ALSO enabled. BuildHTTPClientConfig fails closed at + // config-build time otherwise rather than silently choosing one. + Egress string `koanf:"egress"` +} + +// HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS +// handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). +type HTTPClientProxyTLSConfig struct { + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` +} + +// HTTPClientSSRFConfig mirrors the TOML-expressible redirect/scheme knobs of +// httpclient.SSRFConfig. It deliberately has NO Enabled/Preset field: SSRF guarding for +// this client is always on with netguard.PermitPrivateBlockMetadata() (see +// HTTPClientConfig's doc comment) — there is no supported way to disable it via config. +type HTTPClientSSRFConfig struct { + MaxRedirects int `koanf:"max_redirects"` // 0 uses netguard's own default (5) + AllowedSchemes []string `koanf:"allowed_schemes"` // empty defaults to {"https"} +} + // DefaultConfig returns configuration with sensible defaults. func DefaultConfig() *Config { return &Config{ @@ -132,6 +251,30 @@ func DefaultConfig() *Config { Level: "info", Format: "text", }, + HTTPClient: HTTPClientConfig{ + Pooling: HTTPClientPoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + }, + Timeouts: HTTPClientTimeoutsConfig{ + Overall: 30 * time.Second, // common default only; each call site's own timeout overrides this + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + }, + TLS: HTTPClientTLSConfig{ + MinVersion: "TLS1_2", + MaxVersion: "TLS1_3", + CurvePreferences: "X25519MLKEM768,X25519,P-256", + }, + Proxy: HTTPClientProxyConfig{ + Mode: "none", + }, + }, } } @@ -277,6 +420,9 @@ func validate(cfg *Config) error { if err := validateReadableFile(cfg.Server.WebSubTLSKeyFile, "server.websub_tls_key_file", "server.websub_tls_enabled"); err != nil { return err } + if err := validateListenerTLSTuning("server.websub_tls", cfg.Server.WebSubTLSMinVersion, cfg.Server.WebSubTLSMaxVersion, cfg.Server.WebSubTLSCipherSuites, cfg.Server.WebSubTLSCurvePreferences); err != nil { + return err + } } if cfg.Server.WebSocketTLSEnabled { @@ -286,6 +432,9 @@ func validate(cfg *Config) error { if err := validateReadableFile(cfg.Server.WebSocketTLSKeyFile, "server.websocket_tls_key_file", "server.websocket_tls_enabled"); err != nil { return err } + if err := validateListenerTLSTuning("server.websocket_tls", cfg.Server.WebSocketTLSMinVersion, cfg.Server.WebSocketTLSMaxVersion, cfg.Server.WebSocketTLSCipherSuites, cfg.Server.WebSocketTLSCurvePreferences); err != nil { + return err + } } switch cfg.Logging.Level { @@ -307,6 +456,25 @@ func validate(cfg *Config) error { return nil } +// validateListenerTLSTuning validates the optional min/max TLS version, +// cipher suite, and curve preference tuning for one of the inbound HTTPS +// listeners (WebSub or WebSocket). All four fields are optional — leaving +// them empty defers to Go's own crypto/tls defaults — but if set, they must +// name a value tlsconfig recognizes. fieldPrefix is used to qualify the +// returned error (e.g. "server.websub_tls"). +func validateListenerTLSTuning(fieldPrefix, minVersion, maxVersion, cipherSuites, curvePreferences string) error { + if err := tlsconfig.ValidateVersionRange(minVersion, maxVersion); err != nil { + return fmt.Errorf("%s_min_version/%s_max_version: %w", fieldPrefix, fieldPrefix, err) + } + if _, err := tlsconfig.ParseCipherSuites(cipherSuites); err != nil { + return fmt.Errorf("%s_cipher_suites: %w", fieldPrefix, err) + } + if _, err := tlsconfig.ParseCurvePreferences(curvePreferences); err != nil { + return fmt.Errorf("%s_curve_preferences: %w", fieldPrefix, err) + } + return nil +} + func validateKafkaConfig(kafkaCfg KafkaConfig) error { if len(kafkaCfg.Brokers) == 0 { return fmt.Errorf("kafka.brokers must contain at least one broker") diff --git a/event-gateway/gateway-runtime/internal/config/httpclient.go b/event-gateway/gateway-runtime/internal/config/httpclient.go new file mode 100644 index 0000000000..84f3f21e21 --- /dev/null +++ b/event-gateway/gateway-runtime/internal/config/httpclient.go @@ -0,0 +1,111 @@ +/* + * 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 ( + "fmt" + + "github.com/wso2/api-platform/httpkit/httpclient" + "github.com/wso2/api-platform/httpkit/netguard" +) + +// BuildHTTPClientConfig translates HTTPClientConfig (sourced from the [http_client] +// section in config.toml) into an httpkit httpclient.Config for the WebSub Verifier and +// Deliverer HTTP clients (see HTTPClientConfig's doc comment for the shared-config +// rationale). The caller is expected to override the returned Timeouts.Overall with its +// own existing per-call-site timeout (Verifier's `timeout` argument / Deliverer's own +// delivery timeout) — this function only fills in the common default. +// +// SSRF guarding is unconditionally enabled here with netguard.PermitPrivateBlockMetadata() +// — every caller of this shared config dials a tenant/subscriber-supplied CallbackURL, so +// unlike gateway-controller's analogous translation there is no Enabled/Preset switch to +// interpret; only the redirect/scheme knobs in HTTPClientSSRFConfig are read from config. +func BuildHTTPClientConfig(hc HTTPClientConfig) (httpclient.Config, error) { + cfg := httpclient.DefaultConfig() + + cfg.Pooling.MaxIdleConns = hc.Pooling.MaxIdleConns + cfg.Pooling.MaxIdleConnsPerHost = hc.Pooling.MaxIdleConnsPerHost + cfg.Pooling.MaxConnsPerHost = hc.Pooling.MaxConnsPerHost + cfg.Pooling.IdleConnTimeout = hc.Pooling.IdleConnTimeout + cfg.Pooling.KeepAlive = hc.Pooling.KeepAlive + cfg.Pooling.DisableKeepAlives = hc.Pooling.DisableKeepAlives + cfg.Pooling.EnableHTTP2 = hc.Pooling.EnableHTTP2 + + cfg.Timeouts.Overall = hc.Timeouts.Overall + cfg.Timeouts.Dial = hc.Timeouts.Dial + cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake + cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader + cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes + + cfg.TLS.MinVersion = hc.TLS.MinVersion + cfg.TLS.MaxVersion = hc.TLS.MaxVersion + cfg.TLS.CipherSuites = hc.TLS.CipherSuites + cfg.TLS.CurvePreferences = hc.TLS.CurvePreferences + cfg.TLS.RootCAFile = hc.TLS.RootCAFile + cfg.TLS.ClientCertFile = hc.TLS.ClientCertFile + cfg.TLS.ClientKeyFile = hc.TLS.ClientKeyFile + // InsecureSkipVerify is intentionally not exposed here: unlike gateway-controller + // there is no existing single source-of-truth boolean for it to reuse, and these + // clients dial arbitrary tenant-supplied CallbackURLs, so it stays at its safe + // default (verified) rather than becoming a per-client opt-out. + + switch hc.Proxy.Mode { + case "", "none": + // no proxy — cfg.Proxy stays at its zero value + case "environment": + cfg.Proxy.Mode = "environment" + case "url": + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = hc.Proxy.URL + cfg.Proxy.Username = hc.Proxy.Username + cfg.Proxy.Password = hc.Proxy.Password + cfg.Proxy.NoProxy = hc.Proxy.NoProxy + if hc.Proxy.TLS != (HTTPClientProxyTLSConfig{}) { + cfg.Proxy.ProxyTLS = &httpclient.ProxyTLSConfig{ + RootCAFile: hc.Proxy.TLS.RootCAFile, + ClientCertFile: hc.Proxy.TLS.ClientCertFile, + ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, + InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerify, + } + } + default: + return httpclient.Config{}, fmt.Errorf("http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) + } + + // Always on — see this function's doc comment and HTTPClientConfig's doc comment. + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + cfg.SSRF.Policy.AllowedSchemes = hc.SSRF.AllowedSchemes + cfg.SSRF.MaxRedirects = hc.SSRF.MaxRedirects + + if cfg.Proxy.Mode != "" && cfg.Proxy.Mode != "none" { + switch hc.Proxy.Egress { + case "delegated": + cfg.Proxy.Egress = httpclient.ProxyEgressDelegated + case "manual_connect": + cfg.Proxy.Egress = httpclient.ProxyEgressManualCONNECT + default: + return httpclient.Config{}, fmt.Errorf("http_client.proxy.egress must be \"delegated\" or \"manual_connect\" when http_client.proxy.mode is set (SSRF guarding is always enabled for this client), got %q", hc.Proxy.Egress) + } + } + + return cfg, nil +} diff --git a/event-gateway/gateway-runtime/internal/config/httpclient_test.go b/event-gateway/gateway-runtime/internal/config/httpclient_test.go new file mode 100644 index 0000000000..fe31ece996 --- /dev/null +++ b/event-gateway/gateway-runtime/internal/config/httpclient_test.go @@ -0,0 +1,122 @@ +/* + * 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 ( + "reflect" + "testing" + "time" + + "github.com/wso2/api-platform/httpkit/netguard" +) + +func defaultHTTPClientConfigForTest() HTTPClientConfig { + return DefaultConfig().HTTPClient +} + +func TestBuildHTTPClientConfigDefaultsAlwaysEnableSSRFGuard(t *testing.T) { + cfg, err := BuildHTTPClientConfig(defaultHTTPClientConfigForTest()) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + + if !cfg.SSRF.Enabled { + t.Fatal("expected SSRF.Enabled to always be true, got false") + } + wantPolicy := netguard.PermitPrivateBlockMetadata() + if !reflect.DeepEqual(cfg.SSRF.Policy, wantPolicy) { + t.Fatalf("expected SSRF.Policy to be PermitPrivateBlockMetadata, got %+v", cfg.SSRF.Policy) + } + if cfg.Proxy.Mode != "" { + t.Fatalf("expected no proxy mode by default, got %q", cfg.Proxy.Mode) + } +} + +func TestBuildHTTPClientConfigCarriesPoolingTimeoutsAndTLS(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Pooling.MaxIdleConns = 42 + hc.Timeouts.Dial = 5 * time.Second + hc.TLS.MinVersion = "TLS1_3" + hc.SSRF.MaxRedirects = 3 + hc.SSRF.AllowedSchemes = []string{"https", "http"} + + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + + if cfg.Pooling.MaxIdleConns != 42 { + t.Errorf("expected Pooling.MaxIdleConns=42, got %d", cfg.Pooling.MaxIdleConns) + } + if cfg.Timeouts.Dial != 5*time.Second { + t.Errorf("expected Timeouts.Dial=5s, got %v", cfg.Timeouts.Dial) + } + if cfg.TLS.MinVersion != "TLS1_3" { + t.Errorf("expected TLS.MinVersion=TLS1_3, got %q", cfg.TLS.MinVersion) + } + if cfg.SSRF.MaxRedirects != 3 { + t.Errorf("expected SSRF.MaxRedirects=3, got %d", cfg.SSRF.MaxRedirects) + } + if len(cfg.SSRF.Policy.AllowedSchemes) != 2 || cfg.SSRF.Policy.AllowedSchemes[0] != "https" { + t.Errorf("expected SSRF.Policy.AllowedSchemes=[https http], got %v", cfg.SSRF.Policy.AllowedSchemes) + } +} + +func TestBuildHTTPClientConfigRejectsUnknownProxyMode(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "socks5" + + if _, err := BuildHTTPClientConfig(hc); err == nil { + t.Fatal("expected error for unrecognized proxy.mode, got nil") + } +} + +func TestBuildHTTPClientConfigRequiresEgressWhenProxyConfigured(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "url" + hc.Proxy.URL = "https://proxy.example.com:3128" + hc.Proxy.Egress = "" + + if _, err := BuildHTTPClientConfig(hc); err == nil { + t.Fatal("expected error when proxy is configured without an explicit egress policy, got nil") + } + + hc.Proxy.Egress = "delegated" + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error with valid egress: %v", err) + } + if cfg.Proxy.Mode != "url" || cfg.Proxy.URL != hc.Proxy.URL { + t.Errorf("expected proxy url mode carried through, got %+v", cfg.Proxy) + } +} + +func TestBuildHTTPClientConfigEnvironmentProxyMode(t *testing.T) { + hc := defaultHTTPClientConfigForTest() + hc.Proxy.Mode = "environment" + hc.Proxy.Egress = "manual_connect" + + cfg, err := BuildHTTPClientConfig(hc) + if err != nil { + t.Fatalf("BuildHTTPClientConfig returned error: %v", err) + } + if cfg.Proxy.Mode != "environment" { + t.Errorf("expected proxy mode environment, got %q", cfg.Proxy.Mode) + } +} diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go index 1cbda9e9ef..4d6807576c 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go @@ -25,6 +25,7 @@ import ( "time" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/binding" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" ) @@ -40,6 +41,10 @@ type Options struct { DeliveryConcurrency int RuntimeID string ConsumerGroupPrefix string + // HTTPClient configures the shared outbound HTTP client used by both the + // Verifier (subscribe/unsubscribe intent verification) and the Deliverer + // (message delivery) — see config.HTTPClientConfig's doc comment. + HTTPClient config.HTTPClientConfig } // WebSubReceiver is a multi-channel WebSub receiver. @@ -75,12 +80,16 @@ func NewReceiver(cfg connectors.ReceiverConfig, opts Options) (connectors.Receiv topics.Register(cfg.Channel.PublicTopic) } - deliverer := NewDeliverer(DeliveryConfig{ + deliverer, err := NewDeliverer(DeliveryConfig{ MaxRetries: opts.DeliveryMaxRetries, InitialDelayMs: opts.DeliveryInitialDelayMs, MaxDelayMs: opts.DeliveryMaxDelayMs, Concurrency: opts.DeliveryConcurrency, + HTTPClient: opts.HTTPClient, }) + if err != nil { + return nil, fmt.Errorf("failed to create deliverer: %w", err) + } // Create consumer manager for per-callback consumers. consumerMgr := NewConsumerManager( @@ -100,11 +109,15 @@ func NewReceiver(cfg connectors.ReceiverConfig, opts Options) (connectors.Receiv verificationTimeout := time.Duration(opts.VerificationTimeoutSeconds) * time.Second // Create HubHandler for subscribe/unsubscribe on {context}/{version}/hub. - hubHandler := NewHubHandler( + hubHandler, err := NewHubHandler( topics, store, verificationTimeout, opts.DefaultLeaseSeconds, cfg.Processor, cfg.BrokerDriver, cfg.Channel.Name, cfg.Channel.Channels, consumerMgr, syncProducer, + opts.HTTPClient, ) + if err != nil { + return nil, fmt.Errorf("failed to create hub handler: %w", err) + } // Create WebhookReceiverHandler for ingress on {context}/{version}/webhook-receiver. webhookHandler := NewWebhookReceiverHandler( diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go index fbda44619b..3ac85cf4e5 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/delivery.go @@ -30,7 +30,9 @@ import ( "strings" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" + "github.com/wso2/api-platform/httpkit/httpclient" ) // DeliveryConfig holds configuration for the delivery engine. @@ -39,6 +41,11 @@ type DeliveryConfig struct { InitialDelayMs int MaxDelayMs int Concurrency int + // HTTPClient sources pooling/TLS/proxy/SSRF-redirect knobs from config.toml's + // [http_client] section (shared with the Verifier). The delivery timeout below + // remains this call site's own existing value and always overrides + // HTTPClient.Timeouts.Overall — see config.HTTPClientConfig's doc comment. + HTTPClient config.HTTPClientConfig } // Deliverer delivers events to a single subscriber callback URL. @@ -47,12 +54,31 @@ type Deliverer struct { client *http.Client } -// NewDeliverer creates a new Deliverer. -func NewDeliverer(config DeliveryConfig) *Deliverer { - return &Deliverer{ - config: config, - client: &http.Client{Timeout: 30 * time.Second}, +// NewDeliverer creates a new Deliverer. The delivery HTTP client is built +// with the shared httpkit SSRF dial-guard enabled (see ssrf-prevention.md): +// the subscriber CallbackURL is tenant-supplied, so private/loopback +// backends must remain reachable (in-cluster subscribers are the common +// case) while link-local/metadata/unspecified/multicast destinations are +// refused at dial time. Pooling/TLS/proxy knobs come from +// deliveryConfig.HTTPClient (config.toml's [http_client] section, shared +// with the Verifier); the 30s delivery timeout below is this call site's +// own existing value and always overrides HTTPClient.Timeouts.Overall. +func NewDeliverer(deliveryConfig DeliveryConfig) (*Deliverer, error) { + cfg, err := config.BuildHTTPClientConfig(deliveryConfig.HTTPClient) + if err != nil { + return nil, fmt.Errorf("failed to build delivery HTTP client config: %w", err) } + cfg.Timeouts.Overall = 30 * time.Second + + client, err := httpclient.New(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build delivery HTTP client: %w", err) + } + + return &Deliverer{ + config: deliveryConfig, + client: client, + }, nil } // Deliver delivers a message to a single callback URL with retry and HMAC. diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go index 87a210a40f..7eeb25d0c2 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/handler.go @@ -27,6 +27,7 @@ import ( "strconv" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" ) @@ -58,11 +59,17 @@ func NewHubHandler( channels map[string]string, consumerMgr *ConsumerManager, syncProducer *subscription.SyncProducer, -) *HubHandler { + httpClientConfig config.HTTPClientConfig, +) (*HubHandler, error) { + verifier, err := NewVerifier(store, verificationTimeout, httpClientConfig) + if err != nil { + return nil, fmt.Errorf("failed to create verifier: %w", err) + } + return &HubHandler{ topics: topics, store: store, - verifier: NewVerifier(store, verificationTimeout), + verifier: verifier, processor: processor, brokerDriver: brokerDriver, bindingName: bindingName, @@ -70,7 +77,7 @@ func NewHubHandler( consumerMgr: consumerMgr, syncProducer: syncProducer, defaultLease: defaultLease, - } + }, nil } // ServeHTTP dispatches on hub.mode for form-encoded subscribe/unsubscribe requests. diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go index a5cccc4e19..d4a0394c32 100644 --- a/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websub/verification.go @@ -30,7 +30,9 @@ import ( "strconv" "time" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/config" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/subscription" + "github.com/wso2/api-platform/httpkit/httpclient" ) // Verifier performs W3C WebSub §5.3 intent verification for subscribe/unsubscribe. @@ -40,15 +42,32 @@ type Verifier struct { client *http.Client } -// NewVerifier creates a new Verifier. -func NewVerifier(store subscription.SubscriptionStore, timeout time.Duration) *Verifier { +// NewVerifier creates a new Verifier. The verification HTTP client is built +// with the shared httpkit SSRF dial-guard enabled (see ssrf-prevention.md): +// the subscriber CallbackURL being verified is tenant-supplied, so +// private/loopback backends must remain reachable (in-cluster subscribers +// are the common case) while link-local/metadata/unspecified/multicast +// destinations are refused at dial time. Pooling/TLS/proxy knobs come from +// hc (config.toml's [http_client] section, shared with the Deliverer); +// timeout is this call site's own existing parameter and always overrides +// hc.Timeouts.Overall — see config.HTTPClientConfig's doc comment. +func NewVerifier(store subscription.SubscriptionStore, timeout time.Duration, hc config.HTTPClientConfig) (*Verifier, error) { + cfg, err := config.BuildHTTPClientConfig(hc) + if err != nil { + return nil, fmt.Errorf("failed to build verification HTTP client config: %w", err) + } + cfg.Timeouts.Overall = timeout + + client, err := httpclient.New(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build verification HTTP client: %w", err) + } + return &Verifier{ store: store, timeout: timeout, - client: &http.Client{ - Timeout: timeout, - }, - } + client: client, + }, nil } // VerifySubscribe performs intent verification for a subscribe request per W3C WebSub §5.3. diff --git a/event-gateway/gateway-runtime/internal/runtime/runtime.go b/event-gateway/gateway-runtime/internal/runtime/runtime.go index 58858a0a68..2a04807737 100644 --- a/event-gateway/gateway-runtime/internal/runtime/runtime.go +++ b/event-gateway/gateway-runtime/internal/runtime/runtime.go @@ -20,6 +20,7 @@ package runtime import ( "context" + "crypto/tls" "fmt" "log/slog" "net/http" @@ -36,6 +37,7 @@ import ( "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/hub" "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/systempolicies" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/pkg/engine" + "github.com/wso2/api-platform/httpkit/tlsconfig" ) var ( @@ -403,14 +405,14 @@ func (r *Runtime) LoadChannels(channelsPath string) error { // Create shared HTTP servers. if hasWS { - wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, wsMux, "", "") + wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, wsMux, "", "", serverTLSOptions{}) if err != nil { return fmt.Errorf("failed to create WebSocket server: %w", err) } r.servers = append(r.servers, wsServer) // Create WSS server if TLS is enabled if r.cfg.Server.WebSocketTLSEnabled { - wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile) + wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile, webSocketServerTLSOptions(r.cfg.Server)) if err != nil { return fmt.Errorf("failed to create WebSocket HTTPS server: %w", err) } @@ -419,14 +421,14 @@ func (r *Runtime) LoadChannels(channelsPath string) error { } if hasWebSub && r.cfg.Server.WebSubEnabled { // Create HTTP server - websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, websubMux, "", "") + websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, websubMux, "", "", serverTLSOptions{}) if err != nil { return fmt.Errorf("failed to create WebSub HTTP server: %w", err) } r.servers = append(r.servers, websubHTTPServer) // Create HTTPS server if TLS is enabled if r.cfg.Server.WebSubTLSEnabled { - websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile) + websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile, webSubServerTLSOptions(r.cfg.Server)) if err != nil { return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) } @@ -454,7 +456,7 @@ func (r *Runtime) Run(ctx context.Context) error { if r.cfg.ControlPlane.Enabled { // Create WebSocket server for dynamic WebBrokerApi bindings slog.Info("Creating WebSocket server for dynamic WebBrokerApi bindings", "port", r.cfg.Server.WebSocketPort) - wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, r.wsMux, "", "") + wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, r.wsMux, "", "", serverTLSOptions{}) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSocket server: %w", err) @@ -467,7 +469,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create WSS server if TLS is enabled if r.cfg.Server.WebSocketTLSEnabled { slog.Info("Creating WebSocket HTTPS server for dynamic WebBrokerApi bindings", "port", r.cfg.Server.WebSocketHTTPSPort) - wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, r.wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile) + wssServer, err := r.newManagedServer("WebSocket-HTTPS", r.cfg.Server.WebSocketHTTPSPort, r.wsMux, r.cfg.Server.WebSocketTLSCertFile, r.cfg.Server.WebSocketTLSKeyFile, webSocketServerTLSOptions(r.cfg.Server)) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSocket HTTPS server: %w", err) @@ -481,7 +483,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create WebSub servers for dynamic WebSubApi bindings if r.cfg.Server.WebSubEnabled { slog.Info("Creating WebSub HTTP server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPPort) - websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, "", "") + websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, "", "", serverTLSOptions{}) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSub HTTP server: %w", err) @@ -494,7 +496,7 @@ func (r *Runtime) Run(ctx context.Context) error { // Create HTTPS server if TLS is enabled if r.cfg.Server.WebSubTLSEnabled { slog.Info("Creating WebSub HTTPS server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPSPort) - websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile) + websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, r.cfg.Server.WebSubTLSCertFile, r.cfg.Server.WebSubTLSKeyFile, webSubServerTLSOptions(r.cfg.Server)) if err != nil { r.mu.Unlock() return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) @@ -584,7 +586,40 @@ func (r *Runtime) Run(ctx context.Context) error { return nil } -func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, certFile, keyFile string) (*managedServer, error) { +// serverTLSOptions carries the optional cipher suite, ECDH/curve preference, +// and min/max TLS version tuning for one inbound HTTPS listener (WebSub or +// WebSocket). The zero value means "use Go's crypto/tls defaults for +// everything" — every field is independently optional. +type serverTLSOptions struct { + MinVersion string + MaxVersion string + CipherSuites string + CurvePreferences string +} + +// webSubServerTLSOptions extracts the WebSub-HTTPS listener's TLS tuning +// from ServerConfig, validated up front by config.validate. +func webSubServerTLSOptions(cfg config.ServerConfig) serverTLSOptions { + return serverTLSOptions{ + MinVersion: cfg.WebSubTLSMinVersion, + MaxVersion: cfg.WebSubTLSMaxVersion, + CipherSuites: cfg.WebSubTLSCipherSuites, + CurvePreferences: cfg.WebSubTLSCurvePreferences, + } +} + +// webSocketServerTLSOptions extracts the WebSocket-HTTPS listener's TLS +// tuning from ServerConfig, validated up front by config.validate. +func webSocketServerTLSOptions(cfg config.ServerConfig) serverTLSOptions { + return serverTLSOptions{ + MinVersion: cfg.WebSocketTLSMinVersion, + MaxVersion: cfg.WebSocketTLSMaxVersion, + CipherSuites: cfg.WebSocketTLSCipherSuites, + CurvePreferences: cfg.WebSocketTLSCurvePreferences, + } +} + +func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, certFile, keyFile string, tlsOpts serverTLSOptions) (*managedServer, error) { server := &managedServer{ name: name, server: &http.Server{ @@ -600,14 +635,59 @@ func (r *Runtime) newManagedServer(name string, port int, handler http.Handler, if err := ensureReadableTLSAsset(keyFile, name+" TLS key file"); err != nil { return nil, fmt.Errorf("invalid TLS configuration for %s server: %w", name, err) } + + tlsConfig, err := buildListenerTLSConfig(tlsOpts) + if err != nil { + return nil, fmt.Errorf("invalid TLS configuration for %s server: %w", name, err) + } + server.tls = true server.certFile = certFile server.keyFile = keyFile + server.server.TLSConfig = tlsConfig } return server, nil } +// buildListenerTLSConfig builds the *tls.Config carrying the optional +// cipher/curve/version tuning for an inbound HTTPS listener. The certificate +// itself is intentionally left unset here: http.Server.ListenAndServeTLS +// loads it from the cert/key file paths at Serve time (see runServer) and +// merges it into whatever TLSConfig is already set, leaving +// MinVersion/MaxVersion/CipherSuites/CurvePreferences untouched. Config +// validation (config.validate, via validateListenerTLSTuning) already +// parses these same fields at startup, so an error here would only surface +// if that validation were ever skipped — still handled explicitly rather +// than ignored. +func buildListenerTLSConfig(opts serverTLSOptions) (*tls.Config, error) { + if err := tlsconfig.ValidateVersionRange(opts.MinVersion, opts.MaxVersion); err != nil { + return nil, err + } + cfg := &tls.Config{} + if opts.MinVersion != "" { + if v, ok := tlsconfig.ParseVersion(opts.MinVersion); ok { + cfg.MinVersion = v + } + } + if opts.MaxVersion != "" { + if v, ok := tlsconfig.ParseVersion(opts.MaxVersion); ok { + cfg.MaxVersion = v + } + } + ciphers, err := tlsconfig.ParseCipherSuites(opts.CipherSuites) + if err != nil { + return nil, err + } + cfg.CipherSuites = ciphers + curves, err := tlsconfig.ParseCurvePreferences(opts.CurvePreferences) + if err != nil { + return nil, err + } + cfg.CurvePreferences = curves + return cfg, nil +} + func ensureReadableTLSAsset(filePath, fieldName string) error { info, err := os.Stat(filePath) if err != nil { diff --git a/event-gateway/gateway-runtime/internal/runtime/runtime_test.go b/event-gateway/gateway-runtime/internal/runtime/runtime_test.go index 837584e973..08ecede93b 100644 --- a/event-gateway/gateway-runtime/internal/runtime/runtime_test.go +++ b/event-gateway/gateway-runtime/internal/runtime/runtime_test.go @@ -20,10 +20,12 @@ package runtime import ( "context" + "crypto/tls" "errors" "net/http" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -224,7 +226,7 @@ func TestNewManagedServerRejectsMissingTLSFiles(t *testing.T) { }, } - _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), rt.cfg.Server.WebSubTLSCertFile, rt.cfg.Server.WebSubTLSKeyFile) + _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), rt.cfg.Server.WebSubTLSCertFile, rt.cfg.Server.WebSubTLSKeyFile, serverTLSOptions{}) if err == nil { t.Fatal("expected newManagedServer to fail when TLS files are missing") } @@ -247,14 +249,17 @@ func TestNewManagedServerAcceptsReadableTLSFiles(t *testing.T) { rt := &Runtime{ cfg: &config.Config{ Server: config.ServerConfig{ - WebSubTLSEnabled: true, - WebSubTLSCertFile: certPath, - WebSubTLSKeyFile: keyPath, + WebSubTLSEnabled: true, + WebSubTLSCertFile: certPath, + WebSubTLSKeyFile: keyPath, + WebSubTLSMinVersion: "TLS1_2", + WebSubTLSMaxVersion: "TLS1_3", + WebSubTLSCurvePreferences: "X25519MLKEM768,X25519,P-256", }, }, } - server, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath) + server, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath, webSubServerTLSOptions(rt.cfg.Server)) if err != nil { t.Fatalf("expected newManagedServer to succeed, got %v", err) } @@ -267,6 +272,41 @@ func TestNewManagedServerAcceptsReadableTLSFiles(t *testing.T) { if server.keyFile != keyPath { t.Fatalf("expected key path %q, got %q", keyPath, server.keyFile) } + if server.server.TLSConfig == nil { + t.Fatal("expected TLSConfig to be set on the managed server") + } + if server.server.TLSConfig.MinVersion != tls.VersionTLS12 { + t.Fatalf("expected MinVersion TLS1.2, got %x", server.server.TLSConfig.MinVersion) + } + if server.server.TLSConfig.MaxVersion != tls.VersionTLS13 { + t.Fatalf("expected MaxVersion TLS1.3, got %x", server.server.TLSConfig.MaxVersion) + } + wantCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256} + if !reflect.DeepEqual(server.server.TLSConfig.CurvePreferences, wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, server.server.TLSConfig.CurvePreferences) + } +} + +func TestNewManagedServerRejectsInvalidTLSTuning(t *testing.T) { + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "tls.crt") + keyPath := filepath.Join(tempDir, "tls.key") + if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + rt := &Runtime{cfg: &config.Config{}} + + _, err := rt.newManagedServer("WebSub-HTTPS", 8443, http.NewServeMux(), certPath, keyPath, serverTLSOptions{CurvePreferences: "not-a-curve"}) + if err == nil { + t.Fatal("expected newManagedServer to fail on an invalid curve name") + } + if !strings.Contains(err.Error(), "invalid TLS configuration for WebSub-HTTPS server") { + t.Fatalf("expected wrapped TLS configuration error, got %q", err.Error()) + } } func TestNewManagedServerWebSocketRejectsMissingTLSFiles(t *testing.T) { @@ -280,7 +320,7 @@ func TestNewManagedServerWebSocketRejectsMissingTLSFiles(t *testing.T) { }, } - _, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), rt.cfg.Server.WebSocketTLSCertFile, rt.cfg.Server.WebSocketTLSKeyFile) + _, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), rt.cfg.Server.WebSocketTLSCertFile, rt.cfg.Server.WebSocketTLSKeyFile, serverTLSOptions{}) if err == nil { t.Fatal("expected newManagedServer to fail when TLS files are missing") } @@ -303,14 +343,15 @@ func TestNewManagedServerWebSocketAcceptsReadableTLSFiles(t *testing.T) { rt := &Runtime{ cfg: &config.Config{ Server: config.ServerConfig{ - WebSocketTLSEnabled: true, - WebSocketTLSCertFile: certPath, - WebSocketTLSKeyFile: keyPath, + WebSocketTLSEnabled: true, + WebSocketTLSCertFile: certPath, + WebSocketTLSKeyFile: keyPath, + WebSocketTLSCipherSuites: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", }, }, } - server, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), certPath, keyPath) + server, err := rt.newManagedServer("WebSocket-HTTPS", 8444, http.NewServeMux(), certPath, keyPath, webSocketServerTLSOptions(rt.cfg.Server)) if err != nil { t.Fatalf("expected newManagedServer to succeed, got %v", err) } @@ -323,6 +364,13 @@ func TestNewManagedServerWebSocketAcceptsReadableTLSFiles(t *testing.T) { if server.keyFile != keyPath { t.Fatalf("expected key path %q, got %q", keyPath, server.keyFile) } + if server.server.TLSConfig == nil { + t.Fatal("expected TLSConfig to be set on the managed server") + } + wantCiphers := []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256} + if !reflect.DeepEqual(server.server.TLSConfig.CipherSuites, wantCiphers) { + t.Fatalf("expected cipher suites %v, got %v", wantCiphers, server.server.TLSConfig.CipherSuites) + } } func TestAddWebBrokerApiBinding_RedeployDoesNotPanic(t *testing.T) { diff --git a/gateway/Makefile b/gateway/Makefile index b24434f2bc..003d5f37f6 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -232,7 +232,8 @@ AI_DIST_ZIP := target/$(AI_DIST_NAME).zip dist: clean-dist ## Build standalone gateway distribution zip @echo "Building distribution $(DIST_NAME)..." @mkdir -p $(DIST_DIR)/configs $(DIST_DIR)/resources/certificates \ - $(DIST_DIR)/resources/listener-certs $(DIST_DIR)/resources/secure-backend \ + $(DIST_DIR)/resources/listener-certs $(DIST_DIR)/resources/xds-certs \ + $(DIST_DIR)/resources/secure-backend \ $(DIST_DIR)/resources/gateway-controller/db-scripts @cp build.yaml build-manifest.yaml $(DIST_DIR)/ @cp -R configs/. $(DIST_DIR)/configs/ @@ -240,6 +241,13 @@ dist: clean-dist ## Build standalone gateway distribution zip @cp gateway-controller/certificates/default-listener.crt $(DIST_DIR)/resources/certificates/ @cp gateway-controller/listener-certs/default-listener.crt $(DIST_DIR)/resources/listener-certs/ @cp gateway-controller/listener-certs/default-listener.key $(DIST_DIR)/resources/listener-certs/ + @cp gateway-controller/xds-certs/ca.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/server.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/server.key $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/envoy-client.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/envoy-client.key $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/policy-engine-client.crt $(DIST_DIR)/resources/xds-certs/ + @cp gateway-controller/xds-certs/policy-engine-client.key $(DIST_DIR)/resources/xds-certs/ @cp -R resources/secure-backend/. $(DIST_DIR)/resources/secure-backend/ @cp gateway-controller/pkg/storage/gateway-controller-db.postgres.sql $(DIST_DIR)/resources/gateway-controller/db-scripts/ @cp gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql $(DIST_DIR)/resources/gateway-controller/db-scripts/ diff --git a/gateway/build-manifest.yaml b/gateway/build-manifest.yaml index 1524b5423b..4e639e9d1c 100644 --- a/gateway/build-manifest.yaml +++ b/gateway/build-manifest.yaml @@ -13,7 +13,7 @@ policies: version: v0.10.0 gomodule: github.com/wso2/gateway-controllers/policies/aws-authentication@v0 - name: aws-bedrock-guardrail - version: v1.1.0 + version: v1.2.0 gomodule: github.com/wso2/gateway-controllers/policies/aws-bedrock-guardrail@v1 - name: azure-content-safety-content-moderation version: v1.0.2 @@ -94,7 +94,7 @@ policies: version: v1.0.1 gomodule: github.com/wso2/gateway-controllers/policies/opaque-token-auth@v1 - name: openai-to-anthropic-transformer - version: v0.9.0 + version: v0.9.1 gomodule: github.com/wso2/gateway-controllers/policies/openai-to-anthropic-transformer@v0 - name: openai-to-azure-openai-transformer version: v0.9.0 @@ -103,13 +103,13 @@ policies: version: v0.9.1 gomodule: github.com/wso2/gateway-controllers/policies/openai-to-bedrock-transformer@v0 - name: openai-to-gemini-transformer - version: v0.9.0 + version: v0.9.1 gomodule: github.com/wso2/gateway-controllers/policies/openai-to-gemini-transformer@v0 - name: openai-to-mistral-transformer version: v0.9.0 gomodule: github.com/wso2/gateway-controllers/policies/openai-to-mistral-transformer@v0 - name: pii-masking-regex - version: v1.0.3 + version: v1.0.4 gomodule: github.com/wso2/gateway-controllers/policies/pii-masking-regex@v1 - name: prompt-compressor version: v0.9.0 diff --git a/gateway/configs/config-template.toml b/gateway/configs/config-template.toml index 726142183c..17a4929ef8 100644 --- a/gateway/configs/config-template.toml +++ b/gateway/configs/config-template.toml @@ -13,6 +13,66 @@ shutdown_timeout = "15s" # It is recommended to use a uuid_v7 for this to improve db efficiency. gateway_id = '{{ env "APIP_GW_CONTROLLER_SERVER_GATEWAY_ID" "platform-gateway-id" }}' +[controller.server.tls] +# Starts a second, TLS-only REST API listener on `port` below, serving the +# same management API as the plaintext listener on server.api_port above. +# Off by default: no certificate is provisioned by default, and the +# plaintext listener keeps working either way. +enabled = false +port = 9093 +cert_path = "" +key_path = "" +# TLS version bounds, one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". Same +# vocabulary as router.downstream_tls/upstream_tls below 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` below (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 below -- 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" + +[controller.server.xds_tls] +# Switches the main xDS gRPC server (serves Envoy on server.xds_port above) +# from plaintext to mutual TLS -- there is no second listener the way +# server.tls above adds one; server.xds_port itself starts speaking mTLS. +# Off by default: Envoy's xds_cluster (router/config/config-override.yaml) +# must be given a matching client cert/CA before this is turned on, or the +# connection will fail closed. +enabled = false +cert_file = "" +key_file = "" +# PEM bundle of CA certificates trusted to sign Envoy's client certificate. +# Required when enabled -- this server offers no server-only TLS mode, +# since it distributes SDS secrets and full route/cluster config. +client_ca_file = "" +# Accepted peer certificate identities: a certificate's first SAN URI (e.g. +# a SPIFFE ID) if present, otherwise its Subject CommonName. A client +# certificate that chains to a trusted CA is not by itself authorization to +# reach this snapshot -- at least one identity is required when enabled. +allowed_client_identities = [] +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +ciphers = "" +ecdh_curves = "X25519,P-256" + [controller.admin_server] # Dedicated admin/debug HTTP server for config dump and xDS sync endpoints. # Kept enabled by default because it also serves /health, used by Kubernetes @@ -43,12 +103,30 @@ mutex_profile_fraction = 0 port = 18001 [controller.policy_server.tls] -# Enable or disable TLS +# Switches the policy xDS gRPC server (serves the policy-engine on +# policy_server.port above) from plaintext to mutual TLS, on that same +# port. Off by default: policy_engine.xds.tls below must be given a +# matching client cert/CA before this is turned on, or the connection will +# fail closed. enabled = false # Path to TLS certificate file (required if TLS is enabled) cert_file = "./certs/server.crt" # Path to TLS private key file (required if TLS is enabled) key_file = "./certs/server.key" +# PEM bundle of CA certificates trusted to sign the policy-engine's client +# certificate. Required when enabled -- this server offers no server-only +# TLS mode, since it distributes API-key hashes, subscription state, and +# full policy chains for every tenant. +client_ca_file = "./certs/xds-client-ca.crt" +# Accepted peer certificate identities: a certificate's first SAN URI (e.g. +# a SPIFFE ID) if present, otherwise its Subject CommonName. A client +# certificate that chains to a trusted CA is not by itself authorization to +# reach this snapshot -- at least one identity is required when enabled. +allowed_client_identities = [] +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" +ciphers = "" +ecdh_curves = "X25519,P-256" [controller.controlplane] # Control plane websocket endpoint. Environment values reach these keys ONLY through the @@ -147,6 +225,88 @@ application_name = "gateway-controller" encrypt = "true" # disable, false, true, strict trust_server_certificate = "false" +# Configures the single shared outbound *http.Client used by every control-plane / +# platform-API / on-prem-APIM call this process makes (see pkg/config.HTTPClientConfig, +# which mirrors github.com/wso2/go-httpkit/httpclient.Config field-for-field). Every key +# below is optional and already at its built-in default — omitting a subsection entirely +# (including this whole table) keeps that same default. +[controller.http_client.pooling] +max_idle_conns = 100 +max_idle_conns_per_host = 10 +max_conns_per_host = 100 +idle_conn_timeout = "90s" +keep_alive = "30s" +disable_keep_alives = false +# HTTP/2 is off by default: this client always sets a custom TLS config, which makes Go's +# Transport conservatively disable HTTP/2 unless re-enabled here — only opt in after +# considering the connection-coalescing caveat in httpclient.PoolingConfig.EnableHTTP2's doc. +enable_http2 = false + +[controller.http_client.timeouts] +# Safety-net overall budget for every outbound call this process makes. Real per-operation +# budgets are enforced via a context.WithTimeout deadline at each call site (5s for +# well-known discovery, 30s for manifest/platform-API/on-prem-APIM calls, etc.) — this is +# only a generous backstop in case one of those deadlines is ever missing. configs/config.toml +# raises this to 60s; the package-level default is 30s. +overall = "30s" +dial = "10s" +tls_handshake = "10s" +response_header = "10s" +expect_continue = "1s" +# 0 = package default (10MiB); a negative value disables the response-size bound entirely. +max_response_bytes = 0 + +[controller.http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. Empty uses Go's own +# default set/order. configs/config.toml prepends the hybrid post-quantum group +# "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519) as its default, matching +# controller.server.tls.ecdh_curves — a peer that doesn't yet support it still succeeds via +# the later classical entries. +curve_preferences = "" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. Empty +# uses Go's own default secure set. +cipher_suites = "" +# PEM CA bundle / mTLS client cert+key for the outbound call to the control plane. Empty +# uses the system root pool and no client certificate. +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +# insecure_skip_verify is intentionally NOT configured here — it is sourced from +# controller.controlplane.insecure_skip_verify above, which already governs this same trust +# decision for every one of this client's current callers. + +[controller.http_client.proxy] +# "none" (default) | "environment" (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) | "url" +mode = "none" +url = "" +username = "" +password = "" +no_proxy = [] +# Required ("delegated" or "manual_connect") only when mode != "none" AND ssrf.enabled below +# — see pkg/config.HTTPClientProxyConfig's doc comment. +egress = "" + +[controller.http_client.proxy.tls] +# Configures a SEPARATE TLS handshake to an https:// proxy itself, decoupled from +# controller.http_client.tls above (which always governs the origin handshake). +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +insecure_skip_verify = false + +[controller.http_client.ssrf] +# Off by default: this shared client's current callers (control plane, platform API, +# on-prem APIM) all target a single fixed, operator-configured host, not a user/tenant- +# supplied URL — the scenario ssrf-prevention.md targets — so there is nothing to guard +# against today. Exposed for completeness / any future caller that fetches a tenant-supplied +# URL. When enabling, preset must be "permit_private_block_metadata" or "public_only". +enabled = false +preset = "" +max_redirects = 0 +allowed_schemes = [] + [controller.policies] # Directory containing policy definitions. The immutable-gateway builder image sets # APIP_GW_CONTROLLER_POLICIES_DEFINITIONS_PATH=/app/policies, which this token reads. @@ -265,12 +425,13 @@ 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" +# 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 @@ -350,6 +511,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. @@ -369,10 +566,55 @@ initial_reconnect_delay = "1s" max_reconnect_delay = "60s" [policy_engine.xds.tls] -enabled = false -# cert_path = "/path/to/client-cert.pem" -# key_path = "/path/to/client-key.pem" -# ca_path = "/path/to/ca-cert.pem" +# Mutual TLS for this policy-engine's connection to gateway-controller's +# policy xDS server. Off by default; must be enabled together with +# controller.policy_server.tls above (and this cert's identity added to +# controller.policy_server.tls.allowed_client_identities) -- the server +# offers no server-only TLS mode for xDS, so enabling only one side leaves +# the connection unable to complete its handshake. +# +# The policy-engine binary is a subprocess of gateway-runtime's +# docker-entrypoint.sh, forked into (and inheriting the environment of) the +# same container as Envoy -- so, like Envoy's own XDS_CLIENT_* vars, this +# cert/key/CA material is sourced from env vars local to that container +# rather than hardcoded here. Distinct POLICY_ENGINE_XDS_CLIENT_* names (not +# the plain XDS_CLIENT_* Envoy uses) because this leg presents a different +# client identity (spiffe://.../policy-engine, not .../envoy) -- reusing +# Envoy's cert here would fail controller.policy_server.tls's +# allowed_client_identities check. +# +# Every setting below follows the same three-level precedence: its own +# POLICY_ENGINE_XDS_CLIENT_* var (POLICY_ENGINE_XDS_TLS_ENABLED for the +# enabled flag), if set, always wins; otherwise it inherits Envoy's +# equivalent XDS_CLIENT_* var (the common case -- one container-wide +# xDS-client TLS config, since both legs dial the same gateway-controller +# host); if neither is set, it falls back to the literal default below. CA +# fallback is always safe -- controller.server.xds_tls and +# controller.policy_server.tls share the same server cert/CA by content, +# just mounted at different container paths. cert_path/key_path fallback is +# NOT automatically safe: inheriting Envoy's client cert means this leg +# presents Envoy's identity rather than its own, which +# controller.policy_server.tls's allowed_client_identities rejects unless it +# explicitly allows both -- rely on this fallback only in a deployment that +# deliberately shares one client identity across both legs; otherwise set +# POLICY_ENGINE_XDS_CLIENT_* explicitly, as this repo's own docker-compose +# files do. +enabled = '{{ env "POLICY_ENGINE_XDS_TLS_ENABLED" (env "XDS_TLS_ENABLED" "false") }}' +cert_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_CERT_PATH" (env "XDS_CLIENT_CERT_PATH" "") }}' +key_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_KEY_PATH" (env "XDS_CLIENT_KEY_PATH" "") }}' +ca_path = '{{ env "POLICY_ENGINE_XDS_CLIENT_CA_PATH" (env "XDS_CLIENT_CA_PATH" "") }}' +# Comma-separated Go crypto/tls cipher suite names, restricting which +# suites this client offers. Empty by default -- Go's own secure default +# set/order applies. Only affects TLS 1.2 and below. +ciphers = '{{ env "POLICY_ENGINE_XDS_CLIENT_TLS_CIPHERS" (env "XDS_CLIENT_TLS_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 controller.policy_server.tls above is confirmed to +# support it -- this client is Go's own crypto/tls (1.23+ implements +# X25519MLKEM768 natively), so a server that doesn't offer the hybrid group +# simply falls back to a later classical entry in this same list. +ecdh_curves = '{{ env "POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES" (env "XDS_CLIENT_TLS_ECDH_CURVES" "X25519,P-256") }}' [policy_engine.file_config] path = "" @@ -381,6 +623,81 @@ path = "" level = "info" format = "text" +# Configures the single shared outbound *http.Client that the policy engine builds once at +# startup and injects into every policy instance via PolicyMetadata.SharedHTTPClient (see +# internal/config.HTTPClientConfig, which mirrors github.com/wso2/go-httpkit/httpclient.Config +# field-for-field). Every key below is optional and already at its built-in default — +# omitting a subsection entirely (including this whole table) keeps that same default. Use +# this instead of a policy building its own *http.Client for an outbound call (a guardrail/ +# moderation backend, an external validation service, an LLM provider). +[policy_engine.http_client.pooling] +max_idle_conns = 100 +max_idle_conns_per_host = 10 +max_conns_per_host = 100 +idle_conn_timeout = "90s" +keep_alive = "30s" +disable_keep_alives = false +# HTTP/2 is off by default: this client always sets a custom TLS config, which makes Go's +# Transport conservatively disable HTTP/2 unless re-enabled here — only opt in after +# considering the connection-coalescing caveat in httpclient.PoolingConfig.EnableHTTP2's doc. +enable_http2 = false + +[policy_engine.http_client.timeouts] +# Safety-net overall budget for every outbound call a policy makes through this client. A +# policy with its own tighter per-operation deadline should use context.WithTimeout instead +# — this is only a generous backstop. +overall = "30s" +dial = "10s" +tls_handshake = "10s" +response_header = "10s" +expect_continue = "1s" +# 0 = package default (10MiB); a negative value disables the response-size bound entirely. +max_response_bytes = 0 + +[policy_engine.http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. A hybrid post-quantum +# group (e.g. "X25519MLKEM768") can be prepended as an explicit opt-in once the backends +# policies call are confirmed to support it. +curve_preferences = "" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. Empty +# uses Go's own default secure set. +cipher_suites = "" +# PEM CA bundle / mTLS client cert+key for outbound policy calls. Empty uses the system +# root pool and no client certificate. +root_ca_file = "" +client_cert_file = "" +client_key_file = "" + +[policy_engine.http_client.proxy] +# "none" (default) | "environment" (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) | "url" +mode = "none" +url = "" +username = "" +password = "" +no_proxy = [] +# Required ("delegated" or "manual_connect") only when mode != "none" AND ssrf.enabled below +# — see internal/config.HTTPClientProxyConfig's doc comment. +egress = "" + +[policy_engine.http_client.proxy.tls] +# Configures a SEPARATE TLS handshake to an https:// proxy itself, decoupled from +# policy_engine.http_client.tls above (which always governs the origin handshake). +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +insecure_skip_verify = false + +[policy_engine.http_client.ssrf] +# Off by default. Enable (and pick a preset) when a policy's outbound target is derived +# from tenant/request data rather than a single fixed, operator-configured backend — see +# ssrf-prevention.md. preset must be "permit_private_block_metadata" or "public_only". +enabled = false +preset = "" +max_redirects = 0 +allowed_schemes = [] + [policy_engine.metrics] enabled = true port = 9003 diff --git a/gateway/configs/config.toml b/gateway/configs/config.toml index e81a042d7a..d7edc51921 100644 --- a/gateway/configs/config.toml +++ b/gateway/configs/config.toml @@ -14,6 +14,27 @@ enabled = true [controller.server] gateway_id = '{{ env "APIP_GW_CONTROLLER_SERVER_GATEWAY_ID" "platform-gateway-id" }}' + +[controller.server.tls] +enabled = true +port = 9093 +cert_path = "./listener-certs/default-listener.crt" +key_path = "./listener-certs/default-listener.key" +minimum_protocol_version = "TLS1_3" +maximum_protocol_version = "TLS1_3" +ciphers = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" +# 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 below -- 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,X25519MLKEM768" + [controller.storage] type = '{{ env "APIP_GW_CONTROLLER_STORAGE_TYPE" "sqlite" }}' @@ -23,6 +44,17 @@ path = '{{ env "APIP_GW_CONTROLLER_STORAGE_SQLITE_PATH" "./data/gateway.db" }}' [policy_engine.logging] level = "info" +# Configures the single shared outbound *http.Client that the policy engine builds once at +# startup and injects into every policy instance via PolicyMetadata.SharedHTTPClient (see +# internal/config.HTTPClientConfig, which mirrors github.com/wso2/go-httpkit/httpclient.Config +# field-for-field). Every field is optional — anything omitted (including this whole table) +# keeps its Go-level default; see configs/config-template.toml for the full reference. Use +# this instead of a policy building its own *http.Client for an outbound call (a guardrail/ +# moderation backend, an external validation service, an LLM provider). +[policy_engine.http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" + [controller.logging] level = '{{ env "APIP_GW_CONTROLLER_LOGGING_LEVEL" "info" }}' @@ -34,6 +66,29 @@ gateway_name = '{{ env "APIP_GW_CONTROLLER_CONTROLPLANE_GATEWAY_NAME" "default" apim_oauth2_client_id = '{{ env "APIP_GW_CONTROLLER_CONTROLPLANE_APIM_OAUTH2_CLIENT_ID" "" }}' apim_oauth2_client_secret = '{{ env "APIP_GW_CONTROLLER_CONTROLPLANE_APIM_OAUTH2_CLIENT_SECRET" "" }}' +# Configures the single shared outbound *http.Client used by every control-plane / +# platform-API / on-prem-APIM call this process makes (see pkg/config.HTTPClientConfig, +# which mirrors github.com/wso2/go-httpkit/httpclient.Config field-for-field). Every field +# is optional — anything omitted (including this whole table) keeps its Go-level default; +# see configs/config-template.toml for the full reference. +[controller.http_client.timeouts] +# Safety-net overall budget for every outbound call this process makes. Real per-operation +# budgets are enforced via a context.WithTimeout deadline at each call site (5s for +# well-known discovery, 30s for manifest/platform-API/on-prem-APIM calls, etc.) — this is +# only a generous backstop in case one of those deadlines is ever missing. +overall = "60s" + +[controller.http_client.tls] +min_version = "TLS1_2" +max_version = "TLS1_3" +# Comma-separated TLS 1.3 key-exchange groups, most preferred first. Hybrid PQC group +# listed first with classical fallbacks after, matching controller.server.tls.ecdh_curves — +# a peer that doesn't yet support X25519MLKEM768 still succeeds via the later entries. +curve_preferences = "X25519MLKEM768,X25519,P-256" +# insecure_skip_verify is intentionally NOT configured here — it is sourced from +# controller.controlplane.insecure_skip_verify above, which already governs this same trust +# decision for every one of this client's current callers. + [controller.policies] definitions_path = '{{ env "APIP_GW_CONTROLLER_POLICIES_DEFINITIONS_PATH" "./default-policies" }}' diff --git a/gateway/distribution/docker-compose.yaml b/gateway/distribution/docker-compose.yaml index a1ac1f7288..0db9dccf63 100644 --- a/gateway/distribution/docker-compose.yaml +++ b/gateway/distribution/docker-compose.yaml @@ -36,6 +36,7 @@ services: - ./configs/config.toml:/etc/gateway-controller/config.toml:ro - ./resources/certificates:/app/certificates - ./resources/listener-certs:/app/listener-certs:ro + - ./resources/xds-certs:/app/xds-certs:ro # Read-only mTLS dev CA/server cert for the two xDS servers (server.xds_tls, policy_server.tls) - ./resources/aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro extra_hosts: - "host.docker.internal:host-gateway" @@ -66,9 +67,43 @@ services: # Envoy admin is disabled by default in the image; enabled here for local # dev convenience since the port is already mapped to the host above. - ROUTER_ADMIN_ENABLED=true + # Mutual TLS for the Router's (Envoy's) connection to gateway-controller's + # main xDS server -- off by default (plaintext), matching + # controller.server.xds_tls.enabled=false in configs/config.toml. + - XDS_TLS_ENABLED=false + - XDS_CLIENT_CERT_PATH=/etc/xds-certs/envoy-client.crt + - XDS_CLIENT_KEY_PATH=/etc/xds-certs/envoy-client.key + - XDS_CLIENT_CA_PATH=/etc/xds-certs/ca.crt + # Classical curves only by default -- prepend "X25519MLKEM768" (FIPS 203 + # ML-KEM-768 + X25519) once gateway-controller's server.xds_tls.ecdh_curves + # is updated to match, confirming the deployed Envoy/BoringSSL build + # supports the group. + - XDS_CLIENT_TLS_MIN_VERSION=TLS1_2 + - XDS_CLIENT_TLS_MAX_VERSION=TLS1_3 + - XDS_CLIENT_TLS_CIPHERS= + - XDS_CLIENT_TLS_ECDH_CURVES=X25519,P-256 + # Mutual TLS for the Policy Engine's (a subprocess of this same + # container's entrypoint) connection to gateway-controller's policy + # xDS server -- matches controller.policy_server.tls.enabled in + # configs/config.toml. Read by that file's [policy_engine.xds.tls] via + # {{ env }} interpolation, not by docker-entrypoint.sh -- distinct + # POLICY_ENGINE_XDS_CLIENT_* names because this leg presents a + # different client identity than Envoy's XDS_CLIENT_* cert above. + # Left unset, this would inherit XDS_TLS_ENABLED (=false above) -- + # explicit here because, unlike the other two compose files, this + # profile wants the two legs to diverge: Envoy plaintext, policy-engine + # mTLS (matching controller.policy_server.tls.enabled=true, which is + # unconditional in configs/config.toml regardless of profile). + - POLICY_ENGINE_XDS_TLS_ENABLED=true + - POLICY_ENGINE_XDS_CLIENT_CERT_PATH=/etc/policy-engine/xds-certs/policy-engine-client.crt + - POLICY_ENGINE_XDS_CLIENT_KEY_PATH=/etc/policy-engine/xds-certs/policy-engine-client.key + - POLICY_ENGINE_XDS_CLIENT_CA_PATH=/etc/policy-engine/xds-certs/ca.crt + - POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES=X25519MLKEM768 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 + - ./resources/xds-certs:/etc/xds-certs:ro # Envoy's xDS mTLS client cert/key + CA (env vars above) + - ./resources/xds-certs:/etc/policy-engine/xds-certs:ro # Policy-engine's xDS mTLS client cert/key + CA (POLICY_ENGINE_XDS_CLIENT_* env vars above) networks: - gateway-network diff --git a/gateway/docker-compose.debug.yaml b/gateway/docker-compose.debug.yaml index 55bfaa1877..6d6e397063 100644 --- a/gateway/docker-compose.debug.yaml +++ b/gateway/docker-compose.debug.yaml @@ -38,6 +38,7 @@ services: - ./configs/config.toml:/etc/gateway-controller/config.toml:ro - ./gateway-controller/certificates:/app/certificates - ./gateway-controller/listener-certs:/app/listener-certs:ro + - ./gateway-controller/xds-certs:/app/xds-certs:ro # Read-only mTLS dev CA/server cert for the two xDS servers (server.xds_tls, policy_server.tls) - ./gateway-controller/aesgcm-keys/default-aesgcm256-v1.bin:/app/data/aesgcm-keys/default-aesgcm256-v1.bin:ro # AES-256 at-rest encryption key (generated by scripts/setup.sh) extra_hosts: - "host.docker.internal:host-gateway" @@ -73,8 +74,46 @@ services: # Envoy admin is disabled by default in the image; enabled here for local # dev convenience since the port is already mapped to the host above. - ROUTER_ADMIN_ENABLED=true + # Mutual TLS for the Router's (Envoy's) connection to gateway-controller's + # main xDS server -- must match controller.server.xds_tls.enabled in + # configs/config.toml (shared with gateway-controller above). Entirely + # self-contained to this container: gateway-controller never needs to + # know these paths -- SDS/secret delivery rides the same ADS stream + # this bootstrap cluster already opens, so there's no second, + # controller-side copy of this TLS material to keep in sync. + - XDS_TLS_ENABLED=true + - XDS_CLIENT_CERT_PATH=/etc/xds-certs/envoy-client.crt + - XDS_CLIENT_KEY_PATH=/etc/xds-certs/envoy-client.key + - XDS_CLIENT_CA_PATH=/etc/xds-certs/ca.crt + - XDS_CLIENT_TLS_MIN_VERSION=TLS1_2 + - XDS_CLIENT_TLS_MAX_VERSION=TLS1_3 + # TLS 1.2 fallback suites only (BoringSSL/Envoy naming) -- forward-secret + # (ECDHE) + AEAD (AES-GCM/ChaCha20-Poly1305) only, 256-bit and + # ChaCha20 ordered ahead of AES-128 per post-quantum-cryptography.md + # directive 1. X25519MLKEM768 itself is a TLS 1.3 key-share group + # negotiated via XDS_CLIENT_TLS_ECDH_CURVES below regardless of this + # list -- TLS 1.3's own cipher suites are fixed and not configurable. + - XDS_CLIENT_TLS_CIPHERS=ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-CHACHA20-POLY1305,ECDHE-RSA-CHACHA20-POLY1305,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256 + - XDS_CLIENT_TLS_ECDH_CURVES=X25519,P-256,X25519MLKEM768 + # Mutual TLS for the Policy Engine's (a subprocess of this same + # container's entrypoint) connection to gateway-controller's policy + # xDS server -- matches controller.policy_server.tls.enabled in + # configs/config.toml. Read by that file's [policy_engine.xds.tls] via + # {{ env }} interpolation, not by docker-entrypoint.sh -- distinct + # POLICY_ENGINE_XDS_CLIENT_* names because this leg presents a + # different client identity than Envoy's XDS_CLIENT_* cert above. + # No POLICY_ENGINE_XDS_TLS_ENABLED here: unset, it inherits + # XDS_TLS_ENABLED above (=true), which is what we want since both legs + # run mTLS in this profile -- set it explicitly only to diverge from + # Envoy's setting (see distribution/docker-compose.yaml). + - POLICY_ENGINE_XDS_CLIENT_CERT_PATH=/etc/policy-engine/xds-certs/policy-engine-client.crt + - POLICY_ENGINE_XDS_CLIENT_KEY_PATH=/etc/policy-engine/xds-certs/policy-engine-client.key + - POLICY_ENGINE_XDS_CLIENT_CA_PATH=/etc/policy-engine/xds-certs/ca.crt + - POLICY_ENGINE_XDS_CLIENT_TLS_ECDH_CURVES=X25519MLKEM768 volumes: - ./configs/config.toml:/etc/policy-engine/config.toml:ro + - ./gateway-controller/xds-certs:/etc/xds-certs:ro # Envoy's xDS mTLS client cert/key + CA (env vars above) + - ./gateway-controller/xds-certs:/etc/policy-engine/xds-certs:ro # Policy-engine's xDS mTLS client cert/key + CA (POLICY_ENGINE_XDS_CLIENT_* env vars above) networks: - gateway-network cap_add: diff --git a/gateway/docker-compose.yaml b/gateway/docker-compose.yaml index f9d117bff0..3774f5b35a 100644 --- a/gateway/docker-compose.yaml +++ b/gateway/docker-compose.yaml @@ -32,6 +32,7 @@ services: - "9090:9090" # REST API - "9094:9092" # Admin API - "9011:9091" # Metrics + - "9093:9093" # REST API TLS env_file: - path: api-platform.env required: true @@ -62,6 +63,7 @@ services: # Policy Engine - "9002:9002" # Admin API - "9003:9003" # Metrics + - "9004:9004" # Health env_file: - path: api-platform.env required: true @@ -69,6 +71,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/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1baaf9f679..ed9a259f83 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -43,6 +43,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/version" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" + "github.com/wso2/api-platform/httpkit/httpclient" gohttpkit "github.com/wso2/api-platform/httpkit/middleware" ) @@ -389,7 +390,16 @@ func main() { policyEngineConnected := make(chan struct{}) // Start xDS gRPC server with SDS support - xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected) + var xdsServerOpts []xds.ServerOption + if cfg.Controller.Server.XDSTLS.Enabled { + xdsTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.Server.XDSTLS) + if err != nil { + log.Error("invalid server.xds_tls config, refusing to start main xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + xdsServerOpts = append(xdsServerOpts, xds.WithMTLS(xdsTLSConfig, cfg.Controller.Server.XDSTLS.AllowedClientIdentities)) + } + xdsServer := xds.NewServer(snapshotManager, sdsSecretManager, cfg.Controller.Server.XDSPort, log, routerConnected, xdsServerOpts...) go func() { if err := xdsServer.Start(); err != nil { log.Error("xDS server failed", slog.Any("error", err)) @@ -490,10 +500,12 @@ func main() { policyxds.WithOnFirstConnect(policyEngineConnected), } if cfg.Controller.PolicyServer.TLS.Enabled { - serverOpts = append(serverOpts, policyxds.WithTLS( - cfg.Controller.PolicyServer.TLS.CertFile, - cfg.Controller.PolicyServer.TLS.KeyFile, - )) + policyXDSTLSConfig, err := config.BuildXDSServerTLSConfig(cfg.Controller.PolicyServer.TLS) + if err != nil { + log.Error("invalid policy_server.tls config, refusing to start policy xDS server in plaintext", slog.Any("error", err)) + os.Exit(1) + } + serverOpts = append(serverOpts, policyxds.WithMTLS(policyXDSTLSConfig, cfg.Controller.PolicyServer.TLS.AllowedClientIdentities)) } policyXDSServer := policyxds.NewServer(policySnapshotManager, apiKeySnapshotManager, lazyResourceSnapshotManager, subscriptionSnapshotManager, nil, cfg.Controller.PolicyServer.Port, log, serverOpts...) go func() { @@ -519,7 +531,23 @@ func main() { policyValidator := config.NewPolicyValidator(policyDefinitions) validator.SetPolicyValidator(policyValidator) - apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) + // Build the single shared outbound *http.Client used by every control-plane / + // platform-API / on-prem-APIM call this process makes. Built once, here, and injected + // into every constructor below instead of each one building (or caching) its own — + // real per-operation timeout budgets are enforced via context.WithTimeout at each call + // site, not by this client's own Timeout, which is only a generous safety-net backstop. + sharedHTTPClientCfg, err := config.BuildHTTPClientConfig(cfg.Controller.HTTPClient, cfg.Controller.ControlPlane.InsecureSkipVerify) + if err != nil { + log.Error("Invalid controller.http_client configuration", slog.Any("error", err)) + os.Exit(1) + } + sharedHTTPClient, err := httpclient.New(sharedHTTPClientCfg) + if err != nil { + log.Error("Failed to build shared outbound HTTP client", slog.Any("error", err)) + os.Exit(1) + } + + apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService, sharedHTTPClient) mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) @@ -542,6 +570,7 @@ func main() { secretsService, webhooksecret.GetStoreInstance(), nil, + sharedHTTPClient, ) if err := cpClient.Start(); err != nil { log.Error("Failed to start control plane client", slog.Any("error", err)) @@ -560,7 +589,7 @@ func main() { configStore, db, snapshotManager, policyManager, apiSvc, apiKeyXDSManager, cpClient, &cfg.Router, cfg, - &http.Client{Timeout: 10 * time.Second}, config.NewParser(), validator, log, + sharedHTTPClient, config.NewParser(), validator, log, eventHubInstance, secretsService, ) igw := immutable.NewImmutableGW(cfg.ImmutableGateway, restAPIService, llmSvc, mcpSvc) @@ -612,7 +641,7 @@ func main() { log.Info("EventListener started for multi-replica sync") // Initialize API server with the configured validator and API key manager - apiServer := handlers.NewAPIServer( + apiServer, err := handlers.NewAPIServer( configStore, db, snapshotManager, @@ -629,7 +658,12 @@ func main() { subscriptionSnapshotManager, secretsService, restAPIService, + sharedHTTPClient, ) + if err != nil { + log.Error("Failed to create API server", slog.Any("error", err)) + os.Exit(1) + } // Load immutable gateway artifacts from the filesystem (no-op when immutable mode is disabled). if err := igw.LoadArtifacts(log); err != nil { @@ -756,6 +790,31 @@ func main() { } }() + // Optional TLS listener for the REST API, additive to the plaintext one + // above — never instead of it. A misconfigured/missing certificate here + // disables just this listener rather than exiting the process, since the + // plaintext listener remains the required one. + var tlsSrv *http.Server + if cfg.Controller.Server.TLS.Enabled { + tlsConfig, err := buildRESTAPITLSConfig(&cfg.Controller.Server.TLS) + if err != nil { + log.Error("invalid server.tls config, REST API TLS listener disabled", slog.Any("error", err)) + } else { + tlsSrv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Controller.Server.TLS.Port), + Handler: handler, + ReadHeaderTimeout: 30 * time.Second, + TLSConfig: tlsConfig, + } + go func() { + log.Info("Starting REST API TLS server", slog.Int("port", cfg.Controller.Server.TLS.Port)) + if err := tlsSrv.ListenAndServeTLS(cfg.Controller.Server.TLS.CertPath, cfg.Controller.Server.TLS.KeyPath); err != nil && err != http.ErrServerClosed { + log.Error("REST API TLS server error", slog.Any("error", err)) + } + }() + } + } + log.Info("Gateway Controller started successfully") // Print banner when both router and policy engine have sent their first ACK, @@ -807,6 +866,12 @@ func main() { log.Error("Server forced to shutdown", slog.Any("error", err)) } + if tlsSrv != nil { + if err := tlsSrv.Shutdown(ctx); err != nil { + log.Error("REST API TLS server forced to shutdown", slog.Any("error", err)) + } + } + xdsServer.Stop() // Stop policy xDS server if it was started diff --git a/gateway/gateway-controller/cmd/controller/server_tls.go b/gateway/gateway-controller/cmd/controller/server_tls.go new file mode 100644 index 0000000000..e5b10b2571 --- /dev/null +++ b/gateway/gateway-controller/cmd/controller/server_tls.go @@ -0,0 +1,57 @@ +/* + * 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 main + +import ( + "crypto/tls" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +// buildRESTAPITLSConfig translates a config.ServerTLSConfig 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. Config.Validate already rejects a +// bad version/cipher/curve value before this ever runs in production, so an +// error here can only come from a caller that bypassed validation. +func buildRESTAPITLSConfig(cfg *config.ServerTLSConfig) (*tls.Config, error) { + if err := config.ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseServerEcdhCurves(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 +} diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 5529c72806..6e3604fb87 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -34,8 +34,8 @@ import ( commonmodels "github.com/wso2/api-platform/common/models" "github.com/wso2/api-platform/common/redact" adminapi "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/admin" - api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/handlers/handlerkit" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" @@ -99,7 +99,8 @@ func NewAPIServer( subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater, secretService *secrets.SecretService, restAPIService *restapi.RestAPIService, -) *APIServer { + httpClient *http.Client, +) (*APIServer, error) { if db == nil { panic("APIServer requires non-nil storage") } @@ -114,14 +115,13 @@ func NewAPIServer( panic("APIServer requires non-empty gateway ID") } - deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, &systemConfig.Router, eventHub, gatewayID, secretService) + deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, &systemConfig.Router, eventHub, gatewayID, secretService, httpClient) apiKeyService := utils.NewAPIKeyService(store, db, apiKeyXDSManager, &systemConfig.APIKey, eventHub, gatewayID) subscriptionResourceService := utils.NewSubscriptionResourceService(db, subscriptionSnapshotUpdater, eventHub, gatewayID) policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) policyValidator := config.NewPolicyValidator(policyDefinitions) parser := config.NewParser() - httpClient := &http.Client{Timeout: 10 * time.Second} routerConfig := &systemConfig.Router mcpDeploymentService := utils.NewMCPDeploymentService(store, db, snapshotManager, policyManager, policyValidator, eventHub, gatewayID, secretService) @@ -167,7 +167,7 @@ func NewAPIServer( // Register status update callback snapshotManager.SetStatusCallback(server.handleStatusUpdate) - return server + return server, nil } func (s *APIServer) getSubscriptionResourceService() *utils.SubscriptionResourceService { diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index f44f7163b4..a1a10f2469 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -1140,7 +1140,7 @@ func createTestAPIServerWithDB(db storage.Storage) *APIServer { gatewayID: gatewayID, } - deploymentService := utils.NewAPIDeploymentService(store, db, nil, validator, routerCfg, hub, gatewayID, nil) + deploymentService := utils.NewAPIDeploymentService(store, db, nil, validator, routerCfg, hub, gatewayID, nil, httpClient) server.deploymentService = deploymentService server.mcpDeploymentService = utils.NewMCPDeploymentService(store, db, nil, nil, nil, hub, gatewayID, nil) server.llmDeploymentService = utils.NewLLMDeploymentService( @@ -1379,7 +1379,7 @@ func attachTestEventHub(server *APIServer, hub eventhub.EventHub, gatewayID stri } policyValidator := config.NewPolicyValidator(server.policyDefinitions) policyVersionResolver := utils.NewLoadedPolicyVersionResolver(server.policyDefinitions) - server.deploymentService = utils.NewAPIDeploymentService(server.store, server.db, server.snapshotManager, server.validator, server.routerConfig, hub, gatewayID, nil) + server.deploymentService = utils.NewAPIDeploymentService(server.store, server.db, server.snapshotManager, server.validator, server.routerConfig, hub, gatewayID, nil, server.httpClient) server.apiKeyService = utils.NewAPIKeyService(server.store, server.db, server.apiKeyXDSManager, &server.systemConfig.APIKey, hub, gatewayID) server.subscriptionResourceService = utils.NewSubscriptionResourceService(server.db, server.subscriptionSnapshotUpdater, hub, gatewayID) server.mcpDeploymentService = utils.NewMCPDeploymentService(server.store, server.db, server.snapshotManager, server.policyManager, policyValidator, hub, gatewayID, nil) diff --git a/gateway/gateway-controller/pkg/config/config.go b/gateway/gateway-controller/pkg/config/config.go index f7cbea8276..9234988d8e 100644 --- a/gateway/gateway-controller/pkg/config/config.go +++ b/gateway/gateway-controller/pkg/config/config.go @@ -200,6 +200,7 @@ type Controller struct { Storage StorageConfig `koanf:"storage"` Logging LoggingConfig `koanf:"logging"` ControlPlane ControlPlaneConfig `koanf:"controlplane"` + HTTPClient HTTPClientConfig `koanf:"http_client"` PolicyServer PolicyServerConfig `koanf:"policy_server"` Policies PoliciesConfig `koanf:"policies"` LLM LLMConfig `koanf:"llm"` @@ -305,6 +306,77 @@ type ServerConfig struct { ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` GatewayID string `koanf:"gateway_id"` SkipInvalidDeploymentsOnStartup bool `koanf:"skip_invalid_deployments_on_startup"` + + // TLS starts a second, TLS-only listener on TLS.Port serving the same + // REST management API as the plaintext listener on APIPort. Off by + // default. + TLS ServerTLSConfig `koanf:"tls"` + + // XDSTLS switches the main xDS gRPC server (serving Envoy, on XDSPort) + // from plaintext to mutual TLS. Unlike TLS above, this does not add a + // second listener -- XDSPort itself starts speaking mTLS. Off by + // default; see XDSServerTLSConfig for why xDS has no server-only mode. + XDSTLS XDSServerTLSConfig `koanf:"xds_tls"` +} + +// ServerTLSConfig holds configuration for an additional TLS listener for the +// REST management API. It is served alongside — not instead of — the +// plaintext listener on ServerConfig.APIPort, so enabling it never breaks an +// existing plaintext deployment. Same shape and naming conventions as +// policy-engine's AdminTLSConfig (gateway-runtime/policy-engine/internal/config) — +// keep the two in sync if either changes, they are independent implementations +// (different Go modules) of the same pattern. +type ServerTLSConfig 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 REST API listener. Must differ from every + // other configured controller port (server.api_port, server.xds_port, + // admin_server.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 router.downstream_tls/upstream_tls for consistency + // within the 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 router.downstream_tls'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 router.downstream_tls/upstream_tls's EcdhCurves, 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 + // those fields — 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"` } // AdminServerConfig holds controller admin HTTP server configuration. @@ -337,15 +409,8 @@ type PprofConfig struct { // PolicyServerConfig holds policy xDS server-related configuration type PolicyServerConfig struct { - Port int `koanf:"port"` - TLS PolicyServerTLS `koanf:"tls"` -} - -// PolicyServerTLS holds TLS configuration for the policy xDS server -type PolicyServerTLS struct { - Enabled bool `koanf:"enabled"` - CertFile string `koanf:"cert_file"` - KeyFile string `koanf:"key_file"` + Port int `koanf:"port"` + TLS XDSServerTLSConfig `koanf:"tls"` } // PoliciesConfig holds policy-related configuration @@ -564,12 +629,15 @@ type UpstreamTLS 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, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. - EcdhCurves string `koanf:"ecdh_curves"` - 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"` @@ -599,10 +667,13 @@ 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, most preferred first. Defaults to a - // hybrid post-quantum group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) followed by - // classical curves, so key exchange degrades gracefully to classical for peers that don't yet - // support the hybrid group. + // 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"` } @@ -694,6 +765,117 @@ type ControlPlaneConfig struct { GatewayName string `koanf:"gateway_name"` // Name of the gateway for deployment configuration } +// HTTPClientConfig configures the single shared outbound *http.Client used by every +// control-plane / platform-API / on-prem-APIM call this process makes. It mirrors +// github.com/wso2/api-platform/httpkit/httpclient.Config field-for-field (see that package's own doc +// comments for full semantics) so every knob the library exposes that has a natural TOML +// shape is operator-configurable here, rather than hardcoded in main.go. The few fields +// httpclient.Config exposes that CANNOT be expressed in TOML — Go callback hooks +// (GetClientCertificate, VerifyPeerCertificate, VerifyConnection, ConnectHeader) and a +// pre-built *x509.CertPool / []*net.IPNet — are not represented here; a caller that needs +// those uses the httpclient package directly in code. +// +// Timeouts.Overall is only a generous safety-net budget: real per-operation budgets (5s +// well-known discovery, 30s manifest/platform-API/on-prem-APIM calls, etc.) are enforced via +// a context.WithTimeout deadline at each call site, since http.Client.Do honors a request's +// context deadline independent of the client-level Timeout. +// +// TLS.InsecureSkipVerify is intentionally NOT a field here — it is sourced from the single +// existing controller.controlplane.insecure_skip_verify setting (see main.go), which already +// governs this same trust decision for every one of this client's current callers (control +// plane, platform API, and on-prem APIM all copy that one field today). Duplicating it here +// would just create two settings that must always be kept in sync. +type HTTPClientConfig struct { + Pooling HTTPClientPoolingConfig `koanf:"pooling"` + Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` + TLS HTTPClientTLSConfig `koanf:"tls"` + Proxy HTTPClientProxyConfig `koanf:"proxy"` + SSRF HTTPClientSSRFConfig `koanf:"ssrf"` +} + +// HTTPClientPoolingConfig mirrors httpclient.PoolingConfig. +type HTTPClientPoolingConfig struct { + MaxIdleConns int `koanf:"max_idle_conns"` + MaxIdleConnsPerHost int `koanf:"max_idle_conns_per_host"` + MaxConnsPerHost int `koanf:"max_conns_per_host"` + IdleConnTimeout time.Duration `koanf:"idle_conn_timeout"` + KeepAlive time.Duration `koanf:"keep_alive"` + DisableKeepAlives bool `koanf:"disable_keep_alives"` + // EnableHTTP2 opts into HTTP/2. See httpclient.PoolingConfig.EnableHTTP2's doc comment + // on the HTTP/2 connection-coalescing caveat before enabling. + EnableHTTP2 bool `koanf:"enable_http2"` +} + +// HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. +type HTTPClientTimeoutsConfig struct { + Overall time.Duration `koanf:"overall"` // safety-net only; see HTTPClientConfig's doc comment + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + MaxResponseBytes int64 `koanf:"max_response_bytes"` // 0 = package default (10MiB); negative disables the bound +} + +// HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig. +type HTTPClientTLSConfig struct { + MinVersion string `koanf:"min_version"` // one of "TLS1_0".."TLS1_3" + MaxVersion string `koanf:"max_version"` // one of "TLS1_0".."TLS1_3" + CipherSuites string `koanf:"cipher_suites"` // comma-separated Go crypto/tls cipher suite names; TLS 1.2 and below only + CurvePreferences string `koanf:"curve_preferences"` // comma-separated, e.g. "X25519MLKEM768,X25519,P-256" + RootCAFile string `koanf:"root_ca_file"` // PEM CA bundle; empty uses the system root pool + ClientCertFile string `koanf:"client_cert_file"` // mTLS to the origin; both cert and key must be set together + ClientKeyFile string `koanf:"client_key_file"` +} + +// HTTPClientProxyConfig mirrors the TOML-expressible subset of httpclient.ProxyConfig. +type HTTPClientProxyConfig struct { + // Mode selects how the proxy is determined: "none" (default), "environment" + // (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), or "url" (URL/Username/Password/NoProxy below). + Mode string `koanf:"mode"` + URL string `koanf:"url"` + Username string `koanf:"username"` + Password string `koanf:"password"` + NoProxy []string `koanf:"no_proxy"` // exact host, ".suffix", or CIDR entries; only used when mode == "url" + + TLS HTTPClientProxyTLSConfig `koanf:"tls"` + + // Egress states how origin-destination SSRF risk is handled when a forward proxy is + // also configured: "delegated" (trust the proxy's own egress controls) or + // "manual_connect" (validate the origin locally before ever issuing CONNECT). Must be + // set explicitly whenever Mode != "none" and SSRF.Enabled — httpclient.New fails + // closed at startup otherwise rather than silently choosing one. + Egress string `koanf:"egress"` +} + +// HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS +// handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). +type HTTPClientProxyTLSConfig struct { + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` +} + +// HTTPClientSSRFConfig mirrors the TOML-expressible subset of httpclient.SSRFConfig. Off by +// default: this shared client's current callers (control plane, platform API, on-prem APIM) +// all target a single fixed, operator-configured host, not a user/tenant-supplied URL — the +// scenario ssrf-prevention.md targets — so there is nothing to guard against today. Exposed +// for completeness and for any future caller of this shared client that fetches a +// tenant-supplied URL. +type HTTPClientSSRFConfig struct { + Enabled bool `koanf:"enabled"` + // Preset selects a built-in netguard policy: "permit_private_block_metadata" (a + // backend that is normally private — a ClusterIP, a service-DNS name, localhost — + // stays reachable; only link-local/metadata/unspecified/multicast are refused) or + // "public_only" (stricter: every private/loopback/link-local/CGNAT address is + // refused, for a URL expected to point at the public internet). Required when Enabled + // is true. Custom CIDR allow/deny lists have no natural TOML shape and are not + // exposed here — use the httpclient/netguard packages directly in code for that. + Preset string `koanf:"preset"` + MaxRedirects int `koanf:"max_redirects"` + AllowedSchemes []string `koanf:"allowed_schemes"` // empty defaults to {"https"} +} + // APIKeyConfig represents the configuration for API keys type APIKeyConfig struct { APIKeysPerUserPerAPI int `koanf:"api_keys_per_user_per_api"` // Number of API keys allowed per user per API @@ -842,6 +1024,24 @@ func defaultConfig() *Config { ShutdownTimeout: 15 * time.Second, GatewayID: constants.PlatformGatewayId, SkipInvalidDeploymentsOnStartup: false, + TLS: ServerTLSConfig{ + Enabled: false, + Port: 9093, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, + XDSTLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", + }, }, AdminServer: AdminServerConfig{ Enabled: true, @@ -858,10 +1058,15 @@ func defaultConfig() *Config { }, PolicyServer: PolicyServerConfig{ Port: 18001, - TLS: PolicyServerTLS{ - Enabled: false, - CertFile: "./certs/server.crt", - KeyFile: "./certs/server.key", + TLS: XDSServerTLSConfig{ + Enabled: false, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, }, Policies: PoliciesConfig{ @@ -923,6 +1128,33 @@ func defaultConfig() *Config { AIWorkspaceSyncPoolSize: 0, AIWorkspaceSyncQueueSize: 0, }, + HTTPClient: HTTPClientConfig{ + Pooling: HTTPClientPoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + }, + Timeouts: HTTPClientTimeoutsConfig{ + Overall: 60 * time.Second, // safety-net only; see HTTPClientConfig's doc comment + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + }, + TLS: HTTPClientTLSConfig{ + MinVersion: "TLS1_2", + MaxVersion: "TLS1_3", + CurvePreferences: "", + }, + Proxy: HTTPClientProxyConfig{ + Mode: "none", + }, + SSRF: HTTPClientSSRFConfig{ + Enabled: false, + }, + }, EventHub: EventHubConfig{ PollInterval: 3 * time.Second, CleanupInterval: 10 * time.Minute, @@ -999,7 +1231,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: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", }, GatewayHost: "*", Upstream: RouterUpstream{ @@ -1007,7 +1239,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: "X25519MLKEM768,X25519,P-256", + EcdhCurves: "X25519,P-256", TrustedCertPath: "/etc/ssl/certs/ca-certificates.crt", CustomCertsPath: "./certificates", VerifyHostName: true, @@ -1367,6 +1599,44 @@ func (c *Config) Validate() error { return fmt.Errorf("server.gateway_id is required and cannot be empty") } + // Validate REST API TLS config + if c.Controller.Server.TLS.Enabled { + if c.Controller.Server.TLS.Port < 1 || c.Controller.Server.TLS.Port > 65535 { + return fmt.Errorf("server.tls.port must be between 1 and 65535, got: %d", c.Controller.Server.TLS.Port) + } + if c.Controller.Server.TLS.Port == c.Controller.Server.APIPort { + return fmt.Errorf("server.tls.port cannot be same as server.api_port") + } + if c.Controller.Server.TLS.Port == c.Controller.Server.XDSPort { + return fmt.Errorf("server.tls.port cannot be same as server.xds_port") + } + if c.Controller.Server.TLS.CertPath == "" { + return fmt.Errorf("server.tls.cert_path is required when server.tls.enabled") + } + if c.Controller.Server.TLS.KeyPath == "" { + return fmt.Errorf("server.tls.key_path is required when server.tls.enabled") + } + if err := ValidateServerTLSVersions(c.Controller.Server.TLS.MinimumProtocolVersion, c.Controller.Server.TLS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("server.tls: %w", err) + } + if _, err := ParseServerCiphers(c.Controller.Server.TLS.Ciphers); err != nil { + return fmt.Errorf("server.tls.ciphers: %w", err) + } + if _, err := ParseServerEcdhCurves(c.Controller.Server.TLS.EcdhCurves); err != nil { + return fmt.Errorf("server.tls.ecdh_curves: %w", err) + } + } + + // Validate main xDS server mTLS config (serves Envoy on server.xds_port) + if err := ValidateXDSServerTLS("server.xds_tls", c.Controller.Server.XDSTLS); err != nil { + return err + } + + // Validate policy xDS server mTLS config (serves the policy-engine on policy_server.port) + if err := ValidateXDSServerTLS("policy_server.tls", c.Controller.PolicyServer.TLS); err != nil { + return err + } + if c.Controller.AdminServer.Enabled { if c.Controller.AdminServer.Port < 1 || c.Controller.AdminServer.Port > 65535 { return fmt.Errorf("admin_server.port must be between 1 and 65535, got: %d", c.Controller.AdminServer.Port) @@ -1377,6 +1647,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Port == c.Controller.Server.XDSPort { return fmt.Errorf("admin_server.port cannot be same as server.xds_port") } + if c.Controller.Server.TLS.Enabled && c.Controller.AdminServer.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("admin_server.port cannot be same as server.tls.port") + } } // Validate metrics config @@ -1393,6 +1666,9 @@ func (c *Config) Validate() error { if c.Controller.AdminServer.Enabled && c.Controller.Metrics.Port == c.Controller.AdminServer.Port { return fmt.Errorf("metrics.port cannot be same as admin_server.port") } + if c.Controller.Server.TLS.Enabled && c.Controller.Metrics.Port == c.Controller.Server.TLS.Port { + return fmt.Errorf("metrics.port cannot be same as server.tls.port") + } } if c.Router.ListenerPort < 1 || c.Router.ListenerPort > 65535 { diff --git a/gateway/gateway-controller/pkg/config/config_test.go b/gateway/gateway-controller/pkg/config/config_test.go index 2fbebb5417..29b9630f28 100644 --- a/gateway/gateway-controller/pkg/config/config_test.go +++ b/gateway/gateway-controller/pkg/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "crypto/tls" "os" "path/filepath" "strings" @@ -664,6 +665,330 @@ func TestConfig_Validate_Ports(t *testing.T) { } } +func TestConfig_Validate_ServerTLS(t *testing.T) { + validTLS := func() ServerTLSConfig { + return ServerTLSConfig{ + Enabled: true, + Port: 9093, + CertPath: "./certs/rest-api.crt", + KeyPath: "./certs/rest-api.key", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*ServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(tls *ServerTLSConfig) {}, wantErr: false}, + { + name: "PQC hybrid group opt-in", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "X25519MLKEM768,X25519,P-256" }, + wantErr: false, + }, + { + name: "restricted cipher suite list", + mutate: func(tls *ServerTLSConfig) { + tls.Ciphers = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + }, + wantErr: false, + }, + { + name: "invalid port zero", + mutate: func(tls *ServerTLSConfig) { tls.Port = 0 }, + wantErr: true, + errContains: "server.tls.port must be between", + }, + { + name: "port conflicts with api_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 8080 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.api_port", + }, + { + name: "port conflicts with xds_port", + mutate: func(tls *ServerTLSConfig) { tls.Port = 18000 }, + wantErr: true, + errContains: "server.tls.port cannot be same as server.xds_port", + }, + { + name: "missing cert path", + mutate: func(tls *ServerTLSConfig) { tls.CertPath = "" }, + wantErr: true, + errContains: "server.tls.cert_path is required", + }, + { + name: "missing key path", + mutate: func(tls *ServerTLSConfig) { tls.KeyPath = "" }, + wantErr: true, + errContains: "server.tls.key_path is required", + }, + { + name: "missing minimum protocol version", + mutate: func(tls *ServerTLSConfig) { tls.MinimumProtocolVersion = "" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "minimum protocol version greater than maximum", + mutate: func(tls *ServerTLSConfig) { + tls.MinimumProtocolVersion = "TLS1_3" + tls.MaximumProtocolVersion = "TLS1_2" + }, + wantErr: true, + errContains: "cannot be greater than maximum_protocol_version", + }, + { + name: "unsupported cipher suite", + mutate: func(tls *ServerTLSConfig) { tls.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "server.tls.ciphers", + }, + { + name: "unsupported ecdh curve", + mutate: func(tls *ServerTLSConfig) { tls.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "server.tls.ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + tlsCfg := validTLS() + tt.mutate(&tlsCfg) + cfg.Controller.Server.TLS = tlsCfg + + err := cfg.Validate() + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } + + t.Run("disabled - no validation", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.TLS = ServerTLSConfig{Enabled: false, Port: 0} // invalid but should pass since disabled + assert.NoError(t, cfg.Validate()) + }) + + t.Run("conflicts with admin_server.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.AdminServer.Enabled = true + cfg.Controller.AdminServer.Port = 9092 + tlsCfg := validTLS() + tlsCfg.Port = 9092 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "admin_server.port cannot be same as server.tls.port") + }) + + t.Run("conflicts with metrics.port", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.APIPort = 8080 + cfg.Controller.Server.XDSPort = 18000 + cfg.Controller.Metrics.Enabled = true + cfg.Controller.Metrics.Port = 9091 + tlsCfg := validTLS() + tlsCfg.Port = 9091 + cfg.Controller.Server.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "metrics.port cannot be same as server.tls.port") + }) +} + +// TestConfig_Validate_XDSServerTLS verifies Config.Validate() routes +// server.xds_tls and policy_server.tls through ValidateXDSServerTLS. +func TestConfig_Validate_XDSServerTLS(t *testing.T) { + validXDSTLS := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./xds-certs/server.crt", + KeyFile: "./xds-certs/server.key", + ClientCAFile: "./xds-certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://api-platform/gateway-runtime/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + t.Run("valid server.xds_tls passes", func(t *testing.T) { + cfg := validConfig() + cfg.Controller.Server.XDSTLS = validXDSTLS() + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid server.xds_tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = nil + cfg.Controller.Server.XDSTLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "server.xds_tls.allowed_client_identities") + }) + + t.Run("valid policy_server.tls passes", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.AllowedClientIdentities = []string{"spiffe://api-platform/gateway-runtime/policy-engine"} + cfg.Controller.PolicyServer.TLS = tlsCfg + assert.NoError(t, cfg.Validate()) + }) + + t.Run("invalid policy_server.tls is rejected with a prefixed error", func(t *testing.T) { + cfg := validConfig() + tlsCfg := validXDSTLS() + tlsCfg.ClientCAFile = "" + cfg.Controller.PolicyServer.TLS = tlsCfg + err := cfg.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "policy_server.tls.client_ca_file") + }) + + t.Run("disabled by default -- no validation", func(t *testing.T) { + cfg := validConfig() + assert.NoError(t, cfg.Validate()) + }) +} + +// TestParseServerEcdhCurves tests the ECDH curve preference parser used by +// ServerTLSConfig.EcdhCurves. +func TestParseServerEcdhCurves(t *testing.T) { + t.Run("classical curves only", func(t *testing.T) { + curves, err := ParseServerEcdhCurves("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 := ParseServerEcdhCurves("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 := ParseServerEcdhCurves(" 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 := ParseServerEcdhCurves("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 := ParseServerEcdhCurves("") + assert.Error(t, err) + }) +} + +// TestValidateServerTLSVersions tests the min/max protocol version +// validation used by ServerTLSConfig. +func TestValidateServerTLSVersions(t *testing.T) { + t.Run("valid TLS1_2 to TLS1_3 range", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_3")) + }) + + t.Run("equal min and max", func(t *testing.T) { + assert.NoError(t, ValidateServerTLSVersions("TLS1_2", "TLS1_2")) + }) + + t.Run("unrecognized minimum version", func(t *testing.T) { + err := ValidateServerTLSVersions("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 := ValidateServerTLSVersions("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 := ValidateServerTLSVersions("TLS1_3", "TLS1_2") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be greater than maximum_protocol_version") + }) +} + +// TestParseServerTLSVersion tests the version-name to crypto/tls-identifier +// conversion used by ServerTLSConfig. +func TestParseServerTLSVersion(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 := ParseServerTLSVersion(tt.version) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("unrecognized version", func(t *testing.T) { + _, ok := ParseServerTLSVersion("bogus") + assert.False(t, ok) + }) +} + +// TestParseServerCiphers tests the cipher-suite-name parser used by +// ServerTLSConfig.Ciphers. +func TestParseServerCiphers(t *testing.T) { + t.Run("empty string is valid and means Go's defaults", func(t *testing.T) { + suites, err := ParseServerCiphers("") + require.NoError(t, err) + assert.Nil(t, suites) + }) + + t.Run("restricts to the named secure suites", func(t *testing.T) { + suites, err := ParseServerCiphers("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 := ParseServerCiphers(" 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 := ParseServerCiphers("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 := ParseServerCiphers("NOT_A_REAL_SUITE") + assert.Error(t, err) + }) +} + func TestConfig_Validate_MetricsConfig(t *testing.T) { tests := []struct { name string @@ -1812,14 +2137,18 @@ func TestDefaultConfig(t *testing.T) { 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 with a hybrid post-quantum + classical ECDH curve preference - // list must be available by default on both upstream and downstream. + // 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, "X25519MLKEM768,X25519,P-256", cfg.Router.DownstreamTLS.EcdhCurves) + 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, "X25519MLKEM768,X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) + assert.Equal(t, "X25519,P-256", cfg.Router.Upstream.TLS.EcdhCurves) } func TestLoadConfig_HCMTimeouts(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/config/httpclient_config.go b/gateway/gateway-controller/pkg/config/httpclient_config.go new file mode 100644 index 0000000000..b16362f2a5 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/httpclient_config.go @@ -0,0 +1,115 @@ +/* + * 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 ( + "fmt" + + "github.com/wso2/api-platform/httpkit/httpclient" + "github.com/wso2/api-platform/httpkit/netguard" +) + +// BuildHTTPClientConfig translates an HTTPClientConfig (sourced from +// controller.http_client in config.toml) into an httpkit httpclient.Config, mirroring +// every field the library exposes that has a natural TOML shape. insecureSkipVerify is +// threaded in separately from controller.controlplane.insecure_skip_verify — see +// HTTPClientConfig's doc comment for why that trust setting isn't duplicated here. +// +// This is shared by every binary that loads this package's Config (gateway-controller +// and event-gateway-controller today) so the translation logic is implemented exactly +// once rather than copy-pasted per binary. +func BuildHTTPClientConfig(hc HTTPClientConfig, insecureSkipVerify bool) (httpclient.Config, error) { + cfg := httpclient.DefaultConfig() + + cfg.Pooling.MaxIdleConns = hc.Pooling.MaxIdleConns + cfg.Pooling.MaxIdleConnsPerHost = hc.Pooling.MaxIdleConnsPerHost + cfg.Pooling.MaxConnsPerHost = hc.Pooling.MaxConnsPerHost + cfg.Pooling.IdleConnTimeout = hc.Pooling.IdleConnTimeout + cfg.Pooling.KeepAlive = hc.Pooling.KeepAlive + cfg.Pooling.DisableKeepAlives = hc.Pooling.DisableKeepAlives + cfg.Pooling.EnableHTTP2 = hc.Pooling.EnableHTTP2 + + cfg.Timeouts.Overall = hc.Timeouts.Overall + cfg.Timeouts.Dial = hc.Timeouts.Dial + cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake + cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader + cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes + + cfg.TLS.MinVersion = hc.TLS.MinVersion + cfg.TLS.MaxVersion = hc.TLS.MaxVersion + cfg.TLS.CipherSuites = hc.TLS.CipherSuites + cfg.TLS.CurvePreferences = hc.TLS.CurvePreferences + cfg.TLS.RootCAFile = hc.TLS.RootCAFile + cfg.TLS.ClientCertFile = hc.TLS.ClientCertFile + cfg.TLS.ClientKeyFile = hc.TLS.ClientKeyFile + cfg.TLS.InsecureSkipVerify = insecureSkipVerify // #nosec G402 -- explicit operator-controlled opt-out for dev/test environments. + cfg.TLS.InsecureSkipVerifyAcknowledged = insecureSkipVerify // required double-gate; mirrors InsecureSkipVerify, harmless when false. + + switch hc.Proxy.Mode { + case "", "none": + // no proxy — cfg.Proxy stays at its zero value + case "environment": + cfg.Proxy.Mode = "environment" + case "url": + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = hc.Proxy.URL + cfg.Proxy.Username = hc.Proxy.Username + cfg.Proxy.Password = hc.Proxy.Password + cfg.Proxy.NoProxy = hc.Proxy.NoProxy + if hc.Proxy.TLS != (HTTPClientProxyTLSConfig{}) { + cfg.Proxy.ProxyTLS = &httpclient.ProxyTLSConfig{ + RootCAFile: hc.Proxy.TLS.RootCAFile, + ClientCertFile: hc.Proxy.TLS.ClientCertFile, + ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, + InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerify, + } + } + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) + } + + if hc.SSRF.Enabled { + cfg.SSRF.Enabled = true + switch hc.SSRF.Preset { + case "permit_private_block_metadata": + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + case "public_only": + cfg.SSRF.Policy = netguard.PublicOnly() + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.ssrf.preset: unrecognized value %q (want \"permit_private_block_metadata\" or \"public_only\")", hc.SSRF.Preset) + } + cfg.SSRF.Policy.AllowedSchemes = hc.SSRF.AllowedSchemes + cfg.SSRF.MaxRedirects = hc.SSRF.MaxRedirects + + if cfg.Proxy.Mode != "" && cfg.Proxy.Mode != "none" { + switch hc.Proxy.Egress { + case "delegated": + cfg.Proxy.Egress = httpclient.ProxyEgressDelegated + case "manual_connect": + cfg.Proxy.Egress = httpclient.ProxyEgressManualCONNECT + default: + return httpclient.Config{}, fmt.Errorf("controller.http_client.proxy.egress must be \"delegated\" or \"manual_connect\" when both proxy and SSRF are enabled, got %q", hc.Proxy.Egress) + } + } + } + + return cfg, nil +} diff --git a/gateway/gateway-controller/pkg/config/server_tls.go b/gateway/gateway-controller/pkg/config/server_tls.go new file mode 100644 index 0000000000..e3f483cf20 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/server_tls.go @@ -0,0 +1,95 @@ +/* + * 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// ParseServerEcdhCurves 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 REST API TLS listener's config. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +func ParseServerEcdhCurves(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 := tlsconfig.CurvesByName[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 +} + +// ValidateServerTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateServerTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseServerTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateServerTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseServerTLSVersion(name string) (version uint16, ok bool) { + return tlsconfig.ParseVersion(name) +} + +// ParseServerCiphers 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 ParseServerCiphers(raw string) ([]uint16, error) { + return tlsconfig.ParseCipherSuites(raw) +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls.go b/gateway/gateway-controller/pkg/config/xds_tls.go new file mode 100644 index 0000000000..041b61943e --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls.go @@ -0,0 +1,179 @@ +/* + * 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" + "crypto/x509" + "fmt" + "os" +) + +// XDSServerTLSConfig holds mutual-TLS configuration for an xDS gRPC server: +// the main Envoy-facing ADS/SDS server on server.xds_port, and the +// policy-engine-facing server on policy_server.port. Off by default -- both +// servers keep working in plaintext either way, consistent with this +// repo's "PQC/TLS is optional-but-supported, not mandatory" posture (see +// post-quantum-cryptography.md), since not every deployment's Envoy or +// policy-engine build is configured for mTLS yet. +// +// Unlike ServerTLSConfig (the REST management API's TLS listener, which is +// server-only TLS), this type has no server-only mode: xDS is a +// control-plane channel that carries per-tenant API-key hashes, +// subscription state, and full policy chains, so authenticating only the +// server side is not sufficient (go-control-plane-xds-security.md +// directive 2). Whenever Enabled is true, ClientCAFile and +// AllowedClientIdentities are both required -- see ValidateXDSServerTLS. +type XDSServerTLSConfig struct { + // Enabled switches the xDS server from plaintext to mutual TLS on its + // existing port (server.xds_port or policy_server.port) -- there is no + // second listener the way ServerTLSConfig adds one for the REST API, + // since a gRPC server serves one credential type per port. + Enabled bool `koanf:"enabled"` + + // CertFile and KeyFile are the PEM-encoded server certificate and + // private key this xDS server presents to connecting clients. Required + // when Enabled. + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + + // ClientCAFile is a PEM bundle of CA certificates trusted to sign a + // connecting client's certificate (Envoy's or the policy-engine's). + // Required when Enabled -- this is what makes the handshake mutual + // rather than server-only. + ClientCAFile string `koanf:"client_ca_file"` + + // AllowedClientIdentities is an explicit allowlist of accepted peer + // certificate identities: a certificate's first SAN URI (e.g. a SPIFFE + // ID) if present, otherwise its Subject CommonName -- see + // pkg/tlsauth.PeerIdentity. A client certificate that chains to a + // trusted CA is not by itself authorization to reach this snapshot; at + // least one identity is required when Enabled, so this can't be + // silently left as a no-op allowlist (go-control-plane-xds-security.md + // directive 2). + AllowedClientIdentities []string `koanf:"allowed_client_identities"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the + // negotiated TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + // Same vocabulary as ServerTLSConfig/router.downstream_tls for + // consistency within this 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 + // restricting which suites this server 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 not configurable in Go's crypto/tls. + 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"). A hybrid post-quantum + // group ("X25519MLKEM768", FIPS 203 ML-KEM-768 + X25519) can be + // prepended once both the Envoy/policy-engine peers reaching this + // server are confirmed to support it -- this server is Go's own + // crypto/tls (1.23+ implements X25519MLKEM768 natively), so an + // unsupporting peer simply falls back to a later classical entry in + // this same list. + EcdhCurves string `koanf:"ecdh_curves"` +} + +// ValidateXDSServerTLS validates an XDSServerTLSConfig block, a no-op when +// Enabled is false. fieldPrefix is the dotted config path used in error +// messages (e.g. "server.xds_tls" or "policy_server.tls"). +func ValidateXDSServerTLS(fieldPrefix string, cfg XDSServerTLSConfig) error { + if !cfg.Enabled { + return nil + } + if cfg.CertFile == "" { + return fmt.Errorf("%s.cert_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.KeyFile == "" { + return fmt.Errorf("%s.key_file is required when %s.enabled", fieldPrefix, fieldPrefix) + } + if cfg.ClientCAFile == "" { + return fmt.Errorf("%s.client_ca_file is required when %s.enabled -- xDS requires mutual TLS, server-only TLS is not offered for this server", fieldPrefix, fieldPrefix) + } + if len(cfg.AllowedClientIdentities) == 0 { + return fmt.Errorf("%s.allowed_client_identities must list at least one accepted peer identity when %s.enabled", fieldPrefix, fieldPrefix) + } + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return fmt.Errorf("%s: %w", fieldPrefix, err) + } + if _, err := ParseServerCiphers(cfg.Ciphers); err != nil { + return fmt.Errorf("%s.ciphers: %w", fieldPrefix, err) + } + if _, err := ParseServerEcdhCurves(cfg.EcdhCurves); err != nil { + return fmt.Errorf("%s.ecdh_curves: %w", fieldPrefix, err) + } + return nil +} + +// BuildXDSServerTLSConfig turns a validated XDSServerTLSConfig into a +// *tls.Config enforcing mutual TLS: the server's own certificate, plus a +// client CA pool used to require and verify a client certificate +// (tls.RequireAndVerifyClientCert). This only performs the TLS handshake -- +// checking the verified peer's identity against AllowedClientIdentities is +// a separate authorization step the xDS server's stream callbacks must +// still perform (see pkg/tlsauth.VerifyStreamPeer); a certificate chaining +// to a trusted CA is not by itself authorization to reach this snapshot. +// +// Callers should run ValidateXDSServerTLS first; this function re-validates +// version/cipher/curve fields defensively but does not check +// AllowedClientIdentities, which it never reads. +func BuildXDSServerTLSConfig(cfg XDSServerTLSConfig) (*tls.Config, error) { + if err := ValidateServerTLSVersions(cfg.MinimumProtocolVersion, cfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := ParseServerTLSVersion(cfg.MinimumProtocolVersion) + maxVersion, _ := ParseServerTLSVersion(cfg.MaximumProtocolVersion) + + cipherSuites, err := ParseServerCiphers(cfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := ParseServerEcdhCurves(cfg.EcdhCurves) + if err != nil { + return nil, err + } + + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("loading xDS server certificate: %w", err) + } + + caPEM, err := os.ReadFile(cfg.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("reading xDS client CA file: %w", err) + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no valid certificates found in xDS client CA file %q", cfg.ClientCAFile) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: clientCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil +} diff --git a/gateway/gateway-controller/pkg/config/xds_tls_test.go b/gateway/gateway-controller/pkg/config/xds_tls_test.go new file mode 100644 index 0000000000..7c8429fa45 --- /dev/null +++ b/gateway/gateway-controller/pkg/config/xds_tls_test.go @@ -0,0 +1,364 @@ +/* + * 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 ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateXDSTestCA generates a self-signed CA and returns its cert/key +// (PEM-encoded) plus a helper that mints a leaf certificate signed by it. +func generateXDSTestCA(t *testing.T) (caCertPEM []byte, caKey *ecdsa.PrivateKey, caCert *x509.Certificate) { + 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: "test xDS CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return pemBytes, priv, cert +} + +// writeXDSLeafCert mints a leaf certificate signed by the given CA and +// writes its cert+key as PEM files under dir, returning their paths. +func writeXDSLeafCert(t *testing.T, dir, name string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey, isServer bool) (certPath, keyPath string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + if isServer { + template.DNSNames = []string{"localhost"} + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + } else { + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + + der, err := x509.CreateCertificate(rand.Reader, template, caCert, &priv.PublicKey, caKey) + require.NoError(t, err) + + certPath = filepath.Join(dir, name+".crt") + keyPath = filepath.Join(dir, name+".key") + + certOut, err := os.Create(certPath) + require.NoError(t, err) + defer certOut.Close() + require.NoError(t, pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})) + + 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})) + + return certPath, keyPath +} + +func TestValidateXDSServerTLS(t *testing.T) { + validCfg := func() XDSServerTLSConfig { + return XDSServerTLSConfig{ + Enabled: true, + CertFile: "./certs/server.crt", + KeyFile: "./certs/server.key", + ClientCAFile: "./certs/ca.crt", + AllowedClientIdentities: []string{"spiffe://cluster.local/ns/gw/sa/envoy"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + } + + tests := []struct { + name string + mutate func(*XDSServerTLSConfig) + wantErr bool + errContains string + }{ + {name: "valid config", mutate: func(*XDSServerTLSConfig) {}, wantErr: false}, + { + name: "disabled skips all checks", + mutate: func(c *XDSServerTLSConfig) { *c = XDSServerTLSConfig{Enabled: false} }, + wantErr: false, + }, + { + name: "missing cert file", + mutate: func(c *XDSServerTLSConfig) { c.CertFile = "" }, + wantErr: true, + errContains: "cert_file is required", + }, + { + name: "missing key file", + mutate: func(c *XDSServerTLSConfig) { c.KeyFile = "" }, + wantErr: true, + errContains: "key_file is required", + }, + { + name: "missing client CA file -- xDS has no server-only TLS mode", + mutate: func(c *XDSServerTLSConfig) { c.ClientCAFile = "" }, + wantErr: true, + errContains: "client_ca_file is required", + }, + { + name: "empty allowed client identities is fail-closed, not a no-op allowlist", + mutate: func(c *XDSServerTLSConfig) { c.AllowedClientIdentities = nil }, + wantErr: true, + errContains: "allowed_client_identities must list at least one", + }, + { + name: "bad protocol version", + mutate: func(c *XDSServerTLSConfig) { c.MinimumProtocolVersion = "TLS9_9" }, + wantErr: true, + errContains: "minimum_protocol_version", + }, + { + name: "unsupported cipher", + mutate: func(c *XDSServerTLSConfig) { c.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" }, + wantErr: true, + errContains: "ciphers", + }, + { + name: "unsupported curve", + mutate: func(c *XDSServerTLSConfig) { c.EcdhCurves = "not-a-curve" }, + wantErr: true, + errContains: "ecdh_curves", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validCfg() + tt.mutate(&cfg) + err := ValidateXDSServerTLS("policy_server.tls", cfg) + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildXDSServerTLSConfig(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + + t.Run("builds a working mTLS config", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotNil(t, tlsConfig) + + assert.Len(t, tlsConfig.Certificates, 1) + assert.NotNil(t, tlsConfig.ClientCAs) + assert.Equal(t, tls.RequireAndVerifyClientCert, tlsConfig.ClientAuth) + assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), tlsConfig.MaxVersion) + }) + + t.Run("PQC hybrid group opt-in is reflected in CurvePreferences", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"anything"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + tlsConfig, err := BuildXDSServerTLSConfig(cfg) + require.NoError(t, err) + require.NotEmpty(t, tlsConfig.CurvePreferences) + assert.Equal(t, tls.X25519MLKEM768, tlsConfig.CurvePreferences[0]) + }) + + t.Run("missing cert file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: "/nonexistent/cert.pem", + KeyFile: "/nonexistent/key.pem", + ClientCAFile: caCertPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("missing CA file surfaces a clear error", func(t *testing.T) { + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: "/nonexistent/ca.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + }) + + t.Run("garbage CA file content is rejected", func(t *testing.T) { + badCAPath := filepath.Join(dir, "bad-ca.crt") + require.NoError(t, os.WriteFile(badCAPath, []byte("not a certificate"), 0o600)) + cfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: badCAPath, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519", + } + _, err := BuildXDSServerTLSConfig(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid certificates") + }) +} + +// TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation is a live TLS +// handshake (not just a config-shape assertion) proving BuildXDSServerTLSConfig's +// CurvePreferences is actually honored by crypto/tls's negotiation, and that a +// peer without PQC support still completes the handshake via classical +// fallback -- the exact interoperability guarantee post-quantum-cryptography.md +// requires ("must not hard-fail... when talking to a peer that only speaks +// classical algorithms"). +func TestBuildXDSServerTLSConfig_PQCHandshakeNegotiation(t *testing.T) { + dir := t.TempDir() + _, caKey, caCert := generateXDSTestCA(t) + caCertPath := filepath.Join(dir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}), 0o600)) + serverCertPath, serverKeyPath := writeXDSLeafCert(t, dir, "server", caCert, caKey, true) + clientCertPath, clientKeyPath := writeXDSLeafCert(t, dir, "client", caCert, caKey, false) + + serverCfg := XDSServerTLSConfig{ + Enabled: true, + CertFile: serverCertPath, + KeyFile: serverKeyPath, + ClientCAFile: caCertPath, + AllowedClientIdentities: []string{"client"}, + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + } + serverTLSConfig, err := BuildXDSServerTLSConfig(serverCfg) + require.NoError(t, err) + + clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + require.NoError(t, err) + clientCAPool := x509.NewCertPool() + clientCAPool.AddCert(caCert) + + dial := func(t *testing.T, clientCurves []tls.CurveID) tls.CurveID { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverTLSConfig) + require.NoError(t, err) + defer ln.Close() + + negotiated := make(chan tls.CurveID, 1) + errCh := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() + tlsConn := conn.(*tls.Conn) + if err := tlsConn.HandshakeContext(context.Background()); err != nil { + errCh <- err + return + } + negotiated <- tlsConn.ConnectionState().CurveID + errCh <- nil + }() + + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + RootCAs: clientCAPool, + ServerName: "localhost", + CurvePreferences: clientCurves, + } + conn, err := tls.Dial("tcp", ln.Addr().String(), clientTLSConfig) + require.NoError(t, err) + defer conn.Close() + + require.NoError(t, <-errCh) + return <-negotiated + } + + t.Run("PQC-capable peer negotiates the hybrid group", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.X25519MLKEM768, tls.X25519}) + assert.Equal(t, tls.X25519MLKEM768, curve) + }) + + t.Run("classical-only peer still completes the handshake", func(t *testing.T) { + curve := dial(t, []tls.CurveID{tls.CurveP256}) + assert.Equal(t, tls.CurveP256, curve) + }) +} diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index e50480a59d..6173c2f48d 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -123,37 +123,45 @@ type WebhookSecretSnapshotRefresher interface { // Client manages the WebSocket connection to the control plane type Client struct { - config config.ControlPlaneConfig - logger *slog.Logger - state *ConnectionState - ctx context.Context - cancel context.CancelFunc - stopChan chan struct{} - wg sync.WaitGroup - writeMu sync.Mutex // serializes writes to the WebSocket connection - store *storage.ConfigStore - db storage.Storage - snapshotManager *xds.SnapshotManager - parser *config.Parser - validator config.Validator - deploymentService *utils.APIDeploymentService - apiUtilsService *utils.APIUtilsService - apiKeyService *utils.APIKeyService - llmDeploymentService *utils.LLMDeploymentService - mcpDeploymentService *utils.MCPDeploymentService - apiKeyXDSManager utils.XDSManager - apiKeyStore *storage.APIKeyStore - routerConfig *config.RouterConfig - policyManager *policyxds.PolicyManager - systemConfig *config.Config - policyDefinitions map[string]models.PolicyDefinition - subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater - subscriptionResourceService *utils.SubscriptionResourceService - eventHub eventhub.EventHub - gatewayID string - gatewayPath string // cached gateway path from well-known discovery - syncOnce sync.Once // ensures deployment sync runs only on first connect - isFirstConnect atomic.Bool // true on first connect, flipped to false after + config config.ControlPlaneConfig + logger *slog.Logger + state *ConnectionState + ctx context.Context + cancel context.CancelFunc + stopChan chan struct{} + wg sync.WaitGroup + writeMu sync.Mutex // serializes writes to the WebSocket connection + store *storage.ConfigStore + db storage.Storage + snapshotManager *xds.SnapshotManager + parser *config.Parser + validator config.Validator + deploymentService *utils.APIDeploymentService + apiUtilsService *utils.APIUtilsService + apiKeyService *utils.APIKeyService + llmDeploymentService *utils.LLMDeploymentService + mcpDeploymentService *utils.MCPDeploymentService + apiKeyXDSManager utils.XDSManager + apiKeyStore *storage.APIKeyStore + routerConfig *config.RouterConfig + policyManager *policyxds.PolicyManager + systemConfig *config.Config + policyDefinitions map[string]models.PolicyDefinition + subscriptionSnapshotUpdater utils.SubscriptionSnapshotUpdater + subscriptionResourceService *utils.SubscriptionResourceService + eventHub eventhub.EventHub + gatewayID string + gatewayPath string // cached gateway path from well-known discovery + syncOnce sync.Once // ensures deployment sync runs only on first connect + isFirstConnect atomic.Bool // true on first connect, flipped to false after + + // httpClient is the single shared outbound *http.Client for this entire process (built + // once in cmd/controller/main.go and injected here), used for every plain (non-WebSocket) + // REST call this Client makes to the control plane host — well-known gateway-path + // discovery, gateway manifest push, platform-API calls (via apiUtilsService), and on-prem + // APIM calls. Per-operation timeout budgets (5s well-known, 30s manifest, etc.) are + // enforced via context.WithTimeout at each call site rather than a client-level Timeout. + httpClient *http.Client webhookSecretStore *webhooksecret.WebhookSecretStore webhookSecretSnapshotManager WebhookSecretSnapshotRefresher secretSyncer secretSyncer @@ -192,6 +200,7 @@ func NewClient( secretResolver funcs.SecretResolver, webhookSecretStore *webhooksecret.WebhookSecretStore, webhookSecretSnapshotManager WebhookSecretSnapshotRefresher, + httpClient *http.Client, ) *Client { if db == nil { panic("control plane client requires non-nil storage") @@ -209,7 +218,7 @@ func NewClient( ctx, cancel := context.WithCancel(context.Background()) - deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerConfig, eventHubInstance, gatewayID, secretResolver) + deploymentService := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerConfig, eventHubInstance, gatewayID, secretResolver, httpClient) apiKeyService := utils.NewAPIKeyService(store, db, apiKeyXDSManager, apiKeyConfig, eventHubInstance, gatewayID) subscriptionResourceService := utils.NewSubscriptionResourceService(db, subSnapshotManager, eventHubInstance, gatewayID) @@ -235,6 +244,7 @@ func NewClient( gatewayID: gatewayID, webhookSecretStore: webhookSecretStore, webhookSecretSnapshotManager: webhookSecretSnapshotManager, + httpClient: httpClient, state: &ConnectionState{ Current: Disconnected, Conn: nil, @@ -289,7 +299,7 @@ func NewClient( Token: cfg.Token, InsecureSkipVerify: cfg.InsecureSkipVerify, Timeout: 30 * time.Second, - }, logger) + }, httpClient, logger) // Set OAuth2 credentials for on-prem APIM (for API import operations) // Construct TokenURL from the controlplane host @@ -679,21 +689,15 @@ func (c *Client) isOnPrem() bool { func (c *Client) discoverGatewayPath() (string, error) { wellKnownURL := fmt.Sprintf("https://%s/internal/gateway/.well-known", c.config.Host) - httpClient := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: c.config.InsecureSkipVerify, - }, - }, - } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, wellKnownURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil) if err != nil { return "", fmt.Errorf("failed to create well-known request: %w", err) } - resp, err := httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to call well-known endpoint: %w", err) } @@ -4003,23 +4007,17 @@ func (c *Client) pushGatewayManifest(gatewayID string, policies []models.PolicyD return fmt.Errorf("failed to marshal manifest payload: %w", err) } - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: c.config.InsecureSkipVerify, - }, - }, - } + ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create manifest request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", c.config.Token) - resp, err := httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send gateway manifest: %w", err) } diff --git a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go index ca22826252..bf867357df 100644 --- a/gateway/gateway-controller/pkg/controlplane/client_integration_test.go +++ b/gateway/gateway-controller/pkg/controlplane/client_integration_test.go @@ -20,6 +20,7 @@ package controlplane import ( "encoding/json" + "fmt" "log/slog" "net" "net/http" @@ -33,12 +34,31 @@ import ( "github.com/wso2/api-platform/common/eventhub" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/httpkit/httpclient" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } +// testHTTPClient builds a plain outbound *http.Client for tests via httpkit's +// secure-by-default builder. Production code injects one single shared client built in +// cmd/controller/main.go, with TLS.InsecureSkipVerify mirroring +// Controller.ControlPlane.InsecureSkipVerify (see main.go's sharedHTTPClientCfg wiring); +// tests here build their own throwaway instance the same way, since several tests point +// the Client under test at an httptest.NewTLSServer with a self-signed certificate and +// need insecureSkipVerify=true to avoid failing certificate verification. +func testHTTPClient(insecureSkipVerify bool) *http.Client { + cfg := httpclient.DefaultConfig() + cfg.TLS.InsecureSkipVerify = insecureSkipVerify // #nosec G402 -- test-only, mirrors the config under test. + cfg.TLS.InsecureSkipVerifyAcknowledged = insecureSkipVerify // required double-gate; mirrors InsecureSkipVerify. + client, err := httpclient.New(cfg) + if err != nil { + panic(fmt.Sprintf("test HTTP client: unreachable construction error for a fixed default config: %v", err)) + } + return client +} + type integrationTestEventHub struct{} func (m *integrationTestEventHub) Initialize() error { return nil } @@ -144,7 +164,7 @@ func createIntegrationTestClientWithConfig(t *testing.T, cfg config.ControlPlane APIKey: *apiKeyConfig, } - client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) + client := NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil, testHTTPClient(cfg.InsecureSkipVerify)) require.NotNil(t, client.eventHub) require.Equal(t, "test-gateway", client.gatewayID) return client diff --git a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go index c3c9a1b72b..0b7542c5e4 100644 --- a/gateway/gateway-controller/pkg/controlplane/controlplane_test.go +++ b/gateway/gateway-controller/pkg/controlplane/controlplane_test.go @@ -222,7 +222,7 @@ func createTestClientWithConfig(t *testing.T, cfg config.ControlPlaneConfig) *Cl APIKey: *apiKeyConfig, } - return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil) + return NewClient(cfg, logger, store, db, nil, nil, routerConfig, nil, nil, apiKeyConfig, nil, systemConfig, nil, nil, nil, nil, mockHub, nil, nil, nil, testHTTPClient(cfg.InsecureSkipVerify)) } func createTestClientWithHost(t *testing.T, host string) *Client { diff --git a/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go b/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go index 177f87783a..24b0efd5a2 100644 --- a/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go +++ b/gateway/gateway-controller/pkg/controlplane/llm_deletion_test.go @@ -44,7 +44,7 @@ func createLLMDeletionTestClient() (*Client, *storage.ConfigStore, *mockStorageF hub := &mockControlPlaneEventHub{} routerConfig := &config.RouterConfig{ListenerPort: 8080} - apiDeploymentService := utils.NewAPIDeploymentService(store, db, nil, nil, routerConfig, hub, "test-gateway", nil) + apiDeploymentService := utils.NewAPIDeploymentService(store, db, nil, nil, routerConfig, hub, "test-gateway", nil, nil) llmService := utils.NewLLMDeploymentService(store, db, nil, nil, nil, apiDeploymentService, routerConfig, nil, nil) client := &Client{ diff --git a/gateway/gateway-controller/pkg/controlplane/sync.go b/gateway/gateway-controller/pkg/controlplane/sync.go index 32147706a7..3d2da9c1ea 100644 --- a/gateway/gateway-controller/pkg/controlplane/sync.go +++ b/gateway/gateway-controller/pkg/controlplane/sync.go @@ -751,7 +751,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { } for attempt := 1; attempt <= maxRetries; attempt++ { - lastErr = utils.UndeployRevisionFromAPIM(*apimConfig, apimAPIID, revisionID, c.logger) + lastErr = utils.UndeployRevisionFromAPIM(*apimConfig, c.httpClient, apimAPIID, revisionID, c.logger) if lastErr == nil { break } @@ -801,7 +801,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { // CPSyncInfo contains {"id": "", "revision": "..."} from the last successful sync. swaggerOverride := "" if apimAPIID, _ := parseCPSyncInfo(api.CPSyncInfo); apimAPIID != "" { - swagger, err := utils.FetchSwaggerFromAPIM(*apimConfig, apimAPIID, c.logger) + swagger, err := utils.FetchSwaggerFromAPIM(*apimConfig, c.httpClient, apimAPIID, c.logger) if err != nil { c.logger.Warn("Bottom-up sync: failed to fetch swagger from APIM, falling back to local generation", slog.String("uuid", api.UUID), @@ -838,7 +838,7 @@ func (c *Client) SyncArtifactsToOnPremAPIM(apimConfig *utils.APIMConfig) error { currentBuffer := bytes.NewBuffer(zipBytes) importResp, lastErr = utils.ImportAPIToAPIMWithConfig( - *apimConfig, c.logger, api.UUID+".zip", currentBuffer, + *apimConfig, c.httpClient, c.logger, api.UUID+".zip", currentBuffer, ) if lastErr == nil { break diff --git a/gateway/gateway-controller/pkg/policyxds/server.go b/gateway/gateway-controller/pkg/policyxds/server.go index 36e05842ec..f13c0ca00e 100644 --- a/gateway/gateway-controller/pkg/policyxds/server.go +++ b/gateway/gateway-controller/pkg/policyxds/server.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -30,6 +31,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/lazyresourcexds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/subscriptionxds" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" @@ -39,6 +41,15 @@ import ( "google.golang.org/grpc/keepalive" ) +// Bounds on the policy xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised policy-engine) exhaust +// memory or the stream-slot budget every other connection depends on. +const ( + policyXDSMaxMessageSize = 16 * 1024 * 1024 + policyXDSMaxConcurrentStreams = 1000 +) + // WebhookSecretCacheProvider is the extension point through which an external // event-gateway-controller binary supplies the xDS cache backing webhook-secret // (HMAC) resources. Core never implements this interface itself; it is only @@ -58,28 +69,34 @@ type Server struct { subscriptionSnapshotMgr *subscriptionxds.SnapshotManager webhookSecretSnapshotMgr WebhookSecretCacheProvider port int - tlsConfig *TLSConfig + mtls *serverMTLS onFirstConnect chan struct{} logger *slog.Logger } -// TLSConfig holds TLS configuration for the server -type TLSConfig struct { - Enabled bool - CertFile string - KeyFile string +// serverMTLS holds the resolved mutual-TLS state for the policy xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool } // ServerOption is a functional option for configuring the Server type ServerOption func(*Server) -// WithTLS enables TLS with the provided certificate and key files -func WithTLS(certFile, keyFile string) ServerOption { +// WithMTLS enables mutual TLS on the policy xDS gRPC server (serving the +// policy-engine). tlsConfig must come from config.BuildXDSServerTLSConfig, +// which already sets ClientAuth: tls.RequireAndVerifyClientCert -- +// server-only TLS is not offered here because this channel carries +// per-tenant API-key hashes, subscription state, and full policy chains, +// so authenticating only the server side is not enough +// (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { return func(s *Server) { - s.tlsConfig = &TLSConfig{ - Enabled: true, - CertFile: certFile, - KeyFile: keyFile, + s.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), } } } @@ -101,7 +118,6 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP webhookSecretSnapshotMgr: webhookSecretSnapshotMgr, port: port, logger: logger, - tlsConfig: &TLSConfig{Enabled: false}, } // Apply options @@ -119,19 +135,17 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP MinTime: 5 * time.Second, PermitWithoutStream: true, }), + grpc.MaxRecvMsgSize(policyXDSMaxMessageSize), + grpc.MaxSendMsgSize(policyXDSMaxMessageSize), + grpc.MaxConcurrentStreams(policyXDSMaxConcurrentStreams), } - // Add TLS credentials if enabled - if s.tlsConfig.Enabled { - creds, err := credentials.NewServerTLSFromFile(s.tlsConfig.CertFile, s.tlsConfig.KeyFile) - if err != nil { - logger.Error("Failed to load TLS credentials", slog.Any("error", err)) - panic(err) - } - grpcOpts = append(grpcOpts, grpc.Creds(creds)) - logger.Info("TLS enabled for Policy xDS server", - slog.String("cert_file", s.tlsConfig.CertFile), - slog.String("key_file", s.tlsConfig.KeyFile)) + // Add mTLS credentials if enabled + var allowedIdentities map[string]bool + if s.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(s.mtls.tlsConfig))) + allowedIdentities = s.mtls.allowedIdentities + logger.Info("mTLS enabled for Policy xDS server") } grpcServer := grpc.NewServer(grpcOpts...) @@ -150,10 +164,11 @@ func NewServer(snapshotManager *SnapshotManager, apiKeySnapshotMgr *apikeyxds.AP combinedCache := NewCombinedCache(policyCache, apiKeyCache, lazyResourceCache, subscriptionCache, routeConfigCache, eventChannelCache, webhookSecretCache, logger) callbacks := &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]bool), - onFirstConnect: s.onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]bool), + onFirstConnect: s.onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } xdsServer := server.NewServer(context.Background(), combinedCache, callbacks) @@ -174,8 +189,8 @@ func (s *Server) Start() error { } protocol := "insecure" - if s.tlsConfig.Enabled { - protocol = "TLS" + if s.mtls != nil { + protocol = "mTLS" } s.logger.Info("Starting Policy xDS server", slog.Int("port", s.port), @@ -196,16 +211,24 @@ func (s *Server) Stop() { // serverCallbacks implements xDS server callbacks for logging and debugging type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]bool - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]bool + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } // OnStreamOpen is called when a new stream is opened func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, streamID int64, typeURL string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("Policy xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", streamID), slog.Any("error", err)) + return err + } + } cb.logger.Info("Policy xDS stream opened", slog.Int64("stream_id", streamID), slog.String("type_url", typeURL)) diff --git a/gateway/gateway-controller/pkg/policyxds/server_test.go b/gateway/gateway-controller/pkg/policyxds/server_test.go index fc129805f4..ad6aad2dd9 100644 --- a/gateway/gateway-controller/pkg/policyxds/server_test.go +++ b/gateway/gateway-controller/pkg/policyxds/server_test.go @@ -20,6 +20,7 @@ package policyxds import ( "context" + "crypto/tls" "io" "log/slog" "testing" @@ -27,6 +28,7 @@ import ( core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" discoverygrpc "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/anypb" ) @@ -164,35 +166,28 @@ func TestServerCallbacks_OnStreamDeltaResponse(t *testing.T) { cb.OnStreamDeltaResponse(789, req, resp) } -func TestWithTLS(t *testing.T) { - t.Run("enables TLS configuration", func(t *testing.T) { +func TestWithMTLS(t *testing.T) { + t.Run("enables mTLS configuration", func(t *testing.T) { s := &Server{} - opt := WithTLS("/path/to/cert.pem", "/path/to/key.pem") + tlsConfig := &tls.Config{} + opt := WithMTLS(tlsConfig, []string{"spiffe://cluster.local/ns/gw/sa/policy-engine"}) opt(s) - assert.NotNil(t, s.tlsConfig) - assert.True(t, s.tlsConfig.Enabled) - assert.Equal(t, "/path/to/cert.pem", s.tlsConfig.CertFile) - assert.Equal(t, "/path/to/key.pem", s.tlsConfig.KeyFile) + require.NotNil(t, s.mtls) + assert.Same(t, tlsConfig, s.mtls.tlsConfig) + assert.True(t, s.mtls.allowedIdentities["spiffe://cluster.local/ns/gw/sa/policy-engine"]) }) } -func TestTLSConfig(t *testing.T) { - t.Run("default values", func(t *testing.T) { - config := &TLSConfig{} - assert.False(t, config.Enabled) - assert.Empty(t, config.CertFile) - assert.Empty(t, config.KeyFile) - }) +func TestServerCallbacks_OnStreamOpen_RejectsUnauthorizedPeer(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cb := &serverCallbacks{ + logger: logger, + allowedIdentities: map[string]bool{"spiffe://cluster.local/ns/gw/sa/policy-engine": true}, + } - t.Run("with values", func(t *testing.T) { - config := &TLSConfig{ - Enabled: true, - CertFile: "cert.pem", - KeyFile: "key.pem", - } - assert.True(t, config.Enabled) - assert.Equal(t, "cert.pem", config.CertFile) - assert.Equal(t, "key.pem", config.KeyFile) - }) + // No peer/TLS info in a bare background context -- must be rejected, + // not silently allowed through, once an identity allowlist is configured. + err := cb.OnStreamOpen(context.Background(), 999, "test-type-url") + assert.Error(t, err) } diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go new file mode 100644 index 0000000000..b5fcd18820 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity.go @@ -0,0 +1,78 @@ +/* + * 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 tlsauth provides the peer-identity authorization check shared by +// every xDS gRPC server in gateway-controller (pkg/xds, pkg/policyxds). +// Mutual TLS alone proves a connecting client's certificate chains to a +// trusted CA; it does not prove that client is entitled to this particular +// snapshot. go-control-plane-xds-security.md directive 2 requires an +// explicit accept/reject decision against a known-identity allowlist on +// every stream, in addition to the TLS handshake itself. +package tlsauth + +import ( + "context" + "crypto/x509" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// PeerIdentity returns the identity a verified client certificate presents: +// the first SAN URI (e.g. a SPIFFE ID) if present, otherwise the +// certificate's Subject CommonName. +func PeerIdentity(cert *x509.Certificate) string { + if len(cert.URIs) > 0 { + return cert.URIs[0].String() + } + return cert.Subject.CommonName +} + +// AllowedSet converts a config allowlist slice into the map VerifyStreamPeer +// expects. +func AllowedSet(identities []string) map[string]bool { + set := make(map[string]bool, len(identities)) + for _, id := range identities { + set[id] = true + } + return set +} + +// VerifyStreamPeer checks that a streaming RPC's authenticated context +// carries a client certificate whose identity (see PeerIdentity) is in +// allowed. Returns a gRPC status error suitable for returning directly from +// an xDS server.Callbacks.OnStreamOpen implementation; any client that +// clears the mTLS handshake but isn't in allowed is rejected here, not +// merely logged. +func VerifyStreamPeer(ctx context.Context, allowed map[string]bool) error { + p, ok := peer.FromContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "no peer information") + } + tlsInfo, isTLS := p.AuthInfo.(credentials.TLSInfo) + if !isTLS || len(tlsInfo.State.PeerCertificates) == 0 { + return status.Error(codes.Unauthenticated, "no client certificate presented") + } + identity := PeerIdentity(tlsInfo.State.PeerCertificates[0]) + if !allowed[identity] { + return status.Error(codes.PermissionDenied, "peer identity not authorized for this xDS snapshot") + } + return nil +} diff --git a/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go new file mode 100644 index 0000000000..9f37fd2014 --- /dev/null +++ b/gateway/gateway-controller/pkg/tlsauth/peer_identity_test.go @@ -0,0 +1,131 @@ +/* + * 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 tlsauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +func generateTestCert(t *testing.T, cn string, uris []*url.URL) *x509.Certificate { + 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: cn}, + URIs: uris, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func TestPeerIdentity(t *testing.T) { + t.Run("prefers the first SAN URI over CommonName", func(t *testing.T) { + spiffeID, err := url.Parse("spiffe://cluster.local/ns/gw/sa/envoy") + require.NoError(t, err) + cert := generateTestCert(t, "envoy-router", []*url.URL{spiffeID}) + assert.Equal(t, "spiffe://cluster.local/ns/gw/sa/envoy", PeerIdentity(cert)) + }) + + t.Run("falls back to CommonName when there is no SAN URI", func(t *testing.T) { + cert := generateTestCert(t, "policy-engine", nil) + assert.Equal(t, "policy-engine", PeerIdentity(cert)) + }) +} + +func TestAllowedSet(t *testing.T) { + set := AllowedSet([]string{"a", "b", "a"}) + assert.True(t, set["a"]) + assert.True(t, set["b"]) + assert.False(t, set["c"]) + assert.Len(t, set, 2) + + assert.Empty(t, AllowedSet(nil)) +} + +func TestVerifyStreamPeer(t *testing.T) { + t.Run("no peer info in context is unauthenticated", func(t *testing.T) { + err := VerifyStreamPeer(context.Background(), AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("peer info without TLS auth info is unauthenticated", func(t *testing.T) { + p := &peer.Peer{Addr: &net.TCPAddr{}} + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("TLS peer with no client certificate is unauthenticated", func(t *testing.T) { + p := &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{}, + } + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"x"})) + assert.Error(t, err) + }) + + t.Run("allowed identity passes", func(t *testing.T) { + cert := generateTestCert(t, "envoy-router", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.NoError(t, err) + }) + + t.Run("unlisted identity is rejected even with a valid client cert", func(t *testing.T) { + cert := generateTestCert(t, "unknown-caller", nil) + p := makeTLSPeer(cert) + ctx := peer.NewContext(context.Background(), p) + err := VerifyStreamPeer(ctx, AllowedSet([]string{"envoy-router"})) + assert.Error(t, err) + }) +} + +func makeTLSPeer(cert *x509.Certificate) *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{}, + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}, + }, + } +} diff --git a/gateway/gateway-controller/pkg/utils/api_deployment.go b/gateway/gateway-controller/pkg/utils/api_deployment.go index ce638ddf10..80fea72cc3 100644 --- a/gateway/gateway-controller/pkg/utils/api_deployment.go +++ b/gateway/gateway-controller/pkg/utils/api_deployment.go @@ -108,7 +108,10 @@ func (s *APIDeploymentService) validateArtifactConflicts(kind, currentID, displa return nil } -// NewAPIDeploymentService creates a new API deployment service +// NewAPIDeploymentService creates a new API deployment service. httpClient is the single +// shared outbound *http.Client for this process (built once in cmd/controller/main.go and +// injected by every caller), used for RegisterTopicWithHub/UnregisterTopicWithHub-style calls +// that need one, rather than this service building its own. func NewAPIDeploymentService( store *storage.ConfigStore, db storage.Storage, @@ -118,6 +121,7 @@ func NewAPIDeploymentService( eventHub eventhub.EventHub, gatewayID string, secretResolver funcs.SecretResolver, + httpClient *http.Client, ) *APIDeploymentService { if db == nil { panic("APIDeploymentService requires non-nil storage") @@ -130,7 +134,7 @@ func NewAPIDeploymentService( snapshotManager: snapshotManager, parser: config.NewParser(), validator: validator, - httpClient: &http.Client{Timeout: 10 * time.Second}, + httpClient: httpClient, routerConfig: routerConfig, eventHub: eventHub, gatewayID: trimmedGatewayID, diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 1ea2c1b9d0..698a9555b1 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -23,7 +23,7 @@ import ( "archive/zip" "bytes" "compress/gzip" - "crypto/tls" + "context" "encoding/json" "fmt" "io" @@ -73,8 +73,10 @@ type APIUtilsService struct { TokenURL string // Token endpoint URL } -// NewAPIUtilsService creates a new API utilities service -func NewAPIUtilsService(config PlatformAPIConfig, logger *slog.Logger) *APIUtilsService { +// NewAPIUtilsService creates a new API utilities service. httpClient is the single shared +// outbound *http.Client for this process (built once in cmd/controller/main.go and injected +// by every caller), reused here rather than each service building its own. +func NewAPIUtilsService(config PlatformAPIConfig, httpClient *http.Client, logger *slog.Logger) *APIUtilsService { // Set default timeout if not provided if config.Timeout == 0 { config.Timeout = 30 * time.Second @@ -86,27 +88,10 @@ func NewAPIUtilsService(config PlatformAPIConfig, logger *slog.Logger) *APIUtils logger.Warn("TLS certificate verification disabled for API utils (insecure_skip_verify=true)") } - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: config.InsecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - // Connection pool tuning - MaxIdleConns: 20, - MaxIdleConnsPerHost: 5, - MaxConnsPerHost: 10, - IdleConnTimeout: 30 * time.Second, - } - - client := &http.Client{ - Timeout: config.Timeout, - Transport: transport, - } - return &APIUtilsService{ config: config, logger: logger, - client: client, + client: httpClient, } } @@ -139,7 +124,9 @@ func (s *APIUtilsService) FetchAPIDefinition(apiID string) ([]byte, error) { ) // Create request - req, err := http.NewRequest("GET", apiURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -186,7 +173,9 @@ func (s *APIUtilsService) FetchLLMProviderDefinition(providerID string) ([]byte, ) // Create request - req, err := http.NewRequest("GET", providerURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", providerURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -233,7 +222,9 @@ func (s *APIUtilsService) FetchLLMProxyDefinition(proxyID string) ([]byte, error ) // Create request - req, err := http.NewRequest("GET", proxyURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", proxyURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -278,23 +269,16 @@ func (s *APIUtilsService) FetchSubscriptionsForAPI(apiID string) ([]models.Subsc slog.String("url", subURL), ) - client := &http.Client{ - Timeout: s.config.Timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: s.config.InsecureSkipVerify, - }, - }, - } - - req, err := http.NewRequest("GET", subURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", subURL, nil) if err != nil { return nil, fmt.Errorf("failed to create subscriptions request: %w", err) } req.Header.Add("api-key", s.config.Token) req.Header.Add("Accept", "application/json") - resp, err := client.Do(req) + resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch subscriptions: %w", err) } @@ -372,7 +356,9 @@ func (s *APIUtilsService) FetchAPIKeysByKind(artifactKind, issuer string) ([]mod slog.Bool("issuer_filtered", issuer != ""), ) - req, err := http.NewRequest("GET", endpoint, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create API keys request: %w", err) } @@ -450,23 +436,16 @@ func (s *APIUtilsService) FetchSubscriptionPlans() ([]models.SubscriptionPlan, e s.logger.Info("Fetching subscription plans", slog.String("url", planURL)) - client := &http.Client{ - Timeout: s.config.Timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: s.config.InsecureSkipVerify, - }, - }, - } - - req, err := http.NewRequest("GET", planURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", planURL, nil) if err != nil { return nil, fmt.Errorf("failed to create subscription plans request: %w", err) } req.Header.Add("api-key", s.config.Token) req.Header.Add("Accept", "application/json") - resp, err := client.Do(req) + resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch subscription plans: %w", err) } @@ -612,7 +591,9 @@ func (s *APIUtilsService) FetchMCPProxyDefinition(proxyID string) ([]byte, error ) // Create request - req, err := http.NewRequest("GET", proxyURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", proxyURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -663,7 +644,9 @@ func (s *APIUtilsService) FetchResourceZip(resourcePath, resourceLabel string) ( slog.String("url", url), ) - req, err := http.NewRequest("GET", url, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -703,7 +686,9 @@ func (s *APIUtilsService) FetchResourceZip(resourcePath, resourceLabel string) ( func (s *APIUtilsService) FetchResourceJSON(resourcePath, resourceLabel string, out any) error { url := s.getBaseURL() + resourcePath - req, err := http.NewRequest("GET", url, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } @@ -768,7 +753,9 @@ func (s *APIUtilsService) FetchControlPlaneDeployments(since *time.Time) ([]mode slog.String("url", deploymentsURL), ) - req, err := http.NewRequest("GET", deploymentsURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", deploymentsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -818,7 +805,9 @@ func (s *APIUtilsService) BatchFetchDeployments(deploymentIDs []string) ([]byte, return nil, fmt.Errorf("failed to marshal batch fetch request: %w", err) } - req, err := http.NewRequest("POST", batchURL, bytes.NewBuffer(jsonData)) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", batchURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1155,7 +1144,9 @@ func (s *APIUtilsService) PushArtifacts(artifacts []*models.StoredConfig) (*Impo } importURL := s.getBaseURL() + "/artifacts/import-gateway-artifacts" - req, err := http.NewRequest("POST", importURL, body) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1284,7 +1275,9 @@ func (s *APIUtilsService) CheckArtifactsExist(artifactIDs []string) ([]string, e return nil, fmt.Errorf("failed to marshal artifact existence request: %w", err) } - req, err := http.NewRequest("POST", existsURL, bytes.NewReader(jsonData)) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", existsURL, bytes.NewReader(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -1373,7 +1366,9 @@ func (s *APIUtilsService) FetchPlatformSecrets(updatedAfter *time.Time, includeV slog.Bool("includeValues", includeValues), ) - req, err := http.NewRequest(http.MethodGet, secretsURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create secrets request: %w", err) } @@ -1410,7 +1405,9 @@ func (s *APIUtilsService) FetchPlatformSecretValue(secretHandle string) (string, slog.String("url", valueURL), ) - req, err := http.NewRequest(http.MethodGet, valueURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, valueURL, nil) if err != nil { return "", fmt.Errorf("failed to create secret value request: %w", err) } diff --git a/gateway/gateway-controller/pkg/utils/api_utils_test.go b/gateway/gateway-controller/pkg/utils/api_utils_test.go index 0b6573b046..e7a949f09c 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils_test.go +++ b/gateway/gateway-controller/pkg/utils/api_utils_test.go @@ -66,7 +66,7 @@ func TestNewAPIUtilsService(t *testing.T) { BaseURL: "http://localhost:8080", Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) assert.NotNil(t, svc) assert.Equal(t, 30*time.Second, svc.config.Timeout) }) @@ -77,7 +77,7 @@ func TestNewAPIUtilsService(t *testing.T) { Token: "test-token", Timeout: 60 * time.Second, } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) assert.NotNil(t, svc) assert.Equal(t, 60*time.Second, svc.config.Timeout) }) @@ -101,7 +101,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { BaseURL: server.URL, Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("test-api-123") assert.NoError(t, err) @@ -119,7 +119,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { BaseURL: server.URL, Token: "test-token", } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("nonexistent") assert.Error(t, err) @@ -133,7 +133,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { Token: "test-token", Timeout: 1 * time.Second, } - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchAPIDefinition("0000-test-api-0000-000000000000") assert.Error(t, err) @@ -144,7 +144,7 @@ func TestAPIUtilsService_FetchAPIDefinition(t *testing.T) { func TestAPIUtilsService_ExtractYAMLFromZip(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) t.Run("Extract YAML file", func(t *testing.T) { // Create a zip with a YAML file @@ -190,7 +190,7 @@ func TestAPIUtilsService_ExtractYAMLFromZip(t *testing.T) { func TestAPIUtilsService_SaveAPIDefinition(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) // Create a temp directory for testing tmpDir, err := os.MkdirTemp("", "api-test-*") @@ -306,7 +306,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) cpArtifactID, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("RestApi"), "") assert.NoError(t, err) // The CP-minted artifact UUID from the per-dpid result is returned to the caller. @@ -329,7 +329,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("LlmProvider"), "") assert.NoError(t, err) }) @@ -352,7 +352,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { cfg := createTestStoredConfig(models.KindLlmProviderTemplate) cfg.DeployedAt = nil // templates never set DeployedAt - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") require.NoError(t, err) @@ -379,7 +379,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { deployedAt := time.Date(2026, 5, 6, 7, 8, 9, 123456789, time.UTC) cfg.DeployedAt = &deployedAt - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") require.NoError(t, err) @@ -395,7 +395,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { })) defer server.Close() - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact("0000-test-api-0000-000000000000", createTestStoredConfig("RestApi"), "") assert.Error(t, err) assert.Contains(t, err.Error(), "500") @@ -423,7 +423,7 @@ func TestAPIUtilsService_PushArtifact(t *testing.T) { }, } - svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, logger) + svc := NewAPIUtilsService(PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"}, testHTTPClient(), logger) _, err := svc.PushArtifact(cfg.UUID, cfg, "") assert.Error(t, err) assert.Contains(t, err.Error(), "project") @@ -583,7 +583,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.NoError(t, err) @@ -615,7 +615,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(&since) assert.NoError(t, err) @@ -630,7 +630,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.Error(t, err) @@ -640,7 +640,7 @@ func TestAPIUtilsService_FetchControlPlaneDeployments(t *testing.T) { t.Run("Connection error", func(t *testing.T) { cfg := PlatformAPIConfig{BaseURL: "http://localhost:99999", Token: "test-token", Timeout: 1 * time.Second} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.FetchControlPlaneDeployments(nil) assert.Error(t, err) @@ -678,7 +678,7 @@ func TestAPIUtilsService_BatchFetchDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.BatchFetchDeployments([]string{"dep-789", "dep-456"}) assert.NoError(t, err) @@ -693,7 +693,7 @@ func TestAPIUtilsService_BatchFetchDeployments(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) result, err := svc.BatchFetchDeployments([]string{"dep-789"}) assert.Error(t, err) @@ -733,7 +733,7 @@ func TestAPIUtilsService_FetchAPIKeysByKind_WebSubAPI(t *testing.T) { defer server.Close() cfg := PlatformAPIConfig{BaseURL: server.URL, Token: "test-token"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) keys, err := svc.FetchAPIKeysByKind(models.KindWebSubApi, "") require.NoError(t, err) @@ -747,7 +747,7 @@ func TestAPIUtilsService_FetchAPIKeysByKind_WebSubAPI(t *testing.T) { func TestAPIUtilsService_ExtractDeploymentsFromBatchZip(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := PlatformAPIConfig{BaseURL: "http://localhost"} - svc := NewAPIUtilsService(cfg, logger) + svc := NewAPIUtilsService(cfg, testHTTPClient(), logger) t.Run("Extract multiple deployments", func(t *testing.T) { yamlContent1 := []byte("apiVersion: v1\nkind: RestApi\nmetadata:\n name: api-1") diff --git a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go index d32c2c1a6a..86de5494aa 100644 --- a/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go +++ b/gateway/gateway-controller/pkg/utils/on_prem_apim_utils.go @@ -21,7 +21,7 @@ package utils import ( "archive/zip" "bytes" - "crypto/tls" + "context" "encoding/base64" "encoding/json" "fmt" @@ -40,14 +40,29 @@ import ( "gopkg.in/yaml.v3" ) +// defaultAPIMTimeout is the fallback per-call context budget used for on-prem APIM +// requests (token generation, import, undeploy, swagger fetch) when APIMConfig.Timeout is +// unset. Requests used to rely on a per-call client-level Timeout defaulting to this same +// value; now that a single shared *http.Client is reused, the budget is enforced via a +// context.WithTimeout deadline at each call site instead. +const defaultAPIMTimeout = 30 * time.Second + +// effectiveAPIMTimeout returns timeout if positive, otherwise defaultAPIMTimeout. +func effectiveAPIMTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return defaultAPIMTimeout + } + return timeout +} + // APIM publisher API path constants const ( - apimScheme = "https://" - apimPublisherBasePath = "/api/am/publisher/v4" - apimImportQueryParams = "?preserveProvider=false&overwrite=true&dryRun=false&rotateRevision=true" - apimImportPath = apimPublisherBasePath + "/apis/import" + apimImportQueryParams - apimUndeployPath = apimPublisherBasePath + "/apis/%s/undeploy-revision?revisionId=%s" - apimSwaggerPath = apimPublisherBasePath + "/apis/%s/swagger" + apimScheme = "https://" + apimPublisherBasePath = "/api/am/publisher/v4" + apimImportQueryParams = "?preserveProvider=false&overwrite=true&dryRun=false&rotateRevision=true" + apimImportPath = apimPublisherBasePath + "/apis/import" + apimImportQueryParams + apimUndeployPath = apimPublisherBasePath + "/apis/%s/undeploy-revision?revisionId=%s" + apimSwaggerPath = apimPublisherBasePath + "/apis/%s/swagger" ) // APIM zip entry path constants @@ -90,17 +105,17 @@ type APIMHubPolicy struct { // APIMOperation represents an operation in APIM format type APIMOperation struct { - Id string `json:"id" yaml:"id"` - Target string `json:"target" yaml:"target"` - Verb string `json:"verb" yaml:"verb"` - AuthType string `json:"authType" yaml:"authType"` - ThrottlingPolicy string `json:"throttlingPolicy" yaml:"throttlingPolicy"` - Scopes []interface{} `json:"scopes" yaml:"scopes"` - UsedProductIds []interface{} `json:"usedProductIds" yaml:"usedProductIds"` - PayloadSchema interface{} `json:"payloadSchema" yaml:"payloadSchema"` - UriMapping interface{} `json:"uriMapping" yaml:"uriMapping"` - OperationPolicies map[string]interface{} `json:"operationPolicies" yaml:"operationPolicies"` - OperationHubPolicies []APIMHubPolicy `json:"operationHubPolicies" yaml:"operationHubPolicies"` + Id string `json:"id" yaml:"id"` + Target string `json:"target" yaml:"target"` + Verb string `json:"verb" yaml:"verb"` + AuthType string `json:"authType" yaml:"authType"` + ThrottlingPolicy string `json:"throttlingPolicy" yaml:"throttlingPolicy"` + Scopes []interface{} `json:"scopes" yaml:"scopes"` + UsedProductIds []interface{} `json:"usedProductIds" yaml:"usedProductIds"` + PayloadSchema interface{} `json:"payloadSchema" yaml:"payloadSchema"` + UriMapping interface{} `json:"uriMapping" yaml:"uriMapping"` + OperationPolicies map[string]interface{} `json:"operationPolicies" yaml:"operationPolicies"` + OperationHubPolicies []APIMHubPolicy `json:"operationHubPolicies" yaml:"operationHubPolicies"` } // APIMCompleteStructure represents the complete APIM API structure for import @@ -128,32 +143,18 @@ type APIMConfig struct { // APIMTokenService manages authentication for on-prem APIM operations type APIMTokenService struct { config *APIMConfig + httpClient *http.Client cachedToken string tokenExpiry time.Time mu sync.Mutex } -// newAPIMPublisherHTTPClient creates an HTTP client with the given timeout and TLS settings. -// Defaults to 30 seconds if timeout is zero. -func newAPIMPublisherHTTPClient(timeout time.Duration, insecureSkipVerify bool) *http.Client { - if timeout == 0 { - timeout = 30 * time.Second - } - return &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ // #nosec G402 -- Explicit operator-controlled opt-out for dev/test environments. - InsecureSkipVerify: insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - }, - } -} - -// NewAPIMTokenService creates a new APIM token service -func NewAPIMTokenService(config APIMConfig) APIMTokenService { +// NewAPIMTokenService creates a new APIM token service. httpClient is the single shared +// outbound *http.Client for this process, injected by the caller rather than built here. +func NewAPIMTokenService(config APIMConfig, httpClient *http.Client) APIMTokenService { return APIMTokenService{ - config: &config, + config: &config, + httpClient: httpClient, } } @@ -233,7 +234,9 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { } // Create request - req, err := http.NewRequest("POST", s.config.TokenURL, strings.NewReader(body)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(s.config.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", s.config.TokenURL, strings.NewReader(body)) if err != nil { return "", 0, fmt.Errorf("failed to create token request: %w", err) } @@ -244,10 +247,8 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { req.Header.Set("Authorization", authHeader) } - client := newAPIMPublisherHTTPClient(s.config.Timeout, s.config.InsecureSkipVerify) - // Make request - resp, err := client.Do(req) + resp, err := s.httpClient.Do(req) if err != nil { return "", 0, fmt.Errorf("failed to send token request: %w", err) } @@ -293,8 +294,8 @@ func (s *APIMTokenService) generateOAuth2Token() (string, int, error) { // The zipFileBytes should contain the exported API definition as a zip file. // cpHost is the control plane host (e.g., "localhost:9443") // Returns ImportResponse with id and revision on success, error on failure (503 or other status codes). -func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZipName string, zipFileBytes *bytes.Buffer) (*OnPremAPIMImportResponse, error) { - tokenService := NewAPIMTokenService(apimConfig) +func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, httpClient *http.Client, logger *slog.Logger, apiZipName string, zipFileBytes *bytes.Buffer) (*OnPremAPIMImportResponse, error) { + tokenService := NewAPIMTokenService(apimConfig, httpClient) // Construct the import URL with standard query parameters importURL := apimScheme + apimConfig.Host + apimImportPath @@ -324,7 +325,9 @@ func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZi } // Create POST request - req, err := http.NewRequest("POST", importURL, body) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { return nil, fmt.Errorf("failed to create import request: %w", err) } @@ -341,7 +344,7 @@ func ImportAPIToAPIMWithConfig(apimConfig APIMConfig, logger *slog.Logger, apiZi req.Header.Set("Accept", "application/json") // Make the request - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send import request: %w", err) } @@ -450,7 +453,9 @@ func (s *APIUtilsService) generateOAuth2Token() (string, int, error) { } // Create request - req, err := http.NewRequest("POST", s.TokenURL, strings.NewReader(body)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(s.config.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", s.TokenURL, strings.NewReader(body)) if err != nil { return "", 0, fmt.Errorf("failed to create token request: %w", err) } @@ -542,7 +547,9 @@ func (s *APIUtilsService) ImportAPIToAPIM(apiZipName string, zipFileBytes *bytes } // Create POST request - req, err := http.NewRequest("POST", importURL, body) + ctx, cancel := context.WithTimeout(context.Background(), s.config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", importURL, body) if err != nil { return nil, fmt.Errorf("failed to create import request: %w", err) } @@ -618,8 +625,8 @@ type ZipFile struct { // UndeployRevisionFromAPIM undeploys a specific API revision from a gateway in on-prem APIM. // Calls POST /api/am/publisher/v4/apis/{apiId}/undeploy-revision?revisionId={revisionId} // with the gateway name as the deployment environment. -func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID string, logger *slog.Logger) error { - tokenService := NewAPIMTokenService(apimConfig) +func UndeployRevisionFromAPIM(apimConfig APIMConfig, httpClient *http.Client, apiID string, revisionID string, logger *slog.Logger) error { + tokenService := NewAPIMTokenService(apimConfig, httpClient) token, err := tokenService.getAccessToken() if err != nil { @@ -640,14 +647,16 @@ func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID st return fmt.Errorf("failed to marshal undeploy payload: %w", err) } - req, err := http.NewRequest("POST", undeployURL, bytes.NewReader(bodyBytes)) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", undeployURL, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("failed to create undeploy request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send undeploy request: %w", err) } @@ -663,8 +672,8 @@ func UndeployRevisionFromAPIM(apimConfig APIMConfig, apiID string, revisionID st // FetchSwaggerFromAPIM fetches the OpenAPI/Swagger definition of an existing API from APIM. // Used during bottom-up sync updates to retrieve the current swagger instead of generating it locally. -func FetchSwaggerFromAPIM(apimConfig APIMConfig, apiID string, logger *slog.Logger) (string, error) { - tokenService := NewAPIMTokenService(apimConfig) +func FetchSwaggerFromAPIM(apimConfig APIMConfig, httpClient *http.Client, apiID string, logger *slog.Logger) (string, error) { + tokenService := NewAPIMTokenService(apimConfig, httpClient) token, err := tokenService.getAccessToken() if err != nil { @@ -674,13 +683,15 @@ func FetchSwaggerFromAPIM(apimConfig APIMConfig, apiID string, logger *slog.Logg swaggerURL := fmt.Sprintf(apimScheme+"%s"+apimSwaggerPath, apimConfig.Host, apiID) logger.Info("Fetching swagger from APIM", slog.String("url", swaggerURL)) - req, err := http.NewRequest("GET", swaggerURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), effectiveAPIMTimeout(apimConfig.Timeout)) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", swaggerURL, nil) if err != nil { return "", fmt.Errorf("failed to create swagger request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) - resp, err := newAPIMPublisherHTTPClient(apimConfig.Timeout, apimConfig.InsecureSkipVerify).Do(req) + resp, err := httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send swagger request: %w", err) } @@ -1026,15 +1037,15 @@ func buildAPIMOperation(op management.Operation) map[string]interface{} { operationHubPolicies := convertOperationPolicies(op.Policies) return map[string]interface{}{ - "id": "", - "target": op.EffectivePath(), - "verb": strings.ToUpper(op.EffectiveMethod()), - "authType": "Application & Application User", - "throttlingPolicy": "Unlimited", - "scopes": []interface{}{}, - "usedProductIds": []interface{}{}, - "payloadSchema": nil, - "uriMapping": nil, + "id": "", + "target": op.EffectivePath(), + "verb": strings.ToUpper(op.EffectiveMethod()), + "authType": "Application & Application User", + "throttlingPolicy": "Unlimited", + "scopes": []interface{}{}, + "usedProductIds": []interface{}{}, + "payloadSchema": nil, + "uriMapping": nil, "operationPolicies": map[string]interface{}{ "request": []interface{}{}, "response": []interface{}{}, diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go index 3a4d933350..d39ab31397 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go @@ -33,7 +33,7 @@ func TestConstructorReplicaSyncWiring(t *testing.T) { apiKeyConfig := &config.APIKeyConfig{} t.Run("api deployment stores constructor wiring", func(t *testing.T) { - service := NewAPIDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-1 ", nil) + service := NewAPIDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-1 ", nil, nil) require.NotNil(t, service.eventHub) assert.Equal(t, "gateway-1", service.gatewayID) }) diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go index 19f4124368..d5e7b5fd1a 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go @@ -1,15 +1,31 @@ package utils import ( + "fmt" + "net/http" + "github.com/wso2/api-platform/common/eventhub" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" + "github.com/wso2/api-platform/httpkit/httpclient" ) const testGatewayID = "test-gateway" +// testHTTPClient builds a plain outbound *http.Client for tests via httpkit's +// secure-by-default builder. Production code injects one single shared client built in +// cmd/controller/main.go; tests build their own throwaway instance since there is no shared +// process-level client to inject. +func testHTTPClient() *http.Client { + client, err := httpclient.New(httpclient.DefaultConfig()) + if err != nil { + panic(fmt.Sprintf("test HTTP client: unreachable construction error for a fixed default config: %v", err)) + } + return client +} + func newReplicaSyncTestEventHub() eventhub.EventHub { return &mockLLMEventHub{} } @@ -57,6 +73,7 @@ func newTestAPIDeploymentServiceWithHub( hub, gatewayID, nil, + testHTTPClient(), ) } diff --git a/gateway/gateway-controller/pkg/xds/server.go b/gateway/gateway-controller/pkg/xds/server.go index 83f0a3704a..d25e959dd1 100644 --- a/gateway/gateway-controller/pkg/xds/server.go +++ b/gateway/gateway-controller/pkg/xds/server.go @@ -20,6 +20,7 @@ package xds import ( "context" + "crypto/tls" "fmt" "net" "sync" @@ -36,22 +37,71 @@ import ( secretservice "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3" "github.com/envoyproxy/go-control-plane/pkg/server/v3" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/metrics" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/tlsauth" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" ) +// Bounds on the main xDS gRPC server (go-network-service-hardening.md +// directive 2 / go-control-plane-xds-security.md directive 5) -- unbounded +// defaults let one client (a misbehaving/compromised Envoy) exhaust memory +// or the stream-slot budget every other connection depends on. xDS +// snapshots can carry many routes/clusters, so the message ceiling is set +// well above gRPC's 4MB default. +const ( + xdsMaxMessageSize = 16 * 1024 * 1024 + xdsMaxConcurrentStreams = 1000 +) + // Server is the xDS gRPC server type Server struct { grpcServer *grpc.Server xdsServer server.Server snapshotManager *SnapshotManager port int + mtls *serverMTLS logger *slog.Logger } +// serverMTLS holds the resolved mutual-TLS state for the main xDS server. +type serverMTLS struct { + tlsConfig *tls.Config + allowedIdentities map[string]bool +} + +// ServerOption is a functional option for configuring the xDS Server. +type ServerOption func(*serverOptions) + +type serverOptions struct { + mtls *serverMTLS +} + +// WithMTLS enables mutual TLS on the main xDS gRPC server (serving Envoy). +// tlsConfig must come from config.BuildXDSServerTLSConfig, which already +// sets ClientAuth: tls.RequireAndVerifyClientCert -- server-only TLS is not +// offered here because this server distributes SDS secrets and full +// route/cluster config, so authenticating only the server side is not +// enough (go-control-plane-xds-security.md directive 2). allowedIdentities +// restricts accepted streams to peers whose certificate identity +// (tlsauth.PeerIdentity) is in the list. +func WithMTLS(tlsConfig *tls.Config, allowedIdentities []string) ServerOption { + return func(o *serverOptions) { + o.mtls = &serverMTLS{ + tlsConfig: tlsConfig, + allowedIdentities: tlsauth.AllowedSet(allowedIdentities), + } + } +} + // NewServer creates a new xDS server -func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}) *Server { - grpcServer := grpc.NewServer( +func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretManager, port int, logger *slog.Logger, onFirstConnect chan struct{}, opts ...ServerOption) *Server { + var o serverOptions + for _, opt := range opts { + opt(&o) + } + + grpcOpts := []grpc.ServerOption{ grpc.KeepaliveParams(keepalive.ServerParameters{ Time: 30 * time.Second, Timeout: 5 * time.Second, @@ -60,11 +110,23 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana MinTime: 5 * time.Second, PermitWithoutStream: true, }), - ) + grpc.MaxRecvMsgSize(xdsMaxMessageSize), + grpc.MaxSendMsgSize(xdsMaxMessageSize), + grpc.MaxConcurrentStreams(xdsMaxConcurrentStreams), + } + + var allowedIdentities map[string]bool + if o.mtls != nil { + grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(o.mtls.tlsConfig))) + allowedIdentities = o.mtls.allowedIdentities + logger.Info("mTLS enabled for main xDS server") + } + + grpcServer := grpc.NewServer(grpcOpts...) // Create xDS server with the snapshot cache (shared with SDS) cache := snapshotManager.GetCache() - callbacks := NewServerCallbacks(logger, onFirstConnect) + callbacks := NewServerCallbacks(logger, onFirstConnect, allowedIdentities) xdsServer := server.NewServer(context.Background(), cache, callbacks) // Register xDS services @@ -85,6 +147,7 @@ func NewServer(snapshotManager *SnapshotManager, sdsSecretManager *SDSSecretMana xdsServer: xdsServer, snapshotManager: snapshotManager, port: port, + mtls: o.mtls, logger: logger, } } @@ -96,7 +159,11 @@ func (s *Server) Start() error { return fmt.Errorf("failed to listen on port %d: %w", s.port, err) } - s.logger.Info("Starting xDS server", slog.Int("port", s.port)) + protocol := "insecure" + if s.mtls != nil { + protocol = "mTLS" + } + s.logger.Info("Starting xDS server", slog.Int("port", s.port), slog.String("protocol", protocol)) if err := s.grpcServer.Serve(listener); err != nil { return fmt.Errorf("failed to serve: %w", err) @@ -113,24 +180,33 @@ func (s *Server) Stop() { // serverCallbacks implements server.Callbacks type serverCallbacks struct { - logger *slog.Logger - activeStreams map[int64]string // stream_id -> node_id - activeStreamsMu sync.Mutex - onFirstConnect chan struct{} - firstConnectOnce sync.Once - pendingNonces map[int64]string // stream_id -> last sent nonce + logger *slog.Logger + activeStreams map[int64]string // stream_id -> node_id + activeStreamsMu sync.Mutex + onFirstConnect chan struct{} + firstConnectOnce sync.Once + pendingNonces map[int64]string // stream_id -> last sent nonce + allowedIdentities map[string]bool // nil when mTLS is not configured -- no identity check performed } -func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}) *serverCallbacks { +func NewServerCallbacks(logger *slog.Logger, onFirstConnect chan struct{}, allowedIdentities map[string]bool) *serverCallbacks { return &serverCallbacks{ - logger: logger, - activeStreams: make(map[int64]string), - onFirstConnect: onFirstConnect, - pendingNonces: make(map[int64]string), + logger: logger, + activeStreams: make(map[int64]string), + onFirstConnect: onFirstConnect, + pendingNonces: make(map[int64]string), + allowedIdentities: allowedIdentities, } } func (cb *serverCallbacks) OnStreamOpen(ctx context.Context, id int64, typ string) error { + if cb.allowedIdentities != nil { + if err := tlsauth.VerifyStreamPeer(ctx, cb.allowedIdentities); err != nil { + cb.logger.Warn("xDS stream rejected: peer identity not authorized", + slog.Int64("stream_id", id), slog.Any("error", err)) + return err + } + } cb.logger.Info("xDS stream opened", slog.Int64("stream_id", id), slog.String("type", typ)) return nil } diff --git a/gateway/gateway-controller/pkg/xds/snapshot.go b/gateway/gateway-controller/pkg/xds/snapshot.go index bfd14c872d..4d58677707 100644 --- a/gateway/gateway-controller/pkg/xds/snapshot.go +++ b/gateway/gateway-controller/pkg/xds/snapshot.go @@ -137,8 +137,12 @@ func (sm *SnapshotManager) UpdateSnapshot(ctx context.Context, correlationID str return fmt.Errorf("failed to translate configurations: %w", err) } - // Add SDS secrets if SDS secret manager is configured - if sm.sdsSecretManager != nil { + // Add the SDS secret only when this snapshot's clusters actually reference it. + // Envoy never issues a watch for the Secret type URL unless a Cluster it accepted + // points at that secret name via SDS, so pushing it unconditionally just produces + // an "Ignoring unwatched type URL ... Secret" warning whenever no HTTPS-scheme + // upstream is configured. + if sm.sdsSecretManager != nil && ClusterResourcesReferenceUpstreamCASecret(resources[resource.ClusterType]) { secret, err := sm.sdsSecretManager.GetSecret() if err != nil { log.Warn("Failed to get SDS secret, continuing without it", slog.Any("error", err)) diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 0b4f745fd5..ca65e01235 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -930,13 +930,6 @@ func (t *Translator) TranslateConfigs( } } - // Add SDS cluster if cert store is enabled - // This cluster allows Envoy to fetch certificates from the SDS service - if t.certStore != nil { - sdsCluster := t.createSDSCluster() - clusters = append(clusters, sdsCluster) - } - // Add OTEL collector cluster if tracing is enabled // This cluster allows Envoy to send traces to OpenTelemetry collector if t.config.TracingConfig.Enabled { @@ -2227,63 +2220,6 @@ func (t *Translator) createOTELCollectorCluster() *cluster.Cluster { return c } -// createSDSCluster creates an Envoy cluster for the SDS (Secret Discovery Service) -// This cluster allows Envoy to fetch TLS certificates dynamically via xDS -func (t *Translator) createSDSCluster() *cluster.Cluster { - // SDS uses the same xDS server - // In containerized environments, Envoy connects to the gateway-controller container - // Use the same host/port configuration as the main xDS connection - xdsHost := "gateway-controller" // Default for Docker Compose - if envHost := os.Getenv("GATEWAY_CONTROLLER_HOST"); envHost != "" { - xdsHost = envHost - } - - xdsPort := t.config.Controller.Server.XDSPort - if xdsPort == 0 { - xdsPort = 18000 // Default xDS port - } - - address := &core.Address{ - Address: &core.Address_SocketAddress{ - SocketAddress: &core.SocketAddress{ - Protocol: core.SocketAddress_TCP, - Address: xdsHost, - PortSpecifier: &core.SocketAddress_PortValue{ - PortValue: uint32(xdsPort), - }, - }, - }, - } - - lbEndpoint := &endpoint.LbEndpoint{ - HostIdentifier: &endpoint.LbEndpoint_Endpoint{ - Endpoint: &endpoint.Endpoint{ - Address: address, - }, - }, - } - - localityLbEndpoints := &endpoint.LocalityLbEndpoints{ - LbEndpoints: []*endpoint.LbEndpoint{lbEndpoint}, - } - - // Create the SDS cluster - // Note: SDS must use HTTP/2 for gRPC communication - return &cluster.Cluster{ - Name: "sds_cluster", - ConnectTimeout: durationpb.New(5 * time.Second), - ClusterDiscoveryType: &cluster.Cluster_Type{Type: cluster.Cluster_STRICT_DNS}, - DnsLookupFamily: cluster.Cluster_V4_PREFERRED, - LbPolicy: cluster.Cluster_ROUND_ROBIN, - LoadAssignment: &endpoint.ClusterLoadAssignment{ - ClusterName: "sds_cluster", - Endpoints: []*endpoint.LocalityLbEndpoints{localityLbEndpoints}, - }, - // Enable HTTP/2 for gRPC - Http2ProtocolOptions: &core.Http2ProtocolOptions{}, - } -} - // createUpstreamTLSContext creates an upstream TLS context for secure connections func (t *Translator) createUpstreamTLSContext(certificate []byte, address string) *tlsv3.UpstreamTlsContext { // Create TLS context with base configuration @@ -2317,24 +2253,25 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string // 4. If none provided, Envoy falls back to system default trust store if t.certStore != nil { - // Use SDS to dynamically fetch certificates - // This is more efficient than inlining certificates in every cluster config + // Use SDS to dynamically fetch certificates, riding the same ADS + // stream Envoy already has open for LDS/CDS/RDS (bootstrap + // xds_cluster, see envoy-bootstrap.yaml's dynamic_resources). + // The SDS service is registered on the same gRPC server/cache as + // the main xDS server (see xds/server.go), so no dedicated + // cluster or connection is needed -- this also means + // gateway-controller never needs to know any TLS client cert/key + // paths that live on gateway-runtime's filesystem: the ADS + // connection's own TLS is entirely gateway-runtime's concern, + // configured in its own bootstrap (docker-entrypoint.sh + + // config-override.yaml's xds_cluster), independent of this + // process. A prior version of this pushed a second CDS cluster + // ("sds_cluster") that duplicated xds_cluster's host:port and + // required this process to embed gateway-runtime-local file + // paths -- removed in favor of this ADS-based reference. sdsConfig := &core.ConfigSource{ ResourceApiVersion: core.ApiVersion_V3, - ConfigSourceSpecifier: &core.ConfigSource_ApiConfigSource{ - ApiConfigSource: &core.ApiConfigSource{ - ApiType: core.ApiConfigSource_GRPC, - TransportApiVersion: core.ApiVersion_V3, - GrpcServices: []*core.GrpcService{ - { - TargetSpecifier: &core.GrpcService_EnvoyGrpc_{ - EnvoyGrpc: &core.GrpcService_EnvoyGrpc{ - ClusterName: "sds_cluster", - }, - }, - }, - }, - }, + ConfigSourceSpecifier: &core.ConfigSource_Ads{ + Ads: &core.AggregatedConfigSource{}, }, } @@ -2410,6 +2347,41 @@ func (t *Translator) createUpstreamTLSContext(certificate []byte, address string return upstreamTLSContext } +// ClusterResourcesReferenceUpstreamCASecret reports whether any cluster in +// clusters attaches the upstream CA bundle via SDS (ValidationContextSdsSecretConfig +// named SecretNameUpstreamCA). Envoy only issues a watch for the Secret type URL +// once a Cluster it has actually accepted references that secret name, so the +// snapshot manager uses this to decide whether including the Secret resource in +// a given snapshot version is warranted, rather than pushing it unconditionally +// and having Envoy log "Ignoring unwatched type URL ... Secret" when no +// HTTPS-scheme upstream is configured. +func ClusterResourcesReferenceUpstreamCASecret(clusters []types.Resource) bool { + for _, res := range clusters { + c, ok := res.(*cluster.Cluster) + if !ok { + continue + } + for _, tsm := range c.GetTransportSocketMatches() { + typedConfig := tsm.GetTransportSocket().GetTypedConfig() + if typedConfig == nil { + continue + } + var tlsCtx tlsv3.UpstreamTlsContext + if err := typedConfig.UnmarshalTo(&tlsCtx); err != nil { + continue + } + combined, ok := tlsCtx.GetCommonTlsContext().GetValidationContextType().(*tlsv3.CommonTlsContext_CombinedValidationContext) + if !ok { + continue + } + if combined.CombinedValidationContext.GetValidationContextSdsSecretConfig().GetName() == SecretNameUpstreamCA { + return true + } + } + } + return false +} + // createDownstreamTLSContext creates a downstream TLS context for HTTPS listeners func (t *Translator) createDownstreamTLSContext() (*tlsv3.DownstreamTlsContext, error) { // Read certificate and key files diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 283de7ac77..4fb9379475 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -43,6 +43,7 @@ import ( "github.com/stretchr/testify/require" commonconstants "github.com/wso2/api-platform/common/constants" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/certstore" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" @@ -2497,14 +2498,35 @@ func TestNotEffectivelyMatchesPrefix(t *testing.T) { } } -func TestTranslator_CreateSDSCluster(t *testing.T) { +// TestTranslator_CreateUpstreamTLSContext_SDSViaADS verifies that, when a +// cert store is configured, the upstream validation context's SDS reference +// rides the existing ADS stream (ConfigSource_Ads) rather than naming a +// dedicated cluster. This means gateway-controller never needs to construct +// a TLS transport socket pointing at cert/key/CA file paths that only exist +// on gateway-runtime's filesystem -- that connection's TLS is entirely +// gateway-runtime's own concern (its bootstrap xds_cluster). +func TestTranslator_CreateUpstreamTLSContext_SDSViaADS(t *testing.T) { logger := createTestLogger() routerCfg := testRouterConfig() + routerCfg.Upstream.TLS.DisableSslVerification = false cfg := testConfig() translator := NewTranslator(logger, routerCfg, nil, cfg) + // Only t.certStore != nil matters for this code path -- construct one + // directly rather than routing through NewTranslator's CustomCertsPath + // init, which calls LoadCertificates against a real db.Storage. + translator.certStore = certstore.NewCertStore(logger, nil, "", "") - cluster := translator.createSDSCluster() - assert.NotNil(t, cluster) + tlsContext := translator.createUpstreamTLSContext(nil, "example.com") + require.NotNil(t, tlsContext) + + combinedCtx := tlsContext.CommonTlsContext.GetCombinedValidationContext() + require.NotNil(t, combinedCtx) + sdsConfig := combinedCtx.GetValidationContextSdsSecretConfig().GetSdsConfig() + require.NotNil(t, sdsConfig) + + ads := sdsConfig.GetAds() + assert.NotNil(t, ads, "SDS config should ride the ADS stream rather than naming a dedicated cluster") + assert.Nil(t, sdsConfig.GetApiConfigSource(), "SDS config should not name a dedicated grpc cluster") } func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { diff --git a/gateway/gateway-controller/tests/integration/vhost_test.go b/gateway/gateway-controller/tests/integration/vhost_test.go index 2d5b74ab03..b0f5316095 100644 --- a/gateway/gateway-controller/tests/integration/vhost_test.go +++ b/gateway/gateway-controller/tests/integration/vhost_test.go @@ -83,7 +83,7 @@ spec: validator := config.NewAPIValidator() fullCfg := &config.Config{Router: *routerCfg} snapshotManager := xds.NewSnapshotManager(store, logger, routerCfg, db, fullCfg) - svc := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerCfg, integrationTestEventHub{}, "test-gateway", nil) + svc := utils.NewAPIDeploymentService(store, db, snapshotManager, validator, routerCfg, integrationTestEventHub{}, "test-gateway", nil, nil) return svc, db } diff --git a/gateway/gateway-runtime/docker-entrypoint.sh b/gateway/gateway-runtime/docker-entrypoint.sh index 5804c335dc..2a90c29d4c 100644 --- a/gateway/gateway-runtime/docker-entrypoint.sh +++ b/gateway/gateway-runtime/docker-entrypoint.sh @@ -146,6 +146,31 @@ export ROUTER_DRAIN_TIME_SECONDS="${ROUTER_DRAIN_TIME_SECONDS:-15}" export XDS_SERVER_HOST="${GATEWAY_CONTROLLER_HOST}" export XDS_SERVER_PORT="${ROUTER_XDS_PORT}" +# Mutual TLS for the Router's (Envoy's) connection to gateway-controller's main +# xDS server, off by default (plaintext) so this stays interoperable with a +# gateway-controller build/config where server.xds_tls.enabled=false. This +# cert/key/CA material is entirely local to this container -- gateway- +# controller never needs to know these paths. SDS/secret delivery rides the +# same ADS stream this bootstrap xds_cluster already opens (see +# pkg/xds/translator.go's createUpstreamTLSContext, which references the SDS +# config source via ConfigSource_Ads rather than a second, TLS-duplicating +# cluster), so there is no separate controller-side copy of this TLS +# material to keep in sync. +export XDS_TLS_ENABLED="${XDS_TLS_ENABLED:-false}" +export XDS_CLIENT_CERT_PATH="${XDS_CLIENT_CERT_PATH:-}" +export XDS_CLIENT_KEY_PATH="${XDS_CLIENT_KEY_PATH:-}" +export XDS_CLIENT_CA_PATH="${XDS_CLIENT_CA_PATH:-}" + +# TLS parameters for the same xDS connection, kept in their own vars so the +# PQC hybrid group can be opted into independently of turning TLS on at all. +# Classical curves only by default -- prepend "X25519MLKEM768" (FIPS 203 +# ML-KEM-768 + X25519) once the deployed Envoy/BoringSSL build is confirmed +# to support it. +export XDS_CLIENT_TLS_MIN_VERSION="${XDS_CLIENT_TLS_MIN_VERSION:-TLS1_2}" +export XDS_CLIENT_TLS_MAX_VERSION="${XDS_CLIENT_TLS_MAX_VERSION:-TLS1_3}" +export XDS_CLIENT_TLS_CIPHERS="${XDS_CLIENT_TLS_CIPHERS:-}" +export XDS_CLIENT_TLS_ECDH_CURVES="${XDS_CLIENT_TLS_ECDH_CURVES:-X25519,P-256}" + # Policy Engine xDS address PE_XDS_SERVER="${GATEWAY_CONTROLLER_HOST}:${POLICY_ENGINE_XDS_PORT}" @@ -178,6 +203,67 @@ log " Python Timeout: ${PYTHON_POLICY_TIMEOUT}s" rm -f "${POLICY_ENGINE_SOCKET}" rm -f "${PYTHON_EXECUTOR_SOCKET}" +# csv_to_json_array converts "a,b,c" into a JSON array ["a","b","c"], +# trimming whitespace around each element; empty/blank input yields []. +# Used to render XDS_CLIENT_TLS_CIPHERS/ECDH_CURVES (this repo's comma- +# separated convention) into Envoy's cipher_suites/ecdh_curves list fields. +csv_to_json_array() { + local input="$1" out="[" first=true part + IFS=',' read -ra parts <<< "$input" + for part in "${parts[@]}"; do + part="$(echo "${part}" | xargs)" + [ -z "$part" ] && continue + if [ "$first" = true ]; then first=false; else out+=", "; fi + out+="\"${part}\"" + done + out+="]" + echo "$out" +} + +# envoy_tls_version maps this repo's internal TLS version vocabulary +# ("TLS1_2", shared with gateway-controller's Go-side TLS config for +# consistency) to Envoy's own enum names ("TLSv1_2"). +envoy_tls_version() { + case "$1" in + TLS1_0) echo "TLSv1_0" ;; + TLS1_1) echo "TLSv1_1" ;; + TLS1_2) echo "TLSv1_2" ;; + TLS1_3) echo "TLSv1_3" ;; + *) + log "FATAL: unrecognized TLS version '$1' (expected one of TLS1_0, TLS1_1, TLS1_2, TLS1_3)" + exit 1 + ;; + esac +} + +# Build the xds_cluster transport_socket value config-override.yaml +# substitutes in. `null` (== field not present, once Envoy parses the YAML) +# keeps xds_cluster plaintext by default; a flow-style (JSON-like) +# UpstreamTlsContext mapping is used instead when XDS_TLS_ENABLED=true, kept +# on one line so config-override.yaml stays valid YAML both before and after +# envsubst. Cert/key/CA are referenced by filename, read directly from disk +# by Envoy -- never embedded inline in this config. tls_params carries the +# same PQC-capable ecdh_curves preference (X25519MLKEM768 opt-in) as +# gateway-controller's Go-side xDS TLS config -- see XDS_CLIENT_TLS_ECDH_CURVES +# above. cipher_suites is only emitted when XDS_CLIENT_TLS_CIPHERS is +# non-empty; omitted, Envoy/BoringSSL's own default suite set/order applies +# (and only affects TLS 1.2 and below -- TLS 1.3 suite selection is fixed). +if [ "${XDS_TLS_ENABLED}" = "true" ]; then + if [ -z "${XDS_CLIENT_CERT_PATH}" ] || [ -z "${XDS_CLIENT_KEY_PATH}" ] || [ -z "${XDS_CLIENT_CA_PATH}" ]; then + log "FATAL: XDS_TLS_ENABLED=true requires XDS_CLIENT_CERT_PATH, XDS_CLIENT_KEY_PATH, and XDS_CLIENT_CA_PATH to all be set" + exit 1 + fi + XDS_TLS_PARAMS="tls_params: {tls_minimum_protocol_version: $(envoy_tls_version "${XDS_CLIENT_TLS_MIN_VERSION}"), tls_maximum_protocol_version: $(envoy_tls_version "${XDS_CLIENT_TLS_MAX_VERSION}"), ecdh_curves: $(csv_to_json_array "${XDS_CLIENT_TLS_ECDH_CURVES}")" + if [ -n "${XDS_CLIENT_TLS_CIPHERS}" ]; then + XDS_TLS_PARAMS="${XDS_TLS_PARAMS}, cipher_suites: $(csv_to_json_array "${XDS_CLIENT_TLS_CIPHERS}")" + fi + XDS_TLS_PARAMS="${XDS_TLS_PARAMS}}" + + export XDS_CLUSTER_TRANSPORT_SOCKET="{name: envoy.transport_sockets.tls, typed_config: {\"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext, common_tls_context: {${XDS_TLS_PARAMS}, tls_certificates: [{certificate_chain: {filename: \"${XDS_CLIENT_CERT_PATH}\"}, private_key: {filename: \"${XDS_CLIENT_KEY_PATH}\"}}], validation_context: {trusted_ca: {filename: \"${XDS_CLIENT_CA_PATH}\"}}}}}" +else + export XDS_CLUSTER_TRANSPORT_SOCKET="null" +fi + # Generate Envoy config override by substituting environment variables CONFIG_OVERRIDE=$(envsubst < /etc/envoy/config-override.yaml) diff --git a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go index 976513ed97..3bfda44386 100644 --- a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go +++ b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go @@ -463,6 +463,8 @@ func initializeXDSClient(ctx context.Context, cfg *config.Config, serverAddr str TLSCertPath: cfg.PolicyEngine.XDS.TLS.CertPath, TLSKeyPath: cfg.PolicyEngine.XDS.TLS.KeyPath, TLSCAPath: cfg.PolicyEngine.XDS.TLS.CAPath, + TLSCiphers: cfg.PolicyEngine.XDS.TLS.Ciphers, + TLSEcdhCurves: cfg.PolicyEngine.XDS.TLS.EcdhCurves, } client, err := xdsclient.NewClient(xdsConfig, k, reg, resolvers) diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/server.go b/gateway/gateway-runtime/policy-engine/internal/admin/server.go index ef377f6359..88abf3fa6b 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 2ae838c629..21c5494e86 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 0000000000..a3d580e582 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/config/admin_tls.go @@ -0,0 +1,95 @@ +/* + * 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// 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. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +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 := tlsconfig.CurvesByName[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 +} + +// ValidateAdminTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateAdminTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + 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) { + return tlsconfig.ParseVersion(name) +} + +// 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) { + return tlsconfig.ParseCipherSuites(raw) +} diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config.go b/gateway/gateway-runtime/policy-engine/internal/config/config.go index 2b916e904e..9369c6e7fc 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config.go @@ -679,6 +679,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 @@ -739,6 +800,25 @@ type XDSTLSConfig struct { // CAPath is the path to the CA certificate for server verification CAPath string `koanf:"ca_path"` + + // 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 client offers. Empty by default -- 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. Parsed with the same + // ParseAdminCiphers helper the admin TLS listener uses (config.go), + // reused here rather than duplicated. + Ciphers string `koanf:"ciphers"` + + // EcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups + // this client offers, 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 once + // gateway-controller's policy_server.tls is confirmed to support it -- + // this is Go's own crypto/tls (1.23+ implements X25519MLKEM768 + // natively), so an unsupporting peer simply falls back to a later + // classical entry in this same list rather than failing the handshake. + EcdhCurves string `koanf:"ecdh_curves"` } // FileConfigConfig holds file-based configuration settings @@ -981,6 +1061,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, @@ -995,7 +1083,8 @@ func defaultConfig() *Config { InitialReconnectDelay: 1 * time.Second, MaxReconnectDelay: 60 * time.Second, TLS: XDSTLSConfig{ - Enabled: false, + Enabled: false, + EcdhCurves: "X25519,P-256", }, }, FileConfig: FileConfigConfig{ @@ -1150,6 +1239,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 @@ -1164,6 +1281,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 { @@ -1324,6 +1444,12 @@ func (c *Config) validateXDSConfig() error { if c.PolicyEngine.XDS.TLS.CAPath == "" { return fmt.Errorf("xds.tls.ca_path is required when TLS is enabled") } + if _, err := ParseAdminCiphers(c.PolicyEngine.XDS.TLS.Ciphers); err != nil { + return fmt.Errorf("xds.tls.ciphers: %w", err) + } + if _, err := ParseAdminEcdhCurves(c.PolicyEngine.XDS.TLS.EcdhCurves); err != nil { + return fmt.Errorf("xds.tls.ecdh_curves: %w", err) + } } return nil 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 49eccc433c..e790f7da9e 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" @@ -537,6 +538,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 { @@ -555,6 +782,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 { @@ -848,9 +1198,61 @@ func TestValidate_XDSTLSConfig(t *testing.T) { cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519,P-256" }, expectErr: false, }, + { + name: "TLS enabled - PQC hybrid group opt-in", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519MLKEM768,X25519,P-256" + }, + expectErr: false, + }, + { + name: "TLS enabled - unsupported ecdh curve", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "not-a-curve" + }, + expectErr: true, + errMsg: "xds.tls.ecdh_curves", + }, + { + name: "TLS enabled - unsupported cipher suite", + setup: func(cfg *Config) { + cfg.PolicyEngine.ConfigMode.Mode = "xds" + cfg.PolicyEngine.XDS.ConnectTimeout = 10 * time.Second + cfg.PolicyEngine.XDS.RequestTimeout = 5 * time.Second + cfg.PolicyEngine.XDS.InitialReconnectDelay = 1 * time.Second + cfg.PolicyEngine.XDS.MaxReconnectDelay = 60 * time.Second + cfg.PolicyEngine.XDS.TLS.Enabled = true + cfg.PolicyEngine.XDS.TLS.CertPath = "/path/to/cert" + cfg.PolicyEngine.XDS.TLS.KeyPath = "/path/to/key" + cfg.PolicyEngine.XDS.TLS.CAPath = "/path/to/ca" + cfg.PolicyEngine.XDS.TLS.EcdhCurves = "X25519,P-256" + cfg.PolicyEngine.XDS.TLS.Ciphers = "TLS_RSA_WITH_RC4_128_SHA" + }, + expectErr: true, + errMsg: "xds.tls.ciphers", + }, } for _, tt := range tests { diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go index 20a376ebe3..347aa491c7 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go @@ -38,6 +38,7 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/config" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" @@ -336,10 +337,28 @@ func (c *Client) loadTLSConfig() (*tls.Config, error) { return nil, fmt.Errorf("failed to parse CA certificate") } + // CipherSuites/CurvePreferences reuse the admin TLS listener's own + // parsers (policy-engine/internal/config) rather than duplicating the + // curve-name-to-tls.CurveID map here. CurvePreferences is what lets this + // client offer the FIPS 203 X25519MLKEM768 hybrid group ahead of + // classical curves when the operator opts in via xds.tls.ecdh_curves; + // gateway-controller's policy xDS server falls back to a later classical + // entry if it doesn't support the hybrid group. + cipherSuites, err := config.ParseAdminCiphers(c.config.TLSCiphers) + if err != nil { + return nil, fmt.Errorf("invalid xds.tls.ciphers: %w", err) + } + curves, err := config.ParseAdminEcdhCurves(c.config.TLSEcdhCurves) + if err != nil { + return nil, fmt.Errorf("invalid xds.tls.ecdh_curves: %w", err) + } + return &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + CipherSuites: cipherSuites, + CurvePreferences: curves, }, nil } diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go index b3cdee4984..3f49049a05 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go @@ -21,6 +21,7 @@ package xdsclient import ( "crypto/rand" "crypto/rsa" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -47,6 +48,7 @@ func createValidTestConfig() *Config { InitialReconnectDelay: 1 * time.Second, MaxReconnectDelay: 60 * time.Second, TLSEnabled: false, + TLSEcdhCurves: "X25519,P-256", } } @@ -330,6 +332,73 @@ func TestLoadTLSConfig_ValidCerts(t *testing.T) { assert.Equal(t, uint16(0x0303), tlsConfig.MinVersion) // TLS 1.2 } +// TestLoadTLSConfig_PQCHybridCurveOptIn verifies that opting into the FIPS +// 203 X25519MLKEM768 hybrid group via TLSEcdhCurves is actually reflected in +// the tls.Config this client dials with -- not just accepted by config +// validation. +func TestLoadTLSConfig_PQCHybridCurveOptIn(t *testing.T) { + tmpDir := t.TempDir() + ca, caPrivKey := generateTestCA(t) + cert, certPrivKey := generateTestCert(t, ca, caPrivKey) + + certPath := filepath.Join(tmpDir, "cert.pem") + keyPath := filepath.Join(tmpDir, "key.pem") + caPath := filepath.Join(tmpDir, "ca.pem") + writeCertToFile(t, cert, certPath) + writeKeyToFile(t, certPrivKey, keyPath) + writeCertToFile(t, ca, caPath) + + k, reg := createTestKernelAndRegistry(t) + config := createValidTestConfig() + config.TLSEnabled = true + config.TLSCertPath = certPath + config.TLSKeyPath = keyPath + config.TLSCAPath = caPath + config.TLSEcdhCurves = "X25519MLKEM768,X25519,P-256" + + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) + require.NoError(t, err) + + tlsConfig, err := client.loadTLSConfig() + require.NoError(t, err) + require.NotNil(t, tlsConfig) + + require.NotEmpty(t, tlsConfig.CurvePreferences) + assert.Equal(t, tls.X25519MLKEM768, tlsConfig.CurvePreferences[0]) +} + +// TestLoadTLSConfig_InvalidEcdhCurve verifies an unrecognized curve name +// surfaces as an error from loadTLSConfig rather than silently falling back +// to Go's default curve preferences. +func TestLoadTLSConfig_InvalidEcdhCurve(t *testing.T) { + tmpDir := t.TempDir() + ca, caPrivKey := generateTestCA(t) + cert, certPrivKey := generateTestCert(t, ca, caPrivKey) + + certPath := filepath.Join(tmpDir, "cert.pem") + keyPath := filepath.Join(tmpDir, "key.pem") + caPath := filepath.Join(tmpDir, "ca.pem") + writeCertToFile(t, cert, certPath) + writeKeyToFile(t, certPrivKey, keyPath) + writeCertToFile(t, ca, caPath) + + k, reg := createTestKernelAndRegistry(t) + config := createValidTestConfig() + config.TLSEnabled = true + config.TLSCertPath = certPath + config.TLSKeyPath = keyPath + config.TLSCAPath = caPath + config.TLSEcdhCurves = "not-a-curve" + + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) + require.NoError(t, err) + + tlsConfig, err := client.loadTLSConfig() + assert.Error(t, err) + assert.Nil(t, tlsConfig) + assert.Contains(t, err.Error(), "ecdh_curves") +} + // TestLoadTLSConfig_InvalidCertPath tests error when cert file doesn't exist func TestLoadTLSConfig_InvalidCertPath(t *testing.T) { tmpDir := t.TempDir() diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go index a96aadeca0..784cd6436f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config.go @@ -51,6 +51,16 @@ type Config struct { // TLSCAPath is the path to the CA certificate for server verification (if TLSEnabled) TLSCAPath string + + // TLSCiphers is a comma-separated list of Go crypto/tls cipher suite + // names restricting which suites this client offers. Empty means Go's + // own secure default set/order applies. Only affects TLS 1.2 and below. + TLSCiphers string + + // TLSEcdhCurves is a comma-separated list of TLS 1.3 key-exchange groups + // this client offers, most preferred first (e.g. "X25519MLKEM768,X25519,P-256"). + // Required whenever TLSEnabled -- see config.ParseAdminEcdhCurves. + TLSEcdhCurves string } // Validate validates the xDS client configuration @@ -85,6 +95,9 @@ func (c *Config) Validate() error { if c.TLSCAPath == "" { return fmt.Errorf("TLS CA path is required when TLS is enabled") } + if c.TLSEcdhCurves == "" { + return fmt.Errorf("TLS ECDH curves are required when TLS is enabled") + } } return nil diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go index 1e77949b3b..8177249ac9 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/config_test.go @@ -261,6 +261,7 @@ func TestValidate_TLSEnabledWithAllPaths(t *testing.T) { TLSCertPath: "/path/to/cert.pem", TLSKeyPath: "/path/to/key.pem", TLSCAPath: "/path/to/ca.pem", + TLSEcdhCurves: "X25519,P-256", } err := config.Validate() diff --git a/gateway/gateway-runtime/router/config/config-override.yaml b/gateway/gateway-runtime/router/config/config-override.yaml index 88ed0e3653..bf24318273 100644 --- a/gateway/gateway-runtime/router/config/config-override.yaml +++ b/gateway/gateway-runtime/router/config/config-override.yaml @@ -39,6 +39,14 @@ static_resources: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http2_protocol_options: {} + # docker-entrypoint.sh sets this to a flow-style (JSON-like) + # UpstreamTlsContext mapping when XDS_TLS_ENABLED=true, or to `null` + # (== field not present, once parsed) otherwise -- xds_cluster stays + # plaintext by default, matching gateway-controller's server.xds_tls + # being off by default too. A flow-style single-line value keeps this + # file valid YAML both before and after envsubst, unlike a multi-line + # block substitution would. + transport_socket: ${XDS_CLUSTER_TRANSPORT_SOCKET} load_assignment: cluster_name: xds_cluster endpoints: diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index d3655dff52..91ea7f0534 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -302,6 +302,27 @@ port = 9243 cert_file = "/app/data/certs/cert.pem" # default: ./data/certs/cert.pem key_file = "/app/data/certs/key.pem" # default: ./data/certs/key.pem +# minimum_protocol_version / maximum_protocol_version bound the negotiated TLS +# version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" + +# 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 means 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. +ciphers = "" + +# ecdh_curves is a comma-separated list of TLS 1.3 key-exchange groups, most +# preferred first. Classical curves only by default. Prepend the hybrid +# post-quantum group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519) as an +# explicit opt-in once clients reaching this listener are confirmed to +# support it, e.g. "X25519MLKEM768,X25519,P-256" — a client that doesn't +# offer the hybrid group simply falls back to a later classical entry in this +# same list, so enabling it never breaks a legacy peer. +ecdh_curves = "X25519,P-256" + # --------------------------------------------------------------------------- # Listener timeouts # --------------------------------------------------------------------------- diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 3d76e5dc8d..5b24b8d58d 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -397,6 +397,28 @@ type HTTPSListener struct { Port int `koanf:"port"` CertFile string `koanf:"cert_file"` KeyFile string `koanf:"key_file"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + 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. + 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 — 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, so enabling it + // never breaks a legacy peer. See post-quantum-cryptography.md. + EcdhCurves string `koanf:"ecdh_curves"` } // Timeouts bounds the lifetime of a connection on both listeners, so a slow or @@ -1019,6 +1041,17 @@ func validateListenersConfig(l *ServerListeners) error { if l.HTTP.Enabled && l.HTTPS.Enabled && l.HTTP.Port == l.HTTPS.Port { return fmt.Errorf("server.http.port and server.https.port must differ when both listeners are enabled (both are %d)", l.HTTP.Port) } + if l.HTTPS.Enabled { + if err := ValidateHTTPSTLSVersions(l.HTTPS.MinimumProtocolVersion, l.HTTPS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("server.https: %w", err) + } + if _, err := ParseHTTPSCiphers(l.HTTPS.Ciphers); err != nil { + return fmt.Errorf("server.https.ciphers: %w", err) + } + if _, err := ParseHTTPSEcdhCurves(l.HTTPS.EcdhCurves); err != nil { + return fmt.Errorf("server.https.ecdh_curves: %w", err) + } + } return nil } diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index c1cf0ab3ec..7cf112fc9f 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -123,10 +123,14 @@ func defaultConfig() *Server { Port: 9080, }, HTTPS: HTTPSListener{ - Enabled: true, - Port: 9243, - CertFile: "./data/certs/cert.pem", - KeyFile: "./data/certs/key.pem", + Enabled: true, + Port: 9243, + CertFile: "./data/certs/cert.pem", + KeyFile: "./data/certs/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, // Finite by default so a slow or idle peer cannot hold a connection open // indefinitely. Write is the loosest of the four because some handlers diff --git a/platform-api/config/server_tls.go b/platform-api/config/server_tls.go new file mode 100644 index 0000000000..c035e7dc89 --- /dev/null +++ b/platform-api/config/server_tls.go @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// ParseHTTPSEcdhCurves 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 HTTPS listener's TLS config. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation, also used by gateway-controller +// and policy-engine's server TLS listeners); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +func ParseHTTPSEcdhCurves(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 := tlsconfig.CurvesByName[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 +} + +// ValidateHTTPSTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateHTTPSTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseHTTPSTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateHTTPSTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseHTTPSTLSVersion(name string) (version uint16, ok bool) { + return tlsconfig.ParseVersion(name) +} + +// ParseHTTPSCiphers 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 ParseHTTPSCiphers(raw string) ([]uint16, error) { + return tlsconfig.ParseCipherSuites(raw) +} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index bfc443a1ac..cbe805e6a8 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -817,9 +817,31 @@ func (s *Server) buildTLSConfig(httpsCfg config.HTTPSListener) (*tls.Config, err } s.logger.Info("Using mounted certificates", "certFile", certFile, "keyFile", keyFile) + // Config.Validate (validateListenersConfig) already rejects a bad + // version/cipher/curve value before this ever runs in production, so an + // error here can only come from a caller that bypassed validation. + if err := config.ValidateHTTPSTLSVersions(httpsCfg.MinimumProtocolVersion, httpsCfg.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseHTTPSTLSVersion(httpsCfg.MinimumProtocolVersion) + maxVersion, _ := config.ParseHTTPSTLSVersion(httpsCfg.MaximumProtocolVersion) + + cipherSuites, err := config.ParseHTTPSCiphers(httpsCfg.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseHTTPSEcdhCurves(httpsCfg.EcdhCurves) + if err != nil { + return nil, err + } + return &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, }, nil } diff --git a/platform-api/internal/server/server_tls_test.go b/platform-api/internal/server/server_tls_test.go index 82b9492a2a..3dd5f48f20 100644 --- a/platform-api/internal/server/server_tls_test.go +++ b/platform-api/internal/server/server_tls_test.go @@ -20,6 +20,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -66,10 +67,13 @@ func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { writeTestCertPair(t, certDir) tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ - Enabled: true, - Port: 9243, - CertFile: filepath.Join(certDir, "cert.pem"), - KeyFile: filepath.Join(certDir, "key.pem"), + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519,P-256", }) if err != nil { t.Fatalf("expected mounted certificates to load, got %v", err) @@ -77,6 +81,68 @@ func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { if tlsConfig == nil || len(tlsConfig.Certificates) != 1 { t.Fatal("expected exactly one loaded certificate") } + if tlsConfig.MinVersion != tls.VersionTLS12 || tlsConfig.MaxVersion != tls.VersionTLS13 { + t.Fatalf("expected TLS1_2-TLS1_3 bounds, got min=%x max=%x", tlsConfig.MinVersion, tlsConfig.MaxVersion) + } + wantCurves := []tls.CurveID{tls.X25519, tls.CurveP256} + if len(tlsConfig.CurvePreferences) != len(wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + for i, c := range wantCurves { + if tlsConfig.CurvePreferences[i] != c { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + } +} + +// HTTPS listener with the hybrid post-quantum curve opted in: X25519MLKEM768 +// is accepted and placed first, with classical curves retained after it so a +// peer that doesn't support the hybrid group still negotiates successfully. +func TestBuildTLSConfig_PQCHybridCurveOptIn_Loads(t *testing.T) { + certDir := t.TempDir() + writeTestCertPair(t, certDir) + + tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "X25519MLKEM768,X25519,P-256", + }) + if err != nil { + t.Fatalf("expected PQC hybrid opt-in to build successfully, got %v", err) + } + wantCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256} + if len(tlsConfig.CurvePreferences) != len(wantCurves) { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + for i, c := range wantCurves { + if tlsConfig.CurvePreferences[i] != c { + t.Fatalf("expected curve preferences %v, got %v", wantCurves, tlsConfig.CurvePreferences) + } + } +} + +// HTTPS listener with an invalid ecdh_curves value: rejected rather than +// silently falling back to Go's default curve list. +func TestBuildTLSConfig_InvalidEcdhCurve_Errors(t *testing.T) { + certDir := t.TempDir() + writeTestCertPair(t, certDir) + + _, err := testServer().buildTLSConfig(config.HTTPSListener{ + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + EcdhCurves: "not-a-curve", + }) + if err == nil { + t.Fatal("expected an error for an unrecognized ecdh curve name") + } } // writeTestCertPair writes a throwaway self-signed cert.pem / key.pem into dir. diff --git a/portals/ai-workspace/bff/go.mod b/portals/ai-workspace/bff/go.mod index 2de89bb619..402812f909 100644 --- a/portals/ai-workspace/bff/go.mod +++ b/portals/ai-workspace/bff/go.mod @@ -9,6 +9,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.2 github.com/wso2/api-platform/common v0.0.0 + github.com/wso2/api-platform/httpkit v0.0.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 98f69d89cd..6d57f5ee6e 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -53,6 +53,7 @@ type Config struct { Server ServerConfig `koanf:"server"` Logging LoggingConfig `koanf:"logging"` ControlPlane ControlPlaneConfig `koanf:"control_plane"` + HTTPClient HTTPClientConfig `koanf:"http_client"` Session SessionConfig `koanf:"session"` Auth AuthConfig `koanf:"auth"` @@ -90,6 +91,28 @@ type HTTPSListener struct { Port int `koanf:"port"` CertFile string `koanf:"cert_file"` KeyFile string `koanf:"key_file"` + + // MinimumProtocolVersion and MaximumProtocolVersion bound the negotiated + // TLS version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". + 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. + 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 — 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, so enabling it + // never breaks a legacy peer. See post-quantum-cryptography.md. + EcdhCurves string `koanf:"ecdh_curves"` } // LoggingConfig is [ai_workspace.logging]. Level/Format are this process's own logs; @@ -350,6 +373,17 @@ func (c *Config) validate() error { if c.Server.HTTP.Enabled && c.Server.HTTPS.Enabled && c.Server.HTTP.Port == c.Server.HTTPS.Port { return fmt.Errorf("[server.http] port and [server.https] port must differ, both are %d", c.Server.HTTP.Port) } + if c.Server.HTTPS.Enabled { + if err := ValidateHTTPSTLSVersions(c.Server.HTTPS.MinimumProtocolVersion, c.Server.HTTPS.MaximumProtocolVersion); err != nil { + return fmt.Errorf("[server.https]: %w", err) + } + if _, err := ParseHTTPSCiphers(c.Server.HTTPS.Ciphers); err != nil { + return fmt.Errorf("[server.https] ciphers: %w", err) + } + if _, err := ParseHTTPSEcdhCurves(c.Server.HTTPS.EcdhCurves); err != nil { + return fmt.Errorf("[server.https] ecdh_curves: %w", err) + } + } // Every session duration is a lifetime, where <= 0 is never meaningful. if c.Session.IdleTimeout <= 0 { return fmt.Errorf("[session] idle_timeout must be positive, got %s", c.Session.IdleTimeout) diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go index bb57d9fb4f..4b116e90af 100644 --- a/portals/ai-workspace/bff/internal/config/default_config.go +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -35,14 +35,48 @@ func defaultConfig() *Config { Port: 9643, // Convention matches the container's mount path. A certificate pair is // required there whenever the listener terminates TLS. - CertFile: "/etc/ai-workspace/tls/cert.pem", - KeyFile: "/etc/ai-workspace/tls/key.pem", + CertFile: "/etc/ai-workspace/tls/cert.pem", + KeyFile: "/etc/ai-workspace/tls/key.pem", + MinimumProtocolVersion: "TLS1_2", + MaximumProtocolVersion: "TLS1_3", + Ciphers: "", + EcdhCurves: "X25519,P-256", }, }, Logging: LoggingConfig{ Level: "info", Format: "text", }, + // HTTPClient defaults reproduce exactly what proxy.NewTransport hardcoded + // before this became configurable, so an existing deployment that omits + // [ai_workspace.http_client] entirely sees zero behavior change. Everything + // else (Pooling.MaxIdleConns, Timeouts.Dial/TLSHandshake/ResponseHeader/ + // ExpectContinue) already matches httpclient.DefaultConfig()'s own values, so + // only the previously-hardcoded overrides are set explicitly below. + HTTPClient: HTTPClientConfig{ + Pooling: HTTPClientPoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + MaxConnsPerHost: 0, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + EnableHTTP2: true, + }, + Timeouts: HTTPClientTimeoutsConfig{ + Overall: 30 * time.Second, + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + MaxResponseBytes: -1, + }, + // TLS.MinVersion/MaxVersion/CipherSuites/CurvePreferences stay empty: + // Go's own crypto/tls default (TLS 1.2 floor, no configured ceiling) + // applies, matching today's behavior. + Proxy: HTTPClientProxyConfig{ + Mode: "environment", // matches the previous hardcoded http.ProxyFromEnvironment + }, + }, Session: SessionConfig{ Store: "memory", IdleTimeout: 30 * time.Minute, diff --git a/portals/ai-workspace/bff/internal/config/http_client.go b/portals/ai-workspace/bff/internal/config/http_client.go new file mode 100644 index 0000000000..fc36bbc82a --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/http_client.go @@ -0,0 +1,129 @@ +/* + * 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 "time" + +// HTTPClientConfig is [ai_workspace.http_client]: configures the single outbound +// *http.Transport this BFF uses for every call to its upstream Platform API (see +// proxy.NewTransport, and server.New's one call site). It mirrors +// github.com/wso2/api-platform/httpkit/httpclient.Config field-for-field (see that package's own +// doc comments for full semantics) — the same shape gateway-controller's and +// platform-api's own HTTPClientConfig use (gateway/gateway-controller/pkg/config/config.go, +// platform-api/config/config.go) — so every knob the library exposes that has a natural +// TOML shape is operator-configurable here rather than hardcoded in transport.go. +// +// SSRF is deliberately not represented here (unlike platform-api's HTTPClientConfig): +// this transport only ever talks to the fixed, operator-configured Platform API +// (ControlPlaneConfig.URL) — never a tenant/end-user-supplied destination — so there is +// nothing for httpclient's SSRF guard to protect against. See transport.go's own doc +// comment. +// +// TLS.RootCAFile/ClientCertFile/ClientKeyFile/InsecureSkipVerify are also deliberately +// not fields here: this component already has an existing setting for that same trust +// decision — ControlPlaneConfig.CAFile / ControlPlaneConfig.TLSSkipVerify, wired through +// proxy.TLSClientOptions — and duplicating it here would create two settings that must +// always be kept in sync. +type HTTPClientConfig struct { + Pooling HTTPClientPoolingConfig `koanf:"pooling"` + Timeouts HTTPClientTimeoutsConfig `koanf:"timeouts"` + TLS HTTPClientTLSConfig `koanf:"tls"` + Proxy HTTPClientProxyConfig `koanf:"proxy"` +} + +// HTTPClientPoolingConfig mirrors httpclient.PoolingConfig. +type HTTPClientPoolingConfig struct { + MaxIdleConns int `koanf:"max_idle_conns"` + MaxIdleConnsPerHost int `koanf:"max_idle_conns_per_host"` + MaxConnsPerHost int `koanf:"max_conns_per_host"` + IdleConnTimeout time.Duration `koanf:"idle_conn_timeout"` + KeepAlive time.Duration `koanf:"keep_alive"` + DisableKeepAlives bool `koanf:"disable_keep_alives"` + // EnableHTTP2 opts into HTTP/2. See httpclient.PoolingConfig.EnableHTTP2's doc + // comment on the HTTP/2 connection-coalescing caveat before disabling — true by + // default here, matching this transport's previous hardcoded + // ForceAttemptHTTP2: true behavior. + EnableHTTP2 bool `koanf:"enable_http2"` +} + +// HTTPClientTimeoutsConfig mirrors httpclient.TimeoutsConfig. +// +// Overall has no observable effect at this call site today: NewTransport extracts only +// the *http.Transport out of httpclient.New's *http.Client (see transport.go), and +// server.New applies its own, separate 60s http.Client.Timeout on top of it. Kept here +// anyway, defaulted to httpclient.DefaultConfig()'s own value, for shape parity with +// gateway-controller/platform-api and in case a future caller reads the full +// *http.Client instead. +type HTTPClientTimeoutsConfig struct { + Overall time.Duration `koanf:"overall"` + Dial time.Duration `koanf:"dial"` + TLSHandshake time.Duration `koanf:"tls_handshake"` + ResponseHeader time.Duration `koanf:"response_header"` + ExpectContinue time.Duration `koanf:"expect_continue"` + // MaxResponseBytes bounds the response body read through this transport. Defaults + // to -1 (disabled): this transport backs a reverse proxy streaming SSE/long-running + // LLM output between the BFF and its own fixed, trusted Platform API — not an + // arbitrary or tenant-supplied target — so truncating a legitimate long stream would + // be worse than not bounding it. 0 = httpclient's own package default (10MiB); a + // positive value applies that exact cap instead. + MaxResponseBytes int64 `koanf:"max_response_bytes"` +} + +// HTTPClientTLSConfig mirrors the TOML-expressible subset of httpclient.TLSConfig that +// is not already sourced from ControlPlaneConfig (see HTTPClientConfig's doc comment). +// MinVersion/MaxVersion both empty (the default) preserves today's behavior of "no +// override, use Go's own crypto/tls default" (currently a TLS 1.2 floor with no +// configured ceiling). +type HTTPClientTLSConfig struct { + MinVersion string `koanf:"min_version"` // one of "TLS1_0".."TLS1_3" + MaxVersion string `koanf:"max_version"` // one of "TLS1_0".."TLS1_3" + // CipherSuites is a comma-separated list of Go crypto/tls cipher suite names. + // Empty uses Go's own default secure set. Only affects TLS 1.2 and below. + CipherSuites string `koanf:"cipher_suites"` + // CurvePreferences is a comma-separated, order-significant list of curve/group + // names, e.g. "X25519MLKEM768,X25519,P-256" to opt into the FIPS 203 ML-KEM-768 + // hybrid group while retaining classical fallbacks for a Platform API build that + // doesn't support it yet. Empty (the default) uses Go's own defaults (no PQC). + CurvePreferences string `koanf:"curve_preferences"` +} + +// HTTPClientProxyConfig mirrors the TOML-expressible subset of httpclient.ProxyConfig. +// Egress is deliberately not a field here: httpclient.New only requires it when SSRF is +// also enabled, and this component has no SSRF field at all (see HTTPClientConfig's doc +// comment), so it would otherwise sit unused. +type HTTPClientProxyConfig struct { + // Mode selects how the proxy is determined: "none", "environment" + // (HTTP_PROXY/HTTPS_PROXY/NO_PROXY — matches this transport's previous hardcoded + // http.ProxyFromEnvironment behavior, and remains the default here), or "url" (URL/ + // Username/Password/NoProxy below). + Mode string `koanf:"mode"` + URL string `koanf:"url"` + Username string `koanf:"username"` + Password string `koanf:"password"` + NoProxy []string `koanf:"no_proxy"` // exact host, ".suffix", or CIDR entries; only used when mode == "url" + + TLS HTTPClientProxyTLSConfig `koanf:"tls"` +} + +// HTTPClientProxyTLSConfig mirrors httpclient.ProxyTLSConfig (the proxy's own TLS +// handshake, fully decoupled from the origin TLS handshake in HTTPClientTLSConfig). +type HTTPClientProxyTLSConfig struct { + RootCAFile string `koanf:"root_ca_file"` + ClientCertFile string `koanf:"client_cert_file"` + ClientKeyFile string `koanf:"client_key_file"` + InsecureSkipVerify bool `koanf:"insecure_skip_verify"` +} diff --git a/portals/ai-workspace/bff/internal/config/server_tls.go b/portals/ai-workspace/bff/internal/config/server_tls.go new file mode 100644 index 0000000000..48fa3baab0 --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/server_tls.go @@ -0,0 +1,94 @@ +/* + * 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" + + "github.com/wso2/api-platform/httpkit/tlsconfig" +) + +// ParseHTTPSEcdhCurves 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 HTTPS listener's TLS config. +// +// The name-to-tls.CurveID vocabulary is sourced from httpkit/tlsconfig (the +// shared, direction-neutral implementation, also used by platform-api's and +// gateway-controller's server TLS listeners); this function keeps its own +// wrapper for the "empty string is an error" behavior, which differs from +// tlsconfig.ParseCurvePreferences's "empty means use Go's defaults" stance — +// an explicit, always-on TLS listener config has no notion of "unset". +func ParseHTTPSEcdhCurves(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 := tlsconfig.CurvesByName[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 +} + +// ValidateHTTPSTLSVersions checks that min and max are both recognized +// version names and that min does not come after max. Unlike +// tlsconfig.ValidateVersionRange (which treats both-empty as "use Go's +// defaults"), this listener's config always requires both fields to name a +// real version — there is no "unset" state for an always-on TLS listener. +// tls.VersionTLSxx constants are monotonically increasing, so comparing the +// parsed values directly replaces a separate ordering table. +func ValidateHTTPSTLSVersions(minVersion, maxVersion string) error { + minV, ok := tlsconfig.ParseVersion(minVersion) + if !ok { + return fmt.Errorf("minimum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + maxV, ok := tlsconfig.ParseVersion(maxVersion) + if !ok { + return fmt.Errorf("maximum_protocol_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if minV > maxV { + return fmt.Errorf("minimum_protocol_version (%s) cannot be greater than maximum_protocol_version (%s)", minVersion, maxVersion) + } + return nil +} + +// ParseHTTPSTLSVersion converts a validated version name to its crypto/tls +// identifier. Callers should run ValidateHTTPSTLSVersions first; an +// unrecognized name here returns ok=false rather than panicking. +func ParseHTTPSTLSVersion(name string) (version uint16, ok bool) { + return tlsconfig.ParseVersion(name) +} + +// ParseHTTPSCiphers 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 ParseHTTPSCiphers(raw string) ([]uint16, error) { + return tlsconfig.ParseCipherSuites(raw) +} diff --git a/portals/ai-workspace/bff/internal/proxy/transport.go b/portals/ai-workspace/bff/internal/proxy/transport.go index ba3c7cc353..3771b806f3 100644 --- a/portals/ai-workspace/bff/internal/proxy/transport.go +++ b/portals/ai-workspace/bff/internal/proxy/transport.go @@ -17,13 +17,14 @@ package proxy import ( - "crypto/tls" "crypto/x509" "fmt" - "net" "net/http" "os" - "time" + + "github.com/wso2/api-platform/httpkit/httpclient" + + "ai-workspace-bff/internal/config" ) // TLSClientOptions configures how the upstream (Platform API) certificate is @@ -38,36 +39,109 @@ type TLSClientOptions struct { } // NewTransport builds an *http.Transport for upstream calls with explicit -// timeouts and connection pooling. TLS applies only when the upstream URL is -// https:// — this transport is scheme-agnostic and does nothing for http://. -func NewTransport(opts TLSClientOptions) (*http.Transport, error) { - tlsConf := &tls.Config{ - MinVersion: tls.VersionTLS12, - // #nosec G402 — SkipVerify is an explicit, demo-gated escape hatch - // (validated in config); the secure default is false. - InsecureSkipVerify: opts.SkipVerify, - } - if !opts.SkipVerify && opts.CAFile != "" { +// timeouts and connection pooling, via the shared httpkit/httpclient builder. +// hc supplies every knob sourced from [ai_workspace.http_client] in config.toml +// (see config.HTTPClientConfig's doc comment); opts supplies the upstream TLS +// trust settings, sourced from [ai_workspace.control_plane] instead — kept as a +// separate parameter because that trust decision already has its own existing +// config keys (ControlPlaneConfig.CAFile/TLSSkipVerify) which must stay the +// single source of truth, never duplicated onto HTTPClientConfig. TLS applies +// only when the upstream URL is https:// — this transport is scheme-agnostic +// and does nothing for http://. +// +// This transport only ever talks to the fixed, operator-configured Platform +// API (cfg.ControlPlane.URL) — never a tenant/end-user-supplied destination — +// so no SSRF guard (httpclient's Config.SSRF) is enabled here. +func NewTransport(hc config.HTTPClientConfig, opts TLSClientOptions) (*http.Transport, error) { + cfg := httpclient.DefaultConfig() + + cfg.Pooling.MaxIdleConns = hc.Pooling.MaxIdleConns + cfg.Pooling.MaxIdleConnsPerHost = hc.Pooling.MaxIdleConnsPerHost + cfg.Pooling.MaxConnsPerHost = hc.Pooling.MaxConnsPerHost + cfg.Pooling.IdleConnTimeout = hc.Pooling.IdleConnTimeout + cfg.Pooling.KeepAlive = hc.Pooling.KeepAlive + cfg.Pooling.DisableKeepAlives = hc.Pooling.DisableKeepAlives + cfg.Pooling.EnableHTTP2 = hc.Pooling.EnableHTTP2 + + cfg.Timeouts.Overall = hc.Timeouts.Overall + cfg.Timeouts.Dial = hc.Timeouts.Dial + cfg.Timeouts.TLSHandshake = hc.Timeouts.TLSHandshake + cfg.Timeouts.ResponseHeader = hc.Timeouts.ResponseHeader + cfg.Timeouts.ExpectContinue = hc.Timeouts.ExpectContinue + // The default (see config.defaultConfig) is -1: this transport backs a reverse + // proxy that streams SSE/long-running LLM output (see ReverseProxy's + // FlushInterval) to/from the BFF's own fixed, trusted backend — not an + // arbitrary or tenant-supplied target — so disabling httpclient's default + // 10MiB cap is the documented default for exactly this case. An operator can + // still opt into a cap via config; see the type-assertion note below. + cfg.Timeouts.MaxResponseBytes = hc.Timeouts.MaxResponseBytes + + cfg.TLS.MinVersion = hc.TLS.MinVersion + cfg.TLS.MaxVersion = hc.TLS.MaxVersion + cfg.TLS.CipherSuites = hc.TLS.CipherSuites + cfg.TLS.CurvePreferences = hc.TLS.CurvePreferences + + // #nosec G402 — SkipVerify is an explicit, demo-gated escape hatch + // (validated in config); the secure default is false. + cfg.TLS.InsecureSkipVerify = opts.SkipVerify + if opts.SkipVerify { + // Required by httpclient.New alongside InsecureSkipVerify=true: this + // toggle is already an explicit, operator-controlled config option + // (see TLSClientOptions.SkipVerify), so acknowledging it here + // preserves behavior without weakening httpclient's safety gate. + cfg.TLS.InsecureSkipVerifyAcknowledged = true + } else if opts.CAFile != "" { + // Built via caPool (system roots + this bundle appended) rather than + // cfg.TLS.RootCAFile, which would replace the trust store outright — + // this preserves the original "PEM bundle appended to the system + // roots" behavior documented on CAFile above. pool, err := caPool(opts.CAFile) if err != nil { return nil, err } - tlsConf.RootCAs = pool + cfg.TLS.RootCAs = pool + } + + switch hc.Proxy.Mode { + case "", "none": + // no proxy — cfg.Proxy stays at its zero value + case "environment": + cfg.Proxy.Mode = "environment" + case "url": + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = hc.Proxy.URL + cfg.Proxy.Username = hc.Proxy.Username + cfg.Proxy.Password = hc.Proxy.Password + cfg.Proxy.NoProxy = hc.Proxy.NoProxy + if hc.Proxy.TLS != (config.HTTPClientProxyTLSConfig{}) { + cfg.Proxy.ProxyTLS = &httpclient.ProxyTLSConfig{ + RootCAFile: hc.Proxy.TLS.RootCAFile, + ClientCertFile: hc.Proxy.TLS.ClientCertFile, + ClientKeyFile: hc.Proxy.TLS.ClientKeyFile, + InsecureSkipVerify: hc.Proxy.TLS.InsecureSkipVerify, + InsecureSkipVerifyAcknowledged: hc.Proxy.TLS.InsecureSkipVerify, + } + } + default: + return nil, fmt.Errorf("ai_workspace.http_client.proxy.mode: unrecognized value %q (want \"none\", \"environment\", or \"url\")", hc.Proxy.Mode) + } + + client, err := httpclient.New(cfg) + if err != nil { + return nil, err + } + // client.Transport is a concrete *http.Transport here as long as + // Timeouts.MaxResponseBytes stays negative (the shipped default) — this + // config never sets Proxy.Egress = ProxyEgressManualCONNECT, and a negative + // MaxResponseBytes means httpclient.New never wraps the transport in its own + // maxBytesRoundTripper. An operator who explicitly sets + // [ai_workspace.http_client.timeouts] max_response_bytes >= 0 gets a clear + // startup error below instead of a silently-wrong cap. + transport, ok := client.Transport.(*http.Transport) + if !ok { + return nil, fmt.Errorf("httpkit: unexpected transport type %T (set [ai_workspace.http_client.timeouts] max_response_bytes back to a negative value)", client.Transport) } - return &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 20, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - TLSClientConfig: tlsConf, - }, nil + return transport, nil } // caPool returns the system root pool with the PEM bundle at path appended, so diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go index 78fb5f9fbf..967080604e 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -96,7 +96,14 @@ func TestExtractSecretHandle(t *testing.T) { func buildTestServer(t *testing.T, platformURL, jwt string) (*Server, *httptest.Server) { t.Helper() - transport, err := proxy.NewTransport(proxy.TLSClientOptions{SkipVerify: true}) + // MaxResponseBytes: -1 matches the shipped default (see config.defaultConfig) — + // a zero value here would make httpclient wrap the transport in its own + // maxBytesRoundTripper, which NewTransport's concrete *http.Transport + // type-assertion rejects. + transport, err := proxy.NewTransport( + config.HTTPClientConfig{Timeouts: config.HTTPClientTimeoutsConfig{MaxResponseBytes: -1}}, + proxy.TLSClientOptions{SkipVerify: true}, + ) if err != nil { t.Fatalf("NewTransport: %v", err) } @@ -273,7 +280,14 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { ControlPlane: config.ControlPlaneConfig{URL: platform.URL}, Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } - transport, err := proxy.NewTransport(proxy.TLSClientOptions{SkipVerify: true}) + // MaxResponseBytes: -1 matches the shipped default (see config.defaultConfig) — + // a zero value here would make httpclient wrap the transport in its own + // maxBytesRoundTripper, which NewTransport's concrete *http.Transport + // type-assertion rejects. + transport, err := proxy.NewTransport( + config.HTTPClientConfig{Timeouts: config.HTTPClientTimeoutsConfig{MaxResponseBytes: -1}}, + proxy.TLSClientOptions{SkipVerify: true}, + ) if err != nil { t.Fatalf("NewTransport: %v", err) } diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 47d38c2f48..1a45dc9b72 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -67,7 +67,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { return nil, err } - transport, err := proxy.NewTransport(proxy.TLSClientOptions{ + transport, err := proxy.NewTransport(cfg.HTTPClient, proxy.TLSClientOptions{ CAFile: cfg.ControlPlane.CAFile, SkipVerify: cfg.ControlPlane.TLSSkipVerify, }) diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 0d2e40c582..13fe637b1d 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -273,7 +273,33 @@ func buildTLS(c config.HTTPSListener) (*tls.Config, error) { return nil, err } slog.Info("TLS: using mounted certificate", "cert", c.CertFile) - return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + + // Config.validate already rejects a bad version/cipher/curve value before + // this ever runs in production, so an error here can only come from a + // caller that bypassed validation. + if err := config.ValidateHTTPSTLSVersions(c.MinimumProtocolVersion, c.MaximumProtocolVersion); err != nil { + return nil, err + } + minVersion, _ := config.ParseHTTPSTLSVersion(c.MinimumProtocolVersion) + maxVersion, _ := config.ParseHTTPSTLSVersion(c.MaximumProtocolVersion) + + cipherSuites, err := config.ParseHTTPSCiphers(c.Ciphers) + if err != nil { + return nil, err + } + + curves, err := config.ParseHTTPSEcdhCurves(c.EcdhCurves) + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: minVersion, + MaxVersion: maxVersion, + CipherSuites: cipherSuites, // nil == Go's own secure default set/order + CurvePreferences: curves, + }, nil } func fileExists(p string) bool { diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 80a744dea5..541808c473 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -118,6 +118,27 @@ port = 9643 cert_file = "/etc/ai-workspace/tls/cert.pem" key_file = "/etc/ai-workspace/tls/key.pem" +# minimum_protocol_version / maximum_protocol_version bound the negotiated TLS +# version: one of "TLS1_0", "TLS1_1", "TLS1_2", "TLS1_3". +minimum_protocol_version = "TLS1_2" +maximum_protocol_version = "TLS1_3" + +# 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 means 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. +ciphers = "" + +# ecdh_curves is a comma-separated list of TLS 1.3 key-exchange groups, most +# preferred first. Classical curves only by default. Prepend the hybrid +# post-quantum group "X25519MLKEM768" (FIPS 203 ML-KEM-768 + X25519) as an +# explicit opt-in once clients reaching this listener are confirmed to +# support it, e.g. "X25519MLKEM768,X25519,P-256" — a client that doesn't +# offer the hybrid group simply falls back to a later classical entry in this +# same list, so enabling it never breaks a legacy peer. +ecdh_curves = "X25519,P-256" + # --------------------------------------------------------------------------- # Logging. level/format are this process's own logs; browser_debug is @@ -151,6 +172,88 @@ tls_skip_verify = "false" ca_file = "/etc/ai-workspace/tls/cert.pem" +# --------------------------------------------------------------------------- +# Outbound *http.Transport used for every call this BFF makes to the Platform API +# above (see internal/proxy/transport.go's NewTransport, and its one call site in +# internal/server/server.go). Mirrors github.com/wso2/go-httpkit/httpclient.Config +# field-for-field, the same shape gateway-controller's and platform-api's own +# [*.http_client] tables use. Every key below is optional — omitting a subsection +# entirely keeps its Go-level default, and every default here reproduces exactly +# what this transport hardcoded before these keys existed, so a deployment that +# omits this whole table sees zero behavior change. +# +# Not configurable here (see [ai_workspace.control_plane] above instead): +# - TLS trust for the ORIGIN (Platform API) certificate — tls_skip_verify / +# ca_file above already govern that one trust decision; duplicating it here +# would create two settings that must always be kept in sync. +# - SSRF guarding — this transport only ever talks to the fixed url above, never +# a tenant/end-user-supplied destination, so there is nothing to guard against. +# --------------------------------------------------------------------------- +[ai_workspace.http_client.pooling] +max_idle_conns = 100 +max_idle_conns_per_host = 20 +max_conns_per_host = 0 # 0 = no per-host cap +idle_conn_timeout = "90s" +keep_alive = "30s" +disable_keep_alives = false +# HTTP/2 is on by default here (unlike gateway-controller/platform-api, which default +# it off): this transport already sets a custom TLS config, so Go's own Transport +# would otherwise conservatively disable HTTP/2 — see +# httpclient.PoolingConfig.EnableHTTP2's doc comment on the connection-coalescing +# caveat before disabling. +enable_http2 = true + +[ai_workspace.http_client.timeouts] +# Overall has no observable effect at this call site today — NewTransport extracts +# only the *http.Transport, and server.go applies its own separate 60s +# http.Client.Timeout on top of it. Kept for shape parity / future use. +overall = "30s" +dial = "10s" +tls_handshake = "10s" +response_header = "10s" +expect_continue = "1s" +# 0 = httpclient's own package default (10MiB); a negative value disables the +# response-size bound entirely. Negative by default: this transport backs a reverse +# proxy that streams SSE / long-running LLM output between the BFF and its own +# fixed, trusted Platform API — not an arbitrary or tenant-supplied target — so +# truncating a legitimate long stream would be worse than not bounding it. +max_response_bytes = -1 + +[ai_workspace.http_client.tls] +# Both empty (the default) leaves Go's own crypto/tls default in effect (currently a +# TLS 1.2 floor with no configured ceiling) — set explicitly to bound the negotiated +# version instead. +min_version = "" +max_version = "" +# Comma-separated Go crypto/tls cipher suite names; only affects TLS 1.2 and below. +# Empty uses Go's own default secure set. +cipher_suites = "" +# Comma-separated, order-significant TLS 1.3 key-exchange groups, e.g. +# "X25519MLKEM768,X25519,P-256" to opt into the FIPS 203 ML-KEM-768 hybrid group +# while retaining classical fallbacks for a Platform API build that doesn't support +# it yet. Empty (the default) uses Go's own defaults (no PQC). +curve_preferences = "" + +[ai_workspace.http_client.proxy] +# "none" | "environment" (HTTP_PROXY/HTTPS_PROXY/NO_PROXY — matches this +# transport's previous hardcoded http.ProxyFromEnvironment behavior, and remains +# the default here) | "url" (url/username/password/no_proxy below) +mode = "environment" +url = "" +username = "" +password = "" +no_proxy = [] + +[ai_workspace.http_client.proxy.tls] +# Configures a SEPARATE TLS handshake to an https:// proxy itself, decoupled from +# ai_workspace.http_client.tls above (which always governs the origin handshake). +# Only used when [ai_workspace.http_client.proxy] mode = "url". +root_ca_file = "" +client_cert_file = "" +client_key_file = "" +insecure_skip_verify = false + + # --------------------------------------------------------------------------- # Gateway deployment info — browser-safe values the SPA shows in gateway setup # instructions. Unlike [ai_workspace.control_plane] (the BFF's own hop), this is what diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index b8f2c3fb94..8c17a482a4 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -18,6 +18,11 @@ url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' ca_file = "/etc/ai-workspace/tls/cert.pem" +# The outbound *http.Transport used for every call to the Platform API above is left +# entirely at its built-in default here — see configs/config-template.toml's +# [ai_workspace.http_client.*] tables for the fully documented reference (each key's +# meaning, and how to opt into a PQC-hybrid curve_preferences). + [ai_workspace.gateway] controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "host.docker.internal:9243" }}'