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:
parent
f3bb9caee3
commit
f9e45b1676
30 changed files with 886 additions and 50 deletions
83
CHANGELOG.md
83
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
|
||||
|
|
|
|||
7
Makefile
7
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=<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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
playbooks/groupes/serveur_relais.yml
Normal file
19
playbooks/groupes/serveur_relais.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
63
roles/serveur_relais/README.md
Normal file
63
roles/serveur_relais/README.md
Normal 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.
|
||||
34
roles/serveur_relais/defaults/main.yml
Normal file
34
roles/serveur_relais/defaults/main.yml
Normal 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
|
||||
23
roles/serveur_relais/handlers/main.yml
Normal file
23
roles/serveur_relais/handlers/main.yml
Normal 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
|
||||
9
roles/serveur_relais/meta/authentification.yml
Normal file
9
roles/serveur_relais/meta/authentification.yml
Normal 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.
|
||||
9
roles/serveur_relais/meta/empreinte.yml
Normal file
9
roles/serveur_relais/meta/empreinte.yml
Normal 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
|
||||
25
roles/serveur_relais/meta/flux.yml
Normal file
25
roles/serveur_relais/meta/flux.yml
Normal 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.
|
||||
96
roles/serveur_relais/tasks/main.yml
Normal file
96
roles/serveur_relais/tasks/main.yml
Normal 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
|
||||
33
roles/serveur_relais/templates/nftables.conf.j2
Normal file
33
roles/serveur_relais/templates/nftables.conf.j2
Normal 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"
|
||||
}
|
||||
}
|
||||
24
roles/serveur_relais/templates/wg0.conf.j2
Normal file
24
roles/serveur_relais/templates/wg0.conf.j2
Normal 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(', ') }}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
92
scripts/tests/test_face_publique_relais.py
Normal file
92
scripts/tests/test_face_publique_relais.py
Normal 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())
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
48
scripts/tests/test_mode_nat_sortant.py
Normal file
48
scripts/tests/test_mode_nat_sortant.py
Normal 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())
|
||||
71
scripts/tests/test_underlay_tunnel.py
Normal file
71
scripts/tests/test_underlay_tunnel.py
Normal 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())
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue