diff --git a/conformance/tests/gateway-tls-backend-client-certificate.go b/conformance/tests/gateway-tls-backend-client-certificate.go new file mode 100644 index 0000000000..04a12fec51 --- /dev/null +++ b/conformance/tests/gateway-tls-backend-client-certificate.go @@ -0,0 +1,76 @@ +/* +Copyright 2025 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 tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + h "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, GatewayTLSBackendClientCertificate) +} + +var GatewayTLSBackendClientCertificate = suite.ConformanceTest{ + ShortName: "GatewayTLSBackendClientCertificate", + Description: "A Gateway with a client certificate configured should present the certificate when connecting to a backend using TLS.", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayTLSBackendClientCertificate, + features.SupportHTTPRoute, + features.SupportBackendTLSPolicy, + }, + Manifests: []string{"tests/gateway-tls-backend-client-certificate.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + routeNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate", Namespace: ns} + gwNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate", Namespace: ns} + policyNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate-test", Namespace: ns} + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gatewayv1.HTTPRoute{}, false, routeNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + kubernetes.BackendTLSPolicyMustHaveAcceptedConditionTrue(t, suite.Client, suite.TimeoutConfig, policyNN, gwNN) + + t.Run("HTTP request sent to Service using TLS should succeed and the configured client certificate should be presented.", func(t *testing.T) { + expectedClientCert, _, err := GetTLSSecret(suite.Client, types.NamespacedName{Name: "tls-checks-client-certificate", Namespace: ns}) + if err != nil { + t.Fatalf("unexpected error finding TLS client certifcate secret: %v", err) + } + + h.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, + h.ExpectedResponse{ + Namespace: ns, + Request: h.Request{ + Path: "/", + Host: "abc.example.com", + SNI: "abc.example.com", + ClientCert: string(expectedClientCert), + }, + Response: h.Response{StatusCodes: []int{200}}, + }) + }) + }, +} diff --git a/conformance/tests/gateway-tls-backend-client-certificate.yaml b/conformance/tests/gateway-tls-backend-client-certificate.yaml new file mode 100644 index 0000000000..f1319c658b --- /dev/null +++ b/conformance/tests/gateway-tls-backend-client-certificate.yaml @@ -0,0 +1,133 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-tls-backend-client-certificate + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same + tls: + backend: + clientCertificateRef: + group: "" + kind: Secret + name: tls-checks-client-certificate +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-tls-backend-client-certificate + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-tls-backend-client-certificate + namespace: gateway-conformance-infra + hostnames: + - abc.example.com + rules: + - backendRefs: + - group: "" + kind: Service + name: tls-backend-with-client-cert-validation + port: 443 + matches: + - path: + type: Exact + value: / +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: BackendTLSPolicy +metadata: + name: gateway-tls-backend-client-certificate-test + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: "" + kind: Service + name: tls-backend-with-client-cert-validation + validation: + caCertificateRefs: + - group: "" + kind: ConfigMap + # This ConfigMap is generated dynamically by the test suite. + name: "tls-checks-ca-certificate" + hostname: "abc.example.com" +--- +apiVersion: v1 +kind: Service +metadata: + name: tls-backend-with-client-cert-validation + namespace: gateway-conformance-infra +spec: + selector: + app: tls-backend-with-client-cert-validation + ports: + - name: "https" + protocol: TCP + port: 443 + targetPort: 8443 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tls-backend-with-client-cert-validation + namespace: gateway-conformance-infra + labels: + app: tls-backend-with-client-cert-validation +spec: + replicas: 1 + selector: + matchLabels: + app: tls-backend-with-client-cert-validation + template: + metadata: + labels: + app: tls-backend-with-client-cert-validation + spec: + containers: + - name: tls-backend-with-client-cert-validation + image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd + volumeMounts: + - name: secret-volume + mountPath: /etc/secret-volume + - name: configmap-volume + mountPath: /etc/configmap-volume + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: TLS_SERVER_CERT + value: /etc/secret-volume/crt + - name: TLS_SERVER_PRIVKEY + value: /etc/secret-volume/key + - name: TLS_CLIENT_CACERTS + value: /etc/configmap-volume/ca + resources: + requests: + cpu: 10m + volumes: + - name: secret-volume + secret: + secretName: tls-checks-certificate + items: + - key: tls.crt + path: crt + - key: tls.key + path: key + - name: configmap-volume + configMap: + name: tls-checks-ca-certificate + items: + - key: ca.crt + path: ca diff --git a/conformance/utils/http/http.go b/conformance/utils/http/http.go index 2d116423c1..2f2c4f3e9d 100644 --- a/conformance/utils/http/http.go +++ b/conformance/utils/http/http.go @@ -78,6 +78,7 @@ type Request struct { Protocol string Body string SNI string + ClientCert string } // ExpectedRequest defines expected properties of a request that reaches a backend. @@ -415,6 +416,12 @@ func CompareRoundTrip(t *testing.T, req *roundtripper.Request, cReq *roundtrippe if expected.ExpectedRequest.SNI != "" && expected.ExpectedRequest.SNI != cReq.TLS.ServerName { return fmt.Errorf("expected SNI %q to be equal to %q", cReq.TLS.ServerName, expected.ExpectedRequest.SNI) } + + if expected.ExpectedRequest.ClientCert != "" { + if !slices.Contains(cReq.TLS.PeerCertificates, expected.ExpectedRequest.ClientCert) { + return fmt.Errorf("expected client certiifcate was not captured") + } + } } else if roundtripper.IsRedirect(cRes.StatusCode) { if expected.RedirectRequest == nil { return nil diff --git a/conformance/utils/kubernetes/certificate.go b/conformance/utils/kubernetes/certificate.go index 8d9b935793..b4625d52dd 100644 --- a/conformance/utils/kubernetes/certificate.go +++ b/conformance/utils/kubernetes/certificate.go @@ -49,7 +49,7 @@ func MustCreateSelfSignedCertSecret(t *testing.T, namespace, secretName string, var serverKey, serverCert bytes.Buffer - require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, nil, nil), "failed to generate RSA certificate") + require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, nil, nil), "failed to generate RSA certificate") return formatSecret(serverCert, serverKey, namespace, secretName) } @@ -60,7 +60,16 @@ func MustCreateCASignedCertSecret(t *testing.T, namespace, secretName string, ho var serverCert, serverKey bytes.Buffer - require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, ca, caPrivKey), "failed to generate CA signed RSA certificate") + require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, ca, caPrivKey), "failed to generate CA signed RSA certificate") + + return formatSecret(serverCert, serverKey, namespace, secretName) +} + +// MustCreateCASignedClientCertSecret creates a CA-signed SSL certificate and stores it in a secret +func MustCreateCASignedClientCertSecret(t *testing.T, namespace, secretName string, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) *corev1.Secret { + var serverCert, serverKey bytes.Buffer + + require.NoError(t, generateRSACert([]string{}, &serverKey, &serverCert, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, ca, caPrivKey), "failed to generate CA signed RSA client certificate") return formatSecret(serverCert, serverKey, namespace, secretName) } @@ -87,7 +96,7 @@ func formatSecret(serverCert bytes.Buffer, serverKey bytes.Buffer, namespace str // generateRSACert generates a basic self-signed certificate if ca and caPrivKey are nil, // otherwise it creates CA-signed cert with ca and caPrivkey input. Certs are valid for a year. -func generateRSACert(hosts []string, keyOut, certOut io.Writer, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) error { +func generateRSACert(hosts []string, keyOut, certOut io.Writer, extKeyUsage []x509.ExtKeyUsage, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) error { priv, err := rsa.GenerateKey(rand.Reader, rsaBits) if err != nil { return fmt.Errorf("failed to generate key: %w", err) @@ -111,7 +120,7 @@ func generateRSACert(hosts []string, keyOut, certOut io.Writer, ca *x509.Certifi NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + ExtKeyUsage: extKeyUsage, BasicConstraintsValid: true, } diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index d37d16affa..359b7b317b 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -1026,6 +1026,14 @@ func BackendTLSPolicyMustHaveCondition(t *testing.T, client client.Client, timeo require.NoErrorf(t, waitErr, "error waiting for BackendTLSPolicy %v status to have a Condition %v", policyNN, condition) } +func BackendTLSPolicyMustHaveAcceptedConditionTrue(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, policyNN, gwNN types.NamespacedName) { + BackendTLSPolicyMustHaveCondition(t, client, timeoutConfig, policyNN, gwNN, metav1.Condition{ + Type: string(gatewayv1.PolicyConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.PolicyReasonAccepted), + }) +} + // BackendTLSPolicyMustHaveLatestConditions will fail the test if there are // conditions that were not updated func BackendTLSPolicyMustHaveLatestConditions(t *testing.T, r *gatewayv1.BackendTLSPolicy) { diff --git a/conformance/utils/roundtripper/roundtripper.go b/conformance/utils/roundtripper/roundtripper.go index 9490b648e2..f53e40af42 100644 --- a/conformance/utils/roundtripper/roundtripper.go +++ b/conformance/utils/roundtripper/roundtripper.go @@ -92,10 +92,11 @@ type CapturedRequest struct { } type TLS struct { - Version string `json:"version"` - ServerName string `json:"serverName"` - NegotiatedProtocol string `json:"negotiatedProtocol"` - CipherSuite string `json:"cipherSuite"` + Version string `json:"version"` + ServerName string `json:"serverName"` + NegotiatedProtocol string `json:"negotiatedProtocol"` + CipherSuite string `json:"cipherSuite"` + PeerCertificates []string `json:"peerCertificates"` } // RedirectRequest contains a follow up request metadata captured from a redirect diff --git a/conformance/utils/suite/suite.go b/conformance/utils/suite/suite.go index 5df2c722d9..6be659f287 100644 --- a/conformance/utils/suite/suite.go +++ b/conformance/utils/suite/suite.go @@ -383,6 +383,8 @@ func (suite *ConformanceTestSuite) Setup(t *testing.T, tests []ConformanceTest) suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{caConfigMap}, suite.Cleanup) secret = kubernetes.MustCreateCASignedCertSecret(t, "gateway-conformance-infra", "tls-checks-certificate", []string{"abc.example.com", "spiffe://abc.example.com/test-identity", "other.example.com"}, ca, caPrivKey) suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{secret}, suite.Cleanup) + secret = kubernetes.MustCreateCASignedClientCertSecret(t, "gateway-conformance-infra", "tls-checks-client-certificate", ca, caPrivKey) + suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{secret}, suite.Cleanup) // The following CA ceritficate is used for BackendTLSPolicy testing to intentionally force TLS validation to fail. caConfigMap, _, _ = kubernetes.MustCreateCACertConfigMap(t, "gateway-conformance-infra", "mismatch-ca-certificate") diff --git a/pkg/features/gateway.go b/pkg/features/gateway.go index e6cf715f7e..6bbd6895e2 100644 --- a/pkg/features/gateway.go +++ b/pkg/features/gateway.go @@ -57,13 +57,17 @@ const ( // of HTTP listeners. SupportGatewayHTTPListenerIsolation FeatureName = "GatewayHTTPListenerIsolation" - // SupportGatewayInfrastructureAnnotations option indicates support for + // SupportGatewayInfrastructurePropagation option indicates support for // spec.infrastructure.annotations and spec.infrastructure.labels SupportGatewayInfrastructurePropagation FeatureName = "GatewayInfrastructurePropagation" - // SupportGatewayAddressEmpty option indicates support for an empty - // spec.addresses.value field + // SupportGatewayAddressEmpty option indicates support for an empty + // spec.addresses.value field SupportGatewayAddressEmpty FeatureName = "GatewayAddressEmpty" + + // SupportGatewayTLSBackendClientCertificate option indicates support for + // specifying client certificates, which will be sent to the backend. + SupportGatewayTLSBackendClientCertificate = "GatewayTLSBackendClientCertificate" ) var ( @@ -87,11 +91,17 @@ var ( Name: SupportGatewayInfrastructurePropagation, Channel: FeatureChannelStandard, } - // GatewayAddressEmptyFeature contains metadata for the SupportGatewayAddressEmpty feature. + // GatewayEmptyAddressFeature contains metadata for the SupportGatewayAddressEmpty feature. GatewayEmptyAddressFeature = Feature{ Name: SupportGatewayAddressEmpty, Channel: FeatureChannelStandard, } + + // GatewayTLSBackendClientCertificate contains metadata for the SupportGatewayTLSBackendClientCertificate feature. + GatewayTLSBackendClientCertificate = Feature{ + Name: SupportGatewayTLSBackendClientCertificate, + Channel: FeatureChannelExperimental, + } ) // GatewayExtendedFeatures are extra generic features that implementations may @@ -102,4 +112,5 @@ var GatewayExtendedFeatures = sets.New( GatewayHTTPListenerIsolationFeature, GatewayInfrastructurePropagationFeature, GatewayEmptyAddressFeature, + GatewayTLSBackendClientCertificate, )