La technique JIT de WireGuard
(fly.io)- Fly.io a modifié son fonctionnement pour réduire la charge d’état des passerelles WireGuard tout en conservant une communication directe entre
flyctlet les Fly Machines, en ajoutant les pairs au noyau au moment de la connexion plutôt qu’en les préinstallant - Le flux existant reposait sur une API GraphQL qui transmettait la configuration des pairs via NATS RPC, puis
wggwdles enregistrait dans SQLite et dans WireGuard du noyau Linux avant queflyctlne se connecte - La perte de messages NATS combinée à la création ponctuelle de pairs par les jobs CI a conduit à l’accumulation de centaines de milliers de pairs jamais réutilisés sur les passerelles, ralentissant les opérations du noyau et le chargement après redémarrage
- La nouvelle approche intercepte les paquets
handshake initiationvia un filtre BPF ou dans le chemin de réception WebSockets, déchiffre une partie du handshake Noise pour identifier la clé publique, puis récupère via une API HTTP interne uniquement les pairs nécessaires - Après quelques semaines en production, le nombre d’anciens pairs a quasiment disparu, et les passerelles peuvent gérer des configurations de pairs et des redémarrages plus rapides avec moins d’état
Comment Fly.io utilise WireGuard
- Fly.io exécute des conteneurs dans des VM basées sur Firecracker et utilise WireGuard à plusieurs endroits, presque comme une partie de son API client
- À l’exécution,
flyctlcrée sa propre pile TCP/IP avec une adresse IPv6 dédiée et communique directement avec les Fly Machines du réseau Fly.io - Cette approche facilite l’exposition de fonctionnalités comme un builder Docker distant comme si elles se trouvaient sur le même LAN, mais elle est plus difficile à exploiter de manière fiable sur la durée
- Fly.io a fini par faire de WireGuard-over-WebSockets son chemin par défaut
Ancien flux de provisioning des passerelles
- Fly.io relie les connexions WireGuard entrantes à des réseaux privés appropriés via plusieurs serveurs passerelles répartis dans le monde
- Quand
flyctldoit communiquer avec une Fly Machine pour construire un conteneur, ouvrir une console SSH, copier des fichiers ou proxifier un service, il lance ou contacte un processus agent en arrière-plan - Lors de son premier lancement, l’agent crée une nouvelle configuration de pair WireGuard via l’API GraphQL
- La configuration du pair se compose d’une clé publique et d’adresses à connecter
- L’API transmet ensuite cette configuration à la passerelle appropriée via un RPC du système de messagerie NATS
- Sur la passerelle,
wggwdreçoit la configuration, l’enregistre dans SQLite, l’ajoute au noyau via la bibliothèque WireGuard Go, puis répond à l’API que l’installation est terminée - Quand l’API renvoie la configuration à la requête GraphQL,
flyctlse connecte alors au pair WireGuard déjà installé sur la passerelle
Pourquoi l’ancienne architecture est devenue lente
- NATS est rapide, mais ne garantit pas la livraison, ce qui le rend difficile à utiliser comme base fiable pour une API
- Fly.io a réduit son usage interne de NATS et, par exemple, son API interne
flydest passée d’une base NATS à une base HTTP - Même avec cette réduction, l’amélioration des passerelles WireGuard restait insuffisante
- Fly.io a réduit son usage interne de NATS et, par exemple, son API interne
- Les pairs WireGuard créés après la fermeture de
flyctlrestaient sur les passerelles, sans processus de nettoyage des anciens pairs- Ils avaient choisi de ne pas supprimer les pairs, car une nouvelle tentative de déploiement ou de débogage avec
fly ssh consolele lendemain restait possible - Mais la plupart des pairs étaient créés par des jobs CI sans stockage persistant, qui ne pouvaient pas se reconnecter avec le même pair à l’exécution suivante, créant donc un nouveau pair à chaque fois
- Ils avaient choisi de ne pas supprimer les pairs, car une nouvelle tentative de déploiement ou de débogage avec
- Résultat : les passerelles ont accumulé des centaines de milliers de pairs potentiellement jamais réutilisés
- Avec l’augmentation du nombre d’anciens pairs, les opérations WireGuard du noyau sont devenues très lentes
- Le rechargement de tous les pairs dans le noyau après le redémarrage d’un serveur passerelle était particulièrement lent
- Quelques kernel panics sont même survenus
Concevoir une installation des pairs dans le noyau uniquement quand c’est nécessaire
- Stocker tout l’historique des pairs WireGuard dans une seule base SQLite n’est pas difficile, mais conserver tous les pairs dans le noyau Linux devient le vrai goulot d’étranglement
- Au lieu de pousser la configuration vers les passerelles, Fly.io a choisi de laisser les passerelles récupérer à la demande les pairs nécessaires via l’API
- En ajoutant un pair au noyau uniquement quand un client tente de se connecter, les anciens pairs peuvent être supprimés du noyau à tout moment
- Même si un pair supprimé est requis plus tard, il suffit de le récupérer et de l’installer à nouveau lors de la prochaine connexion, ce qui réduit le besoin pour la passerelle de conserver un état durable
- En revanche, le noyau Linux de WireGuard ne fournit pas d’API permettant de s’abonner à un événement de « tentative de connexion entrante »
Mise en œuvre des pairs WireGuard JIT
- L’interface de configuration de WireGuard dans le noyau Linux repose sur Netlink, et la bibliothèque de contrôle WireGuard Go utilise wgctrl-go
- Fly.io exploite le fait qu’une requête de connexion WireGuard est un paquet identifiable pour créer directement des événements avec un filtre BPF et un packet socket
- Sur le chemin WireGuard via WebSockets, il est encore plus facile d’obtenir les paquets WireGuard bruts
- Ce chemin échange avec l’interface de la passerelle des paquets UDP encapsulés dans une connexion WebSockets non authentifiée
- Comme Fly.io possède le code de ce démon, il peut accrocher un hook à la fonction de réception des paquets
- WireGuard ne distingue pas « client » et « serveur » ; c’est un protocole point à point où les pairs se connectent quand ils ont du trafic à envoyer
- Celui qui initie la connexion est l’initiator, l’autre est le responder
- Chez Fly.io, c’est généralement
flyctlqui joue l’initiator, et la passerelle le responder
- Le premier paquet UDP est, selon l’article WireGuard, un
handshake initiation, et le type de paquet est enregistré sur 1 octet en clair- Fly.io capture les connexions entrantes avec le filtre BPF
udp and dst port 51820 and udp[8] = 1
- Fly.io capture les connexions entrantes avec le filtre BPF
Identifier un pair dans le handshake Noise
- WireGuard est basé sur le Noise Protocol Framework, et Noise masque les identifiants pendant le handshake via identity hiding
- Il n’est donc pas possible de lire directement une valeur comme un nom d’utilisateur dans le paquet pour retrouver la configuration correspondante
- Pour identifier les requêtes entrantes, Fly.io exécute une partie du chiffrement Noise afin de déchiffrer l’identité
- Ce code est délicat, mais il tient en environ 200 lignes
- L’interface Netlink du noyau peut fournir à un processus privilégié la clé privée de l’interface, ce qui permet d’obtenir les secrets nécessaires
- Le code associé est publié dans ce gist
- Ce processus permet d’obtenir un flux d’événements des clés publiques des utilisateurs qui tentent une connexion WireGuard vers la passerelle
Optimisations d’installation, de cache et de retry
- La passerelle maintient dans SQLite un cache avec limitation de débit et, lorsqu’elle découvre un nouveau pair, récupère et installe les informations correspondantes via une requête HTTP à une API interne
- Cette logique s’intègre bien dans le petit démon qui gérait déjà WireGuard sur la passerelle
- Les anciens pairs peuvent désormais être supprimés activement par une tâche
cron - Une requête API pour un nouveau pair peut ne pas être assez rapide pour répondre immédiatement au premier message
handshake initiation- WireGuard réessaie rapidement, donc cela ne pose pas de problème de fonctionnement
- Fly.io s’appuie sur une fonctionnalité Netlink de Linux WireGuard signalée par Jason Donenfeld pour établir les connexions plus vite
- Le message d’initiation entrant permet d’obtenir le 4-tuple d’adresses, y compris le port source éphémère de
flyctl - La passerelle installe alors le pair comme si elle était l’initiator et
flyctlle responder - Le noyau Linux démarre la connexion WireGuard vers
flyctl, et le protocole ne dépend pas fortement d’un rôle serveur ou client - Les nouvelles connexions s’établissent ainsi à une vitesse proche du temps d’installation
- Le message d’initiation entrant permet d’obtenir le 4-tuple d’adresses, y compris le port source éphémère de
Résultats en production
- Cette approche fonctionne en production depuis plusieurs semaines
- Le nombre d’anciens pairs WireGuard, qui allait de quelques milliers à plusieurs centaines de milliers par passerelle, est retombé quasiment à 0
- La quantité d’état que les passerelles doivent conserver a diminué
- La configuration des pairs est devenue plus rapide
- Au redémarrage, il y a moins besoin de recharger dans le noyau des pairs inutilisés
1 commentaires
Avis de Hacker News
Je ne comprends pas bien l’affirmation selon laquelle WireGuard dans le noyau Linux n’aurait pas de fonction permettant d’installer des pairs à la demande. Il semble possible d’ajouter des pairs à l’exécution : https://serverfault.com/questions/1101002/wireguard-client-a...
Si j’ai bien compris, cette étape arrive déjà trop tard, et ils semblent vouloir authentifier avant d’ajouter le pair, afin d’éviter que d’anciennes entrées ne restent dans l’interface.
La structure semble donc être la suivante : placer un filtre eBPF devant l’interface, vérifier directement s’il s’agit d’un correspondant autorisé sur la base du routage par clé cryptographique, puis, si c’est validé, ajouter le pair à l’interface et le supprimer après expiration du délai.
Les pairs pourraient tous se trouver dans quelque chose comme SQLite, puis être installés à la demande lorsqu’un client tente de se connecter.
Du point de vue d’un fournisseur de VPN, l’API actuelle est un peu rudimentaire. Il y a certes le fait qu’à un instant donné seule une partie des pairs est réellement utilisée, mais quand leur nombre passe de centaines de milliers à des dizaines de millions, les stocker tous dans une seule instance du noyau devient tout simplement impossible.
Si les pairs doivent être installés à l’avance, on finit par les lier à une machine serveur précise.
Comme le dit l’article, il est déjà possible aujourd’hui de créer quelque chose qui ressemble à l’interface nécessaire avec une simple capture de paquets, et grâce à la bonne conception de l’API par Jason, il est très facile d’inverser le sens d’initiation entre serveur et client. Même si le noyau a jeté le premier message d’initiation, l’utilisateur aura l’impression que la connexion s’établit sans accroc.
Jann Horn disait qu’on aurait même pu aller plus loin : conserver le paquet d’initiation capturé, puis le réinjecter dans le noyau après l’installation du pair. C’est aussi une assez bonne idée.
Je ne pense pas que cet article change une vie ; c’est plutôt une série de petites astuces propres que les gens gagneraient à connaître.
L’étape suivante consisterait à s’appuyer là-dessus pour créer des floating peers, afin de découpler complètement les pairs d’une région. Ainsi, l’utilisateur n’aurait pas à se soucier de la région dans laquelle son pair est configuré, et cela semble offrir de vrais avantages produit, au-delà du simple amusement de geek.
L’expression JIT WireGuard me paraît un peu étrange. Ma première réaction a été : « pourquoi ? Le goulot d’étranglement en performance, c’est le chiffrement, et un JIT par client n’aidera pas là-dessus. »
À leur place, je serais simplement passé en espace utilisateur. On peut obtenir de bonnes performances avec des choses comme tokio-uring ou glommio.
S’ils continuent à pousser dans le noyau, ils vont constamment buter sur des limites, car Linux n’a pas été conçu pour gérer des millions de tunnels actifs. Même des millions de connexions TCP dans un seul noyau, c’est parfois délicat.
Chaque limite nécessite un hack, et chaque hack apporte des paramètres système à appliquer et à gérer. La chaîne d’outils de provisionnement de serveurs physiques Linux est très en retard par rapport aux outils de développement applicatif, de services et de gestion de configuration.
À moins que je sois idiot et que j’aie mal compris quelque chose ?
Si vous voulez créer des pairs WireGuard en espace utilisateur dans une appli Go, vous pouvez jeter un œil à ce projet expérimental récent : https://github.com/dpeckett/noisysockets
Il s’appuie sur l’excellent travail de wireguard-go, mais vise à être plus simple à utiliser comme bibliothèque et plus idiomatique en Go.
Ce serait intéressant d’en faire un service mesh. Il serait sans doute difficile de prendre en charge plusieurs langages, mais on pourrait peut-être implémenter une API de sockets.
Cela dit, je n’ai pas encore vu d’accélération matérielle pour le chiffrement WireGuard, donc côté performances il pourrait être difficile de rivaliser avec mTLS.
Au passage, je cherche actuellement des missions freelance ; si vous avez besoin d’un freelance Golang dans le réseau haute performance et sécurisé, vous pouvez me contacter.
Ce serait une sorte de Magic Wormhole pour tunnels arbitraires, avec l’espoir d’améliorer fortement le problème des transferts de fichiers qui s’effondrent à 20–30 MB/s sur des réseaux longs et à forte bande passante.
0 - https://github.com/slackhq/nebula
Je suis globalement d’accord pour dire que, pour des messages point à point uniques, des requêtes HTTP directes peuvent être plus fiables que de passer par une file de messages, mais je suis un peu surpris que NATS ait perdu autant de messages au point d’avoir un impact important sur le service.
Quand des messages sont perdus, NATS ne les retransmet-il pas jusqu’au succès ? Je me demande si quelqu’un sait pourquoi ils ont subi une instabilité perceptible à ce point.
L’architecture de NATS est intuitive et séduisante, donc je serais curieux de savoir où ça a déraillé. JetStream offre beaucoup de paramètres ajustables.
Par exemple, on peut configurer un stream en mémoire avec une fenêtre de déduplication temporelle, un mode push/pull, ainsi que les politiques de retransmission et d’accusé de réception.
Cela dit, ce n’est peut-être pas adapté à des connexions ponctuelles à message unique. Dans tous les cas, des détails plus précis seraient très utiles.
Mais au final, nous n’en avions pas besoin. Plutôt que d’apporter de l’expressivité, la couche de messages rendait surtout les tests et le monitoring plus difficiles.
Le passage disant « on installe le pair comme si nous étions l’initiator, et on laisse flyctl comme responder. Le noyau Linux relance la connexion WireGuard vers flyctl » ajoute-t-il en pratique une demi-latence aller-retour au handshake ?
Par exemple, je me demande si le flux ressemble à : 1) flyctl envoie une Initiation, 2) le pair est ajouté via netlink et une nouvelle Initiation est envoyée, 3) flyctl envoie une Response
Autrement dit, je pense qu’il n’y a pas d’étape 3, ou pas besoin de l’attendre, et que ce serait clairement le cas si l’on empêchait la nouvelle initiation de l’étape 2
1.a) Bob ne répond pas, mais ajoute le numéro de l’appelant à son carnet d’adresses
Je ne comprends pas ce que signifie « à chaque exécution de flyctl, notre adorable et énorme CLI crée une pile TCP/IP à partir de rien, dispose de sa propre adresse IPv6 et communique directement avec les Fly Machines qui tournent sur notre réseau »
Ils disent « crée une pile TCP/IP à partir de rien » parce que, d’habitude, le système d’exploitation fournit la pile TCP/IP comme une partie du noyau
Avec wireguard-go, la pile TCP/IP s’exécute en espace utilisateur, donc elle peut être créée dans un processus utilisateur ordinaire comme l’interface en ligne de commande flyctl
Pour quelqu’un qui administre des systèmes depuis longtemps, ça peut paraître assez magique. Les piles TCP/IP en espace utilisateur, intégrées au processus et réellement utilisables, sont relativement récentes et originales
Je me demande ce qui empêche de réinjecter dans la pile réseau le tout premier paquet de handshake. Comme ça, il n’y aurait apparemment pas de perte de paquet
Je me demande aussi à quoi sert de vérifier
udp[8] = 1dans le filtre eBPFComme indiqué dans le commentaire voisin, le filtre BPF ne capture que les paquets d’initiation, et c’est bien le comportement recherché. C’est la version WireGuard du fait de sniffer un SYN pour observer le début d’une connexion TCP
udp[8] = 1ne filtre que les paquets de handshake. Sans ça, les paquets de données seraient aussi envoyés au démon en espace utilisateurJe ne suis pas sûr qu’on puisse rejouer le premier handshake, mais comme WireGuard ignore les clients inconnus, c’est peut-être possible
Je trouve intéressant que, par défaut, WireGuard soit tunnelé au-dessus de WebSocket. Ce n’est pas idéal pour les performances, mais pour les tâches à tendance DevOps où flyctl est utilisé, ça semble acceptable
Je me posais aussi ce genre de question en réfléchissant à l’avenir de QUIC/HTTP3. Il n’est pas impossible que des opérateurs réseau bloquent tout simplement le port UDP 443 plutôt que de le gérer correctement
flyctlpropose une option de configuration pour çaQuand UDP ne fonctionne pas, il ne fonctionne tout simplement pas et c’est difficile à déboguer ; nous avons donc choisi par défaut la solution dont nous savons qu’elle fonctionne à coup sûr
Ça me laisse un goût amer d’avoir perdu le débat sur le choix de la valeur par défaut
Ma startup a utilisé Fly pendant presque un an. La fonctionnalité centrale qui transforme du code en code déployé en moins d’une minute est vraiment belle
Monter et démonter de nouveaux nœuds pour du backfill ne prend aussi que quelques secondes
Mais l’entreprise elle-même m’a paru un peu immature. Une fois, notre serveur API est resté inaccessible sur Fly pendant 48 heures, et je n’étais pas certain que ce soit une erreur de configuration de ma part ou encore une autre panne « silencieuse »
Ils ont un produit « db », mais présenté comme « pas du Postgres managé », et nous y avons aussi connu des interruptions régulières
J’ai trouvé étrange d’ajouter Postgres comme nom de premier niveau dans la CLI tout en limitant l’étendue des fonctionnalités prises en charge
L’accès à l’API du service principal tombait aussi souvent, ce qui nous obligeait à attendre pour déployer des modifications sur de nouveaux services
L’expérience de déploiement me manque, mais honnêtement je suis aujourd’hui plus satisfait de Cloud Run de GCP. Il y a beaucoup moins de « surprises » et la documentation est bien plus aboutie
Je trouve étonnant que les fournisseurs de VPS fassent si peu pour aider à réduire la latence des services backend pour les utilisateurs. Aucun ne prend en charge l’Anycast, et les options GeoDNS sont très rares
Cela dit, GeoDNS ajoute une complexité supplémentaire
J’aimerais que les coûts de transfert de données de Fly.io soient plus bas. Pour l’instant, pour un service ressemblant à ngrok sur lequel je travaille, je me retrouve à réimplémenter maladroitement une bonne partie des fonctionnalités de Fly.io
[0]: https://lastlogin.io
[1]: Le code spécifique à Fly nécessaire pour exécuter LastLogin de manière distribuée dans le monde entier tient à peu près à ça : https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
Quand ça marchait bien, c’était vraiment fluide, mais ce n’était pas assez souvent le cas
Je voudrais profiter de l’occasion pour présenter Netmaker[0]
Je n’y suis pas affilié ; je suis simplement quelqu’un qui l’utilise avec satisfaction parce que j’ai besoin d’accéder à des VPC AWS privés répartis sur plusieurs comptes. J’aimerais qu’il soit plus largement adopté
[0] https://www.netmaker.io/
Dans mon précédent emploi, on configurait et gérait wg avec Ansible sur quelques machines Windows et Linux ; ça marchait correctement, mais à la fin c’était devenu un peu brouillon
Le site est beaucoup trop vague
« Une passerelle avec des centaines de milliers de pairs, dont certains ne seront plus jamais utilisés » : c’est exactement ce qui m’est venu à l’esprit en lisant les premiers paragraphes
J’aime aussi l’idée suivante : « Il n’existe pas d’appel API permettant de s’abonner aux événements de tentative de connexion entrante. Pas grave. Nous allons créer nous-mêmes ces événements. Une requête de connexion WireGuard est un paquet, et elle est facile à identifier ; on peut donc l’attraper efficacement avec un filtre BPF et un socket paquet »
Quand on reçoit un message d’initiation entrant, on obtient l’adresse 4-tuple de la connexion voulue, y compris le port source temporaire utilisé par flyctl, puis on installe le pair comme si nous étions l’initiateur et flyctl le répondeur ; je me demande si cela fonctionne aussi derrière un NAT
{wggwd.fly.io, 12345, clientIP, 23456}Qu’il s’agisse d’un nouveau paquet UDP « initiateur » ou d’une réponse à un message d’initiation sortant, pour le NAT UDP sur le chemin, cela paraît exactement identique
La décision ne repose que sur le 4-tuple, et ce 4-tuple est le même