mTLS partout : Comment configurer TLS pour APISIX

Nicolas Fränkel

Nicolas Fränkel

July 31, 2023

Ecosystem

TLS en quelques mots

TLS offre plusieurs fonctionnalités :

  • Authentification du serveur : le client est sûr que le serveur avec lequel il échange des données est le bon. Cela évite d'envoyer des données, potentiellement confidentielles, à un mauvais acteur.
  • Authentification optionnelle du client : à l'inverse, le serveur n'autorise que les clients dont l'identité peut être vérifiée.
  • Confidentialité : aucun tiers ne peut lire les données échangées entre le client et le serveur.
  • Intégrité : aucun tiers ne peut altérer les données.

TLS fonctionne grâce à des certificats. Un certificat est similaire à une pièce d'identité, prouvant l'identité du détenteur du certificat. Tout comme une pièce d'identité, vous devez faire confiance à celui qui l'a délivrée. La confiance est établie via une chaîne : si je fais confiance à Alice, qui fait confiance à Bob, qui à son tour fait confiance à Charlie, qui a délivré le certificat, alors je fais confiance à ce dernier. Dans ce scénario, Alice est connue sous le nom d'autorité de certification racine.

L'authentification TLS est basée sur la cryptographie à clé publique. Alice génère une paire de clés publique/privée et publie la clé publique. Si quelqu'un chiffre des données avec la clé publique, seule la clé privée qui a généré la clé publique peut les déchiffrer. L'autre utilisation consiste à chiffrer des données avec la clé privée et à permettre à toute personne disposant de la clé publique de les déchiffrer, prouvant ainsi son identité.

Enfin, le TLS mutuel, alias mTLS, est la configuration du TLS bidirectionnel : l'authentification du serveur vers le client, comme d'habitude, mais aussi l'inverse, l'authentification du client vers le serveur.

Nous avons maintenant une compréhension suffisante des concepts pour nous mettre au travail.

Génération de certificats avec cert-manager

Quelques autorités de certification racine sont installées par défaut dans les navigateurs. C'est ainsi que nous pouvons naviguer en toute sécurité sur des sites HTTPS, en ayant confiance que https://apache.org est bien le site qu'il prétend être. L'infrastructure n'a pas de certificats préinstallés, nous devons donc partir de zéro.

Nous avons besoin d'au moins un certificat racine. À son tour, il générera tous les autres certificats. Bien qu'il soit possible de tout faire manuellement, je vais m'appuyer sur cert-manager dans Kubernetes. Comme son nom l'indique, cert-manager est une solution pour gérer les certificats.

L'installation avec Helm est simple :

helm repo add jetstack https://charts.jetstack.io  #1

helm install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \                       #2
  --create-namespace \                             #2
  --version v1.11.0 \
  --set installCRDs=true \
  --set prometheus.enabled=false                   #3
  1. Ajouter le dépôt des charts
  2. Installer les objets dans un espace de noms dédié
  3. Ne pas surveiller, dans le cadre de cet article

Nous pouvons vérifier que tout fonctionne comme prévu en regardant les pods :

kubectl get pods -n cert-manager
cert-manager-cainjector-7f694c4c58-fc9bk  1/1  Running  2  (2d1h ago)  7d
cert-manager-cc4b776cf-8p2t8              1/1  Running  1  (2d1h ago)  7d
cert-manager-webhook-7cd8c769bb-494tl     1/1  Running  1  (2d1h ago)  7d

cert-manager peut signer des certificats à partir de plusieurs sources : HashiCorp Vault, Let's Encrypt, etc. Pour rester simple :

  • Nous allons générer notre propre certificat racine, c'est-à-dire Self-Signed.
  • Nous ne gérerons pas la rotation des certificats.

Commençons par ce qui suit :

apiVersion: cert-manager.io/v1
kind: ClusterIssuer                           #1
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
apiVersion: v1
kind: Namespace
metadata:
  name: tls                                   #2
---
apiVersion: cert-manager.io/v1
kind: Certificate                             #3
metadata:
  name: selfsigned-ca
  namespace: tls
spec:
  isCA: true
  commonName: selfsigned-ca
  secretName: root-secret
  issuerRef:
    name: selfsigned-issuer
    kind: ClusterIssuer
    group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer                                  #4
metadata:
  name: ca-issuer
  namespace: tls
spec:
  ca:
    secretName: root-secret
  1. Autorité de certification qui génère des certificats à l'échelle du cluster.
  2. Créer un espace de noms pour notre démo.
  3. Certificat racine dans un espace de noms utilisant l'autorité de certification à l'échelle du cluster. Utilisé uniquement pour créer une autorité de certification dans un espace de noms.
  4. Autorité de certification dans un espace de noms. Utilisée pour créer tous les autres certificats dans cet article.

Après avoir appliqué le manifeste précédent, nous devrions pouvoir voir le seul certificat que nous avons créé :

kubectl get certificate -n tls
NAME            READY   SECRET        AGE
selfsigned-ca   True    root-secret   7s

L'infrastructure de certificats est prête ; examinons Apache APISIX.

Aperçu rapide d'une architecture Apache APISIX

Apache APISIX est une passerelle API. Par défaut, il stocke sa configuration dans etcd, un magasin de clés-valeurs distribué - le même que celui utilisé par Kubernetes. Notez que dans des scénarios réels, nous devrions configurer un cluster etcd pour améliorer la résilience de la solution. Pour cet article, nous nous limiterons à une seule instance etcd. Apache APISIX offre une API d'administration via des points de terminaison HTTP. Enfin, la passerelle transmet les appels du client à un service en amont. Voici un aperçu de l'architecture et des certificats nécessaires :

Architecture Apache APISIX

Commençons par les briques de base : etcd et Apache APISIX. Nous avons besoin de deux certificats : un pour etcd, dans le rôle de serveur, et un pour Apache APISIX, en tant que client etcd.

Configurons les certificats à partir de notre autorité de certification dans un espace de noms :

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: etcd-server                         #1
  namespace: tls
spec:
  secretName: etcd-secret                   #2
  isCA: false
  usages:
    - client auth                           #3
    - server auth                           #3
  dnsNames:
    - etcd                                  #4
  issuerRef:
    name: ca-issuer                         #5
    kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: apisix-client                       #6
  namespace: tls
spec:
  secretName: apisix-client-secret
  isCA: false
  usages:
    - client auth
  emailAddresses:
    - apisix@apache.org                     #7
  issuerRef:
    name: ca-issuer                         #5
    kind: Issuer
  1. Certificat pour etcd.
  2. Nom du Secret Kubernetes, voir ci-dessous.
  3. Utilisations de ce certificat.
  4. Nom du Service Kubernetes, voir ci-dessous.
  5. Référence à l'autorité de certification dans un espace de noms créée précédemment.
  6. Certificat pour Apache APISIX en tant que client d'etcd.
  7. Attribut obligatoire pour les clients.

Après avoir appliqué le manifeste ci-dessus, nous pouvons lister les certificats dans l'espace de noms tls :

kubectl get certificates -n tls
NAME              READY   SECRET                 AGE
selfsigned-ca     True    root-secret            8m59s    //1
apisix-client     True    apisix-client-secret   8m22s    //2
etcd-server       True    etcd-secret            8m54s    //2
  1. Certificat créé précédemment.
  2. Nouveaux certificats signés par selfsigned-ca.

Certificats de cert-manager

Jusqu'à présent, nous avons créé des objets Certificate, mais nous n'avons pas expliqué ce qu'ils sont. En effet, ce sont de simples CRD Kubernetes fournis par cert-manager. Sous le capot, cert-manager crée un Secret Kubernetes à partir d'un Certificate. Il gère tout le cycle de vie, donc supprimer un Certificate supprime le Secret associé. L'attribut secretName dans le manifeste ci-dessus définit le nom du Secret.

kubectl get secrets -n tls
NAME                   TYPE                DATA   AGE
apisix-client-secret   kubernetes.io/tls   3      35m
etcd-secret            kubernetes.io/tls   3      35m
root-secret            kubernetes.io/tls   3      35m

Examinons un Secret, par exemple apisix-client-secret :

kubectl describe apisix-client-secret -n tls
Name:         apisix-client-secret
Namespace:    tls
Labels:       controller.cert-manager.io/fao=true
Annotations:  cert-manager.io/alt-names:
              cert-manager.io/certificate-name: apisix-client
              cert-manager.io/common-name:
              cert-manager.io/ip-sans:
              cert-manager.io/issuer-group:
              cert-manager.io/issuer-kind: Issuer
              cert-manager.io/issuer-name: ca-issuer
              cert-manager.io/uri-sans:

Type:  kubernetes.io/tls

Data
====
ca.crt:   1099 bytes
tls.crt:  1115 bytes
tls.key:  1679 bytes

Un Secret créé par un Certificate fournit trois attributs :

  • tls.crt : Le certificat lui-même.
  • tls.key : La clé privée.
  • ca.crt : Le certificat de signature dans la chaîne de certificats, c'est-à-dire root-secret/tls.crt.

Kubernetes encode le contenu des Secret en base 64. Pour obtenir l'un des éléments ci-dessus en texte clair, il faut le décoder, par exemple :

 kubectl get secret etcd-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64
-----BEGIN CERTIFICATE-----
MIIDBjCCAe6gAwIBAgIQM3JUR8+R0vuUndjGK/aOgzANBgkqhkiG9w0BAQsFADAY
MRYwFAYDVQQDEw1zZWxmc2lnbmVkLWNhMB4XDTIzMDMxNjEwMTYyN1oXDTIzMDYx
NDEwMTYyN1owADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMQpMj/0
giDVOjOosSRRKUwTzl1Wo2R9YYAeteOW3fuMiAd+XaBGmRO/+GWZQN1tyRQ3pITM
ezBgogYAUUNcuqN/UAsgH/JM58niMjZdjRKn4+it94Nj1e24jFL4ts2snCn7FfKJ
3zRtY9tyS7Agw3tCwtXV68Xpmf3CsfhPmn3rGdWHXyYctzAZhqYfEswN3hxpJZxR
YVeb55WgDoPo5npZo3+yYiMtoOimIprcmZ2Ye8Wai9S4QKDafUWlvU5GQ65VVLzH
PEdOMwbWcwiLqwUv889TiKiC5cyAD6wJOuPRF0KKxxFnG+lHlg9J2S1i5sC3pqoc
i0pEQ+atOOyLMMECAwEAAaNkMGIwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUF
BwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAU2ZaAdEficKUWPFRjdsKSEX/l
gbMwEgYDVR0RAQH/BAgwBoIEZXRjZDANBgkqhkiG9w0BAQsFAAOCAQEABcNvYTm8
ZJe3jUq6f872dpNVulb2UvloTpWxQ8jwXgcrhekSKU6pZ4p9IPwfauHLjceMFJLp
t2eDi5fSQ1upeqXOofeyKSYjjyA/aVf1zMI8ReCCQtQuAVYyJWBlNLc3XMMecbcp
JLGtd/OAZnKDeYYkUX7cJ2wN6Wl/wGLM2lxsqDhEHEZwvGL0DmsdHw7hzSjdVmxs
0Qgkh4jVbNUKdBok5U9Ivr3P1xDPaD/FqGFyM0ssVOCHxtPxhOUA/m3DSr6klfEF
McOfudZE958bChOrJgVrUnY3inR0J335bGQ1luEp5tYwPgyD9dG4MQEDD3oLwp+l
+NtTUqz8WVlMxQ==
-----END CERTIFICATE-----

Configuration du mTLS entre etcd et APISIX

Avec les certificats disponibles, nous pouvons maintenant configurer le TLS mutuel entre etcd et APISIX. Commençons par etcd :

apiVersion: v1
kind: Pod
metadata:
  name: etcd
  namespace: tls
  labels:
    role: config
spec:
  containers:
    - name: etcd
      image: bitnami/etcd:3.5.7
      ports:
        - containerPort: 2379
      env:
        - name: ETCD_TRUSTED_CA_FILE        #1
          value: /etc/ssl/private/ca.crt
        - name: ETCD_CERT_FILE              #2
          value: /etc/ssl/private/tls.crt
        - name: ETCD_KEY_FILE               #3
          value: /etc/ssl/private/tls.key
        - name: ETCD_ROOT_PASSWORD
          value: whatever
        - name: ETCD_CLIENT_CERT_AUTH       #4
          value: "true"
        - name: ETCD_LISTEN_CLIENT_URLS
          value: https://0.0.0.0:2379
      volumeMounts:
        - name: ssl
          mountPath: /etc/ssl/private       #5
  volumes:
    - name: ssl
      secret:
        secretName: etcd-secret             #5
  1. Définir l'autorité de certification de confiance.
  2. Définir le certificat.
  3. Définir la clé privée.
  4. Exiger que les clients présentent leur certificat, assurant ainsi une authentification mutuelle.
  5. Monter le secret précédemment généré dans le conteneur pour y accéder.

Maintenant, c'est au tour d'Apache APISIX :

apiVersion: v1
kind: ConfigMap                                            #1
metadata:
  name: apisix-config
  namespace: tls
data:
  config.yaml: >-
    apisix:
      ssl:
        ssl_trusted_certificate: /etc/ssl/certs/ca.crt     #2
    deployment:
      etcd:
        host:
          - https://etcd:2379
        tls:
          cert: /etc/ssl/certs/tls.crt                     #2
          key: /etc/ssl/certs/tls.key                      #2
      admin:
        allow_admin:
          - 0.0.0.0/0
        https_admin: true                                  #3
        admin_api_mtls:
          admin_ssl_cert: /etc/ssl/private/tls.crt         #3
          admin_ssl_cert_key: /etc/ssl/private/tls.key     #3
          admin_ssl_ca_cert: /etc/ssl/private/ca.crt       #3
---
apiVersion: v1
kind: Pod
metadata:
  name: apisix
  namespace: tls
  labels:
    role: gateway
spec:
  containers:
    - name: apisix
      image: apache/apisix:3.2.0-debian
      ports:
        - containerPort: 9443                              #4
        - containerPort: 9180                              #5
      volumeMounts:
        - name: config                                      #1
          mountPath: /usr/local/apisix/conf/config.yaml
          subPath: config.yaml
        - name: ssl                                        #6
          mountPath: /etc/ssl/private
        - name: etcd-client                                #7
          mountPath: /etc/ssl/certs
  volumes:
    - name: config
      configMap:
        name: apisix-config
    - name: ssl                                            #6,8
      secret:
        secretName: apisix-server-secret
    - name: etcd-client                                    #7,8
      secret:
        secretName: apisix-client-secret
  1. Apache APISIX ne permet pas la configuration via des variables d'environnement. Nous devons utiliser une approche ConfigMap qui reflète le fichier config.yaml habituel.
  2. Configurer l'authentification client pour etcd.
  3. Configurer l'authentification serveur pour l'API d'administration.
  4. Port HTTPS standard.
  5. Port HTTPS d'administration.
  6. Certificats pour l'authentification serveur.
  7. Certificats pour l'authentification client.
  8. Deux ensembles de certificats sont utilisés, un pour l'authentification serveur pour l'API d'administration et le HTTPS standard, et un pour l'authentification client pour etcd.

À ce stade, nous pouvons appliquer les manifestes ci-dessus et voir les deux pods communiquer. Lors de la connexion, Apache APISIX envoie son certificat apisix-client via HTTPS. Parce qu'une autorité de confiance signe le certificat qu'etcd reconnaît, il autorise la connexion.

J'ai omis la définition des Service par souci de concision, mais vous pouvez les consulter dans le dépôt GitHub associé.

NAME     READY   STATUS    RESTARTS   AGE
apisix   1/1     Running   0          179m
etcd     1/1     Running   0          179m

Accès client

Maintenant que nous avons mis en place l'infrastructure de base, nous devrions tester son accès avec un client. Nous utiliserons notre fidèle curl, mais tout client permettant de configurer des certificats devrait fonctionner, par exemple httpie.

La première étape consiste à créer une paire de certificat-clé dédiée pour le client :

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: curl-client
  namespace: tls
spec:
  secretName: curl-secret
  isCA: false
  usages:
    - client auth
  emailAddresses:
    - curl@localhost.dev
  issuerRef:
    name: ca-issuer
    kind: Issuer

curl nécessite un chemin vers le fichier de certificat plutôt que le contenu. Nous pouvons contourner cette limitation grâce à la magie de zsh : la syntaxe =( ... ) permet la création d'un fichier temporaire. Si vous utilisez un autre shell, vous devrez trouver la syntaxe équivalente ou télécharger les fichiers manuellement.

Interrogeons l'API d'administration pour toutes les routes existantes. Cette commande simple permet de vérifier qu'Apache APISIX est connecté à etcd et qu'il peut lire sa configuration à partir de là.

curl --resolve 'admin:32180:127.0.0.1' https://admin:32180/apisix/admin/routes \                     #1
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \  #2
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \   #2
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d) \ #2
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
  1. --resolve évite de polluer le fichier /etc/hosts. curl traduira admin en localhost, mais la requête sera envoyée à admin à l'intérieur du cluster Kubernetes, utilisant ainsi le bon Service.
  2. Obtenir les données nécessaires dans le Secret, les décoder et les utiliser comme fichier temporaire.

Si tout fonctionne, et cela devrait être le cas, le résultat devrait être le suivant :

{"total":0,"list":[]}

Aucune route n'est disponible pour le moment car nous n'en avons pas encore créé.

TLS avec les services en amont

Enfin, nous devons configurer le TLS pour les services en amont. Dans ce qui suit, j'utiliserai une simple instance nginx qui répond avec du contenu statique. Utilisez-la comme illustration pour des services en amont plus complexes.

La première étape, comme toujours, est de générer un Certificate dédié pour le service en amont. Je vais sauter la manière de le faire car nous en avons déjà créé quelques-uns. Je l'appelle upstream-server et son Secret, sans imagination, upstream-secret. Nous pouvons maintenant utiliser ce dernier pour sécuriser NGINX :

apiVersion: v1
kind: ConfigMap                                           #1
metadata:
  name: nginx-config
  namespace: tls
data:
  nginx.conf: >-
    events {
      worker_connections 1024;
    }
    http {
      server {
        listen              443 ssl;
        server_name         upstream;
        ssl_certificate     /etc/ssl/private/tls.crt;     #2
        ssl_certificate_key /etc/ssl/private/tls.key;     #2

        root /www/data;
        location / {
            index index.json;
        }
      }
    }
---
apiVersion: v1
kind: Pod
metadata:
  name: upstream
  namespace: tls
  labels:
    role: upstream
spec:
  containers:
    - name: upstream
      image: nginx:1.23-alpine
      ports:
        - containerPort: 443
      volumeMounts:
        - name: config
          mountPath: /etc/nginx/nginx.conf                #1
          subPath: nginx.conf
        - name: content
          mountPath: /www/data/index.json                 #3
          subPath: index.json
        - name: ssl                                       #2
          mountPath: /etc/ssl/private
  volumes:
    - name: config
      configMap:
        name: nginx-config
    - name: ssl                                           #2
      secret:
        secretName: upstream-secret
    - name: content                                       #3
      configMap:
        name: nginx-content
  1. NGINX ne permet pas la configuration via des variables d'environnement ; nous devons utiliser l'approche ConfigMap.
  2. Utiliser la paire clé-certificat créée via le Certificate.
  3. Un contenu statique sans importance dans le cadre de cet article.

L'étape suivante consiste à créer la route avec l'aide de l'API d'administration. Nous avons tout préparé à l'étape précédente ; maintenant, nous pouvons utiliser l'API :

curl --resolve 'admin:32180:127.0.0.1' https://admin:32180/apisix/admin/routes/1 \
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \     #1
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \      #1
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d) \    #1
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d "{
        \"uri\": \"/\",
        \"upstream\": {
          \"scheme\": \"https\",                                                                        #2
          \"nodes\": {
            \"upstream:443\": 1
          },
          \"tls\": {
            \"client_cert\": \"$(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d)\", #3
            \"client_key\": \"$(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d)\"   #3
          }
        }
     }"
  1. Authentification client pour l'API d'administration, comme ci-dessus.
  2. Utiliser HTTPS pour le service en amont.
  3. Configurer la paire clé-certificat pour la route. Apache APISIX stocke les données dans etcd et les utilisera lorsque vous appellerez la route. Alternativement, vous pouvez conserver la paire comme un objet dédié et utiliser la référence nouvellement créée (comme pour les services en amont). Cela dépend du nombre de routes nécessitant le certificat. Pour plus d'informations, consultez le point de terminaison SSL.

Enfin, nous pouvons vérifier que cela fonctionne comme prévu :

curl --resolve 'upstream:32443:127.0.0.1' https://upstream:32443/ \
     --cert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.crt }' | base64 -d) \
     --key =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.tls\.key }' | base64 -d) \
     --cacert =(kubectl get secret curl-secret -n tls -o jsonpath='{ .data.ca\.crt }' | base64 -d)

Et cela fonctionne :

{ "hello": "world" }

Conclusion

Dans cet article, j'ai décrit une architecture Apache APISIX fonctionnelle et implémenté le TLS mutuel entre tous les composants : etcd et APISIX, client et APISIX, et enfin, client et service en amont. J'espère que cela vous aidera à faire de même.

Contactez-nous si vous avez des questions ou des demandes concernant APISIX et la gestion des API.

Pour aller plus loin :

Tags: