From e9b03399b96b58ad855cc27247ffb0ec4424a5aa Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 29 Sep 2026 15:54:36 -0400 Subject: [PATCH] frontiere : NAT des locataires par leur adresse publique (devis) Sortie traduite avec l adresse attribuee par le site ; flux externe rediriges depuis cette adresse vers la machine du role. Gardes : pas de redirection en double, adresse du pool. Non applique : en attente de l accord de l exploitant. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 ++++++++++++++++ scripts/appliquer_opnsense.py | 12 +++++-- scripts/devis_opnsense.py | 59 ++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2729dc..8b946c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (36) — Le NAT des locataires par leur adresse publique (devis ; application en attente) + +**La règle** : les services publics d'un locataire passent par SON adresse publique, et +sont redirigés par la frontière ; sa sortie est traduite avec cette même adresse. + +**Ce que faisait la frontière.** Sortie : les deux locataires et le site sortaient tous par +l'adresse du WAN (`wanip`, `.62`) — vus de l'Internet, une seule machine. Entrée : seule la +redirection du site existait (DNS public 53) ; un flux `externe` de locataire (Postfix 25, +Dovecot 993) produisait une règle de filtrage vers une adresse privée, sans redirection +devant — une publication qui avait l'air faite et ne recevait rien. + +**Le devis** (`devis_opnsense.py`) : +- NAT sortant de chaque locataire vers l'adresse que le pool du site lui attribue ; le site + garde `wanip` ; sans attribution, repli sur `wanip` (et une note). +- Tout flux entrant `externe` d'un locataire — hors SSH de gestion, hors ICMP — devient une + redirection DEPUIS son adresse publique vers la machine unique qui porte le rôle (plusieurs + machines : aucune redirection, une note). La règle WAN existante voit l'adresse traduite. +- Gardes : pas deux redirections sur la même adresse/protocole/port public ; l'adresse d'une + redirection de locataire est celle que le pool lui attribue. +- `appliquer_opnsense.py` : l'adresse publique entre dans l'identité d'une redirection (les + deux locataires publient le 25) — sauf `wanip`, pour que celle du site ne soit pas recréée ; + le plan l'affiche. + +**Plan** (non appliqué) : NAT Chezlepro → `.61`, Technolibre → `.60` (2 remplacés) ; +redirections `.61`/`.60` : 25 → `edge-mta-01`, 993 → `infra-mail-01`. Le pare-feu Proxmox reçoit +déjà ces deux ports depuis `+t-internet` (entrée 33). + ## 2026-09-29 (35) — Le pool d'adresses publiques du site ; chaque locataire reçoit la sienne **La règle** : l'hébergeur détient un pool d'adresses publiques, et c'est le site qui en diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 5848a35..b045c3d 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -136,7 +136,12 @@ def cle_nat(n: dict) -> str: def cle_redirection(d: dict) -> str: """Identite d'une redirection publique : ce que l'Internet frappe, et ou ca aboutit.""" + # L'ADRESSE PUBLIQUE ENTRE DANS L'IDENTITE (2026-09-29) — deux locataires publient le + # meme port sur deux adresses. Omise pour `wanip` : la redirection du site garde sa cle, + # et n'est pas recreee (ce qui couperait son DNS public le temps de l'operation). + dest = str(d.get("destination") or "wanip") return (f"setopsrdr:{d['interface']}:{d['protocole']}:{d['port_public']}" + f"{'' if dest == 'wanip' else '@' + dest}" f"->{d['cible']}:{d['port_local']}") @@ -154,7 +159,8 @@ def _corps_redirection(d: dict, k: str) -> dict: "ipprotocol": "inet", "protocol": d["protocole"], "source": {"network": "any", "port": "", "not": "0"}, - "destination": {"network": "wanip", "port": str(d["port_public"]), "not": "0"}, + "destination": {"network": str(d.get("destination") or "wanip"), + "port": str(d["port_public"]), "not": "0"}, "target": d["cible"], "local-port": str(d["port_local"]), "natreflection": "disable", @@ -440,8 +446,10 @@ def afficher(p: dict) -> bool: print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} " f"-> {n['cible']}") for k, d in sorted(p["rdr_creer"].items()): + _pub = f"{d.get('destination') or 'wanip'}:{d['port_public']}" print(f" + REDIRECTION PUBLIQUE {d['interface']:<5} {d['protocole']:<4} " - f"{d['port_public']:<9} -> {d['cible']}:{d['port_local']} ({d.get('role', '')})") + f"{_pub:<20} -> {d['cible']}:{d['port_local']} ({d.get('tenant') or 'SITE'}, " + f"{d.get('role', '')})") for k, r in sorted(p["routes_creer"].items()): print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} " f"{r['tenant']}") diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index f8b913b..be587d0 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -550,6 +550,11 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # frontiere un alias qu'aucune regle ne reference : `appliquer_opnsense` l'aurait # cree, et personne n'aurait su a quoi il sert. + # L'ADRESSE PUBLIQUE DE CHAQUE LOCATAIRE (2026-09-29) — attribuee par le site dans son + # pool (`opnsense_ips_publiques`). Elle traduit sa sortie ET porte ses services publics. + ips_publiques = {str(qui): str(ip) for ip, qui in + (_intr.get("opnsense_ips_publiques") or {}).items() if str(qui) != "libre"} + for nom, pfx, n in tenants: etiquette = f"{pfx}{n['index']}" alias_tenant = f"SETOPS_TENANT_{etiquette}" @@ -692,6 +697,38 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "tenant": nom, "raison": fl.get("raison", ""), }) + # UN SERVICE PUBLIC D'UN LOCATAIRE EST JOINT PAR SON ADRESSE PUBLIQUE (2026-09-29). + # + # La regle WAN ci-dessus vise une adresse privee : sans redirection devant elle, + # elle ne recoit rien. La redirection part de l'adresse que le site attribue au + # locataire — jamais de celle du WAN, que tous partagent : deux locataires qui + # publient le 25 ne se disputent rien. Le SSH de gestion n'est pas publie ; l'ICMP + # n'a pas de port. Une cible, ou rien : une redirection porte UNE adresse. + if (entrant and "externe" in _pairs(fl) and not ssh_gestion + and role != ROLE_SOCLE + and str(fl.get("protocole", "tcp")).lower() in ("tcp", "udp")): + ip_pub = ips_publiques.get(nom) + if not ip_pub: + print(f"note : {nom} n'a pas d'adresse publique au pool du site — " + f"{role} ({', '.join(_ports(fl))}) n'est pas redirige.", file=sys.stderr) + elif len(cibles) != 1: + print(f"note : {role} est porte par {len(cibles)} machine(s) de {nom} — une " + f"redirection exige une cible unique ; aucune.", file=sys.stderr) + else: + _pub = fl.get("port_public") + for _pl in _ports(fl): + if not str(_pl).isdigit(): + continue + redirections.append({ + "interface": if_wan, + "protocole": fl.get("protocole", "tcp"), + "destination": ip_pub, + "port_public": int(_pub) if str(_pub or "").isdigit() else int(_pl), + "cible": cibles[0], + "port_local": int(_pl), + "role": role, + "tenant": nom, + }) intrants = intrants_frontiere() # --- LES MACHINES DU SITE ------------------------------------------------- @@ -1257,12 +1294,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # du WAN. Le mode automatique d'OPNsense ne couvre QUE les reseaux directement attaches ; # un supernet joint par route statique en sort sans avertissement. Mesure du 2026-08-06 : # le filtre laissait passer, un etat s'ouvrait sans `nat_addr`, et rien ne revenait. + # + # ET CHAQUE LOCATAIRE SORT PAR SA PROPRE ADRESSE (2026-09-29) — celle que le site lui + # attribue. Tous sortaient par l'adresse du WAN : vu de l'Internet, les deux locataires + # et le site etaient une seule machine (reputation du courriel, listes d'acces, journaux + # de ceux qu'on joint). Sans attribution, le repli reste l'adresse du WAN. nat = [ { "interface": if_wan, "source": f"SETOPS_TENANT_{pfx}{n['index']}", "destination": "any", - "cible": "wanip", + "cible": ips_publiques.get(nom, "wanip"), "tenant": nom, } for nom, pfx, n in tenants @@ -1850,6 +1892,21 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: f"{d['port_local']} sans regle WAN correspondante.") if not ipaddress.ip_address(d["cible"]).is_private: erreurs.append(f"Redirection vers {d['cible']} : une cible de redirection est interne.") + # UNE ADRESSE, UN PROTOCOLE, UN PORT PUBLIC : UNE SEULE DESTINATION. Deux redirections + # identiques cote Internet, la premiere gagnerait sans que rien ne le dise. + _vues: dict[tuple, str] = {} + for d in devis.get("redirections") or []: + c = (d.get("destination", "wanip"), d["protocole"], str(d["port_public"])) + if c in _vues: + erreurs.append(f"Redirection en double sur {c[0]} {c[1]}/{c[2]} : " + f"{_vues[c]} et {d['cible']}:{d['port_local']}.") + _vues[c] = f"{d['cible']}:{d['port_local']}" + # L'ADRESSE D'UN LOCATAIRE EST LA SIENNE : celle que le pool du site lui attribue. + _pool = {str(ip): str(q) for ip, q in (intrants_frontiere().get("opnsense_ips_publiques") or {}).items()} + for d in devis.get("redirections") or []: + if d.get("tenant") and _pool.get(str(d.get("destination"))) != d["tenant"]: + erreurs.append(f"Redirection de {d['tenant']} depuis {d.get('destination')} : " + f"le pool du site ne lui attribue pas cette adresse.") # UN TUNNEL DE LOCATAIRE NE VOIT QUE SON LOCATAIRE (2026-09-17). #