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 :
| Constat | Correctif annoncé par le message |
|---|---|
le fichier appartient à quelqu’un d’autre — y compris root | chown <vous> <chemin> && chmod 600 <chemin> |
il est inscriptible par le groupe ou par tous (mode & 0022) | chmod 600 <chemin> |
| c’est un lien symbolique | en 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_NOFOLLOWne porte que sur le dernier composant du chemin : un lien symbolique posé sur~/.configou~/.config/airn’est pas vu. Le correctif exact seraitRESOLVE_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/setlisent encore l’artefact sans cette garde :config getpeut donc afficher une configuration queconnectrefuse. 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
| Verbe | Ce que dit le bloc | Affiché par config get |
|---|---|---|
yes | demande le relais pour cet hôte | forward-agent=yes |
no | refuse le relais pour cet hôte | forward-agent=no |
unset | ne 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
-Aet-aensemble ⇒ 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-agentet--ssh-agent.-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 ».-A⇒ relais demandé. La ligne de commande prime sur la config, dans ce sens comme dans l’autre.- Sinon ⇒ ce que dit le bloc hôte, et
falses’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) :
-Nn’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_SOCKabsente, 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-agentn’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-agentdans$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 :
- fait un échange de clés complet et vérifie la signature d’hôte ;
- applique la confiance d’hôte (ADR-109, ADR-137) avec les magasins de votre
configuration :
trusted-host-caetrevoked-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 ; - 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églage | Ordre de décision, pour un saut |
|---|---|
| compte | le user@ du saut > le user du bloc du saut > $USER |
| port | le :port du saut > le port du bloc du saut > 22 |
| adresse | le 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,PermitOpencôté serveur ; optionno-port-forwardingde 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 IdentitiesOnly — dé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 CanonicalizeHostname — hors 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 client — hors 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 yespeut très bien ne produire aucunSSH_AUTH_SOCKdistant 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 getdistingue-deno. 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 champforward_agentn’est émis que si le bloc se prononce, et unforward_agentqui 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é.
Prodetprodsont deux motifs différents. -ane 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 0n’est pasunset. Sur un bloc,0éteint le keepalive pour cet hôte quoi que dise le poste ;unsetretire la surcharge et rend au bloc son silence.config getles distingue :0contre-. 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-maxn’est pas son défaut — il est refusé.0voudrait 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 dontabsentest le premier membre (donc le zéro) et vaut 3 : un artefact écrit avant cet incrément relit3, jamais0. Un0é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/importne fige pas un défaut que personne n’a choisi. - Contre un
air-sshdmonolithique (sans--privsep), n’activez pasServerAliveInterval: 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), pasinterval × count_max: la dernière sonde a droit, elle aussi, à son intervalle complet. -Jn’est pas-L.-Jfait passer votre session par un bastion ;-Lpublie 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 bastiondemanderootsurprodet$USER(ou leuserdu blocbastion) sur le bastion. Un « Permission denied » sur le premier étage vient de là neuf fois sur dix. proxy-jump noneretire, 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-caetrevoked-host-keyss’appliquent à chaque saut. Un bastion dont le certificat a expiré fait échouer la connexion à la cible, et c’est voulu. - Le
chmodporte sur le fichier, pas sur son répertoire. Un~/.config/airinscriptible par le groupe reste un problème — quelqu’un peut y remplacer l’artefact par le sien, qui sera alors en0600et lui appartiendra… sauf qu’il ne vous appartiendra plus, et la garde le refusera. Contrôler le répertoire reste préférable.