From 8a63c49cc3b7754aba5f4d81f3aaf5f8bb787e9e Mon Sep 17 00:00:00 2001 From: onthebed <1136664562@qq.com> Date: Fri, 24 Apr 2026 20:02:19 +0800 Subject: [PATCH 1/2] docs(interceptor): document TLS SNI certificate selection Document how the interceptor selects SNI-specific certificates, when it falls back to the default certificate, and what happens when no match is configured. Add a unit test proving an SNI match is preferred over the default certificate and record the change in the changelog. Fixes #1600 Signed-off-by: onthebed <1136664562@qq.com> --- CHANGELOG.md | 2 +- docs/developing.md | 10 ++++++++++ interceptor/config/serving.go | 5 ++++- interceptor/tls_config_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) 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..2680e5383 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 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..64ce05b14 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..788e4dfd5 100644 --- a/interceptor/tls_config_test.go +++ b/interceptor/tls_config_test.go @@ -79,6 +79,34 @@ func TestBuildTLSConfig_FallbackToDefault(t *testing.T) { requireCertForHost(t, tlsCfg, "unknown.example.com") } +func TestBuildTLSConfig_PrefersSNIMatchOverDefault(t *testing.T) { + dir := t.TempDir() + writeCert(t, dir, "default", "default.example.com") + writeCert(t, dir, "app", "app.example.com") + + opts := TLSOptions{ + CertificatePath: filepath.Join(dir, "default.crt"), + KeyPath: filepath.Join(dir, "default.key"), + CertStorePaths: dir, + } + + 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{} From ea57577f63375b1e6943033e4b7e5372045a68e3 Mon Sep 17 00:00:00 2001 From: onthebed <1136664562@qq.com> Date: Fri, 24 Apr 2026 21:38:53 +0800 Subject: [PATCH 2/2] docs(interceptor): clarify TLS SNI matching details Tighten the TLS SNI docs to mention the comma-separated cert store format and isolate the SNI preference test so the default certificate is not loaded through the store path. Signed-off-by: onthebed <1136664562@qq.com> --- docs/developing.md | 2 +- interceptor/config/serving.go | 2 +- interceptor/tls_config_test.go | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/developing.md b/docs/developing.md index 2680e5383..ccf57ffa1 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -125,7 +125,7 @@ The `E2E_ARGS` variable passes flags to the [e2e-framework](https://github.com/k ### 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 one or more directories that contain certificate/key pairs. During the TLS handshake it: +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. diff --git a/interceptor/config/serving.go b/interceptor/config/serving.go index 64ce05b14..e14645465 100644 --- a/interceptor/config/serving.go +++ b/interceptor/config/serving.go @@ -28,7 +28,7 @@ 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 directories containing additional + // 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. diff --git a/interceptor/tls_config_test.go b/interceptor/tls_config_test.go index 788e4dfd5..45662189a 100644 --- a/interceptor/tls_config_test.go +++ b/interceptor/tls_config_test.go @@ -80,14 +80,18 @@ func TestBuildTLSConfig_FallbackToDefault(t *testing.T) { } func TestBuildTLSConfig_PrefersSNIMatchOverDefault(t *testing.T) { - dir := t.TempDir() - writeCert(t, dir, "default", "default.example.com") - writeCert(t, dir, "app", "app.example.com") + 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(dir, "default.crt"), - KeyPath: filepath.Join(dir, "default.key"), - CertStorePaths: dir, + CertificatePath: filepath.Join(baseDir, "default.crt"), + KeyPath: filepath.Join(baseDir, "default.key"), + CertStorePaths: storeDir, } tlsCfg, err := BuildTLSConfig(opts, logr.Discard())