frontiere : publier un service du site, port public et redirection

Un flux externe porte port (la machine) et port_public (l'Internet). Le devis emet la
redirection et sa regle WAN, garde qu'elles aillent ensemble ; l'applicateur reconcilie
d_nat et encode les champs imbriques. DNS public : WAN:53 -> site-dnspub-01:1053 (dnsdist).
Plan lu : 4 objets a creer, rien d'autre ; pas encore applique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 22:38:39 -04:00
parent 53cfe71c7e
commit c1d0d9b1a7
6 changed files with 181 additions and 20 deletions

View file

@ -1,5 +1,30 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (10) — Publier un service du site sur l'Internet : la redirection devient une capacite
Phase 2, quatrieme volet. Le devis du site sautait tout flux entrant `externe` (« rien d'autre
n'entre chez le site depuis l'exterieur ») : aucun service du site ne pouvait etre publie
autrement qu'a la main.
- **Un flux porte deux ports** : `port`, ce que la machine ecoute, et `port_public`, ce que
l'Internet frappe. `serveur_dns_public` declare `WAN:53 -> 1053` (UDP et TCP) : le 53 public
aboutit au frontal dnsdist, jamais a PowerDNS.
- **`devis_opnsense`** emet une `redirection` et sa regle WAN (source `any`, destination la
machine, port local). Une cible unique ou rien ; pas de `port_public`, pas de publication
(une note le dit). La garde refuse une redirection sans regle, ou vers une cible publique.
- **`appliquer_opnsense`** reconcilie les redirections (`d_nat`, identite `setopsrdr:`), sans
regle associee creee par le boitier, reflexion NAT desactivee, et les charge
(`d_nat/apply`). Le formulaire encode desormais les champs imbriques
(`rule[destination][network]`) — un seul niveau aurait envoye une destination vide.
- **Pare-feu d'hote** de `site-dnspub-01` regenere et pose : `1053` ouvert a tous, `53` reste
limite aux supernets des locataires.
Plan de la frontiere, lu et non applique : 4 objets a creer (2 regles, 2 redirections),
286 inchanges, rien a retirer. `make prouver` : CONFORME, 82 OK.
NON APPLIQUE : l'ecriture sur la frontiere attend le geste de l'exploitant
(`CONFIRMER=true python3 scripts/appliquer_opnsense.py`).
## 2026-09-16 (9) — Un frontal devant le serveur public : dnsdist borne ce qu'on demande
Phase 2, troisieme volet. Un autoritatif signe DNSSEC expose sans frontal est un

View file

@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 117 flux, schéma + matrice OK. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -45,7 +45,7 @@
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 70 scripts expliques et atteignables, 131 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 40 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 42 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (42 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
@ -55,14 +55,14 @@
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 66 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 190 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 192 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 97 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (135 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 280 regles `pass`), tous non consignes et tous motives. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 282 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
@ -88,7 +88,7 @@
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 9 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 162 gabarits de role : tous se rendent. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 163 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen |

View file

@ -37,6 +37,8 @@
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
| `serveur_dns_public` | ingress | 1053 | udp | externe | clair | Les resolveurs de l'Internet interrogent les zones publiques des locataires du site. Les reponses sont signees DNSSEC : leur integrite ne depend ni du transport, ni de nous. |
| `serveur_dns_public` | ingress | 1053 | tcp | externe | clair | Repli TCP : reponses tronquees par dnsdist (ANY, debit) et reponses signees trop grosses pour l'UDP. |
| `serveur_dns_public` | egress | 5300 | tcp | primaires_dns_locataires | clair | AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne chiffre pas — et une zone publique n'a rien a cacher. |
| `serveur_dns_public` | egress | 5300 | udp | primaires_dns_locataires | clair | SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change. Sans lui, le secondaire garde sa premiere copie pour toujours. |
| `serveur_dovecot` | ingress | 24 | tcp | serveur_postfix | tls-requis | Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify). |
@ -126,7 +128,7 @@
## Synthèse chiffrement
- **clair** : 47 flux
- **clair** : 49 flux
- **n-a** : 8 flux
- **ssh** : 8 flux
- **starttls** : 6 flux

View file

@ -1,11 +1,31 @@
---
# Flux reseau du serveur DNS public du site. Voir docs/flux-conception.md.
#
# PHASE 1 : AUCUN FLUX DEPUIS L'INTERNET. Le port 53 public viendra en phase 2, avec la
# redirection de la frontiere, APRES DNSSEC et la limitation de debit. Le declarer ici
# aujourd'hui ferait emettre au devis une regle sur le WAN — une exposition que personne
# n'aurait decidee.
# PHASE 2 (2026-09-16) : L'INTERNET ENTRE, ET SEULEMENT PAR LE FRONTAL. Declare APRES que
# toutes les zones sont signees DNSSEC (P82 le refuse sinon) et que dnsdist borne le debit.
flux:
# LE 53 PUBLIC. La frontiere redirige `WAN:53` (`port_public`) vers le frontal dnsdist
# (`port`, 1053) ; PowerDNS n'est jamais joint depuis dehors. Le 53 de la machine reste
# celui des primaires et du site.
- sens: ingress
port: 1053
port_public: 53
protocole: udp
pair: externe
chiffrement: clair
raison: >-
Les resolveurs de l'Internet interrogent les zones publiques des locataires du site. Les
reponses sont signees DNSSEC : leur integrite ne depend ni du transport, ni de nous.
- sens: ingress
port: 1053
port_public: 53
protocole: tcp
pair: externe
chiffrement: clair
raison: >-
Repli TCP : reponses tronquees par dnsdist (ANY, debit) et reponses signees trop grosses
pour l'UDP.
# LES NOTIFICATIONS DES PRIMAIRES. Un locataire previent le site qu'une zone a change ;
# le site tire alors la nouvelle version. Sans ce flux, la replique attendrait le
# rafraichissement du SOA — une heure de retard sur chaque changement.

View file

@ -78,12 +78,19 @@ class Frontiere:
d'OPNsense ne peut la refuser, alors que le JSON depend du boitier.
"""
champs: dict[str, str] = {}
for racine, valeurs in corps.items():
if isinstance(valeurs, dict):
for k, v in valeurs.items():
champs[f"{racine}[{k}]"] = "" if v is None else str(v)
# IMBRIQUE A TOUTE PROFONDEUR (2026-09-16). La redirection d'OPNsense 26.7 porte
# `rule[destination][network]` : un seul niveau aurait envoye le dictionnaire comme
# texte, et le boitier aurait garde une destination vide.
def poser(prefixe: str, valeur) -> None:
if isinstance(valeur, dict):
for k, v in valeur.items():
poser(f"{prefixe}[{k}]", v)
else:
champs[racine] = "" if valeurs is None else str(valeurs)
champs[prefixe] = "" if valeur is None else str(valeur)
for racine, valeurs in corps.items():
poser(racine, valeurs)
return urllib.parse.urlencode(champs).encode()
def __call__(self, chemin: str, corps: dict | None = None) -> dict:
@ -127,6 +134,36 @@ def cle_nat(n: dict) -> str:
return f"setopsnat:{n['tenant']}:{n['interface']}:{n['source']}->{n['destination']}:{n['cible']}"
def cle_redirection(d: dict) -> str:
"""Identite d'une redirection publique : ce que l'Internet frappe, et ou ca aboutit."""
return (f"setopsrdr:{d['interface']}:{d['protocole']}:{d['port_public']}"
f"->{d['cible']}:{d['port_local']}")
def _corps_redirection(d: dict, k: str) -> dict:
"""Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16.
`pass: ""` : AUCUNE regle associee. La regle de filtrage vient du devis, comme toutes les
autres — une regle creee en douce par le boitier n'aurait ni identite ni retrait.
`natreflection: disable` : l'interieur n'a aucune raison de passer par l'adresse publique
pour joindre un serveur qu'il atteint directement.
"""
return {
"disabled": "0",
"interface": d["interface"],
"ipprotocol": "inet",
"protocol": d["protocole"],
"source": {"network": "any", "port": "", "not": "0"},
"destination": {"network": "wanip", "port": str(d["port_public"]), "not": "0"},
"target": d["cible"],
"local-port": str(d["port_local"]),
"natreflection": "disable",
"pass": "",
"log": "0",
"descr": f"{k} — {d.get('role', '')}"[:255],
}
def cle_route(r: dict) -> str:
"""Identite d'une route statique, meme principe que `cle_regle`.
@ -340,6 +377,14 @@ def plan(api: Frontiere, devis: dict) -> dict:
if d.startswith("setopsnat:"):
nat_poses[d.split(" — ")[0]] = x
rdr_voulues = {cle_redirection(d): d for d in devis.get("redirections") or []}
rdr_posees = {}
for x in (_lire(api, "/api/firewall/d_nat/search_rule/",
{"current": 1, "rowCount": 1000}).get("rows") or []):
d = str(x.get("descr") or x.get("description") or "")
if d.startswith("setopsrdr:"):
rdr_posees[d.split(" — ")[0]] = x
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
rt_posees = {}
for x in (_lire(api, "/api/routes/routes/searchroute/",
@ -364,6 +409,9 @@ def plan(api: Frontiere, devis: dict) -> dict:
"routes_creer": {k: r for k, r in rt_voulues.items() if k not in rt_posees},
"routes_garder": {k for k in rt_voulues if k in rt_posees},
"routes_retirer": {k: x for k, x in rt_posees.items() if k not in rt_voulues},
"rdr_creer": {k: d for k, d in rdr_voulues.items() if k not in rdr_posees},
"rdr_garder": {k for k in rdr_voulues if k in rdr_posees},
"rdr_retirer": {k: x for k, x in rdr_posees.items() if k not in rdr_voulues},
"nat_creer": {k: n for k, n in nat_voulus.items() if k not in nat_poses},
"nat_garder": {k for k in nat_voulus if k in nat_poses},
"nat_retirer": {k: x for k, x in nat_poses.items() if k not in nat_voulus},
@ -391,6 +439,9 @@ def afficher(p: dict) -> bool:
for k, n in sorted(p["nat_creer"].items()):
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
f"-> {n['cible']}")
for k, d in sorted(p["rdr_creer"].items()):
print(f" + REDIRECTION PUBLIQUE {d['interface']:<5} {d['protocole']:<4} "
f"{d['port_public']:<9} -> {d['cible']}:{d['port_local']} ({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']}")
@ -401,6 +452,8 @@ def afficher(p: dict) -> bool:
for k, x in sorted(p["nat_retirer"].items()):
print(f" - NAT PERIME {str(x.get('interface')):<5} "
f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('target'))}")
for k, x in sorted(p["rdr_retirer"].items()):
print(f" - REDIRECTION PERIMEE {k}")
for k, x in sorted(p["routes_retirer"].items()):
print(f" - route PERIMEE {str(x.get('network')):<18} "
f"-> {str(x.get('gateway'))}")
@ -430,15 +483,15 @@ def afficher(p: dict) -> bool:
print(" Les reseaux ci-dessus ne sont PAS routes. Appliquer force leur "
"reinstallation.")
creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) \
+ len(p["routes_creer"])
+ len(p["routes_creer"]) + len(p["rdr_creer"])
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
+ len(p["routes_retirer"])
+ len(p["routes_retirer"]) + len(p["rdr_retirer"])
print(f"\n a creer : {creer} | a retirer : {retirer}"
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder'])}"
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder'])}"
f" + {len(p['routes_garder'])} routes")
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
"regles_creer", "regles_retirer",
"nat_creer", "nat_retirer",
"nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
"routes_creer", "routes_retirer"))
@ -473,6 +526,12 @@ def appliquer(api: Frontiere, p: dict) -> int:
_fait(api("/api/firewall/filter/add_rule/", {"rule": _corps_regle(r, k)}), f"regle {k}")
for k, n in sorted(p["nat_creer"].items()):
_fait(api("/api/firewall/source_nat/add_rule/", {"rule": _corps_nat(n, k)}), f"nat {k}")
# LA REDIRECTION APRES SA REGLE DE FILTRAGE : redirigee sans regle, la requete serait
# jetee ; la regle sans redirection ne correspond a rien. Le premier ordre ne laisse
# jamais passer ce que le devis n'a pas dit.
for k, d in sorted(p["rdr_creer"].items()):
_fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}),
f"redirection {k}")
# 2bis. Routes, meme ordre et pour une raison plus forte encore : une route
# manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un
@ -493,6 +552,8 @@ def appliquer(api: Frontiere, p: dict) -> int:
_fait(api(f"/api/firewall/filter/del_rule/{x['uuid']}", {}), f"retrait {k}")
for k, x in sorted(p["nat_retirer"].items()):
_fait(api(f"/api/firewall/source_nat/del_rule/{x['uuid']}", {}), f"retrait nat {k}")
for k, x in sorted(p["rdr_retirer"].items()):
_fait(api(f"/api/firewall/d_nat/del_rule/{x['uuid']}", {}), f"retrait redirection {k}")
for n, x in sorted(p["alias_retirer"].items()):
_fait(api(f"/api/firewall/alias/delItem/{x['uuid']}", {}), f"retrait alias {n}")
@ -521,6 +582,8 @@ def _charger(api: Frontiere, p: dict) -> int:
print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?"))
print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip())
print(" nat :", str(api("/api/firewall/source_nat/apply/", {}).get("status", "?")).strip())
if p.get("rdr_creer") or p.get("rdr_retirer") or p.get("rdr_garder"):
print(" rdr :", str(api("/api/firewall/d_nat/apply/", {}).get("status", "?")).strip())
return 0

View file

@ -517,6 +517,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# frontiere route deja vers eux tous (section 2). N'en filtrer qu'un laisserait les
# autres routes menant a un `block` muet.
regles: list[dict] = []
# Ce que l'Internet frappe sur l'adresse publique, et ou la frontiere le renvoie.
redirections: list[dict] = []
tenants_sans_inventaire: list[str] = []
tenants_sans_admin: list[str] = []
# PAS D'ALIAS POUR LA SOURCE DE L'INSEMINATION ICI. La regle est emise du cote SITE
@ -969,6 +971,40 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_portees += [(nom_alias(_ap, "SITE"), z) for z in _zones_ap]
if not _portees:
continue
elif _entrant and "externe" in _pairs_fl:
# UN SERVICE DU SITE PUBLIE SUR L'INTERNET (2026-09-16, DNS public).
#
# L'adresse publique est celle de la frontiere : sans redirection, un
# filtre sur le WAN vers une adresse privee ne correspond a rien. Le
# role dit DEUX ports — `port_public`, ce que l'Internet frappe, et
# `port`, ce que la machine ecoute — parce qu'ils different : le 53
# public aboutit au frontal dnsdist (1053), jamais a PowerDNS.
#
# UNE CIBLE, OU RIEN. Une redirection porte une adresse ; deux machines
# portant le role rendraient un choix que personne n'a fait.
_pub = _fl.get("port_public")
if not str(_pub or "").isdigit():
print(f"note : {_role} declare une entree `externe` sans "
f"`port_public` — aucune redirection, aucune regle.",
file=sys.stderr)
continue
if len(_ips) != 1:
print(f"note : {_role} est porte par {len(_ips)} machine(s) du site "
f"— une redirection publique exige une cible unique ; aucune "
f"regle emise.", file=sys.stderr)
continue
for _pl in _ports(_fl):
redirections.append({
"interface": if_wan,
"protocole": _fl.get("protocole", "tcp"),
"port_public": int(_pub),
"cible": _ips[0],
"port_local": int(_pl),
"role": _role,
})
# Le filtre voit le paquet APRES traduction : destination = la machine,
# port = celui qu'elle ecoute. Source : tout l'Internet.
_portees = [("any", if_wan)]
elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur
else:
@ -1345,6 +1381,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"alias": alias,
"routes": routes,
"nat": nat,
"redirections": redirections,
"regles": regles,
"admin": admin,
"tenants_sans_inventaire": tenants_sans_inventaire,
@ -1590,6 +1627,20 @@ def verifier(devis: dict) -> tuple[bool, list[str]]:
"acheminerait des paquets que le filtre refuserait."
)
# UNE REDIRECTION ET SA REGLE VONT ENSEMBLE. Redirigee sans regle, la requete est jetee
# apres traduction ; la regle sans redirection vise une adresse privee que l'Internet ne
# peut pas nommer. L'une sans l'autre est une publication qui a l'air faite.
wan = devis.get("if_wan")
for d in devis.get("redirections") or []:
if not any(r["interface"] == wan and r["sens"] == "in" and r["source"] == "any"
and r["protocole"] == d["protocole"] and str(d["port_local"]) in map(str, r["ports"])
and d["cible"] in ((devis["alias"].get(r["destination"]) or {}).get("contenu") or [])
for r in devis["regles"]):
erreurs.append(f"Redirection {d['protocole']}/{d['port_public']} -> {d['cible']}:"
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.")
gestion = devis.get("reseaux_gestion") or []
for r in devis["regles"]:
src = str(r.get("source") or "")