frontiere : les flux admin traduits chez les tenants ; edge retire de l Internet

Le devis OPNsense ne rendait le chemin du poste chez un tenant qu au travers des flux
externe ; il traduit desormais les flux admin (gestion, VPN). Applique : 4 retraits
(WAN -> edges), 18 ajouts, 0 ecart. sonde_tcp : une fermeture propre apres la requete
est une reponse de la destination ; la mesure de la frontiere est conforme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 15:05:23 -04:00
parent 81cdb84f66
commit 712a7da1fd
5 changed files with 133 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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())