[FIX] proxmox : ne pas démarrer le pare-feu depuis l'extérieur

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)
This commit is contained in:
Mathieu Benoit 2026-08-25 06:31:30 -04:00
parent 93b6256dba
commit e3138bea7e
5 changed files with 138 additions and 17 deletions

View file

@ -145,7 +145,14 @@ freeze_cloud_hosts() {
local dossier=/etc/cloud/cloud.cfg.d local dossier=/etc/cloud/cloud.cfg.d
local fichier="${dossier}/99-erplibre-hosts.cfg" local fichier="${dossier}/99-erplibre-hosts.cfg"
[ -d /etc/cloud ] || return 0 [ -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" say " cloud-init ne touche déjà plus à /etc/hosts"
return 0 return 0
fi 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 # 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 # 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. # 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() { revive_pve_services() {
command -v systemctl >/dev/null 2>&1 || return 0 command -v systemctl >/dev/null 2>&1 || return 0

View file

@ -20,6 +20,7 @@ fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau.
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import re import re
import shlex 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: def parse_cluster_check(text: str) -> dict:
"""{"actif": bool, "monte": bool, "adresses": [...]} depuis """{"actif", "monte", "adresses", "routables", "lu"} depuis
CLUSTER_CHECK_CMD. CLUSTER_CHECK_CMD.
`adresses` sans aucune adresse routable est la cause la plus fréquente : `routables` vide est la cause la plus fréquente : pmxcfs parcourt les
pmxcfs parcourt les adresses du nom d'hôte jusqu'à en trouver une qui ne adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de
soit pas de bouclage, et l'entrée « 127.0.1.1 <nom> » de l'image cloud le bouclage, et l'entrée « 127.0.1.1 <nom> » de l'image cloud le mène dans
mène dans le mur.""" 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 "") brut = strip_ssh_noise(text or "")
tete, _, reste = brut.partition("---ERPLIBRE-PVE-FS---") tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---")
milieu, _, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---")
adresses = [ adresses = [a for a in queue.split() if a[:1].isdigit() or ":" in a]
a
for a in queue.split()
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a) or ":" in a
]
return { return {
"lu": bool(sep1 and sep2),
"actif": "active" in tete and "inactive" not in tete, "actif": "active" in tete and "inactive" not in tete,
"monte": "MONTE" in milieu, "monte": "MONTE" in milieu,
"adresses": adresses, "adresses": adresses,
"routables": [ "routables": [a for a in adresses if _usable_address(a)],
a for a in adresses if not a.startswith("127.") and a != "::1"
],
} }

View file

@ -665,6 +665,15 @@ class ProxmoxMenuMixin:
_c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40)
etat = pve.parse_cluster_check(out) 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"]: if etat["monte"]:
return [] return []
lignes = [ lignes = [

View file

@ -3358,6 +3358,10 @@ TRANSLATIONS = {
"fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ",
"en": "Size (+10G to add, 40G for a target): ", "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.": { "pve-cluster is down: /etc/pve is not mounted.": {
"fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.",
"en": "pve-cluster is down: /etc/pve is not mounted.", "en": "pve-cluster is down: /etc/pve is not mounted.",

View file

@ -814,6 +814,46 @@ class TestPourquoiAucunStockage(unittest.TestCase):
self.assertTrue(lu["monte"]) self.assertTrue(lu["monte"])
self.assertEqual(lu["routables"], ["10.10.10.152"]) 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): def test_the_loopback_only_case(self):
lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"])) lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"]))
self.assertFalse(lu["monte"]) self.assertFalse(lu["monte"])
@ -877,6 +917,36 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase):
self.assertIsNotNone(m) self.assertIsNotNone(m)
self.assertEqual(m.group(1).split()[0], "pve-cluster") 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): def test_the_mount_is_verified_not_assumed(self):
self.assertIn("/etc/pve/.version", self.src) self.assertIn("/etc/pve/.version", self.src)