#!/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/.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=` 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