diff --git a/CHANGELOG.md b/CHANGELOG.md index d03e026..30f2c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (34) — La frontière appliquée : l'edge hors de l'Internet, l'administration traduite chez les tenants + +**Un premier plan refusé.** Retirer `externe` de l'edge faisait aussi disparaître, à la +frontière, les chemins gestion → edge et VPN → edge des locataires : chez un locataire, le +devis ne rendait le chemin du poste qu'au travers des flux `externe` (option `poste`). Les +flux `admin`, appliqués par Proxmox et nftables, n'y étaient pas traduits — ils l'étaient au +site. Le devis les traduit désormais chez les locataires aussi, par les mêmes portes que le +SSH de gestion (gestion, WAN d'administration s'il est déclaré, VPN) ; un port non +numérique (`derive`) est sauté avec une note plutôt que rendu en règle invalide. + +**Appliqué** (plan relu avant) : 4 règles retirées — « WAN, n'importe qui → edge 80/443 » +des deux locataires ; 18 ajouts — gestion/VPN → edge du site en 80, et gestion/VPN → Grafana +3000, Icinga Web 8080, console 8090 des locataires (déclarés `admin` par leurs rôles, déjà +permis par Proxmox et nftables ; Icinga Web et la console n'écoutent qu'en local chez les +locataires). Convergé : 0 écart, 293 règles. Depuis le poste : edges des deux locataires et du +site en 443 (302) et 80 (301), Dovecot 993 en TLS 1.3. La route du poste vers le site +(`10.37.0.0/16 via 10.37.0.1`), ajoutée par l'exploitant, ferme la régression notée en (33). + +**La mesure de la frontière accusait l'edge à tort.** `frontiere-mesurer` rendait +`infra-edge-01:80` « rien n'est livré : l'hôte refuse ». C'était le serveur par défaut de +nginx, qui ferme sans rien dire un nom inconnu (`return 444`) — la sonde envoie `Host: +sonde`. Mesuré : l'edge ferme en 0,00 s (FIN, lecture vide) ; le contrôle et un port bloqué +n'aboutissent pas (délai). `sonde_tcp.py` compte désormais une fermeture propre après la +requête comme une réponse de la destination ; le silence reste AMBIGU, et le contrôle garde +l'instrument (`test_sonde_tcp.py` : parle, lit-puis-ferme, muet). Mesure conforme chez les +deux locataires. Au passage : la mesure écrit toujours dans `instance/` — mesurer un autre +locataire que celui monté écrase le relevé du premier. + +**Architecture précisée par l'exploitant**, pour la suite : l'edge expose les services +internes à l'administration ; les services publics d'un locataire le seront par son web +frontal, avec des certificats Let's Encrypt ; le site attribue à chaque locataire une IP +publique de son pool. + ## 2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer **La règle, mise au point par l'exploitant** : les URL `*.internal` des edges ne s'ouvrent diff --git a/Makefile b/Makefile index 5d9be91..015344c 100644 --- a/Makefile +++ b/Makefile @@ -320,6 +320,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_empreinte_plancher.py python3 scripts/tests/test_expositions_sans_port.py python3 scripts/tests/test_proxmox_fw_externe.py + python3 scripts/tests/test_sonde_tcp.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index efacb9a..f8b913b 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -260,7 +260,13 @@ def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict for role, flux in sorted(flux_par_role.items()) for fl in flux if ("externe" in _pairs(fl) or "voisins_site" in _pairs(fl) - or "frontiere" in _pairs(fl)) + or "frontiere" in _pairs(fl) + # `admin` AUSSI (2026-09-29). Le poste de l'exploitant est sur le plan de + # gestion, derriere la frontiere : un flux `admin` la traverse. Le devis ne le + # rendait qu'au travers des flux `externe` (option `poste`) — si bien qu'en + # retirant `externe` de l'edge, le chemin du poste vers les interfaces des + # locataires disparaissait avec lui. Proxmox et nftables, eux, l'appliquaient. + or (fl.get("sens") == "ingress" and "admin" in _pairs(fl))) ] retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port")))) return retenus @@ -580,7 +586,15 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: continue # Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet : # sa `raison` dit « l'acces depuis l'exterieur est filtre a l'OPNsense ». - ssh_gestion = entrant and "22" in _ports(fl) + # LE CHEMIN DE L'ADMINISTRATION : le SSH de gestion, et tout flux `admin`. Memes + # portes (gestion, WAN d'administration, VPN), memes alias. + par_admin = entrant and "admin" in _pairs(fl) and "externe" not in _pairs(fl) + if par_admin and not all(p.isdigit() for p in _ports(fl)): + print(f"note : {role} declare un flux `admin` sur un port non numerique " + f"({', '.join(_ports(fl))}) — aucune regle a la frontiere de {nom}.", + file=sys.stderr) + continue + ssh_gestion = entrant and ("22" in _ports(fl) or par_admin) if ssh_gestion and not admin_par_tenant.get(etiquette): # Sans reseau d'administration declare, la regle n'aurait pas de source : # l'omettre ferme l'acces, l'ouvrir a `any` exposerait le SSH a Internet. diff --git a/scripts/sonde_tcp.py b/scripts/sonde_tcp.py index 11db912..a3750cb 100644 --- a/scripts/sonde_tcp.py +++ b/scripts/sonde_tcp.py @@ -71,6 +71,15 @@ def _sonder(hote: str, port: int) -> dict: if data: return {"verdict": "LIVRE", "ms": ms, "preuve": data.decode("utf-8", "replace").strip()[:60]} + # UNE FERMETURE PROPRE EST UNE REPONSE (2026-09-29). `recv` rend b'' : la + # destination a LU la requete et ferme (FIN) — c'est le serveur par defaut de + # nginx, qui refuse un nom inconnu (`return 444`). Une frontiere qui repond a + # la poignee sans relayer ne ferme pas ainsi : rien ne revient, le delai + # expire (mesure du jour : 0,00 s pour l'edge, 4 s pour le controle). Le + # controle reste le garde-fou : s'il « fermait » aussi, il serait LIVRE et le + # releve entier serait declare nul. + return {"verdict": "LIVRE", "ms": ms, + "preuve": "requete lue, connexion fermee par la destination (refus applicatif)"} except (TimeoutError, socket.timeout, OSError): pass finally: diff --git a/scripts/tests/test_sonde_tcp.py b/scripts/tests/test_sonde_tcp.py new file mode 100644 index 0000000..c24271d --- /dev/null +++ b/scripts/tests/test_sonde_tcp.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""La sonde TCP distingue une destination qui PARLE, qui REFUSE en fermant, et le SILENCE. + +POURQUOI (2026-09-29). Le serveur par defaut de l'edge ferme sans rien dire un nom inconnu +(`return 444`). La sonde de `frontiere-mesurer` y lisait « rien n'est livre » et accusait +l'hote de refuser ce que la frontiere autorise. Or une fermeture propre (FIN) apres la +requete est une REPONSE de la destination ; le silence, lui — ce que rend une frontiere +qui repond a la poignee sans relayer — doit rester AMBIGU. Confondre les deux dans l'autre +sens rouvrirait le piege du `synproxy` (voir l'en-tete de `sonde_tcp.py`). +""" +from __future__ import annotations + +import socket +import sys +import threading +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import sonde_tcp # noqa: E402 + +sonde_tcp.DELAI_CONNEXION = 1.0 +sonde_tcp.DELAI_LECTURE = 0.5 + + +def serveur(comportement) -> int: + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(4) + + def boucle(): + while True: + try: + c, _ = srv.accept() + except OSError: + return + threading.Thread(target=comportement, args=(c,), daemon=True).start() + threading.Thread(target=boucle, daemon=True).start() + return srv.getsockname()[1] + + +def parle(c): + c.sendall(b"SSH-2.0-test\r\n") + c.close() + + +def lit_puis_ferme(c): + c.settimeout(2) + try: + c.recv(256) + except OSError: + pass + c.close() + + +def muet(c): + threading.Event().wait(5) # accepte, ne dit rien, ne ferme pas + + +def main() -> int: + attendu = {"parle": "LIVRE", "lit_puis_ferme": "LIVRE", "muet": "AMBIGU"} + echecs = 0 + for nom, f in (("parle", parle), ("lit_puis_ferme", lit_puis_ferme), ("muet", muet)): + v = sonde_tcp._sonder("127.0.0.1", serveur(f)) + if v["verdict"] != attendu[nom]: + print(f"ECHEC {nom} : attendu {attendu[nom]}, obtenu {v}") + echecs += 1 + if echecs: + return 1 + print("OK — parle : LIVRE ; lit puis ferme : LIVRE ; muet : AMBIGU.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())