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:
parent
81cdb84f66
commit
712a7da1fd
5 changed files with 133 additions and 2 deletions
33
CHANGELOG.md
33
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
|
||||
|
|
|
|||
1
Makefile
1
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
74
scripts/tests/test_sonde_tcp.py
Normal file
74
scripts/tests/test_sonde_tcp.py
Normal 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())
|
||||
Loading…
Reference in a new issue