Netops un outil (WIP)(POC) permettant de controller et géré des serveurs à distance sans serveurs central directement via le net (protocol gRpc) outils en rust agents en rust.
  • Rust 62.9%
  • Svelte 27.3%
  • JavaScript 3.2%
  • Shell 2.9%
  • SCSS 1.9%
  • Other 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
dwi c92aaa4a37
All checks were successful
CI / Audit et qualité du code (push) Successful in 10m49s
CI / Frontend (tests, build, cohérence de dist) (push) Successful in 20s
CI / Rust (format, lint, tests) (push) Successful in 13m57s
CI / Console Linux (push) Successful in 11m27s
CI / Agent Linux (push) Successful in 2m6s
CI / Console Windows (cross-compilation) (push) Successful in 10m55s
CI / Publication sur la forge (push) Successful in 11s
Merge pull request 'Correctif vim, audit qualité, tests d'intégration, et durcissement de l'agent' (#2) from fix/onglet-edition-vim into main
2026-09-10 22:06:59 +02:00
.cargo audit et qualité : analyses statiques, dans la CI comme en local 2026-09-10 16:34:07 +02:00
.forgejo/workflows harnais de test de composants, binaires 33 à 46 % plus légers, banc d'essai en CI 2026-09-10 17:32:58 +02:00
agent jeton partagé optionnel : fermer la porte du mesh sans casser le parc 2026-09-10 17:40:48 +02:00
console jeton partagé optionnel : fermer la porte du mesh sans casser le parc 2026-09-10 17:40:48 +02:00
protocol jeton partagé optionnel : fermer la porte du mesh sans casser le parc 2026-09-10 17:40:48 +02:00
screens docs: README remanié + captures de la console 2026-09-08 04:36:39 +02:00
scripts audit et qualité : analyses statiques, dans la CI comme en local 2026-09-10 16:34:07 +02:00
spike banc d'essai : gRPC et remoc contre le protocole actuel 2026-09-10 06:41:32 +02:00
.gitignore banc d'essai : gRPC et remoc contre le protocole actuel 2026-09-10 06:41:32 +02:00
Cargo.lock passer le protocole agent <-> console à gRPC 2026-09-10 07:34:01 +02:00
Cargo.toml harnais de test de composants, binaires 33 à 46 % plus légers, banc d'essai en CI 2026-09-10 17:32:58 +02:00
Makefile harnais de test de composants, binaires 33 à 46 % plus légers, banc d'essai en CI 2026-09-10 17:32:58 +02:00
README.md jeton partagé optionnel : fermer la porte du mesh sans casser le parc 2026-09-10 17:40:48 +02:00

🛰️ NetBird Ops Console

Piloter un serveur Linux à distance avec un agent Rust et une console GUI (Tauri + Svelte), le tout uniquement à travers le mesh NetBird. Pensé pour de l'opérationnel sysadmin : métriques, services, logs, terminal, fichiers, runbooks, comptes.

⚠️ Version de test, pas d'authentification applicative. La confidentialité repose entièrement sur le tunnel WireGuard de NetBird, et l'agent n'accepte que les connexions venant de 100.64.0.0/10. N'expose jamais le port de l'agent hors du mesh. L'agent exécute des commandes shell arbitraires : ne le lance que sur des hôtes de test.

📸 Aperçu

Tableau de bord

Tableau de bord : métriques poussées en continu, système, disques. Les panneaux Processus et Interfaces sont vides ici parce que l'agent de cet hôte date d'avant ces métriques ; la console reste compatible et affiche simplement le reste.

🧩 Services et Runbooks

Services

Les unités systemd, avec filtre et actions groupées (démarrer, arrêter, redémarrer, statut).

Runbooks

L'onglet Runbooks quand aucun catalogue n'est encore rempli : les quatre emplacements consultés sont affichés avec leur état.

✨ Ce que ça fait

Onglet Ce qu'on y fait
📊 Tableau de bord Métriques poussées (~1 s) avec courbes, top processus, débit par interface, disques, anomalies
⚙️ Services Unités systemd : lister, filtrer, démarrer, arrêter, redémarrer, statut détaillé
📜 Logs Suivi temps réel du journal complet, d'une unité, ou d'un fichier
💻 Shell Terminaux interactifs en onglets, chacun sa session PTY indépendante
📁 Fichiers Gestionnaire façon FileZilla : navigation, envoi, téléchargement, permissions, renommage, suppression
📓 Runbooks Actions scriptées pré-déclarées, lancées en un clic, avec paramètres
🧱 Pare-feu Règles nftables ou iptables en tableaux, par chaîne et par interface
👥 Comptes Utilisateurs, groupes, et gestion des clés SSH autorisées

La barre latérale mémorise les hôtes en local et sait importer ceux de ~/.ssh/config pour éviter de les ressaisir.

🏗️ Architecture

flowchart LR
    subgraph WIN["Poste Windows"]
        C["console<br/>(Tauri + Svelte)"]
    end
    subgraph LNX["Serveur Linux"]
        A["agent<br/>(tokio)"]
        OS["systemd · journalctl · nftables<br/>PTY · fichiers · comptes"]
        A --> OS
    end
    C == "gRPC (HTTP/2, h2c)<br/>tunnel WireGuard NetBird (100.64.0.0/10)" ==> A
    A -. "réponses · flux multiplexés" .-> C
Crate Rôle
protocol/ Contrat agent ↔ console : schéma gRPC (proto/agent.proto) et conversions, plus la logique pure partagée (catalogue de runbooks, analyse des règles de pare-feu et des clés SSH)
agent/ Binaire Linux. Écoute sur l'IP NetBird, exécute les requêtes. Mode probe inclus
console/ App Tauri. Backend Rust + frontend Svelte 5 / Vite / SCSS dans console/ui/

Le workspace racine ne contient que protocol + agent, pour un build Linux rapide. console/ est un crate autonome (son propre [workspace]) afin que cargo build à la racine ne tente pas de compiler la GUI.

🔌 Le protocole en détail

Le contrat vit dans protocol/proto/agent.proto et le code est généré au build (protocol/build.rs). Une opération = une méthode ; le mode de streaming porte la sémantique qui était auparavant conventionnelle :

mode opérations ce que ça remplace
unaire GetSysInfo, GetServiceList, ListDir, Chmod… requête / réponse corrélées par id
server-streaming MetricsSubscribe, Exec, LogFollow, FileGet trame terminale (Exit, End, FileEnd)
client-streaming FilePut, AgentUpgrade contenu entier en base64 dans une trame
bidirectionnel Shell ShellOpen/ShellInput{target} + table de sessions
sequenceDiagram
    participant C as console
    participant A as agent
    C->>A: GetSysInfo()
    A-->>C: SysInfo
    C->>A: LogFollow(unit) — ouvre un flux
    A-->>C: LogLine
    A-->>C: LogLine
    C--xA: la console lâche le flux
    Note over A: l'agent tue journalctl<br/>sans message d'annulation

Ce que le transport prend en charge, et qui n'est donc plus écrit à la main :

  • la corrélation — un appel est son propre contexte, il n'y a plus d'id croissant ni de table pending côté console ;
  • la fin de flux — un flux qui se ferme est la fin de l'opération, ce qui supprime le is_terminal() de vingt bras qu'il fallait tenir à jour (l'oublier pour une variante laissait la console attendre son délai complet) ;
  • l'annulation — lâcher un flux suffit, l'agent le voit et libère ce qui va avec (processus de log, PTY). L'opération Cancel et le registre d'AbortHandle ont disparu, et une console qui disparaît ne laisse plus de session orpheline ;
  • l'équité entre flux — le contrôle de flux par stream de HTTP/2 fait qu'un transfert de fichier ne bloque plus le shell. Mesuré : la latence d'une frappe pendant un transfert passe de 232 ms à 44 ms au p90 (voir spike/RAPPORT.md).

Les id que le frontend voit dans les événements agent-msg existent toujours, mais ils sont désormais locaux à la console : elle les distribue pour que l'interface route ses flux et puisse en désigner un à annuler. Ils ne voyagent plus sur le réseau.

Les métriques sont poussées (MetricsSubscribe) plutôt que sondées. L'agent garde un échantillonneur d'un relevé à l'autre : l'usage CPU par processus et les débits réseau sont des écarts entre deux mesures, ils vaudraient zéro avec un System recréé à chaque appel.

Les octets bruts (PTY, morceaux de fichiers, binaire de mise à jour) voyagent en bytes protobuf. Le base64 ne subsiste qu'à la frontière IPC de Tauri, qui sérialise en JSON : c'est une contrainte de l'interface, pas du réseau.

🔄 Compatibilité entre versions

Compatibilité agent / console

L'agent et la console évoluent séparément : le binaire déployé sur un hôte est souvent plus ancien que la console qu'on vient de rebuilder. Le protocole doit donc tolérer qu'un pair reçoive quelque chose qu'il ne connaît pas.

Versions agent / console

La console demande sa carte d'identité à l'agent au moment de la connexion :

✔ Agent v0.1.0 build c48bdd5 (2026-09-08 17:57) · protocole v4
  capacités : metrics, shell, files, runbooks, accounts, firewall, upgrade

Elle affiche la version dans la barre du haut, en gris quand tout va bien, en rouge quand l'agent est en retard. Et surtout, elle n'envoie pas à un agent une opération qu'il n'annonce pas : c'est ce qui évite qu'un onglet récent échoue sans explication.

L'agent annonce une liste de capacités plutôt qu'un simple numéro : une table version → fonctionnalités côté console dupliquerait la connaissance et finirait par diverger.

PROTOCOL_VERSION (dans protocol/src/lib.rs) est à incrémenter à chaque ajout d'opération, et la capacité correspondante à déclarer dans agent_info().

Ce que gRPC règle tout seul

  • Une opération inconnue n'est plus un incident. Un agent qui ne connaît pas une méthode répond UNIMPLEMENTED : un statut normal, sur un flux normal, qui ne touche pas au reste de la connexion. Auparavant il ne savait pas désérialiser la trame et fermait la session — la console perdait tout au moment précis où on ouvrait un onglet récent, ce qui avait demandé un décodeur tolérant exprès (decode_request) pour repêcher l'id d'une requête indécodable.
  • Les champs inconnus sont ignorés, pas fatals. Un champ ajouté au schéma est sauté par un pair plus ancien, sans qu'il faille l'annoter. Les serde(default) posés à la main sur SysInfo restent utiles à la frontière IPC, mais plus sur le fil.
  • Les numéros de champ sont le contrat. Renommer un champ du .proto est sans effet sur le fil ; changer son numéro ou son type casse la compatibilité. C'est la seule règle à retenir en modifiant agent.proto : on ajoute des numéros, on n'en réutilise jamais un.

Les agents d'avant gRPC

Le passage à gRPC est une rupture de transport : un agent en protocole 3 ou moins parle l'ancien cadrage [u32][JSON] et ne répondra pas du tout. Une erreur de transport nue (« stream closed because of a broken pipe ») ne dirait rien d'utile à l'utilisateur.

console/src/identity.rs garde donc, exprès, un client de l'ancien dialecte, pour la seule chose qu'on lui demande : reconnaître un tel agent et récupérer le chemin de son binaire, qui est l'argument d'un redéploiement par SSH. La console affiche alors quoi faire :

l'agent de ep1 parle l'ancien protocole (pré-gRPC) : il ne sait pas interpréter
cette requête. Redéploie-le une fois par SSH, avec
« make deploy HOST=ep1 REMOTE_PATH=/tmp/agent » ; ensuite il se mettra à jour
depuis la console.

C'est du code figé, qui ne suivra plus l'évolution du contrat — d'où ses types locaux plutôt qu'un emprunt au crate protocol. Il est couvert par des tests qui montent un faux agent d'avant en local (mod dialecte_ancien), donc sans réseau ni hôte du mesh.

🔌 Les sondes d'identité passent par une connexion jetable : la fermeture brutale d'un agent qui ne comprend pas la question ne coûte que cette connexion-là, jamais la session de travail.

🔄 Après avoir ajouté une opération au protocole, redéploie l'agent sur les hôtes concernés. La console nomme sinon l'opération manquante dans son message d'erreur.

🚀 Démarrage

Prérequis

Le contrat réseau est généré au build depuis protocol/proto/agent.proto, ce qui demande protoc ≥ 3.21.12 :

sudo apt install protobuf-compiler   # Debian 13 / Ubuntu 24.04 fournissent 3.21.12

C'est le plancher exact exigé par tonic 0.14 : la marge est nulle, et scripts/ci-rust.sh vérifie la version plutôt que de la supposer — sinon l'échec n'apparaît qu'au milieu du build, sous forme d'une erreur de codegen difficile à relier à sa cause.

Pour le livrable Windows, cross-compilé depuis Linux :

rustup target add x86_64-pc-windows-gnu
sudo apt install mingw-w64

Agent (Linux)

cargo build --release -p agent      # -> target/release/agent

./agent serve                       # bind auto sur l'IP NetBird détectée, port 7878
./agent serve --bind 100.120.45.50 --port 7878

Options : --bind <ip>, --port <n>, --allow-any (accepte les connexions hors NetBird, tests locaux uniquement), --token-file <fichier>.

🔑 Exiger un jeton partagé

Par défaut l'agent accepte tout ce qui vient du mesh. C'était assumé pour un banc d'essai, mais le rayon d'action a grandi : remplacement du binaire à distance, écriture de fichiers arbitraires, PTY interactif, lancement d'un programme avec ses arguments. Un seul hôte compromis du mesh donne aujourd'hui la main sur tous les autres.

--token-file ferme cette porte, sans rien casser : un agent lancé sans l'option se comporte exactement comme avant.

# Sur l'hôte
head -c 32 /dev/urandom | base64 > /etc/netbird-ops/token
chmod 600 /etc/netbird-ops/token
./agent serve --token-file /etc/netbird-ops/token

# Sur le poste : la console et `agent probe` lisent NBOPS_TOKEN…
export NBOPS_TOKEN="$(cat le-meme-jeton)"
# …ou, pour la console, un fichier `token` à côté de sa configuration de forge
#   Linux   : ~/.config/netbird-ops/token
#   Windows : %APPDATA%\netbird-ops\token

Une requête sans le bon jeton est refusée en UNAUTHENTICATED, avant d'atteindre le service, et sur toutes les méthodes — un test le vérifie justement sur les appels unaires et sur les deux sens de streaming. La comparaison est faite en temps constant : un == s'arrête au premier octet différent, et son temps de réponse laisserait reconstituer le jeton octet par octet. Le message d'erreur ne distingue pas « absent » de « faux ».

Ce que ça protège : un autre hôte du mesh. Ce que ça ne protège pas : quelqu'un qui sait lire le fichier de jeton, ni la console elle-même. C'est un secret partagé, pas une identité par hôte — pour ça il faudrait du mTLS, qui est un autre chantier.

🔑 En lecture (métriques, liste des services, logs) un utilisateur normal suffit souvent. Pour start/stop/restart, les clés SSH d'un autre compte ou l'écriture de fichiers système, l'agent doit tourner en root ou avec une policy polkit adaptée.

Mettre à jour un agent depuis la console

La barre du haut propose Mettre à jour dès qu'un hôte est connecté, et pas seulement quand un retard est détecté : la console ne compare que les versions de contrat, un agent « à jour » de ce point de vue peut très bien tourner sur un vieux binaire. Le panneau offre trois sources.

Source Ce que c'est Quand
🌐 Dernier paquet de la forge La release roulante agent-latest, publiée par la CI à chaque poussée sur main Le cas courant : remettre un serveur à niveau sans rien reconstruire
📦 Agent embarqué L'agent du même commit que la console Juste après un make win
📁 Binaire du poste Un fichier choisi à la main Test d'une version locale

Dans les trois cas, la console envoie le binaire, l'agent se remplace et se relance ; la session tombe quelques secondes puis est reprise automatiquement.

🌐 Ce que la console va chercher sur la forge

Elle interroge l'API Forgejo/Gitea du dépôt, par tag : /api/v1/repos/…/releases/tags/agent-latest. Demander « la release la plus récente » serait plus court et faux, puisque ce dépôt publie aussi une console-latest, plus récente une fois sur deux. Si le tag est introuvable, elle parcourt la liste et retient la première release publiée qui contienne vraiment l'artefact cherché, ce qui rattrape un tag renommé.

Elle y choisit ensuite l'artefact nommé agent et le télécharge.

La CI dépose à côté du binaire un fichier agent.sha256 : l'empreinte vient donc de la publication, pas du téléchargement. Un artefact tronqué côté forge est détecté ici, avant d'atteindre le serveur, ce que TLS seul ne garantit pas. Le panneau affiche ✓ empreinte publiée vérifiée quand ce fichier existe.

Le tag et le nom de l'artefact sont réglables dans le panneau (Release et Artefact), pour viser une autre release sans reconstruire la console.

Le dépôt visé est déduit du remote git au moment du build : rien à saisir quand la forge se sert sous le même nom d'hôte que le remote. Ce n'est pas toujours le cas : un remote en git.example.org peut très bien correspondre à une interface web en forge.example.org, et le certificat TLS ne couvrir que le second. C'est alors le panneau qui tranche, en une fois.

Pour un dépôt privé, il faut de plus un jeton : la lecture de la release comme le téléchargement de l'artefact passent par lui. Configurer la forge dans le panneau écrit %APPDATA%\netbird-ops\forge.json (~/.config/netbird-ops/forge.json sous Linux). Les variables NBOPS_FORGE_URL, _OWNER, _REPO, _ASSET, _TAG et _TOKEN priment sur le fichier, le temps d'un lancement. Elles servent aussi à lancer le test de bout en bout, ignoré par défaut pour que la CI ne dépende pas d'un serveur tiers :

cd console && NBOPS_FORGE_URL=https://forge.example.org NBOPS_FORGE_OWNER=dwi \
  NBOPS_FORGE_REPO=app cargo test -- --ignored --nocapture
🕰️ Un agent trop ancien pour être mis à jour à distance

AgentInfo et AgentUpgrade sont arrivés ensemble. Un agent antérieur ne sait donc ni se présenter, ni recevoir un binaire : il ferme la connexion au lieu de répondre. Lui envoyer une mise à jour donne une déconnexion, une reconnexion, et rien de changé sur l'hôte. La console refuse maintenant l'envoi plutôt que de laisser recommencer.

Elle sait quand même dire qui il est. Un tel agent gère Exec, alors on lui demande de décrire son propre binaire :

e=$(readlink -f /proc/$PPID/exe); echo "path=$e"; stat -c "mtime=%Y" "$e"; …

Exec lance sh -c directement depuis le processus de l'agent, donc le $PPID de ce shell est l'agent : pas de pgrep qui se tromperait de processus, pas de chemin codé en dur. On en tire la version, la date du binaire, son empreinte courte, et surtout son chemin, qui est l'argument du redéploiement. L'entête affiche alors retard · v0.1.0 · 08/09/2026 au lieu d'un simple « en retard », et le panneau donne la commande à copier :

make deploy HOST=vps.example.fr REMOTE_PATH=/usr/local/bin/agent

Une fois cette bascule faite, l'agent se met à jour depuis la console pour toujours. Comme les deux sondes passent par une connexion jetable, un agent qui raccroche ne coûte jamais la session de travail.

La console embarque aussi l'agent de son propre build. make win construit l'agent Linux puis l'inclut dans l'exécutable Windows : elle peut donc pousser exactement l'agent qui lui correspond, sans réseau ni jeton : la correspondance des versions est garantie par construction plutôt que par convention. Si la console a été construite sans (EMBED_AGENT=0), cette source est grisée.

🔧 Comment le remplacement se passe, et ce qui le protège

Trois contraintes dictent la marche à suivre :

  • on ne peut pas écrire sur un exécutable en cours (ETXTBSY), mais on peut le remplacer par un rename : l'ancien inode reste mappé par le processus vivant, le nouveau prend sa place dans le dossier ;
  • le fichier temporaire doit être dans le même dossier que la cible, sinon le rename traverse un point de montage et échoue ;
  • l'agent répond avant de se relancer, sinon la console voit une coupure sans savoir si la mise à jour a abouti.

La relance est un exec : le PID est conservé, seule l'image change. Pour vérifier qu'elle a bien eu lieu, comparer l'inode de /proc/<pid>/exe à celui du fichier ; une image non remplacée apparaîtrait comme supprimée.

Le contenu reçu est vérifié avant toute écriture : taille plausible, en-tête ELF, et empreinte SHA-256. Un transfert tronqué, une archive ou un exécutable Windows sont refusés sans que l'agent en place soit touché, et la session survit au refus.

⚠️ Remplacer l'agent à distance revient à exécuter du code arbitraire sur l'hôte. Cette version n'a pas d'authentification et expose déjà Exec : la mise à jour n'ouvre donc pas une catégorie de risque nouvelle. Les contrôles ci-dessus visent l'accident, pas l'attaque.

Déployer l'agent sur un hôte

make deploy HOST=ep1.example.fr           # /usr/local/bin/agent par défaut
make deploy HOST=ep1.example.fr REMOTE_PATH=/opt/nbops/agent

⚠️ Ne pas installer l'agent dans un dossier partagé comme /tmp. La mise à jour écrit un fichier temporaire à côté du binaire : c'est fait en exclusivité, donc un lien posé à l'avance fait échouer l'opération au lieu d'être suivi, mais un dossier où personne d'autre n'écrit reste la bonne réponse. Le défaut a changé pour cette raison.

Le script construit le binaire, l'envoie, reprend les arguments du processus en place (bind explicite, port non standard) et relance, puis vérifie avec un probe. Il n'y a pas d'hôte par défaut : make deploy tout court ne pousse rien nulle part.

⚠️ Ne jamais arrêter l'agent distant avec pkill -f "agent serve" : la ligne de commande du shell qui exécute la commande contient elle aussi le motif, et se fait tuer au passage. Le script cible le processus par son nom (pgrep -x agent).

Smoke test sans la GUI, pratique pour valider la connectivité mesh. Le probe exerce chaque mode de streaming, ce qui en fait le contrôle de recette d'un déploiement :

# Appels unaires : Ping, AgentInfo, SysInfo, pare-feu, services, runbooks
./agent probe 100.120.45.50:7878

# Flux : exécution streamée, métriques poussées (annulées en lâchant le flux),
# PTY bidirectionnel, listing de répertoire
./agent probe 100.120.45.50:7878 --exec 'uptime' --metrics 3 --shell --ls /etc

# Transferts dans les deux sens, avec comparaison d'empreintes
./agent probe 100.120.45.50:7878 --transfer 64

📦 --transfer N envoie N Mio puis les relit et compare les empreintes SHA-256. Il sert notamment à vérifier qu'il n'y a plus de plafond de taille : l'ancien protocole mettait le fichier entier en base64 dans une trame unique, plafonnée à 64 Mio, et coupait la connexion en BrokenPipe passé ~48 Mio de fichier réel. Vérifié sur le mesh à 64 et 200 Mio.

Console (Windows et Linux)

Le frontend est prébuildé en statique dans console/ui/dist/, que Tauri embarque. Conséquence : le build de l'exécutable n'a besoin que de Rust, pas de Node.

Le plus simple est de prendre l'archive déjà construite : la CI publie à chaque poussée sur main une release roulante console-latest avec un zip par plateforme, tout ce qui va ensemble dedans. Sinon, depuis les sources :

make win      # -> dist-win64/netbird-ops-console-windows-x86_64.zip  (cross-compilé)
make linux    # -> dist-linux/netbird-ops-console-linux-x86_64.zip

Un zip plutôt que des fichiers en vrac parce que l'exécutable Windows ne démarre pas sans WebView2Loader.dll à côté de lui : livrer les deux séparément, c'est livrer une question. L'archive règle aussi un défaut du transport par artefacts de CI, qui fait perdre son bit exécutable à un binaire Linux ; à l'intérieur d'un zip, il le garde.

Moteur de rendu Prérequis à l'exécution
Windows WebView2 préinstallé sur Windows 11, rien à faire
Linux WebKitGTK libwebkit2gtk-4.1-0, libgtk-3-0, libsoup-3.0-0

C'est la différence de fond entre les deux : Windows fournit son moteur, Linux attend qu'il soit dans le système. Les deux binaires embarquent en revanche la même chose, le frontend et l'agent du même commit.

Sous Windows, cargo tauri build reste possible pour produire en plus un installeur NSIS (cargo install tauri-cli --version "^2").

Au premier lancement : + dans la barre latérale pour ajouter un hôte (nom et adresse, par exemple 100.120.45.50:7878), ou l'icône d'import pour reprendre ceux de ~/.ssh/config, puis ▶ pour se connecter à son agent. Les hôtes sont mémorisés localement.

⚠️ Le piège du build Windows : la feature custom-protocol

generate_context! décide d'embarquer ou non le frontend avec :

dev: cfg!(not(feature = "custom-protocol"))

Sans cette feature, Tauri embarque un jeu d'assets vide et la fenêtre charge devUrl (localhost:5173). L'exécutable compile, se lie et se lance : rien ne signale que le frontend n'est pas dedans. cargo tauri build passe le flag tout seul, mais la cross-compilation utilise un cargo build nu, qui doit le passer explicitement. C'est fait dans make win.

Deux garde-fous, dans scripts/check-frontend.sh, appelés automatiquement par make win (ou seuls via make verify) :

  • avant le build : index.html référence-t-il des assets qui existent vraiment, et le dist est-il plus récent que les sources ?
  • après le build : les clés de la table d'assets (/index.html, /assets/index-*.js) sont-elles présentes dans le binaire ? Elles y sont écrites en clair, donc leur absence prouve un build sans frontend.

Symptôme associé, si ça arrive quand même : le webview affiche une ancienne version en cache au lieu d'une page blanche, parce que WebView2 garde http://tauri.localhost/index.html d'un lancement précédent. D'où additionalBrowserArgs avec --disable-http-cache dans tauri.conf.json, et la cible make clean-webview pour vider ce cache sans toucher aux hôtes mémorisés.

🤖 Intégration continue (Forgejo)

.forgejo/workflows/ci.yml, syntaxe compatible GitHub Actions. Quatre jobs :

Job Ce qu'il fait
rust cargo fmt --check, clippy -D warnings et tests sur les deux workspaces
frontend tests vitest, build, et vérification que console/ui/dist committé correspond aux sources
agent build release, publié en artefact agent-linux-x86_64
linux build natif de la console (GTK/WebKit), artefact console-linux-x86_64
publish sur main seulement : dépose l'agent dans agent-latest, celle que la console interroge, et les deux zips du client dans console-latest
windows cross-compilation mingw, contrôles d'embarquement, artefact console-windows-x86_64

Les runners sont Linux uniquement, ce qui n'empêche pas de produire l'exécutable Windows : il est de toute façon cross-compilé, exactement comme avec make win en local.

Les étapes appellent les cibles du Makefile plutôt que de recopier les commandes, pour que CI et poste de travail ne puissent pas diverger. make ci enchaîne localement la même séquence. Même raisonnement pour l'installation de la toolchain, dans scripts/ci-rust.sh : quatre jobs en ont besoin, et la recopier quatre fois avait déjà produit un rustup-init mal appelé qui cassait la CI.

Le job publish ne reconstruit rien : il récupère les artefacts des autres jobs et publie exactement ces octets-là, ce qui évite qu'un binaire contrôlé et un binaire publié puissent différer. Deux releases plutôt qu'une, parce que agent-latest est un contrat lu par la console, qui y cherche un artefact nommé agent : y déposer aussi les clients rendrait ce contrat moins lisible. Chaque fichier est publié avec son .sha256. Il réécrit tag et release à chaque fois, pour que agent-latest pointe toujours le dernier commit de main : supprimer la release sans supprimer le tag laisserait la suivante s'ouvrir sur l'ancien commit. Il utilise le jeton éphémère du runner, ou un secret FORGE_TOKEN s'il existe. Beaucoup d'instances donnent au runner un jeton en lecture seule, avec lequel la publication échouerait en 403.

Le contrôle « dist committé à jour » mérite un mot : console/ui/dist est versionné exprès, et un dist périmé produit un exécutable qui embarque une vieille interface sans que rien ne le signale. Le job rebuild le frontend et échoue si le résultat diffère de ce qui est committé. Il suppose la même version majeure de Node qu'en local (24), sinon les empreintes de fichiers peuvent varier sans que le contenu change.

⚡ Raccourcis (Makefile)
make full-rebuild   table rase puis reconstruction complète
make ui        rebuild du frontend -> console/ui/dist   (nécessite Node)
make win       livrable Windows (zip) -> dist-win64/
make linux     livrable Linux   (zip) -> dist-linux/
make client    les deux : le cas courant après une modif d'UI
make verify    contrôle seul : dist cohérent + frontend bien embarqué
make agent     binaire Linux de l'agent
make deploy HOST=ep1.example.fr    envoie l'agent sur un hôte et le relance

make check     fmt + clippy -D warnings + tests, sur les deux workspaces
make audit     audit des dépendances + lints stricts + liens de doc
make ci        la séquence complète de la forge, en local
make test-ui   tests du frontend (vitest)
make clean     supprime target/, console/target/, console/target-win/, dist-win64/
make clean-webview  vide le cache HTTP du webview Windows
make mrproper  clean + node_modules
🔍 Ce que fait make audit

Trois contrôles, aussi joués par la CI dans le job Audit et qualité du code. La cible est unique pour que le poste de travail et la forge ne puissent pas diverger.

1. cargo audit --deny warnings, sur les deux Cargo.lock. Toute alerte arrête le build — y compris les « non maintenu » et « non sûr », qui ne sont pas des vulnérabilités. Les exceptions tolérées sont listées et justifiées dans .cargo/audit.toml ; c'est ce qui garantit qu'une nouvelle alerte, elle, échoue. Les huit exceptions actuelles sont toutes transitives (portable-pty et l'arbre GTK de Tauri), donc pas corrigeables sans changer une dépendance de premier rang.

2. Un jeu de lints clippy au-delà de -D warnings, choisi pour attraper des défauts et non du style :

lint ce qu'il attrape
cast_possible_truncation un as qui rétrécit en silence
cast_sign_loss un signe perdu à la conversion
cast_possible_wrap un non-signé qui repasse négatif
dbg_macro, todo, unimplemented des restes de mise au point

Le premier a déjà payé : il a révélé que la géométrie de terminal reçue du fil était tronquée en silence. proto3 n'a pas d'entier 16 bits, donc cols et rows voyagent en uint32 ; un as u16 nu transformait 70 000 colonnes en 4 464. Le code sature désormais explicitement.

clippy::pedantic en entier a été écarté : sur ce dépôt il produit 228 avertissements dont l'essentiel est de la préférence de style — des backticks autour de « systemd » dans de la prose française — ce qui noierait le signal.

3. Les liens intra-doc (rustdoc::broken_intra_doc_links), qui cassent en silence quand un type est renommé ou déplacé.

make win cross-compile depuis Linux (x86_64-pc-windows-gnu), ce qui permet de tout faire depuis WSL sans repasser par Windows. Prérequis, une fois :

rustup target add x86_64-pc-windows-gnu
sudo apt install mingw-w64

Tous les artefacts de compilation vont dans ~/.cache/netbird-ops/, donc sur ext4 et pas sous /mnt/c. Trois raisons, toutes vécues sur ce dépôt :

  • le pont 9p divise la vitesse par cinq (1 min contre 5 min pour l'exe Windows) ;
  • Windows peut poser un verrou sur un artefact, qui devient alors ni réinscriptible ni supprimable, et le build échoue sur un Permission denied sans rapport avec le code ;
  • sous compilation parallèle, cargo se prend des No such file or directory sur des fichiers qu'il vient lui-même de créer.

Seuls les artefacts bougent : le dépôt reste sous Documents, accessible depuis Windows. Les emplacements se changent avec LINUX_DIR=, CONSOLE_DIR= et WIN_DIR=.

Il consomme console/ui/dist/ tel quel, sans toucher à Node : c'est tout l'intérêt d'avoir ce dossier versionné. Pour la même raison, make clean ne le supprime pas.

🔧 Rebuild du frontend et mode dev

À faire après toute modification de console/ui/ :

cd console/ui
npm install          # une fois
npm run build        # -> console/ui/dist/

Peut se faire sous WSL avec Node natif ; dist/ est ensuite consommé tel quel par le build Windows. C'est pour ça que dist/ est versionné volontairement.

Run de dev avec rechargement à chaud (nécessite Node) :

cd console
cargo tauri dev
🐧 Développement sous WSL

L'agent se build et se teste nativement. La GUI peut aussi tourner sous WSLg pour du debug, à condition d'installer les libs Tauri :

sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \
                 libsoup-3.0-dev libjavascriptcoregtk-4.1-dev \
                 libayatana-appindicator3-dev
cd console && cargo build

En build debug lancé hors cargo tauri dev, la fenêtre est blanche : Tauri charge alors devUrl (localhost:5173). Lance Vite (npm --prefix ui run dev) ou build en release, qui embarque dist/.

📓 Runbooks

Des actions scriptées déclarées à l'avance, lancées en un clic sur l'hôte sélectionné. Deux catalogues indépendants, cumulables ou consultables séparément via le sélecteur Les deux / Console / Hôte :

Catalogue Fichier système (root) Fichier utilisateur
💼 Console, portable, vaut pour tous les hôtes %ProgramData%\NetBirdOps\runbooks.toml %APPDATA%\NetBirdOps\runbooks.toml
🖥️ Hôte, propre à la machine /etc/netbird-ops/runbooks.toml ~/.config/netbird-ops/runbooks.toml

Dans chaque catalogue, le fichier utilisateur surcharge le fichier système : un même id y est redéfini. Les boutons Créer un fichier d'exemple et Ouvrir dans l'éditeur évitent d'aller chercher le chemin à la main.

[[runbook]]
id          = "journal-purge"
name        = "Purger le journal"
group       = "Maintenance"
description = "Réduit /var/log/journal à la taille demandée."
danger      = true                  # rouge + confirmation obligatoire
script      = """
journalctl --vacuum-size="$taille"
"""

  [[runbook.params]]
  name    = "taille"
  label   = "Taille cible"
  default = "200M"
  choices = ["100M", "200M", "500M", "1G"]   # liste fermée au lieu d'un champ libre
🔒 Comment un runbook est réellement exécuté

Le script part en Exec : sortie streamée et annulable comme n'importe quelle commande.

Les paramètres ne sont jamais interpolés dans le texte du script. Ils sont posés en variables shell échappées côté Rust, au-dessus du corps :

set -u
taille='200M'

journalctl --vacuum-size="$taille"

Une valeur hostile du genre a'; rm -rf / # reste donc une chaîne, et un test le vérifie. Le set -u fait échouer franchement une variable non déclarée au lieu de la réduire silencieusement à du vide.

Un runbook marqué confirm ou danger affiche le script réellement composé avant de partir, pas seulement son libellé.

🧱 Pare-feu

Les règles de l'hôte, en tableaux, une carte par chaîne. L'ordre des règles dans une chaîne est précisément ce qui décide du sort d'un paquet : un tableau global le noierait, donc chaque chaîne garde le sien.

Chaque chaîne affiche son point d'accroche (hook input, postrouting...), sa priorité et sa policy. Une chaîne sans hook est signalée comme chaîne utilisateur : elle n'est atteinte que par un jump, elle ne filtre rien d'elle même. Les colonnes couvrent interface d'entrée et de sortie, source, destination, protocole, port, état de connexion, verdict et compteurs. Filtres par famille, par interface et par texte ; les règles qui ont déjà compté des paquets sont marquées.

Lecture seule pour l'instant. L'édition est prévue pour une v2, et chaque règle porte déjà son handle, qui est exactement ce dont nft a besoin pour insérer ou supprimer au bon endroit.

🔍 D'où viennent les règles

Deux sources, dans cet ordre :

  1. nftables via nft --json list ruleset. C'est la source de vérité sur Debian récent, y compris quand les règles ont été posées avec iptables, qui est en réalité iptables-nft.
  2. iptables legacy via iptables-save et ip6tables-save, pour les hôtes qui n'ont pas migré.

Le JSON donne la structure, mais rendre une expression nftables en texte lisible reviendrait à réimplémenter le formateur de nft. La console récupère donc aussi la sortie de nft -a list ruleset et apparie les deux par handle : chaque règle porte à la fois ses champs exploitables et sa ligne exacte, dépliable d'un clic. Un appariement par position se décalerait au premier ruleset réordonné.

🔑 Lire un ruleset demande CAP_NET_ADMIN. Si l'agent ne tourne pas en root, il retente via sudo -n et le signale dans un bandeau. En cas d'échec, la raison est affichée plutôt qu'une liste vide sans explication.

👥 Comptes et clés SSH

Utilisateurs et groupes locaux lus dans /etc/passwd et /etc/group, sans aucun appel shell. Fiche par utilisateur (uid/gid, home, shell, groupe primaire, groupes secondaires, connexion possible ou non), vue groupes séparée, et gestion complète des authorized_keys : algorithme, taille, empreinte SHA256:... identique à ssh-keygen -l, commentaire, options, avec ajout, modification et retrait.

🛡️ Les garde-fous sur authorized_keys

Une clé est identifiée par son matériel, pas par sa position. Un authorized_keys réordonné ou édité à la main entre deux appels ne fait pas modifier ou supprimer la mauvaise ligne. Les lignes que l'agent ne sait pas analyser (commentaires de l'admin, en-têtes) sont conservées telles quelles.

L'écriture est atomique : temporaire voisin, chmod 600, chown, puis rename. sshd ne voit jamais un fichier tronqué ou trop permissif.

La saisie est validée par le même analyseur que celui qui écrira. Une clé privée collée par erreur, ou une ligne qui annonce ssh-rsa avec un blob ed25519, sont refusées avant même l'aller-retour réseau.

🔒 Ce qui protège quoi

Cette version n'a pas d'authentification : qui joint le port de l'agent a l'exécution de code, par conception (Exec). Le seul contrôle d'accès est l'appartenance au mesh. Les garde-fous ci-dessous visent donc ce qui reste : l'accident, et ce qu'un utilisateur local de l'hôte pourrait tirer de l'agent.

🧷 Les points durs, et pourquoi ils sont là

Filtre d'accès. L'agent se lie à l'IP NetBird détectée, pas à 0.0.0.0, et refuse toute source hors 100.64.0.0/10 (IPv4-mapped compris). --allow-any existe pour les tests locaux et le dit au démarrage. Les refus sont journalisés par salve, pas un par connexion : sinon, insister suffit à remplir le disque.

Contre-pression. Les réponses passent par un canal borné. Un canal non borné n'en exerce aucune : le lecteur de fichier produit à la vitesse du disque, la socket draine à la vitesse du lien, et l'écart s'accumule en mémoire. Mesuré sur un fichier de 400 Mio tiré par un client bridé : 535 Mio de RSS avant, 20 Mio après. Sur un fichier de plusieurs gigaoctets, c'était l'OOM killer.

FileGet refuse en plus ce qui n'est pas un fichier ordinaire, /dev/zero et les tubes nommés ne se terminant jamais, et plafonne à 1 Gio. Côté console, le fichier s'écrit au fil de l'eau dans un .part publié par rename : un transfert coupé ne laisse pas à sa place un fichier tronqué d'allure normale.

Écritures publiées par rename. L'agent écrit authorized_keys et son propre binaire dans un temporaire voisin, créé en exclusivité (O_CREAT | O_EXCL) et avec ses droits dès l'ouverture. Deux raisons : fs::write suit les liens symboliques, et le nom du temporaire contenait le PID de l'agent, que pgrep donne. Dans un dossier partagé, un utilisateur local pouvait donc y poser un lien à l'avance et faire écrire l'agent, souvent root, à l'endroit de son choix. Poser les droits après coup laissait par ailleurs une fenêtre où la clé était lisible par tous.

Paramètres de runbook. Posés en variables shell échappées au-dessus du script, jamais interpolés dedans, et exécutés sous set -u. Une valeur contenant ; rm -rf / reste une chaîne.

Jeton de forge. Écrit en 0600 dans un dossier 0700, dès la création.

Verrous. Pris en tolérant l'empoisonnement : un Mutex empoisonné est seulement un Mutex dont un porteur a paniqué, et refuser l'accès ensuite transformerait un incident isolé en session définitivement morte.

Dépendances. cargo audit sur les deux workspaces : aucune vulnérabilité.

🧭 Pistes d'amélioration (hors scope test)

  • 🔐 Authentification : token pré-partagé HMAC sur challenge, ou mTLS.
  • 📦 Transfert de fichiers chunké (actuellement en un bloc base64).
  • 🔁 Reconnexion automatique, multi-sélection d'hôtes, exécution en parallèle.
  • 📈 Historique de métriques persistant, latence entre hôtes du mesh.