Enrutamiento de tráfico de salida a destinos wildcard

Un enfoque genérico para configurar gateways de salida que pueden enrutar tráfico a un conjunto restringido de hosts remotos objetivo de forma dinámica, incluyendo dominios wildcard.

Dec 1, 2023 | Por Gergő Huszty - IBM

Si está usando Istio para manejar tráfico originado por aplicaciones hacia destinos fuera de la malla, probablemente está familiarizado con el concepto de gateways de salida. Los gateways de salida pueden usarse para monitorear y reenviar tráfico desde aplicaciones internas de la malla a ubicaciones fuera de la malla. Esta es una característica útil si su sistema está operando en un entorno restringido y desea controlar qué se puede alcanzar en la internet pública desde su malla.

El caso de uso de configurar un gateway de salida para manejar dominios wildcard arbitrarios había sido incluido en la documentación oficial de Istio hasta la versión 1.13, pero fue posteriormente eliminado porque la solución documentada no estaba oficialmente soportada o recomendada y estaba sujeta a romperse en futuras versiones de Istio. Sin embargo, la solución antigua todavía era utilizable con versiones de Istio anteriores a 1.20. Istio 1.20, sin embargo, eliminó alguna funcionalidad de Envoy que era requerida para que el enfoque funcionara.

Esta publicación intenta describir cómo resolvimos el problema y llenamos el vacío con un enfoque similar usando componentes independientes de la versión de Istio y características de Envoy, pero sin la necesidad de un proxy SNI de Nginx separado. Nuestro enfoque permite a los usuarios de la solución antigua migrar configuraciones sin problemas antes de que sus sistemas enfrenten los cambios disruptivos en Istio 1.20.

Problema a resolver

Los casos de uso de gateway de salida actualmente documentados se basan en el hecho de que el objetivo del tráfico (el hostname) se configura estáticamente en un VirtualService, indicando a Envoy en el pod del gateway de salida hacia dónde hacer proxy TCP de las conexiones salientes coincidentes. Puede usar múltiples, e incluso wildcard, nombres DNS para coincidir con los criterios de enrutamiento, pero no puede enrutar el tráfico a la ubicación exacta especificada en la solicitud de la aplicación. Por ejemplo puede coincidir tráfico para objetivos *.wikipedia.org, pero luego necesita reenviar el tráfico a un único objetivo final, por ejemplo, en.wikipedia.org. Si hay otro servicio, por ejemplo, anyservice.wikipedia.org, que no está alojado por el mismo servidor(es) que en.wikipedia.org, el tráfico a ese host fallará. Esto es porque, aunque el hostname objetivo en el handshake TLS de la carga HTTP contiene anyservice.wikipedia.org, los servidores en.wikipedia.org no podrán servir la solicitud.

La solución a este problema en un alto nivel es inspeccionar el nombre del servidor original (extensión SNI) en el handshake TLS de la aplicación (que se envía en texto plano, por lo que no se necesita terminación TLS u otra operación de hombre en el medio) en cada nueva conexión del gateway y usarlo como el objetivo para hacer proxy TCP dinámicamente del tráfico que sale del gateway.

Al restringir el tráfico de salida a través de gateways de salida, necesitamos bloquear los gateways de salida para que solo puedan ser usados por clientes dentro de la malla. Esto se logra aplicando ISTIO_MUTUAL (autenticación de pares mTLS) entre la aplicación sidecar y el gateway. Eso significa que habrá dos capas de TLS en la carga L7 de la aplicación. Una que es la aplicación originada sesión TLS de extremo a extremo terminada por el objetivo remoto final, y otra que es la sesión mTLS de Istio.

Otra cosa a tener en cuenta es que para mitigar cualquier corrupción potencial del pod de aplicación, tanto el sidecar de la aplicación como el gateway deben realizar verificaciones de lista de hostnames. De esta manera, cualquier pod de aplicación comprometido aún solo podrá acceder a los objetivos permitidos y nada más.

Programación de Envoy de bajo nivel al rescate

Las versiones recientes de Envoy incluyen una solución de proxy TCP de reenvío dinámico que usa el encabezado SNI en una base por conexión para determinar el objetivo de una solicitud de aplicación. Aunque un VirtualService de Istio no puede configurar un objetivo así, podemos usar EnvoyFilters para alterar las instrucciones de enrutamiento generadas por Istio para que se use el encabezado SNI para determinar el objetivo.

Para hacer que todo funcione, comenzamos configurando un gateway de salida personalizado para escuchar el tráfico saliente. Usando un DestinationRule y un VirtualService instruimos a los sidecars de la aplicación para enrutar el tráfico (para una lista seleccionada de hostnames) a ese gateway, usando mTLS de Istio. En el lado del pod del gateway construimos el reenviador SNI con los EnvoyFilters, mencionados anteriormente, introduciendo listeners y clusters internos de Envoy para hacer que todo funcione. Finalmente, parcheamos el destino interno del proxy TCP implementado por el gateway al reenviador SNI interno.

El flujo de solicitud de extremo a extremo se muestra en el siguiente diagrama:

Enrutamiento SNI de salida con nombres de dominio arbitrarios
Enrutamiento SNI de salida con nombres de dominio arbitrarios

Este diagrama muestra una solicitud HTTPS de salida a en.wikipedia.org usando SNI como clave de enrutamiento.

Desplegar la muestra

Para desplegar la configuración de muestra, comience creando el namespace istio-egress y luego use el siguiente YAML para desplegar un gateway de salida, junto con algo de RBAC y su Service. Usamos el método de inyección de gateway para crear el gateway en este ejemplo. Dependiendo de su método de instalación, puede querer desplegarlo de manera diferente (por ejemplo, usando un CR IstioOperator o usando Helm).

# New k8s cluster service to put egressgateway into the Service Registry,
# so application sidecars can route traffic towards it within the mesh.
apiVersion: v1
kind: Service
metadata:
  name: egressgateway
  namespace: istio-egress
spec:
  type: ClusterIP
  selector:
    istio: egressgateway
  ports:
  - port: 443
    name: tls-egress
    targetPort: 8443

---
# Gateway deployment with injection method
apiVersion: apps/v1
kind: Deployment
metadata:
  name: istio-egressgateway
  namespace: istio-egress
spec:
  selector:
    matchLabels:
      istio: egressgateway
  template:
    metadata:
      annotations:
        inject.istio.io/templates: gateway
      labels:
        istio: egressgateway
        sidecar.istio.io/inject: "true"
    spec:
      containers:
      - name: istio-proxy
        image: auto # The image will automatically update each time the pod starts.
        securityContext:
          capabilities:
            drop:
            - ALL
          runAsUser: 1337
          runAsGroup: 1337

---
# Set up roles to allow reading credentials for TLS
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: istio-egressgateway-sds
  namespace: istio-egress
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "watch", "list"]
- apiGroups:
  - security.openshift.io
  resourceNames:
  - anyuid
  resources:
  - securitycontextconstraints
  verbs:
  - use

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: istio-egressgateway-sds
  namespace: istio-egress
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: istio-egressgateway-sds
subjects:
- kind: ServiceAccount
  name: default

Verifique que el pod del gateway esté funcionando en el namespace istio-egress y luego aplique el siguiente YAML para configurar el enrutamiento del gateway:

# Define a new listener that enforces Istio mTLS on inbound connections.
# This is where sidecar will route the application traffic, wrapped into
# Istio mTLS.
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: egressgateway
  namespace: istio-system
spec:
  selector:
    istio: egressgateway
  servers:
  - port:
      number: 8443
      name: tls-egress
      protocol: TLS
    hosts:
      - "*"
    tls:
      mode: ISTIO_MUTUAL

---
# VirtualService that will instruct sidecars in the mesh to route the outgoing
# traffic to the egress gateway Service if the SNI target hostname matches
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: direct-wildcard-through-egress-gateway
  namespace: istio-system
spec:
  hosts:
    - "*.wikipedia.org"
  gateways:
  - mesh
  - egressgateway
  tls:
  - match:
    - gateways:
      - mesh
      port: 443
      sniHosts:
        - "*.wikipedia.org"
    route:
    - destination:
        host: egressgateway.istio-egress.svc.cluster.local
        subset: wildcard
# Dummy routing instruction. If omitted, no reference will point to the Gateway
# definition, and istiod will optimise the whole new listener out.
  tcp:
  - match:
    - gateways:
      - egressgateway
      port: 8443
    route:
    - destination:
        host: "dummy.local"
      weight: 100

---
# Instruct sidecars to use Istio mTLS when sending traffic to the egress gateway
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: egressgateway
  namespace: istio-system
spec:
  host: egressgateway.istio-egress.svc.cluster.local
  subsets:
  - name: wildcard
  trafficPolicy:
    tls:
      mode: ISTIO_MUTUAL

---
# Put the remote targets into the Service Registry
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: wildcard
  namespace: istio-system
spec:
  hosts:
    - "*.wikipedia.org"
  ports:
  - number: 443
    name: tls
    protocol: TLS

---
# Access logging for the gateway
apiVersion: telemetry.istio.io/v1alpha1
kind: Telemetry
metadata:
  name: mesh-default
  namespace: istio-system
spec:
  accessLogging:
    - providers:
      - name: envoy

---
# And finally, the configuration of the SNI forwarder,
# it's internal listener, and the patch to the original Gateway
# listener to route everything into the SNI forwarder.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: sni-magic
  namespace: istio-system
spec:
  configPatches:
  - applyTo: CLUSTER
    match:
      context: GATEWAY
    patch:
      operation: ADD
      value:
        name: sni_cluster
        load_assignment:
          cluster_name: sni_cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  envoy_internal_address:
                    server_listener_name: sni_listener
  - applyTo: CLUSTER
    match:
      context: GATEWAY
    patch:
      operation: ADD
      value:
        name: dynamic_forward_proxy_cluster
        lb_policy: CLUSTER_PROVIDED
        cluster_type:
          name: envoy.clusters.dynamic_forward_proxy
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig
            dns_cache_config:
              name: dynamic_forward_proxy_cache_config
              dns_lookup_family: V4_ONLY

  - applyTo: LISTENER
    match:
      context: GATEWAY
    patch:
      operation: ADD
      value:
        name: sni_listener
        internal_listener: {}
        listener_filters:
        - name: envoy.filters.listener.tls_inspector
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector

        filter_chains:
        - filter_chain_match:
            server_names:
            - "*.wikipedia.org"
          filters:
            - name: envoy.filters.network.sni_dynamic_forward_proxy
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.sni_dynamic_forward_proxy.v3.FilterConfig
                port_value: 443
                dns_cache_config:
                  name: dynamic_forward_proxy_cache_config
                  dns_lookup_family: V4_ONLY
            - name: envoy.tcp_proxy
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
                stat_prefix: tcp
                cluster: dynamic_forward_proxy_cluster
                access_log:
                - name: envoy.access_loggers.file
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
                    path: "/dev/stdout"
                    log_format:
                      text_format_source:
                        inline_string: '[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%
                          %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %RESPONSE_CODE_DETAILS% %CONNECTION_TERMINATION_DETAILS%
                          "%UPSTREAM_TRANSPORT_FAILURE_REASON%" %BYTES_RECEIVED% %BYTES_SENT% %DURATION%
                          %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%"
                          "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" %UPSTREAM_CLUSTER%
                          %UPSTREAM_LOCAL_ADDRESS% %DOWNSTREAM_LOCAL_ADDRESS% %DOWNSTREAM_REMOTE_ADDRESS%
                          %REQUESTED_SERVER_NAME% %ROUTE_NAME%

                          '
  - applyTo: NETWORK_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: "envoy.filters.network.tcp_proxy"
    patch:
      operation: MERGE
      value:
        name: envoy.tcp_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
          stat_prefix: tcp
          cluster: sni_cluster

Verifique los logs de istiod y del gateway para cualquier error o advertencia. Si todo salió bien, los sidecars de su malla ahora están enrutando solicitudes *.wikipedia.org a su pod de gateway mientras el pod de gateway las está reenviando al host remoto exacto especificado en la solicitud de la aplicación.

Pruébelo

Siguiendo otros ejemplos de salida de Istio, usaremos el pod sleep como fuente de prueba para enviar solicitudes. Asumiendo que la inyección automática de sidecar está habilitada en su namespace predeterminado, despliegue la aplicación de prueba usando el siguiente comando:

$ kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.29/samples/sleep/sleep.yaml

Obtenga sus pods sleep y gateway:

$ export SOURCE_POD=$(kubectl get pod -l app=sleep -o jsonpath={.items..metadata.name})
$ export GATEWAY_POD=$(kubectl get pod -n istio-egress -l istio=egressgateway -o jsonpath={.items..metadata.name})

Ejecute el siguiente comando para confirmar que puede conectarse al sitio wikipedia.org:

$ kubectl exec "$SOURCE_POD" -c sleep -- sh -c 'curl -s https://en.wikipedia.org/wiki/Main_Page | grep -o "<title>.*</title>"; curl -s https://de.wikipedia.org/wiki/Wikipedia:Hauptseite | grep -o "<title>.*</title>"'
<title>Wikipedia, the free encyclopedia</title>
<title>Wikipedia – Die freie Enzyklopädie</title>

¡Pudimos alcanzar tanto subdominios de wikipedia.org en inglés como en alemán, genial!

Normalmente, en un entorno de producción, bloquearíamos solicitudes externas que no están configuradas para redirigir a través del gateway de salida, pero dado que no hicimos eso en nuestro entorno de prueba, accedamos a otro sitio externo para comparar:

$ kubectl exec "$SOURCE_POD" -c sleep -- sh -c 'curl -s https://cloud.ibm.com/login | grep -o "<title>.*</title>"'
<title>IBM Cloud</title>

Dado que tenemos el registro de acceso activado globalmente (con el CR Telemetry en el manifiesto), ahora podemos inspeccionar los logs para ver cómo se manejaron las solicitudes anteriores por los proxies.

Primero, verifique los logs del gateway:

$ kubectl logs -n istio-egress $GATEWAY_POD
[...]
[2023-11-24T13:21:52.798Z] "- - -" 0 - - - "-" 813 111152 55 - "-" "-" "-" "-" "185.15.59.224:443" dynamic_forward_proxy_cluster 172.17.5.170:48262 envoy://sni_listener/ envoy://internal_client_address/ en.wikipedia.org -
[2023-11-24T13:21:52.798Z] "- - -" 0 - - - "-" 1531 111950 55 - "-" "-" "-" "-" "envoy://sni_listener/" sni_cluster envoy://internal_client_address/ 172.17.5.170:8443 172.17.34.35:55102 outbound_.443_.wildcard_.egressgateway.istio-egress.svc.cluster.local -
[2023-11-24T13:21:53.000Z] "- - -" 0 - - - "-" 821 92848 49 - "-" "-" "-" "-" "185.15.59.224:443" dynamic_forward_proxy_cluster 172.17.5.170:48278 envoy://sni_listener/ envoy://internal_client_address/ de.wikipedia.org -
[2023-11-24T13:21:53.000Z] "- - -" 0 - - - "-" 1539 93646 50 - "-" "-" "-" "-" "envoy://sni_listener/" sni_cluster envoy://internal_client_address/ 172.17.5.170:8443 172.17.34.35:55108 outbound_.443_.wildcard_.egressgateway.istio-egress.svc.cluster.local -

Hay cuatro entradas de log, representando dos de nuestras tres solicitudes curl. Cada par muestra cómo una sola solicitud fluye a través del pipeline de procesamiento de tráfico de envoy. Están impresas en orden inverso, pero podemos ver que la 2da y 4ta línea muestran que las solicitudes llegaron al servicio gateway y se pasaron a través del objetivo sni_cluster interno. La 1ra y 3ra línea muestran que el objetivo final se determina del encabezado SNI interno, es decir, el host objetivo establecido por la aplicación. La solicitud se reenvía a dynamic_forward_proxy_cluster que finalmente envía la solicitud desde Envoy al objetivo remoto.

Genial, pero ¿dónde está la tercera solicitud a IBM Cloud? Revisemos los logs del sidecar:

$ kubectl logs $SOURCE_POD -c istio-proxy
[...]
[2023-11-24T13:21:52.793Z] "- - -" 0 - - - "-" 813 111152 61 - "-" "-" "-" "-" "172.17.5.170:8443" outbound|443|wildcard|egressgateway.istio-egress.svc.cluster.local 172.17.34.35:55102 208.80.153.224:443 172.17.34.35:37020 en.wikipedia.org -
[2023-11-24T13:21:52.994Z] "- - -" 0 - - - "-" 821 92848 55 - "-" "-" "-" "-" "172.17.5.170:8443" outbound|443|wildcard|egressgateway.istio-egress.svc.cluster.local 172.17.34.35:55108 208.80.153.224:443 172.17.34.35:37030 de.wikipedia.org -
[2023-11-24T13:21:55.197Z] "- - -" 0 - - - "-" 805 15199 158 - "-" "-" "-" "-" "104.102.54.251:443" PassthroughCluster 172.17.34.35:45584 104.102.54.251:443 172.17.34.35:45582 cloud.ibm.com -

Como puede ver, las solicitudes de Wikipedia se enviaron a través del gateway mientras que la solicitud a IBM Cloud salió directamente del pod de aplicación a internet, como lo indica el log PassthroughCluster.

Conclusión

Implementamos enrutamiento controlado para tráfico HTTPS/TLS de salida usando gateways de salida, soportando nombres de dominio arbitrarios y wildcard. En un entorno de producción, el ejemplo mostrado en esta publicación se extendería para soportar requisitos de HA (por ejemplo, agregando Deployments de gateway conscientes de zona, etc.) y para restringir el acceso directo a red externa de su aplicación para que la aplicación solo pueda acceder a la red pública a través del gateway, que está limitado a un conjunto predefinido de hostnames remotos.

La solución escala fácilmente. ¡Puede incluir múltiples nombres de dominio en la configuración, y serán permitidos tan pronto como lo despliegue! No hay necesidad de configurar VirtualServices por dominio u otros detalles de enrutamiento. Sin embargo, tenga cuidado, ya que los nombres de dominio están listados en múltiples lugares en la configuración. Si usa herramientas para CI/CD (por ejemplo, Kustomize), es mejor extraer la lista de nombres de dominio en un solo lugar desde el cual puede renderizar en los recursos de configuración requeridos.

¡Eso es todo! Espero que esto haya sido útil. Si es un usuario existente de la solución anterior basada en Nginx, ahora puede migrar a este enfoque antes de actualizar a Istio 1.20, que de otro modo interrumpirá su configuración actual.

¡Feliz enrutamiento SNI!

Referencias

Share this post