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 # 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 ## 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 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. | | 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. | | 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. | | 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). | | 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 | | 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. | | 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. | | 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. | | 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). | | 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)). | | 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). | | 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). | | 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. | | 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). | | 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 | | 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. | | 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). | | 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 | | 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). | | 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. | | 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). | | 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 280 regles `pass`), tous non consignes et tous motives. | | 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 | | 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. | | 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 | | 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)). | | 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. | | 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. | | 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. | | 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). | | 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 | | 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_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 | 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 | 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 | 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_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). | | `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 ## Synthèse chiffrement
- **clair** : 47 flux - **clair** : 49 flux
- **n-a** : 8 flux - **n-a** : 8 flux
- **ssh** : 8 flux - **ssh** : 8 flux
- **starttls** : 6 flux - **starttls** : 6 flux

View file

@ -1,11 +1,31 @@
--- ---
# Flux reseau du serveur DNS public du site. Voir docs/flux-conception.md. # 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 # PHASE 2 (2026-09-16) : L'INTERNET ENTRE, ET SEULEMENT PAR LE FRONTAL. Declare APRES que
# redirection de la frontiere, APRES DNSSEC et la limitation de debit. Le declarer ici # toutes les zones sont signees DNSSEC (P82 le refuse sinon) et que dnsdist borne le debit.
# aujourd'hui ferait emettre au devis une regle sur le WAN — une exposition que personne
# n'aurait decidee.
flux: 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 ; # 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 # le site tire alors la nouvelle version. Sans ce flux, la replique attendrait le
# rafraichissement du SOA — une heure de retard sur chaque changement. # 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. d'OPNsense ne peut la refuser, alors que le JSON depend du boitier.
""" """
champs: dict[str, str] = {} champs: dict[str, str] = {}
for racine, valeurs in corps.items():
if isinstance(valeurs, dict): # IMBRIQUE A TOUTE PROFONDEUR (2026-09-16). La redirection d'OPNsense 26.7 porte
for k, v in valeurs.items(): # `rule[destination][network]` : un seul niveau aurait envoye le dictionnaire comme
champs[f"{racine}[{k}]"] = "" if v is None else str(v) # 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: 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() return urllib.parse.urlencode(champs).encode()
def __call__(self, chemin: str, corps: dict | None = None) -> dict: 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']}" 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: def cle_route(r: dict) -> str:
"""Identite d'une route statique, meme principe que `cle_regle`. """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:"): if d.startswith("setopsnat:"):
nat_poses[d.split(" — ")[0]] = x 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_voulues = {cle_route(r): r for r in devis.get("routes") or []}
rt_posees = {} rt_posees = {}
for x in (_lire(api, "/api/routes/routes/searchroute/", 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_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_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}, "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_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_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}, "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()): for k, n in sorted(p["nat_creer"].items()):
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} " print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
f"-> {n['cible']}") 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()): for k, r in sorted(p["routes_creer"].items()):
print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} " print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} "
f"{r['tenant']}") f"{r['tenant']}")
@ -401,6 +452,8 @@ def afficher(p: dict) -> bool:
for k, x in sorted(p["nat_retirer"].items()): for k, x in sorted(p["nat_retirer"].items()):
print(f" - NAT PERIME {str(x.get('interface')):<5} " print(f" - NAT PERIME {str(x.get('interface')):<5} "
f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('target'))}") 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()): for k, x in sorted(p["routes_retirer"].items()):
print(f" - route PERIMEE {str(x.get('network')):<18} " print(f" - route PERIMEE {str(x.get('network')):<18} "
f"-> {str(x.get('gateway'))}") 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 " print(" Les reseaux ci-dessus ne sont PAS routes. Appliquer force leur "
"reinstallation.") "reinstallation.")
creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) \ 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"]) \ 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}" 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") f" + {len(p['routes_garder'])} routes")
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
"regles_creer", "regles_retirer", "regles_creer", "regles_retirer",
"nat_creer", "nat_retirer", "nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
"routes_creer", "routes_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}") _fait(api("/api/firewall/filter/add_rule/", {"rule": _corps_regle(r, k)}), f"regle {k}")
for k, n in sorted(p["nat_creer"].items()): 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}") _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 # 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 # 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}") _fait(api(f"/api/firewall/filter/del_rule/{x['uuid']}", {}), f"retrait {k}")
for k, x in sorted(p["nat_retirer"].items()): for k, x in sorted(p["nat_retirer"].items()):
_fait(api(f"/api/firewall/source_nat/del_rule/{x['uuid']}", {}), f"retrait nat {k}") _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()): for n, x in sorted(p["alias_retirer"].items()):
_fait(api(f"/api/firewall/alias/delItem/{x['uuid']}", {}), f"retrait alias {n}") _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(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?"))
print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip()) print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip())
print(" nat :", str(api("/api/firewall/source_nat/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 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 # frontiere route deja vers eux tous (section 2). N'en filtrer qu'un laisserait les
# autres routes menant a un `block` muet. # autres routes menant a un `block` muet.
regles: list[dict] = [] 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_inventaire: list[str] = []
tenants_sans_admin: list[str] = [] tenants_sans_admin: list[str] = []
# PAS D'ALIAS POUR LA SOURCE DE L'INSEMINATION ICI. La regle est emise du cote SITE # 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] _portees += [(nom_alias(_ap, "SITE"), z) for z in _zones_ap]
if not _portees: if not _portees:
continue 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: elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur continue # rien d'autre n'entre chez le site depuis l'exterieur
else: else:
@ -1345,6 +1381,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"alias": alias, "alias": alias,
"routes": routes, "routes": routes,
"nat": nat, "nat": nat,
"redirections": redirections,
"regles": regles, "regles": regles,
"admin": admin, "admin": admin,
"tenants_sans_inventaire": tenants_sans_inventaire, "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." "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 [] gestion = devis.get("reseaux_gestion") or []
for r in devis["regles"]: for r in devis["regles"]:
src = str(r.get("source") or "") src = str(r.get("source") or "")