[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:
parent
93b6256dba
commit
e3138bea7e
5 changed files with 138 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue