diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go index 596b1f140ac..0c5e6b5485f 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go @@ -146,6 +146,10 @@ type TLSConfig struct { // Must be configured if TCPTransport.URL is prefixed with https:// // +optional ClientCert string + + // tlsServerName is used to check server certificate. If tlsServerName is empty, the hostname used to contact the server is used. + // +optional + TLSServerName string } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go index 6e0f86da86d..1e20c697b55 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go @@ -147,6 +147,10 @@ type TLSConfig struct { // Must be configured if TCPTransport.URL is prefixed with https:// // +optional ClientCert string `json:"clientCert,omitempty"` + + // tlsServerName is used to check server certificate. If tlsServerName is empty, the hostname used to contact the server is used. + // +optional + TLSServerName string `json:"tlsServerName,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go index 9d7a8ce6279..4ea02978954 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go @@ -817,6 +817,7 @@ func autoConvert_v1alpha1_TLSConfig_To_apiserver_TLSConfig(in *TLSConfig, out *a out.CABundle = in.CABundle out.ClientKey = in.ClientKey out.ClientCert = in.ClientCert + out.TLSServerName = in.TLSServerName return nil } @@ -829,6 +830,7 @@ func autoConvert_apiserver_TLSConfig_To_v1alpha1_TLSConfig(in *apiserver.TLSConf out.CABundle = in.CABundle out.ClientKey = in.ClientKey out.ClientCert = in.ClientCert + out.TLSServerName = in.TLSServerName return nil } diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/types.go index 16260efb66d..44bfbbd3c1f 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/types.go @@ -118,6 +118,10 @@ type TLSConfig struct { // Must be configured if TCPTransport.URL is prefixed with https:// // +optional ClientCert string `json:"clientCert,omitempty"` + + // tlsServerName is used to check server certificate. If tlsServerName is empty, the hostname used to contact the server is used. + // +optional + TLSServerName string `json:"tlsServerName,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/zz_generated.conversion.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/zz_generated.conversion.go index 4f25bd60c2c..04323e2082e 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/zz_generated.conversion.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1beta1/zz_generated.conversion.go @@ -753,6 +753,7 @@ func autoConvert_v1beta1_TLSConfig_To_apiserver_TLSConfig(in *TLSConfig, out *ap out.CABundle = in.CABundle out.ClientKey = in.ClientKey out.ClientCert = in.ClientCert + out.TLSServerName = in.TLSServerName return nil } @@ -765,6 +766,7 @@ func autoConvert_apiserver_TLSConfig_To_v1beta1_TLSConfig(in *apiserver.TLSConfi out.CABundle = in.CABundle out.ClientKey = in.ClientKey out.ClientCert = in.ClientCert + out.TLSServerName = in.TLSServerName return nil } diff --git a/staging/src/k8s.io/apiserver/pkg/server/egressselector/config_test.go b/staging/src/k8s.io/apiserver/pkg/server/egressselector/config_test.go index 3eb72683ff0..441bc3224dd 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/egressselector/config_test.go +++ b/staging/src/k8s.io/apiserver/pkg/server/egressselector/config_test.go @@ -302,6 +302,52 @@ spec: expectedResult: nil, expectedError: ptr.To("invalid service configuration object \"DaemonSet\""), }, + { + name: "v1beta1 with tlsServerName", + createFile: true, + contents: ` +apiVersion: apiserver.k8s.io/v1beta1 +kind: EgressSelectorConfiguration +egressSelections: +- name: "cluster" + connection: + proxyProtocol: "HTTPConnect" + transport: + tcp: + url: "https://proxy.kube-system.svc.cluster.local:8443" + tlsConfig: + caBundle: "/etc/srv/kubernetes/pki/konnectivity-server/ca.crt" + clientKey: "/etc/srv/kubernetes/pki/konnectivity-server/client.key" + clientCert: "/etc/srv/kubernetes/pki/konnectivity-server/client.crt" + tlsServerName: "konnectivity-server.example.com" +`, + expectedResult: &apiserver.EgressSelectorConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "", + APIVersion: "", + }, + EgressSelections: []apiserver.EgressSelection{ + { + Name: "cluster", + Connection: apiserver.Connection{ + ProxyProtocol: "HTTPConnect", + Transport: &apiserver.Transport{ + TCP: &apiserver.TCPTransport{ + URL: "https://proxy.kube-system.svc.cluster.local:8443", + TLSConfig: &apiserver.TLSConfig{ + CABundle: "/etc/srv/kubernetes/pki/konnectivity-server/ca.crt", + ClientKey: "/etc/srv/kubernetes/pki/konnectivity-server/client.key", + ClientCert: "/etc/srv/kubernetes/pki/konnectivity-server/client.crt", + TLSServerName: "konnectivity-server.example.com", + }, + }, + }, + }, + }, + }, + }, + expectedError: nil, + }, } for _, tc := range testcases { diff --git a/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector.go b/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector.go index 27408fe1582..b898ff3270c 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector.go +++ b/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector.go @@ -300,6 +300,7 @@ func getTLSConfig(t *apiserver.TLSConfig) (*tls.Config, error) { return &tls.Config{ Certificates: []tls.Certificate{clientCerts}, RootCAs: certPool, + ServerName: t.TLSServerName, }, nil } diff --git a/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector_test.go b/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector_test.go index 896b2446183..875cdacd8c8 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector_test.go +++ b/staging/src/k8s.io/apiserver/pkg/server/egressselector/egress_selector_test.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" "net" + "os" + "path/filepath" "strings" "testing" "time" @@ -29,6 +31,7 @@ import ( utilnet "k8s.io/apimachinery/pkg/util/net" "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/server/egressselector/metrics" + certutil "k8s.io/client-go/util/cert" "k8s.io/component-base/metrics/legacyregistry" "k8s.io/component-base/metrics/testutil" testingclock "k8s.io/utils/clock/testing" @@ -343,3 +346,61 @@ konnectivity_network_proxy_client_client_connections{status="dialing"} 1 }) } } + +func TestGetTLSConfig(t *testing.T) { + tempDir := t.TempDir() + + certPEM, keyPEM, err := certutil.GenerateSelfSignedCertKey("localhost", nil, nil) + if err != nil { + t.Fatalf("Failed to generate test certificates: %v", err) + } + + certPath := filepath.Join(tempDir, "cert.crt") + keyPath := filepath.Join(tempDir, "cert.key") + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + t.Fatalf("Failed to write cert file: %v", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatalf("Failed to write key file: %v", err) + } + + testcases := []struct { + name string + tlsConfig *apiserver.TLSConfig + expectedServerName string + }{ + { + name: "with TLSServerName set", + tlsConfig: &apiserver.TLSConfig{ + CABundle: certPath, + ClientCert: certPath, + ClientKey: keyPath, + TLSServerName: "custom-server.example.com", + }, + expectedServerName: "custom-server.example.com", + }, + { + name: "without TLSServerName (empty)", + tlsConfig: &apiserver.TLSConfig{ + CABundle: certPath, + ClientCert: certPath, + ClientKey: keyPath, + TLSServerName: "", + }, + expectedServerName: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tlsConfig, err := getTLSConfig(tc.tlsConfig) + if err != nil { + t.Fatalf("getTLSConfig returned unexpected error: %v", err) + } + + if tlsConfig.ServerName != tc.expectedServerName { + t.Errorf("expected ServerName %q, got %q", tc.expectedServerName, tlsConfig.ServerName) + } + }) + } +} diff --git a/test/integration/apiserver/egressselector/main_test.go b/test/integration/apiserver/egressselector/main_test.go new file mode 100644 index 00000000000..3c7b2e12f03 --- /dev/null +++ b/test/integration/apiserver/egressselector/main_test.go @@ -0,0 +1,27 @@ +/* +Copyright The Kubernetes Authors. + +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 egressselector + +import ( + "testing" + + "k8s.io/kubernetes/test/integration/framework" +) + +func TestMain(m *testing.M) { + framework.EtcdMain(m.Run) +} diff --git a/test/integration/apiserver/egressselector/tls_server_name_test.go b/test/integration/apiserver/egressselector/tls_server_name_test.go new file mode 100644 index 00000000000..ee4902e79d8 --- /dev/null +++ b/test/integration/apiserver/egressselector/tls_server_name_test.go @@ -0,0 +1,232 @@ +/* +Copyright The Kubernetes Authors. + +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 egressselector + +import ( + "context" + "crypto" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "k8s.io/apimachinery/pkg/util/wait" + certutil "k8s.io/client-go/util/cert" + "k8s.io/client-go/util/keyutil" + kubeapiserverapptesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/framework" + testutils "k8s.io/kubernetes/test/utils" + utilsoidc "k8s.io/kubernetes/test/utils/oidc" +) + +func generateTestCerts(t *testing.T, tempDir, serverName string) (caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath string) { + t.Helper() + + caKey, err := testutils.NewPrivateKey() + require.NoError(t, err, "Failed to generate CA key") + caCert, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: "Test CA"}, caKey) + require.NoError(t, err, "Failed to create CA certificate") + caCertPath = filepath.Join(tempDir, "ca.crt") + require.NoError(t, os.WriteFile(caCertPath, testutils.EncodeCertPEM(caCert), 0600), "Failed to write CA cert") + + serverKey, err := testutils.NewPrivateKey() + require.NoError(t, err, "Failed to generate server key") + serverCert, err := testutils.NewSignedCert(&certutil.Config{ + CommonName: serverName, + AltNames: certutil.AltNames{DNSNames: []string{serverName}}, + Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, serverKey, caCert, caKey) + require.NoError(t, err, "Failed to create server certificate") + serverCertPath, serverKeyPath = writeCertAndKey(t, tempDir, "server", serverCert, serverKey) + + clientKey, err := testutils.NewPrivateKey() + require.NoError(t, err, "Failed to generate client key") + clientCert, err := testutils.NewSignedCert(&certutil.Config{ + CommonName: "test-client", + Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }, clientKey, caCert, caKey) + require.NoError(t, err, "Failed to create client certificate") + clientCertPath, clientKeyPath = writeCertAndKey(t, tempDir, "client", clientCert, clientKey) + + return caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath +} + +func writeCertAndKey(t *testing.T, dir, name string, cert *x509.Certificate, key crypto.Signer) (certPath, keyPath string) { + t.Helper() + certPath = filepath.Join(dir, name+".crt") + keyPath = filepath.Join(dir, name+".key") + require.NoError(t, os.WriteFile(certPath, testutils.EncodeCertPEM(cert), 0600)) + keyPEM, err := keyutil.MarshalPrivateKeyToPEM(key) + require.NoError(t, err) + require.NoError(t, os.WriteFile(keyPath, keyPEM, 0600)) + return certPath, keyPath +} + +// runTLSEgressProxy runs an HTTP CONNECT proxy with TLS. +func runTLSEgressProxy(t *testing.T, serverCertPath, serverKeyPath, caCertPath string, called *atomic.Bool, observedSNI *atomic.Pointer[string]) string { + t.Helper() + + serverCert, err := tls.LoadX509KeyPair(serverCertPath, serverKeyPath) + require.NoError(t, err, "Failed to load server cert") + + clientCAs, err := certutil.NewPool(caCertPath) + require.NoError(t, err, "Failed to load CA cert pool") + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + observedSNI.Store(&hello.ServerName) + return nil, nil + }, + } + + listener, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig) + require.NoError(t, err, "Failed to start TLS listener") + + proxyAddr := listener.Addr().String() + + server := &http.Server{Handler: utilsoidc.NewHTTPConnectProxyHandler(t, called)} + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + t.Logf("TLS egress proxy serve error: %v", err) + } + }() + + t.Cleanup(func() { + if err := server.Close(); err != nil { + t.Logf("Failed to close server: %v", err) + } + }) + + return proxyAddr +} + +// TestTLSServerName verifies that the tlsServerName field in the egress selector configuration +// correctly overrides SNI for TLS certificate validation when connecting through a proxy. +func TestTLSServerName(t *testing.T) { + tempDir := t.TempDir() + + proxyHostname := "egress-proxy.example.com" + + caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath := generateTestCerts(t, tempDir, proxyHostname) + + testCases := []struct { + name string + tlsServerName string + expectProxyCalled bool + expectedSNI string + description string + }{ + { + name: "matching tlsServerName succeeds TLS handshake", + tlsServerName: proxyHostname, + expectProxyCalled: true, + expectedSNI: proxyHostname, + description: "SNI override matches cert SANs, connection succeeds", + }, + { + name: "mismatched tlsServerName fails TLS handshake", + tlsServerName: "wrong-hostname.example.com", + expectProxyCalled: false, + expectedSNI: "wrong-hostname.example.com", + description: "SNI override doesn't match cert SANs, connection fails", + }, + { + name: "empty tlsServerName uses dial address and fails", + tlsServerName: "", + expectProxyCalled: false, + expectedSNI: "", + description: "Without tlsServerName, uses dial address 127.0.0.1 which fails cert validation", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var proxyCalled atomic.Bool + var observedSNI atomic.Pointer[string] + + proxyAddr := runTLSEgressProxy(t, serverCertPath, serverKeyPath, caCertPath, &proxyCalled, &observedSNI) + t.Logf("TLS egress proxy ready at %s (cert issued for: %s)", proxyAddr, proxyHostname) + + egressConfig := fmt.Sprintf(` +apiVersion: apiserver.k8s.io/v1beta1 +kind: EgressSelectorConfiguration +egressSelections: +- name: cluster + connection: + proxyProtocol: HTTPConnect + transport: + tcp: + url: https://%s + tlsConfig: + caBundle: %s + clientCert: %s + clientKey: %s + tlsServerName: %s +`, proxyAddr, caCertPath, clientCertPath, clientKeyPath, tc.tlsServerName) + + const authenticationConfigWithEgress = ` +apiVersion: apiserver.config.k8s.io/v1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: https://panda.snorlax + audiences: + - foo + egressSelectorType: cluster + claimMappings: + username: + expression: "'test-' + claims.sub" +` + + server := kubeapiserverapptesting.StartTestServerOrDie( + t, + kubeapiserverapptesting.NewDefaultTestServerOptions(), + []string{ + fmt.Sprintf("--egress-selector-config-file=%s", utilsoidc.WriteTempFile(t, egressConfig)), + fmt.Sprintf("--authentication-config=%s", utilsoidc.WriteTempFile(t, authenticationConfigWithEgress)), + }, + framework.SharedEtcd(), + ) + t.Cleanup(server.TearDownFn) + + var sni string + err := wait.PollUntilContextTimeout(t.Context(), time.Second, wait.ForeverTestTimeout, true, + func(ctx context.Context) (done bool, err error) { + if ptr := observedSNI.Load(); ptr != nil { + sni = *ptr + return true, nil + } + return false, nil + }, + ) + require.NoError(t, err, "SNI not observed") + require.Equal(t, tc.expectedSNI, sni, "SNI mismatch") + require.Equal(t, tc.expectProxyCalled, proxyCalled.Load(), "proxy called mismatch") + }) + } +} diff --git a/test/integration/apiserver/oidc/egress_test.go b/test/integration/apiserver/oidc/egress_test.go index 40ebab1bc06..58300ef72b0 100644 --- a/test/integration/apiserver/oidc/egress_test.go +++ b/test/integration/apiserver/oidc/egress_test.go @@ -19,7 +19,6 @@ package oidc import ( "context" "errors" - "io" "net" "net/http" "sync" @@ -29,6 +28,7 @@ import ( utilnet "k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/wait" + utilsoidc "k8s.io/kubernetes/test/utils/oidc" ) func runEgressProxy(t testing.TB, udsName string, ready chan<- struct{}) { @@ -41,75 +41,7 @@ func runEgressProxy(t testing.TB, udsName string, ready chan<- struct{}) { } var called atomic.Bool - httpConnectProxy := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/ready" { - t.Log("egress proxy ready") - w.WriteHeader(http.StatusOK) - return - } - - called.Store(true) - - if r.Method != http.MethodConnect { - http.Error(w, "this proxy only supports CONNECT passthrough", http.StatusMethodNotAllowed) - return - } - - backendConn, err := (&net.Dialer{}).DialContext(r.Context(), "tcp", r.Host) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = backendConn.Close() }() - - hijacker, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijacking not supported", http.StatusInternalServerError) - return - } - - requestHijackedConn, _, err := hijacker.Hijack() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = requestHijackedConn.Close() }() - - // use t.Errorf for all errors after this Write since the client may think the connection is good - _, err = requestHijackedConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) - if err != nil { - t.Errorf("unexpected established error: %v", err) - return - } - - writerComplete := make(chan struct{}) - readerComplete := make(chan struct{}) - - go func() { - _, err := io.Copy(backendConn, requestHijackedConn) - if err != nil && !utilnet.IsProbableEOF(err) { - t.Errorf("unexpected writer error: %v", err) - } - close(writerComplete) - }() - - go func() { - _, err := io.Copy(requestHijackedConn, backendConn) - if err != nil && !utilnet.IsProbableEOF(err) { - t.Errorf("unexpected reader error: %v", err) - } - close(readerComplete) - }() - - // Wait for one half the connection to exit. Once it does, - // the defer will clean up the other half of the connection. - select { - case <-writerComplete: - case <-readerComplete: - } - }) - - server := http.Server{Handler: httpConnectProxy} + server := http.Server{Handler: utilsoidc.NewHTTPConnectProxyHandler(t, &called)} t.Cleanup(func() { if !called.Load() { diff --git a/test/integration/apiserver/oidc/oidc_test.go b/test/integration/apiserver/oidc/oidc_test.go index c238fc0df35..a5498535b5f 100644 --- a/test/integration/apiserver/oidc/oidc_test.go +++ b/test/integration/apiserver/oidc/oidc_test.go @@ -2219,7 +2219,7 @@ func startTestAPIServerForOIDC[L utilsoidc.JosePublicKey](t *testing.T, c apiSer var customFlags []string if len(c.authenticationConfigYAML) > 0 { - customFlags = []string{fmt.Sprintf("--authentication-config=%s", writeTempFile(t, c.authenticationConfigYAML))} + customFlags = []string{fmt.Sprintf("--authentication-config=%s", utilsoidc.WriteTempFile(t, c.authenticationConfigYAML))} if c.needsEgressProxyOnStart { udsName := filepath.Join(t.TempDir(), "uds") ready := make(chan struct{}) @@ -2241,7 +2241,7 @@ egressSelections: uds: udsName: %s `, udsName) - customFlags = append(customFlags, fmt.Sprintf("--egress-selector-config-file=%s", writeTempFile(t, egressConfig))) + customFlags = append(customFlags, fmt.Sprintf("--egress-selector-config-file=%s", utilsoidc.WriteTempFile(t, egressConfig))) } } else { customFlags = []string{ @@ -2358,23 +2358,6 @@ func generateCert(t *testing.T) (cert, key []byte, certFilePath, keyFilePath str return cert, key, certFilePath, keyFilePath } -func writeTempFile(t *testing.T, content string) string { - t.Helper() - file, err := os.CreateTemp("", "oidc-test") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := os.Remove(file.Name()); err != nil { - t.Fatal(err) - } - }) - if err := os.WriteFile(file.Name(), []byte(content), 0600); err != nil { - t.Fatal(err) - } - return file.Name() -} - // indentCertificateAuthority indents the certificate authority to match // the format of the generated authentication config. func indentCertificateAuthority(caCert string) string { diff --git a/test/utils/oidc/proxy.go b/test/utils/oidc/proxy.go new file mode 100644 index 00000000000..234ba4aa662 --- /dev/null +++ b/test/utils/oidc/proxy.go @@ -0,0 +1,103 @@ +/* +Copyright The Kubernetes Authors. + +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 oidc + +import ( + "io" + "net" + "net/http" + "sync/atomic" + "testing" + + utilnet "k8s.io/apimachinery/pkg/util/net" +) + +// NewHTTPConnectProxyHandler returns an http.Handler that implements an HTTP CONNECT proxy. +// When a CONNECT request is received, it dials the target, hijacks the client connection, +// and bidirectionally copies data between them. The called flag is set to true when a +// non-ready request is received. +func NewHTTPConnectProxyHandler(t testing.TB, called *atomic.Bool) http.Handler { + t.Helper() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ready" { + t.Log("egress proxy ready") + w.WriteHeader(http.StatusOK) + return + } + + called.Store(true) + + if r.Method != http.MethodConnect { + http.Error(w, "this proxy only supports CONNECT passthrough", http.StatusMethodNotAllowed) + return + } + + backendConn, err := (&net.Dialer{}).DialContext(r.Context(), "tcp", r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = backendConn.Close() }() + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = clientConn.Close() }() + + // use t.Errorf for all errors after this Write since the client may think the connection is good + _, err = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + if err != nil { + t.Errorf("unexpected established error: %v", err) + return + } + + writerComplete := make(chan struct{}) + readerComplete := make(chan struct{}) + + go func() { + _, err := io.Copy(backendConn, clientConn) + if err != nil && !utilnet.IsProbableEOF(err) { + t.Logf("writer error: %v", err) + } + close(writerComplete) + }() + + go func() { + _, err := io.Copy(clientConn, backendConn) + if err != nil && !utilnet.IsProbableEOF(err) { + t.Logf("reader error: %v", err) + } + close(readerComplete) + }() + + // Wait for one half the connection to exit. Once it does, + // the defer will clean up the other half of the connection. + select { + case <-writerComplete: + case <-readerComplete: + } + }) +} diff --git a/test/utils/oidc/testserver.go b/test/utils/oidc/testserver.go index 405c09d291f..c429e16360f 100644 --- a/test/utils/oidc/testserver.go +++ b/test/utils/oidc/testserver.go @@ -33,6 +33,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/go-jose/go-jose.v2" + "k8s.io/kubernetes/test/utils/oidc/handlers" ) @@ -255,3 +256,22 @@ func GetSignatureAlgorithm[K JoseKey](key K) jose.SignatureAlgorithm { panic("unknown key type") // should be impossible } } + +// WriteTempFile writes content to a temporary file and returns its path. +// The file is automatically cleaned up when the test completes. +func WriteTempFile(t *testing.T, content string) string { + t.Helper() + file, err := os.CreateTemp("", "oidc-test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Remove(file.Name()); err != nil { + t.Fatal(err) + } + }) + if err := os.WriteFile(file.Name(), []byte(content), 0600); err != nil { + t.Fatal(err) + } + return file.Name() +}