diff --git a/CHANGELOG.md b/CHANGELOG.md index acdd4e0fa..03ea2b0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ This changelog keeps track of work items that have been completed and are ready ### Improvements -- **General**: TODO ([#TODO](https://github.com/kedacore/http-add-on/issues/TODO)) +- **Interceptor**: Document TLS SNI certificate selection and fallback behavior; add tests covering SNI-specific certificate preference over the default certificate ([#1600](https://github.com/kedacore/http-add-on/issues/1600)) ### Fixes diff --git a/docs/developing.md b/docs/developing.md index 3946281dc..ccf57ffa1 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -122,3 +122,13 @@ make e2e-test E2E_ARGS="--dry-run" The `PROFILE` variable selects a test profile directory under `test/e2e/` (e.g. `PROFILE=tls` runs `./test/e2e/tls/...`). Each subdirectory in `test/e2e/` is a profile. The `RUN` variable filters tests by name using Go's `-run` flag (supports regex, e.g. `RUN=TestColdStart` or `RUN="TestHost|TestPath"`). The `E2E_ARGS` variable passes flags to the [e2e-framework](https://github.com/kubernetes-sigs/e2e-framework) via `-args` (e.g. `--labels`, `--feature`, `--skip-labels`, `--dry-run`). + +### TLS SNI behavior + +The interceptor can serve more than one certificate from the TLS listener by setting `KEDA_HTTP_PROXY_TLS_CERT_STORE_PATHS` to a comma-separated list of one or more directories that contain certificate/key pairs. During the TLS handshake it: + +1. Looks for an exact match between the client SNI value and a certificate SAN loaded from the configured certificate-store directories. +2. Falls back to the certificate from `KEDA_HTTP_PROXY_TLS_CERT_PATH` and `KEDA_HTTP_PROXY_TLS_KEY_PATH` when no SNI-specific certificate matches. +3. Fails the handshake when there is no matching SNI certificate and no default certificate is configured. + +The existing `test/e2e/tls` profile covers successful TLS termination. The interceptor unit tests in `interceptor/tls_config_test.go` additionally cover the no-match fallback and no-default error paths. diff --git a/interceptor/config/serving.go b/interceptor/config/serving.go index a7b364cae..e14645465 100644 --- a/interceptor/config/serving.go +++ b/interceptor/config/serving.go @@ -28,7 +28,10 @@ type Serving struct { TLSCertPath string `env:"KEDA_HTTP_PROXY_TLS_CERT_PATH" envDefault:"/certs/tls.crt"` // TLSKeyPath is the path to read the private key file from for the TLS server TLSKeyPath string `env:"KEDA_HTTP_PROXY_TLS_KEY_PATH" envDefault:"/certs/tls.key"` - // TLSCertStorePaths is a comma separated list of paths to read the certificate/key pairs for the TLS server + // TLSCertStorePaths is a comma-separated list of directories containing additional + // certificate/key pairs for the TLS server. During the TLS handshake, the proxy + // first looks for an exact SNI/SAN match in these directories and falls back to + // TLSCertPath/TLSKeyPath when no matching certificate is found. TLSCertStorePaths string `env:"KEDA_HTTP_PROXY_TLS_CERT_STORE_PATHS" envDefault:""` // TLSSkipVerify is a boolean flag to specify whether the interceptor should skip TLS verification for upstreams TLSSkipVerify bool `env:"KEDA_HTTP_PROXY_TLS_SKIP_VERIFY" envDefault:"false"` diff --git a/interceptor/tls_config_test.go b/interceptor/tls_config_test.go index 7814e07c3..45662189a 100644 --- a/interceptor/tls_config_test.go +++ b/interceptor/tls_config_test.go @@ -79,6 +79,38 @@ func TestBuildTLSConfig_FallbackToDefault(t *testing.T) { requireCertForHost(t, tlsCfg, "unknown.example.com") } +func TestBuildTLSConfig_PrefersSNIMatchOverDefault(t *testing.T) { + baseDir := t.TempDir() + storeDir := filepath.Join(baseDir, "store") + if err := os.Mkdir(storeDir, 0o755); err != nil { + t.Fatalf("creating cert store dir: %v", err) + } + writeCert(t, baseDir, "default", "default.example.com") + writeCert(t, storeDir, "app", "app.example.com") + + opts := TLSOptions{ + CertificatePath: filepath.Join(baseDir, "default.crt"), + KeyPath: filepath.Join(baseDir, "default.key"), + CertStorePaths: storeDir, + } + + tlsCfg, err := BuildTLSConfig(opts, logr.Discard()) + if err != nil { + t.Fatalf("failed to build TLS config: %v", err) + } + + cert, err := tlsCfg.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"}) + if err != nil { + t.Fatalf("expected SNI-matched certificate, got error: %v", err) + } + if cert == nil || cert.Leaf == nil { + t.Fatal("expected certificate leaf to be populated") + } + if got := cert.Leaf.DNSNames; len(got) != 1 || got[0] != "app.example.com" { + t.Fatalf("expected app.example.com certificate, got %v", got) + } +} + func TestBuildTLSConfig_NoDefaultCert(t *testing.T) { opts := TLSOptions{}