l edge publie : trois noms en TLS verifie de bout en bout

expositions n etait resolu nulle part — ni par le pare-feu d hote, qui ne filtre
pas l egress, ni par le devis de frontiere. Il n avait jamais eu besoin de
l etre : chez un locataire le trafic edge vers amont ne traverse pas le boitier.
Au site il le traverse. Une declaration peut dormir des mois avant que la
premiere fabric ne la reveille.

Une regle par amont, avec son port. Jamais une regle large : ouvrir l edge sur
tout le site annulerait ce qu une zone de publication separee cherche a obtenir.

La meme faute qu hier, avalee de la meme facon : U au lieu de underlay_mod, et
un except Exception large a transforme le NameError en note plausible. Resserre
a OSError/ValueError — une faute de frappe doit faire du bruit.

Le schema de proxy_pass suit maintenant le port, et l amont TLS est VERIFIE
contre l AC interne : relayer en TLS sans verifier ne fait que deplacer la
confiance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-15 07:50:21 -04:00
parent fae3cc8838
commit 95dc225037
5 changed files with 264 additions and 1 deletions

View file

@ -1,5 +1,56 @@
# CHANGELOG — Set-OPS
## 2026-09-15 (1) — L'edge publie : trois noms, en TLS verifie de bout en bout
observatoire.genese.internal http=302 tls=0 (Grafana, vers sa connexion)
vigie.genese.internal http=401 tls=0 (le vestibule tient)
forge.genese.internal http=200 tls=0
`tls=0` : verification reelle contre la racine du site, pas un `-k` complaisant.
### `expositions` n'etait resolu nulle part
`serveur_nginx` declare `egress port: derive, pair: expositions` — « je sors vers ce que
je publie ». Ce mot n'etait consomme par AUCUN moteur : ni le pare-feu d'hote, qui ne
filtre pas l'egress, ni le devis de frontiere, qui ne le connaissait pas.
IL N'AVAIT JAMAIS EU BESOIN DE L'ETRE. Chez un locataire, les passerelles de zone sont
tenues par le SDN de Proxmox : le trafic edge -> amont ne traverse pas le boitier. Au
SITE, elles sont tenues par la frontiere — premier endroit ou un edge doit franchir la
bordure pour atteindre ce qu'il relaie. Une declaration peut dormir des mois avant que la
premiere fabric ne la reveille.
UNE REGLE PAR AMONT, avec SON port :
opt10 tcp 443 SERVEUR_NGINX -> SERVEUR_FORGEJO
opt10 tcp 3000 SERVEUR_NGINX -> SERVEUR_GRAFANA
opt10 tcp 8080 SERVEUR_NGINX -> SERVEUR_ICINGAWEB2
Jamais une regle large. Un edge qui publie trois services ne doit joindre que ces
trois-la : l'ouvrir sur « tout le site » annulerait ce qu'une zone de publication separee
cherche a obtenir.
### LA MEME FAUTE QU'HIER, AVALEE DE LA MEME FACON
Premiere ecriture de cette resolution : `U.lire_plan_site(...)` dans un fichier ou le
module s'importe sous `underlay_mod`. Le `NameError` est tombe dans un
`except Exception` large, et le devis a rendu une note **plausible** — « le plan du site
n'en declare aucune avec un port » — alors que le plan en declarait trois.
C'est exactement le defaut du 2026-09-14 sur `site_inventaire.py`, refait 24 h plus tard
par la meme main. Le `except` est resserre a `(OSError, ValueError)` : une faute de frappe
doit faire du bruit, pas une phrase credible.
### Le schema de l'amont suivait une constante, pas le port
`proxy_pass http://…` etait ecrit en dur. La forge termine deja son TLS sur 443 : elle
recevait du clair sur un port qui attend une poignee de main, et rendait `400`. Du TLS
parfait a l'entree, une erreur de protocole a la sortie.
Le schema suit desormais le port (`serveur_nginx_ports_tls`), **et l'amont est verifie** :
`proxy_ssl_verify on` contre l'AC interne. Un edge qui relaie en TLS sans verifier ne fait
que DEPLACER la confiance — le visiteur croit l'edge, l'edge ne croit personne.
## 2026-09-14 (23) — Du TLS qui ressemblait a du TLS, et une regle qu'un tenant n'a jamais eu besoin d'ecrire
Trois defauts de plus sur le chemin de l'edge, et le premier est le plus grave de la

View file

@ -0,0 +1,114 @@
# Preuve de conformite — Set-OPS — 2026-09-15
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (77 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| 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 : 41 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 111 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. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 30 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 29 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 6 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 4 tenant(s), 52 groupe(s), 96 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 43 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| 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 | 67 scripts expliques et atteignables, 123 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 36 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 46 document(s) declarent leur lecteur (40 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). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 96 page(s) de wiki toutes atteignables. |
| 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 : 63 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 : 8 machine(s) du plan retrouvees, 181 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 92 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 (129 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 271 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 |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (78 preuves, 68 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 40 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 3 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (5 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 11 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 6 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 | 155 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). |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-15._

View file

@ -50,3 +50,12 @@ serveur_nginx_proxy_busy_buffers_size: "32k"
# Les ports que l'edge DOIT servir. Variable, donc mise en defaut en nommant un port qui
# n'ecoute pas, sans toucher a nginx.
serveur_nginx_sonde_ports: "80 443"
# LES PORTS OU UN AMONT TERMINE DEJA LE TLS. `proxy_pass` doit alors parler `https://` :
# du clair vers un port qui attend une poignee de main rend `400 Bad Request`, et
# l'erreur parle de requete malformee, jamais de protocole.
serveur_nginx_ports_tls: [443, 8443]
# L'AC INTERNE, POUR VERIFIER L'AMONT. Meme racine que celle qui signe le certificat que
# l'edge presente : la confiance est la meme des deux cotes de la terminaison.
serveur_nginx_ca_interne: "/etc/step/certs/root_ca.crt"

View file

@ -12,8 +12,16 @@ map $http_upgrade $connection_upgrade { default upgrade; "" close; }
{% endif %}
{% for expo in serveur_nginx_expositions | default([]) %}
{% set ns = namespace(amont='') %}
{#- LE SCHEMA DE L'AMONT SUIT LE PORT DE L'AMONT (2026-09-15).
`http://` etait ecrit en dur. Un service qui termine DEJA le TLS — la forge, sur son
443 — recevait donc du clair sur un port qui attend une poignee de main, et rendait
`400 Bad Request`. Du TLS parfait a l'entree, une erreur de protocole a la sortie.
On ne devine pas : un amont declare sur un port de TLS se parle en TLS. La liste est
reglable (`serveur_nginx_ports_tls`) parce qu'un service peut servir du TLS ailleurs
que sur 443. -#}
{% if expo.hote in hostvars and hostvars[expo.hote].ansible_host is defined and expo.port %}
{% set ns.amont = 'http://' ~ hostvars[expo.hote].ansible_host ~ ':' ~ expo.port %}
{% set _schema = 'https://' if (expo.port | int) in (serveur_nginx_ports_tls | default([443, 8443])) else 'http://' %}
{% set ns.amont = _schema ~ hostvars[expo.hote].ansible_host ~ ':' ~ expo.port %}
{% endif %}
{% if ns.amont %}
@ -37,6 +45,15 @@ server {
location / {
proxy_pass {{ ns.amont }};
{% if ns.amont.startswith('https://') %}
# ON VERIFIE L'AMONT, PAS SEULEMENT LE VISITEUR. Un edge qui relaie en TLS sans
# verifier ne fait que DEPLACER la confiance : le visiteur croit l'edge, l'edge
# ne croit personne. L'AC interne est la meme des deux cotes.
proxy_ssl_verify on;
proxy_ssl_trusted_certificate {{ serveur_nginx_ca_interne }};
proxy_ssl_name {{ expo.fqdn }};
proxy_ssl_server_name on;
{% endif %}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -671,6 +671,51 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_machines_site = _machines_du_plan_site()
_services_site = _services_du_plan_site()
_ports_site = _ports_du_plan_site()
# CE QU'UN EDGE DOIT JOINDRE POUR RELAYER (2026-09-15).
#
# `serveur_nginx` declare `egress port: derive, pair: expositions` — « je sors vers
# ce que je publie ». Ce mot n'etait resolu NULLE PART : ni par le pare-feu d'hote
# (qui ne filtre pas l'egress), ni ici. La declaration existait, aucun moteur ne la
# consommait.
#
# ELLE N'A JAMAIS EU BESOIN DE L'ETRE. Chez un locataire, les passerelles de zone
# sont tenues par le SDN de Proxmox : le trafic edge -> amont ne traverse pas le
# boitier. Au SITE, elles sont tenues par la frontiere — et c'est le premier endroit
# ou un edge doit franchir la bordure pour atteindre ce qu'il relaie.
#
# Mesure du 2026-09-15 : les trois noms du site repondaient en TLS verifie, puis
# `http=000`. Le devis le disait, et c'est la seule raison qu'on l'ait vu :
#
# note : serveur_nginx declare un port `derive` que le plan du site ne resout
# pas — aucune regle emise.
#
# UNE REGLE PAR AMONT, jamais une regle large : un edge qui publie trois services ne
# doit pouvoir joindre que ces trois-la, sur LEUR port. `expositions` rendu comme
# « tout le site » aurait ouvert l'edge sur ses voisins — l'exact contraire de ce
# qu'une zone de publication separee cherche a obtenir.
#
# `underlay_mod`, ET PAS `U` — LA MEME ERREUR QU'HIER, AVALEE DE LA MEME FACON.
# Le module s'importe sous ce nom ICI ; `U` est l'alias d'un AUTRE fichier. Un
# `except Exception` large a transforme le `NameError` en « aucune exposition », et
# le devis a rendu une note plausible au lieu d'une erreur. On attrape donc ce que
# la lecture d'un fichier peut vraiment lever, et rien de plus : une faute de frappe
# doit faire du bruit.
_amonts_expositions: list[tuple[str, int]] = []
try:
_plan_apps = (underlay_mod.lire_plan_site("applications.yml")
or {}).get("applications") or {}
except (OSError, ValueError) as _e:
print(f"note : plan du site illisible ({_e.__class__.__name__}) — aucune "
f"exposition derivee.", file=sys.stderr)
_plan_apps = {}
for _app in _plan_apps.values():
if not isinstance(_app, dict) or not _app.get("expose"):
continue
_g, _port = str(_app.get("groupe") or ""), _app.get("port")
if _g and _g != "serveur_nginx" and str(_port or "").isdigit():
_amonts_expositions.append((_g, int(_port)))
_amonts_expositions = sorted(set(_amonts_expositions))
if _machines_site:
_res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)}
_sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"])
@ -908,6 +953,33 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# tenant de ce site ne declare encore de runner.
if "serveur_ops_tenant" in _pairs_fl and not _runners_tenants:
continue
# UN EDGE QUI SORT VERS SES EXPOSITIONS : une regle par amont, chacune
# avec SON port. Traite ici et pas plus bas, parce que ni le port ni la
# destination ne sont ceux du role qui declare le flux — ils sont ceux
# des services qu'il relaie.
if not _entrant and "expositions" in _pairs_fl:
if not _amonts_expositions:
print(f"note : {_role} sort vers ses expositions, mais le plan du "
f"site n'en declare aucune avec un port — aucune regle "
f"emise.", file=sys.stderr)
continue
for _g_amont, _port_amont in _amonts_expositions:
if _g_amont not in _roles_site:
continue
for _src, _itf in _portees:
regles.append({
"sens": "out",
"interface": _itf,
"protocole": _fl.get("protocole", "tcp"),
"source": _a,
"destination": nom_alias(_g_amont, "SITE"),
"ports": [str(_port_amont)],
"chiffrement": _fl.get("chiffrement"),
"role": _role,
"tenant": "SITE",
"raison": f"{_fl.get('raison', '')} (amont {_g_amont})",
})
continue
_p = _ports(_fl)
# L'ICMP N'A PAS DE PORT, ET SON `port` N'EST PAS SYMBOLIQUE (2026-09-09).
#