1 pontos por GN⁺ 2024-03-14 | 1 comentários | Compartilhar no WhatsApp
  • A Fly.io mudou a forma de reduzir a carga de estado dos gateways WireGuard mantendo a comunicação direta entre flyctl e Fly Machines: em vez de pré-instalar peers, eles passam a ser adicionados ao kernel no momento da conexão
  • O fluxo antigo funcionava com a API GraphQL enviando a configuração do peer via RPC sobre NATS, o wggwd registrando isso no SQLite e no WireGuard do kernel Linux, e então o flyctl se conectando
  • A combinação de perda de mensagens no NATS com a criação de peers descartáveis em jobs de CI fez com que centenas de milhares de peers nunca reutilizados se acumulassem no gateway, deixando operações no kernel e o carregamento após reboot mais lentos
  • O novo método captura pacotes de handshake initiation no filtro BPF ou no caminho de recepção via WebSockets, descriptografa parte do handshake Noise para identificar a chave pública e então busca apenas os peers necessários por meio de uma API HTTP interna
  • Após algumas semanas em produção, a quantidade de peers antigos praticamente desapareceu, e os gateways passaram a lidar com menos estado, configuração de peers mais rápida e reinicializações mais ágeis

Como a Fly.io usa WireGuard

  • A Fly.io executa contêineres em VMs baseadas em Firecracker e usa WireGuard em vários pontos, quase como parte da API para clientes
  • O flyctl, ao iniciar, cria sua própria pilha TCP/IP com um endereço IPv6 próprio e se comunica diretamente com Fly Machines na rede da Fly.io
  • Essa abordagem facilita apresentar recursos como builders remotos de Docker como se estivessem na mesma LAN, mas mantê-la operando de forma confiável é mais difícil
  • No fim, a Fly.io mudou o caminho padrão para WireGuard-over-WebSockets

Fluxo antigo de provisionamento dos gateways

  • A Fly.io conecta conexões WireGuard que entram por vários servidores de gateway ao redor do mundo às redes privadas apropriadas
  • Quando o flyctl precisa se comunicar com uma Fly Machine para build de contêiner, console SSH, cópia de arquivos ou proxy de serviços, ele inicia ou se conecta a um processo de agente em segundo plano
  • Na primeira execução, o agente cria uma nova configuração de peer WireGuard na API GraphQL
    • A configuração do peer é composta pela chave pública e pelos endereços a conectar
  • A API envia essa configuração ao gateway apropriado por RPC no sistema de mensageria NATS
  • O wggwd no gateway recebe a configuração, salva no SQLite, adiciona ao kernel usando a biblioteca WireGuard Go e então responde à API que a instalação foi concluída
  • Quando a API retorna a configuração na requisição GraphQL, o flyctl se conecta usando o peer WireGuard já instalado no gateway

Por que a estrutura antiga ficou lenta

  • O NATS é rápido, mas não garante entrega, o que dificultava seu uso como base de uma API confiável
    • A Fly.io reduziu o uso interno de NATS e, por exemplo, sua API interna flyd passou de NATS para HTTP
    • Mesmo com menos NATS, os gateways WireGuard melhoraram, mas não o suficiente
  • Os peers WireGuard criados permaneciam no gateway mesmo depois que o flyctl encerrava, e não havia processo para limpar peers antigos
    • Havia a decisão de não remover os peers porque o usuário poderia fazer novo deploy no dia seguinte ou depurar com fly ssh console
    • Mas a maioria dos peers era criada em jobs de CI sem armazenamento persistente, e como não era possível reconectar com o mesmo peer na execução seguinte, um novo peer era criado a cada vez
  • Como resultado, os gateways passaram a manter centenas de milhares de peers que talvez nunca fossem reutilizados
    • À medida que o número de peers antigos crescia, as operações do WireGuard no kernel ficavam muito lentas
    • O processo de recarregar todos os peers no kernel após reboot dos servidores de gateway era especialmente lento
    • Também ocorreram alguns kernel panics

Projeto para instalar peers no kernel só quando necessário

  • Manter todo o histórico de peers WireGuard em um único SQLite não é difícil, mas manter todos os peers no kernel Linux se tornou um gargalo
  • Em vez de enviar configurações aos gateways, a Fly.io escolheu um modelo em que o gateway busca sob demanda na API apenas os peers necessários
  • Se o peer for adicionado ao kernel só quando o cliente tenta se conectar, peers antigos podem ser removidos do kernel a qualquer momento
  • Mesmo os peers removidos podem ser buscados e reinstalados na próxima conexão, então o gateway não precisa manter tanto estado de longo prazo
  • Porém, o WireGuard no kernel Linux não tem uma API para assinar eventos de “incoming connection attempt”

Como os peers JIT do WireGuard foram implementados

  • A interface de configuração do WireGuard no kernel Linux é o Netlink, e a biblioteca de controle WireGuard Go usa o wgctrl-go
  • A Fly.io aproveitou o fato de que pedidos de conexão WireGuard são pacotes identificáveis e criou eventos diretamente com filtros BPF e packet socket
  • No caminho WireGuard sobre WebSockets, é ainda mais fácil obter pacotes WireGuard brutos
    • Esse caminho troca pacotes UDP encapsulados por framing com a interface do gateway por meio de conexões WebSocket sem autenticação
    • Como a Fly.io mantém o código desse daemon, foi possível colocar um hook na função de recepção de pacotes
  • O WireGuard não tem os conceitos de “cliente” e “servidor”; é um protocolo ponto a ponto em que peers se conectam ao enviar tráfego
    • Quem inicia primeiro é o initiator, e o outro lado é o responder
    • Na Fly.io, normalmente o flyctl é o initiator e o gateway é o responder
  • O primeiro pacote UDP é chamado de handshake initiation no artigo do WireGuard, e o tipo do pacote é registrado em 1 byte em texto puro
    • A Fly.io captura conexões de entrada com o filtro BPF udp and dst port 51820 and udp[8] = 1

Identificando o peer no handshake Noise

  • O WireGuard é baseado no Noise Protocol Framework, e o Noise esconde identificadores durante o handshake por identity hiding
  • Por isso, não dá para ler do pacote um valor como nome de usuário e localizar diretamente a configuração
  • Para identificar requisições de entrada, a Fly.io executa parte da criptografia do Noise para descriptografar a identidade
    • O código é delicado, mas tem cerca de 200 linhas
    • A interface Netlink do kernel pode fornecer a chave privada da interface a processos com privilégio, permitindo obter os segredos necessários
    • O código relacionado foi publicado em um gist
  • Com isso, eles obtêm um fluxo de eventos com as chaves públicas dos usuários que estão tentando abrir uma conexão WireGuard com o gateway

Otimizações de instalação, cache e retry

  • O gateway mantém no SQLite um cache com limitação de taxa e, ao encontrar um novo peer, busca e instala as informações correspondentes por meio de uma requisição à API HTTP interna
  • Essa lógica se encaixou bem no pequeno daemon que já gerenciava o WireGuard no gateway
  • Peers antigos agora podem ser removidos de forma agressiva com um job de cron
  • A consulta à API para um novo peer pode não ser rápida o bastante para responder imediatamente à primeira mensagem de handshake initiation
    • Como o WireGuard tenta novamente rapidamente, isso não causa problema de funcionamento
  • Um recurso do Netlink do WireGuard no Linux, sugerido por Jason Donenfeld, permite estabelecer a conexão mais rápido
    • A partir da mensagem de initiation recebida, é possível obter a 4-tupla de endereços, incluindo a porta de origem temporária do flyctl
    • O gateway instala o peer como se ele próprio fosse o initiator e o flyctl fosse o responder
    • O kernel Linux inicia a conexão WireGuard em direção ao flyctl, e o protocolo não depende fortemente de papéis de servidor e cliente
    • A nova conexão é estabelecida quase na velocidade de instalação

Resultado em produção

  • Essa abordagem está rodando em produção há algumas semanas
  • A quantidade de peers WireGuard antigos por gateway, que ia de milhares a centenas de milhares, caiu para quase 0
  • O estado que o gateway precisa manter diminuiu
  • A configuração de peers ficou mais rápida
  • Na reinicialização, diminuiu a necessidade de recarregar peers não utilizados no kernel

1 comentários

 
GN⁺ 2024-03-14
Opiniões no Hacker News
  • Não entendo muito bem a afirmação de que o WireGuard no kernel Linux não tem um recurso para instalar peers quando necessário. Parece possível adicionar peers em tempo de execução: https://serverfault.com/questions/1101002/wireguard-client-a...
    Se entendi corretamente, essa etapa já seria tarde demais, e eles parecem querer autenticar antes de adicionar o peer, para que entradas antigas não fiquem na interface
    Então a arquitetura parece ser colocar um filtro eBPF na frente da interface, tentar se conectar diretamente para verificar se a contraparte é aprovada com base no roteamento por chave criptográfica e, se passar, adicionar o peer à interface e removê-lo após um timeout

    • No fim, o que se quer é uma API Netlink em que o WireGuard do kernel exponha a lista de chaves públicas vista nas mensagens do initiator. No médio prazo, Jason também parece querer oferecer algo assim e, com esse feed, não seria necessário pré-instalar nenhum peer do WireGuard
      Os peers ficariam todos em algum lugar como SQLite e seriam instalados sob demanda quando o cliente tentasse se conectar
      Para um provedor de VPN, a API atual é meio grosseira. Embora, na prática, só uma parte dos peers esteja em uso em um dado momento, quando o número de peers cresce de centenas de milhares para dezenas de milhões, armazenar todos eles em uma única instância do kernel se torna inviável
      Se for preciso pré-instalar os peers, no fim eles ficam presos a uma máquina de servidor específica
      Como o texto diz, mesmo hoje é possível criar algo parecido com a interface necessária usando uma captura simples de pacotes e, graças ao bom design da API feito por Jason, é muito fácil inverter a direção de iniciação entre servidor e cliente. Mesmo que o kernel descarte a primeira mensagem de iniciação, para o usuário a conexão parecerá acontecer de forma fluida
      Jann Horn disse que dava para ir um passo além e guardar o pacote de iniciação capturado para reinjetá-lo no kernel depois de instalar o peer, e isso também é uma ideia bem boa
      Não acho que esse texto seja algo que mude vidas; vejo mais como alguns truques elegantes que seria bom as pessoas conhecerem
      O próximo passo é usar isso como base para criar floating peers, separando completamente o peer da localidade. Assim, o usuário não precisaria se preocupar em qual região o peer foi configurado, e isso parece ter benefícios reais de produto, além de ser apenas uma diversão de entusiasta
    • Parece que fizeram isso para evitar a alternativa de rodar o WireGuard fora do kernel. O kernel Linux não tem um recurso para rotear primeiro por endereço criptográfico, mas eles não queriam sair do kernel, então meio que colocaram isso com um hack
      A expressão JIT WireGuard me soa um pouco estranha. Meu primeiro pensamento foi: “por quê? O gargalo de desempenho é a criptografia, e JIT por cliente não vai ajudar nisso”
      Eu provavelmente teria ido direto para user space. Dá para extrair desempenho usando algo como tokio-uring ou glommio
      Se continuarem forçando tudo dentro do kernel, vão continuar batendo em limites, porque o Linux não foi feito para lidar com milhões de túneis ativos. Mesmo milhões de conexões TCP em um único kernel às vezes já são complicados
      Cada limite exige um hack, e cada hack gera configurações de sistema que precisam ser aplicadas e gerenciadas. A cadeia de ferramentas de provisionamento de servidores físicos Linux está muito atrás das ferramentas de desenvolvimento de apps e serviços e de gerenciamento de configuração
      Ou será que eu sou burro e entendi alguma coisa errado?
  • Se você quer criar um peer WireGuard em user space em um app Go, vale olhar este projeto experimental recente: https://github.com/dpeckett/noisysockets
    Ele se baseia no excelente trabalho do wireguard-go, mas tentou ser mais simples de usar como biblioteca e mais idiomático em Go
    Acho que seria interessante criar uma service mesh com isso. Suportar várias linguagens seria difícil, mas talvez desse para implementar uma API de sockets
    Ainda não vi aceleração por hardware para a criptografia do WireGuard, então, em desempenho, talvez seja difícil competir com mTLS
    A propósito, estou procurando trabalho freelance no momento; se precisar de um freelancer Golang na área de redes rápidas e seguras, pode entrar em contato

    • Tenho o sonho de pegar um projeto WireGuard em user space, trocar chaves WireGuard por PAKE em um relay de front-end e, depois, criar túneis diretos com hole punching
      Seria algo como um Magic Wormhole para túneis arbitrários, e espero que também melhore bastante o problema de transferências de arquivos em redes longas e de alta largura de banda que desabam em 20–30 MB/s
    • Fico curioso se o Noisy Transport é, em certa medida, parecido com o Nebula [0] da Slack, ou se sou eu que estou confundindo as coisas
      0 - https://github.com/slackhq/nebula
  • Em geral, concordo que, para mensagens ponto a ponto simples, uma requisição HTTP direta pode ser mais confiável do que passar por uma fila de mensagens, mas achei um pouco surpreendente que tantas mensagens fossem perdidas no NATS a ponto de afetar bastante o serviço
    Quando uma mensagem é perdida, o NATS não retransmite até dar certo? Fico curioso se alguém sabe por que eles sentiram uma instabilidade perceptível

    • Tenho muita curiosidade por mais detalhes. Imagino que os mantenedores do NATS também tenham
      A arquitetura do NATS é intuitiva e atraente, então queria entender onde as coisas saíram dos trilhos. O JetStream tem muitos parâmetros ajustáveis
      Por exemplo, é possível usar streams em memória com uma janela de detecção de duplicatas baseada em tempo, modos push/pull, configurações de retransmissão e políticas de confirmação
      Ainda assim, ele pode não combinar bem com conexões pontuais de mensagem única. De qualquer forma, detalhes mais concretos seriam muito úteis
    • Não estou tentando desmerecer o NATS. É bem provável que nós o estivéssemos usando errado
      Mas, no fim, não precisávamos dele. A camada de mensagens, em vez de nos dar mais expressividade, só tornava testes e monitoramento mais difíceis
    • Se for o caso de usar o core NATS, pelo que sei, por não ser JetStream, não há opção de retransmissão nenhuma
  • A parte “configuramos o peer como se nós fôssemos o initiator e deixamos o flyctl como responder. O kernel Linux reinicia a conexão WireGuard em direção ao flyctl” na prática adiciona meia latência de ida e volta ao handshake?
    Por exemplo, fico curioso se o fluxo é algo como 1) o flyctl envia um Initiation, 2) o peer é adicionado via netlink e um novo Initiation é enviado, 3) o flyctl envia uma Response

    • Pelo que li, os dois peers acabam “achando” que foram eles que iniciaram, mas na prática isso parece não importar
      Ou seja, acho que a etapa 3 não existe ou não precisa ser esperada, e isso certamente seria verdade se o novo início da etapa 2 fosse bloqueado
    • Em linhas gerais, sim. Se você imaginar que “Bob” tem uma política de só poder falar com números que estão na agenda, dá para ver assim
      1. Alice liga para Bob
        1.a) Bob não atende, mas adiciona à agenda o número do identificador de chamadas
      2. Bob liga de volta para esse número, ou seja, para Alice
      3. Alice atende e os dois conversam felizes
  • Não entendi o que quer dizer “toda vez que você executa o flyctl, nossa adorável e enorme CLI cria uma pilha TCP/IP do nada, tem seu próprio endereço IPv6 e se comunica diretamente com Fly Machines em execução na nossa rede”

    • Basicamente quer dizer que ela usa WireGuard em espaço de usuário, como a implementação em Go. É uma abordagem em contraste com o WireGuard dentro do kernel
      A expressão “cria uma pilha TCP/IP do nada” é usada porque normalmente o sistema operacional fornece a pilha TCP/IP como parte do kernel
      No wireguard-go, a pilha TCP/IP roda em espaço de usuário, então ela pode ser criada dentro de um processo comum em espaço de usuário, como a interface de linha de comando flyctl
      Para quem lida com sistemas há muito tempo, isso pode parecer bem mágico. Pilhas TCP/IP em espaço de usuário dentro do próprio processo e realmente utilizáveis são algo relativamente novo e original
    • Escrevi um texto inteiro relacionado a isso: https://fly.io/blog/our-user-mode-wireguard-year/
    • Quer dizer que usa WireGuard
    • Não consigo imaginar muito bem uma CLI enorme que seja adorável
  • Fico curioso sobre o que impede que o primeiro pacote de handshake seja reinjetado na pilha de rede. Assim parece que não haveria perda de pacote
    Também fico curioso sobre o objetivo de verificar udp[8] = 1 no filtro eBPF

    • Nada impede. É uma boa ideia
      Como foi dito no comentário ao lado, o filtro BPF só captura pacotes de iniciação, e esse é o comportamento desejado. É a versão WireGuard de sniffar SYNs para ver o início de uma conexão TCP
    • udp[8] = 1 filtra apenas pacotes de handshake. Sem isso, os pacotes de dados também seriam enviados para o daemon em espaço de usuário
      Não tenho certeza se é possível reproduzir o primeiro handshake, mas talvez seja, já que o WireGuard ignora clientes desconhecidos
    • Parece um helper NFQUEUE que libera o pacote depois de adicionar a chave
  • Acho interessante que, por padrão, eles tunelem o WireGuard sobre WebSocket. Não é bom para desempenho, mas deve ser aceitável para tarefas de estilo DevOps em que o flyctl é usado
    Eu também me perguntava sobre isso ao pensar no futuro do QUIC/HTTP3. A chance de operadores de rede simplesmente bloquearem a porta UDP 443, em vez de tratá-la corretamente, não é zero

    • Claro que também dá para usar WireGuard nativo, e há uma opção de configuração no flyctl
      Se UDP não funciona, não funciona mesmo e é difícil de depurar, então deixamos o padrão no caminho que sabemos com certeza que funciona
      É amargo admitir que perdi o debate sobre qual padrão escolher
  • Minha startup usou a Fly por quase um ano. A funcionalidade principal de transformar código em código implantado em menos de um minuto é realmente linda
    Subir e derrubar novos nós para backfill também leva só alguns segundos
    Mas a empresa em si parecia um pouco imatura. Uma vez, nosso servidor de API ficou inacessível na Fly por 48 horas, e eu não tinha certeza se era erro de configuração meu ou outra indisponibilidade “silenciosa”
    Eles têm um produto “db”, mas é algo na linha de “não é Postgres gerenciado”, e ali também havia interrupções constantes
    Parecia estranho adicionar Postgres como substantivo de primeiro nível na CLI e, ao mesmo tempo, limitar o escopo das funcionalidades suportadas
    O acesso à API do serviço principal também caía com frequência, então era preciso esperar para publicar correções em novos serviços
    Sinto falta da experiência de deploy, mas, sinceramente, hoje estou mais satisfeito com o Cloud Run da GCP. Tem muito menos “surpresas” e a documentação é muito mais completa

    • A experiência de deploy é excelente, mas para mim o killer feature da Fly.io são a rede Anycast e recursos como FLY_REPLAY e LiteFS. Essas coisas tornam o clustering muito fácil
      Acho curioso que provedores de VPS quase não ofereçam suporte para reduzir a latência de serviços de backend para usuários. Nenhum oferece Anycast, e há pouquíssimas opções de GeoDNS
      Dito isso, GeoDNS adiciona uma complexidade à parte
      Eu gostaria que o custo de transferência de dados da Fly.io fosse mais barato. Do jeito que está, para um serviço parecido com ngrok em que estou trabalhando, preciso reimplementar de forma meio desajeitada uma boa parte dos recursos da Fly.io
      [0]: https://lastlogin.io
      [1]: O código específico da Fly necessário para executar o LastLogin de forma distribuída pelo mundo todo é mais ou menos isto: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
    • A Fly parece boa, mas nunca tive oportunidade de usar diretamente. Ainda assim, o Cloud Run da GCP está entre minhas três ferramentas favoritas de infraestrutura e deploy, então a régua está bem alta
    • Tive quase a mesma experiência. Usei a Fly por um ano e migrei para a GCP um ou dois meses atrás; no nosso caso, por alguns motivos, escolhemos GKE
      Quando funcionava bem era muito fluido, mas isso não acontecia com frequência suficiente
  • Quero aproveitar a oportunidade para apresentar o Netmaker[0]
    Não tenho relação com eles; sou apenas alguém que precisa de acesso privado a VPCs da AWS em várias contas e usa a ferramenta com satisfação. Gostaria que ela fosse adotada mais amplamente
    [0] https://www.netmaker.io/

    • O Netmaker é algo como o Tailscale? Só olhando o site, não fica muito claro qual é o diferencial
    • Parece que o Netmaker ou ferramentas parecidas gerenciam as chaves por você, o que deve tornar a administração muito mais fácil
      No emprego anterior, configuramos e gerenciamos wg com Ansible em algumas máquinas Windows e Linux; funcionava bem, mas no fim ficou meio bagunçado
    • Não daria para fazer isso de forma nativa na AWS com private link ou VPC peering? Não conheço muito essa área, então não consigo entender a vantagem do Netmaker
    • É uma plataforma de VPN comum? Fico curioso se é parecida com algo como o Tailscale
      O site é vago demais
  • A parte sobre “um gateway com centenas de milhares de peers, entre eles peers que nunca mais serão usados” foi exatamente o que me veio à cabeça ao ler os primeiros parágrafos
    A ideia de que “não existe uma chamada de API para assinar eventos de tentativa de conexão de entrada. Tudo bem. Nós mesmos criamos o evento. Uma solicitação de conexão do WireGuard é um pacote e é fácil de identificar, então dá para capturá-la com eficiência usando um filtro BPF e um socket de pacotes” também é boa
    Pelo que dizem, ao receber a mensagem inicial de entrada, você passa a ter o endereço de 4-tupla da conexão desejada, incluindo até a porta de origem temporária usada pelo flyctl, e então instala o peer como se nós fôssemos o initiator e o flyctl fosse o responder; fico curioso se isso funciona mesmo atrás de NAT

    • Funciona. Porque o NAT UDP só conhece a 4-tupla. Por exemplo, algo como {wggwd.fly.io, 12345, clientIP, 23456}
      Para o NAT UDP no caminho, tanto um novo pacote UDP do “initiator” quanto uma resposta à mensagem inicial de saída parecem exatamente a mesma coisa
      O critério de decisão é apenas a 4-tupla, e essa 4-tupla é a mesma
    • Se o pacote voltar para o mesmo IP/porta e for gerado a partir do mesmo IP/porta, ele funciona atravessando o NAT