Service de rendez-vous de Linkpearl : signalisation, boîtes aux lettres et relais chiffré. Auto-hébergeable.
  • C# 99.6%
  • Shell 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Yann Rapenne 7a40a43891 fix(build): la publication autonome restaure en mode verrouillé
Publiée en un seul fichier, l'application faisait ajouter par le SDK une
référence implicite à Microsoft.NET.ILLink.Tasks, absente du verrou :
la release échouait à la restauration. L'analyseur qui la demande est
coupé.
2026-10-03 21:13:01 +02:00
.github test: deux tests encore fragiles sur le runner, et un workflow qui les lance à chaque poussée 2026-10-03 21:09:03 +02:00
build feat(rendezvous): service de rendez-vous, dans son propre dépôt 2026-09-22 21:06:24 +02:00
deploy chore(déploiement): « Eorzea Events Linkpearl Primary », libellé du service principal 2026-09-27 17:59:18 +02:00
docs docs: ce que le service voit vraiment, et l'absence de TLS 2026-10-03 20:30:31 +02:00
Linkpearl.Rendezvous fix(build): la publication autonome restaure en mode verrouillé 2026-10-03 21:13:01 +02:00
Linkpearl.Rendezvous.Tests test: deux tests encore fragiles sur le runner, et un workflow qui les lance à chaque poussée 2026-10-03 21:09:03 +02:00
Protocol chore(protocole): recopie de RendezvousWire et BanList depuis le plugin 2026-10-03 20:02:27 +02:00
.gitignore feat(rendezvous): service de rendez-vous, dans son propre dépôt 2026-09-22 21:06:24 +02:00
CLAUDE.md docs: ce que le service voit vraiment, et l'absence de TLS 2026-10-03 20:30:31 +02:00
Directory.Build.props chore(build): paquets figés par packages.lock.json, restauration verrouillée en CI 2026-10-03 20:25:21 +02:00
Linkpearl.Rendezvous.slnx feat(rendezvous): service de rendez-vous, dans son propre dépôt 2026-09-22 21:06:24 +02:00
README.md docs: ce que le service voit vraiment, et l'absence de TLS 2026-10-03 20:30:31 +02:00

Linkpearl, service de rendez-vous

Le seul service de Linkpearl, la synchronisation pair à pair de l'apparence moddée dans Final Fantasy XIV.

Il aide deux pairs à se trouver, et relaie des octets chiffrés quand la connexion directe échoue. Il ne stocke ni ne redistribue le moindre fichier de mod.

Ce qu'il voit, et ce qu'il ne voit pas

Il ne voit ni manifeste ni fichier. Les sessions entre pairs sont chiffrées de bout en bout, relais compris : le service transporte des octets qu'il ne sait pas lire. Pour l'appariement, les pairs s'annoncent sous un jeton opaque dérivé d'un secret de paire qu'il ne connaît pas, et qui change toutes les dix minutes : deux fenêtres ne se relient pas par ce jeton.

Il voit, en revanche, des noms de personnage et des clés publiques. Les demandes de pairage et d'admission dans un groupe, et leurs réponses, passent par les boîtes aux lettres du service, en clair : le nom, le monde et la clé publique de qui demande y sont lisibles, et pour une admission le code du groupe aussi. Le service ne les journalise ni ne les conserve, mais un opérateur qui modifierait son service pourrait les lire, les garder, et s'intercaler au premier contact. C'est la limite principale du protocole, assumée et décrite dans le modèle de confiance du plugin.

Il voit qui est en ligne, et n'importe qui peut le lui demander. Une adresse de boîte personnelle dérive du nom de personnage, par une empreinte rapide : connaître nom@monde suffit à la calculer. Interroger la présence de cette boîte (MailboxQuery), ou y déposer une demande et lire la réponse « destinataire absent », dit à quiconque si ce personnage a le plugin ouvert en ce moment, sans être pairé avec lui. Le service ne peut pas l'empêcher sans casser la découverte ; il borne seulement le débit, par adresse, par /64 et par /48 en IPv6 (--rate). L'adresse tourne toutes les trente minutes, ce qui empêche de relier deux périodes par l'adresse seule, pas de réinterroger le même nom. Une boîte personnelle se réclame en exclusivité (MailboxClaim, 0x1D, réponse MailboxClaimed 0x1E) : un tiers qui connaît le nom ne peut plus l'ouvrir en même temps que son titulaire pour lire ce qui lui est adressé, mais il peut toujours tester sa présence.

Il voit les adresses IP, et lesquelles s'apparient ou relaient ensemble dans une fenêtre : c'est irréductible sans relais systématique.

Il n'y a pas de TLS. Le port du service parle un protocole binaire en clair sur TCP et UDP. Ce qui compte est chiffré par les pairs eux-mêmes, mais tout ce que le service voit en clair, un observateur du réseau entre le joueur et le service le voit aussi : noms, mondes et clés des demandes de pairage, adresses de boîtes interrogées, jetons d'appariement. Et un tel observateur peut modifier ces trames comme le pourrait un opérateur malveillant, donc s'intercaler au premier contact. La liste signée du réseau ouvert est protégée par sa signature, pas par le transport. La console, elle, est en HTTP clair et n'écoute qu'en local (voir plus bas).

Le rendez-vous n'est une autorité qu'au pairage (hors du rôle d'autorité du réseau ouvert, plus bas, qui ne voit pas plus de clés ni de noms que ce qui précède). L'autorisation vient du carnet de pairs local de chaque joueur, mais la clé publique d'un pair y arrive par la boîte aux lettres du service, en clair : un opérateur malveillant, ou quiconque sur le chemin réseau, peut s'intercaler au premier contact, sans que personne s'en aperçoive. Une fois la clé épinglée dans le carnet, il peut faire échouer une connexion, jamais usurper une identité. Le modèle de confiance complet est dans docs/protocol.md du plugin.

Lancer

dotnet run --project Linkpearl.Rendezvous -- --port 47900 --rate 60
dotnet test Linkpearl.Rendezvous.Tests/Linkpearl.Rendezvous.Tests.csproj

--port sert en TCP et en UDP. 443 est un choix raisonnable pour les réseaux restrictifs, la charge utile étant de toute façon chiffrée de bout en bout par les pairs eux-mêmes. --rate borne les trames par minute et par adresse (annonces, relais, boîtes, candidatures), un /64 comptant pour une adresse en IPv6. Un settings.json écrit depuis la console le surcharge.

Le journal dit ce qui se passe, jamais à qui : ni adresse IP, ni fragment de jeton ou de boîte, parce qu'un journal est un fichier qui reste, relu et copié. --verbose rend le détail, adresses comprises, pour diagnostiquer une soirée.

Les trames de ticket d'invitation (TicketRegister, TicketRedeem) ne sont plus servies : le plugin ne s'en sert plus, et le service y répond « trame inattendue » comme à toute trame inconnue.

Héberger un serveur

curl -fsSL https://linkpearl-sync.github.io/install.sh | sudo bash -s -- --public-address rdv.exemple.org --label "Mon service"

Le générateur https://linkpearl-sync.github.io/heberger.html écrit cette ligne. Le script télécharge la dernière release, vérifie lprdv.sha256 avant de rien poser, installe le binaire, lprdv.service, lprdv-update.service et lprdv-update.timer, et écrit les options dans le complément /etc/systemd/system/lprdv.service.d/options.conf. Options : --port, --public-address, --label, --no-announce, --no-firewall, --no-auto-update, --auto-update. Il est rejouable, et relancé sans option il garde celles déjà posées. Il ne touche jamais à /var/lib/lprdv. Son détail est dans le README du site.

Chaque release publie lprdv, lprdv.service, lprdv-update.service, lprdv-update.timer, lprdv.sha256 (qui couvre le binaire et les trois unités), ainsi que lprdv.release.json et sa signature lprdv.release.json.sig, qui couvrent eux aussi le binaire et les trois unités. Le workflow ne publie qu'un commit déjà sur main, restaure les paquets en mode verrouillé (packages.lock.json), et crée la release en brouillon avant de la publier complète : une release publiée est immuable.

Déployer la production

LPRDV_HOST=debian@203.0.113.7 LPRDV_KEY=~/.ssh/ma_cle ./deploy/deploy.sh

Le script compile un binaire autonome, le pose dans /opt/lprdv/, installe l'unité deploy/lprdv.service et son complément deploy/production.conf (les options de rdv.linkpearl.eorzea.events), redémarre le service, puis attend que /healthz réponde. L'unité reste générique, car c'est elle que publie chaque version et que chacun installe : les options propres à un serveur vont toujours dans un complément. Il lui faut un compte distant avec sudo, et il est rejouable : le premier passage crée le compte système lprdv, et chaque passage repose le binaire, l'unité et production.conf.

Le service tourne sous lprdv, sans capacité, avec le système de fichiers en lecture seule sauf /var/lib/lprdv, où vit tout l'état, et redémarre seul s'il tombe. Un service lancé à la main dans un terminal SSH s'arrête avec lui, et personne ne le voit : c'est ce qui est arrivé au premier déploiement public. journalctl -u lprdv donne le journal.

Mise à jour automatique

Les serveurs installés par install.sh (site) se mettent à jour seuls : lprdv-update.timer lance lprdv update quinze minutes après le démarrage, puis chaque heure, décalé au hasard jusqu'à une heure. Une release n'est installée que si son manifeste lprdv.release.json porte une signature d'une clé de ReleaseKeys.cs, et seulement 24 heures après sa signature. Une release urgente n'attend qu'une heure : le message de son tag annoté porte une ligne urgent seule, et le tag est signé par une clé SSH inscrite dans .github/release-signers (git tag -s -a vX.Y.Z -m vX.Y.Z -m urgent), sans quoi la publication échoue. L'heure de garde est inscrite dans le binaire installé, hors de portée du workflow, et la release se publie sous le nom « vX.Y.Z (urgente) » avec un avertissement en tête de ses notes. Une version qui ne répond pas sur /healthz dans les 30 secondes est défaite (retour à lprdv.previous) et n'est plus retentée. Retirer une release pendant les 24 heures (la supprimer, ou la passer en pré-version) suffit à ce que personne ne l'installe. La production n'a pas de minuteur : deploy.sh la met à jour, et elle essuie chaque version la première.

La ronde ne pose que lprdv et lprdv.service : un changement de lprdv-update.service ou du minuteur demande de relancer install.sh. Le manifeste signé couvre aussi ces deux unités, qui tournent en root ; un manifeste antérieur, qui ne les porte pas, reste lisible. Avant de rien poser, elle vérifie que le service répond sur http://127.0.0.1:47901/healthz : un service arrêté par son opérateur n'est pas relancé, et un service lancé avec --no-admin ou un autre --admin-port n'est jamais mis à jour. Une pré-version ne porte pas de manifeste et n'est jamais installée. Un binaire compilé sans -p:Version se dit 0.0.0-dev et n'est jamais touché, pas plus qu'une version à suffixe comme en produit git describe hors d'un tag. Couper : systemctl disable --now lprdv-update.timer.

La clé privée vit dans le secret LPRDV_RELEASE_KEY de l'environnement GitHub release (tags v* seulement) et hors ligne chez le mainteneur. Elle est distincte de directory.key.

Ce qui est sur le disque

Rien de ce que le rendez-vous fait : les jetons, les attentes et les boîtes ouvertes vivent en mémoire et disparaissent avec le processus. Redémarrer n'efface donc aucune trace d'usage, puisqu'il n'y en a pas.

Ce qui est sur le disque, en revanche : peers.txt (l'annuaire, écrit par l'opérateur) et pending.txt (les candidatures reçues, écrites par le service), bans.json pour la liste de bannissement et son sel, settings.json pour les réglages changés depuis la console, admin.token pour la console, et, pour une autorité, directory.key (sa clé de signature) et authority.json (les services suivis, l'adresse d'où vient chaque candidature et les 144 dernières sondes de chacun). Ceux que le service réécrit le sont d'un bloc, à côté puis renommés, pour qu'un arrêt du processus laisse l'ancien contenu plutôt qu'un fichier tronqué. admin.token et directory.key sont créés une fois, lisibles par leur seul propriétaire.

Sur un serveur mis à jour automatiquement, /var/lib/lprdv-update tient deux marqueurs : pending (une version posée et pas encore confirmée par /healthz) et refused (la dernière version défaite, jamais retentée). La version remplacée reste à côté, en /opt/lprdv/lprdv.previous et /etc/systemd/system/lprdv.service.previous. Les options d'un serveur vivent dans /etc/systemd/system/lprdv.service.d/ (options.conf par install.sh, production.conf par deploy.sh), que la mise à jour ne touche pas.

La console

Une page, sur http://127.0.0.1:47901/, qui montre l'état du service (version, mémoire, santé des deux boucles, connexions, relais, refus par motif), les compteurs et leur historique sur trois minutes, une heure ou vingt-quatre heures, l'annuaire, la liste de bannissement et les réglages, et qui permet d'approuver une candidature sans éditer un fichier en SSH. La page ne charge aucune ressource tierce, pas même les polices du site : une feuille venue d'ailleurs lirait le DOM de la console. Les polices servent si la machine les a, sinon celles du système prennent le relais.

GET /healthz se passe de jeton, comme la liste : 200 si les boucles d'acceptation TCP et de réflexion UDP tournent, 503 sinon, et rien d'autre dans le corps. C'est ce qu'un superviseur regarde : un processus debout dont une boucle est morte paraît vivant et personne ne le redémarre. Il obéit à la même règle d'adresse que le reste de la console.

lprdv --port 47900 --admin-port 47901          # console, machine locale seule
lprdv --port 47900 --admin-allow any           # console ouverte à tous, à éviter
lprdv --port 47900 --no-admin                  # pas de console du tout

Sans console, ou sur un autre port, ni deploy.sh, ni install.sh, ni la mise à jour automatique ne peuvent vérifier le service.

Au premier démarrage, le service écrit un jeton aléatoire dans admin.token et journalise le chemin du fichier, jamais le jeton lui-même : c'est là qu'on le lit. Le navigateur le demande avant d'afficher quoi que ce soit, dans sa propre boîte de dialogue : n'importe quel identifiant, et le jeton comme mot de passe. Il le renvoie ensuite tout seul, donc la page n'a ni champ à remplir ni secret à stocker. Le perdre se répare en supprimant le fichier.

La page elle-même demande le jeton, et pas seulement les données qu'elle affiche : une console qui s'ouvre à qui la demande annonce ce qui tourne ici et invite à essayer. Un jeton de trente-deux octets ne se devine pas, mais les essais répétés depuis une même adresse sont freinés, pour qu'un robot ne remplisse pas le journal.

La console est en HTTP clair, et ne sert que la machine locale. Pour l'ouvrir depuis l'extérieur, mettre un proxy inverse avec TLS sur la même machine, pas --admin-allow any : sans TLS, le jeton voyagerait en clair. Gérer des certificats ici reviendrait à refaire moins bien ce qu'un proxy fait déjà.

En accès local, le port n'écoute que sur la boucle locale (127.0.0.1, et l'adresse que résout localhost), et le service refuse encore par un 403 tout ce qui n'en vient pas. Le prix : HttpListener apparie ses préfixes sur l'en-tête Host, donc la console ne répond qu'aux hôtes 127.0.0.1:47901 et localhost:47901, et rend 404 à tout autre. Un proxy inverse doit transmettre cet hôte et non le nom public : nginx le fait de lui-même avec proxy_pass http://127.0.0.1:47901; (sans proxy_set_header Host $host), Caddy avec header_up Host {upstream_hostport}. ::1 ne s'écrit pas dans un préfixe HttpListener, d'où localhost. Avec --admin-allow any, le port reste ouvert sur toutes les interfaces.

Aucun nom de personnage n'apparaît nulle part sur cette page : le service ne garde ni ne journalise ceux qui traversent ses boîtes, et ne tient que des empreintes dans sa liste de bannissement.

Le navigateur rejoue l'authentification basique sur toute requête vers la console, y compris celles qu'une page tierce lui ferait envoyer. Les mutations (POST, PUT, DELETE) sont donc refusées par un 403 quand Sec-Fetch-Site dit cross-site, et par un 415 quand le corps n'est pas en application/json, le seul type qu'un formulaire HTML ne sait pas produire. En ligne de commande, envoyer ce Content-Type suffit.

La modération

Le réseau doit pouvoir écarter quelqu'un qui s'en sert pour diffuser des contenus illégaux. C'est le seul recours disponible : signaler à l'éditeur du jeu exposerait tous les utilisateurs en même temps que l'auteur, les mods violant ses conditions.

Un bannissement porte sur le personnage et non sur la clé, qui se régénère en une seconde là où un personnage se paie en temps et en argent. La liste ne contient jamais de nom : seulement une empreinte lente de nom@monde sous un sel commun, servie à qui la demande sur le port du service (trame BanListQuery, par pages de 64 entrées), et aussi par GET /api/bans sur la console.

{
  "version": 1,
  "kdf": { "algorithm": "pbkdf2-sha256", "iterations": 600000 },
  "salt": "8d7f0768...",
  "updated": 1790119641,
  "entries": [ { "hash": "3b69f39f...", "reason": "contenu illegal", "since": 1790119641 } ]
}

Lente parce qu'une liste d'empreintes rapides serait un annuaire de joueurs utilisant des mods, ce qui les exposerait pour une raison étrangère à ce qu'on leur reproche. Vérifier quelqu'un dont on a le nom coûte une dérivation ; énumérer l'espace des noms coûte une fortune. Le sel est engendré une fois et ne change jamais : en changer invaliderait toute la liste, qu'il faudrait reconstruire depuis des noms que le service ne conserve pas.

Trois limites, écrites plutôt que laissées à croire résolues :

  • La liste n'est pas une preuve. Le service ne voit aucun contenu, donc il ne peut vérifier aucune accusation. Toute liste relève de la réputation, et celui qui l'applique en répond.
  • Un banni continue d'ouvrir ses boîtes. Une adresse de boîte est une empreinte rapide du nom, la liste porte une empreinte lente : les deux ne se comparent pas. Ce qui l'arrête est côté client, qui refuse de se pairer avec lui et de poser son apparence. C'est là que le mal se produit, donc c'est là que la protection vit.
  • PBKDF2 et non argon2. Tout ce qui est cryptographique dans ce projet passe par la bibliothèque standard, sans dépendance, et le client doit dériver à l'identique depuis un autre dépôt. Le prix est que PBKDF2 se calcule bien sur processeur graphique, là où argon2 y résisterait par sa consommation mémoire.

Le monde se donne par son nom (« Ragnarok ») ou par son identifiant numérique. La table des noms est embarquée dans le service, tirée de la feuille World du jeu, et se maintient à la main à l'ouverture d'un monde : l'identifiant reste accepté pour un monde qu'elle ne connaît pas encore. « Vérifier » dérive l'empreinte d'un nom et dit si elle est listée, sans rien écrire ni journaliser. Une liste exportée par un autre service se fusionne par empreinte, à condition qu'il partage le même sel : sinon ses empreintes ne se comparent pas aux nôtres, et l'import est refusé par un 409 qui le dit.

GET /api/bans et GET /healthz se passent de jeton, et à dessein : la liste est la même que celle que les clients reçoivent sur le port du service, la santé est ce qu'un superviseur interroge. Ils restent soumis à la règle d'adresse de la console (machine locale par défaut). Tout le reste, la page comprise, exige le jeton, en Authorization: Bearer <jeton> pour la ligne de commande ou en authentification basique pour le navigateur.

Méthode et chemin Effet
GET / La page
GET /healthz 200 si les deux boucles tournent, 503 sinon. Public
GET /api/status Version, mémoire, santé, compteurs, refus par motif, annuaire, liste ; pour une autorité, la clé publique, la version émise et les services suivis
GET /api/history?range=3m|1h|24h Un point par minute : boîtes, appariements, octets relayés, refus
POST /api/peers Approuve une candidature
DELETE /api/peers Retire un pair connu, ou écarte une candidature
GET /api/bans La liste, telle que les clients la téléchargent. Public
GET /api/bans/export La même, en téléchargement bans.json
POST /api/bans Ajoute une entrée, depuis un nom, un monde (nom ou numéro) et un motif ; 409 si déjà listée
POST /api/bans/verify Dit si un nom@monde est listé, sans rien écrire
POST /api/bans/import Fusionne un bans.json sous le même sel, 409 sinon
DELETE /api/bans Retire une entrée
GET /api/worlds La table des mondes, id, nom, centre de données, région
GET /api/settings Les réglages en vigueur et leurs bornes
PUT /api/settings Change des réglages, à chaud et dans settings.json ; 400 hors bornes
POST /api/authority/veto Écarte un service du réseau ouvert ; 404 s'il n'est pas suivi
DELETE /api/authority/veto Rétablit un service écarté, qui repart en candidat
POST /api/authority/admit Admet tout de suite un service en probation qui répond ; 409 sinon, avec la raison

Le réseau ouvert

Avec --directory-authority, le service tient en plus le rôle d'autorité du réseau ouvert. Il est désactivé par défaut : un service autohébergé n'en a pas l'usage, et le plugin n'accepterait de toute façon que la liste signée par une clé qu'il connaît.

L'autorité sonde toutes les dix minutes les candidats reçus, jamais une adresse non publique. Une candidature est liée à l'adresse qui l'a envoyée : le service n'entre que s'il répond depuis cette adresse (ou ce /64), pour que personne n'inscrive le service d'un autre. peers.txt reste l'annuaire manuel et n'est pas sondé. Un candidat entre dans la liste après 72 heures à au moins 95 % de sondes réussies, en sort après 24 heures de silence, reprend sa place s'il revient dans les 72 heures, et est oublié après 7 jours sans réponse ; un candidat qui n'a jamais répondu est oublié au bout d'une heure. Au plus deux services listés par /24 ou /48, quatre suivis par /24 ou /48 de l'adresse qui les a proposés, et cinq admissions par jour. Une candidature compte dans le limiteur, et n'atteint l'autorité que si l'annuaire l'a laissée passer (une par adresse et par heure). Un libellé qui porte un caractère de contrôle, de mise en forme (inversion bidirectionnelle comprise) ou un retour à la ligne est refusé avant d'être écrit nulle part, et un service listé qui change de libellé repasse en probation. La liste est signée par directory.key (--directory-key), valable sept jours, et resignée chaque jour même inchangée ; les probations vivent dans authority.json (--authority-state).

L'autorité attribue à chaque service le continent de son adresse, d'après la base IP to Country Lite de DB-IP, sous licence CC BY 4.0. Elle la télécharge seule, une fois par mois, dans geoip.mmdb.

La console montre la clé publique, la version émise, et chaque service suivi avec son état et son taux de réussite. « Écarter » retire un service tout de suite ; l'admission se passe de geste humain, le retrait non. « Admettre » lève la durée de probation d'un service qui répond, pour les services de l'opérateur lui-même : les bornes par réseau et par jour tiennent toujours.

L'autorité sert, sur son port habituel et non sur la console, la liste signée (ConsensusQuery, et sa version à régions ConsensusV2Query 0x1B, réponse ConsensusV2Page 0x1C) et l'état public du réseau (NetworkStatusQuery 0x19, réponse NetworkStatusPage 0x1A, par tranches de 32 Kio et seize pages au plus). Un service ordinaire répond qu'il ne le publie pas. Cet état, recalculé à chaque ronde de sondes, est relevé chaque heure par le site et affiché sur https://linkpearl-sync.github.io/reseau.html : les services en probation, listés ou sortis, avec leur disponibilité sur 24 heures. Jamais un service écarté, ni l'adresse d'où vient une candidature, ni la famille ; les candidats jamais joints n'y sont que comptés.

Tout service se porte candidat par défaut auprès de l'autorité officielle (rdv.linkpearl.eorzea.events), au démarrage puis chaque jour, et retente après cinq minutes une candidature qui n'a pas pu partir. C'est la seule connexion sortante d'un service ordinaire. --announce-to remplace cette cible et peut se répéter, --no-announce supprime toute candidature, --label donne le nom affiché (64 octets UTF-8 au plus). Sans --public-address, l'autorité retient l'adresse IPv4 d'où part la candidature, avec le port de --port : la candidature part donc en IPv4, seule famille qu'une adresse littérale sache écrire, et une machine joignable seulement en IPv6 doit donner --public-address.

Le format de fil est une copie

Protocol/ contient six fichiers copiés littéralement depuis le dépôt du plugin :

Ici Là-bas
Protocol/Core/Transport/Rendezvous/RendezvousWire.cs Linkpearl/Core/Transport/Rendezvous/RendezvousWire.cs
Protocol/Core/Transport/Rendezvous/ServiceConsensus.cs Linkpearl/Core/Transport/Rendezvous/ServiceConsensus.cs
Protocol/Core/Transport/Rendezvous/RendezvousTicket.cs Linkpearl/Core/Transport/Rendezvous/RendezvousTicket.cs
Protocol/Core/Transport/Rendezvous/RendezvousAddress.cs Linkpearl/Core/Transport/Rendezvous/RendezvousAddress.cs
Protocol/Core/Abstractions/IClock.cs Linkpearl/Core/Abstractions/IClock.cs
Protocol/Core/Safety/BanList.cs Linkpearl/Core/Safety/BanList.cs

Ils doivent rester identiques à l'octet près, et diff doit rester vide. De même, Protocol/rendezvous-vectors.json est la copie de Linkpearl.Core.Tests/Fixtures/rendezvous-vectors.json, et RendezvousVectorTests.cs et BanListTests.cs sont copiés à l'identique de leurs pendants du plugin.

Deux copies divergent toujours un jour, et la panne qui s'ensuit est de celles qui coûtent une soirée : deux joueurs qui ne se voient plus, sans message d'erreur qui dise pourquoi. C'est pourquoi Protocol/rendezvous-vectors.json existe. Le fichier est le même dans les deux dépôts, le test qui le lit est le même aussi, et le premier côté qui touche à un octet du format casse son propre test avant d'avoir livré quoi que ce soit.

Ce que ces vecteurs attestent, et rien de plus : la cohérence de format entre les deux copies. Ils ont été produits par l'implémentation qu'ils testent, donc ils n'attrapent pas une erreur de conception, seulement une dérive.

La dérivation du jeton tournant, elle, est l'affaire du plugin et y reste testée. Le serveur ne lit de RendezvousTicket que sa taille et sa fenêtre.