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:
parent
4a1d0836d0
commit
3698152b6a
13 changed files with 820 additions and 9 deletions
109
CHANGELOG.md
109
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 <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
|
||||
|
|
|
|||
7
Makefile
7
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
|
||||
|
|
|
|||
60
docs/audit/preuve-2026-08-01.md
Normal file
60
docs/audit/preuve-2026-08-01.md
Normal 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._
|
||||
|
|
@ -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
169
docs/frontiere-opnsense.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
300
scripts/devis_opnsense.py
Normal 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:]))
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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", "?")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue