Skip to content

Commit a3d4540

Browse files
authored
feat: support downstream mTLS via Gateway API frontendValidation (#424)
1 parent 3caa40c commit a3d4540

13 files changed

Lines changed: 658 additions & 3 deletions

File tree

config/rbac/role.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ rules:
77
- apiGroups:
88
- ""
99
resources:
10+
- configmaps
1011
- endpoints
1112
- namespaces
1213
- pods

docs/en/latest/concepts/gateway-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,5 @@ The fields below are specified in the Gateway API specification but are either p
8282
| `spec.listeners[].tls.certificateRefs[].group` | Partially supported | Only `""` is supported; other group values cause validation failure. |
8383
| `spec.listeners[].tls.certificateRefs[].kind` | Partially supported | Only `Secret` is supported. |
8484
| `spec.listeners[].tls.mode` | Partially supported | `Terminate` is implemented; `Passthrough` is effectively unsupported for Gateway listeners. |
85+
| `spec.listeners[].tls.frontendValidation` | Partially supported | Enables downstream (client) mTLS. `caCertificateRefs` may reference a `ConfigMap` (Gateway API Core support) or a `Secret` (implementation-specific) holding the CA certificate under the `ca.crt` key; clients are then required to present a certificate signed by one of the referenced CAs. |
8586
| `spec.addresses` | Not supported | Controller does not read or act on `spec.addresses`. |

internal/adc/translator/gateway.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ package translator
2020
import (
2121
"encoding/json"
2222
"fmt"
23+
"strings"
2324

2425
"github.com/pkg/errors"
26+
corev1 "k8s.io/api/core/v1"
2527
"k8s.io/apimachinery/pkg/types"
2628
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
2729

@@ -76,6 +78,12 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g
7678
sslObjs := make([]*adctypes.SSL, 0)
7779
switch *listener.TLS.Mode {
7880
case gatewayv1.TLSModeTerminate:
81+
// frontendValidation configures downstream mTLS: clients must present a
82+
// certificate signed by one of the referenced CAs during the TLS handshake.
83+
client, err := t.translateFrontendValidation(tctx, listener, obj)
84+
if err != nil {
85+
return nil, err
86+
}
7987
for refIndex, ref := range listener.TLS.CertificateRefs {
8088
ns := obj.GetNamespace()
8189
if ref.Namespace != nil {
@@ -118,6 +126,7 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g
118126
}
119127
sslObj.Snis = append(sslObj.Snis, hosts...)
120128
}
129+
sslObj.Client = client
121130
sslObj.ID = id.GenID(fmt.Sprintf("%s_%s_%d", adctypes.ComposeSSLName(internaltypes.KindGateway, obj.Namespace, obj.Name), listener.Name, refIndex))
122131
t.Log.V(1).Info("generated ssl id", "ssl id", sslObj.ID, "secret", secretNN.String())
123132
sslObj.Labels = label.GenLabel(obj)
@@ -135,6 +144,65 @@ func (t *Translator) translateSecret(tctx *provider.TranslateContext, listener g
135144
return sslObjs, nil
136145
}
137146

147+
// translateFrontendValidation builds the downstream mTLS client configuration from a
148+
// listener's frontendValidation. The referenced CA certificates (ConfigMap, key `ca.crt`)
149+
// are bundled into a single trust anchor used to validate client certificates.
150+
func (t *Translator) translateFrontendValidation(tctx *provider.TranslateContext, listener gatewayv1.Listener, obj *gatewayv1.Gateway) (*adctypes.ClientClass, error) {
151+
if listener.TLS.FrontendValidation == nil || len(listener.TLS.FrontendValidation.CACertificateRefs) == 0 {
152+
return nil, nil
153+
}
154+
155+
cas := make([]string, 0, len(listener.TLS.FrontendValidation.CACertificateRefs))
156+
for _, ref := range listener.TLS.FrontendValidation.CACertificateRefs {
157+
// caCertificateRefs must be in the core API group. ConfigMap is the
158+
// Gateway API Core support; Secret is an implementation-specific extension.
159+
if ref.Group != "" && string(ref.Group) != corev1.GroupName {
160+
return nil, fmt.Errorf("unsupported frontendValidation caCertificateRef group %q in listener %s, only the core group is supported", ref.Group, listener.Name)
161+
}
162+
ns := obj.GetNamespace()
163+
if ref.Namespace != nil {
164+
ns = string(*ref.Namespace)
165+
}
166+
nn := types.NamespacedName{Namespace: ns, Name: string(ref.Name)}
167+
168+
kind := internaltypes.KindConfigMap
169+
if ref.Kind != "" {
170+
kind = string(ref.Kind)
171+
}
172+
var (
173+
ca []byte
174+
err error
175+
)
176+
switch kind {
177+
case internaltypes.KindConfigMap:
178+
cm := tctx.ConfigMaps[nn]
179+
if cm == nil {
180+
return nil, fmt.Errorf("frontendValidation CA ConfigMap %s not found", nn.String())
181+
}
182+
if ca, err = sslutils.ExtractCAFromConfigMap(cm); err != nil {
183+
t.Log.Error(err, "failed to extract CA from configmap", "configmap", nn.String())
184+
return nil, fmt.Errorf("failed to extract CA from ConfigMap %s: %w", nn.String(), err)
185+
}
186+
case internaltypes.KindSecret:
187+
secret := tctx.Secrets[nn]
188+
if secret == nil {
189+
return nil, fmt.Errorf("frontendValidation CA Secret %s not found", nn.String())
190+
}
191+
if ca, err = sslutils.ExtractCAFromSecret(secret); err != nil {
192+
t.Log.Error(err, "failed to extract CA from secret", "secret", nn.String())
193+
return nil, fmt.Errorf("failed to extract CA from Secret %s: %w", nn.String(), err)
194+
}
195+
default:
196+
return nil, fmt.Errorf("unsupported frontendValidation caCertificateRef kind %q in listener %s, only ConfigMap and Secret are supported", ref.Kind, listener.Name)
197+
}
198+
cas = append(cas, strings.TrimSpace(string(ca)))
199+
}
200+
201+
return &adctypes.ClientClass{
202+
CA: strings.Join(cas, "\n"),
203+
}, nil
204+
}
205+
138206
// fillPluginsFromGatewayProxy fill plugins from GatewayProxy to given plugins
139207
func (t *Translator) fillPluginsFromGatewayProxy(plugins adctypes.GlobalRule, gatewayProxy *v1alpha1.GatewayProxy) {
140208
if gatewayProxy == nil {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package translator
19+
20+
import (
21+
"context"
22+
"testing"
23+
24+
"github.com/go-logr/logr"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
corev1 "k8s.io/api/core/v1"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/types"
30+
"k8s.io/utils/ptr"
31+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
32+
33+
"github.com/apache/apisix-ingress-controller/internal/provider"
34+
)
35+
36+
const testCACert = `-----BEGIN CERTIFICATE-----
37+
MIIBQzCB6qADAgECAgEBMAoGCCqGSM49BAMCMBIxEDAOBgNVBAMTB3Rlc3QtY2Ew
38+
HhcNNzAwMTAxMDAwMDAwWhcNMzgwMTE5MDMxNDA4WjASMRAwDgYDVQQDEwd0ZXN0
39+
LWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJo4AsM30ZHN+mYeHjqwceGBz
40+
V2bMz1+OyNXuaPYVrSF7HShZhanOYNHb6QLNhjGxMsBDQHVLolPjyTQJp9R5GqMx
41+
MC8wDgYDVR0PAQH/BAQDAgIEMB0GA1UdDgQWBBRzjh0YVmnpN/cFJziO0aYySuti
42+
4DAKBggqhkjOPQQDAgNIADBFAiEA7fEGiQA7wX0LrrkRH4KplAPOgVV5Kvm/1dv1
43+
3TLq9ssCIHKkv2dhydRvv36KC1WsRDcrl7W+7YmEnCS9PZfb8agM
44+
-----END CERTIFICATE-----`
45+
46+
func newTLSGateway(frontendValidation *gatewayv1.FrontendTLSValidation) *gatewayv1.Gateway {
47+
return &gatewayv1.Gateway{
48+
ObjectMeta: metav1.ObjectMeta{
49+
Namespace: "default",
50+
Name: "gw",
51+
},
52+
Spec: gatewayv1.GatewaySpec{
53+
Listeners: []gatewayv1.Listener{
54+
{
55+
Name: "https",
56+
Hostname: ptr.To(gatewayv1.Hostname("example.com")),
57+
TLS: &gatewayv1.GatewayTLSConfig{
58+
Mode: ptr.To(gatewayv1.TLSModeTerminate),
59+
CertificateRefs: []gatewayv1.SecretObjectReference{
60+
{
61+
Kind: ptr.To(gatewayv1.Kind("Secret")),
62+
Name: gatewayv1.ObjectName("server-cert"),
63+
},
64+
},
65+
FrontendValidation: frontendValidation,
66+
},
67+
},
68+
},
69+
},
70+
}
71+
}
72+
73+
func newTranslateContextWithTLS() *provider.TranslateContext {
74+
tctx := provider.NewDefaultTranslateContext(context.Background())
75+
tctx.Secrets[types.NamespacedName{Namespace: "default", Name: "server-cert"}] = &corev1.Secret{
76+
Data: map[string][]byte{
77+
"cert": []byte("server-cert-data"),
78+
"key": []byte("server-key-data"),
79+
},
80+
}
81+
tctx.ConfigMaps[types.NamespacedName{Namespace: "default", Name: "ca-cm"}] = &corev1.ConfigMap{
82+
Data: map[string]string{
83+
corev1.ServiceAccountRootCAKey: testCACert,
84+
},
85+
}
86+
tctx.Secrets[types.NamespacedName{Namespace: "default", Name: "ca-secret"}] = &corev1.Secret{
87+
Data: map[string][]byte{
88+
corev1.ServiceAccountRootCAKey: []byte(testCACert),
89+
},
90+
}
91+
return tctx
92+
}
93+
94+
func TestTranslateSecret_FrontendValidation(t *testing.T) {
95+
t.Run("with frontendValidation sets downstream mTLS client CA", func(t *testing.T) {
96+
tr := &Translator{Log: logr.Discard()}
97+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
98+
CACertificateRefs: []gatewayv1.ObjectReference{
99+
{
100+
Group: "",
101+
Kind: "ConfigMap",
102+
Name: "ca-cm",
103+
},
104+
},
105+
})
106+
tctx := newTranslateContextWithTLS()
107+
108+
sslObjs, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
109+
require.NoError(t, err)
110+
require.Len(t, sslObjs, 1)
111+
require.NotNil(t, sslObjs[0].Client, "client mTLS config should be set")
112+
assert.Equal(t, testCACert, sslObjs[0].Client.CA)
113+
assert.Equal(t, []string{"example.com"}, sslObjs[0].Snis)
114+
})
115+
116+
t.Run("with Secret CA ref sets downstream mTLS client CA", func(t *testing.T) {
117+
tr := &Translator{Log: logr.Discard()}
118+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
119+
CACertificateRefs: []gatewayv1.ObjectReference{
120+
{Group: "", Kind: "Secret", Name: "ca-secret"},
121+
},
122+
})
123+
tctx := newTranslateContextWithTLS()
124+
125+
sslObjs, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
126+
require.NoError(t, err)
127+
require.Len(t, sslObjs, 1)
128+
require.NotNil(t, sslObjs[0].Client, "client mTLS config should be set")
129+
assert.Equal(t, testCACert, sslObjs[0].Client.CA)
130+
})
131+
132+
t.Run("missing CA Secret returns error", func(t *testing.T) {
133+
tr := &Translator{Log: logr.Discard()}
134+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
135+
CACertificateRefs: []gatewayv1.ObjectReference{
136+
{Kind: "Secret", Name: "missing"},
137+
},
138+
})
139+
tctx := newTranslateContextWithTLS()
140+
141+
_, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
142+
require.Error(t, err)
143+
})
144+
145+
t.Run("without frontendValidation leaves client nil", func(t *testing.T) {
146+
tr := &Translator{Log: logr.Discard()}
147+
gateway := newTLSGateway(nil)
148+
tctx := newTranslateContextWithTLS()
149+
150+
sslObjs, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
151+
require.NoError(t, err)
152+
require.Len(t, sslObjs, 1)
153+
assert.Nil(t, sslObjs[0].Client)
154+
})
155+
156+
t.Run("missing CA ConfigMap returns error", func(t *testing.T) {
157+
tr := &Translator{Log: logr.Discard()}
158+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
159+
CACertificateRefs: []gatewayv1.ObjectReference{
160+
{Kind: "ConfigMap", Name: "missing"},
161+
},
162+
})
163+
tctx := newTranslateContextWithTLS()
164+
165+
_, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
166+
require.Error(t, err)
167+
})
168+
169+
t.Run("unsupported CA ref kind returns error", func(t *testing.T) {
170+
tr := &Translator{Log: logr.Discard()}
171+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
172+
CACertificateRefs: []gatewayv1.ObjectReference{
173+
{Kind: "Pod", Name: "ca-cm"},
174+
},
175+
})
176+
tctx := newTranslateContextWithTLS()
177+
178+
_, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
179+
require.Error(t, err)
180+
})
181+
182+
t.Run("unsupported CA ref group returns error", func(t *testing.T) {
183+
tr := &Translator{Log: logr.Discard()}
184+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
185+
CACertificateRefs: []gatewayv1.ObjectReference{
186+
{Group: "example.com", Kind: "ConfigMap", Name: "ca-cm"},
187+
},
188+
})
189+
tctx := newTranslateContextWithTLS()
190+
191+
_, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
192+
require.Error(t, err)
193+
})
194+
195+
t.Run("malformed CA data returns error", func(t *testing.T) {
196+
tr := &Translator{Log: logr.Discard()}
197+
gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
198+
CACertificateRefs: []gatewayv1.ObjectReference{
199+
{Kind: "ConfigMap", Name: "ca-cm"},
200+
},
201+
})
202+
tctx := newTranslateContextWithTLS()
203+
tctx.ConfigMaps[types.NamespacedName{Namespace: "default", Name: "ca-cm"}] = &corev1.ConfigMap{
204+
Data: map[string]string{corev1.ServiceAccountRootCAKey: " not a pem cert "},
205+
}
206+
207+
_, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0], gateway)
208+
require.Error(t, err)
209+
})
210+
}

0 commit comments

Comments
 (0)