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

Configuration du client air-ssh — blocs par-hôte, ForwardAgent, keepalive, ProxyJump

Ce guide s’adresse à qui utilise air-ssh : comment sa configuration est rangée, ce qu’un bloc par-hôte peut dire, ce que ForwardAgent expose exactement, comment détecter un serveur mort, comment rebondir par un bastion — dans quel ordre les décisions se prennent, et ce que nous avons refusé d’implémenter.

Décisions de référence : ADR-099 (le sous-système de configuration client), ADR-118 §4 et §5 (les options client et le keepalive), ADR-116 §3-c (l’agent forwarding), ADR-117 §2 (le rebond), ADR-073 (pourquoi c’est binaire).

Cette page couvre les incréments c1 (ForwardAgent), c2 (ServerAliveInterval/ServerAliveCountMax) et c3 (ProxyJump), qui closent le volet client d’ADR-118.

1. Où vit la configuration, et pourquoi elle n’est pas un fichier texte

air-ssh ne lit aucun ~/.ssh/config. Sa configuration est un artefact binaire au repos :

${XDG_CONFIG_HOME:-~/.config}/air/ssh.aircfg

C’est la doctrine d’ADR-073 : ce qu’un composant lit en entrée pour décider repose sur un schéma à disposition fixe et bornée — plus d’analyse syntaxique, un contrôle de présence. Il n’y a donc pas de fichier à éditer à la main, mais des verbes qui écrivent l’artefact, et un export/import JSON pour les échanges.

Conséquence pratique : on ne copie pas son ssh_config d’OpenSSH. C’est un coût assumé, consigné dans ADR-118 (« pas d’interop texte »).

2. Le fichier doit vous appartenir — et vous seul doit pouvoir l’écrire

air-ssh refuse de lire ssh.aircfg si :

ConstatCorrectif annoncé par le message
le fichier appartient à quelqu’un d’autre — y compris rootchown <vous> <chemin> && chmod 600 <chemin>
il est inscriptible par le groupe ou par tous (mode & 0022)chmod 600 <chemin>
c’est un lien symboliqueen faire un fichier ordinaire
ce n’est pas un fichier ordinaire (tube nommé, socket, périphérique, répertoire)rm <chemin> puis réécrire l’artefact

0644 passe : on refuse l’inscriptible, jamais le lisible — un umask 022 standard produit 0644, et un durcissement qui casse toutes les installations existantes n’est pas déployé, donc ne protège personne. Une config absente reste sans effet et sans erreur : la plupart des postes n’en ont pas. En revanche, une config présente mais inouvrable (permissions, descripteurs épuisés…) est un refus, jamais un « pas de config » : ce serait appliquer en silence une politique que vous n’avez pas écrite.

air-ssh config set écrit désormais l’artefact en 0600, par échange atomique (temporaire privé, fchmod, fsync, rename) : privé par construction quel que soit l’umask, et jamais de fichier à moitié écrit si la machine s’arrête en cours de route.

Pourquoi cette garde, et pourquoi seulement maintenant

C’est le précédent d’OpenSSH : il refuse un ~/.ssh/config non possédé par l’utilisateur ou inscriptible groupe/autres — « Bad owner or permissions » — et il le fait précisément parce que ce fichier peut allumer ForwardAgent et ProxyCommand.

Jusqu’à l’incrément c1, la config d’air-ssh ne pouvait pas allumer le relais d’agent. Elle vient de franchir ce seuil : la garde est la contrepartie de la capacité ajoutée, pas un ornement. Le scénario qu’elle ferme est banal — machine multi-utilisateurs, ~/.config/air créé sous un umask 002 avec un groupe partagé ; un tiers y pose forward-agent yes --host prod ; à la connexion suivante l’agent de la victime est relayé vers un hôte qu’elle n’a jamais choisi d’approvisionner.

L’enjeu dépasse d’ailleurs le relais. Ce même fichier porte déjà trusted-host-ca et revoked-host-keys (ADR-109, ADR-138) : quiconque peut l’écrire peut neutraliser la révocation de certificats d’hôte. La garde valait donc déjà avant -A — c’était une dette ; ForwardAgent l’a rendue exigible.

Le refus du lien symbolique n’est pas du zèle : sans O_NOFOLLOW, la vérification porterait sur le lien — dont le mode est toujours 0777 et ignoré du noyau — et la lecture sur sa cible. On vérifierait un fichier et on en lirait un autre. Pour la même raison, les métadonnées sont lues sur le descripteur déjà ouvert (statx/AT_EMPTY_PATH) : il n’y a pas de fenêtre entre le contrôle et la lecture.

Le refus de ce qui n’est pas un fichier ordinaire ferme un déni de service, pas une fuite : O_NOFOLLOW ne dit rien du type de l’entrée, et open() sur un tube nommé (mkfifo) bloque indéfiniment jusqu’à ce qu’un écrivain se présente. Le même tiers du scénario ci-dessus pose un FIFO et chaque air-ssh de la victime se fige — sans message, sans délai. L’ouverture pose donc O_NONBLOCK (pour atteindre la garde ; sur un fichier ordinaire il ne change rien à la lecture) et la garde refuse le type.

Ce que la garde ne contrôle pas

  • Les répertoires parents. O_NOFOLLOW ne porte que sur le dernier composant du chemin : un lien symbolique posé sur ~/.config ou ~/.config/air n’est pas vu. Le correctif exact serait RESOLVE_NO_SYMLINKS (openat2), que la surface de fichiers d’Air n’expose pas encore. La propriété qui tient alors est le contrôle de propriétaire : où que le lien mène, un non-privilégié ne peut pas y créer un fichier possédé par vous, et un fichier qui ne vous appartient pas est refusé. OpenSSH ne contrôle pas davantage les parents de ~/.ssh/config — c’est la parité, pas un oubli.
  • La taille de l’artefact n’est pas plafonnée à la lecture. Le fichier vous appartient (la garde vient de l’exiger) : le seul moyen de s’en servir est de se le faire à soi-même. À réconcilier avec le plafond déjà employé pour authorized_keys.
  • config get/export/set lisent encore l’artefact sans cette garde : config get peut donc afficher une configuration que connect refuse. Le diagnostic est plus permissif que l’exécution — à réconcilier.

Une config posée par l’administrateur cesse de fonctionner

OpenSSH tolère un ~/.ssh/config appartenant à root. Air non : le propriétaire doit être votre uid effectif, root compris.

C’est délibéré. L’argument d’OpenSSH est qu’un fichier possédé par root ne peut pas avoir été piégé par un tiers non privilégié — vrai. Mais il ouvre une asymétrie que nous ne voulons pas : le fichier qui décide de votre agent et de votre politique de révocation d’hôte serait alors modifiable par quelqu’un que vous ne pouvez pas contredire, et vous n’auriez aucun moyen de reprendre la main sans privilège. La règle d’Air est que ce qui décide pour vous vous appartient.

Conséquence pratique, à connaître avant de déployer : une config provisionnée par un admin dans le home d’un utilisateur (install -o root) sera refusée. La provisionner en la donnant à l’utilisateur (chown <user>) est la marche à suivre.

3. Les verbes

air-ssh config get    <path>
air-ssh config set    <path> <clé> [valeur…]
air-ssh config export <path>                 # → JSON sur stdout
air-ssh config import <path> <fichier.json>  # JSON → artefact binaire

Les clés globales : default-identity, trusted-host-ca, revoked-host-keys (et leurs variantes …-clear), plus le keepalive du poste (§6). Les blocs par-hôte :

air-ssh config set <path> host <motif> hostname|user|port|identity <valeur>
air-ssh config set <path> host-remove <motif>
air-ssh config set <path> forward-agent yes|no|unset --host <motif>
air-ssh config set <path> server-alive-interval  <secondes>|unset [--host <motif>]
air-ssh config set <path> server-alive-count-max <n>|unset        [--host <motif>]
air-ssh config set <path> proxy-jump <[user@]hôte[:port][,…]>|none --host <motif>

Le <motif> est un nom exact — les jokers */? d’OpenSSH sont différés. Le premier bloc dont le motif égale l’hôte saisi fournit ses surcharges.

Le JSON n’est jamais lu au démarrage du client (ADR-099) : c’est un format d’import/export, pas une seconde source de vérité.

4. ForwardAgent — ce que c’est, et pourquoi c’est opt-in

Le forwarding d’agent relaie votre agent ambiant ($SSH_AUTH_SOCK) jusqu’à l’hôte distant, pour y rebondir sans y déposer de clé. C’est commode, et c’est la fonctionnalité la plus dangereuse d’SSH : tant que la session vit, quiconque contrôle l’hôte distant peut se servir de votre agent. Il ne peut pas exfiltrer vos clés (le protocole ssh-agent n’a aucun message pour ça), mais il peut signer avec — donc s’authentifier partout où elles ouvrent.

C’est pourquoi le défaut est, et reste, fermé — et pourquoi le relais n’existe que sous double opt-in (ADR-116) : le client doit le demander, et le serveur doit l’accorder (politique du démon et permission de la clé). Ce guide ne parle que de la première moitié : ce que le client demande. Rien de ce qui suit n’élargit ce qu’un serveur accorde.

Deux parades valent d’être rappelées : un agent qui confirme chaque usage (ssh-add -c), et ne donner le relais qu’aux hôtes à qui vous le confieriez.

Poser l’option

air-ssh config set ~/.config/air/ssh.aircfg forward-agent yes   --host prod
air-ssh config set ~/.config/air/ssh.aircfg forward-agent no    --host bastion
air-ssh config set ~/.config/air/ssh.aircfg forward-agent unset --host prod

--host est obligatoire. ForwardAgent n’existe ici qu’en surcharge par hôte : il n’y a pas de défaut global. Accepter une forme sans hôte laisserait croire qu’on allume le relais partout, et sur cette fonctionnalité-là le malentendu ne se répare pas après coup.

Trois états, pas deux

VerbeCe que dit le blocAffiché par config get
yesdemande le relais pour cet hôteforward-agent=yes
norefuse le relais pour cet hôteforward-agent=no
unsetne se prononce pas (défaut)forward-agent=-

Le troisième état n’est pas une coquetterie. Un bloc par-hôte est un modèle de surcharge : sans distinguer « je ne dis rien » de « je dis non », un bloc ne pourrait jamais éteindre ce qu’un défaut global allumerait — et unset est le seul moyen de rendre son silence à un bloc, sinon une surcharge posée par erreur ne pourrait plus qu’être retournée, jamais effacée.

Dans le schéma, c’est une union à trois membres dont unspecified est le premier, donc le zéro : un artefact écrit avant cet incrément relit « ne se prononce pas », jamais une valeur posée. Un discriminant hors des trois fait refuser l’artefact entier — une union qu’on ne comprend pas ne se devine pas dans le sens qui expose l’agent.

5. La précédence : -a > -A > config > défaut fermé

air-ssh connect prod          # ce que dit le bloc `prod`, sinon rien
air-ssh connect -A prod       # relais demandé, quoi que dise la config
air-ssh connect -a prod       # AUCUN relais, quoi que dise la config
air-ssh connect -A -a prod    # ERREUR de ligne de commande
  1. -A et -a ensemble ⇒ erreur. Deux ordres contraires sur l’exposition de votre agent ; trancher à votre place reviendrait à décider seul de ce qu’un hôte distant peut faire avec vos clés. Même posture que les exclusions entre -i, --air-agent et --ssh-agent.
  2. -a ⇒ jamais de relais. Le refus gagne toujours. C’est ce qui rend la config sûre à allumer par hôte : il reste toujours un moyen de dire « pas cette fois ».
  3. -A ⇒ relais demandé. La ligne de commande prime sur la config, dans ce sens comme dans l’autre.
  4. Sinon ⇒ ce que dit le bloc hôte, et false s’il ne dit rien.

-a n’existait pas avant cet incrément, et c’était sans conséquence tant que seul -A pouvait allumer le relais. Dès lors qu’un fichier peut l’allumer, son absence deviendrait un défaut de sécurité : un interrupteur sans coupe-circuit.

Ce que la config allume, elle le dit

Quand c’est un bloc de configuration qui allume le relais — et seulement dans ce cas — air-ssh l’écrit sur stderr, en nommant le bloc responsable :

air-ssh: forwarding d'agent activé par la configuration (bloc « prod ») — `-a` pour refuser

Avec -A, rien : le consentement vient d’être tapé, il n’y a rien à rappeler. Venu d’un fichier, il peut dater de six mois — et l’administrateur du bastion peut avoir été compromis entre-temps. Le message n’apparaît pas non plus si le relais n’est finalement pas obtenu (pas d’agent ambiant, -N) : annoncer une exposition qui n’a pas lieu serait un mensonge de plus, pas une garantie de moins.

Ce que la demande obtient

Demander n’est pas obtenir. Une fois la demande établie, trois règles indépendantes s’appliquent (inchangées depuis ADR-116 inc. c-3) :

  • -N n’ouvre aucune session — donc aucun canal d’agent ne peut naître. Un avertissement le dit ; la connexion se poursuit.
  • Sans agent ambiant ($SSH_AUTH_SOCK absente, vide, ou inexploitable), le relais n’est pas demandé au serveur. Avertissement, et la session continue : on ne fait pas échouer une connexion pour une commodité absente.
  • L’agent relayé est celui de $SSH_AUTH_SOCK, et rien d’autre. En particulier, --air-agent n’est pas relayable tel quel : son socket parle le protocole natif d’Air, pas celui d’OpenSSH. Pour relayer des clés d’Air, exporter le pont OpenSSH d’air-agent dans $SSH_AUTH_SOCK.

6. Keepalive — ServerAliveInterval / ServerAliveCountMax

Le problème, et ce qu’il coûte

Un serveur dont la machine s’éteint, un routeur NAT qui oublie sa traduction, un lien qui tombe : rien de tout cela ne ferme la connexion. Tout cela la fait taire. TCP, seul, ne le remarquera pas avant des heures — et pour une connexion qui ne transporte rien, jamais.

Le coût est concret. Un air-ssh -N -L 5432:localhost:5432 prod reste en vie indéfiniment, à écouter un port local qui ne mène plus nulle part : chaque application qui s’y connecte attend, puis échoue, et personne ne comprend pourquoi le tunnel « marche » sans transporter un octet. Un shell interactif, lui, se fige sans jamais rendre l’invite.

La parade est celle d’OpenSSH, et elle tient en deux nombres : on sonde périodiquement, on compte les sondes restées sans réponse, on ferme au-delà d’un seuil.

Le mécanisme, exactement

Toutes les ServerAliveInterval secondes sans avoir reçu un seul octet du serveur, air-ssh lui envoie une requête globale keepalive@openssh.com avec want_reply. Le serveur — n’importe quel serveur SSH, qui n’a pas à connaître ce nom — répond REQUEST_FAILURE, et cette réponse est un paquet reçu comme un autre. C’est tout ce qui compte : le client ne lit pas le contenu de la réponse, il constate qu’il en est arrivé une. Tout trafic du serveur — une ligne de sortie, un ajustement de fenêtre, un CHANNEL_OPEN — vaut preuve de vie et remet le compteur à zéro.

Au bout de ServerAliveCountMax sondes consécutives sans le moindre octet en retour, la connexion est fermée. air-ssh sort alors en échec, en le disant :

air-ssh: le serveur ne répond plus — 3 sondes de keepalive consécutives sans réponse
         (ServerAliveInterval 30 s)

Chaque sonde émise obtient un intervalle complet pour être honorée : le délai réel de détection est donc interval × (count_max + 1), pas interval × count_max. C’est l’ordre d’OpenSSH, et il est du bon côté — on ne ferme jamais une connexion à qui l’on n’a pas laissé le temps de répondre.

0 désactive, et c’est le défaut. Sans réglage, aucun timer n’est armé, aucune sonde n’est émise, et le chemin d’exécution est exactement celui d’avant cet incrément.

Le régime qui en a le plus besoin : -N

Une connexion qui ne porte que des redirections oisives est précisément celle où un pair mort passe inaperçu — il n’y a aucun trafic applicatif pour trahir son silence. Le keepalive couvre les deux régimes du client (shell/exec et moteur -N/-L/-R), mais c’est là qu’il change quelque chose.

Poser les réglages

# Le défaut du poste : sonder tous les hôtes toutes les 30 s.
air-ssh config set ~/.config/air/ssh.aircfg server-alive-interval 30

# Un bastion derrière un NAT nerveux : plus serré, et moins tolérant.
air-ssh config set ~/.config/air/ssh.aircfg server-alive-interval  10 --host bastion
air-ssh config set ~/.config/air/ssh.aircfg server-alive-count-max 2  --host bastion

# Une machine du même commutateur : pas de sonde du tout.
air-ssh config set ~/.config/air/ssh.aircfg server-alive-interval 0 --host lan

# Rendre à un bloc son silence (il hérite de nouveau du poste).
air-ssh config set ~/.config/air/ssh.aircfg server-alive-interval unset --host bastion

Et en ligne de commande, pour une connexion :

air-ssh connect --server-alive-interval 15 --server-alive-count-max 4 prod
air-ssh connect --server-alive-interval 0 prod    # éteint ce que la config allume

Deux portées, et pourquoi — là où ForwardAgent n’en a qu’une

ForwardAgent n’existe qu’en surcharge par hôte : il n’y a pas de défaut global, parce qu’un défaut global exposerait votre agent partout (§4). Le keepalive, lui, a les deux : un défaut de poste et une surcharge par hôte.

La différence n’est pas de commodité, elle est de nature. Un keepalive n’ouvre et ne ferme aucun accès : il ne fait que borner le temps qu’une connexion morte peut occuper des ressources. Il n’y a donc rien qu’un défaut global puisse élargir. Et le client, à la différence du démon, contacte des hôtes dont les chemins réseau n’ont rien de commun : un bastion derrière un NAT qui oublie ses traductions n’a pas les mêmes besoins qu’une machine du même commutateur. C’est aussi la sémantique d’OpenSSH, où ServerAliveInterval est une option de bloc Host.

La composition, en une règle : le bloc gagne s’il se prononce, le poste sinon — et les deux réglages se composent séparément. Un bloc peut donc raccourcir le seul intervalle et garder le count_max du poste, sans avoir à recopier — donc à figer — ce qu’il ne voulait pas changer.

La précédence complète : --server-alive-* > bloc hôte > défaut du poste > désactivé.

Pourquoi le client emploie des timers là où le serveur emploie un ppoll

Le même mécanisme, deux ordonnanceurs. Un lecteur qui compare les deux implémentations sans cette phrase conclura à une divergence de style ; ce n’en est pas une.

Le keepalive serveur (ADR-118 §5, incrément serveur 8) vit dans le worker confiné, post-authentification. Ce worker n’a aucun réacteur : sa cage seccomp n’autorise pas un seul io_uring_*, et son exécuteur ne poll sa future qu’une fois, avec un réveil inerte. La seule horloge dont il dispose est le délai de son ppoll — le keepalive y est donc une échéance de plus dans une boucle qui tournait déjà.

Le keepalive client vit dans air-ssh, qui construit un vrai air_async::Runtime et fait tourner ses moteurs dans un block_on. Le réacteur io_uring est là, les moteurs sont déjà des ensembles de futures concurrentes, et le keepalive n’y est qu’une future de plus, armée sur un Timeout réel. C’est ce que l’ADR annonçait (« réutilise les timers d’air-async ») — vrai ici, faux là-bas.

Ce qui est partagé, en revanche, c’est la seule chose qui compte : la décision (sonder / attendre / fermer). Elle est écrite une fois, dans crate::keepalive, et les quatre points d’intégration — deux serveur, deux client — s’en servent à l’identique. Il n’y a pas deux sémantiques de keepalive dans ce dépôt.

L’autre sens : air-ssh répond aussi

Le keepalive n’est pas unilatéral. Un air-sshd portant un ClientAliveInterval, ou un sshd d’OpenSSH durci, sonde son client — et déconnecte celui qui ne répond pas. Que vous ayez configuré ServerAliveInterval ou non, air-ssh honore ces sondes : c’est une règle de protocole (RFC 4254), pas un réglage.

⚠️ Un trou connu, en face : le démon Air en mode monolithique. Un air-sshd lancé sans --privsep cesse de dépouiller les messages du client dès qu’il a lancé un shell ou une commande — il ne répondra donc à aucune de vos sondes, et votre air-ssh conclura à un pair mort au bout d’interval × (count_max + 1) sur une connexion parfaitement saine. Ce n’est pas une incohérence : ce même démon refuse déjà de démarrer avec un ClientAliveInterval, son flux io_uring n’ayant pas d’attente bornée. Le mode monolithique ne supporte pas le keepalive, dans un sens comme dans l’autre. Contre un air-sshd privsep ou contre OpenSSH, aucun problème.

Il y a deux formes de sonde sur le fil, et il faut savoir répondre aux deux. air-sshd émet toujours une requête globale. OpenSSH, lui, en émet une requête de canal dès qu’un canal est ouvert, et ne retombe sur la requête globale qu’à défaut. Un client qui ne connaîtrait que la première se ferait couper par un sshd durci dès qu’une session existe — c’est-à-dire toujours. Ce défaut-là n’a pas été déduit : il a été trouvé par le test d’interopérabilité contre un vrai sshd, qui échouait alors que toutes les suites contre pair scripté passaient.

7. ProxyJump — rebondir par un bastion

Le problème

Une machine qui compte n’est pas jointe depuis n’importe où. On la met derrière un bastion : une seule machine exposée, journalisée, surveillée, par laquelle tout passe. Sans rebond, il faut alors se connecter au bastion, puis se reconnecter depuis lui — ce qui suppose d’y déposer une clé privée. C’est précisément ce qu’on ne veut pas : la machine la plus exposée de l’infrastructure serait aussi celle qui détient les clés.

ProxyJump supprime ce compromis. air-ssh ouvre une connexion SSH complète vers le bastion, y ouvre un canal direct-tcpip vers la machine suivante, et fait courir dans ce canal le transport de la connexion suivante. Le bastion voit passer des octets chiffrés de bout en bout entre vous et la cible : il ne les déchiffre pas, il ne détient rien, et aucune clé ne quitte votre poste. (Cette propriété suppose que l’identité de la cible soit vérifiable ; lisez le paragraphe d’avertissement de §7 avant de vous y fier.)

# Une session sur `prod`, en rebondissant par `bastion`.
air-ssh connect prod -J bastion

# Chaîne : `a`, puis `b` depuis `a`, puis `prod` depuis `b`.
air-ssh connect prod -J a,b

# Compte et port par saut, comme OpenSSH.
air-ssh connect prod -J ops@bastion:2222

Le rebond est transparent pour tout le reste : -L, -R, -A, le keepalive, la confiance d’hôte par certificat et la session interactive fonctionnent au-dessus, sans réglage particulier — c’est le même code client, au-dessus d’un flux d’octets différent.

Pourquoi ProxyCommand n’est pas là

OpenSSH offre deux façons de rebondir. ProxyJump est celle décrite ci-dessus. ProxyCommand est l’autre : elle fait lancer une commande arbitraire, désignée par un fichier de configuration, dont l’entrée/sortie standard porte le transport.

air-ssh n’implémente pas ProxyCommand, et ce n’est pas un oubli.

Un ProxyCommand est une porte d’exécution : quiconque peut écrire votre fichier de configuration peut faire exécuter n’importe quoi, sous votre compte, au moment où vous tapez air-ssh. C’est exactement la classe de mécanisme que l’amendement D6 d’ADR-046 (« plus d’exécution non confinée ») et ADR-150 (manifeste/octroi) regardent de près — et c’est la même raison qui a fait écarter le critère exec des blocs Match client (§8.3). La décision du BDFL est que ProxyCommand attend son propre arbitrage ; d’ici là, l’absence est explicite.

ProxyJump, lui, est purement interne : aucun processus lancé, aucune commande, aucun exec. C’est ce qui le rend acceptable là où l’autre ne l’est pas.

Chaque saut passe par la même vérification que la cible

Le rebond ne dispense de rien. À chaque étage — bastion compris — air-ssh :

  1. fait un échange de clés complet et vérifie la signature d’hôte ;
  2. applique la confiance d’hôte (ADR-109, ADR-137) avec les magasins de votre configuration : trusted-host-ca et revoked-host-keys. Un certificat d’hôte non vérifiable — CA inconnue, identité incohérente, expiré, révoqué — fait échouer la connexion, à n’importe quel étage ;
  3. s’authentifie, séparément, avec le compte et la clé de cet étage.

Deux tests verrouillent le point 2 explicitement : un certificat d’hôte invalide au bastion fait échouer, et un certificat d’hôte invalide à la cible fait échouer aussi — bastion sain ou non.

⚠️ Ce que cette vérification ne fait pas — lisez ce paragraphe

Elle n’a d’effet que si l’hôte présente un certificat d’une CA que vous avez déclarée dans trusted-host-ca (ADR-109). Devant une clé d’hôte nue — le cas de la plupart des serveurs SSH aujourd’hui — air-ssh ne vérifie rien : il constate que la signature du KEX est cohérente avec la clé présentée, et accepte cette clé, quelle qu’elle soit. L’épinglage qui devrait trancher là — known_hosts / première-vue (TOFU) — n’est pas implémenté ; c’est un chantier ouvert, pas un réglage à activer.

Conséquence, dite sans détour : sans CA d’hôte configurée, un bastion compromis peut se faire passer pour la cible. Rien ne l’oblige à relayer quoi que ce soit : il peut répondre lui-même dans le canal, terminer la connexion « intérieure » sous une clé d’hôte de son choix, et récupérer votre session — et, si vous avez donné -A, l’accès à votre agent. La promesse « le bastion ne voit que des octets chiffrés qu’il ne peut pas lire » (§7, introduction) tient exactement dans la mesure où l’identité de la cible est vérifiable.

Ce manque n’est pas propre au rebond : il vaut pour toute connexion air-ssh, y compris directe. Le rebond ne l’aggrave pas et ne le corrige pas. Deux façons de s’en prémunir aujourd’hui :

  • déployer une CA d’hôtes (ADR-109) et la déclarer en trusted-host-ca — c’est le chemin que ce client vérifie réellement, de bout en bout, à chaque étage ;
  • à défaut, ne pas traiter le bastion comme un élément de confiance moindre que la cible : aujourd’hui, il l’est autant.

Le compte et la clé, saut par saut

Un bastion et la machine qu’il protège n’ont aucune raison de partager un compte ni une clé. La résolution est donc par saut, et c’est tout l’intérêt d’avoir les sauts dans la configuration :

RéglageOrdre de décision, pour un saut
comptele user@ du saut > le user du bloc du saut > $USER
portle :port du saut > le port du bloc du saut > 22
adressele hostName du bloc du saut > le nom du saut lui-même
identitél’identity du bloc du saut > l’identité de cette invocation (-i, --key-seed, --air-agent, --ssh-agent, sinon le défaut du poste : default-identity, puis ~/.ssh/id_ed25519)

Le compte de la cible ne fuit jamais vers un saut : air-ssh connect root@prod -J bastion ne demande pas root sur le bastion. Et il ne se devine pas non plus : sans user@, sans user sur le bloc du saut et sans $USER, la commande échoue en nommant le saut — avant le moindre octet de réseau. Partir avec un compte vide sur le fil serait un refus du bastion que personne ne saurait expliquer.

Pour l’identité, le repli est l’identité de l’invocation — et « l’invocation » exclut l’identity que le bloc de la cible porte. Ce n’est pas une nuance : avec

Host prod
  identity /home/moi/.keys/prod-only-key
  proxyJump bastion

replier sur l’identité résolue de la cible présenterait la clé dédiée à prod au bastion, ce qui dément l’invariant de tout ce paragraphe (le défaut a été relevé par la revue adverse de c3 et corrigé). Le repli n’est pas davantage la default-identity seule : ce serait faire gagner un défaut de fichier contre un -i explicite. En pratique, une seule clé — ou un agent — couvre le bastion et la cible ; quand ce n’est pas le cas, le bloc du saut nomme la sienne.

# Le bastion a son compte, son port, son adresse réelle et sa clé.
air-ssh config set ~/.config/air/ssh.aircfg host bastion hostname bastion.example.com
air-ssh config set ~/.config/air/ssh.aircfg host bastion user ops
air-ssh config set ~/.config/air/ssh.aircfg host bastion port 2222
air-ssh config set ~/.config/air/ssh.aircfg host bastion identity /home/moi/.ssh/id_bastion

# Et `prod` passe par lui, sans qu'on ait à le retaper.
air-ssh config set ~/.config/air/ssh.aircfg proxy-jump bastion --host prod

La précédence, et comment annuler

Ligne de commande > bloc hôte, la règle générale du client. -J est répétable (les sauts s’ajoutent), et -J none annule — y compris ce que la configuration pose sur le bloc de la cible :

air-ssh connect prod -J none    # aujourd'hui, en direct : le bastion est en maintenance.

C’est la parité d’OpenSSH, où ProxyJump none a exactement ce sens, et c’est la même nécessité que -a pour ForwardAgent : un réglage de fichier qu’on ne peut pas contredire ponctuellement est un réglage qu’on n’ose plus poser.

Côté configuration, proxy-jump none --host <motif> retire le rebond du bloc. --host est obligatoire : il n’y a pas de défaut global de rebond, et laisser croire qu’on route tout par un bastion serait un malentendu qu’on ne répare pas après coup.

La chaîne est bornée, et le cycle refusé

Le bloc d’un saut peut lui-même porter un proxy-jump : la chaîne se développe transitivement, comme chez OpenSSH. Une configuration peut donc décrire une chaîne longue, voire cyclique (a passe par b, qui passe par a).

Une récursion pilotée par un fichier de configuration est un défaut, pas une fonctionnalité. air-ssh refuse donc, avant tout octet de réseau :

  • au-delà de 8 sauts, développement compris — très au-delà de tout usage réel, et chaque saut coûte un handshake complet, une authentification et un canal ;
  • tout cycle, en nommant le motif fautif pour qu’on sache quel bloc rompre.

Le cycle traqué est celui du graphe de configuration, pas une coïncidence de noms : air-ssh connect 127.0.0.1 -p 2200 -J 127.0.0.1:2222 décrit deux démons sur la même machine, et passe. (Ce faux positif-là existait dans une première mouture ; c’est le test de bout en bout par les binaires qui l’a levé.)

Aucun repli direct — jamais

Si un saut échoue — injoignable, clé d’hôte refusée, authentification rejetée, canal refusé par sa politique — la connexion échoue. air-ssh ne rejoint jamais la cible par un autre chemin.

Ce n’est pas une position de principe : rejoindre la cible en direct quand le bastion tombe reviendrait à annuler la raison d’être du bastion — et à le faire silencieusement, au pire moment. Un test le verrouille : la cible est joignable en direct, le saut est mort, et la commande ne s’exécute pas.

Corollaire utile : avec -J, la cible n’est plus résolue localement. C’est le dernier saut qui résout son nom — donc un nom qui n’existe que derrière le bastion se joint désormais sans que votre poste ait à le connaître.

Un saut muet ne fige pas la commande

Un bastion peut accepter la connexion TCP, aller au bout de l’échange de clés et de l’authentification… puis ne jamais répondre à l’ouverture du canal. Rien, dans ce scénario, ne ressemble à une panne : le keepalive ServerAliveInterval ne couvre que la session intérieure, une fois établie.

Chaque étage est donc monté sous une échéance de 60 secondes — handshake, confiance d’hôte, authentification et ouverture du canal tout compris. L’échéance écoulée est un échec de la commande, jamais un repli. La valeur est délibérément large (OpenSSH accorde 120 s à l’authentification entière côté serveur) : elle n’existe pas pour rendre la commande vive, elle existe pour qu’un pair hostile ne puisse pas la faire pendre.

Ce que le rebond coûte

  • Un handshake et une authentification par saut. Une chaîne de deux sauts, c’est trois connexions SSH complètes avant le premier octet utile.
  • Le débit est celui du maillon le plus lent, et le bastion relaie tout.
  • Le bastion doit autoriser la redirection de port (AllowTcpForwarding, PermitOpen côté serveur ; option no-port-forwarding de la clé côté authorized_keys). Un refus se voit comme un échec du rebond, pas comme un problème de la cible.

8. Trois refus argumentés

ADR-118 §4 nomme quatre options client. Une est implémentée ci-dessus ; voici pourquoi les trois autres ne le sont pas — ou n’ont rien à implémenter.

8.1 IdentitiesOnlydéjà vrai par construction

L’ADR relève qu’IdentitiesOnly « est le défaut de fait : sans lui, le client épuise le plafond de 6 essais (ADR-094) avant d’atteindre la bonne clé (constaté en test) ». Ce constat porte sur le client OpenSSH : dans nos suites d’interop, c’est au vrai ssh que l’on passe -o IdentitiesOnly=yes.

air-ssh n’a jamais eu de liste d’identités à essayer. Sa résolution d’identité rend exactement une source (clé locale, agent d’Air, ou agent externe), et devant un agent porteur de plusieurs clés sans sélecteur, elle refuse pour ambiguïté plutôt que d’en essayer plusieurs. C’est plus strict qu’IdentitiesOnly : là où OpenSSH restreint la liste des identités essayées, air-ssh n’en essaie jamais qu’une.

Ajouter un bouton IdentitiesOnly ne changerait donc rien — et un bouton qui ne change rien ment sur ce que fait le programme. L’invariant est verrouillé par un test qui le nomme (air_ssh_n_offre_jamais_plus_d_une_identite) : face à un agent réel détenant deux clés, sans empreinte, on obtient un refus, et aucun signataire n’est construit — donc aucune tentative d’authentification, a fortiori pas six.

Le sélecteur est ce qui débloque la situation : --agent-key <SHA256:…> (agent d’Air) ou --ssh-agent-key <base64> (agent externe).

8.2 CanonicalizeHostnamehors périmètre

Canonicaliser un nom d’hôte via DNS avant de chercher le bloc correspondant mettrait une résolution réseau sur le chemin d’une décision de configuration — précisément ce que le Principe 11 (entrée stricte) borne. Le gain serait par ailleurs nul : la correspondance de motif est aujourd’hui exacte (les jokers sont différés), donc il n’y a rien qu’une forme canonique permettrait de faire correspondre et qu’un nom exact ne permet pas déjà.

Si les jokers arrivent un jour, la question se reposera — et se posera comme elle doit : sur les mérites d’une résolution réseau en amont d’un choix de politique, pas comme un sous-produit.

8.3 Blocs Match clienthors périmètre

Le client a déjà son modèle conditionnel : le bloc par-hôte (motif → surcharges). Ce que le Match client d’OpenSSH ajoute par-dessus, ce sont des critères — originalhost, exec… — dont exec est une porte d’exécution pilotée par un fichier de configuration : un fichier de config qui lance des processus pour décider de la config.

C’est exactement ce que refusent l’amendement D6 d’ADR-046 (« plus d’exécution non confinée ») et ADR-150. Le modèle conditionnel client existe, il s’appelle bloc par-hôte, et on ne lui ajoute pas de critère exécutant.

9. Pièges

  • Ce n’est pas parce que le client demande que le serveur accorde. Le double opt-in d’ADR-116 est intact : un ForwardAgent yes peut très bien ne produire aucun SSH_AUTH_SOCK distant si la politique du démon ou la permission de la clé le refuse. Le diagnostic est côté serveur, pas dans ce fichier.
  • config get distingue - de no. Un tiret n’est pas un refus : c’est un bloc qui ne se prononce pas. À l’export JSON, la même règle — le champ forward_agent n’est émis que si le bloc se prononce, et un forward_agent qui n’est pas un booléen fait refuser l’import.
  • Un artefact écrit avant cet incrément ne relaie rien. Il ne porte pas le champ, donc aucun de ses hôtes ne se prononce, donc le défaut fermé s’applique partout — c’est prouvé par un test, pas supposé.
  • Le motif d’hôte est comparé en octets, pas en texte normalisé. Prod et prod sont deux motifs différents.
  • -a ne se combine pas avec -A : ce n’est pas « le dernier gagne », c’est une erreur qui refuse la connexion.
  • Une config mal protégée fait échouer la connexion, elle n’est pas ignorée. On ne dégrade jamais silencieusement une politique en « pas de politique » : ce fichier désigne aussi vos magasins de révocation d’hôte. Le message dit le chmod à taper.
  • server-alive-interval 0 n’est pas unset. Sur un bloc, 0 éteint le keepalive pour cet hôte quoi que dise le poste ; unset retire la surcharge et rend au bloc son silence. config get les distingue : 0 contre -. Sans cette distinction, un défaut de poste ne pourrait jamais être éteint pour un seul hôte.
  • Le zéro de server-alive-count-max n’est pas son défaut — il est refusé. 0 voudrait dire « aucune sonde sans réponse tolérée », c’est-à-dire couper à la première échéance sans jamais laisser au serveur le temps de répondre. Dans le schéma, le champ est une union dont absent est le premier membre (donc le zéro) et vaut 3 : un artefact écrit avant cet incrément relit 3, jamais 0. Un 0 écrit fait refuser la configuration entière, à n’importe laquelle des deux portées. Le refus n’est plus affaire de vigilance des lecteurs : il est porté par le type de la politique, qui ne se construit pas sur un couple interdit — que la demande vienne de la configuration, du JSON, de la ligne de commande ou d’un appel à la bibliothèque.
  • Le compteur affiché « 3 (défaut) » n’est pas la même chose qu’un « 3 » posé. Les deux se comportent à l’identique ; seul l’export JSON les distingue — il n’émet que ce qui a été écrit, pour qu’un aller-retour export/import ne fige pas un défaut que personne n’a choisi.
  • Contre un air-sshd monolithique (sans --privsep), n’activez pas ServerAliveInterval : il ne répond pas aux sondes une fois la session lancée, et votre client coupera une connexion saine (§6).
  • Un keepalive serré des deux côtés ne prouve rien. Deux pairs qui se sondent mutuellement se maintiennent en vie sans avoir à se répondre : les sondes de l’un sont du trafic reçu par l’autre, ce qui remet son compteur à zéro. Si vous diagnostiquez une déconnexion, éteignez un des deux sens avant de conclure.
  • Le délai de détection est interval × (count_max + 1), pas interval × count_max : la dernière sonde a droit, elle aussi, à son intervalle complet.
  • -J n’est pas -L. -J fait passer votre session par un bastion ; -L publie un port distant sur votre poste. Ils se combinent (-J + -N -L … fonctionne), mais ils ne répondent pas à la même question.
  • Un saut n’hérite pas du compte de la cible. air-ssh connect root@prod -J bastion demande root sur prod et $USER (ou le user du bloc bastion) sur le bastion. Un « Permission denied » sur le premier étage vient de là neuf fois sur dix.
  • proxy-jump none retire, il ne désactive pas. Sur un bloc, il efface la chaîne ; en ligne de commande (-J none), il annule pour cette invocation seulement.
  • La borne de 8 sauts porte sur la chaîne DÉVELOPPÉE, pas sur ce qui est écrit dans un bloc. Huit blocs qui se relaient l’un l’autre n’ont rien d’anormal à la lecture de chacun, et forment pourtant une chaîne qui dépasse la borne.
  • Un rebond ne dispense pas des magasins de confiance. trusted-host-ca et revoked-host-keys s’appliquent à chaque saut. Un bastion dont le certificat a expiré fait échouer la connexion à la cible, et c’est voulu.
  • Le chmod porte sur le fichier, pas sur son répertoire. Un ~/.config/air inscriptible par le groupe reste un problème — quelqu’un peut y remplacer l’artefact par le sien, qui sera alors en 0600 et lui appartiendra… sauf qu’il ne vous appartiendra plus, et la garde le refusera. Contrôler le répertoire reste préférable.