Blocs Match d’air-sshd — politique conditionnelle, et le piège du replace-vide
Ce guide s’adresse à qui administre un serveur air-sshd : comment écrire des blocs
Match en ligne de commande, et — surtout — pourquoi l’un des gestes possibles fait le
contraire de ce qu’il a l’air de faire, et comment Air s’y prend pour qu’on ne le pose
pas par accident.
Décision de référence : ADR-118
§2, §3 et §5 (incréments 3 à 8). Rappel de contexte : la configuration d’air-sshd est un
artefact binaire (/etc/air/sshd.aircfg), jamais un fichier texte relu au démarrage —
il n’y a donc pas de sshd_config à éditer, mais des verbes qui écrivent l’artefact.
1. Ce qu’est un bloc Match
Un bloc Match est une surcharge conditionnelle de la politique de base : « pour les
connexions qui satisfont ces conditions, remplace ces champs-là ».
- Conditions (jusqu’à trois, conjonctives) :
users,groups,source(CIDR). Une condition absente n’est pas une condition — le bloc ne regarde pas ce critère. Un bloc sans aucune condition matche donc toute connexion (c’est leMatch alld’OpenSSH). - Surcharges (sept) :
allow-tcp-forwarding,permit-open,permit-listen,permit-root-login,allow-users,allow-groups,force-command(§4).
Les blocs sont ordonnés, et l’ordre est la politique : la dernière correspondance qui fixe un champ gagne. C’est pourquoi la CLI affiche systématiquement l’index de chaque bloc — l’index est sa priorité, pas un numéro de ligne.
Les blocs Match ne s’appliquent que sous séparation de privilèges
(privilege-separation on) : c’est le monitor qui les évalue, avec le compte, les groupes
et l’adresse du pair établis de source fiable. Une configuration qui porte des blocs
Match avec privilege-separation off fait échouer le démarrage du démon, plutôt que
d’appliquer une moitié de la politique.
2. Les verbes
air-sshd config match list <path>
air-sshd config match add <path> [--users a,b,…] [--groups g,…] [--source CIDR,…]
air-sshd config match set <path> <index> <champ> <valeur>
air-sshd config match unset <path> <index> <champ>
air-sshd config match open <path> <index> allow-users|allow-groups|force-command
air-sshd config match remove <path> <index>
listaffiche les blocs avec leur index, leurs conditions et leurs surcharges.addajoute un bloc en fin de liste — donc en position la plus forte — avec les conditions données et aucune surcharge, et affiche l’index attribué. Ajouter en fin et non « à sa place » est délibéré : une insertion par heuristique de spécificité déciderait à la place de l’administrateur ce qui l’emporte. Un bloc sans condition ni surcharge est refusé (il ne ferait rien).setpose une surcharge. Valeurs :all|no|local-only|remote-onlypourallow-tcp-forwarding;any(aucune borne),none(rien n’est permis) ou une listehost:port,host:portpourpermit-open/permit-listen;no|yes|forced-commands-onlypourpermit-root-login(prohibit-passwordest refusé — Air est publickey-only) ; une liste de noms non vide pourallow-users/allow-groups; la commande, en un seul argument, pourforce-command(§4) ;0ou1pourmax-sessions(§6).unsetremet la surcharge à « rien » : le bloc hérite de nouveau de la politique de base pour ce champ. C’est la façon normale d’annuler une surcharge.removesupprime un bloc — et les suivants se réindexent, ce que le message dit explicitement, puisque leurs priorités relatives se conservent mais que leurs index dans les commandes suivantes changent.open: voir §3. C’est le verbe qui n’existe que pour un seul geste, et qui le nomme.
Après chaque mutation, l’artefact est réencodé, relu et comparé à la configuration demandée avant d’être écrit sur le disque : le fichier n’est jamais remplacé par quelque chose qu’on ne saurait pas relire.
Exemple — restreindre le forwarding pour les connexions venues du VPN, et n’y autoriser que deux comptes :
air-sshd config match add /etc/air/sshd.aircfg --source 10.8.0.0/24
air-sshd config match set /etc/air/sshd.aircfg 0 allow-tcp-forwarding local-only
air-sshd config match set /etc/air/sshd.aircfg 0 permit-open 127.0.0.1:5432
air-sshd config match set /etc/air/sshd.aircfg 0 allow-users alice,bob
air-sshd config match list /etc/air/sshd.aircfg
3. Le piège : une liste d’autorisation vide ouvre, elle ne ferme pas
C’est le point important de ce guide, et la raison d’être du verbe open.
Une surcharge remplace la valeur de la base ; elle ne s’y ajoute pas. Or, par parité
avec OpenSSH, une liste d’autorisation vide ne filtre personne : AllowUsers vide
veut dire « aucune restriction de compte », pas « aucun compte autorisé ».
Mettez les deux règles bout à bout :
Match user=alice+allow-users = []⇒ pour les connexions que ce bloc matche, la restriction de compte de la base est retirée.
L’opérateur qui tape ça croit resserrer sur alice. Il desserre. Et le geste a
exactement la forme d’un durcissement — c’est ce qui en fait un piège plutôt qu’une simple
erreur.
Air ferme ce piège en trois temps :
setrefuse la liste vide. Une variable de shell non substituée, un argument oublié, un""recopié : rien de tout cela ne peut produire ce geste. Le refus oriente vers les deux suites légitimes plutôt que de se contenter de dire non.openle pose explicitement, et c’est le seul chemin CLI qui y mène. Le verbe est nommé pour ce qu’il fait — ouvrir. Il faut l’avoir tapé.- Toute lecture l’affiche.
config get,config match listet le chargement de la configuration par le démon émettent un avertissement surstderrpour chaque bloc concerné. L’affichage lui-même distingue les deux états :(LISTE VIDE — remplace la base : ce bloc RETIRE la restriction)du côté surcharge,(aucune restriction — tous les comptes)du côté base.
Donc, selon ce que vous vouliez :
| Intention | Geste |
|---|---|
| Restreindre à certains comptes | config match set <path> <i> allow-users alice,bob |
| Que ce bloc ne dise rien de ce champ (hériter de la base) | config match unset <path> <i> allow-users |
| Retirer délibérément la restriction pour ce bloc | config match open <path> <i> allow-users |
Le démon démarre malgré l’avertissement, et c’est voulu : le replace-vide est une
politique valide — parfois exactement ce qu’on veut (ouvrir un segment à tous les
comptes de la machine) —, seulement piégeuse. On ne l’interdit pas ; on la rend visible.
Notez qu’elle ne concerne que la restriction de compte : elle n’ouvre pas root, que
permit-root-login gouverne seul.
L’asymétrie mérite d’être retenue : pour les listes de bornage du forwarding, la liste
vide est du côté fermé (permit-open none ne permet rien). Il n’y a donc pas de piège
de ce côté-là, et pas de garde-fou non plus — le geste va vers le restrictif.
4. force-command — la commande forcée du serveur
ForceCommand impose ce que la session exécutera, quoi que le client demande. C’est le
pendant, côté serveur, de la commande forcée portée par une clé (command="…" dans
authorized_keys, option critique force-command d’un certificat —
ADR-128 §2).
air-sshd config set <path> force-command '/usr/bin/rrsync -ro /srv' # base : pose
air-sshd config set <path> force-command # base : retire
air-sshd config match set <path> <i> force-command '/usr/bin/backup --nightly'
air-sshd config match unset <path> <i> force-command # bloc : hérite
air-sshd config match open <path> <i> force-command # bloc : EFFACE
La précédence : le serveur prime sur la clé
Quand la clé impose X et que la configuration effective impose Y, c’est Y qui
s’exécute. C’est la règle d’OpenSSH, et c’est la seule qui se défende : l’administrateur de
la machine décide de ce qui tourne dessus, et une clé — que n’importe quel utilisateur peut
ajouter à son magasin — ne lui échappe pas en portant sa propre commande.
Ce que la précédence ne fait jamais, c’est rendre un shell libre : les deux valeurs en concurrence sont deux commandes forcées, et celle qui gagne en est une. Une clé restreinte reste restreinte — à autre chose, décidé par le serveur.
Ce que le client avait demandé : SSH_ORIGINAL_COMMAND
La commande du client est exposée au processus lancé dans SSH_ORIGINAL_COMMAND, sans
avoir été exécutée — c’est ce qui permet à un wrapper (rrsync, un dispatcher de sauvegarde)
de décider quoi faire de la demande. C’est bien la demande du client qui y voyage, jamais
la commande de la clé que le serveur a supplantée.
Une commande forcée tourne sans PTY
Comme toute commande forcée dans Air, elle s’exécute sur un socketpair, même si le client
a demandé un terminal (ssh -t). Un pty-req n’y change rien : ce qui décide de la forme
de l’I/O, c’est ce qui est exécuté, pas ce qui est réclamé. Une commande qui a besoin d’un
terminal doit l’obtenir elle-même.
La commande est une suite d’octets, passée telle quelle
Elle voyage en un seul argument, sans redécoupage ni requotage : c’est le shell de login
du compte qui l’interprète (shell -c <commande>), exactement comme le fait OpenSSH. Quotez-la
si elle contient des espaces. La commande vide est refusée partout — elle n’exécuterait
rien, et une configuration qui a l’air d’imposer une commande sans rien imposer est le genre
d’écart qui ne se voit qu’en production.
Le troisième état, et pourquoi il existe
Sur un bloc Match, force-command a trois états, là où les six autres surcharges n’en
ont que deux — parce que le champ de base est lui-même optionnel :
| Intention | Geste | JSON |
|---|---|---|
| Imposer une commande à ce bloc | config match set <path> <i> force-command '<cmd>' | "force_command": "<cmd>" |
| Que ce bloc ne dise rien (hériter de la base) | config match unset <path> <i> force-command | "force_command": null |
| Retirer la commande forcée pour ce bloc | config match open <path> <i> force-command | "force_command": {"clear": true} |
Le troisième est un relâchement : les connexions que ce bloc matche retrouvent le service
qu’elles demandent, shell interactif compris. Il relève donc du même traitement que le
replace-vide du §3 — set ne peut pas le produire, open le nomme, et toute lecture
(config get, config match list, chargement par le démon) l’affiche sur stderr dès lors
que la base impose effectivement une commande. Il ne lève que la commande du serveur :
celle de la clé, si la clé en porte une, s’applique de nouveau.
privilege-separation on est obligatoire
Comme les blocs Match, une force-command — de base ou de bloc — exige la séparation
de privilèges : c’est le monitor qui lance la session et substitue la commande
(ADR-147 inc.1). Le chemin
monolithique, lui, n’applique aucune commande forcée — pas même le command="…" d’une
clé. Une configuration qui porte une force-command avec privilege-separation off fait
donc échouer le démarrage du démon : la démarrer en l’ignorant servirait des shells
libres là où l’administrateur a écrit une commande.
5. banner — la bannière pré-authentification, et pourquoi elle n’a pas de Match
banner est le texte que le serveur envoie au client avant toute authentification —
l’avertissement légal d’usage, la mention du service. C’est un
SSH_MSG_USERAUTH_BANNER (RFC 4252 §5.4), que
ssh(1) affiche sur sa sortie d’erreur.
# Poser (un SEUL argument : une bannière est multi-lignes et pleine d'espaces)
$ air-sshd config set /etc/air/sshd.aircfg banner $'Accès réservé.\nToute connexion est journalisée.'
# Retirer (le verbe sans valeur)
$ air-sshd config set /etc/air/sshd.aircfg banner
# Relire — l'affichage est échappé sur une ligne (cf. plus bas)
$ air-sshd config get /etc/air/sshd.aircfg
banner: "Accès réservé.\nToute connexion est journalisée." (52 octet(s), émis à TOUTE connexion avant authentification)
Trois traits la distinguent de tout ce qui précède, et chacun est une décision.
Le texte est dans l’artefact, pas un chemin de fichier
OpenSSH nomme ici un fichier (Banner /etc/issue) que le démon relit à chaque connexion.
Air y met le contenu, dans l’artefact binaire scellé
(ADR-073 /
ADR-040). Deux raisons, et la seconde est
la plus forte :
- le texte devient une donnée de la configuration — versionnée avec elle,
checksummée avec elle, revue avec elle. Un
/etc/issueréécrit par un tiers ne change plus ce que le serveur annonce ; - l’étage qui parle au réseau avant authentification — l’enfant pré-auth — est sous
cage Landlock deny-all
(ADR-124 §D7). Il ne peut
ouvrir aucun fichier. Un chemin n’aurait donc rien donné : la bannière lui arrive
par héritage mémoire au
fork, comme la clé d’hôte publique.
Elle est globale — il n’y a pas de surcharge Match, et c’en est une décision de sécurité
C’est la seule option de cette campagne qu’un bloc Match ne peut pas toucher, et le
schéma le porte : le champ vit à côté des blocs, pas dans la politique qu’ils surchargent.
La raison est un oracle d’énumération de comptes
(ADR-105, interdit). Une bannière
conditionnée à users ou groups obligerait le serveur à résoudre un compte avant de
l’avoir authentifié — et la présence, l’absence ou le contenu de la bannière
renseigneraient alors un attaquant sur l’existence de ce compte, avant qu’il n’ait présenté
la moindre clé. C’est exactement la fuite que le service ssh-userauth d’Air ferme
partout ailleurs (toute tentative non satisfaite reçoit le même USERAUTH_FAILURE,
quel que soit le compte). L’ouvrir pour un texte d’accueil serait la payer très cher.
La bannière est donc émise une fois par connexion, indépendamment de l’identité
annoncée : juste après le SERVICE_ACCEPT du service ssh-userauth, donc avant que le
client n’ait nommé le moindre compte. Rien dans cette décision ne dépend du client.
(Une bannière conditionnée à la seule source — un CIDR, information que le listener observe sans rien résoudre — serait envisageable sans cet écueil. Elle demanderait un RFC (ADR-015) et n’est pas dans cet incrément.)
Elle vaut pour les deux modes de service
Contrairement aux blocs Match et à force-command, la bannière ne réclame pas
privilege-separation on. Elle n’a besoin d’aucun monitor : elle est émise par le service
ssh-userauth, que les deux modes partagent. Un démon monolithique et un démon privsep
disent donc la même chose de la même configuration.
Elle est aussi indifférente à ce que la connexion deviendra : une session interactive, un
ssh -N, un -L, un -R la reçoivent tous — elle précède l’ouverture de tout canal.
Les points de détail qui se remarquent en production
- Des octets, pas du texte. Une bannière peut être multi-lignes et rédigée dans l’encodage local du site ; elle part telle quelle, octet pour octet.
- L’étiquette de langue du message est vide, comme chez OpenSSH. Le serveur ne connaît ni la locale du client ni celle de la bannière — en inventer une ferait mentir le message.
- Absente ⇒ aucun message n’est émis. Ce n’est pas une bannière vide : rien ne part. Un artefact écrit avant cet incrément se relit donc sans changement de comportement.
- La bannière vide est refusée à l’écriture (CLI et JSON) : l’artefact binaire ne saurait pas la distinguer de l’absence, et l’accepter écrirait une configuration qui se relit autrement qu’elle ne s’est écrite. Pour n’en émettre aucune : le verbe sans valeur.
config getl’échappe. Une bannière est la seule donnée de cette configuration écrite pour être affichée, et un terminal interprète ce qu’on lui envoie : une bannière porteuse de séquences ANSI pourrait réécrire les lignes queconfig getvient d’imprimer — donc mentir à l’administrateur sur sa propre configuration, dans la commande même qui sert à la vérifier. Les caractères de contrôle sont donc neutralisés à l’affichage ; les accents, eux, restent lisibles.- Retirer une bannière est un geste muet. Contrairement à l’effacement d’une
force-command(§4), il ne relâche aucun contrôle : un texte n’en exerce aucun.
6. max-sessions — le compte tunnel-only
MaxSessions dit combien de canaux de session (shell, exec, subsystem) une
connexion peut ouvrir. Dans Air, il vaut 0 ou 1, et rien d’autre :
| Valeur | Ce que ça fait |
|---|---|
1 | Une session par connexion. Défaut, et le comportement du serveur depuis toujours. |
0 | Aucune session : ni shell, ni exec, ni subsystem. Le forwarding (-L, -R, -N) reste entier. |
> 1 | Refusé — à l’écriture comme au chargement du démon. |
air-sshd config set <path> max-sessions 0 # base : tout le serveur en tunnel-only
air-sshd config set <path> max-sessions 1 # base : retour au défaut
air-sshd config match set <path> <i> max-sessions 0 # bloc : ce compte-là seulement
air-sshd config match unset <path> <i> max-sessions # bloc : hérite de la base
Le périmètre, dit franchement : > 1 est refusé
air-sshd ne sert qu’un canal de session par connexion. Ce n’est pas un réglage
qu’on aurait oublié de câbler : c’est codé en dur en couche 2, à deux endroits — la
couche connexion (connection/mod.rs) refuse tout canal de session au-delà du premier,
et le démultiplexeur du worker (forward_demux_blocking.rs) oppose le même refus à une
session demandée après qu’une redirection a pris le transport. Le multiplexage
multi-sessions est un chantier architectural à venir, pas une valeur à écrire.
D’où le refus de > 1, plutôt que l’acceptation silencieuse. Un MaxSessions 10 recopié
d’un sshd_config OpenSSH et servi comme 1 serait le pire des deux mondes : l’exploitant
croirait avoir ouvert dix canaux, et rien dans son serveur ne le détromperait. Air
n’importe aucune configuration texte d’OpenSSH — il n’a donc aucune raison de tolérer par
compatibilité une valeur qu’il n’honore pas. Le démon refuse de démarrer si l’artefact
en porte une, de base ou dans un bloc.
Ce que 0 fait vraiment : un compte de rebond
max-sessions 0 ne ferme pas le compte — il lui retire le shell. Le compte
s’authentifie normalement, et tout son forwarding continue de fonctionner :
air-sshd config match add <path> --users backup
air-sshd config match set <path> 0 max-sessions 0
backup peut désormais faire ssh -N -L 5432:db.interne:5432 serveur ; il ne peut plus
faire ssh serveur ni ssh serveur 'commande'. C’est ce qu’on veut d’un compte de rebond,
et c’est ce qu’on n’obtient pas en retirant sa clé (il ne ferait plus rien) ni par
force-command (il exécuterait quelque chose, donc consommerait un processus par
connexion).
Le refus que voit le client : le même que « une seule session »
Un compte max-sessions 0 reçoit, mot pour mot, le refus qu’aurait reçu une session
surnuméraire : only one session channel. C’est délibéré — le client ne doit pas pouvoir
distinguer « zéro » de « une seule », sans quoi la simple ouverture d’un canal
renseignerait sur la politique du serveur avant toute tentative
(ADR-105).
Rétro-compatibilité : un artefact d’avant sert une session, jamais zéro
C’est le point délicat de cette option, et il vaut d’être connu. Tous les autres champs
additifs de la configuration binaire s’appuient sur la même propriété : le zéro du type
est le comportement d’avant. Ici, c’est faux — 0 veut dire « aucune session ». Un champ
UInt32 nu aurait donc fait relire 0 à tout artefact écrit avant cette option,
c’est-à-dire couper le shell de tous les comptes à la mise à jour, sans qu’aucune
configuration ait changé.
Le schéma binaire porte donc une union (absent / limit) plutôt qu’un entier : la
branche absent est le zéro du discriminant, donc ce que rend un artefact muet, et elle
vaut 1. Le 0 ne s’obtient que par une valeur écrite. Même précaution à l’import
JSON : une clé max_sessions absente — ou à null — vaut 1, jamais 0.
Les deux modes de service l’appliquent
Contrairement à force-command (§4) et aux blocs Match (§1), max-sessions ne
requiert pas privilege-separation on pour la valeur de base : refuser un canal de
session ne demande aucun privilège et n’a besoin d’aucune médiation — c’est la couche
connexion, que les deux modes partagent, qui l’oppose. Un serveur monolithique avec
max-sessions 0 est donc bien tunnel-only.
La surcharge par bloc Match, elle, hérite de la contrainte du §1 : les blocs Match
exigent la séparation de privilèges, faute de monitor pour les évaluer. Un
max-sessions conditionnel implique donc privilege-separation on, comme toute
conditionnalité.
7. client-alive — détecter un pair mort, et rendre ses ressources
Un client dont la machine s’éteint, dont le portable ferme son capot ou dont le routeur
NAT oublie la traduction ne ferme pas sa connexion : il se tait. Rien ne remonte au
serveur — pas de FIN, pas de RST —, et le worker qui le servait reste bloqué
jusqu’au prochain redémarrage, avec son descripteur, sa mémoire et, s’il en avait,
ses écoutes -R.
Le keepalive serveur est la parade, et c’est celle d’OpenSSH : on sonde périodiquement, on compte les sondes restées sans réponse, on ferme au-delà d’un seuil.
| Réglage | Ce qu’il dit |
|---|---|
client-alive-interval <s> | Toutes les <s> secondes sans avoir reçu un octet du client, émettre une sonde. 0 = désactivé, et c’est le défaut. |
client-alive-count-max <n> | Combien de sondes consécutives sans réponse sont tolérées avant de fermer. Défaut 3, comme OpenSSH. |
air-sshd config set <path> client-alive-interval 30 # sonder toutes les 30 s
air-sshd config set <path> client-alive-count-max 3 # fermer après 3 sondes muettes
air-sshd config set <path> client-alive-interval 0 # désactiver (le défaut)
air-sshd config get <path> # « client-alive: 30 s, 3 sonde(s)… »
Le mécanisme, exactement
La sonde est une requête globale keepalive@openssh.com avec want_reply — la même que
celle d’OpenSSH. Le client n’a pas à connaître ce nom : RFC 4254 §4 veut qu’un pair
réponde REQUEST_FAILURE à une requête globale qu’il ne reconnaît pas, et c’est
exactement ce qu’on attend de lui. Le serveur ne lit pas le contenu de la réponse — il
constate qu’il en est arrivé une.
Tout trafic du client vaut preuve de vie et remet le compteur à zéro : la réponse à la sonde, une frappe au clavier, un ajustement de fenêtre, une ouverture de canal. Une sonde ne part donc jamais tant que quelque chose circule, et il n’en part jamais deux dans le même intervalle.
Chaque sonde émise dispose d’un intervalle complet pour être honorée ; la fermeture
intervient à l’échéance qui suit la count_max-ième sonde restée muette. Le délai maximal
de détection est donc interval × (count_max + 1) — avec 30/3, deux minutes. C’est ce
que config get affiche, plutôt que de laisser le calcul à l’exploitant.
C’est post-auth, et seulement post-auth
Le keepalive s’applique à une connexion authentifiée. La fenêtre pré-authentification a sa propre discipline — throttle d’admission, cage de l’enfant pré-auth (ADR-105, ADR-124) — et n’a rien à faire de sondes : personne ne s’y attarde.
Pourquoi un ppoll, et pas un timer air-async
C’est le point d’ingénierie de cette option, et il mérite d’être écrit noir sur blanc.
Le keepalive s’applique là où la connexion attend, c’est-à-dire dans le worker
confiné — l’étage qui sert la session et les redirections après authentification. Ce
processus n’a aucun réacteur : son profil seccomp ne contient pas un seul
io_uring_*, et son exécuteur poll chaque future une fois, avec un Waker inerte
(ADR-129 §D1). Un
Runtime::timeout n’y serait jamais réveillé. La seule horloge que cet étage possède est
le délai du ppoll qu’il fait déjà tourner pour surveiller ses descripteurs.
Le keepalive y est donc une échéance de plus dans ce ppoll, à côté de celles des
ouvertures de canal en attente — jamais une reprise de contrôle, jamais un tour de boucle
supplémentaire. Quand rien n’est configuré (interval 0), aucune échéance n’est injectée
et la boucle garde exactement le pas qu’elle avait.
Conséquence directe : le keepalive exige privilege-separation on. Le démon
monolithique lit son flux par io_uring, dont le réacteur n’offre aucune attente
bornée : ses sondes ne partiraient jamais. Plutôt que de servir un réglage qu’il
ignorerait en silence, ce démon refuse de démarrer avec un client-alive-interval
non nul — la même discipline que pour les blocs Match (§1) et la force-command (§4).
Pas de surcharge Match, et c’en est une décision
Le keepalive est global au démon. Il ne décide d’aucun accès : il dit au bout de
combien de temps un pair qui ne répond plus cesse d’occuper un worker. Le conditionner par
compte n’ouvrirait ni ne fermerait rien à personne, et ferait voyager une échéance de plus
jusque dans la boucle du worker — là où chaque échéance supplémentaire est une occasion de
plus de famine. Le refus est porté par le type : les deux réglages vivent hors de la
politique d’accès, seule structure que les blocs Match surchargent.
Rétro-compatibilité : le second réglage porte le même piège que max-sessions
L’intervalle n’a aucun piège : son zéro veut dire « désactivé », qui est exactement le comportement d’avant cette option. Un artefact plus ancien se relit sans le moindre changement de sens.
Le compteur, lui, en a un : son zéro dirait « aucune sonde sans réponse tolérée »,
c’est-à-dire fermer à la première échéance sans jamais laisser au pair le temps de
répondre. Le schéma binaire porte donc une union (absent / count), comme pour
max-sessions (§6) et pour la même raison : la branche absent est le zéro du
discriminant, donc ce que rend un artefact muet, et elle vaut 3. Même précaution à
l’import JSON : une clé absente — ou à null — vaut 3, jamais 0.
Et la combinaison incohérente est refusée partout où elle peut s’écrire (CLI, import
JSON, chargement du démon) : un client-alive-count-max 0 avec un intervalle actif ferait
tomber les connexions à la première échéance. Une politique que personne n’écrit
volontairement ne se corrige pas en silence — elle se refuse, avec un message qui dit quoi
écrire.
8. Le JSON reste la voie brute
config export / config import restent le format d’interchange fidèle
(ADR-032, zéro discard silencieux) : le
JSON distingue null (le bloc ne dit rien de ce champ — hérite) de [] (remplace par une
liste vide), et l’import peut produire les deux. De même pour force-command, dont les trois
états ont trois formes JSON distinctes (§4) — l’effacement s’y écrit {"clear": true}, une
forme qu’on ne produit pas par accident, là où un "" ou un false pourraient tomber d’un
gabarit. Pour max_sessions (§6) et client_alive_count_max (§7), la même distinction se
lit à l’envers : null ou clé absente valent le défaut (1 et 3), et non 0 — un
document muet ne coupe le shell de personne, et ne ferme aucune connexion à la première
échéance. Il n’y a pas de garde-fou à l’import :
un format qui refuserait de relire ce qu’il vient d’exporter ne serait plus un format
d’interchange. C’est l’avertissement de lecture (§3.3) qui rattrape le cas — un []
importé en JSON reste visible à chaque config get et au démarrage du démon.
9. Ce que les blocs Match d’Air n’ont pas
- Aucune condition d’hôte / reverse-DNS. La résoudre demanderait un PTR, que l’attaquant contrôle très souvent sur sa propre plage (ADR-129 §D4 le refuse pour toute décision de sécurité). Seule l’adresse observée par le listener entre dans la décision, sous forme de CIDR.
- Aucun motif glob sur les noms : la comparaison est exacte, en octets.
admin*est refusé à l’écriture — l’accepter en silence le ferait comparer littéralement, et n’ouvrirait donc personne tout en donnant à lire une famille de comptes. - Aucun CIDR nié (
!10.0.0.0/8) dans une condition : ce qu’on veut exclure s’écrit en resserrant le bloc, ou en le faisant suivre d’un bloc qui le corrige.