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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 15:54:36 -04:00
parent e84b155a5f
commit e9b03399b9
3 changed files with 95 additions and 3 deletions

View file

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

View file

@ -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']}")

View file

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