Skip to content

Commit cf6f6cf

Browse files
authored
conformance: normative test for backend client certificate in Gateway (#4119)
* conformance: normative test for backend client certificate in Gateway * add empty line * refactor ceritifcate conformance test utils Signed-off-by: Norwin Schnyder <norwin.schnyder+github@gmail.com> --------- Signed-off-by: Norwin Schnyder <norwin.schnyder+github@gmail.com>
1 parent 9948587 commit cf6f6cf

File tree

8 files changed

+263
-16
lines changed

8 files changed

+263
-16
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package tests
18+
19+
import (
20+
"testing"
21+
22+
"k8s.io/apimachinery/pkg/types"
23+
24+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
25+
h "sigs.k8s.io/gateway-api/conformance/utils/http"
26+
"sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
27+
"sigs.k8s.io/gateway-api/conformance/utils/suite"
28+
"sigs.k8s.io/gateway-api/pkg/features"
29+
)
30+
31+
func init() {
32+
ConformanceTests = append(ConformanceTests, GatewayTLSBackendClientCertificate)
33+
}
34+
35+
var GatewayTLSBackendClientCertificate = suite.ConformanceTest{
36+
ShortName: "GatewayTLSBackendClientCertificate",
37+
Description: "A Gateway with a client certificate configured should present the certificate when connecting to a backend using TLS.",
38+
Features: []features.FeatureName{
39+
features.SupportGateway,
40+
features.SupportGatewayTLSBackendClientCertificate,
41+
features.SupportHTTPRoute,
42+
features.SupportBackendTLSPolicy,
43+
},
44+
Manifests: []string{"tests/gateway-tls-backend-client-certificate.yaml"},
45+
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
46+
ns := "gateway-conformance-infra"
47+
48+
routeNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate", Namespace: ns}
49+
gwNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate", Namespace: ns}
50+
policyNN := types.NamespacedName{Name: "gateway-tls-backend-client-certificate-test", Namespace: ns}
51+
52+
kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns})
53+
gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gatewayv1.HTTPRoute{}, false, routeNN)
54+
kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN)
55+
kubernetes.BackendTLSPolicyMustHaveAcceptedConditionTrue(t, suite.Client, suite.TimeoutConfig, policyNN, gwNN)
56+
57+
t.Run("HTTP request sent to Service using TLS should succeed and the configured client certificate should be presented.", func(t *testing.T) {
58+
expectedClientCert, _, err := GetTLSSecret(suite.Client, types.NamespacedName{Name: "tls-checks-client-certificate", Namespace: ns})
59+
if err != nil {
60+
t.Fatalf("unexpected error finding TLS client certifcate secret: %v", err)
61+
}
62+
63+
h.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr,
64+
h.ExpectedResponse{
65+
Namespace: ns,
66+
Request: h.Request{
67+
Path: "/",
68+
Host: "abc.example.com",
69+
SNI: "abc.example.com",
70+
ClientCert: string(expectedClientCert),
71+
},
72+
Response: h.Response{StatusCodes: []int{200}},
73+
})
74+
})
75+
},
76+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
apiVersion: gateway.networking.k8s.io/v1
2+
kind: Gateway
3+
metadata:
4+
name: gateway-tls-backend-client-certificate
5+
namespace: gateway-conformance-infra
6+
spec:
7+
gatewayClassName: "{GATEWAY_CLASS_NAME}"
8+
listeners:
9+
- name: http
10+
port: 80
11+
protocol: HTTP
12+
allowedRoutes:
13+
namespaces:
14+
from: Same
15+
tls:
16+
backend:
17+
clientCertificateRef:
18+
group: ""
19+
kind: Secret
20+
name: tls-checks-client-certificate
21+
---
22+
apiVersion: gateway.networking.k8s.io/v1
23+
kind: HTTPRoute
24+
metadata:
25+
name: gateway-tls-backend-client-certificate
26+
namespace: gateway-conformance-infra
27+
spec:
28+
parentRefs:
29+
- name: gateway-tls-backend-client-certificate
30+
namespace: gateway-conformance-infra
31+
hostnames:
32+
- abc.example.com
33+
rules:
34+
- backendRefs:
35+
- group: ""
36+
kind: Service
37+
name: tls-backend-with-client-cert-validation
38+
port: 443
39+
matches:
40+
- path:
41+
type: Exact
42+
value: /
43+
---
44+
apiVersion: gateway.networking.k8s.io/v1
45+
kind: BackendTLSPolicy
46+
metadata:
47+
name: gateway-tls-backend-client-certificate-test
48+
namespace: gateway-conformance-infra
49+
spec:
50+
targetRefs:
51+
- group: ""
52+
kind: Service
53+
name: tls-backend-with-client-cert-validation
54+
validation:
55+
caCertificateRefs:
56+
- group: ""
57+
kind: ConfigMap
58+
# This ConfigMap is generated dynamically by the test suite.
59+
name: "tls-checks-ca-certificate"
60+
hostname: "abc.example.com"
61+
---
62+
apiVersion: v1
63+
kind: Service
64+
metadata:
65+
name: tls-backend-with-client-cert-validation
66+
namespace: gateway-conformance-infra
67+
spec:
68+
selector:
69+
app: tls-backend-with-client-cert-validation
70+
ports:
71+
- name: "https"
72+
protocol: TCP
73+
port: 443
74+
targetPort: 8443
75+
---
76+
apiVersion: apps/v1
77+
kind: Deployment
78+
metadata:
79+
name: tls-backend-with-client-cert-validation
80+
namespace: gateway-conformance-infra
81+
labels:
82+
app: tls-backend-with-client-cert-validation
83+
spec:
84+
replicas: 1
85+
selector:
86+
matchLabels:
87+
app: tls-backend-with-client-cert-validation
88+
template:
89+
metadata:
90+
labels:
91+
app: tls-backend-with-client-cert-validation
92+
spec:
93+
containers:
94+
- name: tls-backend-with-client-cert-validation
95+
image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd
96+
volumeMounts:
97+
- name: secret-volume
98+
mountPath: /etc/secret-volume
99+
- name: configmap-volume
100+
mountPath: /etc/configmap-volume
101+
env:
102+
- name: POD_NAME
103+
valueFrom:
104+
fieldRef:
105+
fieldPath: metadata.name
106+
- name: NAMESPACE
107+
valueFrom:
108+
fieldRef:
109+
fieldPath: metadata.namespace
110+
- name: TLS_SERVER_CERT
111+
value: /etc/secret-volume/crt
112+
- name: TLS_SERVER_PRIVKEY
113+
value: /etc/secret-volume/key
114+
- name: TLS_CLIENT_CACERTS
115+
value: /etc/configmap-volume/ca
116+
resources:
117+
requests:
118+
cpu: 10m
119+
volumes:
120+
- name: secret-volume
121+
secret:
122+
secretName: tls-checks-certificate
123+
items:
124+
- key: tls.crt
125+
path: crt
126+
- key: tls.key
127+
path: key
128+
- name: configmap-volume
129+
configMap:
130+
name: tls-checks-ca-certificate
131+
items:
132+
- key: ca.crt
133+
path: ca

conformance/utils/http/http.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ type Request struct {
7878
Protocol string
7979
Body string
8080
SNI string
81+
ClientCert string
8182
}
8283

8384
// ExpectedRequest defines expected properties of a request that reaches a backend.
@@ -421,6 +422,12 @@ func CompareRoundTrip(t *testing.T, req *roundtripper.Request, cReq *roundtrippe
421422
if expected.ExpectedRequest.SNI != "" && expected.ExpectedRequest.SNI != cReq.TLS.ServerName {
422423
return fmt.Errorf("expected SNI %q to be equal to %q", cReq.TLS.ServerName, expected.ExpectedRequest.SNI)
423424
}
425+
426+
if expected.ExpectedRequest.ClientCert != "" {
427+
if !slices.Contains(cReq.TLS.PeerCertificates, expected.ExpectedRequest.ClientCert) {
428+
return fmt.Errorf("expected client certiifcate was not captured")
429+
}
430+
}
424431
} else if roundtripper.IsRedirect(cRes.StatusCode) {
425432
if expected.RedirectRequest == nil {
426433
return nil

conformance/utils/kubernetes/certificate.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func MustCreateSelfSignedCertSecret(t *testing.T, namespace, secretName string,
4949

5050
var serverKey, serverCert bytes.Buffer
5151

52-
require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, nil, nil), "failed to generate RSA certificate")
52+
require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, nil, nil), "failed to generate RSA certificate")
5353

5454
return formatSecret(serverCert, serverKey, namespace, secretName)
5555
}
@@ -60,17 +60,26 @@ func MustCreateCASignedCertSecret(t *testing.T, namespace, secretName string, ho
6060

6161
var serverCert, serverKey bytes.Buffer
6262

63-
require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, ca, caPrivKey), "failed to generate CA signed RSA certificate")
63+
require.NoError(t, generateRSACert(hosts, &serverKey, &serverCert, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, ca, caPrivKey), "failed to generate CA signed RSA certificate")
6464

6565
return formatSecret(serverCert, serverKey, namespace, secretName)
6666
}
6767

68-
// formatSecret formats the server certificate, key, namespace, and secretName
68+
// MustCreateCASignedClientCertSecret creates a CA-signed SSL client certificate and stores it in a secret
69+
func MustCreateCASignedClientCertSecret(t *testing.T, namespace, secretName string, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) *corev1.Secret {
70+
var clientCert, clientKey bytes.Buffer
71+
72+
require.NoError(t, generateRSACert([]string{}, &clientKey, &clientCert, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, ca, caPrivKey), "failed to generate CA signed RSA client certificate")
73+
74+
return formatSecret(clientCert, clientKey, namespace, secretName)
75+
}
76+
77+
// formatSecret formats the certificate, key, namespace, and secretName
6978
// and converts it to a Kubernetes Secret object.
70-
func formatSecret(serverCert bytes.Buffer, serverKey bytes.Buffer, namespace string, secretName string) *corev1.Secret {
79+
func formatSecret(cert bytes.Buffer, privateKey bytes.Buffer, namespace string, secretName string) *corev1.Secret {
7180
data := map[string][]byte{
72-
corev1.TLSCertKey: serverCert.Bytes(),
73-
corev1.TLSPrivateKeyKey: serverKey.Bytes(),
81+
corev1.TLSCertKey: cert.Bytes(),
82+
corev1.TLSPrivateKeyKey: privateKey.Bytes(),
7483
}
7584

7685
newSecret := &corev1.Secret{
@@ -87,7 +96,7 @@ func formatSecret(serverCert bytes.Buffer, serverKey bytes.Buffer, namespace str
8796

8897
// generateRSACert generates a basic self-signed certificate if ca and caPrivKey are nil,
8998
// otherwise it creates CA-signed cert with ca and caPrivkey input. Certs are valid for a year.
90-
func generateRSACert(hosts []string, keyOut, certOut io.Writer, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) error {
99+
func generateRSACert(hosts []string, keyOut, certOut io.Writer, extKeyUsage []x509.ExtKeyUsage, ca *x509.Certificate, caPrivKey *rsa.PrivateKey) error {
91100
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
92101
if err != nil {
93102
return fmt.Errorf("failed to generate key: %w", err)
@@ -111,7 +120,7 @@ func generateRSACert(hosts []string, keyOut, certOut io.Writer, ca *x509.Certifi
111120
NotAfter: notAfter,
112121

113122
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
114-
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
123+
ExtKeyUsage: extKeyUsage,
115124
BasicConstraintsValid: true,
116125
}
117126

conformance/utils/kubernetes/helpers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,14 @@ func BackendTLSPolicyMustHaveCondition(t *testing.T, client client.Client, timeo
10261026
require.NoErrorf(t, waitErr, "error waiting for BackendTLSPolicy %v status to have a Condition %v", policyNN, condition)
10271027
}
10281028

1029+
func BackendTLSPolicyMustHaveAcceptedConditionTrue(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, policyNN, gwNN types.NamespacedName) {
1030+
BackendTLSPolicyMustHaveCondition(t, client, timeoutConfig, policyNN, gwNN, metav1.Condition{
1031+
Type: string(gatewayv1.PolicyConditionAccepted),
1032+
Status: metav1.ConditionTrue,
1033+
Reason: string(gatewayv1.PolicyReasonAccepted),
1034+
})
1035+
}
1036+
10291037
// BackendTLSPolicyMustHaveLatestConditions will fail the test if there are
10301038
// conditions that were not updated
10311039
func BackendTLSPolicyMustHaveLatestConditions(t *testing.T, r *gatewayv1.BackendTLSPolicy) {

conformance/utils/roundtripper/roundtripper.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ type CapturedRequest struct {
9393
}
9494

9595
type TLS struct {
96-
Version string `json:"version"`
97-
ServerName string `json:"serverName"`
98-
NegotiatedProtocol string `json:"negotiatedProtocol"`
99-
CipherSuite string `json:"cipherSuite"`
96+
Version string `json:"version"`
97+
ServerName string `json:"serverName"`
98+
NegotiatedProtocol string `json:"negotiatedProtocol"`
99+
CipherSuite string `json:"cipherSuite"`
100+
PeerCertificates []string `json:"peerCertificates"`
100101
}
101102

102103
// RedirectRequest contains a follow up request metadata captured from a redirect

conformance/utils/suite/suite.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,8 @@ func (suite *ConformanceTestSuite) Setup(t *testing.T, tests []ConformanceTest)
383383
suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{caConfigMap}, suite.Cleanup)
384384
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)
385385
suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{secret}, suite.Cleanup)
386+
secret = kubernetes.MustCreateCASignedClientCertSecret(t, "gateway-conformance-infra", "tls-checks-client-certificate", ca, caPrivKey)
387+
suite.Applier.MustApplyObjectsWithCleanup(t, suite.Client, suite.TimeoutConfig, []client.Object{secret}, suite.Cleanup)
386388

387389
// The following CA ceritficate is used for BackendTLSPolicy testing to intentionally force TLS validation to fail.
388390
caConfigMap, _, _ = kubernetes.MustCreateCACertConfigMap(t, "gateway-conformance-infra", "mismatch-ca-certificate")

pkg/features/gateway.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,17 @@ const (
5757
// of HTTP listeners.
5858
SupportGatewayHTTPListenerIsolation FeatureName = "GatewayHTTPListenerIsolation"
5959

60-
// SupportGatewayInfrastructureAnnotations option indicates support for
60+
// SupportGatewayInfrastructurePropagation option indicates support for
6161
// spec.infrastructure.annotations and spec.infrastructure.labels
6262
SupportGatewayInfrastructurePropagation FeatureName = "GatewayInfrastructurePropagation"
6363

64-
// SupportGatewayAddressEmpty option indicates support for an empty
65-
// spec.addresses.value field
64+
// SupportGatewayAddressEmpty option indicates support for an empty
65+
// spec.addresses.value field
6666
SupportGatewayAddressEmpty FeatureName = "GatewayAddressEmpty"
67+
68+
// SupportGatewayTLSBackendClientCertificate option indicates support for
69+
// specifying client certificates, which will be sent to the backend.
70+
SupportGatewayTLSBackendClientCertificate = "GatewayTLSBackendClientCertificate"
6771
)
6872

6973
var (
@@ -87,11 +91,17 @@ var (
8791
Name: SupportGatewayInfrastructurePropagation,
8892
Channel: FeatureChannelStandard,
8993
}
90-
// GatewayAddressEmptyFeature contains metadata for the SupportGatewayAddressEmpty feature.
94+
// GatewayEmptyAddressFeature contains metadata for the SupportGatewayAddressEmpty feature.
9195
GatewayEmptyAddressFeature = Feature{
9296
Name: SupportGatewayAddressEmpty,
9397
Channel: FeatureChannelStandard,
9498
}
99+
100+
// GatewayTLSBackendClientCertificate contains metadata for the SupportGatewayTLSBackendClientCertificate feature.
101+
GatewayTLSBackendClientCertificate = Feature{
102+
Name: SupportGatewayTLSBackendClientCertificate,
103+
Channel: FeatureChannelExperimental,
104+
}
95105
)
96106

97107
// GatewayExtendedFeatures are extra generic features that implementations may
@@ -102,4 +112,5 @@ var GatewayExtendedFeatures = sets.New(
102112
GatewayHTTPListenerIsolationFeature,
103113
GatewayInfrastructurePropagationFeature,
104114
GatewayEmptyAddressFeature,
115+
GatewayTLSBackendClientCertificate,
105116
)

0 commit comments

Comments
 (0)