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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-05 10:12:37 -04:00
parent ce84d0e1ea
commit 92a6fb0ead
3 changed files with 80 additions and 2 deletions

View file

@ -1,5 +1,39 @@
# CHANGELOG — Set-OPS # 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=<adresse>` 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 ## 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 **La question de l'exploitant** : « un flux est permis entre une machine d'un tenant et l'API

View file

@ -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` # 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). # 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" 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]

View file

@ -33,10 +33,21 @@
# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle # couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle
# posee : mieux vaut l'apprendre avant. # 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). # Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif. # `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif.
# `CONNECTIVITE_PASSERELLE=<adresse>` 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' 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: try:
d = json.load(open(sys.argv[1], encoding="utf-8")) 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)) resultats = list(ex.map(essai, sorties))
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")] 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"] personne = [(f, r) for f, r in resultats if r == "refuse"]
ouverts = len(resultats) - len(coupes) - len(personne) 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]) \ 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 "") + (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: if coupes:
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}") print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
sys.exit(2) sys.exit(2)
avert = [] avert = []
info = "" info = ""
if INTERDITS and not gw:
avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas")
if personne: if personne:
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — " avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
f"{nomme(personne)}") f"{nomme(personne)}")