Set-OPS-Public/roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Daniel Allaire 6a8fc53084 frontiere : les interfaces internes refusent a voix haute, le WAN reste muet
Un reject final, journalise, sur chaque interface interne (zones du site,
gestion, transit, WireGuard) : un flux mal declare entre deux zones echoue
au lieu d'expirer. P53 affinee, P50 ne compte que les block, test du devis.
La sonde classe ECONNREFUSED en avertissement : le RST de la frontiere est
indiscernable d'un port ferme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:39:46 -04:00

154 lines
6.6 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.
#
# Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif.
exec python3 - "${CONNECTIVITE_JSON:-/usr/local/lib/setops/connectivite.json}" <<'PY'
import concurrent.futures, errno, ipaddress, json, socket, subprocess, sys
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")]
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 hors_regles={len(hors)};;;0"
if coupes:
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
sys.exit(2)
avert = []
info = ""
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