[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 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

View file

@ -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 <nom> » 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 <nom> » 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)],
}

View file

@ -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 = [

View file

@ -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.",

View file

@ -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)