- Rust 62.9%
- Svelte 27.3%
- JavaScript 3.2%
- Shell 2.9%
- SCSS 1.9%
- Other 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
|
||
| .cargo | ||
| .forgejo/workflows | ||
| agent | ||
| console | ||
| protocol | ||
| screens | ||
| scripts | ||
| spike | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Makefile | ||
| README.md | ||
🛰️ 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 : 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
Les unités systemd, avec filtre et actions groupées (démarrer, arrêter, redémarrer, statut).
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'
idcroissant ni de tablependingcô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
Cancelet le registre d'AbortHandleont 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'idd'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 surSysInforestent utiles à la frontière IPC, mais plus sur le fil. - Les numéros de champ sont le contrat. Renommer un champ du
.protoest sans effet sur le fil ; changer son numéro ou son type casse la compatibilité. C'est la seule règle à retenir en modifiantagent.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 unrename: 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
renametraverse 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 Nenvoie 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 enBrokenPipepassé ~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.htmlréférence-t-il des assets qui existent vraiment, et ledistest-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 deniedsans rapport avec le code ; - sous compilation parallèle, cargo se prend des
No such file or directorysur 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 alorsdevUrl(localhost:5173). Lance Vite (npm --prefix ui run dev) ou build en release, qui embarquedist/.
📓 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 :
- 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 aveciptables, qui est en réalitéiptables-nft. - iptables legacy via
iptables-saveetip6tables-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 viasudo -net 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.


