La demande de fonctionnalité était simple : après une mise à jour de sh0 via le tableau de bord, l'utilisateur devait exécuter systemctl restart sh0 sur l'hôte. Mais il était déjà dans le tableau de bord. Pourquoi devrait-il avoir besoin de SSH ?
sh0 disposait déjà d'un terminal de conteneur -- xterm.js dans le navigateur, WebSocket vers le backend, Docker exec sur le conteneur. La question était : peut-on faire la même chose pour l'hôte lui-même ?
Le fossé architectural
Les terminaux de conteneurs sont faciles. L'API exec de Docker fournit un flux bidirectionnel avec prise en charge TTY. On crée une instance exec, on la lance avec Attach=true, Tty=true, et on obtient une connexion HTTP upgradée qui transmet des octets PTY bruts. Le backend fait simplement le pont entre les frames WebSocket et le flux Docker.
Les terminaux hôtes, c'est différent. Il n'y a pas d'API Docker. Il faut créer un vrai PTY sur le système d'exploitation hôte. Cela implique :
openpty()pour créer une paire PTY maître/esclavefork()+setsid()+TIOCSCTTYpour configurer un terminal de contrôleexec()du shell avec le fd esclave comme stdin/stdout/stderr- I/O non bloquant sur le fd maître pour la lecture/écriture asynchrone
- Nettoyage propre :
SIGHUP(à la fermeture du PTY), puisSIGKILL, puiswaitpid()
Le tout à l'intérieur d'un runtime asynchrone Rust (tokio), où bloquer la boucle d'événements est interdit.
Ce que nous avons construit
Backend (host_access.rs, ~550 lignes de Rust) :
- PTY créé dans spawn_blocking pour éviter de bloquer tokio
- fd maître encapsulé dans AsyncFd<OwnedFd> pour l'I/O non bloquant
- Un helper pty_write_all() qui gère EAGAIN et les écritures partielles
- Récupération du processus enfant via Child::wait() (pas waitpid brut)
- Navigateur de fichiers utilisant tokio::fs avec canonicalisation des chemins
Frontend : même configuration xterm.js que le terminal de conteneur, mais connecté à /api/v1/host/terminal au lieu de /api/v1/apps/:id/terminal. Un navigateur de fichiers avec la même disposition à deux panneaux, réutilisant le composant FileTree existant via une nouvelle prop browseFn.
Ce que deux auditeurs ont trouvé
Nous avons lancé deux agents d'audit indépendants simultanément. Ils se sont accordés sur les problèmes critiques et chacun a trouvé des vulnérabilités uniques que l'autre avait manquées.
Les deux auditeurs ont détecté : contournement par traversée de liens symboliques
Le système de protection des chemins vérifiait /tmp/file contre une liste de blocage de /proc, /sys, /bin, etc. Mais un administrateur pouvait créer /tmp/evil -> /etc/shadow puis écrire dans /tmp/evil. La chaîne /tmp/evil passe tous les contrôles. Le système de fichiers suit le lien symbolique.
Correctif : tokio::fs::canonicalize() avant de vérifier les protections. Si /tmp/evil se résout en /etc/shadow, l'écriture est bloquée.
Auditeur 1 : double fermeture du fd esclave
Stdio::from_raw_fd(slave_fd) prend possession du fd. Puis libc::close(slave_fd) dans le parent le ferme une seconde fois. Si un autre thread ouvre un fd entre les deux fermetures, on ferme le mauvais fd.
Correctif : dup() du fd esclave pour chacun de stdin/stdout/stderr. Fermer l'original une seule fois.
Auditeur 2 : écritures bloquantes sur le runtime asynchrone
Le côté lecture utilisait correctement AsyncFd::readable() avec try_io(). Mais le côté écriture utilisait libc::write() brut -- bloquant. Si le tampon PTY se remplit (le shell ne lit pas), le thread worker tokio entier se bloque.
Correctif : pty_write_all() utilisant AsyncFd::writable() + try_io(), avec gestion correcte des retries EAGAIN et des écritures partielles.
Auditeur 2 : /dev/zero provoque un OOM
metadata.len() renvoie 0 pour les fichiers de périphérique. La vérification de taille passe. tokio::fs::read() lit indéfiniment. Mémoire épuisée.
Correctif : vérifier metadata.is_file() pour rejeter les fichiers de périphérique. Utiliser AsyncReadExt::take(MAX_READ_SIZE) au lieu de faire confiance aux métadonnées.
La méthodologie
Construire, puis auditer, puis auditer de nouveau, puis décider. Chaque session optimise localement. Le premier auditeur repère le problème de liens symboliques mais peut manquer la race condition EAGAIN. Le second auditeur, avec un regard neuf, détecte les écritures bloquantes mais peut ne pas remarquer la double fermeture. Ensemble, ils ont trouvé 15 problèmes dans les catégories Critique/Important.
L'implémentation a pris environ 30 minutes. Le double audit environ 5 minutes (agents parallèles). Les correctifs environ 15 minutes. Total : moins d'une heure pour un terminal hôte prêt pour la production avec une revue de sécurité complète.
Le résultat
La page Paramètres dispose maintenant d'un onglet "Host Access" avec un bandeau d'avertissement rouge et deux sous-vues : Terminal et Fichiers. Les administrateurs peuvent exécuter des commandes sur l'hôte, parcourir le système de fichiers, modifier des fichiers de configuration et redémarrer des services -- le tout depuis le navigateur. La fonctionnalité qui a motivé ce travail (redémarrer sh0 après une mise à jour) est désormais une tâche de 5 secondes au lieu de "ouvrir une session SSH séparée".