diff --git a/CHANGELOG.md b/CHANGELOG.md index d1945da..834949d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` — 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 + (``, ``) 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 diff --git a/Makefile b/Makefile index 796d8a8..8bbd5a5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/audit/preuve-2026-08-01.md b/docs/audit/preuve-2026-08-01.md new file mode 100644 index 0000000..50e75a4 --- /dev/null +++ b/docs/audit/preuve-2026-08-01.md @@ -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._ diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 2bf3f46..1e23d44 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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-.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', )` | — | | 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). diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md new file mode 100644 index 0000000..ac326f5 --- /dev/null +++ b/docs/frontiere-opnsense.md @@ -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** (``, +``) ; 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 any`. À trancher : la déclarer + explicitement, ou l'assumer comme politique par défaut de la bordure. diff --git a/docs/intrants-communs.md b/docs/intrants-communs.md index afd5e90..389140e 100644 --- a/docs/intrants-communs.md +++ b/docs/intrants-communs.md @@ -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. diff --git a/playbooks/proxmox/supprimer_vm_debian.yml b/playbooks/proxmox/supprimer_vm_debian.yml index 6342ba5..8256b36 100644 --- a/playbooks/proxmox/supprimer_vm_debian.yml +++ b/playbooks/proxmox/supprimer_vm_debian.yml @@ -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 }}" diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py new file mode 100644 index 0000000..5da0618 --- /dev/null +++ b/scripts/devis_opnsense.py @@ -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 = "" +IF_TRANSIT = "" +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:])) diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index de727ff..700de7b 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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 ") 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) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 8c596f9..c81f303 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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 > >\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"])) diff --git a/scripts/prouver.py b/scripts/prouver.py index a8e3f5b..3175e58 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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)) diff --git a/scripts/underlay.py b/scripts/underlay.py index 99377a9..ef53834 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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", "?") diff --git a/underlay.yml.example b/underlay.yml.example index 86f1b81..35eab46 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -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: