pare-feu Proxmox : le SSH inter-nœud était perdu

Je sautais le flux entier dès qu'un de ses pairs valait `externe`. Or le SSH
du socle est déclaré `[flotte, externe]` : la moitié `externe` relève de la
frontière, mais la moitié `flotte` — le SSH entre hôtes, celui d'Ansible —
était perdue. Sous une politique DROP, plus aucun hôte n'aurait été joignable
en SSH depuis l'intérieur. Même piège pour le SMTP interne de Postfix,
déclaré `[externe, client_smtp]`.

`externe` est sauté pair par pair, jamais le flux entier. 36 groupes,
56 règles.

Ajouté : la liste des rôles sans règle entrante, avec leur motif. Onze rôles
sont injoignables sous DROP, et c'est voulu dans les onze cas — boucle locale
pour Prometheus, Redis, rspamd, Icinga et Unbound ; frontière seule pour
nginx ; aucun service pour `serveur_durci` et les clients. Un douzième motif
existe, marqué d'un avertissement : « flux entrants déclarés mais aucune
source résolue ici » — celui-là serait un vrai trou.

Preuves : 25 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 12:04:16 -04:00
parent 7b2c9272d7
commit e9786700aa
3 changed files with 58 additions and 4 deletions

View file

@ -1,5 +1,26 @@
# CHANGELOG — Set-OPS
## 2026-08-03 (suite 9) — le SSH inter-nœud était perdu
En éclatant les règles par rôle source, un défaut de ma première version est apparu : je
**sautais le flux entier** dès qu'un de ses pairs valait `externe`.
Or le SSH du socle est déclaré `pair: [flotte, externe]`. La moitié `externe` relève bien de
la frontière — mais la moitié **`flotte`**, le SSH entre hôtes, celui d'Ansible, était perdue.
Sous une politique `DROP`, **plus aucun hôte n'aurait été joignable en SSH depuis l'intérieur**.
Même piège pour le SMTP interne de Postfix, déclaré `[externe, client_smtp]`.
`externe` est désormais sauté **pair par pair**, jamais le flux entier. 36 groupes, 56 règles.
### Ajouté — ce qui n'a aucune règle entrante, et pourquoi
Onze rôles portés n'ont aucune règle entrante : sous `DROP`, ils sont injoignables. C'est
voulu dans les onze cas — la boucle locale pour Prometheus, Redis, rspamd, Icinga et Unbound,
la frontière seule pour nginx, aucun service pour `serveur_durci` et les clients.
Le devis les **nomme avec leur motif** au lieu de laisser un lecteur le vérifier lui-même. Et
un douzième motif existe, marqué `/!\` : « flux entrants déclarés mais aucune source résolue
ici » — celui-là serait un vrai trou.
## 2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre
`make devis-proxmox-fw` (**preuve P25**). Le filtrage est-ouest intra-tenant est désormais

View file

@ -13,7 +13,7 @@
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | <unknown>:1: SyntaxWarning: invalid decimal literal |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
@ -37,7 +37,7 @@
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 34 groupe(s), 52 regle(s). |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 36 groupe(s), 56 regle(s). |
## Couverture des affirmations ✅ du registre

View file

@ -141,8 +141,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if fl.get("sens") != "ingress":
continue
pairs = _pairs(fl)
if "externe" in pairs:
continue # la frontiere s'en charge
# `externe` est saute PAIR PAR PAIR, jamais le flux entier : le SSH du
# socle est declare `[flotte, externe]`, et sauter tout le flux perdait
# la moitie `flotte` — plus aucun hote joignable en SSH depuis
# l'interieur, Ansible compris. Meme piege pour le SMTP interne de
# Postfix, declare `[externe, client_smtp]`.
pairs = [p for p in pairs if p != "externe"]
if not pairs:
continue # purement externe : la frontiere s'en charge
if pairs == ["localhost"]:
continue # boucle locale : aucune regle inter-noeud
if not _resoudre_sources(data, fl.get("pair"), tous, ips):
@ -198,7 +204,29 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if porte:
affect.append({"hote": hote, "vmid": vmid, "groupes": porte})
# Roles portes mais SANS regle entrante : sous une politique DROP ils sont
# injoignables. C'est souvent voulu (ils n'ecoutent que sur la boucle locale, ou
# seule la frontiere les atteint) — mais l'absence doit se lire, pas se deviner.
avec = {g["role"] for g in groupes}
muets: list[dict] = []
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe) or groupe in avec:
continue
if not _hotes_du_groupe(data, groupe):
continue
ing = [f for f in flux_par_role.get(groupe, []) if f.get("sens") == "ingress"]
if not ing:
motif = "aucun service ecoute"
elif all(set(_pairs(f)) <= {"localhost"} for f in ing):
motif = "n'ecoute que sur la boucle locale"
elif all(set(_pairs(f)) <= {"externe"} for f in ing):
motif = "joignable seulement par la frontiere"
else:
motif = "A VERIFIER — flux entrants declares mais aucune source resolue ici"
muets.append({"role": groupe, "motif": motif})
blocs.append({
"muets": muets,
"tenant": nom, "etiquette": etiquette,
"ipsets": {k: v for k, v in ipsets.items() if k in utilises},
"groupes": groupes,
@ -251,6 +279,11 @@ def rendre(devis: dict) -> str:
for a in b["affectations"]:
out.append(f" vmid {a['vmid'] or '(non materialise)'} {a['hote']:<18} "
f"-> {', '.join(a['groupes'])}")
if b["muets"]:
out += ["", "### Sans regle entrante — injoignables sous la politique DROP"]
for m in b["muets"]:
marque = " /!\\" if m["motif"].startswith("A VERIFIER") else ""
out.append(f" {m['role']:<22} {m['motif']}{marque}")
if b["sans_source"]:
out += ["", "# /!\\ Flux ingress sans source resolue dans ce tenant (aucune regle emise) :"]
out += [f"# {x}" for x in b["sans_source"]]