Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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écodage TERM, winsize, modes termios encodés ; allocation via air_terminal::openpty() + set_window_size ; window-changeset_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 ; exec passe shell -c "commande".
  • signal : relais des signaux client ; exit-status/exit-signal : code de sortie du processus (via air-process::wait) renvoyé avant CHANNEL_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 canal direct-tcpip{host,port} ; le serveur connecte (air-async::TcpStream) et pompe canal ↔ socket.
  • Distant -R (tcpip-forward + forwarded-tcpip) : requête globale tcpip-forward → le serveur écoute (air-async::TcpListener) ; chaque connexion entrante → canal forwarded-tcpip vers le client. cancel-tcpip-forward ferme 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.ContenuPreuve
C.0Cet ADR (design + plan).ratifié
C.1Additif 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.2Cœur canaux multiplexés (state machine + codec + fenêtres + ouverture/fermeture) + fuzz du parseur de messages de canal.multiplexing prouvé (property/model-based)
C.3Session 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.4Port-forwarding direct-tcpip + tcpip-forward/forwarded-tcpip (cœur + pilote air-async).ssh -L/-R réels traversent (interop scellée)
C.5Démon air-sshd (systemd v1, accept-loop, multiplexé) + config host key/authorized.démon servant plusieurs connexions/canaux
C.6Client air-ssh connect (rôle client, session/shell).air-sshair-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 sans Co-Authored-By, suivi mis à jour, mergé après re-vérification.

Conséquences

  • air-sshd devient utilisable (shell interactif) et multiplexé (sessions + forwarding).
  • Le client air-ssh existe (premier binaire client Air) — la paire air-ssh/air-sshd dé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.2 différé.
  • Différés : sftp/subsystem, verbes air-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 client connect.
  • Fork async-signal-safe en couche 2 (pour éviter l’additif air-process). Rejetée : dupliquerait la machinerie de spawn_process et 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).