From 7c860ff047e16f5b4dc6808bfbe9bdc29f0eb2bf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 21:33:10 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20cache=20qemu=20:=20NixOS=20apprend=20l'?= =?UTF-8?q?autorit=C3=A9,=20et=20le=20hors=20ligne=20s'ouvre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NixOS était SOUSTRAIT du cache faute d'ancre de confiance par fichier, ce qui lui fermait le hors ligne : le magasin est alors la seule source, et l'exception ne laisse rien. Une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. L'autorité est donc POINTÉE, consommateur par consommateur, et l'environnement se perd à trois frontières : nix-daemon, activé par socket, qu'un fragment sous /run/systemd/system atteint ; sudo, que « env_keep » traverse — le nix de root parle droit au magasin local et télécharge lui-même ; et la session ssh, ouverte une seconde avant que cloud-init n'écrive le faisceau. Vérifié : installation complète, 0 refus. --- EN --- NixOS was EXEMPTED from the cache for want of a per-file trust anchor, which closed offline deployment to it: the store is then the only source, and an exemption leaves nothing. A declaration would come too late, the first rebuild being the first download. The authority is therefore POINTED AT, consumer by consumer, and the environment is lost at three boundaries: nix-daemon, socket-activated, reached by a drop-in under /run/systemd/system; sudo, crossed by « env_keep » — root's nix talks straight to the local store and downloads itself; and the ssh session, opened one second before cloud-init writes the bundle. Checked: a complete install, 0 refusals. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + conf/nixos/erplibre.nix | 18 ++ script/install/install_nixos_dependency.sh | 7 + script/qemu/deploy_qemu.py | 143 ++++++++++- script/todo/qemu_install.py | 41 +++ test/test_install_nixos.py | 200 +++++++++++++++ test/test_qemu_cache_guest.py | 277 +++++++++++++++++++-- test/test_qemu_seed_locale_keyboard.py | 16 +- 10 files changed, 674 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index b364c6b..723abf2 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -217,6 +217,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before - The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing - A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason +- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. Measured on an intercepted VM: a READ goes through the nix client and a session variable suffices, but REALISING a derivation goes through nix-daemon, which never sees it; a drop-in under /run/systemd/system — a tmpfs, writable where /etc is not — reaches it. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked with upstream CUT on a fresh VM: nix-shell realises from the store - Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms - ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo - What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line @@ -270,6 +271,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant - Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien - Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison +- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se contente d'une variable de session, mais RÉALISER une dérivation passe par nix-daemon, qui ne la voit pas ; un fragment sous /run/systemd/system — un tmpfs, inscriptible là où /etc ne l'est pas — l'atteint. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin - Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms - ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo - Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e211897..92dbe33 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -121,6 +121,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant - Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien - Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison +- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se contente d'une variable de session, mais RÉALISER une dérivation passe par nix-daemon, qui ne la voit pas ; un fragment sous /run/systemd/system — un tmpfs, inscriptible là où /etc ne l'est pas — l'atteint. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin - Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms - ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo - Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e8d32..c32eb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before - The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing - A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason +- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. Measured on an intercepted VM: a READ goes through the nix client and a session variable suffices, but REALISING a derivation goes through nix-daemon, which never sees it; a drop-in under /run/systemd/system — a tmpfs, writable where /etc is not — reaches it. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked with upstream CUT on a fresh VM: nix-shell realises from the store - Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms - ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo - What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line diff --git a/conf/nixos/erplibre.nix b/conf/nixos/erplibre.nix index f85cc07..f0aac4a 100644 --- a/conf/nixos/erplibre.nix +++ b/conf/nixos/erplibre.nix @@ -194,6 +194,24 @@ CPATH = "/run/current-system/sw/include"; LIBRARY_PATH = "/run/current-system/sw/lib"; PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig"; + } // lib.optionalAttrs ("@EL_CA_BUNDLE@" != "") { + # L'autorité du cache de téléchargement, pour TOUTE session d'après + # l'installation — un « git pull » échouerait sinon sur un certificat + # qu'il ne reconnaît pas, comme le clone avant elle. + # + # La commande d'installation porte ces mêmes variables elle-même : elle + # tourne AVANT la première reconstruction, et aucun chemin PAM n'est + # inscriptible d'ici là. Les deux moitiés se relaient. + # + # « optionalAttrs » et non une valeur de repli : sur une machine sans + # cache le faisceau n'existe pas, et y pointer SSL_CERT_FILE couperait + # TLS partout. + SSL_CERT_FILE = "@EL_CA_BUNDLE@"; + CURL_CA_BUNDLE = "@EL_CA_BUNDLE@"; + GIT_SSL_CAINFO = "@EL_CA_BUNDLE@"; + REQUESTS_CA_BUNDLE = "@EL_CA_BUNDLE@"; + NODE_EXTRA_CA_CERTS = "@EL_CA_BUNDLE@"; + PIP_CERT = "@EL_CA_BUNDLE@"; }; # Les réglages régionaux demandés au déploiement. diff --git a/script/install/install_nixos_dependency.sh b/script/install/install_nixos_dependency.sh index c75e22e..36a3b1c 100755 --- a/script/install/install_nixos_dependency.sh +++ b/script/install/install_nixos_dependency.sh @@ -30,6 +30,11 @@ lire_seed() { sudo grep -m1 -E "^${1}: " "${EL_CLOUD_CFG}" 2>/dev/null | cut -d" " -f2- | tr -d "\r" || true } +# Le faisceau que cloud-init a bâti, s'il l'a fait : vide sur une machine +# sans cache, et le module n'y déclare alors aucune variable — y pointer +# couperait TLS partout. +EL_CA_BUNDLE=/var/lib/erplibre/ca-bundle.crt +[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE="" EL_LOCALE=${EL_LOCALE:-$(lire_seed locale)} EL_TZ=${EL_TZ:-$(lire_seed timezone)} MODULE_SRC="conf/nixos/erplibre.nix" @@ -51,8 +56,10 @@ echo -e "\n---- Module ERPLibre pour NixOS ----" # module déclare un rôle PostgreSQL, et un rôle porte un nom. sed -e "s/@EL_USER@/${EL_USER}/g" -e "s#@EL_DIR@#${EL_DIR}#g" \ -e "s/@EL_LOCALE@/${EL_LOCALE}/g" -e "s#@EL_TZ@#${EL_TZ}#g" \ + -e "s#@EL_CA_BUNDLE@#${EL_CA_BUNDLE}#g" \ "${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null echo " posé : ${MODULE_DST} (compte ${EL_USER}, dépôt ${EL_DIR})" +echo " autorité du cache : ${EL_CA_BUNDLE:-aucune}" echo " régional : locale « ${EL_LOCALE:-non demandée} »," \ "fuseau « ${EL_TZ:-non demandé} »" diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index e61caf1..75755bc 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -3212,6 +3212,74 @@ OFFLINE_BOOTCMD = [ " - systemctl stop --no-block systemd-time-wait-sync.service || true", ] +# NixOS n'a pas d'ancre de confiance PAR FICHIER : /etc est généré depuis le +# store et monté en lecture seule, donc la forme de CACHE_TRUST — un +# répertoire où déposer, une commande qui relit — n'y existe pas. Le +# téléchargeur n'est pas non plus le même : mesuré, une LECTURE passe par le +# client et honore une variable de session, mais RÉALISER une dérivation +# passe par nix-daemon, qui ne la voit pas. Seul un fragment systemd +# l'atteint. +# +# /run/systemd/system est un tmpfs, donc inscriptible quand /etc ne l'est +# pas : le fragment y vit, et il prend effet sans reconstruction — ce qui est +# tout l'enjeu, la première reconstruction étant elle-même le premier +# téléchargement. +# +# Le faisceau est la CONCATÉNATION de celui du système et de l'autorité : +# donner l'autorité seule ferait cesser d'approuver tout le reste. +NIX_CA_DIR = "/var/lib/erplibre" +NIX_CA_BUNDLE = f"{NIX_CA_DIR}/ca-bundle.crt" +NIX_CA_SYSTEME = "/etc/ssl/certs/ca-certificates.crt" +NIX_DROPIN = "/run/systemd/system/nix-daemon.service.d/10-erplibre-cache.conf" + +# Les variables qui portent le faisceau aux consommateurs d'une SESSION. +# +# Elles sont la seule voie ici : les quatre familles impératives déposent +# l'autorité dans le magasin du système, que tout lit sans qu'on demande, et +# qui SURVIT à sudo puisque ce n'est pas un environnement. NixOS n'a pas ce +# magasin — /etc/ssl/certs est un lien vers le store — donc chaque +# consommateur doit être pointé, et sudo les efface tous. +NIX_CA_VARS = ( + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "PIP_CERT", +) + + +def nix_trust_commands() -> list[str]: + """Ce qui fait approuver l'autorité du cache par nix, sans reconstruire. + + Mesuré sur une VM interceptée : sans cela « nix-shell -p hello » échoue + sur « self-signed certificate in certificate chain » ; avec, la dérivation + est réalisée depuis cache.nixos.org à travers le cache, et le magasin + garde ses .narinfo — ce qui rend le hors ligne possible ensuite. + """ + dossier = NIX_DROPIN.rsplit("/", 1)[0] + return [ + f"mkdir -p {NIX_CA_DIR} {dossier}", + f"cat {NIX_CA_SYSTEME} {NIX_CA_DIR}/{CACHE_CERT_NAME}" + f" > {NIX_CA_BUNDLE}", + f"printf '[Service]\\nEnvironment=NIX_SSL_CERT_FILE=%s\\n" + f"Environment=CURL_CA_BUNDLE=%s\\n' {NIX_CA_BUNDLE} {NIX_CA_BUNDLE}" + f" > {NIX_DROPIN}", + "systemctl daemon-reload", + # Socket-activé : arrêter les deux fait reprendre l'environnement à + # la prochaine connexion. + "systemctl stop nix-daemon 2>/dev/null || true", + "systemctl restart nix-daemon.socket 2>/dev/null || true", + # ET sudo, sans quoi rien de ce que la commande exporte ne survit : + # « sudo nixos-rebuild switch » évalue la configuration en allant + # chercher des centaines de narinfo, et les demandait toutes sans + # l'autorité. Les autres familles n'en ont pas besoin pour la même + # raison qu'elles n'ont pas besoin des variables : leur magasin + # système n'est pas un environnement. + commande_sudoers(NIX_CA_VARS), + ] + + CACHE_CERT_NAME = "erplibre-cache.crt" # Les familles à qui AUCUN des trois gestes de CACHE_TRUST ne s'applique. @@ -3227,7 +3295,11 @@ CACHE_CERT_NAME = "erplibre-cache.crt" # donc en être SOUSTRAITE, faute de quoi elle ne télécharge plus rien — et le # message qu'elle rendrait, « self-signed certificate in certificate chain », # ne dit rien d'une famille sans magasin. -CACHE_SANS_AUTORITE = frozenset({"nix"}) +# Plus aucune famille n'y figure. « nix » y était tant qu'on ne savait pas +# lui donner l'autorité : il l'a désormais par un fragment systemd, mesuré. +# La table reste, et le repli avec elle — une famille future sans magasin +# retomberait dessus plutôt que d'être interceptée sans rien. +CACHE_SANS_AUTORITE: frozenset = frozenset() def cache_sans_autorite(distro: str) -> bool: @@ -3435,7 +3507,7 @@ def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: if getattr(args, "cache_bypass", False): return [] famille = cache_family(args.distro) - if famille not in CACHE_TRUST: + if famille != "nix" and famille not in CACHE_TRUST: return [] try: with open(args.cache_ca, encoding="utf-8") as fh: @@ -3446,7 +3518,7 @@ def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: return [] if "BEGIN CERTIFICATE" not in pem: return [] - anchors = CACHE_TRUST[famille][0] + anchors = NIX_CA_DIR if famille == "nix" else CACHE_TRUST[famille][0] return [(f"{anchors}/{CACHE_CERT_NAME}", "0644", pem, "")] @@ -3467,7 +3539,15 @@ def cache_commands(args: argparse.Namespace) -> list[str]: """ if not cache_files(args): return [] - _, commande, faisceau = CACHE_TRUST[cache_family(args.distro)] + famille = cache_family(args.distro) + # NixOS ne relit pas un magasin : il n'en a pas de la forme attendue. Ses + # gestes bâtissent un faisceau, le donnent au démon qui télécharge, et + # font traverser sudo aux variables qui le désignent. Pas /etc/environment + # en revanche : son pam_env porte « readenv=0 » et son fichier de + # configuration est un lien vers le store. + if famille == "nix": + return nix_trust_commands() + _, commande, faisceau = CACHE_TRUST[famille] commandes = [f"{commande} || true"] for var in CACHE_ENV_VARS: commandes.append( @@ -3506,14 +3586,44 @@ def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str: répertoire sudoers.d — retire le temporaire et ne fait pas échouer la commande. Une variable par ligne, sans guillemets : la commande passe telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh. + + LE PROFIL DU SYSTÈME EST AJOUTÉ AU PATH. cloud-init ne donne à son + « runcmd » que le PATH de son propre service, et sur une distribution + déclarative celui-ci est une liste de chemins du store qui ne contient + pas sudo : visudo est alors introuvable, la vérification échoue, et le + garde-fou efface le fichier au lieu de le poser — sans rien dire, puisque + c'est précisément sa dégradation prévue. Le répertoire n'existe pas sur + les quatre familles impératives, où l'ajout ne change rien. """ dossier, nom = fichier.rsplit("/", 1) tmp = f"{dossier}/.{nom}" return ( - f"sh -c 'for v in {' '.join(variables)};" + f"sh -c 'PATH=$PATH:/run/current-system/sw/bin;" + f" for v in {' '.join(variables)};" f" do echo Defaults env_keep += $v; done > {tmp}" f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}" - f" || rm -f {tmp}'" + f' || (rm -f {tmp}; echo " ⚠ {t_sudoers_manque()}")\'' + ) + + +def t_sudoers_manque() -> str: + """Ce que dit un sudoers non posé. + + L'échec est VOLONTAIREMENT sans conséquence sur le déploiement — un + sudoers.d absent ne vaut pas de tout arrêter. Mais muet, il se paie plus + tard et ailleurs, sur un « self-signed certificate » que rien ne relie à + ce geste. La ligne nomme le seul effet qui compte, et ce qu'il faut + regarder. + + TROIS CARACTÈRES INTERDITS, et chacun casse ailleurs. L'apostrophe ferme + la quote simple qui enveloppe toute la commande, et le shell distant + meurt sur « unexpected EOF ». « : » suivi d'une espace, une accolade ou un + crochet font lire au YAML de « runcmd » autre chose qu'un scalaire + simple. Le message ne les porte donc pas, et un test le vérifie. + """ + return ( + "sudoers non posé — visudo introuvable, " + "sudo perdra le faisceau du cache" ) @@ -5721,9 +5831,24 @@ def main() -> None: # l'autorité du cache : on l'en SOUSTRAIT plutôt que de la laisser buter # sur un certificat inconnu à chaque téléchargement. Décidé ici et non # demandé à l'opérateur — c'est le catalogue qui sait. - if cache_sans_autorite(getattr(args, "distro", "")) and not getattr( - args, "cache_bypass", False - ): + # + # JAMAIS HORS LIGNE, et c'est la même raison que sur la voie Proxmox : + # l'amont est alors coupé et le magasin est la SEULE source. Soustraire + # la VM ne la ferait pas télécharger en direct — cela ne lui laisserait + # RIEN. Les deux issues échouent pour une distribution sans magasin de + # certificats : exceptée, elle n'a plus de source ; interceptée, elle bute + # sur un certificat qu'elle ne reconnaît pas. Le dire AVANT vaut mieux + # qu'une heure d'installation pour y arriver. + sans_magasin = cache_sans_autorite(getattr(args, "distro", "")) + if sans_magasin and getattr(args, "offline", False): + print( + f"\n ⚠ {args.distro} n'a pas de magasin de certificats, et" + " l'amont du cache est coupé." + "\n Aucune source ne lui reste : l'exception la priverait du" + " magasin, et sans elle" + "\n chaque téléchargement bute sur un certificat inconnu." + ) + elif sans_magasin and not getattr(args, "cache_bypass", False): args.cache_bypass = True print( f"\n {args.distro} n'a pas de magasin de certificats :" diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index be01248..5daf1ac 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -428,6 +428,39 @@ class QemuInstallMixin: # cloud de 40 G, l'oublier remplit le disque en pleine installation. QEMU_DESKTOP_EXTRA_DISK_GB = 6 + @staticmethod + def _qemu_ca_exports(): + """Donne l'autorité du cache à TOUT ce que la commande distante lance. + + Les quatre familles impératives posent l'autorité dans le magasin du + système, et curl, git et le reste la trouvent seuls. NixOS n'a pas + d'ancre par fichier : l'autorité y est un faisceau sous un chemin + inscriptible, que rien ne consulte sans qu'on le dise — nix mis à + part, qui a son fragment de service. + + PAS par /etc/environment : mesuré sur une VM, le pam_env de sshd porte + « readenv=0 » et son fichier de configuration est un lien vers le + store. Aucun chemin PAM n'est inscriptible avant la première + reconstruction, et c'est précisément avant elle que le clone a lieu. + La commande porte donc ses variables elle-même. + + GARDÉ par l'existence du fichier : ailleurs il n'y en a pas, et + pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout. Un + « if » et non un « && » : sous « set -e », une garde fausse en fin de + liste ET-OU est un cas limite qui dépend du shell. + """ + faisceau = "/var/lib/erplibre/ca-bundle.crt" + variables = ( + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "PIP_CERT", + ) + export = " ".join(f"{v}={faisceau}" for v in variables) + return f"if [ -r {faisceau} ]; then export {export}; fi; " + @staticmethod def _qemu_cloud_init_wait(): """Attend la fin de cloud-init, qui tient le verrou apt/dnf/pacman @@ -477,6 +510,14 @@ class QemuInstallMixin: # Les variables du cache sont écrites par cloud-init PENDANT # l'attente : cette session, ouverte avant, ne les a pas reçues. + cache_env_reload() + "; " + # ICI, et nulle part avant. Le faisceau que ces exports désignent + # est écrit par cloud-init lui aussi : mesuré sur une VM, la + # session ssh est acceptée une seconde avant qu'il existe, donc + # une garde évaluée en tête de commande est fausse et n'exporte + # rien. Tout ce qui suit perd alors l'autorité — le clone du dépôt + # échoue sur « self-signed certificate », quand nix, qui lit son + # fragment au moment de s'en servir, télécharge très bien. + + QemuInstallMixin._qemu_ca_exports() ) @staticmethod diff --git a/test/test_install_nixos.py b/test/test_install_nixos.py index 0b22662..a74f4bd 100644 --- a/test/test_install_nixos.py +++ b/test/test_install_nixos.py @@ -35,6 +35,22 @@ SCRIPT = RACINE / "script/install/install_nixos_dependency.sh" AIGUILLAGE = RACINE / "script/install/install_dev.sh" +def _deploy_qemu(): + """deploy_qemu.py chargé comme module, comme le fait todo.py.""" + import importlib.util + import sys + + sys.argv = ["todo.py"] + chemin = RACINE / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + + class LAiguillage(unittest.TestCase): def setUp(self): self.src = AIGUILLAGE.read_text(encoding="utf-8") @@ -440,6 +456,126 @@ class LesReglagesRegionauxDemandes(unittest.TestCase): self.assertIn("glibc-locales", self.src) +class LAutoriteAtteintToutCeQuiTelecharge(unittest.TestCase): + """nix a son fragment de service ; curl, git et le reste n'ont rien. + + Les quatre familles impératives posent l'autorité dans le magasin du + système et tout la trouve seul. Ici l'autorité est un faisceau sous un + chemin inscriptible, que rien ne consulte sans qu'on le dise — et le + clone du dépôt échouait donc sur « self-signed certificate in + certificate chain » alors que nix, lui, téléchargeait très bien. + + Deux moitiés qui se relaient : la commande d'installation porte ses + variables elle-même, car elle tourne AVANT la première reconstruction et + aucun chemin PAM n'est inscriptible d'ici là — mesuré, le pam_env de + sshd porte « readenv=0 » et son fichier est un lien vers le store. Le + module prend le relais pour les sessions d'après. + """ + + def setUp(self): + self.src = MODULE.read_text(encoding="utf-8") + self.script = SCRIPT.read_text(encoding="utf-8") + + def _exports(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO)._qemu_ca_exports() + + def test_the_command_carries_them_itself(self): + exports = self._exports() + for var in ("SSL_CERT_FILE", "GIT_SSL_CAINFO", "CURL_CA_BUNDLE"): + with self.subTest(var=var): + self.assertIn(var, exports) + + def test_nothing_is_exported_where_there_is_no_bundle(self): + """Pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout, + et les quatre autres familles n'ont pas ce faisceau.""" + self.assertIn("if [ -r ", self._exports()) + # Un « if » et non un « && » : sous « set -e », une garde fausse en + # fin de liste ET-OU est un cas limite qui dépend du shell. + self.assertNotIn("] && export", self._exports()) + + def test_the_module_takes_over_afterwards(self): + """Sans cela un « git pull » plus tard échouerait comme le clone.""" + self.assertIn("GIT_SSL_CAINFO", self.src) + self.assertIn("@EL_CA_BUNDLE@", self.src) + + def test_a_machine_without_a_cache_declares_nothing(self): + """« optionalAttrs » et non un défaut : le faisceau n'existe pas + partout, et l'installeur laisse le marqueur vide dans ce cas.""" + self.assertIn('lib.optionalAttrs ("@EL_CA_BUNDLE@" != "")', self.src) + self.assertIn( + '[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE=""', self.script + ) + + +class SudoNEffacePasLAutorite(unittest.TestCase): + """L'installation reconstruit le système par « sudo », et sudo remet + l'environnement à zéro. + + Le fragment de service donné au démon nix ne couvre pas ce cas : mesuré + sur une VM, « sudo nix store info » rend « Store URL: local » et un + NIX_REMOTE vide. Le nix de root parle DIRECTEMENT au magasin local, sans + jamais passer par le démon — c'est lui qui télécharge, avec + l'environnement que sudo lui laisse, et il n'en laisse aucun. + + Le mode de défaillance : chaque objet que la reconstruction doit chercher + est demandé sans l'autorité, et le seul journal est une répétition de + « unable to download ... narinfo: SSL peer certificate ... was not OK ». + Le système n'est pas activé, et rien ne nomme la cause. + + Le remède est celui des autres familles, « Defaults env_keep », et non un + second : NixOS lit bien /etc/sudoers.d — son /etc/sudoers porte + « #includedir », le répertoire existe, et visudo est dans le PATH de root. + """ + + def _commandes(self): + import tempfile + + with tempfile.NamedTemporaryFile("w", suffix=".crt") as fh: + fh.write("-----BEGIN CERTIFICATE-----\n") + fh.flush() + args = DQ.build_parser().parse_args( + ["--distro", "nixos", "--hostname", "x", "--cache-ca", fh.name] + ) + return DQ.cache_commands(args) + + def test_the_variables_cross_sudo(self): + joint = " ".join(self._commandes()) + self.assertIn(DQ.CACHE_SUDOERS, joint) + self.assertIn("env_keep", joint) + + def test_every_variable_the_session_exports_is_kept(self): + """Les deux listes sont lues ensemble ou pas du tout : une variable + exportée que sudo efface est exactement le défaut qu'on répare, et + une variable gardée que rien n'exporte ne garde rien.""" + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + exportees = set( + re.findall( + r"(\w+)=/var/lib/erplibre/ca-bundle\.crt", + TODO.__new__(TODO)._qemu_ca_exports(), + ) + ) + self.assertTrue(exportees, "la commande n'exporte rien") + self.assertEqual(exportees, set(DQ.NIX_CA_VARS)) + + def test_the_rebuild_is_the_one_that_needs_it(self): + """Le garde-fou pointe la ligne qui justifie tout le reste : si la + reconstruction cessait de passer par sudo, ce mécanisme n'aurait plus + de raison d'être, et personne ne s'en apercevrait.""" + self.assertIn( + "sudo nixos-rebuild switch", + SCRIPT.read_text(encoding="utf-8"), + ) + + class LePortDOdooTraverseLePareFeu(unittest.TestCase): """NixOS active un pare-feu par défaut ; aucune des images cloud des quatre autres distributions n'en active un. @@ -524,6 +660,70 @@ class LeMenuNEcritPasDansEtcSurNixos(unittest.TestCase): self.assertIn("make install_os", cmd) +class LAutoriteEstExporteeApresQueCloudInitLAEcrite(unittest.TestCase): + """L'ordre entre l'attente et les exports, qui n'est pas un détail. + + Le faisceau est écrit par cloud-init, et la session ssh est ouverte + AVANT lui : mesuré sur une VM, ssh est accepté à 13:18:27 et le fichier + apparaît à 13:18:28. Une garde « if [ -r … ] » évaluée en tête de + commande est donc fausse, n'exporte rien, et tout ce qui suit dans ce + shell perd l'autorité — le clone du dépôt échoue sur « self-signed + certificate » alors que nix, qui lit son fragment au moment de s'en + servir, télécharge très bien. Le symptôme accuse le réseau ; la cause est + une seconde d'écart. + + Le remède est STRUCTUREL : les exports vivent dans l'attente elle-même, + à côté de la relecture des variables que les autres familles y font déjà. + Trois commandes distantes sont bâties à des endroits différents, et + aucune ne peut plus les oublier ni les mettre trop tôt. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + def test_the_exports_live_inside_the_wait(self): + attente = self._todo()._qemu_cloud_init_wait() + self.assertIn("ca-bundle.crt", attente) + + def test_they_come_after_the_unit_that_writes_the_bundle(self): + """« cloud-init status --wait » rend la main trop tôt : c'est + l'attente de l'unité finale qui garantit le fichier écrit.""" + attente = self._todo()._qemu_cloud_init_wait() + self.assertLess( + attente.index("cloud-final"), attente.index("ca-bundle.crt") + ) + + def test_every_remote_command_carries_them_exactly_once(self): + """Les trois points de sortie — bureau seul, bureau et outils, + installation complète — passent tous par l'attente.""" + todo = self._todo() + for cas in ( + {"branch": None, "desktop": False}, + {"branch": None, "desktop": True}, + {"branch": "master", "desktop": False}, + ): + with self.subTest(**cas): + cmd = todo._qemu_erplibre_remote_cmd(**cas) + self.assertEqual( + cmd.count("if [ -r /var/lib/erplibre/ca-bundle.crt ]"), + 1, + "les exports doivent y être, et une seule fois", + ) + + def test_no_caller_puts_them_back_in_front(self): + """Un appelant qui les rajouterait en tête ramènerait le défaut sans + que rien d'autre ne change.""" + source = (RACINE / "script/todo/qemu_deploy.py").read_text( + encoding="utf-8" + ) + self.assertNotIn("_qemu_ca_exports()", source) + + class LeServiceAttendQuOdooSoitLa(unittest.TestCase): """L'unité est déclarée par le module, donc démarrée par la reconstruction — laquelle a lieu PENDANT « make install_os ». diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index 4133911..722e633 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -318,9 +318,9 @@ class TestLesVariablesTraversentSudo(unittest.TestCase): commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"), ) self.assertIn("chmod 0440", commande) - self.assertTrue( - commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai") - ) + # Le temporaire part toujours, et l'échec se DIT : muet, il se paie + # plus tard sur un « self-signed certificate » que rien ne relie ici. + self.assertIn("|| (rm -f /etc/sudoers.d/.essai; echo ", commande) def test_le_contenu_ecrit_est_une_ligne_par_variable(self): """Joué dans un vrai shell, visudo remplacé : c'est le fichier produit @@ -386,7 +386,16 @@ class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase): self.assertTrue(ligne.rstrip().endswith("|| true")) def test_une_vm_en_ligne_garde_sa_synchronisation(self): - self.assertNotIn("bootcmd", self.config()) + """La levée n'a lieu QUE hors ligne. + + « Aucun bootcmd » l'a longtemps dit, parce que la levée était le seul. + Le locale en pose un autre depuis — il génère la locale demandée avant + que le module de cloud-init ne l'applique —, et l'absence GLOBALE ne + prouve donc plus rien. C'est la levée nommément qui doit manquer, et + c'est elle que cette classe garde.""" + cmd = self.config().get("bootcmd") or [] + aplati = " ".join(str(c) for c in cmd) + self.assertNotIn("systemd-time-wait-sync", aplati) class TestLaLangueDesMessagesDuCache(unittest.TestCase): @@ -471,20 +480,44 @@ class TestAucunSystemeNestOublie(unittest.TestCase): apprendre l'autorité et qu'on laisse passer n'échoue pas à l'installation du certificat — elle échoue sur CHAQUE téléchargement, avec un message qui ne dit rien de la cause. + + L'épreuve porte sur le COMPORTEMENT et non sur l'appartenance à une + table : « nix » ne pose pas l'autorité dans un répertoire d'ancres — + il n'en a pas — mais il la pose, par un fragment systemd. Compter les + entrées de CACHE_TRUST l'aurait déclaré manquant alors qu'il est + servi, et déclarerait manquante toute mécanique future. """ + import tempfile + from script.qemu.deploy_qemu import ( CACHE_SANS_AUTORITE, - CACHE_TRUST, DISTROS, + build_parser, + cache_commands, cache_family, ) - familles = {cache_family(d) for d in DISTROS} - {""} - manquantes = sorted(familles - set(CACHE_TRUST) - CACHE_SANS_AUTORITE) + with tempfile.NamedTemporaryFile( + "w", suffix=".crt", delete=False + ) as fh: + fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n") + fh.write("-----END CERTIFICATE-----\n") + ca = fh.name + muettes = [] + for distro in DISTROS: + if not cache_family(distro): + continue + if cache_family(distro) in CACHE_SANS_AUTORITE: + continue + args = build_parser().parse_args( + ["--distro", distro, "--hostname", "x", "--cache-ca", ca] + ) + if not cache_commands(args): + muettes.append(distro) self.assertEqual( - manquantes, + muettes, [], - f"familles sans commande de confiance : {manquantes}", + f"systèmes qui ne posent rien : {muettes}", ) def test_une_famille_exemptee_nest_pas_aussi_dans_la_table(self): @@ -495,17 +528,20 @@ class TestAucunSystemeNestOublie(unittest.TestCase): self.assertEqual(set(), set(CACHE_TRUST) & CACHE_SANS_AUTORITE) - def test_une_exemptee_est_bien_soustraite_au_detournement(self): - """L'exemption ne vaut que si le déploiement la POSE : la déclarer et - laisser la VM sur le pont ne change rien à son sort.""" - from script.qemu.deploy_qemu import cache_sans_autorite + def test_plus_aucun_systeme_du_catalogue_nest_soustrait(self): + """L'exemption est le dernier recours, et plus personne n'y tombe. - self.assertTrue(cache_sans_autorite("nixos")) - for connue in ("debian", "ubuntu", "fedora", "arch", "opensuse"): - with self.subTest(distro=connue): - self.assertFalse(cache_sans_autorite(connue)) - # Un système hors catalogue n'est pas « exempté » : il n'a pas de - # famille, et c'est une autre question que celle-ci. + NixOS y était tant qu'on ne savait pas lui donner l'autorité : /etc + est en lecture seule, et une déclaration arriverait après le premier + téléchargement. Il l'a désormais par un fragment systemd, ce qui le + fait ENTRER dans le cache au lieu de l'en soustraire — et sans cela le + hors ligne lui était fermé, le magasin étant alors la seule source. + """ + from script.qemu.deploy_qemu import DISTROS, cache_sans_autorite + + for distro in DISTROS: + with self.subTest(distro=distro): + self.assertFalse(cache_sans_autorite(distro)) self.assertFalse(cache_sans_autorite("inconnue")) def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self): @@ -517,5 +553,208 @@ class TestAucunSystemeNestOublie(unittest.TestCase): self.assertEqual(cache_family(d), attendue, d) +class LeHorsLigneNeSousTraitPasUneDistributionSansMagasin(unittest.TestCase): + """Les deux ne se combinent pas, et c'est dit AVANT. + + Hors ligne, l'amont est coupé et le magasin est la SEULE source. + Soustraire au cache une distribution sans magasin de certificats ne la + ferait pas télécharger en direct : cela ne lui laisserait RIEN. Et sans + l'exception, chaque téléchargement bute sur un certificat inconnu. Les + deux issues échouent — une heure plus tard, si personne ne le dit. + """ + + SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8") + + def _bloc(self): + i = self.SRC.index("sans_magasin = cache_sans_autorite(") + return self.SRC[i : i + 1400] + + def test_offline_is_tested_before_the_fallback(self): + """L'ordre EST la correction : l'ancien code posait l'exception sans + jamais regarder si l'amont était coupé.""" + bloc = self._bloc() + self.assertLess( + bloc.index('getattr(args, "offline", False)'), + bloc.index("args.cache_bypass = True"), + ) + + def test_offline_never_exempts(self): + bloc = self._bloc() + avant = bloc[: bloc.index("args.cache_bypass = True")] + self.assertNotIn("args.cache_bypass = True", avant) + self.assertIn("elif sans_magasin", bloc) + + def test_the_dead_end_is_named(self): + """Un refus qui ne dit pas pourquoi renvoie chercher dans la VM.""" + bloc = self._bloc() + self.assertIn("Aucune source", bloc) + + +class NixApprendLAutoriteSansReconstruire(unittest.TestCase): + """NixOS n'a pas d'ancre de confiance par fichier, et une déclaration + arriverait après le premier téléchargement — la première reconstruction + EST ce téléchargement. + + Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se + contente d'une variable de session, mais RÉALISER une dérivation passe + par nix-daemon, qui ne la voit pas. Un fragment systemd l'atteint, et + /run/systemd/system est un tmpfs — inscriptible quand /etc ne l'est pas. + """ + + def _args(self, distro="nixos"): + import tempfile + + with tempfile.NamedTemporaryFile( + "w", suffix=".crt", delete=False + ) as fh: + fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n") + fh.write("-----END CERTIFICATE-----\n") + return build_parser().parse_args( + ["--distro", distro, "--hostname", "x", "--cache-ca", fh.name] + ) + + def test_the_authority_lands_where_it_can_be_written(self): + """/etc est généré depuis le store : y déposer échouerait.""" + from script.qemu.deploy_qemu import NIX_CA_DIR, cache_files + + chemins = [f[0] for f in cache_files(self._args())] + self.assertTrue(chemins) + for c in chemins: + with self.subTest(chemin=c): + self.assertTrue(c.startswith(NIX_CA_DIR)) + self.assertFalse(c.startswith("/etc/")) + + def test_the_bundle_keeps_the_system_authorities(self): + """Donner la seule autorité du cache ferait cesser d'approuver tout + le reste : le faisceau est une CONCATÉNATION.""" + from script.qemu.deploy_qemu import NIX_CA_SYSTEME, cache_commands + + joint = " ".join(cache_commands(self._args())) + self.assertIn(NIX_CA_SYSTEME, joint) + self.assertIn("cat ", joint) + + def test_the_daemon_is_reached_not_the_session(self): + """La session suffit pour LIRE, jamais pour réaliser : c'est le démon + qui télécharge alors, et seul un fragment l'atteint.""" + from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands + + joint = " ".join(cache_commands(self._args())) + self.assertIn(NIX_DROPIN, joint) + self.assertIn("NIX_SSL_CERT_FILE", joint) + self.assertIn("daemon-reload", joint) + + def test_nothing_needs_a_rebuild(self): + """Tout l'enjeu : la première reconstruction est elle-même le premier + téléchargement.""" + joint = " ".join(cache_commands(self._args())) + self.assertNotIn("nixos-rebuild", joint) + + def test_the_other_families_are_untouched(self): + """Elles ont un répertoire d'ancres et une commande qui le relit.""" + from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands + + for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"): + with self.subTest(distro=distro): + joint = " ".join(cache_commands(self._args(distro))) + self.assertNotIn(NIX_DROPIN, joint) + self.assertIn("/etc/environment", joint) + + +class LesGestesSurvivententAuTransport(unittest.TestCase): + """Les commandes du cache voyagent par deux transports, et chacun a ses + caractères mortels. + + Le premier est le « runcmd » de cloud-init, un scalaire simple de YAML : + un « : » suivi d'une espace, une accolade ou un crochet en tête y font + lire autre chose qu'une commande. Le second est « sh -c '…' », que la + moindre apostrophe referme — le shell distant meurt alors sur + « unexpected EOF », loin de la ligne fautive. + + Aucun des deux ne se voit à la lecture du Python : la chaîne y est + correcte, et c'est sa TRAVERSÉE qui échoue. D'où ces épreuves, une par + transport, sur toutes les familles à la fois. + """ + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def _familles(self): + """Une distribution par famille, nix compris — il est le seul dont + les gestes ne viennent pas de CACHE_TRUST.""" + return ("debian", "ubuntu", "fedora", "arch", "opensuse", "nixos") + + def test_every_runcmd_line_survives_a_yaml_parse(self): + for distro in self._familles(): + with self.subTest(distro=distro): + lignes = cache_runcmd(faux_args(Path(self.tmp.name), distro)) + charge = yaml.safe_load("runcmd:\n" + "\n".join(lignes)) + self.assertEqual( + charge["runcmd"], + [l.removeprefix(" - ") for l in lignes], + "le YAML ne rend pas la commande telle qu'elle est écrite", + ) + + def test_every_command_survives_a_real_shell(self): + """« sh -n » lit la commande sans rien exécuter : une quote non + refermée s'y voit, un rm -rf ne s'y joue pas.""" + import subprocess + + for distro in self._familles(): + for commande in cache_commands( + faux_args(Path(self.tmp.name), distro) + ): + with self.subTest(distro=distro, debut=commande[:40]): + res = subprocess.run( + ["sh", "-n", "-c", commande], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, res.stderr) + + +class LeSudoersAtteintVisudoLaOuIlEst(unittest.TestCase): + """Un système déclaratif ne met pas sudo dans le PATH de cloud-init. + + Mesuré sur une VM : le « runcmd » hérite du PATH du service de + cloud-init, vingt-six chemins du magasin dont AUCUN ne porte sudo, et + /usr/bin/visudo n'existe pas non plus. La vérification échouait donc, + le garde-fou effaçait le fichier au lieu de le poser, et sudo continuait + de vider l'environnement — l'installation ne rendait plus qu'une + répétition de refus de certificat. + + Le profil du système est le chemin que ces distributions garantissent ; + les quatre familles impératives ne l'ont pas, et l'ajout n'y change rien. + """ + + PROFIL = "/run/current-system/sw/bin" + + def test_the_system_profile_is_on_the_path(self): + self.assertIn(self.PROFIL, commande_sudoers(["A"])) + + def test_the_path_is_extended_not_replaced(self): + """Remplacer le PATH perdrait echo, chmod et mv, qui viennent du + magasin eux aussi et ne sont nulle part ailleurs.""" + self.assertIn(f"PATH=$PATH:{self.PROFIL}", commande_sudoers(["A"])) + + def test_a_failure_is_announced(self): + """Muet, l'échec se paie plus tard sur un « self-signed certificate » + que rien ne relie à ce geste.""" + self.assertIn("echo ", commande_sudoers(["A"]).split("||")[-1]) + + def test_the_message_carries_none_of_the_deadly_characters(self): + """L'apostrophe referme la commande, et les trois autres font lire au + YAML autre chose qu'un scalaire simple.""" + from script.qemu.deploy_qemu import t_sudoers_manque + + message = t_sudoers_manque() + self.assertTrue(message) + for interdit in ("'", ": ", "{", "}", "[", "]"): + with self.subTest(interdit=interdit): + self.assertNotIn(interdit, message) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_seed_locale_keyboard.py b/test/test_qemu_seed_locale_keyboard.py index 63da544..a6efe00 100644 --- a/test/test_qemu_seed_locale_keyboard.py +++ b/test/test_qemu_seed_locale_keyboard.py @@ -223,12 +223,20 @@ class LAutoriteDuCacheDansLeSeedAssemble(unittest.TestCase): doc["runcmd"], ) - def test_a_system_with_no_anchor_gets_nothing_and_keeps_the_rest(self): - """Un système déclaratif n'a pas d'ancre où écrire. Il ne reçoit donc - pas l'autorité — et le reste du document lui parvient quand même.""" + def test_a_declarative_system_gets_it_where_it_can_be_written(self): + """Un système déclaratif n'a pas d'ancre PAR FICHIER, mais il reçoit + l'autorité quand même : sous un chemin inscriptible, /etc étant + généré depuis le store et monté en lecture seule. + + Il l'a longtemps reçue nulle part, et c'est ce qui lui fermait le + hors ligne — le magasin est alors la seule source, et une VM + soustraite n'a plus rien.""" doc = yaml.safe_load(_cloud_config("nixos", self.ca)) chemins = [f["path"] for f in doc.get("write_files", [])] - self.assertFalse([c for c in chemins if DQ.CACHE_CERT_NAME in c]) + autorite = [c for c in chemins if DQ.CACHE_CERT_NAME in c] + self.assertEqual(1, len(autorite), chemins) + self.assertFalse(autorite[0].startswith("/etc/")) + # Et le reste du document lui parvient toujours. self.assertIn("users", doc) self.assertEqual("ca", doc["keyboard"]["layout"])