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"])