[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 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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue