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
|
# 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
|
## 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
|
**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_empreinte_plancher.py
|
||||||
python3 scripts/tests/test_expositions_sans_port.py
|
python3 scripts/tests/test_expositions_sans_port.py
|
||||||
python3 scripts/tests/test_proxmox_fw_externe.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
|
# 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
|
# 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 role, flux in sorted(flux_par_role.items())
|
||||||
for fl in flux
|
for fl in flux
|
||||||
if ("externe" in _pairs(fl) or "voisins_site" in _pairs(fl)
|
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"))))
|
retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port"))))
|
||||||
return retenus
|
return retenus
|
||||||
|
|
@ -580,7 +586,15 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
continue
|
continue
|
||||||
# Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet :
|
# 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 ».
|
# 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):
|
if ssh_gestion and not admin_par_tenant.get(etiquette):
|
||||||
# Sans reseau d'administration declare, la regle n'aurait pas de source :
|
# 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.
|
# 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:
|
if data:
|
||||||
return {"verdict": "LIVRE", "ms": ms,
|
return {"verdict": "LIVRE", "ms": ms,
|
||||||
"preuve": data.decode("utf-8", "replace").strip()[:60]}
|
"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):
|
except (TimeoutError, socket.timeout, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
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