relais : la face publique d'un site sans adresse fixe, et le site de reprise de TechnoLibre

Le site de TechnoLibre n'a qu'une adresse DHCP. Trois adresses fixes sont louees sur un VPS
(le relais), qui repond a l'ARP pour elles et les route en /32 dans un tunnel WireGuard que la
frontiere initie. Aucune traduction : l'adresse du client arrive intacte.

- underlay.py : `tunnel: wireguard` dispense de VLAN ; role d'hote `relais`.
- devis_opnsense.py : `opnsense_face_publique`. Les entrees publiques s'y posent avec
  reply-to ; les sorties des locataires du pool passent par le relais ; une redirection n'y
  vise plus `wanip`. Le devis de Chezlepro est identique au caractere pres.
- appliquer_opnsense.py : replyto et gateway transmis et dans l'identite (seulement s'ils
  existent) ; le NAT sortant en mode automatic arrete l'application au lieu de dupliquer.
- role serveur_relais, inventorie depuis la carte comme un hyperviseur ; cle privee dans la
  voute du site. Deploye, idempotent.
- site_inventaire.py : les hyperviseurs ne sont plus filtres sur 192.168.11. ; site-appliquer
  charge la voute designee par SETOPS_UNDERLAY ; voute.py n'exige plus d'un locataire un
  secret du site.
- prouver.py : P67 lit le plan par underlay.plan_site() ; P53 couvre la face publique.
- Tests : test_underlay_tunnel, test_face_publique_relais, test_mode_nat_sortant.

95 preuves (make verifier), make test passe. CHANGELOG 144 et 145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-13 21:20:04 -04:00
parent f3bb9caee3
commit f9e45b1676
30 changed files with 886 additions and 50 deletions

View file

@ -1,5 +1,88 @@
## 2026-10-13 (145) — La face publique d'un site sans adresse fixe : le relais
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin : le devis de
la frontière de Chezlepro et l'inventaire de son site sont **identiques au caractère près** à
ceux d'avant ces changements. Face au site de TechnoLibre : 90 OK, les 4 écarts attendus d'un
site de reprise (entrée 144), et P16 sautée faute de clé de voûte dans la session.
**Le site de TechnoLibre a reçu son matériel** (relevé sur place avec Daniel) : frontière
`requin` (OPNsense 26.7), nœud `maurice` (Proxmox 9.2, mis à jour, horloge recalée — elle avait
six mois de retard et toutes les signatures Debian étaient refusées), commutateur `caillou`.
Deux ponts (`vmbr0` administration sur `nic0`, `vmbr1` VLAN-aware sur `nic2`), deux stockages
(`nvme` pour les VM, `miroir` ZFS 2 × 12 To pour `site-backup-01`), gabarit 9006 restauré. Les
huit pattes de la frontière (VLAN 31 à 37 et 40) sont prouvées par ARP depuis `maurice`, avec
un VLAN 99 témoin muet.
**Le relais.** Le site n'a qu'une adresse DHCP. Trois adresses fixes sont louées sur un VPS
d'OVH, qui répond à l'ARP pour elles (proxy) et les **route** en /32 dans un tunnel WireGuard
que `requin` initie. Aucune traduction : l'adresse du client arrive intacte. Prouvé de bout en
bout — des pings depuis l'Internet arrivent sur `wg0` avec leur vraie source et reviennent par
le tunnel (7,4 ms contre 4,3 ms pour le VPS seul) ; une adresse non relayée reste muette.
**Moteur — une face publique qui n'est pas le WAN.**
- `underlay.py` : `tunnel: wireguard` dispense de VLAN (liste fermée), rôle d'hôte `relais`.
- `devis_opnsense.py` : `opnsense_face_publique` (interface + passerelle). Ce que le site publie
s'y pose ; chaque entrée `pass` porte **`reply-to`** — sans lui, mesuré : la requête entre et
la réponse part par le WAN. Les sorties des locataires du pool passent par le relais, leur
traduction aussi ; le WAN garde la sortie du site. Sur un relais, une redirection ne vise plus
`wanip`, qui y désigne l'adresse du tunnel.
- `appliquer_opnsense.py` : `replyto` et `gateway` transmis à l'API et entrés dans l'identité
d'une règle seulement quand ils existent (les clés posées ailleurs ne bougent pas) ; pool
vérifié sur la face publique. **Le NAT sortant en mode `automatic` arrête l'application** :
OPNsense y ignore les règles manuelles et l'API ne les liste pas — trois traductions se sont
écrites sans se charger, puis dupliquées au passage suivant. Mode lu, `hybrid` posé.
- P53 exige que le refus audible exclue le WAN **et** la face publique.
- Nouveaux tests : `test_underlay_tunnel.py`, `test_face_publique_relais.py`,
`test_mode_nat_sortant.py`.
**Le rôle `serveur_relais`.** Le VPS est un équipement de la carte, inventorié comme un
hyperviseur — pas une machine du plan, dont tout descend de zones qu'il n'atteint pas. Ses
valeurs sont dérivées (carte, `opnsense.yml`, clés publiques des deux bouts) ; sa clé privée
vit dans la voûte du site. Simulé d'abord (écart : les seuls commentaires), puis déployé,
idempotent (`changed=0` au second passage). Un changement de `wg0.conf` coupe la face publique
une à deux minutes, le temps que la frontière renégocie.
**Trois défauts trouvés en route.**
- Les hyperviseurs n'étaient retenus que sous `192.168.11.` : `maurice` n'était dans aucun
inventaire. La première adresse déclarée fait foi.
- `site-appliquer` chargeait la voûte par le lien `underlay.yml` : contre TechnoLibre, il aurait
servi les secrets de Chezlepro. Elle suit `SETOPS_UNDERLAY`.
- `voute.py` exigeait d'un locataire un secret du site dès qu'un rôle l'utilisait.
**Reste.** Une sonde propre au relais ; le VLAN 1 encore présent sur les ports SFP+ de
`caillou` ; les règles d'administration `*_WAN` des locataires, à revoir pour une reprise ; la
sortie de `site-dnspub-01` par le relais si les primaires filtrent par adresse.
# CHANGELOG — Set-OPS
## 2026-10-12 (144) — Le site de reprise de TechnoLibre, refait sur le modèle du site de Chezlepro
**95 preuves.** `make verifier` passe (site de Chezlepro). Face au site de TechnoLibre
(`SETOPS_UNDERLAY=…/SITE-Technolibre/underlay.yml`) : 91 OK, et les 4 écarts restants sont
**attendus** pour un site de reprise — les locataires désignent le site où ils tournent (P37,
P69, P73, P79). Il en relevait 9 avant la refonte.
Daniel installe sur place l'OPNsense du site (QOTOM : 5 × 2,5G, 4 × SFP+ 10G) ; le nœud est un
ASUS Z10PE-D16 WS, deux processeurs, 2 × 128 Go. Le dépôt de septembre ne suivait plus les
méthodes du moteur ; il est refait **sur le modèle de `SITE-Chezlepro`, avec la même
nomenclature de VM** (décision de Daniel) :
- carte au schéma actuel (`nature`, `segment_physique`, zones dérivées de l'index), zone de
publication (VLAN 37) ;
- **neuf machines au lieu de sept** : `site-edge-01` et `site-dnspub-01` — le second serveur de
noms public des deux écosystèmes vit sur ce site ; `site-mon-01` sauvegardé ; le runner
connaît les deux locataires (ce site est la reprise de l'un et de l'autre) ;
- `domaines.yml`, `mainteneurs.yml`, applications et bases repris (domaine `socle.internal`) ;
- `opnsense.yml` aux clés actuelles (exportateur, silences, vérification de l'API, prête à
passer à `true` une fois la frontière certifiée par `frontiere_pki.py`) ;
- quatre secrets ajoutés à la voûte du site, tirés au hasard (exportateur PostgreSQL, Icinga
Web, console) ; les clés TSIG de réplication du DNS public restent à faire, avec celles du
site de Chezlepro.
**Moteur — P67 suivait le lien, pas `SETOPS_UNDERLAY`.** `_plan_du_site_applications` lisait le
plan du site par le symlink `underlay.yml`, que l'inventaire, lui, ne suit pas quand la
variable est posée : juger le site de reprise comparait ses noms à ceux du plan de Chezlepro
(six faux écarts). Elle passe désormais par `underlay.plan_site()`.
## 2026-10-12 (143) — La frontière est certifiée par la PKI du site, et porte sa propre autorité
**95 preuves.** `make verifier` passe ; `test_frontiere_pki.py` garde le chantier (témoin : il

View file

@ -346,6 +346,9 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py
python3 scripts/tests/test_underlay_tunnel.py
python3 scripts/tests/test_face_publique_relais.py
python3 scripts/tests/test_mode_nat_sortant.py
python3 scripts/tests/test_frontiere_refus.py
python3 scripts/tests/test_contexte.py
python3 scripts/tests/test_appels_locataire.py
@ -1246,6 +1249,8 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
@# `underlay.vault.yml`, qui vit a cote de sa carte et hors depot. Sans le `-e @`, le
@# role echoue sur son assertion — et l'echec ne dit pas qu'il manque un FICHIER,
@# seulement que les valeurs sont vides.
@# `SETOPS_UNDERLAY` (2026-10-13) : la carte lue par l'inventaire DESIGNE aussi la voute.
@# Lire le symlink seul aurait charge les secrets de Chezlepro contre TechnoLibre.
@set -e; \
if [[ ! -f "playbooks/groupes/$(GROUPE).yml" ]]; then \
printf 'Refus: aucun playbook pour le groupe %s.\n' "$(GROUPE)"; exit 2; \
@ -1257,7 +1262,7 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
printf 'Groupes du site : %s\n' "$$connus"; \
exit 2; \
fi; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$(GROUPE).yml" \
"$${supp[@]}" $(ARGS)

View file

@ -98,7 +98,7 @@ Une directive qu'aucune garde ne vérifie finit par ne plus être vraie. Chaque
| `socle-identite` | **est** la chaîne d'identité, ne peut pas se déléguer à elle-même | keycloak, openldap |
| `ldap-direct` | protocole non-OIDC lié à LDAP | dovecot, postfix |
| `interne-sans-auth` | interface joignable **dans** le tenant sans authentification, non exposée | prometheus, loki |
| `sans-auth-humaine` | aucun point d'authentification humaine | 23 |
| `sans-auth-humaine` | aucun point d'authentification humaine | 24 |
La preuve refuse **l'oubli et le mensonge** : un rôle sans déclaration, une portée
inventée, un `web-sso` sans accès de secours ou sans posture de formulaire, un `web-sso

View file

@ -23,8 +23,8 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 70 | `roles/*/` |
| README de rôles | 70 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 47 | `docs/*.md` |
| pièces d'audit | 53 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |

View file

@ -66,6 +66,7 @@ Ce que la reconstruction couvre, par capacité :
| Resolveur du site | `serveur_resolveur_site` | designe LE resolveur que les ecosystemes de ce site interrogent tant qu'ils n'ont pas le leur. Marqueur : `serveur_resolveur` installe. |
| Depot de sauvegarde du site | `serveur_backup_site` | designe LE depot ou les ecosystemes de ce site posent leur etat tant qu'ils n'ont pas le leur. Marqueur : `serveur_backup` installe. |
| DNS public du site | `serveur_dns_public` | **secondaire** public de toutes les zones `autorite: primaire-cache` des locataires du site : le locataire ecrit, le site sert. Transfert signe TSIG, aucune adresse de confiance, aucune zone `.internal`. Phase 1 : non expose a Internet |
| Relais de la face publique | `serveur_relais` | un VPS **hors du site** qui loue ses adresses fixes quand le site n'a qu'une adresse DHCP : il repond a l'ARP de son hebergeur pour elles (proxy) et les **route** en /32 dans un tunnel WireGuard que la frontiere initie. Aucune traduction : l'adresse du client arrive intacte. Inventorie depuis la carte, comme un hyperviseur |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp`, `client_sante` | collecte, relais et rapport de santé sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`
@ -201,7 +202,7 @@ dans le plan.
## Ordre de déploiement — le raisonnement
> **L'ordre exécutable n'est pas ici.** Il vit dans `docs/couches-deploiement.yml` et
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (42 groupes classés, aucun
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (43 groupes classés, aucun
> cycle, aucune arête en arrière) et que `make reconstruire` suit. Ce qui suit en est le
> **raisonnement**, utile pour comprendre pourquoi cet ordre-là — et pour placer un
> service nouveau. Les phases sont franchies : les « intégrations à prévoir » ci-dessous

View file

@ -92,6 +92,10 @@ couches:
- serveur_resolveur_site
# Le depot de sauvegarde du site, meme nature : il recoit l'etat des locataires.
- serveur_backup_site
# LE RELAIS (2026-10-13) : la face publique d'un site qui n'a qu'une adresse DHCP. Un
# equipement HORS du site (VPS), inventorie depuis la carte comme un hyperviseur. Il
# vient avant tout ce qui se publie : sans lui, une adresse publique ne mene nulle part.
- serveur_relais
# Le DNS public du site vient APRES les services : il tire les zones que les
# autoritatifs des locataires ecrivent, et n'a rien a servir avant eux.
- serveur_dns_public

View file

@ -22,7 +22,7 @@ flux:
port: 5432 # entier ou liste [80, 443]
protocole: tcp # tcp | udp
pair: [serveur_keycloak, serveur_forgejo] # QUI — voir « Résolution du pair »
chiffrement: tls-requis # tls-requis | tls | starttls | ssh | tls-cible | clair | n-a (bonus audit)
chiffrement: tls-requis # tls-requis | tls | starttls | ssh | tls-cible | wireguard | clair | n-a (bonus audit)
# tls-requis : chiffré + pair vérifié (verify-full) | tls : chiffré | starttls : mise à niveau opportuniste
# ssh : transport SSH (chiffré, hôte vérifié) | tls-cible : TLS visé mais pas encore appliqué (feuille de route)
# clair : non chiffré (local ou terminé à l'edge) | n-a : sans objet

View file

@ -112,6 +112,8 @@
| `serveur_prometheus` | egress | 9100 | tcp | frontiere | tls | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. |
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
| `serveur_relais` | ingress | 22 | tcp | externe | ssh | La console d'administration, sur l'adresse PROPRE du VPS seulement. Les adresses relayees ne menent jamais a ce SSH : elles partent dans le tunnel. |
| `serveur_relais` | ingress | derive | udp | externe | wireguard | La frontiere du site ouvre le tunnel depuis son adresse DHCP (port derive : 51800 + index du site). Seule sa cle est admise ; le reste de l'Internet ne recoit aucune reponse. |
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
| `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_resolveur` | egress | 53 | udp | externe | clair | Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie ses questions à personne. |
@ -132,8 +134,9 @@
- **clair** : 49 flux
- **n-a** : 8 flux
- **ssh** : 9 flux
- **ssh** : 10 flux
- **starttls** : 6 flux
- **tls** : 10 flux
- **tls-cible** : 2 flux
- **tls-requis** : 37 flux
- **wireguard** : 1 flux

View file

@ -0,0 +1,19 @@
---
- name: Appliquer le groupe serveur_relais
hosts: serveur_relais
become: true
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_relais

View file

@ -62,6 +62,8 @@
import_playbook: groupes/serveur_resolveur_site.yml
- name: Couche services — groupe serveur_backup_site
import_playbook: groupes/serveur_backup_site.yml
- name: Couche services — groupe serveur_relais
import_playbook: groupes/serveur_relais.yml
- name: Couche services — groupe serveur_dns_public
import_playbook: groupes/serveur_dns_public.yml
- name: Couche services — groupe serveur_resolveur

View file

@ -0,0 +1,63 @@
# serveur_relais
La **face publique d'un site qui n'a qu'une adresse DHCP**. Un VPS loue des adresses fixes,
répond à l'ARP de son hébergeur pour elles (proxy ARP) et les **route en /32** dans un tunnel
WireGuard que la frontière du site initie. Aucune traduction d'adresse : les adresses arrivent
intactes sur la frontière, qui les porte et qui filtre. L'adresse du client reste visible de
bout en bout (courriel, journaux, blocage des abus).
```
Internet ──► hébergeur (ARP « qui a 51.79.x ? ») ──► relais : « c'est moi » (proxy ARP)
──► route /32 vers wg0 ──► tunnel ──► frontière (porte les adresses, filtre)
```
Né le 2026-10-13 au site de TechnoLibre (VPS OVH, Debian 13, frontière `requin`).
## Ce que le rôle pose
| Fichier | Rôle |
|---|---|
| `/etc/wireguard/wg0.conf` | le tunnel ; routes /32 et proxy ARP en `PostUp`, sans `Table` |
| `/etc/wireguard/relais.key` | la clé privée, depuis la voûte du site |
| `/etc/nftables.conf` | entrée : SSH et tunnel sur la console seulement ; transit : les adresses relayées, dans chaque sens, rien d'autre |
| `/etc/sysctl.d/90-relais.conf` | `ip_forward` |
| `/etc/systemd/resolved.conf.d/90-relais.conf` | LLMNR et mDNS coupés (ils écoutaient sur l'adresse publique) |
Le relais **ne décide d'aucun service** : il ne connaît que des adresses. Ce qui est publié,
et à qui, se décide sur la frontière (`devis_opnsense.py`, `opnsense_face_publique`).
## D'où viennent les valeurs
Toutes **dérivées** par `scripts/site_inventaire.py` ; aucune n'est à recopier.
| Variable | Source |
|---|---|
| `serveur_relais_tunnel_adresse`, `serveur_relais_tunnel_mtu` | hôte `role: relais` et réseau `tunnel: wireguard` de `underlay.yml` |
| `serveur_relais_tunnel_port` | 51800 + index du site |
| `serveur_relais_pair_*` | hôte `role: frontiere` du même réseau, avec sa `cle_publique` |
| `serveur_relais_adresses_relayees` | `opnsense_wan_ip` + `opnsense_ips_publiques` de `opnsense.yml` |
| `serveur_relais_cle_privee` | `vault_relais_wg_cle_privee`, voûte du site |
| `ansible_host` | `console:` de l'hôte relais — jamais une adresse relayée |
Le relais n'est **pas** dans `plan/serveurs.yml` : tout ce qu'une machine du plan reçoit
descend des zones du site, que le VPS n'atteint pas. Comme un hyperviseur, c'est un
équipement de la carte.
## Ce qui reste hors du rôle
- **Côté frontière** (OPNsense) : l'instance WireGuard, l'interface, la passerelle et les
adresses virtuelles se posent par l'API, une fois. Le devis y pose ensuite les règles avec
`reply-to` (voir `test_face_publique_relais.py`).
- **La supervision** : le relais est hors du site et ne joint pas sa supervision. Ce qui le
dit vivant aujourd'hui, c'est la passerelle `RELAIS_GW` de la frontière (surveillée par
ping à travers le tunnel). Une sonde propre au rôle reste à écrire.
- **Le durcissement commun** (`serveur_durci`) : il dérive son pare-feu des zones du site et
écraserait celui du relais. Non appliqué ici.
## Déployer
```
SETOPS_UNDERLAY=../SITE-<site>/underlay.yml make site-appliquer GROUPE=serveur_relais ARGS="--check --diff"
```
Le pare-feu est validé (`nft -c`) avant d'être posé : la seule porte du VPS est son SSH.

View file

@ -0,0 +1,34 @@
---
# LE RELAIS — la face publique d'un site qui n'a qu'une adresse DHCP (2026-10-13).
#
# Un VPS loue des adresses fixes ; il repond a l'ARP de son hebergeur pour elles (proxy) et
# les ROUTE en /32 dans un tunnel WireGuard que la frontiere du site INITIE. Aucune
# traduction : les adresses arrivent intactes sur la frontiere, qui les porte et qui filtre.
# Le relais ne decide d'aucun service — il fait suivre, et refuse tout le reste.
#
# Tout ce qui suit est DERIVE par `scripts/site_inventaire.py` de la carte du site
# (`underlay.yml` : reseau `relais`, hote `role: relais`) et de `opnsense.yml` (adresse du
# site et pool des locataires). Rien n'est a recopier ici.
serveur_relais_tunnel_adresse: "" # ex. 10.31.60.1/30
serveur_relais_tunnel_port: 0 # 51800 + index du site
serveur_relais_tunnel_mtu: 1420
serveur_relais_pair_nom: frontiere
serveur_relais_pair_adresse: "" # l'adresse de la frontiere dans le tunnel
serveur_relais_pair_cle_publique: ""
serveur_relais_adresses_relayees: [] # l'adresse du site, puis le pool des locataires
serveur_relais_site: ""
# La console SSH reste sur l'adresse PROPRE du VPS : les adresses relayees ne lui sont
# jamais attribuees, et ne mennent jamais a son SSH.
serveur_relais_console: "{{ ansible_host }}"
# L'interface ou l'hebergeur livre les adresses (celle de la route par defaut).
serveur_relais_interface_publique: "{{ ansible_facts.default_ipv4.interface }}"
serveur_relais_interface_tunnel: wg0
# La cle privee vit dans la voute du site : reconstruire le relais ne change pas sa cle,
# donc ne touche pas a la frontiere.
serveur_relais_cle_privee: "{{ vault_relais_wg_cle_privee | default('') }}"
serveur_relais_paquets:
- wireguard-tools
- nftables

View file

@ -0,0 +1,23 @@
---
- name: Appliquer les reglages noyau du relais
ansible.builtin.systemd:
name: systemd-sysctl
state: restarted
- name: Redemarrer systemd-resolved
ansible.builtin.systemd:
name: systemd-resolved
state: restarted
# Le tunnel se recree : quelques secondes sans relais, la frontiere se reconnecte d'elle-meme
# (maintien de connexion toutes les 25 s).
- name: Redemarrer le tunnel du relais
ansible.builtin.systemd:
name: "wg-quick@{{ serveur_relais_interface_tunnel }}"
state: restarted
# `nft -f` remplace le jeu entier d'un coup (`flush ruleset` en tete) : aucun instant sans
# regle. Le fichier a ete valide (`nft -c`) avant d'etre pose.
- name: Recharger le pare-feu du relais
ansible.builtin.command: nft -f /etc/nftables.conf
changed_when: true

View file

@ -0,0 +1,9 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
raison: >-
Le relais ne sert rien a personne : il fait suivre des paquets. Ce qui s'authentifie ici,
c'est la FRONTIERE du site (cle WireGuard, seul pair admis), et l'exploitant par SSH a
cle sur la console — l'acces d'administration commun, pas un service du role.

View file

@ -0,0 +1,9 @@
---
# Empreinte ressources du logiciel — dimensionnement VM derive (scripts/instancier.py).
# Le relais route dans le noyau : WireGuard et nftables, aucun service en espace utilisateur.
# Il vit hors du site (VPS), donc rien n'est clone a partir de ces valeurs ; elles disent ce
# qu'il faut louer.
setops_empreinte:
coeurs: 1
memoire_mo: 512
disque_go: 2

View file

@ -0,0 +1,25 @@
---
# Flux reseau du relais. Voir docs/flux-conception.md.
#
# LE RELAIS N'EST PAS DANS LE PLAN DU SITE : aucun devis ne derive de regle de ces lignes
# (son pare-feu est le sien, `templates/nftables.conf.j2`). Elles DISENT ce qui le traverse,
# pour qu'un humain le lise au meme endroit que pour tout autre role.
flux:
# `derive` : 51800 + index du site (51831 au site de TechnoLibre). Le role est generique.
- sens: ingress
port: derive
protocole: udp
pair: externe
chiffrement: wireguard
raison: >-
La frontiere du site ouvre le tunnel depuis son adresse DHCP (port derive : 51800 +
index du site). Seule sa cle est admise ; le reste de l'Internet ne recoit aucune
reponse.
- sens: ingress
port: 22
protocole: tcp
pair: externe
chiffrement: ssh
raison: >-
La console d'administration, sur l'adresse PROPRE du VPS seulement. Les adresses
relayees ne menent jamais a ce SSH : elles partent dans le tunnel.

View file

@ -0,0 +1,96 @@
---
# Voir defaults/main.yml pour ce qu'est le relais, et d'ou viennent ses valeurs.
- name: Le relais a tout ce qu'il faut pour exister
ansible.builtin.assert:
that:
- serveur_relais_tunnel_adresse | length > 0
- serveur_relais_tunnel_port | int > 0
- serveur_relais_pair_adresse | length > 0
- serveur_relais_pair_cle_publique | length > 0
- serveur_relais_adresses_relayees | length > 0
- serveur_relais_cle_privee | length > 0
- serveur_relais_console not in serveur_relais_adresses_relayees
fail_msg: >-
Relais incomplet. Les valeurs viennent de la carte du site (reseau `relais`, hotes
`role: relais` et `role: frontiere` avec `cle_publique`), de `opnsense.yml` (adresse du
site et pool) et de la voute du site (`vault_relais_wg_cle_privee`). La console ne doit
pas etre une adresse relayee : elle menerait au SSH par le tunnel.
no_log: true
- name: Installer WireGuard et nftables
ansible.builtin.apt:
name: "{{ serveur_relais_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
# Faire suivre : sans lui, les paquets arrives pour une adresse relayee meurent ici.
- name: Autoriser le transit IPv4
ansible.builtin.copy:
dest: /etc/sysctl.d/90-relais.conf
content: "net.ipv4.ip_forward = 1\n"
owner: root
group: root
mode: "0644"
notify: Appliquer les reglages noyau du relais
# LLMNR et mDNS ecoutaient sur l'adresse publique (5355) : aucun service ici ne s'annonce.
- name: Couper LLMNR et mDNS
ansible.builtin.copy:
dest: /etc/systemd/resolved.conf.d/90-relais.conf
content: "[Resolve]\nLLMNR=no\nMulticastDNS=no\n"
owner: root
group: root
mode: "0644"
notify: Redemarrer systemd-resolved
- name: Dossier de WireGuard
ansible.builtin.file:
path: /etc/wireguard
state: directory
owner: root
group: root
mode: "0700"
- name: Cle privee du relais (depuis la voute du site)
ansible.builtin.copy:
dest: /etc/wireguard/relais.key
content: "{{ serveur_relais_cle_privee }}\n"
owner: root
group: root
mode: "0600"
no_log: true
notify: Redemarrer le tunnel du relais
- name: Configuration du tunnel
ansible.builtin.template:
src: wg0.conf.j2
dest: "/etc/wireguard/{{ serveur_relais_interface_tunnel }}.conf"
owner: root
group: root
mode: "0600"
notify: Redemarrer le tunnel du relais
# VALIDE AVANT D'ETRE POSE : un jeu de regles refuse par nft, recharge sur un VPS dont la
# seule porte est le SSH, serait la fin de l'acces.
- name: Pare-feu du relais
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
owner: root
group: root
mode: "0755"
validate: "nft -c -f %s"
notify: Recharger le pare-feu du relais
- name: Tunnel actif au demarrage
ansible.builtin.systemd:
name: "wg-quick@{{ serveur_relais_interface_tunnel }}"
enabled: true
state: started
- name: Pare-feu actif au demarrage
ansible.builtin.systemd:
name: nftables
enabled: true

View file

@ -0,0 +1,33 @@
#!/usr/sbin/nft -f
# {{ ansible_managed }}
# Relais de {{ serveur_relais_site }} — role serveur_relais. Il ne decide d'aucun service : il fait
# suivre les adresses relayees vers {{ serveur_relais_pair_nom }}, qui filtre.
flush ruleset
define CONSOLE = {{ serveur_relais_console }}
define RELAYEES = { {{ serveur_relais_adresses_relayees | join(', ') }} }
table inet relais {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
udp sport 67 udp dport 68 accept comment "baux DHCP de l'hebergeur"
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 10/second accept
ip6 nexthdr ipv6-icmp icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, destination-unreachable, packet-too-big, time-exceeded } accept
ip daddr $CONSOLE tcp dport 22 accept comment "console SSH"
ip daddr $CONSOLE udp dport {{ serveur_relais_tunnel_port }} accept comment "tunnel de {{ serveur_relais_pair_nom }}"
iifname "{{ serveur_relais_interface_tunnel }}" ip saddr {{ serveur_relais_pair_adresse }} icmp type echo-request accept comment "sonde de {{ serveur_relais_pair_nom }}"
counter comment "refus en entree"
}
chain forward {
type filter hook forward priority filter; policy drop;
tcp flags syn tcp option maxseg size set rt mtu
ct state established,related accept
ct state invalid drop
iifname "{{ serveur_relais_interface_publique }}" oifname "{{ serveur_relais_interface_tunnel }}" ip daddr $RELAYEES accept comment "Internet vers {{ serveur_relais_pair_nom }}"
iifname "{{ serveur_relais_interface_tunnel }}" oifname "{{ serveur_relais_interface_publique }}" ip saddr $RELAYEES accept comment "{{ serveur_relais_pair_nom }} vers Internet, sources relayees seulement"
counter comment "refus en transit"
}
}

View file

@ -0,0 +1,24 @@
# {{ ansible_managed }}
# Relais de {{ serveur_relais_site }} — role serveur_relais.
# Les adresses relayees sont ROUTEES dans le tunnel, jamais attribuees a l'interface
# {{ serveur_relais_interface_publique }} : le VPS repond seulement a l'ARP de son hebergeur pour elles (proxy).
# Relayees : {{ serveur_relais_adresses_relayees | join(', ') }}.
[Interface]
Address = {{ serveur_relais_tunnel_adresse }}
ListenPort = {{ serveur_relais_tunnel_port }}
MTU = {{ serveur_relais_tunnel_mtu }}
Table = off
PostUp = wg set %i private-key /etc/wireguard/relais.key
PostUp = {% for a in serveur_relais_adresses_relayees %}ip route add {{ a }}/32 dev %i{{ ' ; ' if not loop.last }}{% endfor %}
PostUp = {% for a in serveur_relais_adresses_relayees %}ip neigh add proxy {{ a }} dev {{ serveur_relais_interface_publique }}{{ ' ; ' if not loop.last }}{% endfor %}
PostDown = {% for a in serveur_relais_adresses_relayees %}ip neigh del proxy {{ a }} dev {{ serveur_relais_interface_publique }}{{ ' ; ' if not loop.last }}{% endfor %}
# {{ serveur_relais_pair_nom }} (frontiere de {{ serveur_relais_site }}) : c'est elle qui initie, depuis une
# adresse DHCP — d'ou l'absence d'Endpoint ici. Sources admises : son adresse de tunnel et
# les adresses relayees.
[Peer]
PublicKey = {{ serveur_relais_pair_cle_publique }}
AllowedIPs = {{ ([serveur_relais_pair_adresse ~ '/32'] + (serveur_relais_adresses_relayees | map('regex_replace', '$', '/32') | list)) | join(', ') }}

View file

@ -136,8 +136,15 @@ def cle_regle(r: dict) -> str:
# frontiere, en production. Une marque vide pour le cas majoritaire garde ces cles
# octet pour octet, et n'en donne une nouvelle qu'a ce qui est nouveau.
marque = "" if str(r.get("action") or "pass") == "pass" else f"{r['action']}:"
# LE CHEMIN DE RETOUR ET DE SORTIE ENTRENT DANS L'IDENTITE (2026-10-13), meme principe :
# absents, rien n'est ajoute et les cles deja posees restent octet pour octet. Presents
# (face publique par relais), une regle qui aurait perdu son `reply-to` sur le boitier
# n'a plus la cle du devis : elle est recreee, au lieu de passer pour conforme alors que
# ses reponses partent par le WAN et se perdent.
via = (f"|reply:{r['replyto']}" if r.get("replyto") else "") + \
(f"|via:{r['passerelle']}" if r.get("passerelle") else "")
return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
f"{r['source']}->{r['destination']}:{ports}")
f"{r['source']}->{r['destination']}:{ports}{via}")
def cle_nat(n: dict) -> str:
@ -292,6 +299,13 @@ def _corps_regle(r: dict, k: str) -> dict:
}
if r["protocole"] in ("tcp", "udp") and r["ports"]:
corps["destination_port"] = ",".join(map(str, r["ports"]))
# Noms de passerelle d'OPNsense 26.7 (champs `replyto` et `gateway` du modele Filter,
# verifies sur le boitier le 2026-10-13). `replyto` renvoie la reponse par ou la
# requete est arrivee ; `gateway` envoie la sortie par le relais (policy routing).
if r.get("replyto"):
corps["replyto"] = r["replyto"]
if r.get("passerelle"):
corps["gateway"] = r["passerelle"]
return corps
@ -643,7 +657,28 @@ def _charger(api: Frontiere, p: dict) -> int:
return 0
def _ips_publiques(api: Frontiere) -> None:
def _mode_nat_sortant(api: Frontiere) -> str | None:
"""Le mode du NAT sortant (`automatic`, `hybrid`, `advanced`, `disabled`), ou None.
POURQUOI (2026-10-13, au site de TechnoLibre). En mode `automatic`, OPNsense IGNORE les
regles manuelles : celles du devis s'ecrivaient (« saved »), ne se chargeaient jamais,
et la recherche de l'API ne les listait pas — le plan suivant les croyait absentes et
les recreait. Trois regles en double, et aucune traduction des locataires. Au site de
Chezlepro le mode avait ete bascule a la main ; le script le supposait sans le lire.
None : le boitier ne repond pas a cette lecture (version anterieure) — on le DIT.
"""
# Lecture DIRECTE, pas `_lire` : celle-ci arrete tout sur un refus, et un boitier
# anterieur qui ne connait pas ce chemin bloquerait sa frontiere entiere pour une garde.
rep = api("/api/firewall/source_nat/get", None)
if not isinstance(rep, dict) or rep.get("_erreur"):
return None
mode = (((rep.get("filter") or {}).get("general") or {}).get("snat_mode"))
if isinstance(mode, dict):
mode = next((k for k, v in mode.items() if isinstance(v, dict) and v.get("selected")), None)
return str(mode) if mode else None
def _ips_publiques(api: Frontiere, devis: dict | None = None) -> None:
"""Le pool d'adresses publiques du site face a ce que le WAN porte vraiment.
LECTURE SEULE (2026-09-29). Les adresses virtuelles du WAN sont posees par l'exploitant,
@ -652,21 +687,30 @@ def _ips_publiques(api: Frontiere) -> None:
du WAN hors du pool est une adresse que personne n'a declaree : les deux se disent ici.
"""
from site_intrants import pool_ips_publiques
pool = pool_ips_publiques()
pool = dict(pool_ips_publiques())
if not pool:
return
wan = {str(x.get("subnet")) for x in (_lire(api, "/api/interfaces/vip_settings/searchItem/")
.get("rows") or [])
if str(x.get("interface")) == "wan"}
# LA FACE PUBLIQUE PEUT ETRE UN RELAIS (2026-10-13) : les adresses sont alors des alias
# sur l'interface du tunnel, pas sur le WAN — et l'adresse du SITE en est une aussi,
# puisque le WAN n'a qu'une adresse DHCP. Elle rejoint donc ce qui doit etre pose.
itf = str((devis or {}).get("if_publique") or "wan")
if (devis or {}).get("if_publique") and str((devis or {}).get("wan_ip") or ""):
pool[str(devis["wan_ip"])] = "SITE"
# `rowCount` en POST : OPNsense 26.7 refuse la recherche en GET (400, mesure le
# 2026-10-13 sur requin), et un refus ici se lirait « aucune adresse posee ».
wan = {str(x.get("subnet")).split("/")[0]
for x in (_lire(api, "/api/interfaces/vip_settings/searchItem/", {"rowCount": 1000})
.get("rows") or [])
if str(x.get("interface")) == itf}
manquantes = sorted(set(pool) - wan)
hors_pool = sorted(wan - set(pool))
attribuees = sum(1 for q in pool.values() if q != "libre")
print(f" adresses publiques : {len(pool)} au pool ({attribuees} attribuee(s)), "
f"{len(wan)} sur le WAN")
f"{len(wan)} sur {itf}")
for ip in manquantes:
print(f" !! {ip} ({pool[ip]}) est au pool mais ABSENTE du WAN — a poser sur le boitier")
print(f" !! {ip} ({pool[ip]}) est au pool mais ABSENTE de {itf} — a poser sur le boitier")
for ip in hors_pool:
print(f" !! {ip} est sur le WAN mais HORS du pool — a declarer dans opnsense.yml")
print(f" !! {ip} est sur {itf} mais HORS du pool — a declarer dans opnsense.yml")
print()
@ -707,7 +751,22 @@ def main() -> int:
print(f"Frontiere {url} — {len(devis['regles'])} regles au devis\n")
p = plan(api, devis)
_ips_publiques(api)
_ips_publiques(api, devis)
if devis.get("nat"):
mode = _mode_nat_sortant(api)
if mode == "automatic":
print(" !! NAT sortant en mode AUTOMATIC : OPNsense ignore les regles manuelles, et l'API")
print(" ne les liste pas. Les creer les dupliquerait sans jamais les charger.")
print(" Passer le mode en `hybrid` (Firewall > NAT > Outbound) puis rejouer.")
print(" RIEN NE SERA POUSSE : une politique chargee sans ses traductions laisserait")
print(" les sorties par le relais partir avec une adresse privee, et se perdre.")
return 2
elif mode is None:
print(" ! Mode du NAT sortant ILLISIBLE par l'API : verifier a la main qu'il n'est pas")
print(" `automatic` — dans ce mode les regles du devis ne s'appliquent pas.")
else:
print(f" NAT sortant : mode {mode}")
print()
p["_uuid_alias"] = {x["name"]: x["uuid"] for x in
(_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])}
# UNE ROUTE ABSENTE DU NOYAU EST DU TRAVAIL, MEME SI LE MODELE EST A JOUR.

View file

@ -489,6 +489,20 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT)
if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION)
if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE)
# LA FACE PUBLIQUE PEUT NE PAS ETRE LE WAN (2026-10-13). Au site de TechnoLibre, le WAN
# n'a qu'une adresse DHCP : les adresses fixes sont louees sur un VPS (le relais) et
# arrivent par un tunnel, sur sa propre interface. Tout ce que le site PUBLIE (regles
# entrantes, redirections, poignees de main des tunnels d'administration, traduction
# sortante des locataires) se pose alors sur `if_public` ; le WAN ne garde que la sortie
# du site lui-meme et son silence.
#
# `gw_public` : le `reply-to` de chaque regle entrante de cette interface, et la sortie
# des locataires. Sans lui la reponse repart par la route par defaut — le WAN — et se
# perd (mesure du 2026-10-13 : pings arrives sur `wg0`, zero reponse). Vide quand la
# face publique EST le WAN : rien ne change alors, au caractere pres.
_fp = dict(_intr.get("opnsense_face_publique") or {})
if_public = str(_fp.get("interface") or "").strip() or if_wan
gw_public = str(_fp.get("passerelle") or "").strip() if if_public != if_wan else ""
# UNE PATTE PAR ZONE DU SITE (2026-08-25). Le site n'est plus un `/24` plat : chaque
# nature d'autorite a son VLAN, et le trafic penetre le pare-feu par SA patte. Une
# regle posee sur la mauvaise interface ne correspond jamais.
@ -698,7 +712,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if admin_par_if[etiquette]["gestion"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["wan"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan))
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_public))
if admin_par_if[etiquette]["vpn"]:
portees.append((f"SETOPS_ADMIN_{etiquette}_VPN", if_vpn))
elif entrant and "voisins_site" in _pairs(fl):
@ -742,7 +756,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# visible) — elle se desactive a la main. La declarer ici est ce qui rend
# cette desactivation possible SANS couper l'exploitant de ses propres
# services. Sans elle, fermer le LAN fermerait aussi ses consoles web.
portees = [("any", if_wan)]
portees = [("any", if_public)]
if admin_par_if[etiquette]["gestion"] and fl.get("poste", True):
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
if admin_par_if[etiquette]["vpn"] and fl.get("poste", True):
@ -803,7 +817,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if not str(_pl).isdigit():
continue
redirections.append({
"interface": if_wan,
"interface": if_public,
"protocole": fl.get("protocole", "tcp"),
"destination": ip_pub,
"port_public": int(_pub) if str(_pub or "").isdigit() else int(_pl),
@ -1181,16 +1195,22 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
continue
for _pl in _ports(_fl):
redirections.append({
"interface": if_wan,
"interface": if_public,
"protocole": _fl.get("protocole", "tcp"),
"port_public": int(_pub),
"cible": _ips[0],
"port_local": int(_pl),
"role": _role,
# SUR UN RELAIS, `wanip` MENT (2026-10-13) : c'est l'adresse de
# l'interface, donc celle du tunnel (10.31.60.2), que l'Internet
# ne frappe jamais. L'adresse publique du site est nommee. Sur le
# WAN, rien ne change (et la cle de la redirection non plus).
**({"destination": str(_intr.get("opnsense_wan_ip"))}
if gw_public and str(_intr.get("opnsense_wan_ip") or "") else {}),
})
# Le filtre voit le paquet APRES traduction : destination = la machine,
# port = celui qu'elle ecoute. Source : tout l'Internet.
_portees = [("any", if_wan)]
_portees = [("any", if_public)]
elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur
else:
@ -1397,7 +1417,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# de ceux qu'on joint). Sans attribution, le repli reste l'adresse du WAN.
nat = [
{
"interface": if_wan,
"interface": if_public,
"source": f"SETOPS_TENANT_{pfx}{n['index']}",
"destination": "any",
"cible": ips_publiques.get(nom, "wanip"),
@ -1481,7 +1501,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# n'ecoute. La sonde `connectivite` le classe donc en AVERTISSEMENT, pas en information.
_internes = ({if_gestion, if_transit, if_site, if_vpn} | set(if_zones.values())
| {str(r.get("interface")) for r in regles})
for _itf in sorted(i for i in _internes if i and i != if_wan and not i.startswith("<")):
# La face publique est l'Internet, comme le WAN : elle reste muette (P53).
for _itf in sorted(i for i in _internes
if i and i not in (if_wan, if_public) and not i.startswith("<")):
regles.append({
"sens": "in",
"interface": _itf,
@ -1712,7 +1734,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
})
regles.append({
"sens": "in",
"interface": if_wan,
"interface": if_public,
"protocole": "udp",
"source": "any",
"destination": "SETOPS_FRONTIERE_WAN",
@ -1741,7 +1763,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
}
regles.append({
"sens": "in",
"interface": if_wan,
"interface": if_public,
"protocole": "udp",
"source": "any",
"destination": "SETOPS_FRONTIERE_WAN",
@ -1754,6 +1776,23 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"voie demande sur l'Internet."),
})
# LE RETOUR ET LA SORTIE PAR LE RELAIS (2026-10-13). Une seule passe, a la fin : toute
# regle ENTRANTE de la face publique porte le `reply-to` ; toute sortie vers l'Internet
# d'un locataire qui a une adresse au pool passe par la meme passerelle, ou sa
# traduction (section NAT, sur `if_public`) l'attend. Les sorties du site, elles, restent
# sur le WAN local. Une regle posee plus haut sans y penser est ainsi couverte.
if gw_public:
_relayes = set(ips_publiques)
for _r in regles:
# `pass` seulement : un blocage (le silence) ne repond pas, et un `reply-to` sur
# un `block` n'aurait aucun sens pour pf.
if (_r["interface"] == if_public and _r["sens"] == "in"
and str(_r.get("action") or "pass") == "pass"):
_r["replyto"] = gw_public
elif (_r["sens"] == "out" and _r.get("tenant") in _relayes
and str(_r.get("destination") or "").startswith("!")):
_r["passerelle"] = gw_public
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
_ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit)
@ -1765,6 +1804,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"api_url": str(intrants.get("opnsense_api_url") or "").strip(),
"wan_ip": str(intrants.get("opnsense_wan_ip") or "").strip(),
"if_wan": _ou_marqueur(intrants, "opnsense_if_wan", IF_WAN),
**({"if_publique": if_public, "passerelle_publique": gw_public} if gw_public else {}),
"if_transit": _ou_marqueur(intrants, "opnsense_if_transit", IF_TRANSIT),
"if_gestion": _ou_marqueur(intrants, "opnsense_if_gestion", IF_GESTION),
"reseaux_gestion": reseaux_gestion,
@ -1939,9 +1979,11 @@ def rendre(devis: dict) -> str:
# « pass » devant une regle de blocage serait pire qu'un devis muet.
_act = str(rg.get("action") or "pass")
_mut = "" if rg.get("journaliser", False) or _act == "pass" else " [non consigne]"
_via = (f" reply-to {rg['replyto']}" if rg.get("replyto") else "") + \
(f" route-to {rg['passerelle']}" if rg.get("passerelle") else "")
out.append(
f"{_act:4} in on {rg['interface']:8} proto {rg['protocole']:3} "
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7}{_via} "
f"# {rg['role']} ({rg['chiffrement']}){_mut}"
)
out.append(f"# {rg['raison']}")
@ -2020,7 +2062,7 @@ def verifier(devis: dict) -> tuple[bool, list[str]]:
# 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")
wan = devis.get("if_publique") or 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"])
@ -2078,7 +2120,7 @@ def verifier(devis: dict) -> tuple[bool, list[str]]:
+ " n'est attache a aucun plan local de la frontiere — le paquet "
"arriverait par une autre interface et la regle ne matcherait jamais."
)
if r["interface"] == devis.get("if_wan") and locaux:
if r["interface"] in (devis.get("if_wan"), devis.get("if_publique")) and locaux:
erreurs.append(
f"Regle sur {r['interface']} (WAN) avec la source {src} : "
+ ", ".join(locaux)

View file

@ -2863,12 +2863,15 @@ def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]:
if '"action": "reject"' not in front:
fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un "
"flux mal declare entre deux zones expirerait au lieu d'echouer")
if "i != if_wan" not in front:
fautes.append("le refus audible de la frontiere n'exclut pas le WAN : la bordure "
"repondrait aux balayages d'Internet")
# LA FACE PUBLIQUE PAR RELAIS (2026-10-13) est l'Internet au meme titre que le WAN : le
# refus audible doit exclure les DEUX. La garde exige donc la forme qui les nomme
# ensemble ; l'ancienne (`i != if_wan`) laisserait le tunnel du relais repondre.
if "i not in (if_wan, if_public)" not in front:
fautes.append("le refus audible de la frontiere n'exclut pas le WAN et la face "
"publique : la bordure repondrait aux balayages d'Internet")
actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front))
| set(re.findall(r'action.*?or\s+"(\w+)"', front)))
mesures.append("frontiere : WAN muet, interfaces internes audibles (actions : "
mesures.append("frontiere : WAN et face publique muets, interfaces internes audibles (actions : "
+ ", ".join(actions or ["pass"]) + ")")
if fautes:
@ -3600,12 +3603,17 @@ def _noms_exposes(f_app) -> dict:
def _plan_du_site_applications():
"""Le `applications.yml` du site MONTE, ou None s'il n'y en a pas."""
lien = RACINE / "underlay.yml"
if not lien.exists():
return None
f = lien.resolve().parent / "plan" / "applications.yml"
return f if f.is_file() else None
"""Le `applications.yml` du site MONTE, ou None s'il n'y en a pas.
PAR `underlay.plan_site()`, PAS PAR LE LIEN (2026-10-12). Cette fonction suivait le
symlink `underlay.yml` elle-meme, et ignorait donc `SETOPS_UNDERLAY` — que l'inventaire,
lui, honore. Juger le site de reprise de TechnoLibre sans toucher au lien comparait ses
noms (`socle.internal`) a ceux du plan de Chezlepro (`genese.internal`) : six faux ecarts.
"""
import underlay as _u
d = _u.plan_site()
f = (d / "applications.yml") if d else None
return f if f and f.is_file() else None
def _inventaire_du_site():

View file

@ -38,7 +38,9 @@ SENS = {"ingress", "egress"}
# Sans lui, la decouverte de MTU de chemin ne peut pas etre DECLAREE — et une bordure
# en default-deny la casse en silence des que l'overlay descend sous 1500.
PROTO = {"tcp", "udp", "icmp"}
CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"}
# `wireguard` (2026-10-13) : le tunnel du relais. Le devis de la frontiere l'employait deja
# pour ses propres regles ; un role qui le declare ne pouvait pas, et `clair` aurait menti.
CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "wireguard", "clair", "n-a"}
# `admin` : les RESEAUX d'administration (intrant `nftables_admin_ssh`), pas des
# hotes. C'est le troisieme chemin d'arrivee, distinct de `flotte` (le tenant) et
# d'`externe` (Internet) — l'exploitant n'est ni l'un ni l'autre.

View file

@ -1050,10 +1050,12 @@ def inventaire() -> dict:
# (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION,
# la seule que le poste et le runner routent — les autres ne sont pas joignables
# d'ou l'on parle.
# LA PREMIERE DECLAREE. Les deux sites ecrivent l'adresse d'administration en tete
# (`grappe-controle` chez Chezlepro, `management` chez TechnoLibre). Un filtre
# `startswith("192.168.11.")` tenait lieu de regle : il ecartait EN SILENCE tout
# hyperviseur d'un autre site — `maurice` n'etait dans aucun inventaire (2026-10-13).
if _nom in _hyperviseurs:
continue
if not _ip.startswith("192.168.11."):
continue
_hyperviseurs[_nom] = _ip
for _nom, _ip in sorted(_hyperviseurs.items()):
@ -1129,6 +1131,56 @@ def inventaire() -> dict:
# ce que la route par defaut gelee empeche. D'ou metriques TIREES, journaux POUSSES.
groupes.setdefault("client_journal", []).append(_nom)
# LE RELAIS — la face publique d'un site qui n'a qu'une adresse DHCP (2026-10-13).
#
# Un VPS hors du site, que nous administrons. Il n'est PAS dans `plan/serveurs.yml` :
# tout ce qu'une machine du plan recoit descend des zones du site (temps et resolveur
# par la patte de sa zone, cache apt, certificat de l'autorite, journaux et metriques
# vers la supervision, pare-feu de zone) — et le VPS, sur l'Internet, n'atteint rien de
# cela. Comme un hyperviseur, c'est un EQUIPEMENT de la carte, decrit ici a part.
#
# Joint DIRECTEMENT sur sa console (aucun ProxyJump : la frontiere n'est pas sur son
# chemin). Tout ce qu'il porte se DERIVE de ce qui est deja declare ; rien n'est recopie.
for _h in U.hotes(u):
if str(_h.get("role")) != "relais":
continue
_nom = str(_h.get("nom") or "")
_r = par_reseau.get(_h.get("reseau")) or {}
_pair = next((x for x in U.hotes(u) if x.get("reseau") == _h.get("reseau")
and str(x.get("role")) == "frontiere"), {})
import site_intrants as _SI
_relayees = sorted({a for a in [_SI._ip_publique_site(), *_SI.pool_ips_publiques()] if a})
_manque = [k for k, v in (("console", _h.get("console")),
("ip", _h.get("ip")),
("sous_reseau du reseau", _r.get("sous_reseau")),
("frontiere sur le meme reseau", _pair.get("ip")),
("cle_publique de la frontiere", _pair.get("cle_publique")),
("adresses relayees (opnsense.yml)", _relayees),
("index du site", u.get("index"))) if not v]
if _manque:
print(f"note : relais '{_nom}' NON inventorie — il manque : {', '.join(_manque)}",
file=sys.stderr)
continue
hostvars[_nom] = {
"ansible_host": str(_h["console"]),
"ansible_user": UTILISATEUR_DEFAUT,
"setops_site": True,
"serveur_relais_tunnel_adresse": f"{_h['ip']}/{str(_r['sous_reseau']).split('/')[1]}",
# Le port derive de l'index, comme ceux des tunnels d'administration des
# locataires (52000 + index) : deux sites ne peuvent pas collisionner.
"serveur_relais_tunnel_port": 51800 + int(u["index"]),
"serveur_relais_tunnel_mtu": int(_r.get("mtu") or 1420),
"serveur_relais_pair_nom": str(_pair.get("nom") or "frontiere"),
"serveur_relais_pair_adresse": str(_pair["ip"]),
"serveur_relais_pair_cle_publique": str(_pair["cle_publique"]),
"serveur_relais_adresses_relayees": _relayees,
"serveur_relais_site": str((U.chemin().resolve().parent.name if U.chemin() else "") or ""),
# La cle du site, comme ailleurs ; PAS le ProxyJump des autres hotes.
**({"ansible_ssh_private_key_file": communes["ansible_ssh_private_key_file"]}
if communes.get("ansible_ssh_private_key_file") else {}),
}
groupes.setdefault("serveur_relais", []).append(_nom)
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
out["site"] = {"hosts": sorted(hostvars)}
out["_meta"] = {"hostvars": hostvars}

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Une face publique par relais : chaque entree y repart par ou elle est arrivee.
POURQUOI (2026-10-13). Le site de TechnoLibre recoit ses adresses fixes par un tunnel vers
un VPS (le relais), sur sa propre interface. Trois pings depuis l'Internet sont arrives sur
`wg0` de la frontiere et n'ont recu AUCUNE reponse : OPNsense ne pose `reply-to` que sur une
interface dont la passerelle amont est declaree, et la reponse repartait par la route par
defaut — le WAN. Une seule regle oubliee suffit a publier un service qui ne repond jamais.
Sur le VRAI devis (sans site visible, le test se saute) :
- si une face publique est declaree (`if_publique`) :
* toute regle ENTRANTE de cette interface porte `replyto` ;
* toute sortie vers l'Internet d'un locataire au pool porte la passerelle du relais,
et sa traduction sortante est sur la face publique ;
* aucune regle `reject` n'y repond (elle est l'Internet, comme le WAN) ;
* le WAN ne porte plus aucune entree `pass` (tout ce qui est publie a demenage) ;
* aucune redirection n'y vise `wanip` (l'adresse du tunnel) ;
- sinon (temoin : le site de Chezlepro), AUCUNE regle ne porte `replyto` ni passerelle —
la nouveaute ne deborde pas sur un site qui ne l'a pas declaree.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
def main() -> int:
r = subprocess.run([sys.executable, "scripts/devis_opnsense.py", "--json"],
capture_output=True, text=True, cwd=RACINE)
if r.returncode != 0 or not r.stdout.strip().startswith("{"):
print("SAUTE le devis de la frontiere ne se genere pas ici (site invisible)")
return 0
devis = json.loads(r.stdout)
regles = devis.get("regles") or []
pub, gw, wan = devis.get("if_publique"), devis.get("passerelle_publique"), devis.get("if_wan")
echecs: list[str] = []
if not pub:
portant = [x for x in regles if x.get("replyto") or x.get("passerelle")]
if portant:
echecs.append(f"{len(portant)} regle(s) portent reply-to/passerelle sans face "
f"publique declaree")
for e in echecs:
print("ECHEC " + e)
if not echecs:
print("OK aucune face publique declaree : aucune regle ne porte reply-to ni "
"passerelle (temoin)")
return 1 if echecs else 0
entrees = [x for x in regles if x.get("interface") == pub and x.get("sens") == "in"
and str(x.get("action") or "pass") == "pass"]
bloquees = [x for x in regles if x.get("interface") == pub and x.get("action") == "block"
and x.get("replyto")]
for x in bloquees:
echecs.append(f"{pub} : un blocage ({x.get('role')}) porte reply-to")
sans = [x for x in entrees if x.get("replyto") != gw]
if not entrees:
echecs.append(f"aucune regle entrante sur la face publique {pub}")
for x in sans:
echecs.append(f"{pub} : {x.get('role')} -> {x.get('destination')} sans reply-to {gw}")
relayes = {str(n.get("tenant")) for n in devis.get("nat") or [] if n.get("interface") == pub}
sorties = [x for x in regles if x.get("sens") == "out" and x.get("tenant") in relayes
and str(x.get("destination") or "").startswith("!")]
for x in sorties:
if x.get("passerelle") != gw:
echecs.append(f"sortie de {x.get('tenant')} ({x.get('role')}) sans passerelle {gw}")
# Sur un relais, `wanip` est l'adresse du TUNNEL : une redirection qui la vise ne
# recoit jamais rien (trouve le 2026-10-13, au premier chargement sur requin).
for d in devis.get("redirections") or []:
if d.get("interface") == pub and str(d.get("destination") or "wanip") == "wanip":
echecs.append(f"redirection {d.get('role')} {d.get('protocole')}/{d.get('port_public')} "
f"vise `wanip` sur {pub} : l'adresse du tunnel, que l'Internet ne frappe pas")
if any(x.get("action") == "reject" and x.get("interface") == pub for x in regles):
echecs.append(f"un `reject` sur {pub} : la face publique repondrait aux balayages")
restes = [x for x in regles if x.get("interface") == wan and x.get("sens") == "in"
and str(x.get("action") or "pass") == "pass"]
for x in restes:
echecs.append(f"entree encore sur le WAN : {x.get('role')} -> {x.get('destination')}")
for e in echecs:
print("ECHEC " + e)
if echecs:
return 1
print(f"OK face publique {pub} : {len(entrees)} entree(s), toutes reply-to {gw} ; "
f"{len(sorties)} sortie(s) de {len(relayes)} locataire(s) par {gw} ; WAN sans entree.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -33,11 +33,14 @@ def main() -> int:
# `if_wan`, la cle que le devis produit. Le test lisait `interfaces.wan`, qui n'existe
# pas : il retombait sur « wan », juste au site par chance, faux sur le modele public.
wan = str(devis.get("if_wan") or "wan")
# La face publique par relais (2026-10-13) est l'Internet, comme le WAN : muette aussi.
muettes = {wan} | ({str(devis["if_publique"])} if devis.get("if_publique") else set())
echecs = []
refus = [x for x in regles if x.get("action") == "reject"]
if any(x.get("interface") == wan for x in refus):
echecs.append("un `reject` sur le WAN : la bordure repondrait aux balayages")
interfaces = {x.get("interface") for x in regles} - {wan}
for m in sorted(muettes):
if any(x.get("interface") == m for x in refus):
echecs.append(f"un `reject` sur {m} : la bordure repondrait aux balayages")
interfaces = {x.get("interface") for x in regles} - muettes
for itf in sorted(i for i in interfaces if i):
siens = [x for x in regles if x.get("interface") == itf]
rj = [x for x in siens if x.get("action") == "reject"]

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Le NAT sortant en mode `automatic` arrete l'application, il ne duplique plus en silence.
POURQUOI (2026-10-13, site de TechnoLibre). En mode `automatic`, OPNsense ignore les regles
manuelles et l'API ne les liste pas : les traductions du devis s'ecrivaient sans jamais se
charger, et le plan suivant les recreait. `_mode_nat_sortant` lit le mode ; ce test le joue
sur un boitier SIMULE (aucune frontiere requise) :
- `automatic` est reconnu (c'est lui qui arrete l'application) ;
- `hybrid` est reconnu (temoin : la garde ne bloque pas un boitier conforme) ;
- un boitier qui refuse la lecture rend None — dit « illisible », sans arreter le
script (un boitier anterieur ne doit pas perdre sa frontiere pour une garde).
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import appliquer_opnsense as A # noqa: E402
def _boitier(mode):
def api(chemin, corps=None):
if chemin != "/api/firewall/source_nat/get":
return {}
if mode is None:
return {"_erreur": "404 Not Found"}
return {"filter": {"general": {"snat_mode": {
m: {"value": m, "selected": int(m == mode)}
for m in ("automatic", "hybrid", "advanced", "disabled")}}}}
return api
def main() -> int:
echecs = 0
for donne, attendu in (("automatic", "automatic"), ("hybrid", "hybrid"), (None, None)):
try:
vu = A._mode_nat_sortant(_boitier(donne))
except SystemExit as e:
vu = f"ARRET ({str(e)[:40]})"
ok = vu == attendu
echecs += not ok
print(f"{'OK ' if ok else 'ECHEC '} boitier {donne or 'muet'} -> {vu}")
return 1 if echecs else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Un lien de l'underlay peut etre un TUNNEL : ni VLAN, ni port physique.
POURQUOI (2026-10-13). Le site de TechnoLibre n'a qu'une adresse DHCP ; ses adresses fixes
sont louees sur un VPS (le relais) et lui arrivent par un tunnel WireGuard. Ce lien porte un
sous-reseau de liaison mais aucune etiquette — la validation exigeait un `vlan` ou
`segment_physique`, et l'aurait refuse. `tunnel: wireguard` le dit.
Sur un underlay SYNTHETIQUE (aucun site requis) :
- un tunnel declare passe ;
- un type de tunnel inconnu est refuse (faute de frappe, pas un tunnel) ;
- `tunnel` et `vlan` ensemble sont refuses ;
- sans `tunnel` ni `vlan`, le lien est refuse comme avant (temoin : la garde n'a pas
ete relachee pour tout le monde) ;
- le role d'hote `relais` est connu, un role mal ecrit ne l'est pas.
"""
from __future__ import annotations
import copy
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import underlay as U # noqa: E402
BASE = {
"index": 31,
"reseaux": [
{"nom": "management", "nature": "fabric", "segment_physique": True,
"sous_reseau": "10.31.0.0/24", "passerelle": "10.31.0.1"},
{"nom": "relais", "nature": "fabric", "fabric": "relais", "tunnel": "wireguard",
"sous_reseau": "10.31.60.0/30", "mtu": 1420},
],
"hotes": [
{"nom": "requin", "reseau": "management", "ip": "10.31.0.1", "role": "frontiere"},
{"nom": "requin", "reseau": "relais", "ip": "10.31.60.2", "role": "frontiere"},
{"nom": "relais", "reseau": "relais", "ip": "10.31.60.1", "role": "relais"},
],
}
def _erreurs(modif=None, sur="relais") -> list[str]:
u = copy.deepcopy(BASE)
if modif:
modif(next(r for r in u["reseaux"] if r["nom"] == sur), u)
return [e for e in U.valider(u) if "relais" in e or "relai" in e]
def main() -> int:
cas = [
("tunnel declare", None, False),
("type de tunnel inconnu", lambda r, u: r.__setitem__("tunnel", "wireguardd"), True),
("tunnel et vlan ensemble", lambda r, u: r.__setitem__("vlan", 60), True),
("ni tunnel ni vlan (temoin)", lambda r, u: r.pop("tunnel"), True),
("role relais mal ecrit",
lambda r, u: next(h for h in u["hotes"] if h["nom"] == "relais").__setitem__("role", "relai"),
True),
]
echecs = 0
for nom, modif, doit_refuser in cas:
e = _erreurs(modif)
if bool(e) != doit_refuser:
echecs += 1
print(f"ECHEC {nom} : {'accepte' if not e else e}")
else:
print(f"OK {nom}" + (f" — refuse : {e[0][:70]}…" if e else ""))
return 1 if echecs else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -429,7 +429,18 @@ def _ports_a_plat(hote: dict) -> list[str]:
# adresse, des choses sortent par lui — mais aucun devis ne le configure et il ne recoit
# aucune regle. Le declarer n'est pas l'adopter : c'est empecher le validateur de crier
# a la passerelle fantome pour une passerelle qui, elle, est bien reelle.
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont")
#
# `relais` (2026-10-13) : une machine HORS du site, que nous administrons (role
# `serveur_relais`), et qui porte sa face publique. Le site de TechnoLibre n'a qu'une
# adresse DHCP ; ses adresses fixes sont louees sur un VPS et lui arrivent par un tunnel.
# Ce n'est pas une `passerelle_amont` — celle-ci, nous ne la configurons jamais.
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont", "relais")
# UN LIEN QUI N'EST NI UN VLAN NI UN PORT (2026-10-13). Le tunnel vers le relais porte
# un sous-reseau de liaison, mais aucune etiquette : il ne traverse aucun commutateur.
# `tunnel: <type>` le dit et dispense de `vlan`, comme `segment_physique`. Liste FERMEE :
# un type inconnu est une faute de frappe, pas un tunnel.
TUNNELS = ("wireguard",)
def role_hote(hote: dict) -> str:
@ -639,11 +650,20 @@ def valider(underlay: dict | None,
# trunke qui n'existe nulle part : trois sondes depuis asgard (bond0 tag 10,
# bond3 tag 10, vmbr1 natif) sont restees muettes, temoin positif a l'appui.
# `segment_physique: true` dit cette nature, et dispense d'etiquette.
if r.get("segment_physique") and vlan is None:
tun = r.get("tunnel")
if tun is not None and str(tun) not in TUNNELS:
erreurs.append(f"reseau '{nom}': tunnel '{tun}' inconnu (attendu : {', '.join(TUNNELS)})")
continue
if tun is not None and vlan is not None:
erreurs.append(f"reseau '{nom}': un tunnel ne porte pas de VLAN — `tunnel` et `vlan` "
f"s'excluent")
continue
if (r.get("segment_physique") or tun is not None) and vlan is None:
vlan = None
elif vlan is None or sous is None:
erreurs.append(f"reseau '{nom}': 'vlan' et 'sous_reseau' sont requis "
f"(ou `segment_physique: true` si le reseau n'est pas etiquete)")
f"(ou `segment_physique: true` si le reseau n'est pas etiquete, "
f"`tunnel: wireguard` s'il passe dans un tunnel)")
continue
if sous is None:
erreurs.append(f"reseau '{nom}': 'sous_reseau' est requis")

View file

@ -43,8 +43,11 @@ MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")
# ils ne vivent PLUS dans la voute d'un tenant mais dans celle de l'underlay, chez
# l'hebergeur (docs/frontiere-physique-virtuel.md). Un tenant qui les porte encore est un
# reste de migration, pas une exigence — d'ou leur retrait des attentes du gabarit.
# `vault_relais_wg_cle_privee` (2026-10-13) : la cle du relais de la face publique, meme
# nature — elle appartient au SITE (`underlay.vault.yml`), jamais a un locataire.
SECRETS_MONDE_PHYSIQUE = {"proxmox_api_token_id", "proxmox_api_token_secret",
"vault_opnsense_api_key", "vault_opnsense_api_secret"}
"vault_opnsense_api_key", "vault_opnsense_api_secret",
"vault_relais_wg_cle_privee"}
SECRETS_HORS_MOTIF: set[str] = set()
@ -339,7 +342,10 @@ def main() -> int:
print(f"{nom:<34} {etat:<18} <- {', '.join(sorted(exiges[nom]))}")
return 0
manquants = sorted(set(exiges) - presentes)
# Un secret du SITE (monde physique) n'a rien a faire au gabarit d'un LOCATAIRE, meme
# quand un role l'exige : il est servi par `underlay.vault.yml`. Le retrait ne valait
# que pour les cles inutiles ; le premier role a en exiger un l'a montre (2026-10-13).
manquants = sorted(set(exiges) - presentes - SECRETS_MONDE_PHYSIQUE)
inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF
- SECRETS_MONDE_PHYSIQUE)