From e3138bea7e56338ee9bb81821955acd16d52922b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 06:31:30 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20proxmox=20:=20ne=20pas=20d=C3=A9marrer?= =?UTF-8?q?=20le=20pare-feu=20depuis=20l'ext=C3=A9rieur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une révision adversariale de la réparation à distance a rendu un constat que ses TROIS lentilles — réseau, systemd, shell — ont trouvé indépendamment : démarrer pve-firewall peut couper le ssh qui répare. Sa configuration vit dans /var/lib/pve-cluster/config.db, donc elle est invisible tant que /etc/pve n'est pas monté — c'est-à-dire exactement dans l'état qu'on répare. On appliquerait des règles qu'on ne peut pas lire, sur la seule voie d'accès à la machine. Il n'est pas nécessaire au but : le stockage et le suivi demandent pve-cluster et pvestatd, l'interface web pveproxy. Il repartira au prochain démarrage, quand /etc/pve sera monté à temps. Le retirer de la liste coûte donc rien et supprime le seul geste qui pouvait isoler un hôte. Deux autres constats de la même révision, également réels. Le gel de cloud-init gardait sur l'EXISTENCE du fichier. Or « printf … > » le TRONQUE avant d'écrire : une coupure au mauvais moment laisse zéro octet, et la garde annonce « déjà gelé » pour toujours. cloud-init continue de remettre 127.0.1.1 à chaque démarrage et le défaut redevient invisible — celui-là même que ce code existe pour supprimer. La garde porte maintenant sur le CONTENU. Et les adresses de lien-local passaient pour routables. Mesuré : « hostname --ip-address » peut ne rendre QUE des fe80::, et une APIPA en 169.254 passait le seul test « ne commence pas par 127. ». pmxcfs n'a alors rien d'utilisable, mais le diagnostic concluait l'inverse et renvoyait vers journalctl au lieu de /etc/hosts. Enfin « la sonde n'a pas répondu » n'est plus lu comme « rien n'est monté » : un dépassement de délai rend les mêmes vides, et on affirmait une cause qu'on n'avait pas constatée. --- EN --- An adversarial review of the remote repair produced one finding all THREE of its lenses — network, systemd, shell — reached independently: starting pve-firewall can cut the ssh doing the repair. Its configuration lives in /var/lib/pve-cluster/config.db, so it is invisible while /etc/pve is unmounted — exactly the state being repaired. We would apply rules we cannot read, over the machine's only way in. It is not needed for the goal: storage and monitoring need pve-cluster and pvestatd, the web interface pveproxy. It will come back at the next boot, when /etc/pve mounts in time. Removing it from the list costs nothing and removes the one gesture that could isolate a host. Two more findings from the same review, equally real. The cloud-init freeze guarded on the file's EXISTENCE. But "printf … >" TRUNCATES before writing: an ill-timed cut leaves zero bytes, and the guard then reports "already frozen" forever. cloud-init keeps putting 127.0.1.1 back at every boot and the defect becomes invisible again — the very one this code exists to remove. The guard now looks at the CONTENT. And link-local addresses counted as routable. Measured: "hostname --ip-address" can return ONLY fe80:: entries, and an APIPA 169.254 passed the lone "does not start with 127." test. pmxcfs then has nothing usable, yet the diagnosis concluded the opposite and pointed at journalctl instead of /etc/hosts. Finally "the probe did not answer" is no longer read as "nothing is mounted": a timeout returns the same emptiness, and we were asserting a cause we had not measured. Assisted-by: Claude Opus 5 (cherry picked from commit fa9fb729d82e8d1a8e4fb549cc8061c7281b5dcb) --- script/proxmox/install_proxmox.sh | 22 +++++++++- script/proxmox/proxmox_deploy.py | 50 +++++++++++++++------- script/todo/proxmox_menu.py | 9 ++++ script/todo/todo_i18n.py | 4 ++ test/test_proxmox_deploy.py | 70 +++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 17 deletions(-) diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 2225365..9cd1226 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -145,7 +145,14 @@ freeze_cloud_hosts() { local dossier=/etc/cloud/cloud.cfg.d local fichier="${dossier}/99-erplibre-hosts.cfg" [ -d /etc/cloud ] || return 0 - if [ -f "${fichier}" ]; then + # Sur le CONTENU et non sur l'existence : « printf … > fichier » TRONQUE + # avant d'écrire. Une coupure au mauvais moment laisse un fichier de zéro + # octet, et une garde à l'existence annonce alors « déjà gelé » pour + # toujours — cloud-init continue de remettre 127.0.1.1 à chaque + # démarrage, et le défaut redevient invisible. Une redirection est de + # toute façon idempotente : il n'y a rien à protéger d'autre. + if grep -qE "^[[:space:]]*manage_etc_hosts:[[:space:]]*false" \ + "${fichier}" 2>/dev/null; then say " cloud-init ne touche déjà plus à /etc/hosts" return 0 fi @@ -213,7 +220,18 @@ fix_hosts() { # arrêté, l'hôte rend une entrée SQUELETTIQUE par VM — ni nom, ni mémoire, ni # disque, et « status: unknown ». Le tableau de bord n'a alors aucune colonne # vivante, et il a même pris cette entrée pour une VM disparue. -PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy pve-firewall" +# pve-firewall n'y est PAS, et c'est délibéré. Sa configuration vit dans +# /var/lib/pve-cluster/config.db, donc elle est invisible tant que /etc/pve +# n'est pas monté — c'est-à-dire exactement dans l'état qu'on répare. Le +# démarrer, c'est appliquer des règles qu'on ne peut pas lire sur la seule +# voie d'accès à la machine : ce script tourne au bout d'un ssh, et une VM +# imbriquée n'a pas d'autre porte. Une révision adversariale l'a classé +# « isole l'hôte » par trois lentilles indépendantes. +# +# Il n'est de toute façon pas nécessaire au but : le stockage et le suivi +# demandent pve-cluster et pvestatd, l'interface web pveproxy. Le pare-feu +# repartira au prochain démarrage, quand /etc/pve sera monté à temps. +PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy" revive_pve_services() { command -v systemctl >/dev/null 2>&1 || return 0 diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index d6eb108..671c2d8 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -20,6 +20,7 @@ fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. """ from __future__ import annotations +import ipaddress import json import re import shlex @@ -302,29 +303,48 @@ CLUSTER_CHECK_CMD = ( ) +def _usable_address(adresse: str) -> bool: + """Cette adresse permet-elle à pmxcfs de s'identifier ? + + Ni bouclage, ni LIEN-LOCAL. Le lien-local est le piège : mesuré, + « hostname --ip-address » peut ne rendre QUE des fe80::, et une adresse + APIPA en 169.254 passait le seul test « ne commence pas par 127. ». Dans + les deux cas pmxcfs n'a rien d'utilisable, mais le diagnostic concluait + « le nom résout vers une adresse routable » — et renvoyait vers + journalctl au lieu de /etc/hosts, sur un hôte qu'on ne peut inspecter que + par ssh.""" + try: + adr = ipaddress.ip_address(adresse) + except ValueError: + return False + return not (adr.is_loopback or adr.is_link_local) + + def parse_cluster_check(text: str) -> dict: - """{"actif": bool, "monte": bool, "adresses": [...]} depuis + """{"actif", "monte", "adresses", "routables", "lu"} depuis CLUSTER_CHECK_CMD. - `adresses` sans aucune adresse routable est la cause la plus fréquente : - pmxcfs parcourt les adresses du nom d'hôte jusqu'à en trouver une qui ne - soit pas de bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le - mène dans le mur.""" + `routables` vide est la cause la plus fréquente : pmxcfs parcourt les + adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de + bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le mène dans + le mur. + + `lu` dit si la sonde a RÉPONDU — les deux sentinelles sont là. Sans lui, + un simple dépassement de délai rendait « monte: False, adresses: [] », et + l'appelant affirmait « le nom d'hôte ne résout que vers ? » sans avoir + rien mesuré. Affirmer une cause qu'on n'a pas constatée est pire que se + taire : cela envoie réécrire /etc/hosts sur une machine peut-être + saine.""" brut = strip_ssh_noise(text or "") - tete, _, reste = brut.partition("---ERPLIBRE-PVE-FS---") - milieu, _, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") - adresses = [ - a - for a in queue.split() - if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a) or ":" in a - ] + tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---") + milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") + adresses = [a for a in queue.split() if a[:1].isdigit() or ":" in a] return { + "lu": bool(sep1 and sep2), "actif": "active" in tete and "inactive" not in tete, "monte": "MONTE" in milieu, "adresses": adresses, - "routables": [ - a for a in adresses if not a.startswith("127.") and a != "::1" - ], + "routables": [a for a in adresses if _usable_address(a)], } diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 2c01844..7be8e04 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -665,6 +665,15 @@ class ProxmoxMenuMixin: _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) etat = pve.parse_cluster_check(out) + # « La sonde n'a pas répondu » n'est PAS « rien n'est monté ». Un + # dépassement de délai — hostname bloqué sur un DNS injoignable — rend + # les mêmes vides, et on affirmait alors « le nom ne résout que vers + # ? » sans avoir rien mesuré. Affirmer une cause qu'on n'a pas + # constatée est pire que se taire. + if not etat["lu"]: + return [ + f"⚠ {t('The cluster probe did not answer: cause unknown.')}" + ] if etat["monte"]: return [] lignes = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 73c72b5..f76cb1e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3358,6 +3358,10 @@ TRANSLATIONS = { "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", "en": "Size (+10G to add, 40G for a target): ", }, + "The cluster probe did not answer: cause unknown.": { + "fr": "La sonde du cluster n'a pas répondu : cause inconnue.", + "en": "The cluster probe did not answer: cause unknown.", + }, "pve-cluster is down: /etc/pve is not mounted.": { "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "en": "pve-cluster is down: /etc/pve is not mounted.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 0037cb5..2eef09d 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -814,6 +814,46 @@ class TestPourquoiAucunStockage(unittest.TestCase): self.assertTrue(lu["monte"]) self.assertEqual(lu["routables"], ["10.10.10.152"]) + def test_a_probe_that_did_not_answer_says_so(self): + """« La sonde n'a pas répondu » n'est PAS « rien n'est monté ». + + Un dépassement de délai — hostname bloqué sur un DNS injoignable — + rend les mêmes vides. On affirmait alors « le nom ne résout que vers + ? » sans avoir rien mesuré, ce qui envoyait réécrire /etc/hosts sur + une machine peut-être saine.""" + self.assertFalse(pve.parse_cluster_check("timeout")["lu"]) + self.assertFalse(pve.parse_cluster_check("")["lu"]) + self.assertTrue( + pve.parse_cluster_check(self._sortie(True, True, ["10.0.0.1"]))[ + "lu" + ] + ) + + def test_a_link_local_address_is_not_routable(self): + """Mesuré : « hostname --ip-address » peut ne rendre QUE des fe80::. + + Le seul test « ne commence pas par 127. » les prenait pour routables, + et une APIPA en 169.254 aussi. pmxcfs n'a alors rien d'utilisable, + mais le diagnostic concluait l'inverse — et renvoyait vers journalctl + au lieu de /etc/hosts.""" + for adresses in ( + ["fe80::5054:ff:fecf:bba9", "fe80::fc54:ff:fe79:78a4"], + ["169.254.3.4"], + ["127.0.1.1"], + ): + with self.subTest(adresses=adresses): + lu = pve.parse_cluster_check( + self._sortie(False, False, adresses) + ) + self.assertEqual(lu["routables"], []) + self.assertEqual(lu["adresses"], adresses) + + def test_a_real_address_among_link_locals_still_counts(self): + lu = pve.parse_cluster_check( + self._sortie(True, True, ["10.10.10.152", "fe80::1"]) + ) + self.assertEqual(lu["routables"], ["10.10.10.152"]) + def test_the_loopback_only_case(self): lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"])) self.assertFalse(lu["monte"]) @@ -877,6 +917,36 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertIsNotNone(m) self.assertEqual(m.group(1).split()[0], "pve-cluster") + def test_the_firewall_is_never_started_from_outside(self): + """Le seul constat que trois lentilles ont trouvé indépendamment. + + La configuration de pve-firewall vit dans + /var/lib/pve-cluster/config.db : elle est donc INVISIBLE tant que + /etc/pve n'est pas monté — c'est-à-dire exactement dans l'état qu'on + répare. Le démarrer, c'est appliquer des règles qu'on ne peut pas lire + sur la seule voie d'accès à la machine ; ce script tourne au bout d'un + ssh, et une VM imbriquée n'a pas d'autre porte. + + Il n'est pas nécessaire au but : le stockage et le suivi demandent + pve-cluster et pvestatd, l'interface web pveproxy.""" + import re + + m = re.search(r'PVE_SERVICES="([^"]+)"', self.src) + self.assertIsNotNone(m) + self.assertNotIn("pve-firewall", m.group(1).split()) + + def test_the_freeze_is_guarded_on_content(self): + """« printf … > fichier » TRONQUE avant d'écrire. + + Une coupure au mauvais moment laisse zéro octet, et une garde à + l'EXISTENCE annonce « déjà gelé » pour toujours : cloud-init continue + de remettre 127.0.1.1 à chaque démarrage et le défaut redevient + invisible.""" + bloc = self.src[self.src.index("freeze_cloud_hosts() {") :] + bloc = bloc[: bloc.index("\nfix_hosts()")] + self.assertIn("manage_etc_hosts:[[:space:]]*false", bloc) + self.assertNotIn('[ -f "${fichier}" ]', bloc) + def test_the_mount_is_verified_not_assumed(self): self.assertIn("/etc/pve/.version", self.src)