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>
189 lines
8.4 KiB
Django/Jinja
189 lines
8.4 KiB
Django/Jinja
#!/bin/bash
|
|
# GENERE par Set-OPS (role nftables_baseline). Ne pas editer a la main.
|
|
#
|
|
# SONDE « connectivite » — chaque flux SORTANT que le registre des flux promet a cette
|
|
# machine passe-t-il, en ce moment ? Rapportee chaque minute (porteur `client_sante`, mode
|
|
# `minute`) : un flux coupe se voit dans la minute, pas au prochain deploiement.
|
|
#
|
|
# POURQUOI (2026-09-30). L'activation du pare-feu Proxmox se prouvait par une matrice rejouee
|
|
# VM par VM, avant et apres : vingt minutes en fin de reconstruction, et une preuve valable
|
|
# a l'instant de l'activation seulement. Cette sonde la remplace, et veille ensuite en
|
|
# permanence.
|
|
#
|
|
# LA LISTE n'est pas ecrite ici : `make flux` la derive des MEMES regles resolues que le
|
|
# `.nft` de chaque hote (`flux-genere/<hote>.connectivite.json`). On teste ce que le pare-feu
|
|
# applique, pas ce qu'on croit.
|
|
#
|
|
# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre :
|
|
# ouvert le flux passe -> OK
|
|
# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT
|
|
# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un
|
|
# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les
|
|
# `seulement_si`, aucun flux declare ne mene plus a un port sans service :
|
|
# ce cas n'est plus jamais normal. Jaune, pas rouge — un service qui
|
|
# redemarre ne doit pas reveiller quelqu'un.
|
|
# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou
|
|
# machine injoignable -> CRITIQUE
|
|
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
|
|
# Un `connect()` TCP ne prouve pas une livraison au-dela d'un relais ; ici les flux sont
|
|
# directs (meme locataire, routes par la frontiere sans relais applicatif), et c'est bien
|
|
# le passage des pare-feux qu'on mesure.
|
|
#
|
|
# CE QU'ELLE OBSERVE AUSSI : les connexions entrantes ETABLIES que les entrees declarees ne
|
|
# 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=<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'
|
|
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"))
|
|
except (OSError, ValueError) as e:
|
|
print(f"Liste des flux absente ou illisible ({sys.argv[1]}) : rien n'est teste. "
|
|
"Regenerer `make flux` et redeployer nftables_baseline.")
|
|
sys.exit(1)
|
|
|
|
|
|
def tcp(ip, port):
|
|
s = socket.socket()
|
|
s.settimeout(2)
|
|
try:
|
|
s.connect((ip, int(port)))
|
|
return "ouvert"
|
|
except socket.timeout:
|
|
return "delai"
|
|
except OSError as e:
|
|
if e.errno == errno.ECONNREFUSED:
|
|
return "refuse"
|
|
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
|
|
return "rejete"
|
|
return errno.errorcode.get(e.errno, str(e.errno))
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
def ping(ip):
|
|
r = subprocess.run(["ping", "-c1", "-W2", ip], capture_output=True)
|
|
return "ouvert" if r.returncode == 0 else "delai"
|
|
|
|
|
|
def essai(f):
|
|
return f, (ping(f["ip"]) if f["proto"] == "icmp" else tcp(f["ip"], f["port"]))
|
|
|
|
|
|
sorties = d.get("sorties") or []
|
|
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)
|
|
|
|
# Entrees etablies hors des regles declarees.
|
|
def lignes(cmd):
|
|
try:
|
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return []
|
|
|
|
ecoute = {l.split()[3].rsplit(":", 1)[1] for l in lignes(["ss", "-Hltn"]) if len(l.split()) >= 4}
|
|
# MES PROPRES ADRESSES : une machine qui se parle a elle-meme par son adresse (Loki sur
|
|
# obs-01, port 9096) passe par la boucle locale, jamais par un pare-feu. Vu au premier
|
|
# deploiement : `obs-01` se declarait « hors des regles » envers lui-meme.
|
|
moi = set()
|
|
for l in lignes(["ip", "-o", "addr", "show"]):
|
|
c = l.split()
|
|
if len(c) >= 4 and c[2] in ("inet", "inet6"):
|
|
moi.add(c[3].split("/")[0])
|
|
reseaux = {}
|
|
for e in d.get("entrees") or []:
|
|
if e.get("proto") == "tcp":
|
|
reseaux.setdefault(str(e["port"]), []).extend(e.get("sources") or [])
|
|
reseaux.setdefault("22", []).extend(d.get("admin_ssh") or [])
|
|
hors = set()
|
|
for l in lignes(["ss", "-Hnt", "state", "established"]):
|
|
c = l.split()
|
|
if len(c) < 4:
|
|
continue
|
|
lport = c[2].rsplit(":", 1)[1]
|
|
pair = c[3].rsplit(":", 1)[0].strip("[]").replace("::ffff:", "")
|
|
if lport not in ecoute:
|
|
continue
|
|
try:
|
|
adr = ipaddress.ip_address(pair)
|
|
except ValueError:
|
|
continue
|
|
if adr.is_loopback or str(adr) in moi:
|
|
continue
|
|
permis = False
|
|
for r in reseaux.get(lport, []):
|
|
try:
|
|
if adr in ipaddress.ip_network(str(r), strict=False):
|
|
permis = True
|
|
break
|
|
except ValueError:
|
|
pass
|
|
if not permis:
|
|
hors.add(f"{pair}->{lport}")
|
|
|
|
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 "
|
|
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)}")
|
|
if hors:
|
|
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
|
|
+ ", ".join(sorted(hors)[:5]))
|
|
if avert:
|
|
print(f"{ouverts}/{len(resultats)} flux sortants ouverts{info} ; " + " ; ".join(avert) + f". {perf}")
|
|
sys.exit(1)
|
|
if not resultats:
|
|
print(f"Aucun flux sortant declare pour {d.get('hote', 'cet hote')} ; aucune entree hors des regles. {perf}")
|
|
sys.exit(0)
|
|
print(f"Connectivite : {ouverts}/{len(resultats)} flux sortants ouverts{info} ; aucune entree hors des regles. {perf}")
|
|
PY
|