ADR-097 — air-sshd phase 3 : ssh-connection (canaux multiplexés, session pty/shell/exec, port-forwarding) + client air-ssh
Statut : Accepté (2026-07-23, décision BDFL). Met en œuvre la phase 3
d’ADR-074 §88 (ssh-connection), au-dessus de la phase 1
transport (ADR-093) et de la phase 2 ssh-userauth
(ADR-094, U.0→U.4 mergés). Paramètres de design tranchés
par le BDFL ce jour : (1) additifs couche 1 minimaux autorisés pour la phase 3 (non
re-scellés — folés dans le futur re-sceau couche-1-v2.2) ; (2) shell de login issu de
/etc/passwd (via air-account) ; (3) périmètre = serveur air-sshd complet + client
air-ssh connect (sftp/subsystem et verbes air-ssh config/keygen différés) ;
(4) multiplexing complet (plusieurs sessions et port-forwarding) dès la phase 3.
Reste sur le motif sans-IO obligatoire (ADR-091).
S’appuie sur air-terminal (PTY/termios, ADR-060/ADR-061),
air-process (spawn/privsep, ADR-068), air-account
(ADR-067), air-async (I/O, ADR-092),
et le packaging exécutables (ADR-095).
Catégorie : Architecture (couche 2, service réseau). Toute évolution structurante passe par un RFC (ADR-015).
Contexte
Après l’authentification (phase 2), un vrai client SSH ouvre le service ssh-connection
(RFC 4254) : il multiplexe des canaux sur la connexion chiffrée, dont un canal de
session portant un PTY + un shell/commande, et des canaux de redirection de ports.
La phase 3 rend air-sshd réellement utilisable (ssh user@host donne un shell) et livre
le client air-ssh (ADR-095), premier binaire client d’Air.
Décision
1. Topologie — le cœur sans-IO gagne la couche canaux
- Cœur
air-ssh-proto(pur,#![forbid(unsafe_code)], fuzzable) : codec + state machine des canaux (RFC 4254 §5), table de multiplexing, fenêtres de flux par canal, et les codecs des requêtes de canal (pty-req/env/shell/exec/window-change/signal/exit-status/exit-signal) et des ouvertures (session/direct-tcpip/forwarded-tcpip). - Pilote
air-sshd(couche service) : allocation PTY (air-terminal), spawn du shell (air-process), pump de données canal ↔ PTY/socket, I/O de forwarding (air-async), publication AirCom. - Client
air-ssh: un pilote rôle client sur le même cœur (KEX/userauth/canaux côté client), binaire distinct (ADR-095).
2. Canaux multiplexés (RFC 4254 §5)
Table de canaux indexée par identifiant local ; par canal : fenêtres entrante/sortante
(bornées, CHANNEL_WINDOW_ADJUST), DATA/EXTENDED_DATA (stderr), EOF, CLOSE
(demi-fermeture correcte), CHANNEL_REQUEST/SUCCESS/FAILURE. Ouverture :
CHANNEL_OPEN/OPEN_CONFIRMATION/OPEN_FAILURE. Anti-hostile : bornes strictes sur les
tailles de fenêtre/paquet, rejet des identifiants inconnus, jamais d’allocation non bornée.
3. Session — PTY + shell/exec (RFC 4254 §6)
pty-req: décodageTERM,winsize, modes termios encodés ; allocation viaair_terminal::openpty()+set_window_size;window-change→set_window_size(master).env: allowlist (jamais d’injection arbitraire d’environnement) —TERM,LANG,LC_*par défaut, extensible par configuration.shell/exec: lance le shell de login de l’utilisateur (PasswdEntry.shell,air-account) sur le slave du PTY ;execpasseshell -c "commande".signal: relais des signaux client ;exit-status/exit-signal: code de sortie du processus (viaair-process::wait) renvoyé avantCHANNEL_CLOSE.
4. Login — setsid + terminal de contrôle + drop_to_user
L’enfant (via air-process::spawn_process) : setsid (chef de session) → slave = terminal
de contrôle (TIOCSCTTY) → dup2(slave→0/1/2) → drop_to_user (uid/gid + groupes
supplémentaires, air-account) → chdir(home) → execve le shell (argv[0] = "-shell" pour
un login interactif). Environnement minimal fixé : HOME/USER/LOGNAME/SHELL/PATH/TERM
(+ allowlist env). drop_to_user AVANT l’exec : le shell ne tourne jamais en
privilégié (privsep, ADR-068).
5. Port-forwarding (RFC 4254 §7)
- Local
-L(direct-tcpip) : le client ouvre un canaldirect-tcpip{host,port}; le serveur connecte (air-async::TcpStream) et pompe canal ↔ socket. - Distant
-R(tcpip-forward+forwarded-tcpip) : requête globaletcpip-forward→ le serveur écoute (air-async::TcpListener) ; chaque connexion entrante → canalforwarded-tcpipvers le client.cancel-tcpip-forwardferme l’écoute. - Bornes de sécurité : pas de forwarding avant authentification ; adresses/ports validés.
6. Additifs couche 1 (autorisés, non re-scellés)
La phase 3 exige au moins un additif air-process : SpawnAttributes.setsid +
terminal de contrôle (TIOCSCTTY dans l’enfant, async-signal-safe), car un login shell
l’impose et la couche 1 ne l’expose pas encore. Tout additif couche 1 de la phase 3 est
strictement minimal, non cassant (descellement additif), NON re-scellé maintenant : il
sera folé dans le re-sceau unique couche-1-v2.2 posé (signé) par le superviseur quand
air-ssh sera terminé (décision BDFL 2026-07-23), avec F2 (contributory X25519) et les
additifs déjà en attente.
7. Démon air-sshd (systemd v1)
Binaire réel : socket d’écoute (systemd LISTEN_FDS ou bind), accept-loop, une tâche
air-async par connexion, canaux multiplexés. Clé d’hôte + politique d’autorisation
(trait AuthorizedKeys U.3a ; backend fichier = U.3b, bloqué tooling → en attendant, backend
en mémoire/système). Lancé par systemd (ADR-093 §6,
v1).
8. Client air-ssh connect
Binaire client (ADR-095) : bannière/KEX/userauth rôle client (offre de clés publickey,
signature), ouverture d’un canal de session, pty-req/shell, pont PTY local ↔ canal
(mode raw sur le terminal appelant). Prouvé contre air-sshd (les deux exécutables Air) et
contre un serveur OpenSSH de stock. Verbes config/keygen différés.
9. Événements AirCom
sessionOpened{sessionId,user,pty} / shellStarted{sessionId,user} /
sessionClosed{sessionId} — schéma déjà figé (sshd.capnp, ids @9–@14). Publication via
le SessionEventPublisher (U.3a).
10. Incréments (phase 3) — chaque incrément = 1 PR verte, cœur ~100 % + fuzz, interop réel
| Inc. | Contenu | Preuve |
|---|---|---|
| C.0 | Cet ADR (design + plan). | ratifié |
| C.1 | Additif couche 1 air-process : SpawnAttributes.setsid + terminal de contrôle (async-signal-safe) + tests on-target. | shell forké devient chef de session avec ctty |
| C.2 | Cœur canaux multiplexés (state machine + codec + fenêtres + ouverture/fermeture) + fuzz du parseur de messages de canal. | multiplexing prouvé (property/model-based) |
| C.3 | Session pty-req/env/shell/exec/window-change/signal/exit-status (cœur) + pilote (PTY + spawn + pump). | vrai ssh user@host → shell interactif, echo/exit-status scellés en CI |
| C.4 | Port-forwarding direct-tcpip + tcpip-forward/forwarded-tcpip (cœur + pilote air-async). | ssh -L/-R réels traversent (interop scellée) |
| C.5 | Démon air-sshd (systemd v1, accept-loop, multiplexé) + config host key/authorized. | démon servant plusieurs connexions/canaux |
| C.6 | Client air-ssh connect (rôle client, session/shell). | air-ssh↔air-sshd ET air-ssh↔OpenSSH-server donnent un shell |
Chaque incrément : cœur air-ssh-proto ~100 % (unit + property + model-based + fuzz), pilote
90 %, barrière verte (
fmt/clippy -D/check --workspace/check-layers/test/llvm-cov), commit signé GPG+DCO sansCo-Authored-By, suivi mis à jour, mergé après re-vérification.
Conséquences
air-sshddevient utilisable (shell interactif) et multiplexé (sessions + forwarding).- Le client
air-sshexiste (premier binaire client Air) — la paireair-ssh/air-sshddémontre la couche 2 de bout en bout ([[air-sshd-vision]]). - Additifs couche 1 de la phase 3 : accumulés pour le re-sceau
couche-1-v2.2différé. - Différés : sftp/
subsystem, verbesair-ssh config/keygen(ADR-095), agent-forwarding, X11-forwarding — évolutions additives ultérieures. - Dette phase 1 (
couche-1-v2.2) + F2 contributory X25519 : soldées à la fin d’air-ssh.
Alternatives rejetées
- Un seul canal de session (pas de multiplexing). Écartée par le BDFL : multiplexing complet (sessions + forwarding) dès la phase 3.
- Différer le client
air-ssh. Écartée : le périmètre inclut le clientconnect. - Fork async-signal-safe en couche 2 (pour éviter l’additif
air-process). Rejetée : dupliquerait la machinerie despawn_processet affaiblirait la frontière de couches ; l’additif couche 1 (setsid/ctty) est la bonne place. - Shell fixe / shell maison. Écartée : shell de login
/etc/passwd(comportement standard).