diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3a683..234514b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index c9cfd3e..93f4bf3 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -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 | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 1c606d3..b3ed0f8 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 diff --git a/roles/serveur_dns_public/meta/flux.yml b/roles/serveur_dns_public/meta/flux.yml index 0030c73..699a297 100644 --- a/roles/serveur_dns_public/meta/flux.yml +++ b/roles/serveur_dns_public/meta/flux.yml @@ -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. diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 3198f56..f16b9c8 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index b702441..ea84119 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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 "")