frontière nord/sud : devis dérivé, lien de transit et les deux routes

La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.

`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.

Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.

Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.

La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.

Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
  garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
  avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
  secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
  playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
  reliquat de compatibilité.

Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-01 19:32:04 -04:00
parent 4a1d0836d0
commit 3698152b6a
13 changed files with 820 additions and 9 deletions

View file

@ -1,5 +1,114 @@
# CHANGELOG — Set-OPS
## 2026-08-01 — le lien de transit et les deux routes (la boucle est fermée)
### Ajouté — réseau de transit dans l'underlay (clé `passerelle_sortie`)
Le lien entre le routeur est-ouest (switches L3) et la frontière nord/sud manquait dans
**tous** les fichiers : le devis switch ne contenait pas une seule `ip route`.
Il vit dans l'**underlay** et non dans un tenant, pour une raison qui tranche : la frontière
route vers tous les supernets tenants par le **même** prochain saut. Le lien est donc partagé
et ne peut dériver d'aucun `index`. Un réseau underlay portant `passerelle_sortie` (l'adresse
du pare-feu sur le lien) le déclare — chez Chezlepro : VLAN 40, `10.0.4.0/29`, SVI `10.0.4.1`,
frontière `10.0.4.2`. Un `/29` et non un `/30` parce que deux pare-feux cohabitent pendant la
transition.
`underlay.py` valide la nouvelle clé (sortie sur le lien, distincte du SVI, SVI obligatoire,
un seul transit) — **preuve P23**, cas de rejet exercés un par un.
### Ajouté — `devis-reseau` émet les routes (section 5)
Deux routes dérivées, et il en faut impérativement deux :
- **l'aller** : `ip route 0.0.0.0 0.0.0.0 <sortie>` — sans elle, aucun hôte n'a de sortie ;
- **le retour** : une route par réseau d'administration — sans elle, la réponse d'une VM
revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence. C'est le piège qui a coûté la passe de déploiement du 2026-07-29.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : **même source
unique** que la garde anti-lockout des nftables et l'alias `SETOPS_ADMIN` de la frontière —
les trois pare-feux et les routes ne peuvent pas diverger. Sans transit déclaré, la section
s'affiche en clair comme manquante plutôt que de disparaître silencieusement.
### Corrigé — le panneau refusait d'enregistrer les intrants de la frontière
`group_vars/opnsense.yml` porte à la fois des paramètres anodins et deux **références** de
voûte (`{{ vault_opnsense_api_key }}`). La fusion « préserve les clés non gérées » les
relisait, et le garde-fou, qui ne regardait que les **noms**, les prenait pour des secrets
soumis. Il regarde désormais la **valeur** : une référence de voûte est un pointeur et passe ;
toute valeur réelle sur ces clés fait toujours échouer l'écriture. Ajout d'un refus explicite
à l'entrée pour une requête forgée, au lieu d'un abandon silencieux.
### Retiré — reliquat `proxmox.vault.yml`
La voûte est **unique** (`group_vars/all/vault.yml`) ; l'ancien fichier séparé n'était plus
chargé automatiquement (aucun groupe `proxmox` dans l'inventaire) et entretenait la confusion.
Supprimé de l'instance, avec son gabarit.
Au passage : `supprimer_vm_debian.yml` ne chargeait **que** ce reliquat pour ses secrets. Le
supprimer tel quel aurait cassé `make detruire`, l'outil même du rebuild from-zero. Sa liste
est alignée sur celle du playbook de clonage (`all/vault.yml` en dernier, il l'emporte), et la
résolution du jeton depuis la voûte unique est vérifiée en exécution réelle.
## 2026-07-29 (suite) — la frontière OPNsense, dérivée du registre des flux
### Ajouté — `make devis-opnsense` (+ preuve P24)
La bordure nord/sud devient un **artefact dérivé**, comme le devis switch. Rien de saisi à la
main : ni port, ni adresse, ni nom d'hôte n'apparaît dans le générateur.
Le constat qui rend la chose évidente : `resoudre_flux.py` **saute volontairement** les flux
`pair: externe` (`scripts/resoudre_flux.py:184`) parce qu'ils ne concernent pas le pare-feu
d'hôte. Plusieurs `raison` du registre disent déjà « filtré à l'OPNsense ». **La politique de
la frontière était donc déjà écrite** — il ne restait qu'à la dériver.
- **`scripts/devis_opnsense.py`** — agrège les flux `externe`, résout les destinations depuis
l'inventaire (hôtes actifs **et** planifiés : la frontière se prépare avant les VM), les
supernets depuis les nomenclatures fédérées, et l'accès d'administration depuis l'intrant
`nftables_admin_ssh`. Sort un devis relisible ou `--json` (destiné à l'API OPNsense).
- **Garde anti-lockout (P24)**`--verifier` **refuse** un devis dont `nftables_admin_ssh`
est vide : la règle SSH entrante n'aurait aucune source et le `block in` final fermerait
l'accès d'administration. Même intrant que les nftables d'hôte : source unique, donc pas de
divergence possible entre la bordure et les hôtes.
- **Section 0 du devis : les routes de retour à poser sur les switches.** C'est le piège qui a
coûté la passe de déploiement du jour — la passerelle de zone répond au ping, mais aucun
hôte derrière elle n'est joignable, parce que la réponse revient au pare-feu par une autre
interface que celle où l'état a été créé.
- **`docs/frontiere-opnsense.md`** — les décisions d'architecture (frontière nord/sud, les SVI
restent sur les switches L3), le partage des rôles entre les trois pare-feux, le chemin
d'application par l'API, et ce qui reste ouvert (câblage, second VPN, sortie générale).
### Corrigé — intrant `nftables_admin_ssh` vide sur l'instance Chezlepro
Il valait `[]` alors que `group_vars/hotes_actifs.yml` active `nftables_baseline_enabled`.
Autrement dit : la flotte se serait mise en `policy drop` sans **aucune** règle autorisant le
contrôleur, qui arrive par le VPN hors du sous-réseau de la flotte. Renseigné à
`192.168.255.0/24`. Le sous-réseau du second VPN (OPNsense) devra y être ajouté.
### Ajouté — la frontière devient réglable depuis la console (section *Frontière*)
Les valeurs non sensibles du pare-feu de bordure sont de **vrais intrants**, pas un fichier
YAML tenu à la main : un opérateur règle la frontière depuis le panneau « Intrants de base »,
sans IA et sans éditeur.
- **`INTRANTS_SCHEMA`** — nouvelle section *Frontière* : `opnsense_api_url`,
`opnsense_api_verifier_certs`, `opnsense_if_wan`, `opnsense_if_transit`,
`opnsense_prochain_saut`. Cible d'écriture `group_vars/opnsense.yml`, en **fusion** (comme
Proxmox) pour préserver les références de voûte du fichier. Le panneau rend ses sections
génériquement : aucune modification d'interface n'a été nécessaire.
- **Garde-fou**`opnsense_api_key` / `opnsense_api_secret` ajoutés à
`INTRANTS_CLES_INTERDITES` : le GUI **refuse** de les écrire, donc impossible de coller un
secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur
nom, dans `SECRETS_ATTENDUS`.
- **`devis_opnsense.py`** lit désormais ces intrants et n'affiche ses marqueurs
(`<IF-TRANSIT>`, `<PROCHAIN-SAUT-SWITCH>`) qu'en repli : le devis se complète de lui-même
dès que la console est renseignée.
### Ajouté — les identifiants d'API de la frontière, dans la voûte
Même partage que Proxmox : l'anodin en clair, le secret dans la voûte unique de l'instance.
- **`group_vars/opnsense.yml`** (instance, en clair) — URL de gestion, interfaces et prochain
saut à figer au câblage, plus les **références par nom** `{{ vault_opnsense_api_key }}` et
`{{ vault_opnsense_api_secret }}`. Aucune valeur de secret n'y figure.
- **Gabarit de voûte** — les deux clés ajoutées à `vault.yml.example`. `scripts/voute.py` les
a recensées **tout seul** depuis les `group_vars` (il ne lit jamais la voûte, il ne compare
que des noms) : le gabarit passe de 23 à 25 secrets, et la **preuve P18** reste verte.
Validation : `make verifier` vert — **24 preuves CONFORME**, 0 échec, 0 sauté.
## 2026-07-29 — dette documentaire soldée (un README par rôle) + carte revue
### Ajouté — les 12 README de rôles manquants

View file

@ -344,6 +344,13 @@ flux: ansible-runtime
devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau converge, derive des nomenclatures. DIALECTE=cisco|binardat
python3 scripts/devis_reseau.py $(if $(DIALECTE),--dialecte $(DIALECTE),)
.PHONY: devis-opnsense devis-opnsense-verifier
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
devis-opnsense-verifier:
python3 scripts/devis_opnsense.py --verifier
.PHONY: underlay
underlay: ## Underlay (fabric physique cluster-global : mgmt/iSCSI/Ceph) : affiche + valide (P23)
python3 scripts/underlay.py

View file

@ -0,0 +1,60 @@
# Preuve de conformite — Set-OPS — 2026-08-01
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (24 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 63 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 32 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles d'instance valident | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-001 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-001 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-001 | ✅ OK | CONFORME : frontiere nord/sud, 6 regles, 2 routes, admin=192.168.255.0/24. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-01._

View file

@ -19,7 +19,7 @@ code + les README de rôles). Cette page comble ces deux trous.
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |
| **Réseau / pare-feu** | `docs/flux-conception.md` (le modèle) → `docs/registre-flux.md` (**généré**, matrice d'audit) ; underlay : `underlay.yml.example` + `make underlay` |
| **Réseau / pare-feu** | `docs/flux-conception.md` (le modèle) → `docs/registre-flux.md` (**généré**, matrice d'audit) `docs/frontiere-opnsense.md` (la bordure nord/sud) ; underlay : `underlay.yml.example` + `make underlay` |
| **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) |
| **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver``docs/audit/preuve-<date>.md`, `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` |
| **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` |
@ -45,6 +45,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
| Voûte au déploiement | secret jamais en clair | `ANSIBLE_VAULT_PASSWORD_FILE` / `~/.config/setops-vault-pass` ; déréférencé par `lookup('vars', <nom>)` | — |
| Multi-instance | un dépôt par écosystème ; l'active = symlink `instance/`, les autres **découvertes par convention** (dossiers frères, aucun registre) | active : symlink `instance/` ; découverte : `scripts/instances.py` / `devis_reseau.py` (glob `../*/plan/nomenclature.yml` avec `index`) ; garde-fou collision : preuve **P21** | `multi-instances.md` |
| Exposition → edge | app expose un FQDN public servi par un edge | `plan/domaines.yml` + `expose` (applications) | `bindings-conception.md` §4 |
| Frontière nord/sud | les flux `pair: externe`**sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` |
> ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier),
> `app→base` côté base (registre, résolu en rôle pour que le secret ne quitte jamais le rôle).

169
docs/frontiere-opnsense.md Normal file
View file

@ -0,0 +1,169 @@
# La frontière OPNsense (nord/sud)
> Le pare-feu de bordure de l'écosystème. Ce document fixe les décisions d'architecture
> prises le **2026-07-29**, et décrit le devis **dérivé** qui en découle
> (`make devis-opnsense`). Il complète `docs/flux-conception.md` (le modèle des flux) et
> `docs/registre-flux.md` (la matrice d'audit).
## 1. Le partage des rôles
Trois pare-feux coexistent dans le modèle, et chacun voit une chose différente :
| Où | Quoi | Généré par |
|---|---|---|
| **Sur chaque hôte** | nftables `policy drop`, moindre privilège par IP source | `make flux` (`resoudre_flux.py`) |
| **Sur les switches L3** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` |
| **À la frontière (OPNsense)** | ce qui **entre et sort** de l'écosystème | `make devis-opnsense` |
**Décision : OPNsense est une frontière nord/sud, pas la passerelle des zones.** Les SVI
restent sur les switches L3, qui continuent d'assurer le routage inter-zone au débit ligne.
OPNsense ne porte **aucun SVI de tenant** et ne voit pas le trafic est-ouest.
Conséquence assumée : l'isolation inter-zone repose sur les ACL de switch, moins expressives
que le registre des flux. Le zéro-confiance est-ouest reste porté par les nftables d'hôte et
le TLS mutuel — pas par le pare-feu de bordure.
## 2. Ce que la frontière décide (et pourquoi rien n'est saisi à la main)
Le registre des flux distingue les pairs par **mot-clé**. Or `resoudre_flux.py` **saute
volontairement** le pair `externe` (`scripts/resoudre_flux.py:184`) : ces flux-là ne
concernent pas le pare-feu d'hôte, ils relèvent de la bordure. Plusieurs `raison` le disent
déjà noir sur blanc — « Frontière publique gérée à l'OPNsense ».
Autrement dit : **la politique de la frontière était déjà écrite dans le registre**, et il ne
restait qu'à la dériver. C'est ce que fait `scripts/devis_opnsense.py`, à partir de :
- `roles/*/meta/flux.yml` — les flux `pair: externe`, qui donnent les règles ;
- l'inventaire de l'instance active — quels hôtes portent quel rôle, donc les destinations ;
- `../*/plan/nomenclature.yml` — les supernets des tenants fédérés, donc les routes ;
- l'intrant `nftables_admin_ssh` — les réseaux d'administration.
Aucun port, aucune adresse et aucun nom d'hôte n'est écrit dans le générateur.
## 3. La garde anti-lockout
Une seule règle entrante ne vient **pas** d'Internet : le SSH de gestion. Sa source est
l'alias `SETOPS_ADMIN`, alimenté par l'intrant `nftables_admin_ssh`**le même** qui nourrit
la garde des nftables d'hôte. Source unique, donc pas de divergence possible entre « ce que
le pare-feu d'hôte laisse passer » et « ce que la bordure laisse entrer ».
`make devis-opnsense-verifier` (**preuve P24**) **refuse** un devis dont cet intrant est vide :
sans lui, la règle SSH n'aurait aucune source et le `block in` final fermerait l'accès
d'administration. Le trou ne peut plus passer inaperçu.
## 4. Le lien de transit, et les deux routes
**C'est le piège qui nous a coûté une passe de déploiement le 2026-07-29.**
### Le lien vit dans l'underlay, pas dans un tenant
La frontière route vers **tous** les supernets tenants (`10.21.0.0/16`, `10.27.0.0/16`…) par
le **même** prochain saut. Le lien qui la relie au routeur est-ouest est donc *partagé* : il
n'appartient à aucun tenant et ne peut dériver d'aucun `index`. Sa place est l'underlay,
cluster-global, au même titre que le management, l'iSCSI et Ceph.
Il se déclare dans `underlay.yml` par la clé **`passerelle_sortie`** — l'adresse du pare-feu
sur ce lien :
```yaml
- nom: transit-frontiere
vlan: 40
sous_reseau: 10.0.4.0/29
passerelle: 10.0.4.1 # SVI du switch L3
passerelle_sortie: 10.0.4.2 # la frontière = sortie par défaut de la flotte
```
Un `/29` plutôt qu'un `/30` : pendant une transition, deux pare-feux cohabitent sur le lien,
et un `/30` n'offre que deux adresses. `make underlay` (**preuve P23**) refuse une sortie hors
du lien, confondue avec le SVI, sans SVI, ou déclarée deux fois.
### Les deux routes, et pourquoi il en faut deux
`make devis-reseau` émet alors, en section 5 :
```
ip route 0.0.0.0 0.0.0.0 10.0.4.2 # aller
ip route 192.168.255.0 255.255.255.0 10.0.4.2 # retour (nftables_admin_ssh)
```
**L'aller** est la sortie générale : sans elle, aucun hôte de la flotte n'atteint quoi que ce
soit hors de sa zone.
**Le retour** est le piège proprement dit. Un paquet d'administration entre par la frontière
et atteint la VM ; la réponse part de la VM vers sa passerelle — un SVI **de switch**. Si le
switch n'a pas de route vers le réseau d'administration, ou s'il l'atteint par un autre chemin
(typiquement sa passerelle de *management*), la réponse revient au pare-feu **par une autre
interface** que celle où l'état a été créé. Elle est alors jetée en silence : ni réponse, ni
ICMP unreachable.
Le symptôme est déroutant : la passerelle de zone répond au ping (elle, sa pile de management
sait revenir), mais **aucun hôte derrière elle** n'est joignable. On croit à une règle de
pare-feu ; c'est une route manquante à l'autre bout.
Les réseaux d'administration ne sont pas saisis ici : ils viennent de l'intrant
`nftables_admin_ssh`, **la même source unique** qui alimente la garde des nftables d'hôte et
l'alias `SETOPS_ADMIN` de la frontière. Les trois pare-feux et les routes de retour ne peuvent
donc pas diverger.
## 5. Appliquer le devis
OPNsense expose une **API REST de première classe**, authentifiée par **clé + secret**
(*System → Access → Users → l'utilisateur → API keys*). C'est ce qui permettra d'appliquer le
devis sans clics : `devis_opnsense.py --json` produit déjà la structure destinée à cet usage
(alias, routes, règles), et les endpoints visés sont `/api/firewall/alias/*`,
`/api/firewall/filter/*` et `/api/routes/*`, avec le motif habituel « on prépare puis on
applique ».
### Où vivent les identifiants
Même partage que Proxmox — l'anodin en clair, le secret dans la voûte :
| Quoi | Où | Réglable |
|---|---|---|
| URL de gestion, interfaces, prochain saut | `group_vars/opnsense.yml` (en clair) | **panneau « Intrants de base » du GUI**, section *Frontière* |
| Clé et secret d'API | voûte unique de l'instance, sous `vault_opnsense_api_key` / `vault_opnsense_api_secret` | `ansible-vault edit` |
Les valeurs non sensibles sont de **vrais intrants** : `opnsense_api_url`,
`opnsense_api_verifier_certs`, `opnsense_if_wan`, `opnsense_if_transit` et
`opnsense_prochain_saut` figurent au schéma du panneau (`INTRANTS_SCHEMA`) et s'éditent sans
toucher au YAML — un opérateur règle la frontière depuis la console, sans IA.
Tant qu'elles sont vides, le devis affiche des **marqueurs** (`<IF-TRANSIT>`,
`<PROCHAIN-SAUT-SWITCH>`) ; il se complète tout seul dès qu'elles sont renseignées.
> **Garde-fou** : `opnsense_api_key` et `opnsense_api_secret` sont dans
> `INTRANTS_CLES_INTERDITES` — le GUI **refuse** de les écrire. Impossible de coller un
> secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur
> nom, dans le rappel `SECRETS_ATTENDUS`.
Le fichier en clair ne porte que des **références par nom** (`{{ vault_opnsense_api_key }}`).
`scripts/voute.py` les recense automatiquement et la **preuve P18** vérifie qu'elles figurent
au gabarit `vault.yml.example` — le gabarit est passé de 23 à 25 secrets sans intervention.
Pour renseigner les vraies valeurs, une seule commande, jamais de secret sur disque en clair :
```sh
ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml
```
> Le secret d'API n'est affiché **qu'à sa création** dans OPNsense. S'il est perdu, il faut
> révoquer la clé et en générer une nouvelle.
> pfSense CE, lui, n'a **pas** d'API officielle (celle de Netgate n'existe que sur pfSense
> Plus) : seul un paquet tiers en fournit une. C'est l'une des raisons du choix d'OPNsense.
## 6. Ce qui reste ouvert
- **Le câblage** — le lien de transit est *décidé* (VLAN 40, `10.0.4.0/29`) et le devis
switch émet déjà son SVI et ses routes. Restent à figer, une fois le boîtier raccordé, les
deux intrants côté frontière : `opnsense_if_transit` (l'interface qui porte le VLAN 40) et
`opnsense_prochain_saut` (`10.0.4.1`).
- **Le second VPN** — pendant la transition, le VPN d'administration actuel (pfSense) reste
en service et OPNsense en montera un second. Son sous-réseau devra être **ajouté à
`nftables_admin_ssh`**, sans quoi il sera muet de la même façon.
- **L'application par l'API**`--json` produit la structure ; le client d'application reste
à écrire.
- **La sortie générale** (mises à jour apt, ACME) n'est pas déclarée dans le registre : les
hôtes sortent aujourd'hui parce que la politique `output` des nftables est `accept` et que
l'ACL de switch se termine par `permit ip <tenant> any`. À trancher : la déclarer
explicitement, ou l'assumer comme politique par défaut de la bordure.

View file

@ -19,9 +19,11 @@ fois**, depuis un endroit unique, puis les laisser se **dériver** ou se **propa
- `supernet`, `cidr_hote`, `reservations`, `categories` (VLAN/sous-réseau/passerelle),
`fonctions` (catégorie + service).
### C. Hyperviseur Proxmox — `group_vars/proxmox.yml` (+ `proxmox.vault.yml`)
### C. Hyperviseur Proxmox — `group_vars/proxmox.yml`
- `proxmox_api_host`, `proxmox_api_user`, `proxmox_api_port`, `proxmox_validate_certs`.
- 🔒 `proxmox_api_token_id`, `proxmox_api_token_secret` *(Vault)*.
- 🔒 `proxmox_api_token_id`, `proxmox_api_token_secret` — dans la **voûte unique** de
l'instance, `group_vars/all/vault.yml`. (L'ancienne `proxmox.vault.yml` reste lue en
compatibilité si elle existe encore ; cf. `docs/config-proxmox.md`.)
- Golden template : `proxmox_clone_vmid_modele`, `proxmox_clone_source_nom`.
- Placement par défaut : `proxmox_clone_noeud`, `proxmox_clone_stockage`,
`proxmox_clone_pont`, format, complet, timeout, disque, interface, démarrer.

View file

@ -10,7 +10,10 @@
vars:
setops_inventaires: [lab, principal, production]
setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml]
# Voute Proxmox : la voute UNIQUE de l'instance (all/vault.yml) est autoritaire ;
# `proxmox.vault.yml` reste accepte en compat legacy pour les instances qui en
# ont encore une. all/vault.yml en dernier => il l'emporte sur les memes cles.
setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml, all/vault.yml]
# cibles_vmid : liste d'entiers, passée en -e (sinon dérivée des hôtes actifs).
cibles_vmid: "{{ groups['hotes_actifs'] | map('extract', hostvars, 'proxmox_vmid') | map('int') | list }}"

300
scripts/devis_opnsense.py Normal file
View file

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Devis de configuration OPNsense — la frontiere nord/sud du reseau converge.
DERIVE de deux sources deja tenues a jour, rien n'est saisi a la main :
- le REGISTRE DES FLUX (`roles/*/meta/flux.yml`) : les flux dont le pair est
`externe` sont, par construction, ceux que le pare-feu d'hote NE traite PAS
(scripts/resoudre_flux.py les saute) parce qu'ils relevent de la frontiere.
Plusieurs `raison` le disent explicitement (« filtre a l'OPNsense »).
- les NOMENCLATURES des instances federees (`../*/plan/nomenclature.yml`) :
les supernets tenants a router, decoulant du seul seed `index`.
ROLE ASSUME : frontiere NORD/SUD uniquement. Les passerelles de zone (SVI)
restent sur les switches L3 ; OPNsense ne porte AUCUN SVI de tenant et ne voit
pas le trafic est-ouest, qui reste filtre par les ACL de `make devis-reseau`.
NON destructif : ce script n'ecrit RIEN sur le pare-feu. Il produit un devis a
relire, puis (a venir) un JSON consommable par l'API OPNsense.
Usage :
python3 scripts/devis_opnsense.py # devis lisible
python3 scripts/devis_opnsense.py --json # meme contenu, pour l'API
python3 scripts/devis_opnsense.py --verifier # garde anti-lockout (P24)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import supernet_de # noqa: E402
# Helpers internes du resolveur de flux : reutilises VOLONTAIREMENT plutot que
# redupliques ici — le registre des flux et l'inventaire doivent avoir une seule
# lecture, sinon le devis et les nftables divergeraient en silence.
from resoudre_flux import ( # noqa: E402
charger_flux,
_enfants,
_hotes_du_groupe,
_inventaire,
_ip_par_hote,
_pairs,
_sources_admin_ssh,
)
from devis_reseau import decouvrir, prefixe # noqa: E402
# Marqueurs de repli, tant que les intrants de frontiere ne sont pas renseignes
# (meme idiome que devis_reseau). Le panneau « Intrants de base » du GUI les ecrit
# dans group_vars/opnsense.yml : le devis se complete tout seul ensuite.
PROCHAIN_SAUT = "<PROCHAIN-SAUT-SWITCH>"
IF_TRANSIT = "<IF-TRANSIT>"
IF_WAN = "<IF-WAN>"
def intrants_frontiere() -> dict:
"""Intrants NON sensibles de la frontiere (group_vars/opnsense.yml). {} si absent."""
for nom in ("principal", "production", "lab"):
p = _inventaire().parent.parent / nom / "group_vars" / "opnsense.yml"
if p.is_file():
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return {}
def _ou_marqueur(intrants: dict, cle: str, marqueur: str) -> str:
"""La valeur d'intrant si elle est renseignee, sinon le marqueur a completer."""
valeur = str(intrants.get(cle) or "").strip()
return valeur or marqueur
# Le socle est porte par TOUS les hotes : sa destination n'est pas une poignee
# de machines mais le supernet du tenant. Evite un alias de 14 adresses.
ROLE_SOCLE = "serveur_debian"
def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict]]:
"""[(role, flux)] pour tout flux dont le pair inclut `externe`, trie."""
retenus = [
(role, fl)
for role, flux in sorted(flux_par_role.items())
for fl in flux
if "externe" in _pairs(fl)
]
retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port"))))
return retenus
def _ports(flux: dict) -> list[str]:
port = flux["port"]
return [str(p) for p in (port if isinstance(port, list) else [port])]
def cibles_par_role(role: str) -> list[str]:
"""IP des hotes qui portent ce role, dans l'instance active (actifs ET planifies).
On inclut les planifies a dessein : la frontiere se prepare avant que les VM
existent, sinon le devis changerait a chaque materialisation d'hote.
"""
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
ips = _ip_par_hote(data)
return sorted({ips[h] for h in _hotes_du_groupe(data, role) if h in ips})
def nom_alias(role: str) -> str:
return f"SETOPS_{role.upper()}"
def _instance_active() -> tuple[str, dict] | tuple[None, None]:
"""(nom, nomenclature) de l'instance vers laquelle pointe le symlink `instance`."""
cible = (RACINE / "instance").resolve().name
for nom, _pfx, n in decouvrir():
if nom == cible:
return nom, n
return None, None
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure — rendue ensuite en texte ou en JSON."""
flux_par_role = charger_flux()
retenus = flux_frontiere(flux_par_role)
admin = _sources_admin_ssh()
nom_actif, nomenclature_active = _instance_active()
alias: dict[str, dict] = {}
for nom, pfx, n in tenants:
alias[f"SETOPS_TENANT_{pfx}{n['index']}"] = {
"type": "network",
"contenu": [supernet_de(n["index"])],
"description": f"Supernet du tenant {nom} (index {n['index']})",
}
alias["SETOPS_ADMIN"] = {
"type": "network",
"contenu": list(admin),
"description": "Reseaux d'administration (intrant nftables_admin_ssh) — "
"source unique, partagee avec les nftables d'hote",
}
supernet_actif = (
supernet_de(nomenclature_active["index"]) if nomenclature_active else None
)
alias_tenant_actif = next(
(a for a in alias if a.startswith("SETOPS_TENANT_") and nom_actif
and supernet_de(nomenclature_active["index"]) in alias[a]["contenu"]),
None,
)
regles: list[dict] = []
for role, fl in retenus:
if role == ROLE_SOCLE:
destination = alias_tenant_actif or "any"
else:
cibles = cibles_par_role(role)
if not cibles:
continue # role absent de cette instance : aucune regle a poser
a = nom_alias(role)
alias[a] = {
"type": "host",
"contenu": cibles,
"description": f"Hotes portant {role} (instance {nom_actif})",
}
destination = a
entrant = fl.get("sens") == "ingress"
# Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet :
# sa `raison` dit « l'acces depuis l'exterieur est filtre a l'OPNsense ».
source = "SETOPS_ADMIN" if (entrant and "22" in _ports(fl)) else "any"
regles.append({
"sens": "in" if entrant else "out",
"protocole": fl.get("protocole", "tcp"),
"source": source if entrant else destination,
"destination": destination if entrant else "any",
"ports": _ports(fl),
"chiffrement": fl.get("chiffrement"),
"role": role,
"raison": fl.get("raison", ""),
})
intrants = intrants_frontiere()
saut = _ou_marqueur(intrants, "opnsense_prochain_saut", PROCHAIN_SAUT)
routes = [
{
"reseau": supernet_de(n["index"]),
"prochain_saut": saut,
"description": f"{nom} (index {n['index']}) — routage inter-zone sur les switches L3",
}
for nom, _pfx, n in tenants
]
return {
"role_frontiere": "nord-sud",
"instance_active": nom_actif,
"supernet_actif": supernet_actif,
"api_url": str(intrants.get("opnsense_api_url") or "").strip(),
"if_wan": _ou_marqueur(intrants, "opnsense_if_wan", IF_WAN),
"if_transit": _ou_marqueur(intrants, "opnsense_if_transit", IF_TRANSIT),
"alias": alias,
"routes": routes,
"regles": regles,
"admin": admin,
"reciproque_switch": [
f"ip route {cidr} -> {saut} (retour vers l'administration)"
for cidr in admin
],
}
def rendre(devis: dict) -> str:
out = [
"# ============================================================",
"# DEVIS OPNSENSE — frontiere nord/sud (Set-OPS)",
"# Genere par scripts/devis_opnsense.py. NE PAS EDITER A LA MAIN.",
"# Derive du registre des flux (pair: externe) + des nomenclatures.",
"# Les passerelles de zone restent sur les switches L3 : aucun SVI ici.",
"# ============================================================",
"",
"## 0. Prerequis reciproques — A FAIRE SUR LES SWITCHES",
"# Sans ces routes de retour, le trafic part mais ne revient pas : la",
"# reponse sort par une autre interface et le pare-feu la jette (hors etat).",
]
out += [f"# {r}" for r in devis["reciproque_switch"]] or ["# (aucun reseau d'administration declare)"]
out += [
"",
"## 1. Interfaces",
f"# Gestion : {devis['api_url'] or '(intrant opnsense_api_url a renseigner)'}",
f"# WAN : {devis['if_wan']}",
f"# TRANSIT : {devis['if_transit']} (trunk 802.1Q vers les switches)",
"",
"## 2. Routes statiques (vers les supernets tenants)",
]
for r in devis["routes"]:
out.append(f"route add {r['reseau']} via {r['prochain_saut']} # {r['description']}")
out += ["", "## 3. Alias"]
for nom, a in devis["alias"].items():
contenu = ", ".join(a["contenu"]) if a["contenu"] else "(VIDE)"
out.append(f"alias {nom:32} {a['type']:8} {contenu}")
out.append(f"# {a['description']}")
out += ["", "## 4. Regles (defaut : deny + journalisation)"]
for rg in devis["regles"]:
ports = ",".join(rg["ports"])
out.append(
f"pass {rg['sens']:3} proto {rg['protocole']:3} "
f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} "
f"# {rg['role']} ({rg['chiffrement']})"
)
out.append(f"# {rg['raison']}")
out += [
"",
"## 5. Defaut",
"block in log all # tout ce qui n'est pas ci-dessus",
"block out log all",
]
return "\n".join(out)
def verifier(devis: dict) -> tuple[bool, list[str]]:
"""Garde anti-lockout : la frontiere ne doit pas etre generee a l'aveugle."""
erreurs: list[str] = []
if not devis["admin"]:
erreurs.append(
"Aucun reseau d'administration declare (intrant `nftables_admin_ssh` vide) : "
"la regle SSH entrante n'aurait aucune source et le defaut deny fermerait "
"l'acces d'administration. Declarer le(s) CIDR d'admin (VPN compris)."
)
if not devis["routes"]:
erreurs.append("Aucun tenant federe decouvert : rien a router.")
for nom, a in devis["alias"].items():
if not a["contenu"]:
erreurs.append(f"Alias {nom} vide : une regle le referencerait dans le vide.")
return (not erreurs), erreurs
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--json", action="store_true", help="Sortie structuree (future API OPNsense).")
ap.add_argument("--verifier", action="store_true", help="Valide le devis sans l'afficher.")
args = ap.parse_args(argv)
tenants = decouvrir()
if not tenants:
print("Aucune instance federee trouvee (../*/plan/nomenclature.yml).", file=sys.stderr)
return 1
devis = construire(tenants)
if args.verifier:
ok, erreurs = verifier(devis)
for e in erreurs:
print(f"ECHEC : {e}", file=sys.stderr)
if ok:
print(f"CONFORME : frontiere nord/sud, {len(devis['regles'])} regles, "
f"{len(devis['routes'])} routes, admin={','.join(devis['admin'])}.")
return 0 if ok else 1
print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -31,6 +31,9 @@ DOSSIER_INSTANCES = RACINE.parent
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import passerelle_de, supernet_de, vlan_de # noqa: E402
import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global, hors tenant)
# Reseaux d'administration : SOURCE UNIQUE partagee avec les nftables d'hote et la
# frontiere (intrant `nftables_admin_ssh`). On reutilise le resolveur, pas une copie.
from resoudre_flux import _sources_admin_ssh # noqa: E402
def masque(cidr: int) -> str:
@ -79,6 +82,47 @@ def section_underlay(underlay: dict | None) -> list[str]:
return out
def section_routes(underlay: dict | None) -> list[str]:
"""Routes du switch vers la frontiere nord/sud. Vide si aucun transit declare.
Deux routes, et il en faut IMPERATIVEMENT deux :
- l'ALLER : defaut vers la frontiere, sinon les hotes n'ont aucune sortie ;
- le RETOUR : vers les reseaux d'administration, sinon la reponse d'une VM part
par la passerelle de management et revient au pare-feu par une autre interface
que celle ou l'etat a ete cree — elle est jetee en silence. Symptome : le SVI
de zone repond au ping, mais aucun hote derriere lui n'est joignable.
Les reseaux d'administration viennent de l'intrant `nftables_admin_ssh` : la meme
source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN de la
frontiere. Aucune adresse n'est ecrite ici.
"""
transit = underlay_mod.reseau_transit(underlay)
if not transit:
return ["! ----- 5. Routes vers la frontiere -----",
"! Aucun reseau de transit declare dans underlay.yml (cle 'passerelle_sortie').",
"! Sans lui : pas de route par defaut, et pas de retour vers l'administration.",
"!"]
sortie = transit["passerelle_sortie"]
out = ["! ----- 5. Routes vers la frontiere nord/sud -----",
f"! Transit '{transit['nom']}' (VLAN {transit['vlan']}) : SVI {transit['passerelle']}"
f" <-> frontiere {sortie}",
"! Aller : sortie generale de la flotte.",
f"ip route 0.0.0.0 0.0.0.0 {sortie}"]
admin = _sources_admin_ssh()
if admin:
out.append("! Retour : sans ces routes, les reponses partent par une autre interface")
out.append("! que celle ou l'etat a ete cree, et le pare-feu les jette en silence.")
for cidr in admin:
net = ipaddress.ip_network(cidr, strict=False)
out.append(f"ip route {net.network_address} {net.netmask} {sortie}"
f" ! administration (intrant nftables_admin_ssh)")
else:
out.append("! ATTENTION : intrant `nftables_admin_ssh` vide — aucune route de retour")
out.append("! vers l'administration ne peut etre derivee. Cf. preuve P24.")
out.append("!")
return out
def prefixe(nom_dossier: str) -> str:
base = re.sub(r"^OPS-", "", nom_dossier)
base = re.sub(r"-lab$", "", base)
@ -155,7 +199,8 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out.append("interface <PORT-VERS-PROXMOX>")
out.append(" switchport mode trunk")
out.append(f" switchport trunk allowed vlan add {vlans}")
out += ["!", "end", "write memory"]
out += ["!"] + section_routes(underlay)
out += ["end", "write memory"]
return "\n".join(out)

View file

@ -85,8 +85,11 @@ INTRANTS_PROXMOX = INVENTAIRE_MODELE.parent / "group_vars/proxmox.yml"
# Le seed reseau (`index`) vit dans la nomenclature (le plan reseau) — d'ou tout
# l'adressage se derive. Le panneau Intrants le lit/ecrit la, chirurgicalement, sans
# reformater le fichier (cf. _ecrire_index_nomenclature).
# Frontiere nord/sud (OPNsense) : parametres NON sensibles du pare-feu de bordure.
# La cle/secret d'API n'y entrent JAMAIS — voute uniquement (cf. INTRANTS_CLES_INTERDITES).
INTRANTS_FRONTIERE = INVENTAIRE_DEFAUT.parent / "group_vars/opnsense.yml"
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX,
"reseau": FICHIER_NOMENCLATURE}
"reseau": FICHIER_NOMENCLATURE, "frontiere": INTRANTS_FRONTIERE}
# (variable, fichier, classe, section, libelle, type)
INTRANTS_SCHEMA = [
@ -108,14 +111,21 @@ INTRANTS_SCHEMA = [
("proxmox_noeuds", "proxmox", "catalogue", "Proxmox", "Nœuds disponibles (liste)", "liste"),
("proxmox_stockages", "proxmox", "catalogue", "Proxmox", "Stockages disponibles (liste)", "liste"),
("proxmox_ponts", "proxmox", "catalogue", "Proxmox", "Ponts réseau disponibles (liste)", "liste"),
("opnsense_api_url", "frontiere", "constante", "Frontière", "URL de gestion de la frontière OPNsense (API REST)", "str"),
("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool"),
("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN", "str"),
("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface/trunk vers les switches (transit)", "str"),
("opnsense_prochain_saut", "frontiere", "defaut", "Frontière", "IP du switch L3 qui porte les SVI (prochain saut des routes tenants)", "str"),
]
# Cles secretes : interdites a l'ecriture par le GUI (garde-fou).
INTRANTS_CLES_INTERDITES = {
"proxmox_api_token_id", "proxmox_api_token_secret", "ansible_become_pass", "ansible_password",
# Frontiere : les identifiants d'API ne transitent jamais par le panneau.
"opnsense_api_key", "opnsense_api_secret",
}
# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault.
SECRETS_ATTENDUS = [
# Accès Proxmox (proxmox.vault.yml)
# Accès Proxmox (voûte unique de l'instance : group_vars/all/vault.yml)
"proxmox_api_token_id", "proxmox_api_token_secret",
# PKI (step-ca)
"vault_step_ca_password", "vault_step_ca_provisioner_password",
@ -135,6 +145,8 @@ SECRETS_ATTENDUS = [
"vault_nextcloud_oidc", "vault_oauth2_cookie",
# Sauvegarde (restic hors-nœud)
"vault_restic_password", "vault_backup_ssh_privkey",
# Frontière nord/sud (OPNsense) — API REST du pare-feu de bordure
"vault_opnsense_api_key", "vault_opnsense_api_secret",
]
# Garde-fous des executions (verifier / deployer) depuis l'interface.
@ -468,8 +480,25 @@ def _coercer_intrant(typ: str, brut: object) -> object:
return s
_REFERENCE_VOUTE = re.compile(r"^\{\{\s*vault_[A-Za-z0-9_]+\s*\}\}$")
def _est_reference_voute(valeur: object) -> bool:
"""Vrai si la valeur est une simple REFERENCE de voute (`{{ vault_xxx }}`).
Un fichier d'intrants peut porter, a cote de ses parametres anodins, des renvois
par NOM vers la voute (cas de group_vars/opnsense.yml). Ce n'est pas un secret :
c'est un pointeur. La fusion « preserve les cles non gerees » les relit donc du
disque, et le garde-fou ne doit pas les confondre avec une valeur saisie.
"""
return bool(_REFERENCE_VOUTE.match(str(valeur).strip()))
def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
interdits = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
# Une cle sensible n'est toleree que sous forme de reference de voute preservee.
# Toute VALEUR reelle sur une de ces cles fait echouer l'ecriture.
interdits = sorted(cle for cle in set(valeurs) & INTRANTS_CLES_INTERDITES
if not _est_reference_voute(valeurs[cle]))
if interdits:
raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}")
path.parent.mkdir(parents=True, exist_ok=True)
@ -480,6 +509,11 @@ def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
def ecrire_intrants(payload: dict) -> None:
valeurs = payload.get("valeurs", {}) or {}
# Garde-fou en entree : le panneau n'affiche jamais ces cles, mais une requete
# forgee pourrait les soumettre. On refuse bruyamment plutot que d'ignorer.
soumis = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
if soumis:
raise ValueError(f"Cles sensibles soumises au panneau (voute uniquement): {', '.join(soumis)}")
if not str(valeurs.get("domaine_interne", "")).strip():
raise ValueError("domaine_interne ne peut pas etre vide (cle de voute de l'ecosysteme).")
types = {v: t for (v, f, c, s, l, t) in INTRANTS_SCHEMA}
@ -501,8 +535,27 @@ def ecrire_intrants(payload: dict) -> None:
fusion.update(par_fichier["proxmox"])
_ecrire_intrants_fichier(
INTRANTS_PROXMOX, fusion,
"# Parametres Proxmox non sensibles (les secrets vont dans proxmox.vault.yml).\n"
"# Parametres Proxmox non sensibles. Les secrets (jetons d'API) vont dans la\n"
"# voute UNIQUE de l'instance : group_vars/all/vault.yml.\n"
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n")
if "frontiere" in par_fichier:
# Fusion (comme Proxmox) : preserve les cles non gerees par le panneau,
# en particulier les references de voute `{{ vault_opnsense_api_* }}`.
fusion = _lire_yaml_dict(INTRANTS_FRONTIERE)
fusion.update(par_fichier["frontiere"])
_ecrire_intrants_fichier(
INTRANTS_FRONTIERE, fusion,
"# Frontiere nord/sud (OPNsense) — parametres NON sensibles.\n"
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n"
"#\n"
"# Les identifiants d'API ne figurent ici que par REFERENCE de nom : leurs\n"
"# valeurs vivent dans la voute unique de l'instance (ansible-vault edit\n"
"# group_vars/all/vault.yml), sous vault_opnsense_api_key / _api_secret.\n"
"# Les obtenir dans OPNsense : System > Access > Users > <utilisateur> >\n"
"# API keys > « + ». Le secret n'est affiche qu'a la creation.\n"
"#\n"
"# Le boitier est HORS FLOTTE Ansible : on ne fait que piloter son API REST\n"
"# depuis le controleur. Cf. docs/frontiere-opnsense.md.\n---\n")
if "reseau" in par_fichier and "index" in par_fichier["reseau"]:
_ecrire_index_nomenclature(int(par_fichier["reseau"]["index"]))

View file

@ -292,6 +292,8 @@ PREUVES: list[dict] = [
"cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]},
{"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-001"],
"cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]},
{"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-001"],
"cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]},
]
@ -318,6 +320,11 @@ def _underlay_absent() -> bool:
return underlay.chemin() is None
def _frontiere_absente() -> bool:
"""Vrai si aucune instance n'est active (depot public nu : aucune frontiere a prouver)."""
return not (RACINE / "instance").exists()
def _executer(preuve: dict) -> tuple[str, str]:
"""Renvoie (statut, detail). statut in {OK, ECHEC, SAUTE}."""
if "func" in preuve:
@ -359,6 +366,13 @@ def main(argv: list[str] | None = None) -> int:
liste.remove(p23)
resultats.append((p23, "SAUTE", "Aucun underlay.yml (fabric non definie) — rien a prouver."))
# Preuve conditionnelle : frontiere nord/sud. Sautee sans instance active.
if _frontiere_absente():
p24 = next((p for p in liste if p["id"] == "P24"), None)
if p24:
liste.remove(p24)
resultats.append((p24, "SAUTE", "Aucune instance active — aucune frontiere a prouver."))
for preuve in liste:
statut, detail = _executer(preuve)
resultats.append((preuve, statut, detail))

View file

@ -58,6 +58,20 @@ def hotes(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("hotes", []) or []
def reseau_transit(underlay: dict | None) -> dict | None:
"""Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare.
Convention : c'est le reseau underlay qui porte `passerelle_sortie` — l'adresse
du pare-feu de bordure sur ce lien. Il vit dans l'underlay et non dans un tenant
parce qu'il est PARTAGE : la frontiere route vers tous les supernets tenants par
ce meme saut, il ne peut donc deriver d'aucun `index`.
"""
for r in reseaux(underlay):
if r.get("passerelle_sortie"):
return r
return None
def _tenants_reperes() -> tuple[set[int], list[str]]:
"""VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis)."""
import devis_reseau
@ -115,8 +129,28 @@ def valider(underlay: dict | None,
erreurs.append(f"reseau '{nom}': passerelle {r['passerelle']} hors de {sous}")
except ValueError:
erreurs.append(f"reseau '{nom}': passerelle '{r['passerelle']}' invalide")
# Transit vers la frontiere : la sortie doit etre SUR le lien, et distincte du SVI.
if r.get("passerelle_sortie"):
try:
sortie = ipaddress.ip_address(r["passerelle_sortie"])
if sortie not in net:
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} hors de {sous}")
elif r.get("passerelle") and str(sortie) == str(r["passerelle"]):
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} identique au SVI "
f"(le switch ne peut pas etre sa propre sortie)")
elif not r.get("passerelle"):
erreurs.append(f"reseau '{nom}': passerelle_sortie exige aussi 'passerelle' "
f"(le SVI du switch sur le lien de transit)")
except ValueError:
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
vus_sous.append((net, nom))
# Une seule sortie possible : deux transits donneraient deux routes par defaut.
transits = [r.get("nom", "?") for r in reseaux(underlay) if r.get("passerelle_sortie")]
if len(transits) > 1:
erreurs.append(f"un seul reseau de transit autorise (passerelle_sortie), "
f"trouves : {', '.join(transits)}")
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
for h in hotes(underlay):
hn = h.get("nom", "?")

View file

@ -37,6 +37,20 @@ underlay:
vlan: 31
sous_reseau: 10.0.3.0/24
mtu: 9000
# Transit vers la frontiere nord/sud (pare-feu de bordure). OPTIONNEL, mais sans lui
# la flotte n'a ni sortie ni chemin de retour vers l'administration.
# Il vit dans l'underlay parce qu'il est PARTAGE : la frontiere route vers TOUS les
# supernets tenants par ce meme saut — il ne peut donc deriver d'aucun `index`.
# `passerelle_sortie` = adresse du pare-feu sur le lien ; c'est elle qui fait emettre
# la route par defaut et les routes de retour (section 5 de `make devis-reseau`).
# Prevoir large : /29 laisse la place aux deux pare-feux pendant une transition.
- nom: transit-frontiere
description: Lien routeur est-ouest (switches L3) <-> frontiere nord/sud
vlan: 40
sous_reseau: 10.0.4.0/29
passerelle: 10.0.4.1 # SVI du switch L3
passerelle_sortie: 10.0.4.2 # pare-feu de bordure = sortie par defaut de la flotte
mtu: 1500
# Hotes fixes documentes (optionnel) : IP hors DHCP, verifiees dans leur reseau.
hotes: