1 puntos por GN⁺ 2024-03-14 | 1 comentarios | Compartir por WhatsApp
  • Fly.io cambió a un enfoque en el que los peers se agregan al kernel en el momento de la conexión, sin instalarlos de antemano, para reducir la carga de estado de las gateways WireGuard mientras mantiene la comunicación directa entre flyctl y Fly Machines
  • El flujo anterior consistía en que la API GraphQL enviaba la configuración del peer mediante RPC de NATS, wggwd la registraba en SQLite y en WireGuard del kernel de Linux, y luego flyctl se conectaba
  • La combinación de pérdida de mensajes de NATS y la creación de peers desechables en trabajos de CI hizo que se acumularan cientos de miles de peers no reutilizados en las gateways, ralentizando las operaciones del kernel y la carga durante reinicios
  • El nuevo enfoque captura paquetes handshake initiation mediante un filtro BPF o la ruta de recepción de WebSockets, descifra parte del handshake de Noise para identificar la clave pública y luego obtiene solo los peers necesarios mediante una API HTTP interna
  • Tras varias semanas en operación, la cantidad de peers antiguos prácticamente desapareció, y las gateways pueden manejar la configuración de peers y los reinicios con menos estado y mayor rapidez

Cómo Fly.io usa WireGuard

  • Fly.io ejecuta contenedores como VM basadas en Firecracker y usa WireGuard en varios lugares como parte de las API para clientes
  • Al ejecutarse, flyctl crea una pila TCP/IP con su propia dirección IPv6 y se comunica directamente con las Fly Machines de la red de Fly.io
  • Este enfoque facilita presentar funciones como builders remotos de Docker como si estuvieran en la misma LAN, pero es más difícil mantenerlo funcionando de forma confiable
  • Fly.io terminó cambiando la ruta predeterminada a WireGuard-over-WebSockets

Flujo anterior de aprovisionamiento de gateways

  • Fly.io conecta las conexiones WireGuard que llegan a varias gateways en todo el mundo con la red privada correspondiente
  • Cuando flyctl necesita comunicarse con una Fly Machine para compilar contenedores, abrir una consola SSH, copiar archivos o hacer proxy de servicios, ejecuta o se conecta a un proceso agente en segundo plano
  • Cuando el agente se ejecuta por primera vez, crea una nueva configuración de peer WireGuard desde la API GraphQL
    • La configuración del peer se compone de una clave pública y la dirección a conectar
  • La API envía esa configuración a la gateway correspondiente mediante RPC del sistema de mensajería NATS
  • wggwd en la gateway recibe la configuración, la guarda en SQLite, la agrega al kernel mediante la biblioteca WireGuard para Go y responde a la API que la instalación terminó
  • Cuando la API devuelve la configuración a la solicitud GraphQL, flyctl se conecta como un peer WireGuard que ya está instalado en la gateway

Por qué la estructura anterior se volvió lenta

  • NATS es rápido, pero no garantiza la entrega, por lo que era difícil usarlo como base de una API confiable
    • Fly.io redujo internamente el uso de NATS; por ejemplo, la API interna flyd pasó de estar basada en NATS a estar basada en HTTP
    • Reducir el uso de NATS mejoró las gateways WireGuard, pero no fue suficiente
  • Los peers WireGuard creados después de cerrar flyctl permanecían en la gateway, y no había un proceso para limpiar peers antiguos
    • Se había optado por no eliminar los peers porque existía la posibilidad de volver a desplegar al día siguiente o depurar con fly ssh console
    • Pero la mayoría de los peers se creaban en trabajos de CI sin almacenamiento persistente, y en la siguiente ejecución no podían reconectarse con el mismo peer, por lo que se creaba uno nuevo cada vez
  • Como resultado, las gateways llegaron a tener cientos de miles de peers que quizá nunca se reutilizarían
    • A medida que aumentaba el número de peers antiguos, las operaciones de WireGuard en el kernel se volvían muy lentas
    • El proceso de volver a cargar todos los peers en el kernel después de reiniciar un servidor gateway era especialmente lento
    • También ocurrieron algunos kernel panics

Diseño para instalar peers en el kernel solo cuando se necesitan

  • No es difícil guardar todo el historial de peers WireGuard en un único SQLite, pero mantener todos los peers en el kernel de Linux se convierte en un cuello de botella
  • Fly.io optó por que la gateway obtenga de la API los peers necesarios bajo demanda, en lugar de empujar la configuración hacia la gateway
  • Si se agregan peers al kernel solo cuando un cliente intenta conectarse, los peers antiguos pueden eliminarse del kernel en cualquier momento
  • Incluso los peers eliminados pueden volver a obtenerse e instalarse en la siguiente conexión, por lo que se reduce la necesidad de que la gateway mantenga estado de largo plazo
  • Sin embargo, WireGuard del kernel de Linux no tiene una API para suscribirse a eventos de “incoming connection attempt”

Cómo se implementan los peers WireGuard JIT

  • La interfaz de configuración de WireGuard del kernel de Linux es Netlink, y la biblioteca de control de WireGuard para Go usa wgctrl-go
  • Fly.io aprovechó que una solicitud de conexión WireGuard es un paquete identificable para crear eventos directamente con un filtro BPF y un packet socket
  • En la ruta WireGuard por WebSockets, es más fácil obtener los paquetes WireGuard sin procesar
    • Esta ruta intercambia paquetes UDP enmarcados mediante una conexión WebSockets sin autenticación con la interfaz de gateway
    • Como Fly.io controla el código de ese daemon, puede engancharse a la función de recepción de paquetes
  • WireGuard no tiene conceptos de “cliente” y “servidor”; es un protocolo punto a punto en el que los peers se conectan entre sí cuando envían tráfico
    • Quien se conecta primero es el initiator, y la contraparte es el responder
    • En Fly.io, normalmente flyctl es el initiator y la gateway es el responder
  • El primer paquete UDP es un handshake initiation según el paper de WireGuard, y el tipo de paquete se registra en un byte en texto plano
    • Fly.io captura conexiones entrantes con el filtro BPF udp and dst port 51820 and udp[8] = 1

Identificar peers en el handshake de Noise

  • WireGuard está basado en Noise Protocol Framework, y Noise oculta los identificadores durante el handshake mediante identity hiding
  • Por eso no se puede leer directamente del paquete un valor como un nombre de usuario para buscar la configuración
  • Fly.io ejecuta parte del cifrado de Noise para descifrar la identidad y así identificar solicitudes entrantes
    • Este código es delicado, pero tiene alrededor de 200 líneas
    • La interfaz Netlink del kernel puede proporcionar la clave privada de la interfaz a procesos con privilegios, lo que permite obtener el secreto necesario
    • El código relacionado está publicado en un gist
  • Con este proceso, se puede obtener un feed de eventos de claves públicas de usuarios que intentan conectarse a WireGuard en la gateway

Instalación, caché y optimización de reintentos

  • La gateway mantiene en SQLite una caché de limitación de velocidad y, cuando descubre un peer nuevo, obtiene e instala la información del peer correspondiente mediante una solicitud a una API HTTP interna
  • Esta lógica encajó bien en el pequeño daemon que ya administraba WireGuard en la gateway
  • Los peers antiguos ahora pueden eliminarse activamente con una tarea cron
  • La consulta a la API para un peer nuevo puede no ser lo bastante rápida como para responder de inmediato al primer mensaje handshake initiation
    • WireGuard reintenta rápidamente, por lo que esto no afecta el funcionamiento en sí
  • Fly.io usa una función de Netlink de WireGuard en Linux que Jason Donenfeld les comentó para establecer la conexión más rápido
    • A partir del mensaje de initiation entrante, obtiene la dirección 4-tuple, incluido el puerto de origen temporal de flyctl
    • La gateway instala el peer como si ella fuera el initiator y flyctl el responder
    • El kernel de Linux inicia la conexión WireGuard hacia flyctl, y el protocolo no depende demasiado de los roles de servidor y cliente
    • La nueva conexión se establece casi a la velocidad a la que puede instalarse

Resultados en producción

  • Este enfoque lleva varias semanas ejecutándose en producción
  • La cantidad de peers WireGuard antiguos, que iba de miles a cientos de miles por gateway, quedó prácticamente en cero
  • Se redujo el estado que deben mantener las gateways
  • La configuración de peers se volvió más rápida
  • Al reiniciar, disminuyó la necesidad de volver a cargar en el kernel peers que no se usan

1 comentarios

 
GN⁺ 2024-03-14
Opiniones en Hacker News
  • No me queda muy claro eso de que WireGuard en el kernel de Linux no tenga la capacidad de instalar peers cuando se necesitan. Parece que también se pueden agregar peers en tiempo de ejecución: https://serverfault.com/questions/1101002/wireguard-client-a...
    Si entendí bien, para entonces ese paso ya es tarde, y lo que intentan es autenticar antes de agregar el peer para que no queden entradas viejas en la interfaz.
    Por eso parece una arquitectura en la que ponen un filtro eBPF delante de la interfaz, verifican directamente si la contraparte está autorizada con base en el enrutamiento por clave criptográfica, y si pasa, agregan el peer a la interfaz y lo eliminan después de un timeout.

    • Al final, lo que se quiere es una API de Netlink con la que WireGuard en el kernel vaya entregando la lista de claves públicas que ve en los mensajes del initiator. A mediano plazo, parece que Jason también quiere ofrecer algo así; con ese feed, no habría necesidad de instalar ni un solo peer de WireGuard por adelantado.
      Los peers podrían estar todos en algo como SQLite y, cuando un cliente intente conectarse, instalarse bajo demanda.
      Desde el punto de vista de un proveedor de VPN, la API actual es algo tosca. Aunque en la práctica solo una parte de los peers está en uso en un momento dado, si la cantidad de peers crece de cientos de miles a decenas de millones, se vuelve imposible guardarlos todos en una sola instancia del kernel.
      Si hay que instalar los peers por adelantado, al final quedan atados a una máquina de servidor específica.
      Como dice el artículo, incluso hoy se puede crear algo parecido a la interfaz necesaria con una simple captura de paquetes, y gracias a que Jason diseñó bien la API, es muy fácil invertir la dirección de inicio entre servidor y cliente. Aunque el kernel haya descartado el primer mensaje de inicio, para el usuario la conexión se siente fluida.
      Jann Horn dijo que se podía ir un paso más allá: guardar el paquete de inicio capturado y reinyectarlo al kernel después de instalar el peer; eso también es una idea bastante buena.
      No creo que este artículo sea algo que cambie la vida, sino más bien unas cuantas técnicas limpias que a la gente le conviene conocer.
      El siguiente paso es construir floating peers sobre esta base para desacoplar por completo los peers de una región. Así el usuario no tendría que preocuparse por en qué región está configurado el peer, y eso parece tener beneficios reales de producto, más allá de ser solo una curiosidad para entusiastas.
    • Parece que lo hicieron así para evitar la alternativa de correr WireGuard fuera del kernel. Linux no tiene una función para enrutar primero por dirección criptográfica, pero como no querían salir del kernel, da la impresión de que lo metieron con un hack.
      La expresión JIT WireGuard se siente un poco rara. Lo primero que pensé fue: “¿por qué? El cuello de botella de rendimiento es el cifrado, y un JIT por cliente no ayudaría en eso”.
      Yo simplemente me habría ido a espacio de usuario. Se podría sacar buen rendimiento usando cosas como tokio-uring o glommio.
      Si siguen empujando todo dentro del kernel, van a chocar una y otra vez con límites, porque Linux no fue hecho para manejar millones de túneles activos. Incluso tener millones de conexiones TCP en un solo kernel a veces es complicado.
      Cada límite requiere un hack, y cada hack trae configuraciones del sistema que hay que aplicar y administrar. La cadena de herramientas para aprovisionar servidores físicos Linux está muy por detrás de las herramientas de desarrollo de apps y servicios y de gestión de configuración.
      ¿O tal vez soy tonto y entendí algo mal?
  • Si quieres crear peers de WireGuard en espacio de usuario desde una app en Go, vale la pena mirar este proyecto experimental reciente: https://github.com/dpeckett/noisysockets
    Se basa en el excelente trabajo de wireguard-go, pero intenté hacerlo más simple de usar como biblioteca y más idiomático en Go.
    Creo que sería interesante construir una malla de servicios con esto. Sería difícil soportar varios lenguajes, pero tal vez se podría implementar una API de sockets.
    Eso sí, todavía no he visto aceleración por hardware para el cifrado de WireGuard, así que en rendimiento podría costarle competir con mTLS.
    Por cierto, ahora estoy buscando trabajo freelance; si necesitan un freelancer de Golang en redes de alta velocidad y seguridad, pueden contactarme.

    • Tengo el sueño de tomar un proyecto de WireGuard en espacio de usuario, intercambiar claves de WireGuard con PAKE en un relay frontal y luego crear un túnel directo mediante hole punching.
      Sería algo como Magic Wormhole para túneles arbitrarios, y espero que también pueda mejorar mucho el problema de que las transferencias de archivos en redes largas de alto ancho de banda se caigan a 20–30 MB/s.
    • Me pregunto si Noisy Transport se parece en cierta medida a Nebula [0] de Slack, o si me estoy confundiendo.
      0 - https://github.com/slackhq/nebula
  • En general estoy de acuerdo en que, para mensajes punto a punto, una solicitud HTTP directa puede ser más confiable que pasar por una cola de mensajes, pero me sorprende un poco que se perdieran tantos mensajes en NATS como para afectar mucho al servicio.
    Si se pierde un mensaje, ¿NATS no lo retransmite hasta que tenga éxito? Me pregunto si alguien sabe por qué experimentaron una inestabilidad tan perceptible.

    • Me interesa mucho conocer más detalles. A los mantenedores de NATS probablemente también.
      La arquitectura de NATS es intuitiva y atractiva, así que me intriga saber dónde se desvió. JetStream tiene muchos parámetros ajustables.
      Por ejemplo, se puede configurar un stream en memoria con ventana de detección de duplicados basada en tiempo, modos push/pull, y políticas de retransmisión y confirmación.
      Dicho eso, puede que no encaje bien con conexiones de un solo mensaje desechables. En cualquier caso, sería muy útil tener detalles más concretos.
    • No quiero menospreciar a NATS. Lo más probable es que nosotros lo estuviéramos usando mal.
      Pero al final no lo necesitábamos. La capa de mensajería, en vez de aportar expresividad, solo hacía más difíciles las pruebas y el monitoreo.
    • Si estaban usando core NATS, tengo entendido que, al no ser JetStream, directamente no hay opción de retransmisión.
  • La parte de “configuramos el peer como si nosotros fuéramos el initiator y dejamos a flyctl como responder. El kernel de Linux reinicia la conexión WireGuard hacia flyctl” ¿en la práctica agrega media ida y vuelta de latencia al handshake?
    Por ejemplo, me pregunto si el flujo sería algo como 1) flyctl envía Initiation, 2) se agrega el peer vía netlink y se envía un nuevo Initiation, 3) flyctl envía Response

    • Según lo que leo, ambos peers “creen” que iniciaron ellos, pero en realidad parece no importar
      Es decir, quizá no exista el paso 3 o no haga falta esperarlo, y si se impide la nueva iniciación del paso 2, creo que definitivamente no sería así
    • En general, sí. Si piensas que “Bob” tiene una política de solo poder hablar con números que están en su libreta de direcciones, se puede ver así
      1. Alice llama a Bob
        1.a) Bob no contesta la llamada, pero agrega el número del identificador de llamadas a su libreta de direcciones
      2. Bob vuelve a llamar a ese número, es decir, a Alice
      3. Alice contesta y ambos conversan felices
  • No entiendo qué significa eso de que “cada vez que ejecutas flyctl, nuestra enorme y adorable CLI crea un stack TCP/IP de la nada, tiene su propia dirección IPv6 y se comunica directamente con Fly Machines que se ejecutan en nuestra red”

    • Básicamente significa que usa WireGuard en espacio de usuario, como la implementación en Go. Es un enfoque distinto a WireGuard dentro del kernel
      La razón por la que dicen que “crea un stack TCP/IP de la nada” es que normalmente el sistema operativo proporciona el stack TCP/IP como parte del kernel
      En wireguard-go, el stack TCP/IP se ejecuta en espacio de usuario, así que puede crearse dentro de un proceso normal de espacio de usuario, como la interfaz de línea de comandos flyctl
      Para quienes llevan mucho tiempo trabajando con sistemas, puede parecer bastante mágico. Los stacks TCP/IP en espacio de usuario, dentro de un proceso y realmente utilizables, son algo relativamente nuevo y novedoso
    • Escribí un artículo completo relacionado con esto: https://fly.io/blog/our-user-mode-wireguard-year/
    • Significa que usa WireGuard
    • Me cuesta imaginar una CLI enorme que sea digna de amor
  • Me pregunto qué impide reinyectar en el stack de red el primer paquete de handshake. Así parecería no haber pérdida de paquetes
    También me da curiosidad cuál es el propósito de comprobar udp[8] = 1 en el filtro eBPF

    • Nada lo impide. Es una buena idea
      Como se dijo en un comentario vecino, el filtro BPF solo captura paquetes de iniciación, y ese es el comportamiento deseado. Es la versión WireGuard de esnifar un SYN para ver el inicio de una conexión TCP
    • udp[8] = 1 filtra solo los paquetes de handshake. Sin eso, los paquetes de datos también se enviarían al daemon en espacio de usuario
      No estoy seguro de si se puede reproducir el primer handshake, pero como WireGuard ignora a clientes desconocidos, quizá sea posible
    • Suena como un helper de NFQUEUE que libera el paquete después de agregar la clave
  • Es interesante que, por defecto, tunelicen WireGuard sobre WebSocket. No es bueno para el rendimiento, pero para tareas tipo DevOps donde se usa flyctl parece estar bien
    También me preguntaba sobre esto al pensar en el futuro de QUIC/HTTP3. No es imposible que los operadores de red, en vez de manejar correctamente UDP en el puerto 443, simplemente lo bloqueen

    • Por supuesto que también se puede usar WireGuard nativo, y flyctl tiene una opción de configuración para eso
      Si UDP no funciona, no funciona en absoluto y además es difícil de depurar, así que dejamos como valor predeterminado lo que sabemos con certeza que funciona
      Me amarga haber perdido el debate sobre qué valor predeterminado elegir
  • Mi startup usó Fly durante casi un año. La funcionalidad clave de convertir código en código desplegado en menos de un minuto es realmente hermosa
    Levantar y bajar nodos nuevos para backfills también toma solo unos segundos
    Pero la empresa en sí me pareció un poco inmadura. Una vez el servidor API estuvo inaccesible en Fly durante 48 horas, y no me quedó claro si fue un error de mi configuración u otra caída “silenciosa”
    Tienen un producto de “db”, pero es algo como “no es Postgres administrado”, y ahí también sufrimos cortes constantes
    Me parecía raro que agregaran Postgres como un sustantivo de primer nivel en la CLI y luego limitaran el alcance de las funciones soportadas
    El acceso a la API del servicio principal también se caía con frecuencia, así que teníamos que esperar para desplegar modificaciones a servicios nuevos
    Extraño la experiencia de despliegue, pero, siendo sincero, ahora estoy más satisfecho con Cloud Run de GCP. Tiene muchas menos “sorpresas” y la documentación está mucho más completa

    • La experiencia de despliegue es excelente, pero para mí las killer features de Fly.io son la red Anycast y funciones como FLY_REPLAY y LiteFS. Todo eso hace que armar clustering sea muy fácil
      Me sorprende que los proveedores de VPS casi no ayuden a reducir la latencia de servicios backend para los usuarios. Ninguno soporta Anycast, y hay muy pocas opciones de GeoDNS
      Aunque GeoDNS agrega complejidad adicional
      Ojalá los costos de transferencia de datos de Fly.io fueran más baratos. Ahora, para un servicio parecido a ngrok en el que estoy trabajando, estoy en una situación donde tengo que reimplementar torpemente buena parte de las funcionalidades de Fly.io
      [0]: https://lastlogin.io
      [1]: El código específico de Fly necesario para ejecutar LastLogin de forma distribuida globalmente es más o menos este: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
    • Fly se ve bien, pero no he tenido oportunidad de probarlo directamente. Dicho eso, Cloud Run de GCP está entre mis tres herramientas favoritas de infraestructura y despliegue, así que el estándar ya estaba bastante alto
    • Tuve casi la misma experiencia. Usé Fly durante un año y me mudé a GCP hace uno o dos meses; en nuestro caso, por ciertas razones, elegimos GKE
      Cuando funcionaba bien era realmente fluido, pero no ocurría con la frecuencia suficiente
  • Quiero aprovechar para presentar Netmaker[0]
    No tengo relación con ellos; solo soy alguien que lo usa con satisfacción porque necesitaba acceso privado a AWS VPC en varias cuentas. Me gustaría que se adoptara más ampliamente
    [0] https://www.netmaker.io/

    • ¿Netmaker es algo como Tailscale? Viendo solo el sitio, no me queda claro cuál es el diferenciador
    • Parece que Netmaker o herramientas similares administran las claves por ti, y eso haría la gestión mucho más sencilla
      En mi trabajo anterior configuré y administré wg con Ansible en algunas máquinas Windows y Linux; estaba bien, pero al final se volvió algo desordenado
    • ¿No se puede hacer de forma nativa de AWS con private link o VPC peering? No conozco mucho de esto, así que no entiendo la ventaja de Netmaker
    • ¿Es una plataforma VPN común? Me pregunto si es parecida a algo como Tailscale
      El sitio es demasiado ambiguo
  • La parte de “un gateway con cientos de miles de peers, de los cuales algunos no se volverán a usar” fue exactamente lo que se me vino a la mente al leer los primeros párrafos
    También es buena la idea de “no hay una llamada de API para suscribirse a eventos de intentos de conexión entrante. Está bien. Crearemos los eventos nosotros mismos. Las solicitudes de conexión de WireGuard son paquetes y se pueden identificar fácilmente, así que se pueden capturar eficientemente con filtros BPF y sockets de paquetes”
    Dicen que, al recibir un mensaje de inicio entrante, se obtiene la dirección de 4-tupla de la conexión deseada, incluido el puerto de origen temporal que usa flyctl, y se instala el peer como si nosotros fuéramos el initiator y flyctl el responder; me pregunto si esto funciona incluso detrás de NAT

    • Funciona. Porque UDP NAT solo conoce la 4-tupla. Por ejemplo, algo como {wggwd.fly.io, 12345, clientIP, 23456}
      Para el UDP NAT en la ruta, un nuevo paquete UDP “initiator” o una respuesta a un mensaje de inicio saliente se ven exactamente igual
      Porque su criterio de decisión es solo la 4-tupla, y esa 4-tupla es la misma
    • Si el paquete vuelve a la misma IP/puerto y se origina desde la misma IP/puerto, atraviesa NAT y funciona