This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new db30aa70 feat: support downstream mTLS via Gateway API
frontendValidation (#2792)
db30aa70 is described below
commit db30aa709f5b3a29b1a1aeb2fec1364158c3a134
Author: AlinsRan <[email protected]>
AuthorDate: Thu Jun 18 08:52:30 2026 +0800
feat: support downstream mTLS via Gateway API frontendValidation (#2792)
---
config/rbac/role.yaml | 1 +
docs/en/latest/concepts/gateway-api.md | 1 +
internal/adc/translator/gateway.go | 68 ++++++++++
internal/adc/translator/gateway_test.go | 210 ++++++++++++++++++++++++++++++
internal/controller/gateway_controller.go | 70 +++++++++-
internal/controller/indexer/indexer.go | 55 +++++++-
internal/controller/utils.go | 88 +++++++++++++
internal/manager/controllers.go | 1 +
internal/provider/provider.go | 2 +
internal/ssl/util.go | 57 ++++++++
internal/types/k8s.go | 1 +
test/e2e/framework/manifests/ingress.yaml | 1 +
test/e2e/gatewayapi/gateway.go | 106 ++++++++++++++-
13 files changed, 658 insertions(+), 3 deletions(-)
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index a883ef90..bc0f3908 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -14,6 +14,7 @@ rules:
- apiGroups:
- ""
resources:
+ - configmaps
- namespaces
- pods
- secrets
diff --git a/docs/en/latest/concepts/gateway-api.md
b/docs/en/latest/concepts/gateway-api.md
index 9b8ad48d..6e4f4049 100644
--- a/docs/en/latest/concepts/gateway-api.md
+++ b/docs/en/latest/concepts/gateway-api.md
@@ -82,4 +82,5 @@ The fields below are specified in the Gateway API
specification but are either p
| `spec.listeners[].tls.certificateRefs[].group` | Partially supported | Only
`""` is supported; other group values cause validation failure. |
| `spec.listeners[].tls.certificateRefs[].kind` | Partially supported
| Only `Secret` is supported.
|
| `spec.listeners[].tls.mode` | Partially supported
| `Terminate` is implemented; `Passthrough` is effectively unsupported for
Gateway listeners. |
+| `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. |
| `spec.addresses` | Not supported
| Controller does not read or act on `spec.addresses`.
|
diff --git a/internal/adc/translator/gateway.go
b/internal/adc/translator/gateway.go
index 53c67144..a5af3738 100644
--- a/internal/adc/translator/gateway.go
+++ b/internal/adc/translator/gateway.go
@@ -20,8 +20,10 @@ package translator
import (
"encoding/json"
"fmt"
+ "strings"
"github.com/pkg/errors"
+ corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -76,6 +78,12 @@ func (t *Translator) translateSecret(tctx
*provider.TranslateContext, listener g
sslObjs := make([]*adctypes.SSL, 0)
switch *listener.TLS.Mode {
case gatewayv1.TLSModeTerminate:
+ // frontendValidation configures downstream mTLS: clients must
present a
+ // certificate signed by one of the referenced CAs during the
TLS handshake.
+ client, err := t.translateFrontendValidation(tctx, listener,
obj)
+ if err != nil {
+ return nil, err
+ }
for refIndex, ref := range listener.TLS.CertificateRefs {
ns := obj.GetNamespace()
if ref.Namespace != nil {
@@ -118,6 +126,7 @@ func (t *Translator) translateSecret(tctx
*provider.TranslateContext, listener g
}
sslObj.Snis = append(sslObj.Snis,
hosts...)
}
+ sslObj.Client = client
sslObj.ID = id.GenID(fmt.Sprintf("%s_%s_%d",
adctypes.ComposeSSLName(internaltypes.KindGateway, obj.Namespace, obj.Name),
listener.Name, refIndex))
t.Log.V(1).Info("generated ssl id", "ssl id",
sslObj.ID, "secret", secretNN.String())
sslObj.Labels = label.GenLabel(obj)
@@ -135,6 +144,65 @@ func (t *Translator) translateSecret(tctx
*provider.TranslateContext, listener g
return sslObjs, nil
}
+// translateFrontendValidation builds the downstream mTLS client configuration
from a
+// listener's frontendValidation. The referenced CA certificates (ConfigMap,
key `ca.crt`)
+// are bundled into a single trust anchor used to validate client certificates.
+func (t *Translator) translateFrontendValidation(tctx
*provider.TranslateContext, listener gatewayv1.Listener, obj
*gatewayv1.Gateway) (*adctypes.ClientClass, error) {
+ if listener.TLS.FrontendValidation == nil ||
len(listener.TLS.FrontendValidation.CACertificateRefs) == 0 {
+ return nil, nil
+ }
+
+ cas := make([]string, 0,
len(listener.TLS.FrontendValidation.CACertificateRefs))
+ for _, ref := range listener.TLS.FrontendValidation.CACertificateRefs {
+ // caCertificateRefs must be in the core API group. ConfigMap
is the
+ // Gateway API Core support; Secret is an
implementation-specific extension.
+ if ref.Group != "" && string(ref.Group) != corev1.GroupName {
+ return nil, fmt.Errorf("unsupported frontendValidation
caCertificateRef group %q in listener %s, only the core group is supported",
ref.Group, listener.Name)
+ }
+ ns := obj.GetNamespace()
+ if ref.Namespace != nil {
+ ns = string(*ref.Namespace)
+ }
+ nn := types.NamespacedName{Namespace: ns, Name:
string(ref.Name)}
+
+ kind := internaltypes.KindConfigMap
+ if ref.Kind != "" {
+ kind = string(ref.Kind)
+ }
+ var (
+ ca []byte
+ err error
+ )
+ switch kind {
+ case internaltypes.KindConfigMap:
+ cm := tctx.ConfigMaps[nn]
+ if cm == nil {
+ return nil, fmt.Errorf("frontendValidation CA
ConfigMap %s not found", nn.String())
+ }
+ if ca, err = sslutils.ExtractCAFromConfigMap(cm); err
!= nil {
+ t.Log.Error(err, "failed to extract CA from
configmap", "configmap", nn.String())
+ return nil, fmt.Errorf("failed to extract CA
from ConfigMap %s: %w", nn.String(), err)
+ }
+ case internaltypes.KindSecret:
+ secret := tctx.Secrets[nn]
+ if secret == nil {
+ return nil, fmt.Errorf("frontendValidation CA
Secret %s not found", nn.String())
+ }
+ if ca, err = sslutils.ExtractCAFromSecret(secret); err
!= nil {
+ t.Log.Error(err, "failed to extract CA from
secret", "secret", nn.String())
+ return nil, fmt.Errorf("failed to extract CA
from Secret %s: %w", nn.String(), err)
+ }
+ default:
+ return nil, fmt.Errorf("unsupported frontendValidation
caCertificateRef kind %q in listener %s, only ConfigMap and Secret are
supported", ref.Kind, listener.Name)
+ }
+ cas = append(cas, strings.TrimSpace(string(ca)))
+ }
+
+ return &adctypes.ClientClass{
+ CA: strings.Join(cas, "\n"),
+ }, nil
+}
+
// fillPluginsFromGatewayProxy fill plugins from GatewayProxy to given plugins
func (t *Translator) fillPluginsFromGatewayProxy(plugins adctypes.GlobalRule,
gatewayProxy *v1alpha1.GatewayProxy) {
if gatewayProxy == nil {
diff --git a/internal/adc/translator/gateway_test.go
b/internal/adc/translator/gateway_test.go
new file mode 100644
index 00000000..4c2c6cae
--- /dev/null
+++ b/internal/adc/translator/gateway_test.go
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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 translator
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/ptr"
+ gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+ "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+const testCACert = `-----BEGIN CERTIFICATE-----
+MIIBQzCB6qADAgECAgEBMAoGCCqGSM49BAMCMBIxEDAOBgNVBAMTB3Rlc3QtY2Ew
+HhcNNzAwMTAxMDAwMDAwWhcNMzgwMTE5MDMxNDA4WjASMRAwDgYDVQQDEwd0ZXN0
+LWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJo4AsM30ZHN+mYeHjqwceGBz
+V2bMz1+OyNXuaPYVrSF7HShZhanOYNHb6QLNhjGxMsBDQHVLolPjyTQJp9R5GqMx
+MC8wDgYDVR0PAQH/BAQDAgIEMB0GA1UdDgQWBBRzjh0YVmnpN/cFJziO0aYySuti
+4DAKBggqhkjOPQQDAgNIADBFAiEA7fEGiQA7wX0LrrkRH4KplAPOgVV5Kvm/1dv1
+3TLq9ssCIHKkv2dhydRvv36KC1WsRDcrl7W+7YmEnCS9PZfb8agM
+-----END CERTIFICATE-----`
+
+func newTLSGateway(frontendValidation *gatewayv1.FrontendTLSValidation)
*gatewayv1.Gateway {
+ return &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: "default",
+ Name: "gw",
+ },
+ Spec: gatewayv1.GatewaySpec{
+ Listeners: []gatewayv1.Listener{
+ {
+ Name: "https",
+ Hostname:
ptr.To(gatewayv1.Hostname("example.com")),
+ TLS: &gatewayv1.GatewayTLSConfig{
+ Mode:
ptr.To(gatewayv1.TLSModeTerminate),
+ CertificateRefs:
[]gatewayv1.SecretObjectReference{
+ {
+ Kind:
ptr.To(gatewayv1.Kind("Secret")),
+ Name:
gatewayv1.ObjectName("server-cert"),
+ },
+ },
+ FrontendValidation:
frontendValidation,
+ },
+ },
+ },
+ },
+ }
+}
+
+func newTranslateContextWithTLS() *provider.TranslateContext {
+ tctx := provider.NewDefaultTranslateContext(context.Background())
+ tctx.Secrets[types.NamespacedName{Namespace: "default", Name:
"server-cert"}] = &corev1.Secret{
+ Data: map[string][]byte{
+ "cert": []byte("server-cert-data"),
+ "key": []byte("server-key-data"),
+ },
+ }
+ tctx.ConfigMaps[types.NamespacedName{Namespace: "default", Name:
"ca-cm"}] = &corev1.ConfigMap{
+ Data: map[string]string{
+ corev1.ServiceAccountRootCAKey: testCACert,
+ },
+ }
+ tctx.Secrets[types.NamespacedName{Namespace: "default", Name:
"ca-secret"}] = &corev1.Secret{
+ Data: map[string][]byte{
+ corev1.ServiceAccountRootCAKey: []byte(testCACert),
+ },
+ }
+ return tctx
+}
+
+func TestTranslateSecret_FrontendValidation(t *testing.T) {
+ t.Run("with frontendValidation sets downstream mTLS client CA", func(t
*testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {
+ Group: "",
+ Kind: "ConfigMap",
+ Name: "ca-cm",
+ },
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ sslObjs, err := tr.translateSecret(tctx,
gateway.Spec.Listeners[0], gateway)
+ require.NoError(t, err)
+ require.Len(t, sslObjs, 1)
+ require.NotNil(t, sslObjs[0].Client, "client mTLS config should
be set")
+ assert.Equal(t, testCACert, sslObjs[0].Client.CA)
+ assert.Equal(t, []string{"example.com"}, sslObjs[0].Snis)
+ })
+
+ t.Run("with Secret CA ref sets downstream mTLS client CA", func(t
*testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Group: "", Kind: "Secret", Name: "ca-secret"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ sslObjs, err := tr.translateSecret(tctx,
gateway.Spec.Listeners[0], gateway)
+ require.NoError(t, err)
+ require.Len(t, sslObjs, 1)
+ require.NotNil(t, sslObjs[0].Client, "client mTLS config should
be set")
+ assert.Equal(t, testCACert, sslObjs[0].Client.CA)
+ })
+
+ t.Run("missing CA Secret returns error", func(t *testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Kind: "Secret", Name: "missing"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ _, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0],
gateway)
+ require.Error(t, err)
+ })
+
+ t.Run("without frontendValidation leaves client nil", func(t
*testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(nil)
+ tctx := newTranslateContextWithTLS()
+
+ sslObjs, err := tr.translateSecret(tctx,
gateway.Spec.Listeners[0], gateway)
+ require.NoError(t, err)
+ require.Len(t, sslObjs, 1)
+ assert.Nil(t, sslObjs[0].Client)
+ })
+
+ t.Run("missing CA ConfigMap returns error", func(t *testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Kind: "ConfigMap", Name: "missing"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ _, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0],
gateway)
+ require.Error(t, err)
+ })
+
+ t.Run("unsupported CA ref kind returns error", func(t *testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Kind: "Pod", Name: "ca-cm"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ _, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0],
gateway)
+ require.Error(t, err)
+ })
+
+ t.Run("unsupported CA ref group returns error", func(t *testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Group: "example.com", Kind: "ConfigMap", Name:
"ca-cm"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+
+ _, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0],
gateway)
+ require.Error(t, err)
+ })
+
+ t.Run("malformed CA data returns error", func(t *testing.T) {
+ tr := &Translator{Log: logr.Discard()}
+ gateway := newTLSGateway(&gatewayv1.FrontendTLSValidation{
+ CACertificateRefs: []gatewayv1.ObjectReference{
+ {Kind: "ConfigMap", Name: "ca-cm"},
+ },
+ })
+ tctx := newTranslateContextWithTLS()
+ tctx.ConfigMaps[types.NamespacedName{Namespace: "default",
Name: "ca-cm"}] = &corev1.ConfigMap{
+ Data: map[string]string{corev1.ServiceAccountRootCAKey:
" not a pem cert "},
+ }
+
+ _, err := tr.translateSecret(tctx, gateway.Spec.Listeners[0],
gateway)
+ require.Error(t, err)
+ })
+}
diff --git a/internal/controller/gateway_controller.go
b/internal/controller/gateway_controller.go
index 748bfe37..0c3a2347 100644
--- a/internal/controller/gateway_controller.go
+++ b/internal/controller/gateway_controller.go
@@ -93,6 +93,10 @@ func (r *GatewayReconciler) SetupWithManager(mgr
ctrl.Manager) error {
Watches(
&corev1.Secret{},
handler.EnqueueRequestsFromMapFunc(r.listGatewaysForSecret),
+ ).
+ Watches(
+ &corev1.ConfigMap{},
+
handler.EnqueueRequestsFromMapFunc(r.listGatewaysForConfigMap),
)
if GetEnableReferenceGrant() {
@@ -399,6 +403,34 @@ func (r *GatewayReconciler) listGatewaysForSecret(ctx
context.Context, obj clien
return requests
}
+func (r *GatewayReconciler) listGatewaysForConfigMap(ctx context.Context, obj
client.Object) (requests []reconcile.Request) {
+ configMap, ok := obj.(*corev1.ConfigMap)
+ if !ok {
+ r.Log.Error(
+ errors.New("unexpected object type"),
+ "ConfigMap watch predicate received unexpected object
type",
+ "expected", FullTypeName(new(corev1.ConfigMap)),
"found", FullTypeName(obj),
+ )
+ return nil
+ }
+ var gatewayList gatewayv1.GatewayList
+ if err := r.List(ctx, &gatewayList, client.MatchingFields{
+ indexer.ConfigMapIndexRef:
indexer.GenIndexKey(configMap.GetNamespace(), configMap.GetName()),
+ }); err != nil {
+ r.Log.Error(err, "failed to list gateways for configmap")
+ return nil
+ }
+ for _, gateway := range gatewayList.Items {
+ requests = append(requests, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Namespace: gateway.GetNamespace(),
+ Name: gateway.GetName(),
+ },
+ })
+ }
+ return requests
+}
+
func (r *GatewayReconciler) listReferenceGrantsForGateway(ctx context.Context,
obj client.Object) (requests []reconcile.Request) {
grant, ok := obj.(*v1beta1.ReferenceGrant)
if !ok {
@@ -443,7 +475,7 @@ func (r *GatewayReconciler) processInfrastructure(tctx
*provider.TranslateContex
func (r *GatewayReconciler) processListenerConfig(tctx
*provider.TranslateContext, gateway *gatewayv1.Gateway) {
listeners := gateway.Spec.Listeners
for _, listener := range listeners {
- if listener.TLS == nil || listener.TLS.CertificateRefs == nil {
+ if listener.TLS == nil {
continue
}
secret := corev1.Secret{}
@@ -466,5 +498,41 @@ func (r *GatewayReconciler) processListenerConfig(tctx
*provider.TranslateContex
tctx.Secrets[types.NamespacedName{Namespace:
ns, Name: string(ref.Name)}] = &secret
}
}
+ // frontendValidation references CA ConfigMaps or Secrets used
for downstream mTLS.
+ if listener.TLS.FrontendValidation != nil {
+ for _, ref := range
listener.TLS.FrontendValidation.CACertificateRefs {
+ ns := gateway.GetNamespace()
+ if ref.Namespace != nil {
+ ns = string(*ref.Namespace)
+ }
+ nn := types.NamespacedName{Namespace: ns, Name:
string(ref.Name)}
+ kind := KindConfigMap
+ if ref.Kind != "" {
+ kind = string(ref.Kind)
+ }
+ switch kind {
+ case KindConfigMap:
+ configMap := corev1.ConfigMap{}
+ if err := r.Get(context.Background(),
nn, &configMap); err != nil {
+ r.Log.Error(err, "failed to get
CA configmap", "namespace", ns, "name", ref.Name)
+
SetGatewayListenerConditionProgrammed(gateway, string(listener.Name), false,
err.Error())
+
SetGatewayListenerConditionResolvedRefs(gateway, string(listener.Name), false,
err.Error())
+ continue
+ }
+ r.Log.Info("Setting CA configmap for
listener", "listener", listener.Name, "configmap", configMap.Name, "namespace",
ns)
+ tctx.ConfigMaps[nn] = &configMap
+ case KindSecret:
+ caSecret := corev1.Secret{}
+ if err := r.Get(context.Background(),
nn, &caSecret); err != nil {
+ r.Log.Error(err, "failed to get
CA secret", "namespace", ns, "name", ref.Name)
+
SetGatewayListenerConditionProgrammed(gateway, string(listener.Name), false,
err.Error())
+
SetGatewayListenerConditionResolvedRefs(gateway, string(listener.Name), false,
err.Error())
+ continue
+ }
+ r.Log.Info("Setting CA secret for
listener", "listener", listener.Name, "secret", caSecret.Name, "namespace", ns)
+ tctx.Secrets[nn] = &caSecret
+ }
+ }
+ }
}
}
diff --git a/internal/controller/indexer/indexer.go
b/internal/controller/indexer/indexer.go
index 58403f71..1ff8a624 100644
--- a/internal/controller/indexer/indexer.go
+++ b/internal/controller/indexer/indexer.go
@@ -45,6 +45,7 @@ const (
ParentRefs = "parentRefs"
IngressClass = "ingressClass"
SecretIndexRef = "secretRefs"
+ ConfigMapIndexRef = "configMapRefs"
IngressClassRef = "ingressClassRef"
IngressClassParametersRef = "ingressClassParametersRef"
ConsumerGatewayRef = "consumerGatewayRef"
@@ -159,6 +160,15 @@ func setupGatewayIndexer(mgr ctrl.Manager) error {
return err
}
+ if err := mgr.GetFieldIndexer().IndexField(
+ context.Background(),
+ &gatewayv1.Gateway{},
+ ConfigMapIndexRef,
+ GatewayConfigMapIndexFunc,
+ ); err != nil {
+ return err
+ }
+
return nil
}
@@ -560,8 +570,15 @@ func IngressSecretIndexFunc(rawObj client.Object) []string
{
func GatewaySecretIndexFunc(rawObj client.Object) (keys []string) {
gateway := rawObj.(*gatewayv1.Gateway)
var m = make(map[string]struct{})
+ add := func(namespace, name string) {
+ key := GenIndexKey(namespace, name)
+ if _, ok := m[key]; !ok {
+ m[key] = struct{}{}
+ keys = append(keys, key)
+ }
+ }
for _, listener := range gateway.Spec.Listeners {
- if listener.TLS == nil || len(listener.TLS.CertificateRefs) ==
0 {
+ if listener.TLS == nil {
continue
}
for _, ref := range listener.TLS.CertificateRefs {
@@ -572,6 +589,42 @@ func GatewaySecretIndexFunc(rawObj client.Object) (keys
[]string) {
if ref.Namespace != nil {
namespace = string(*ref.Namespace)
}
+ add(namespace, string(ref.Name))
+ }
+ // frontendValidation CA references that are Secrets.
+ if listener.TLS.FrontendValidation != nil {
+ for _, ref := range
listener.TLS.FrontendValidation.CACertificateRefs {
+ if string(ref.Kind) != internaltypes.KindSecret
{
+ continue
+ }
+ namespace := gateway.GetNamespace()
+ if ref.Namespace != nil {
+ namespace = string(*ref.Namespace)
+ }
+ add(namespace, string(ref.Name))
+ }
+ }
+ }
+ return keys
+}
+
+// GatewayConfigMapIndexFunc indexes Gateways by the CA ConfigMaps referenced
via
+// listener TLS frontendValidation, so that ConfigMap changes can trigger
reconciliation.
+func GatewayConfigMapIndexFunc(rawObj client.Object) (keys []string) {
+ gateway := rawObj.(*gatewayv1.Gateway)
+ var m = make(map[string]struct{})
+ for _, listener := range gateway.Spec.Listeners {
+ if listener.TLS == nil || listener.TLS.FrontendValidation ==
nil {
+ continue
+ }
+ for _, ref := range
listener.TLS.FrontendValidation.CACertificateRefs {
+ if ref.Kind != "" && string(ref.Kind) !=
internaltypes.KindConfigMap {
+ continue
+ }
+ namespace := gateway.GetNamespace()
+ if ref.Namespace != nil {
+ namespace = string(*ref.Namespace)
+ }
key := GenIndexKey(namespace, string(ref.Name))
if _, ok := m[key]; !ok {
m[key] = struct{}{}
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index 9007dea7..c0994dab 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -51,6 +51,7 @@ import (
"github.com/apache/apisix-ingress-controller/internal/controller/config"
"github.com/apache/apisix-ingress-controller/internal/controller/indexer"
"github.com/apache/apisix-ingress-controller/internal/provider"
+ sslutils "github.com/apache/apisix-ingress-controller/internal/ssl"
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/utils"
)
@@ -66,6 +67,7 @@ const (
KindIngressClass = "IngressClass"
KindGatewayProxy = "GatewayProxy"
KindSecret = "Secret"
+ KindConfigMap = "ConfigMap"
KindService = "Service"
KindApisixRoute = "ApisixRoute"
KindApisixGlobalRule = "ApisixGlobalRule"
@@ -947,6 +949,12 @@ func getListenerStatus(
break
}
}
+
+ // frontendValidation (downstream mTLS) only applies to
Terminate listeners.
+ if listener.TLS.FrontendValidation != nil &&
+ (listener.TLS.Mode == nil || *listener.TLS.Mode
== gatewayv1.TLSModeTerminate) {
+ validateListenerFrontendValidation(ctx, mrgc,
gateway, listener.TLS.FrontendValidation, &conditionResolvedRefs,
&conditionProgrammed)
+ }
}
status := gatewayv1.ListenerStatus{
@@ -985,6 +993,86 @@ func getListenerStatus(
return statusArray, nil
}
+// validateListenerFrontendValidation validates a listener's TLS
frontendValidation
+// (downstream mTLS CA references) and records the outcome on the listener
conditions.
+func validateListenerFrontendValidation(
+ ctx context.Context,
+ mrgc client.Client,
+ gateway *gatewayv1.Gateway,
+ frontendValidation *gatewayv1.FrontendTLSValidation,
+ conditionResolvedRefs, conditionProgrammed *metav1.Condition,
+) {
+ setInvalid := func(reason gatewayv1.ListenerConditionReason, message
string) {
+ conditionResolvedRefs.Status = metav1.ConditionFalse
+ conditionResolvedRefs.Reason = string(reason)
+ conditionResolvedRefs.Message = message
+ conditionProgrammed.Status = metav1.ConditionFalse
+ conditionProgrammed.Reason =
string(gatewayv1.ListenerReasonInvalid)
+ }
+
+ for _, ref := range frontendValidation.CACertificateRefs {
+ if ref.Group != "" && string(ref.Group) != corev1.GroupName {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef,
+ fmt.Sprintf(`Invalid Group for
caCertificateRef, expect "", got "%s"`, ref.Group))
+ return
+ }
+ kind := KindConfigMap
+ if ref.Kind != "" {
+ kind = string(ref.Kind)
+ }
+ if kind != KindConfigMap && kind != KindSecret {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef,
+ fmt.Sprintf(`Invalid Kind for caCertificateRef,
expect "ConfigMap" or "Secret", got "%s"`, ref.Kind))
+ return
+ }
+ if permitted := checkReferenceGrant(ctx,
+ mrgc,
+ v1beta1.ReferenceGrantFrom{
+ Group: gatewayv1.GroupName,
+ Kind: KindGateway,
+ Namespace: v1beta1.Namespace(gateway.Namespace),
+ },
+ gatewayv1.ObjectReference{
+ Group: corev1.GroupName,
+ Kind: gatewayv1.Kind(kind),
+ Name: ref.Name,
+ Namespace: ref.Namespace,
+ },
+ ); !permitted {
+ setInvalid(gatewayv1.ListenerReasonRefNotPermitted,
"caCertificateRefs cross namespaces is not permitted")
+ return
+ }
+ nn := k8stypes.NamespacedName{
+ Namespace: string(*cmp.Or(ref.Namespace,
(*gatewayv1.Namespace)(&gateway.Namespace))),
+ Name: string(ref.Name),
+ }
+ switch kind {
+ case KindConfigMap:
+ var configMap corev1.ConfigMap
+ if err := mrgc.Get(ctx, nn, &configMap); err != nil {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef, err.Error())
+ return
+ }
+ if _, err :=
sslutils.ExtractCAFromConfigMap(&configMap); err != nil {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef,
+ fmt.Sprintf("Malformed CA ConfigMap
referenced: %s", err.Error()))
+ return
+ }
+ case KindSecret:
+ var secret corev1.Secret
+ if err := mrgc.Get(ctx, nn, &secret); err != nil {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef, err.Error())
+ return
+ }
+ if _, err := sslutils.ExtractCAFromSecret(&secret); err
!= nil {
+
setInvalid(gatewayv1.ListenerReasonInvalidCertificateRef,
+ fmt.Sprintf("Malformed CA Secret
referenced: %s", err.Error()))
+ return
+ }
+ }
+ }
+}
+
// SplitMetaNamespaceKey returns the namespace and name that
// MetaNamespaceKeyFunc encoded into key.
func SplitMetaNamespaceKey(key string) (namespace, name string, err error) {
diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go
index a8b9e055..5e48b397 100644
--- a/internal/manager/controllers.go
+++ b/internal/manager/controllers.go
@@ -50,6 +50,7 @@ import (
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
+// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
// CustomResourceDefinition v2
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 92f36a87..51d27cb8 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -50,6 +50,7 @@ type TranslateContext struct {
EndpointSlices
map[k8stypes.NamespacedName][]discoveryv1.EndpointSlice
Secrets map[k8stypes.NamespacedName]*corev1.Secret
+ ConfigMaps map[k8stypes.NamespacedName]*corev1.ConfigMap
PluginConfigs
map[k8stypes.NamespacedName]*v1alpha1.PluginConfig
ApisixPluginConfigs
map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig
Services map[k8stypes.NamespacedName]*corev1.Service
@@ -70,6 +71,7 @@ func NewDefaultTranslateContext(ctx context.Context)
*TranslateContext {
Context: ctx,
EndpointSlices:
make(map[k8stypes.NamespacedName][]discoveryv1.EndpointSlice),
Secrets:
make(map[k8stypes.NamespacedName]*corev1.Secret),
+ ConfigMaps:
make(map[k8stypes.NamespacedName]*corev1.ConfigMap),
PluginConfigs:
make(map[k8stypes.NamespacedName]*v1alpha1.PluginConfig),
ApisixPluginConfigs:
make(map[k8stypes.NamespacedName]*apiv2.ApisixPluginConfig),
Services:
make(map[k8stypes.NamespacedName]*corev1.Service),
diff --git a/internal/ssl/util.go b/internal/ssl/util.go
index f5fc6b19..ad34dd40 100644
--- a/internal/ssl/util.go
+++ b/internal/ssl/util.go
@@ -83,6 +83,63 @@ func ExtractCertificate(secret *corev1.Secret) ([]byte,
error) {
return cert, err
}
+// ExtractCAFromConfigMap extracts the CA certificate from a ConfigMap.
+//
+// Following the Gateway API conformance for frontendValidation, the CA
certificate
+// is read from the `ca.crt` key. Both Data and BinaryData are supported.
+func ExtractCAFromConfigMap(cm *corev1.ConfigMap) ([]byte, error) {
+ if cm == nil {
+ return nil, ErrMissingCert
+ }
+ var ca []byte
+ if v, ok := cm.Data[corev1.ServiceAccountRootCAKey]; ok && v != "" {
+ ca = []byte(v)
+ } else if v, ok := cm.BinaryData[corev1.ServiceAccountRootCAKey]; ok &&
len(v) > 0 {
+ ca = v
+ }
+ if len(ca) == 0 {
+ return nil, ErrMissingCert
+ }
+ // Reject whitespace-only or otherwise malformed data so an invalid
trust
+ // anchor never reaches the downstream mTLS configuration.
+ if !hasCertificatePEMBlock(ca) {
+ return nil, ErrInvalidPEM
+ }
+ return ca, nil
+}
+
+// ExtractCAFromSecret extracts the CA certificate from a Secret's `ca.crt` key
+// (e.g. a cert-manager-issued TLS Secret) and validates it contains a PEM
+// CERTIFICATE block.
+func ExtractCAFromSecret(secret *corev1.Secret) ([]byte, error) {
+ if secret == nil {
+ return nil, ErrMissingCert
+ }
+ ca, ok := secret.Data[corev1.ServiceAccountRootCAKey]
+ if !ok || len(ca) == 0 {
+ return nil, ErrMissingCert
+ }
+ if !hasCertificatePEMBlock(ca) {
+ return nil, ErrInvalidPEM
+ }
+ return ca, nil
+}
+
+// hasCertificatePEMBlock reports whether data contains at least one
PEM-encoded
+// CERTIFICATE block.
+func hasCertificatePEMBlock(data []byte) bool {
+ for {
+ var block *pem.Block
+ block, data = pem.Decode(data)
+ if block == nil {
+ return false
+ }
+ if block.Type == "CERTIFICATE" {
+ return true
+ }
+ }
+}
+
// ExtractHostsFromCertificate parses the certificate PEM block and returns
the DNS names.
func ExtractHostsFromCertificate(certPEM []byte) ([]string, error) {
block, _ := pem.Decode(certPEM)
diff --git a/internal/types/k8s.go b/internal/types/k8s.go
index fa2e05f6..0bb4822d 100644
--- a/internal/types/k8s.go
+++ b/internal/types/k8s.go
@@ -47,6 +47,7 @@ const (
KindIngressClass = "IngressClass"
KindGatewayProxy = "GatewayProxy"
KindSecret = "Secret"
+ KindConfigMap = "ConfigMap"
KindService = "Service"
KindApisixRoute = "ApisixRoute"
KindApisixGlobalRule = "ApisixGlobalRule"
diff --git a/test/e2e/framework/manifests/ingress.yaml
b/test/e2e/framework/manifests/ingress.yaml
index 0c690392..ef96c606 100644
--- a/test/e2e/framework/manifests/ingress.yaml
+++ b/test/e2e/framework/manifests/ingress.yaml
@@ -80,6 +80,7 @@ rules:
- apiGroups:
- ""
resources:
+ - configmaps
- namespaces
- pods
- secrets
diff --git a/test/e2e/gatewayapi/gateway.go b/test/e2e/gatewayapi/gateway.go
index 6d7c8581..cc19739a 100644
--- a/test/e2e/gatewayapi/gateway.go
+++ b/test/e2e/gatewayapi/gateway.go
@@ -36,6 +36,8 @@ import (
const _secretName = "test-apisix-tls"
+const _hostAPI6 = "api6.com"
+
var Cert = strings.TrimSpace(framework.TestServerCert)
var Key = strings.TrimSpace(framework.TestServerKey)
@@ -45,6 +47,16 @@ func createSecret(s *scaffold.Scaffold, secretName string) {
assert.Nil(GinkgoT(), err, "create secret error")
}
+// indentLines indents every line of s with the given prefix, for embedding a
+// multi-line PEM block inside a YAML block scalar.
+func indentLines(s, prefix string) string {
+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
+ for i, line := range lines {
+ lines[i] = prefix + line
+ }
+ return strings.Join(lines, "\n")
+}
+
var _ = Describe("Test Gateway", Label("networking.k8s.io", "gateway"), func()
{
s := scaffold.NewDefaultScaffold()
@@ -165,7 +177,7 @@ spec:
By("create secret")
secretName := _secretName
- host := "api6.com"
+ host := _hostAPI6
createSecret(s, secretName)
gatewayClassName := s.Namespace()
var defaultGatewayClass = `
@@ -218,6 +230,98 @@ spec:
}).WithTimeout(scaffold.DefaultTimeout).ProbeEvery(scaffold.DefaultInterval).Should(Succeed())
})
+ It("Check downstream mTLS via frontendValidation", func() {
+ By("create GatewayProxy")
+ gatewayProxy := fmt.Sprintf(gatewayProxyYaml,
s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())
+ err := s.CreateResourceFromString(gatewayProxy)
+ Expect(err).NotTo(HaveOccurred(), "creating
GatewayProxy")
+ time.Sleep(5 * time.Second)
+
+ By("create server cert secret")
+ secretName := _secretName
+ host := _hostAPI6
+ createSecret(s, secretName)
+
+ By("create CA ConfigMap for frontendValidation")
+ caConfigMapName := "test-client-ca"
+ caConfigMap := fmt.Sprintf(`
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: %s
+data:
+ ca.crt: |
+%s
+`, caConfigMapName, indentLines(framework.TestCACert, " "))
+ err = s.CreateResourceFromString(caConfigMap)
+ Expect(err).NotTo(HaveOccurred(), "creating CA
ConfigMap")
+
+ gatewayClassName := s.Namespace()
+ var defaultGatewayClass = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+ name: %s
+spec:
+ controllerName: "%s"
+`
+ var defaultGateway = fmt.Sprintf(`
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: %s
+spec:
+ gatewayClassName: %s
+ listeners:
+ - name: http1
+ protocol: HTTPS
+ port: 443
+ hostname: %s
+ tls:
+ certificateRefs:
+ - kind: Secret
+ group: ""
+ name: %s
+ frontendValidation:
+ caCertificateRefs:
+ - kind: ConfigMap
+ group: ""
+ name: %s
+ infrastructure:
+ parametersRef:
+ group: apisix.apache.org
+ kind: GatewayProxy
+ name: apisix-proxy-config
+`, s.Namespace(), gatewayClassName, host, secretName, caConfigMapName)
+
+ By("create GatewayClass")
+ err =
s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultGatewayClass,
gatewayClassName, s.GetControllerName()), "")
+ Expect(err).NotTo(HaveOccurred(), "creating
GatewayClass")
+ time.Sleep(5 * time.Second)
+
+ By("create Gateway")
+ err =
s.CreateResourceFromStringWithNamespace(defaultGateway, s.Namespace())
+ Expect(err).NotTo(HaveOccurred(), "creating Gateway")
+ time.Sleep(10 * time.Second)
+
+ Eventually(func() error {
+ tls, err :=
s.DefaultDataplaneResource().SSL().List(context.Background())
+ if err != nil {
+ return err
+ }
+ if len(tls) != 1 {
+ return fmt.Errorf("expect 1 ssl, got
%d", len(tls))
+ }
+ if tls[0].Client == nil {
+ return fmt.Errorf("expect client mTLS
config, got nil")
+ }
+ if got := strings.TrimSpace(tls[0].Client.CA);
got != strings.TrimSpace(framework.TestCACert) {
+ return fmt.Errorf("client CA not
expected, got %s", got)
+ }
+ return nil
+ }).WithTimeout(30 *
time.Second).ProbeEvery(time.Second).Should(Succeed())
+ })
+
It("Gateway SSL with and without hostname", func() {
By("create GatewayProxy")
gatewayProxy := fmt.Sprintf(gatewayProxyYaml,
s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())