diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a61564..37b27d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index bc8afe5..cbc3503 100644 --- a/Makefile +++ b/Makefile @@ -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= **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 diff --git a/docs/couches-deploiement.yml b/docs/couches-deploiement.yml index f6bef52..2d38502 100644 --- a/docs/couches-deploiement.yml +++ b/docs/couches-deploiement.yml @@ -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 diff --git a/docs/flux-conception.md b/docs/flux-conception.md index 19ed128..2941b09 100644 --- a/docs/flux-conception.md +++ b/docs/flux-conception.md @@ -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 diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 28f74b6..1b742a4 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 diff --git a/playbooks/groupes/serveur_relais.yml b/playbooks/groupes/serveur_relais.yml new file mode 100644 index 0000000..9e6f2f4 --- /dev/null +++ b/playbooks/groupes/serveur_relais.yml @@ -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 diff --git a/playbooks/site.yml b/playbooks/site.yml index 97204a3..70398d8 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -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 diff --git a/roles/serveur_relais/README.md b/roles/serveur_relais/README.md new file mode 100644 index 0000000..14cc1ae --- /dev/null +++ b/roles/serveur_relais/README.md @@ -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-/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. diff --git a/roles/serveur_relais/defaults/main.yml b/roles/serveur_relais/defaults/main.yml new file mode 100644 index 0000000..fad85fa --- /dev/null +++ b/roles/serveur_relais/defaults/main.yml @@ -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 diff --git a/roles/serveur_relais/handlers/main.yml b/roles/serveur_relais/handlers/main.yml new file mode 100644 index 0000000..b58f7a1 --- /dev/null +++ b/roles/serveur_relais/handlers/main.yml @@ -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 diff --git a/roles/serveur_relais/meta/authentification.yml b/roles/serveur_relais/meta/authentification.yml new file mode 100644 index 0000000..fca0427 --- /dev/null +++ b/roles/serveur_relais/meta/authentification.yml @@ -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. diff --git a/roles/serveur_relais/meta/empreinte.yml b/roles/serveur_relais/meta/empreinte.yml new file mode 100644 index 0000000..1146c0e --- /dev/null +++ b/roles/serveur_relais/meta/empreinte.yml @@ -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 diff --git a/roles/serveur_relais/meta/flux.yml b/roles/serveur_relais/meta/flux.yml new file mode 100644 index 0000000..ce98ba5 --- /dev/null +++ b/roles/serveur_relais/meta/flux.yml @@ -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. diff --git a/roles/serveur_relais/tasks/main.yml b/roles/serveur_relais/tasks/main.yml new file mode 100644 index 0000000..39786ca --- /dev/null +++ b/roles/serveur_relais/tasks/main.yml @@ -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 diff --git a/roles/serveur_relais/templates/nftables.conf.j2 b/roles/serveur_relais/templates/nftables.conf.j2 new file mode 100644 index 0000000..550b228 --- /dev/null +++ b/roles/serveur_relais/templates/nftables.conf.j2 @@ -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" + } +} diff --git a/roles/serveur_relais/templates/wg0.conf.j2 b/roles/serveur_relais/templates/wg0.conf.j2 new file mode 100644 index 0000000..b169f80 --- /dev/null +++ b/roles/serveur_relais/templates/wg0.conf.j2 @@ -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(', ') }} diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 5dd11fb..d4acc9a 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -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. diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index bbf1d67..d2f6297 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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) diff --git a/scripts/prouver.py b/scripts/prouver.py index a2fc897..61e0373 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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(): diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index cadda9c..be695b1 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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. diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 85bdfb3..5b868aa 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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} diff --git a/scripts/tests/test_face_publique_relais.py b/scripts/tests/test_face_publique_relais.py new file mode 100644 index 0000000..0ffb3d9 --- /dev/null +++ b/scripts/tests/test_face_publique_relais.py @@ -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()) diff --git a/scripts/tests/test_frontiere_refus.py b/scripts/tests/test_frontiere_refus.py index 13da5c1..dda43bf 100644 --- a/scripts/tests/test_frontiere_refus.py +++ b/scripts/tests/test_frontiere_refus.py @@ -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"] diff --git a/scripts/tests/test_mode_nat_sortant.py b/scripts/tests/test_mode_nat_sortant.py new file mode 100644 index 0000000..57cdf22 --- /dev/null +++ b/scripts/tests/test_mode_nat_sortant.py @@ -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()) diff --git a/scripts/tests/test_underlay_tunnel.py b/scripts/tests/test_underlay_tunnel.py new file mode 100644 index 0000000..b2a0c51 --- /dev/null +++ b/scripts/tests/test_underlay_tunnel.py @@ -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()) diff --git a/scripts/underlay.py b/scripts/underlay.py index cb91948..4603bd1 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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: ` 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") diff --git a/scripts/voute.py b/scripts/voute.py index be89236..48b86a5 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -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)