From 92a6fb0ead1cbfd5e3e0fe04f077354dfeb14ed4 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 5 Oct 2026 10:12:37 -0400 Subject: [PATCH] nftables_baseline : la passerelle n'est pas une porte (garde 8006) La sonde connectivite essaie l'API Proxmox (8006) sur la passerelle par defaut : chez un locataire en SDN, c'est l'hyperviseur dans son VRF, hors de la frontiere. Ouvert -> CRITIQUE. Temoin : CONNECTIVITE_PASSERELLE. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 34 ++++++++++++++++ roles/nftables_baseline/defaults/main.yml | 9 +++++ .../templates/sonde-connectivite.sh.j2 | 39 ++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a63da..6b2d220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (88) — La passerelle n'est pas une porte : une garde sur l'API Proxmox + +**Le reste ouvert de (87)** : chez un locataire en SDN, la passerelle de chaque zone est une +interface de l'**hyperviseur**, dans le VRF du locataire. Une API Proxmox qui y répondrait +serait jointe sans passer par la frontière. Fermée aujourd'hui (`ECONNREFUSED`). + +**Option choisie par l'exploitant** : tester la **propriété**, depuis les machines des +locataires, plutôt que lire `net.ipv4.tcp_l3mdev_accept` sur les hyperviseurs. Cela aurait +demandé un nouveau rôle sur des machines que Set-OPS ne gère pas (Prometheus les tire ; leur +node_exporter 1.5 n'a pas de collecteur `sysctl`). + +**Fait** (`nftables_baseline`) : +- la sonde `connectivite`, qui tourne chaque minute sur chaque machine, essaie aussi les ports + de `nftables_baseline_connectivite_passerelle_interdits` (`[8006]`) sur la **passerelle par + défaut**. `ouvert` → **CRITIQUE** (« PASSERELLE OUVERTE ») ; refusé, rejeté ou silencieux → + attendu ; passerelle introuvable → avertissement, une garde qui ne s'exerce pas le dit. Ici, + `ouvert` prouve bien quelque chose : entre une machine et sa passerelle, aucun relais ; +- elle couvre toute cause : le réglage du noyau, un pare-feu d'hôte retiré, un service qui se + mettrait à écouter partout ; +- `CONNECTIVITE_PASSERELLE=` vise une autre « passerelle » : le contrôle négatif de la + garde ; nouvelle mesure de performance `passerelle_ouverte`. + +**Éprouvé**, sonde rendue et lancée à la main : +- cas normal sur `ops-01` et `web-dorsal-01` (Technolibre), `web-frontal-01` (Chezlepro) : la + passerelle de zone refuse le 8006, sonde verte, ses mesures habituelles intactes ; +- au site (`site-mon-01`, passerelle = la frontière) : verte, aucune fausse alarme ; +- témoin négatif (443 interdit, « passerelle » = la forge du site, qui répond) : **CRITIQUE**, + code 2. + +**À déployer** : `nftables_baseline` (groupe `serveur_durci`), chez les deux locataires et au +site. Le ruleset nftables ne change pas ; seule la sonde change. + +**Validation** : `ansible-lint` (0 violation), `make verifier` conforme, 89/89. + ## 2026-10-05 (87) — Une règle qui disait qu'un locataire pouvait créer des VM **La question de l'exploitant** : « un flux est permis entre une machine d'un tenant et l'API diff --git a/roles/nftables_baseline/defaults/main.yml b/roles/nftables_baseline/defaults/main.yml index 2a25a30..866dc3f 100644 --- a/roles/nftables_baseline/defaults/main.yml +++ b/roles/nftables_baseline/defaults/main.yml @@ -17,3 +17,12 @@ nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inve # La liste des flux que la sonde `connectivite` teste chaque minute, generee par `make flux` # a cote du ruleset, depuis les MEMES regles (voir templates/sonde-connectivite.sh.j2). nftables_baseline_connectivite_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.connectivite.json" + +# LA PASSERELLE N'EST PAS UNE PORTE (2026-10-05). Ports qui ne doivent JAMAIS repondre sur la +# passerelle par defaut de la machine — la sonde `connectivite` le verifie chaque minute. +# Chez un locataire en SDN, la passerelle de zone est l'HYPERVISEUR lui-meme, dans le VRF du +# locataire : une API Proxmox qui y repondrait contournerait la frontiere. Fermee aujourd'hui +# (mesure du 2026-10-05 : ECONNREFUSED) ; la garde dit si elle s'ouvre, quelle qu'en soit la +# cause (`net.ipv4.tcp_l3mdev_accept`, un pare-feu d'hote retire...). Sur une machine du site, +# la passerelle est la frontiere, qui n'ecoute pas sur ces ports. +nftables_baseline_connectivite_passerelle_interdits: [8006] diff --git a/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 index b41b43c..767aea4 100644 --- a/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 +++ b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 @@ -33,10 +33,21 @@ # couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle # posee : mieux vaut l'apprendre avant. # +# CE QU'ELLE GARDE AUSSI (2026-10-05) : la passerelle par defaut ne doit repondre sur aucun +# port de `nftables_baseline_connectivite_passerelle_interdits` (l'API Proxmox, 8006). Chez un +# locataire en SDN, cette passerelle est l'hyperviseur, dans le VRF du locataire ; une API qui y +# repondrait contournerait la frontiere. Ici, `ouvert` prouve bien quelque chose : entre la +# machine et sa passerelle, aucun relais. Ouvert -> CRITIQUE ; refuse, rejete, delai -> attendu. +# Passerelle introuvable -> AVERTISSEMENT : une garde qui ne s'exerce pas doit le dire. +# # Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md). # `CONNECTIVITE_JSON=` impose une autre liste : c'est le controle negatif. +# `CONNECTIVITE_PASSERELLE=` vise une autre « passerelle » : le controle negatif de la +# garde (une adresse dont un port interdit repond doit la faire crier). exec python3 - "${CONNECTIVITE_JSON:-/usr/local/lib/setops/connectivite.json}" <<'PY' -import concurrent.futures, errno, ipaddress, json, socket, subprocess, sys +import concurrent.futures, errno, ipaddress, json, os, socket, subprocess, sys + +INTERDITS = {{ nftables_baseline_connectivite_passerelle_interdits | to_json }} try: d = json.load(open(sys.argv[1], encoding="utf-8")) @@ -78,6 +89,23 @@ with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex: resultats = list(ex.map(essai, sorties)) coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")] + +# La passerelle n'est pas une porte. +def passerelle_par_defaut(): + if os.environ.get("CONNECTIVITE_PASSERELLE"): + return os.environ["CONNECTIVITE_PASSERELLE"] + try: + for l in subprocess.run(["ip", "-4", "route", "show", "default"], capture_output=True, + text=True, timeout=5).stdout.splitlines(): + c = l.split() + if "via" in c: + return c[c.index("via") + 1] + except (OSError, subprocess.TimeoutExpired): + pass + return None + +gw = passerelle_par_defaut() +porte = [p for p in INTERDITS if gw and tcp(gw, p) == "ouvert"] personne = [(f, r) for f, r in resultats if r == "refuse"] ouverts = len(resultats) - len(coupes) - len(personne) @@ -132,12 +160,19 @@ def nomme(xs): return ", ".join(f"{f['vers']}:{f['port'] if f['proto'] != 'icmp' else 'ping'} ({r})" for f, r in xs[:5]) \ + (f" et {len(xs) - 5} autre(s)" if len(xs) > 5 else "") -perf = f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 hors_regles={len(hors)};;;0" +perf = (f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 " + f"hors_regles={len(hors)};;;0 passerelle_ouverte={len(porte)};;;0") +if porte: + print(f"PASSERELLE OUVERTE : {gw} repond sur {', '.join(map(str, porte))} — la passerelle de zone " + f"(l'hyperviseur, chez un locataire) expose ce qui doit rester derriere la frontiere. {perf}") + sys.exit(2) if coupes: print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}") sys.exit(2) avert = [] info = "" +if INTERDITS and not gw: + avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas") if personne: avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — " f"{nomme(personne)}")