mTLS en todas partes: Cómo configurar TLS para APISIX

Nicolas Fränkel

Nicolas Fränkel

July 31, 2023

Ecosystem

TLS en pocas palabras

TLS ofrece varias capacidades:

  • Autenticación del servidor: el cliente tiene la certeza de que el servidor con el que intercambia datos es el correcto. Evita enviar datos, que podrían ser confidenciales, a un actor incorrecto.
  • Autenticación opcional del cliente: de manera inversa, el servidor solo permite clientes cuya identidad pueda ser verificada.
  • Confidencialidad: ningún tercero puede leer los datos intercambiados entre el cliente y el servidor.
  • Integridad: ningún tercero puede manipular los datos.

TLS funciona a través de certificados. Un certificado es similar a una identificación, que prueba la identidad del titular del certificado. Al igual que una identificación, es necesario confiar en quién lo emitió. La confianza se establece a través de una cadena: si confío en Alice, quien confía en Bob, quien a su vez confía en Charlie, quien emitió el certificado, entonces confío en este último. En este escenario, Alice se conoce como la autoridad certificadora raíz.

La autenticación de TLS se basa en criptografía de clave pública. Alice genera un par de claves pública/privada y publica la clave pública. Si alguien cifra datos con la clave pública, solo la clave privada que generó la clave pública puede descifrarlos. El otro uso es que alguien cifre datos con la clave privada y que cualquiera con la clave pública pueda descifrarlos, demostrando así su identidad.

Finalmente, el TLS mutuo, también conocido como mTLS, es la configuración de TLS bidireccional: autenticación del servidor hacia el cliente, como es habitual, pero también al revés, autenticación del cliente hacia el servidor.

Ahora que tenemos suficiente comprensión de los conceptos, es hora de poner manos a la obra.

Generación de certificados con cert-manager

Algunas autoridades certificadoras raíz están instaladas en los navegadores por defecto. Así es como podemos navegar por sitios HTTPS de manera segura, confiando en que https://apache.org es el sitio que dicen ser. La infraestructura no tiene certificados preinstalados, por lo que debemos comenzar desde cero.

Necesitamos al menos un certificado raíz. A su vez, este generará todos los demás certificados. Aunque es posible hacer todo manualmente, confiaré en cert-manager en Kubernetes. Como su nombre lo indica, cert-manager es una solución para gestionar certificados.

Instalarlo con Helm es sencillo:

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. Agregar el repositorio de charts
  2. Instalar los objetos en un espacio de nombres dedicado
  3. No monitorear, en el alcance de este post

Podemos asegurarnos de que todo funciona como se espera revisando los 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 puede firmar certificados desde múltiples fuentes: HashiCorp Vault, Let's Encrypt, etc. Para mantener las cosas simples:

  • Generaremos nuestro propio certificado raíz, es decir, Self-Signed.
  • No manejaremos la rotación de certificados.

Comencemos con lo siguiente:

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. Autoridad certificadora que genera certificados en todo el clúster.
  2. Crear un espacio de nombres para nuestra demo.
  3. Certificado raíz en el espacio de nombres utilizando el emisor en todo el clúster. Solo se usa para crear un emisor en el espacio de nombres.
  4. Emisor en el espacio de nombres. Se usa para crear todos los demás certificados en este post.

Después de aplicar el manifiesto anterior, deberíamos poder ver el único certificado que creamos:

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

La infraestructura de certificados está lista; ahora veamos Apache APISIX.

Vista rápida de una arquitectura de muestra de Apache APISIX

Apache APISIX es una puerta de enlace de API. Por defecto, almacena su configuración en etcd, un almacén de clave-valor distribuido, el mismo que usa Kubernetes. Tenga en cuenta que en escenarios del mundo real, deberíamos configurar un clúster de etcd para mejorar la resiliencia de la solución. Para este post, nos limitaremos a una única instancia de etcd. Apache APISIX ofrece una API de administración a través de endpoints HTTP. Finalmente, la puerta de enlace reenvía las llamadas del cliente a un upstream. Aquí hay una vista general de la arquitectura y los certificados requeridos:

Arquitectura de Apache APISIX

Comencemos con los bloques fundamentales: etcd y Apache APISIX. Necesitamos dos certificados: uno para etcd, en el rol de servidor, y otro para Apache APISIX, como cliente de etcd.

Configuraremos los certificados desde nuestro emisor en el espacio de nombres:

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. Certificado para etcd.
  2. Nombre del Secret de Kubernetes, ver más abajo.
  3. Usos para este certificado.
  4. Nombre del Service de Kubernetes, ver más abajo.
  5. Referencia al emisor en el espacio de nombres creado anteriormente.
  6. Certificado para Apache APISIX como cliente de etcd.
  7. Atributo obligatorio para clientes.

Después de aplicar el manifiesto anterior, podemos listar los certificados en el espacio de nombres 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. Certificado creado previamente.
  2. Certificados recién creados firmados por selfsigned-ca.

Certificados de cert-manager

Hasta ahora, hemos creado objetos Certificate, pero no hemos explicado qué son. De hecho, son simples CRDs de Kubernetes proporcionados por cert-manager. Bajo el capó, cert-manager crea un Secret de Kubernetes a partir de un Certificate. Gestiona todo el ciclo de vida, por lo que eliminar un Certificate elimina el Secret asociado. El atributo secretName en el manifiesto anterior establece el nombre del 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

Echemos un vistazo a un Secret, por ejemplo, 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 creado por un Certificate proporciona tres atributos:

  • tls.crt: El certificado en sí.
  • tls.key: La clave privada.
  • ca.crt: El certificado de firma en la cadena de certificados, es decir, root-secret/tls.crt.

Kubernetes codifica el contenido de Secret en base 64. Para obtener cualquiera de los anteriores en texto plano, se debe decodificar, por ejemplo:

 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-----

Configuración de mTLS entre etcd y APISIX

Con los certificados disponibles, ahora podemos configurar el TLS mutuo entre etcd y APISIX. Comencemos con 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. Establecer la CA de confianza.
  2. Establecer el certificado.
  3. Establecer la clave privada.
  4. Requerir que los clientes pasen su certificado, asegurando así la autenticación mutua.
  5. Montar el Secret generado previamente en el contenedor para su acceso.

Ahora es el turno de 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 no ofrece configuración a través de variables de entorno. Necesitamos usar un ConfigMap que refleje el archivo config.yaml regular.
  2. Configurar la autenticación cliente para etcd.
  3. Configurar la autenticación servidor para la API de administración.
  4. Puerto HTTPS regular.
  5. Puerto HTTPS de administración.
  6. Certificados para la autenticación del servidor.
  7. Certificados para la autenticación del cliente.
  8. Se usan dos conjuntos de certificados, uno para la autenticación del servidor para la API de administración y HTTPS regular, y otro para la autenticación del cliente para etcd.

En este punto, podemos aplicar los manifiestos anteriores y ver los dos pods comunicándose. Al conectarse, Apache APISIX envía su certificado apisix-client a través de HTTPS. Debido a que una autoridad firma el certificado en el que etcd confía, permite la conexión.

He omitido la definición del Service por brevedad, pero puede revisarlos en el repositorio de GitHub asociado.

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

Acceso del cliente

Ahora que hemos configurado la infraestructura básica, deberíamos probar el acceso con un cliente. Usaremos nuestro fiel curl, pero cualquier cliente que permita configurar certificados debería funcionar, por ejemplo, httpie.

El primer paso es crear un par de certificado-clave dedicado para el cliente:

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 requiere una ruta al archivo del certificado en lugar del contenido. Podemos sortear esta limitación a través de la magia de zsh: la sintaxis =( ... ) permite la creación de un archivo temporal. Si está usando otro shell, necesitará encontrar la sintaxis equivalente o descargar los archivos manualmente.

Consultemos la API de administración para todas las rutas existentes. Este simple comando permite verificar que Apache APISIX está conectado a etcd y puede leer su configuración desde allí.

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 evita contaminar el archivo /etc/hosts. curl traducirá admin a localhost, pero la consulta se enviará a admin dentro del clúster de Kubernetes, utilizando así el Service correcto.
  2. Obtener los datos requeridos dentro del Secret, decodificarlos y usarlos como un archivo temporal.

Si todo funciona, y debería hacerlo, el resultado debería ser el siguiente:

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

No hay rutas disponibles hasta ahora porque aún no hemos creado ninguna.

TLS con upstreams

Por último, pero no menos importante, debemos configurar TLS para los upstreams. En lo siguiente, usaré una instancia simple de nginx que responde con contenido estático. Úselo como una ilustración para upstreams más complejos.

El primer paso, como siempre, es generar un Certificate dedicado para el upstream. Omitiré cómo hacerlo, ya que ya hemos creado algunos. Lo llamaré upstream-server y su Secret, sin imaginación, upstream-secret. Ahora podemos usar este último para asegurar 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 no permite la configuración a través de variables de entorno; necesitamos usar el enfoque de ConfigMap.
  2. Usar el par clave-certificado creado a través del Certificate.
  3. Algún contenido estático sin importancia en el alcance de este post.

El siguiente paso es crear la ruta con la ayuda de la API de administración. Preparamos todo en el paso anterior; ahora podemos usar la 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. Autenticación del cliente para la API de administración, como arriba.
  2. Usar HTTPS para el upstream.
  3. Configurar el par clave-certificado para la ruta. Apache APISIX almacena los datos en etcd y los usará cuando llame a la ruta. Alternativamente, puede mantener el par como un objeto dedicado y usar la referencia recién creada (al igual que para los upstreams). Depende de cuántas rutas necesite el certificado. Para más información, consulte el endpoint SSL.

Finalmente, podemos verificar que funciona como se espera:

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)

Y lo hace:

{ "hello": "world" }

Conclusión

En este post, he descrito una arquitectura funcional de Apache APISIX e implementado TLS mutuo entre todos los componentes: etcd y APISIX, cliente y APISIX, y finalmente, cliente y upstream. Espero que esto le ayude a lograr lo mismo.

Contáctenos si tiene alguna pregunta o consulta sobre APISIX y la gestión de API.

Para ir más allá:

Tags: