Reconstruction propre : orchestrateur ordonné, registre des flux + pare-feu

Rend l'écosystème reconstructible en une commande (create+deploy idempotent) et
ajoute la couche « accès » (nftables least-privilege) au zéro-confiance.

Orchestrateur (phase 2) :
- docs/couches-deploiement.yml : registre des couches (socle → pki → services → apps → agents)
- scripts/orchestrer.py : tri par couche + topo intra-couche (graphe) → playbooks/site.yml ordonné
- Makefile : site / deployer-tout / flotte-creer / reconstruire / myDay (+ gardes CONFIRMER)
- docs/dependances-groupes.yml : graphe complété (keycloak→openldap, dovecot, postfix, icingaweb2, nextcloud)

Audit codé-en-dur (phase 1b) : labels/slug OIDC dérivés de l'intrant `organisation`
(serveur_forgejo/grafana/nextcloud) — le moteur ne porte plus de nom de tenant.

Registre des flux réseau (phase 0) :
- meta/flux.yml pour tous les rôles (29 rôles, 63 flux ; schéma + matrice validés)
- scripts/resoudre_flux.py : matrice d'audit (docs/registre-flux.md) + rulesets nftables résolus par hôte
- roles/nftables_baseline : déploie le ruleset résolu (moindre-privilège) quand activé, sinon repli

Correctifs : détection du coffre Vault (chemin production → inventaire réellement résolu).
Outillage : make wiki-publier (publication du wiki pédagogique dans Forgejo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-07 03:08:09 -04:00
parent 02bbba61b3
commit f05f505b88
38 changed files with 1433 additions and 24 deletions

View file

@ -1,5 +1,137 @@
# CHANGELOG — Set-OPS
## 2026-07-07
### Ajouté
- **Pare-feu nftables activé sur TOUTE la flotte (14 nœuds, activation prudente).** Les 14 hôtes
actifs tournent sous `policy drop` avec leur ruleset **résolu moindre-privilège** (flux est-ouest
déclarés autorisés par source, reste refusé). Vérifié en conditions réelles : flux déclarés OPEN
(keycloak→pg, postfix→dovecot LMTP, prometheus→node_exporter…), flux non déclaré DROP
(forge→redis), 14/14 `active`+`enabled`+`policy drop`, contrôleur toujours joignable.
- **Intrant `nftables_admin_ssh`** (garde anti-lockout) — CIDR d'administration TOUJOURS
autorisés en SSH, indépendamment des flux. Le résolveur (`resoudre_flux.py`) l'injecte en tête
de chaque ruleset. **Bug de conception rattrapé avant activation** : le contrôleur Ansible arrive
par VPN (`192.168.255.2`, hors sous-réseau flotte) — sans cette règle, activer = lockout immédiat.
- **Rollout prudent** : d'abord `infra-pki-01` seul (test dead-man switch `systemd-run`, SSH
re-vérifié sous drop, puis permanent), puis les 13 autres par lot (dead-man 5 min + sonde des
flux est-ouest avant de persister). Activation pilotée par `nftables_baseline_enabled: true`
(group_vars `hotes_actifs` ; le golden template n'y est pas → reste sans pare-feu, voulu).
- Le déploiement dépose `instance/flux-genere/<hôte>.nft` dans `/etc/nftables.conf` + service
`enabled` (survit reboot ET futurs `make myDay`).
### Corrigé
- **Détection du coffre Vault : `production/` codé en dur → inventaire réel.** `deployer`,
`deployer-tout` et `verifier-deploiement` cherchaient le coffre chiffré dans
`inventories/production/group_vars`, alors qu'une instance en `principal/` (cas courant) n'a pas
ce chemin → l'invite du mot de passe Vault ne se déclenchait jamais et le déploiement échouait au
déchiffrement. Corrigé : la garde vise désormais le `group_vars` de l'inventaire **résolu**
(`$(dir $(INVENTAIRE_PRODUCTION))group_vars`). Vérifié : le coffre de `principal/` est bien détecté.
### Modifié
- **`nftables_baseline` branché sur les flux résolus (reconstruction, phase 0).** Le rôle déploie
désormais le ruleset **résolu** généré par `make flux` (`instance/flux-genere/<hôte>.nft` — règles
par source, `ip saddr` = moindre privilège) quand il est présent ; sinon repli sur le gabarit plat.
**Toujours `nftables_baseline_enabled: false` par défaut → aucune activation** (l'activation reste
un geste dédié, testé par nœud). Nouveau var `nftables_baseline_ruleset_genere`. Syntax-check OK.
### Ajouté
- **Reconstruction from-zero en une commande (reconstruction, phase 3 — outillage).** La création
de VM était unitaire (`creer-vm HOTE=…`) ; on comble le trou entre *créer* (2a) et *configurer*
(2b) :
- **`make flotte-creer CONFIRMER=true`** — boucle `creer-vm` sur tous les hôtes actifs du plan
(clone Proxmox). Nouvelle sous-commande `inventory_host.py lister-actifs`.
- **`make reconstruire CONFIRMER=true`** — enchaîne **flotte-creer → attente SSH de la flotte
(`_attendre-flotte`, `ATTENTE_MAX` réglable) → `deployer-tout`**. La reconstruction complète en
une commande, **idempotente de bout en bout** : le clone (`proxmox_kvm`) saute une VM déjà
présente (par nom), le réseau/disque sont `present`/`resized` (grow-only), le déploiement Ansible
converge. Re-lançable sans risque, qu'il reste des VM ou non.
- **`make myDay`** repointé sur `reconstruire` (le vrai « bouton rouge » ; n'était qu'un alias de
`deployer-tout`). Distinction assumée : `deployer-tout` = **converger** la config d'une flotte
existante (2b, avec `MODE_CHECK=1`) ; `reconstruire`/`myDay` = **créer les VM manquantes puis
déployer** (2a+2b).
Gardes `CONFIRMER=true` sur les trois. Non testé contre Proxmox/lab (validé : énumération des 14
hôtes actifs, refus sans `CONFIRMER`, enchaînement `make -n`).
## 2026-07-06
### Ajouté
- **`make wiki-publier` — fin du dernier geste manuel (reconstruction, phase 0).** Le wiki
pédagogique (`wiki/`, source versionnée) se publie désormais dans le wiki Forgejo par
`make wiki-publier WIKI_REMOTE=…<dépôt>.wiki.git` : clone superficiel du wiki, synchronisation des
pages (`wiki/*.md` sauf `README.md` ; suppressions propagées), commit + push seulement s'il y a du
changement. Refuse sans `WIKI_REMOTE`. Éprouvé de bout en bout contre un dépôt bare local (17
pages publiées = 17 source, diff vide, README exclu, `_Sidebar` inclus, idempotent au 2e passage).
- **Registre des flux réseau complété (reconstruction, phase 0).** Transcription du travail
zéro-confiance est-ouest dans `meta/flux.yml` : **16 rôles remplis** (step_ca, openldap, powerdns,
prometheus, loki, redis, rspamd, backup, dovecot, postfix, keycloak, forgejo, grafana, icinga,
icingaweb2, nextcloud, oauth2_proxy + le socle `serveur_debian` pour le plan de gestion SSH, +
les 5 clients pki/journal/smtp/backup/unbound). Le registre couvre désormais **29 rôles, 63 flux**
(qui-parle-à-qui : port, sens, pair, chiffrement, raison) — la base de génération nftables/OPNsense
et la matrice d'audit. Schéma enrichi (`docs/flux-conception.md`) : valeurs `ssh` (transport SSH,
restic/backup + SSH de gestion) et `tls-cible` (TLS visé, feuille de route edge→backends).
**Point critique traité** : SSH (22) déclaré au socle, sinon les nftables générés couperaient
l'accès Ansible. Validé : schéma conforme (0 erreur) et **matrice cohérente** (tout egress vers un
service a l'ingress correspondant en face). Reste phase 0 : la cible `make wiki-publier`.
- **Résolveur de flux (reconstruction, phase 0 — §Séquence 2).** `scripts/resoudre_flux.py` agrège
les `meta/flux.yml`, résout les `pair`, et produit **deux artefacts, hors-ligne, sans activation** :
- **`docs/registre-flux.md`** (généré) — la matrice d'audit source→destination (rôle, sens, port,
chiffrement, raison) + synthèse chiffrement. Artefact du label de certification.
- **aperçus nftables par hôte** (`instance/flux-genere/<hôte>.nft`, gitignorés) — règles résolues
avec IP réelles, `ip saddr` = **moindre privilège** (ex. LMTP 24 sur le mail n'accepte que l'IP
du nœud Postfix), `policy drop`. **Aperçus inspectables, NON activés** (l'activation reste un
geste dédié testé par nœud, cf. flux-conception §Activation prudente).
- Cibles **`make flux`** (registre + aperçus) et **`make flux-verifier`** (schéma + matrice,
branché dans `make verifier`). Validé : 29 rôles / 63 flux cohérents, 14 aperçus générés.
Reste : brancher `nftables_baseline` (modèle plat aujourd'hui) sur ces aperçus, et le test lab.
- **Orchestrateur ordonné (reconstruction, phase 2).** `playbooks/site.yml` n'est plus un stub :
c'est désormais un **point d'entrée ordonné généré**, qui déploie l'écosystème **couche par
couche, dans l'ordre de reconstruction**, sans intervention manuelle. Nouveautés :
- **`docs/couches-deploiement.yml`** — registre central des couches ordonnées
(`socle → pki_racine → pki_client → services → apps → agents`), les 30 groupes déployables classés.
- **`scripts/orchestrer.py`** — trie les groupes par couche (clé primaire) puis **topologiquement
intra-couche** via `dependances-groupes.yml` (ex. dovecot avant postfix, icingaweb2 après icinga,
nextcloud en dernier). Génère `site.yml` comme une séquence d'`import_playbook`. Artefact du
moteur (déterministe, sans donnée d'instance ; Ansible saute les groupes sans hôte actif).
**Deux gardes anti-dérive** (refus si violé) : bijection univers↔couches (un nouveau rôle non
classé casse la génération) et aucune arête « en arrière » (un prérequis dans une couche plus
tardive = classification fausse). Éprouvées par test négatif.
- **`make site`** (régénère + syntax-check), **`make site-verifier`** (cohérence, branché dans
`make verifier`), **`make deployer-tout CONFIRMER=true`** (déploiement orchestré de la flotte,
limité à `hotes_actifs` ; garde `CONFIRMER` car action impactante ; `MODE_CHECK=1` pour l'essai
idempotent à blanc). Validé : `verifier` OK (30 groupes, aucun cycle/arête arrière),
`--syntax-check` du `site.yml` généré OK, refus `deployer-tout` sans `CONFIRMER` (rc=2).
### Modifié
- **Audit exhaustif du codé-en-dur (reconstruction, phase 1b).** Balayage complet
`tasks + templates + defaults` de tous les rôles (noms de tenant, IP, domaines, emails, orgs).
Résultat : le moteur ne porte plus **aucun** nom de tenant en dur. Corrigé — les labels/slug
OIDC dérivent désormais de l'intrant `organisation` : `serveur_forgejo_oidc_nom` (slug de
callback, `organisation | lower | replace(' ','-')`), `serveur_grafana_oidc_nom`,
`serveur_nextcloud_oidc_nom`, `serveur_nextcloud_theme_nom` (labels d'affichage). Commentaires
« Se connecter avec Chezlepro » → génériques. Non-régression SSO : `organisation: Chezlepro`
slug `chezlepro`, **identique** à l'URI de redirection Keycloak de l'instance (pas de casse).
Conservés intentionnellement : realm `default('chezlepro')` (décision `identite_realm` actée) et
le thème visuel **Alliance Boréale** (identité par défaut assumée du réseau, pas un tenant).
Validé : re-balayage vide, rendu Jinja du slug testé (Chezlepro/Alliance Boréale/Ma Coop),
`--syntax-check` OK (playbook forgejo via inventaire `principal`).
- **Audit du graphe de dépendances (reconstruction, phase 1a).** `docs/dependances-groupes.yml`
gagne les prérequis inter-groupes confirmés dans le code, en vue de l'orchestrateur trié en
topologie. Ajouts : `serveur_keycloak`**`serveur_openldap`** (fédération LDAP via
`resoudre_annuaire_uri`, en plus de PostgreSQL) ; **`serveur_dovecot`** → `serveur_openldap`
(userdb/passdb LDAP) ; **`serveur_postfix`** → `serveur_dovecot` (remise LMTP au mailstore) ;
**`serveur_icingaweb2`** → `serveur_icinga` + `serveur_postgresql` + `serveur_openldap`
(IcingaDB + auth LDAP) ; **`serveur_nextcloud`** → `serveur_postgresql` + `serveur_keycloak`
(OIDC) + `serveur_collabora` (validation WOPI). Réconciliation `meta/liens.yml` : le seul lien
structurel (`serveur_postfix` mailstore → Dovecot) coïncide avec le graphe. **Conclusion d'archi :**
la règle « TLS vérifié ⇒ `client_pki` aux deux bouts » ne devient PAS des arêtes par-groupe
(client_pki est quasi universel) — c'est une **couche** de l'ordre de reconstruction
(`socle → step_ca → client_pki → services → apps → agents`) ; `dependances-groupes.yml` ne
capture que le fin ordonnancement intra-couche. Validé : YAML conforme, **aucun cycle**, tri-topo
réussi (19 nœuds), chargeur `charger_dependances` accepte (12 groupes, `est_groupe_operationnel`
OK), tous les groupes ont un rôle.
## 2026-07-05
### Modifié

150
Makefile
View file

@ -128,6 +128,28 @@ aide:
@printf '%s\n' ' Convention:'
@printf '%s\n' ' groupe serveur_debian -> playbooks/groupes/serveur_debian.yml'
@printf '%s\n' ''
@printf '%s\n' 'Ecosysteme complet (orchestrateur)'
@printf '%s\n' ' Ordre de deploiement (couches + graphe):'
@printf '%s\n' ' make site-verifier # valide la coherence couches/graphe'
@printf '%s\n' ' python3 scripts/orchestrer.py ordre'
@printf '%s\n' ' (Re)generer playbooks/site.yml ordonne:'
@printf '%s\n' ' make site'
@printf '%s\n' ' CONFIGURER la flotte existante, couche par couche (2b, VM deja creees):'
@printf '%s\n' ' make deployer-tout CONFIRMER=true # (MODE_CHECK=1 pour un essai a blanc idempotent)'
@printf '%s\n' ' CREER toutes les VM du plan (2a, clone Proxmox):'
@printf '%s\n' ' make flotte-creer CONFIRMER=true'
@printf '%s\n' ' RECONSTRUIRE from-zero = creer les VM PUIS deployer (2a+2b, VM inexistantes):'
@printf '%s\n' ' make reconstruire CONFIRMER=true # alias: make myDay CONFIRMER=true'
@printf '%s\n' ''
@printf '%s\n' 'Flux reseau (pare-feu / audit)'
@printf '%s\n' ' Matrice d audit + apercus nftables resolus (NON actives):'
@printf '%s\n' ' make flux # -> docs/registre-flux.md + instance/flux-genere/*.nft'
@printf '%s\n' ' make flux-verifier # valide schema + coherence de matrice'
@printf '%s\n' ''
@printf '%s\n' 'Wiki pedagogique'
@printf '%s\n' ' Publier wiki/ dans le wiki Forgejo (source versionnee -> vue browsable):'
@printf '%s\n' ' make wiki-publier WIKI_REMOTE=https://forge.<domaine>/<proprio>/<depot>.wiki.git'
@printf '%s\n' ''
@printf '%s\n' 'Inventaires'
@printf '%s\n' ' Graphe de production:'
@printf '%s\n' ' make inventaire'
@ -189,7 +211,7 @@ test:
python3 scripts/tests/test_inventory_host.py
.PHONY: verifier
verifier: lint test inventaire-verifier syntaxe
verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe
.PHONY: inventaire hote-planifier hote-ajouter hote-groupes hote-afficher appliquer deployer deployer-groupe cloner-vm creer-vm config inventaire-ui inventaire-verifier inventaire-lister inventaire-graphe inventaire-hote inventaire-lab inventaire-production instance-utiliser instance-courante
inventaire: inventaire-production
@ -251,7 +273,7 @@ deployer: _instance-requise
printf '%s\n' 'Refus: aucun playbook applicable pour HOTE=$(HOTE).'; \
exit 2; \
fi; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(SETOPS_INSTANCE)/inventories/production/group_vars 2>/dev/null | head -1 || true)"; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
@ -270,6 +292,128 @@ deployer: _instance-requise
done; \
$(MAKE) verifier-hote LIMITE="$(HOTE)"
.PHONY: site site-verifier deployer-tout
site: ansible-runtime
python3 scripts/orchestrer.py ecrire
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/site.yml --syntax-check
site-verifier:
python3 scripts/orchestrer.py verifier
.PHONY: flux flux-verifier
flux: ansible-runtime
python3 scripts/resoudre_flux.py registre
python3 scripts/resoudre_flux.py nftables
flux-verifier:
python3 scripts/resoudre_flux.py verifier
.PHONY: wiki-publier
wiki-publier:
@set -e; \
if [[ -z "$(WIKI_REMOTE)" ]]; then \
printf '%s\n' 'Refus: URL du wiki Forgejo requise.'; \
printf '%s\n' 'Ex: make wiki-publier WIKI_REMOTE=https://forge.<domaine>/<proprio>/<depot>.wiki.git'; \
exit 2; \
fi; \
src="$(CURDIR)/wiki"; \
tmp="$$(mktemp -d)"; \
trap 'rm -rf "$$tmp"' EXIT; \
printf '%s\n' "Clonage du wiki: $(WIKI_REMOTE)"; \
if ! git clone --quiet --depth 1 "$(WIKI_REMOTE)" "$$tmp/wiki"; then \
printf '%s\n' 'Echec du clone (URL ou acces ?). Le wiki doit exister (creer une 1re page dans Forgejo).'; \
exit 1; \
fi; \
find "$$tmp/wiki" -maxdepth 1 -name '*.md' -delete; \
for f in "$$src"/*.md; do \
bn="$$(basename "$$f")"; \
[[ "$$bn" == "README.md" ]] && continue; \
cp "$$f" "$$tmp/wiki/$$bn"; \
done; \
cd "$$tmp/wiki"; \
if [[ -z "$$(git status --porcelain)" ]]; then \
printf '%s\n' 'Wiki deja a jour (aucun changement).'; \
exit 0; \
fi; \
git add -A; \
sha="$$(git -C "$(CURDIR)" rev-parse --short HEAD 2>/dev/null || echo inconnu)"; \
git commit --quiet -m "Publication du wiki depuis le depot (source: $$sha)"; \
git push --quiet; \
printf '%s\n' 'Wiki publie.'
deployer-tout: _instance-requise
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: deploiement ORCHESTRE de TOUTE la flotte (action impactante).'; \
printf '%s\n' 'Relancer avec CONFIRMER=true. Astuce: tester d abord en idempotent avec MODE_CHECK=1.'; \
exit 2; \
fi; \
python3 scripts/orchestrer.py verifier; \
python3 scripts/orchestrer.py ecrire; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
export ANSIBLE_VAULT_PASSWORD_FILE="$$vf"; \
trap 'rm -f "$$vf"' EXIT; \
else \
printf '%s\n' 'Refus: vault chiffre detecte mais aucun mot de passe (entree non interactive). Fournir ANSIBLE_VAULT_PASSWORD_FILE ou le champ vault de la GUI.'; \
exit 2; \
fi; \
fi; \
$(MAKE) _verifier-acces-hote LIMITE="$(GROUPE_HOTES_ACTIFS)"; \
$(MAKE) _verifier-privileges-hote LIMITE="$(GROUPE_HOTES_ACTIFS)"; \
mode="$$([[ -n "$(MODE_CHECK)" ]] && printf -- '--check --diff' || true)"; \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/site.yml --limit "$(GROUPE_HOTES_ACTIFS)" $$mode
# --- Reconstruction from-zero : creer TOUTES les VM (2a) puis deployer (2b) ---
.PHONY: flotte-creer
flotte-creer: _instance-requise
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: creation de TOUTES les VM actives du plan (clone Proxmox).'; \
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
exit 2; \
fi; \
hotes="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)"; \
if [[ -z "$$hotes" ]]; then printf '%s\n' 'Refus: aucun hote actif dans le plan.'; exit 2; fi; \
for h in $$hotes; do \
printf '\n=== Creation VM: %s ===\n' "$$h"; \
$(MAKE) creer-vm HOTE="$$h"; \
done; \
printf '\nToutes les VM actives sont creees.\n'
.PHONY: _attendre-flotte
_attendre-flotte: ansible-runtime
@set -e; \
max="$${ATTENTE_MAX:-600}"; deadline=$$(( $$(date +%s) + max )); \
printf 'Attente que la flotte reponde en SSH (max %ss)...\n' "$$max"; \
until ansible -i $(INVENTAIRE_PRODUCTION) $(GROUPE_HOTES_ACTIFS) -m ping -e ansible_become=false >/dev/null 2>&1; do \
if (( $$(date +%s) > deadline )); then printf 'Timeout: flotte injoignable apres %ss.\n' "$$max"; exit 1; fi; \
sleep 10; \
done; \
printf 'Flotte joignable.\n'
.PHONY: reconstruire
reconstruire: _instance-requise
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: RECONSTRUCTION — cree les VM manquantes (2a) PUIS deploie tout (2b).'; \
printf '%s\n' 'Idempotent : une VM deja presente est sautee (clone par nom), le deploiement converge.'; \
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
exit 2; \
fi; \
$(MAKE) flotte-creer CONFIRMER=true; \
$(MAKE) _attendre-flotte; \
$(MAKE) deployer-tout CONFIRMER=true
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.
.PHONY: myDay
myDay: reconstruire
deployer-groupe:
@if [[ -z "$(GROUPE)" ]]; then \
printf '%s\n' 'Refus: relancer avec GROUPE=nom_groupe.'; \
@ -291,7 +435,7 @@ verifier-deploiement: ansible-runtime
printf '%s\n' 'Refus: aucun playbook applicable pour HOTE=$(HOTE).'; \
exit 2; \
fi; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(SETOPS_INSTANCE)/inventories/production/group_vars 2>/dev/null | head -1 || true)"; \
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
if [[ -t 0 ]]; then \
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \

View file

@ -0,0 +1,72 @@
---
# Couches de déploiement — l'ordre de reconstruction de l'écosystème.
#
# Registre central (comme docs/dependances-groupes.yml). L'orchestrateur
# (scripts/orchestrer.py → make site) trie les groupes déployables par COUCHE
# (clé primaire, ordre ci-dessous), puis AFFINE l'ordre INTRA-couche avec le
# graphe des dépendances (dependances-groupes.yml, ex. dovecot avant postfix).
#
# Règle : chaque groupe déployable (= un playbooks/groupes/<groupe>.yml
# opérationnel) DOIT figurer dans exactement une couche. Le générateur refuse
# sinon (garde anti-dérive : un nouveau rôle non classé casse la génération).
#
# Règle de cohérence : un prérequis (dependances-groupes.yml) doit vivre dans
# une couche <= celle du groupe qui en dépend. Une arête « en arrière » (un
# groupe qui requiert un groupe d'une couche PLUS TARDIVE) = mauvaise
# classification → le générateur refuse.
#
# L'ordre des couches ci-dessous EST l'ordre de déploiement.
couches:
- nom: socle
raison: "Base du système : /etc/hosts (résolution interne au bootstrap), paquets, durcissement. Tout en dépend."
groupes:
- serveur_debian
- serveur_durci
- nom: pki_racine
raison: "L'autorité de certification interne (step_ca) : la racine de confiance de tous les flux TLS est-ouest."
groupes:
- serveur_step_ca
- nom: pki_client
raison: "Émission des certificats et pose de la racine (root_ca 0644) sur chaque nœud. Prérequis de TOUT service qui sert ou vérifie du TLS."
groupes:
- client_pki
- nom: services
raison: "Les services d'infrastructure dont dépendent les applications : bases, annuaire, DNS, cache, métriques, journaux, edge, courriel."
groupes:
- serveur_postgresql
- serveur_openldap
- serveur_powerdns
- serveur_redis
- serveur_prometheus
- serveur_loki
- serveur_nginx
- serveur_rspamd
- serveur_dovecot
- serveur_postfix
- serveur_backup
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
groupes:
- serveur_keycloak
- serveur_oauth2_proxy
- serveur_forgejo
- serveur_icinga
- serveur_icingaweb2
- serveur_grafana
- serveur_collabora
- serveur_nextcloud
- serveur_web_frontal
- serveur_web_dorsal
- nom: agents
raison: "Les intégrations clientes qui expédient vers les services centraux (métriques, journaux, courriel, sauvegardes, résolution locale). Déployées en dernier, quand leurs cibles sont debout."
groupes:
- client_metrique
- client_journal
- client_smtp
- client_backup
- client_unbound

View file

@ -24,11 +24,24 @@ groupes:
raison: "Les notifications locales doivent relayer vers un MTA actif (Postfix)."
surveillance: "Verifier file d'attente, relais SMTP et echecs de livraison."
serveur_postfix:
requiert_groupes_actifs:
- serveur_dovecot
raison: "Postfix remet le courrier local via LMTP a Dovecot (mailstore) ; la remise exige Dovecot actif."
surveillance: "Verifier file d'attente, remise LMTP (status=sent) et rejets."
serveur_keycloak:
requiert_groupes_actifs:
- serveur_postgresql
raison: "Keycloak doit utiliser une base PostgreSQL geree."
surveillance: "Verifier connexion base, etat realm et disponibilite OIDC."
- serveur_openldap
raison: "Keycloak persiste dans PostgreSQL et federe l'annuaire OpenLDAP (resoudre_annuaire_uri)."
surveillance: "Verifier connexion base, federation LDAP, etat realm et disponibilite OIDC."
serveur_dovecot:
requiert_groupes_actifs:
- serveur_openldap
raison: "Dovecot resout ses utilisateurs (userdb/passdb) sur l'annuaire OpenLDAP (resoudre_annuaire_*)."
surveillance: "Verifier bind LDAP, authentification IMAP et remise LMTP."
serveur_grafana:
requiert_groupes_actifs:
@ -43,6 +56,14 @@ groupes:
raison: "La plateforme Icinga Web/BPM depend d'une base relationnelle."
surveillance: "Verifier moteur Icinga, base, interface web et notifications."
serveur_icingaweb2:
requiert_groupes_actifs:
- serveur_icinga
- serveur_postgresql
- serveur_openldap
raison: "Frontal web d'Icinga : lit IcingaDB (base), affiche le moteur Icinga et authentifie sur l'annuaire OpenLDAP (resoudre_annuaire)."
surveillance: "Verifier acces IcingaDB, connexion Icinga et authentification LDAP."
serveur_forgejo:
requiert_groupes_actifs:
- serveur_postgresql
@ -51,3 +72,11 @@ groupes:
raison: "Forgejo depend d'une base, d'une publication HTTP(S) et d'un relais courriel (MTA Postfix)."
surveillance: "Verifier HTTP(S), base, files Git et envoi courriel."
serveur_nextcloud:
requiert_groupes_actifs:
- serveur_postgresql
- serveur_keycloak
- serveur_collabora
raison: "Nextcloud persiste dans PostgreSQL (verify-full), federe l'identite par OIDC (Keycloak) et valide WOPI contre Collabora (edition en ligne)."
surveillance: "Verifier base, decouverte OIDC, autodetection WOPI et acces web."

View file

@ -20,7 +20,10 @@ flux:
port: 5432 # entier ou liste [80, 443]
protocole: tcp # tcp | udp
pair: [serveur_keycloak, serveur_forgejo] # QUI — voir « Résolution du pair »
chiffrement: tls-requis # tls-requis | tls | starttls | clair | n-a (bonus audit)
chiffrement: tls-requis # tls-requis | tls | starttls | ssh | tls-cible | clair | n-a (bonus audit)
# tls-requis : chiffré + pair vérifié (verify-full) | tls : chiffré | starttls : mise à niveau opportuniste
# ssh : transport SSH (chiffré, hôte vérifié) | tls-cible : TLS visé mais pas encore appliqué (feuille de route)
# clair : non chiffré (local ou terminé à l'edge) | n-a : sans objet
raison: "Connexions applicatives (verify-full)." # lisible, pour l'audit
```

80
docs/registre-flux.md Normal file
View file

@ -0,0 +1,80 @@
# Registre des flux réseau — matrice d'audit (GÉNÉRÉ)
> Généré par `scripts/resoudre_flux.py` (`make flux`) depuis les `roles/*/meta/flux.yml`.
> **Ne pas éditer à la main.** Matrice source→destination pour l'audit de sécurité et le
> label de certification. `ingress` = le rôle écoute ; `egress` = le rôle se connecte.
| Rôle (propriétaire) | Sens | Port | Proto | Pair | Chiffrement | Raison |
| --- | --- | --- | --- | --- | --- | --- |
| `client_backup` | egress | 22 | tcp | serveur_backup | ssh | Poussée des instantanés restic vers le dépôt hors-nœud, par SSH (clé dédiée). |
| `client_journal` | egress | 3100 | tcp | serveur_loki | tls | Expédition des journaux par Alloy vers le collecteur central Loki (HTTPS, cert step-ca). |
| `client_metrique` | ingress | 9100 | tcp | serveur_prometheus | tls | Scrape des métriques par Prometheus (node_exporter en HTTPS). |
| `client_pki` | egress | 8443 | tcp | serveur_step_ca | tls-requis | Émission/renouvellement des certificats par ACME et récupération de la racine auprès de l'AC interne. |
| `client_smtp` | egress | 25 | tcp | serveur_postfix | starttls | Relais des notifications locales vers le MTA central (Postfix), STARTTLS. |
| `client_unbound` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
| `client_unbound` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
| `serveur_dovecot` | ingress | 24 | tcp | serveur_postfix | tls-requis | Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify). |
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP servis via l'edge (TLS terminé à l'edge). |
| `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). |
| `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). |
| `serveur_grafana` | ingress | 3000 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). |
| `serveur_grafana` | egress | 443 | tcp | edge | tls-requis | Authentification OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_icinga` | ingress | 5665 | tcp | localhost | clair | API Icinga 2 consommée en local par Icinga Web 2 co-localisé. |
| `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). |
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). |
| `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). |
| `serveur_icingaweb2` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Lecture d'IcingaDB (base relationnelle, verify-full). |
| `serveur_keycloak` | ingress | 8080 | tcp | edge | clair | Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge). |
| `serveur_keycloak` | egress | 636 | tcp | serveur_openldap | tls-requis | Fédération de l'annuaire OpenLDAP (LDAPS). |
| `serveur_keycloak` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Persistance Keycloak dans PostgreSQL (verify-full). |
| `serveur_loki` | ingress | 3100 | tcp | client_journal | tls | Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca). |
| `serveur_loki` | ingress | 3100 | tcp | localhost | clair | Requêtes de Grafana co-localisé (datasource Loki en localhost). |
| `serveur_nextcloud` | ingress | 80 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). |
| `serveur_nextcloud` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_nextcloud` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Nextcloud (verify-full). |
| `serveur_nextcloud` | egress | 9980 | tcp | serveur_collabora | tls-cible | Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends. |
| `serveur_nginx` | ingress | 80 | tcp | externe | clair | HTTP entrant — redirection permanente vers HTTPS. |
| `serveur_nginx` | ingress | 443 | tcp | externe | tls-requis | HTTPS entrant — services exposés (terminaison TLS à l'edge). |
| `serveur_nginx` | egress | derive | tcp | expositions | tls-cible | Proxy vers les backends exposés (host:port dérivés des expose ; TLS interne = roadmap edge→backends). |
| `serveur_oauth2_proxy` | ingress | 4180 | tcp | edge | clair | Point d'entrée SSO servi via l'edge (TLS terminé à l'edge) devant l'application protégée. |
| `serveur_oauth2_proxy` | egress | 443 | tcp | edge | tls-requis | Émetteur OIDC (Keycloak) via son FQDN publié à l'edge : échange de jetons. |
| `serveur_oauth2_proxy` | egress | 8080 | tcp | localhost | clair | Relais vers l'application co-localisée protégée (upstream en localhost). |
| `serveur_openldap` | ingress | 389 | tcp | flotte | starttls | LDAP + STARTTLS pour les clients internes qui préfèrent la mise à niveau TLS sur 389. |
| `serveur_openldap` | ingress | 636 | tcp | serveur_keycloak, serveur_dovecot, serveur_icingaweb2, serveur_postfix | tls-requis | LDAPS : fédération (Keycloak), userdb courriel (Dovecot), auth web (Icinga Web 2), tables virtuelles (Postfix). |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
| `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. |
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
| `serveur_postfix` | egress | 25 | tcp | externe | starttls | Relais sortant vers les MX distants (STARTTLS opportuniste). |
| `serveur_postfix` | egress | 636 | tcp | serveur_openldap | tls-requis | Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS). |
| `serveur_postfix` | egress | 11332 | tcp | localhost | clair | Filtre milter rspamd co-localisé (antispam + signature DKIM). |
| `serveur_postfix` | egress | 12345 | tcp | serveur_dovecot | tls | Validation SASL des identifiants de soumission contre Dovecot. |
| `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). |
| `serveur_powerdns` | ingress | 53 | udp | flotte | clair | Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS). |
| `serveur_powerdns` | ingress | 53 | tcp | flotte | clair | Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips). |
| `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). |
| `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. |
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
| `serveur_rspamd` | ingress | 11332 | tcp | localhost | clair | Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement. |
| `serveur_rspamd` | ingress | 11334 | tcp | localhost | clair | Interface de contrôle rspamd (statistiques, apprentissage) en local. |
| `serveur_step_ca` | ingress | 8443 | tcp | flotte | tls-requis | ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine. |
| `serveur_web_dorsal` | ingress | 80 | tcp | edge | clair | Front nginx local des webapps, proxie par l'edge (TLS termine a l'edge). |
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
| `serveur_web_frontal` | ingress | 80 | tcp | edge | clair | Contenu statique servi au navigateur via l'edge (TLS termine a l'edge). |
| `serveur_web_frontal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot du site (Forgejo souverain) au deploiement. |
## Synthèse chiffrement
- **clair** : 23 flux
- **ssh** : 3 flux
- **starttls** : 6 flux
- **tls** : 8 flux
- **tls-cible** : 2 flux
- **tls-requis** : 21 flux

View file

@ -1,12 +1,48 @@
---
- name: Point d'entrée général Set-OPS
hosts: all
gather_facts: true
become: true
# GENERE par scripts/orchestrer.py (make site) — NE PAS EDITER A LA MAIN.
# Point d'entree ordonne : deploie l'ecosysteme couche par couche, dans
# l'ordre de reconstruction. Regenerer apres tout changement de couches
# (docs/couches-deploiement.yml) ou du graphe (docs/dependances-groupes.yml).
# Ansible saute les groupes sans hote actif : le meme fichier sert toute instance.
tasks:
- name: Message
ansible.builtin.debug:
msg:
- "Set-OPS est un dépôt global."
- "Utiliser un playbook spécialisé."
# --- couche socle ---
- import_playbook: groupes/serveur_debian.yml
- import_playbook: groupes/serveur_durci.yml
# --- couche pki_racine ---
- import_playbook: groupes/serveur_step_ca.yml
# --- couche pki_client ---
- import_playbook: groupes/client_pki.yml
# --- couche services ---
- import_playbook: groupes/serveur_postgresql.yml
- import_playbook: groupes/serveur_openldap.yml
- import_playbook: groupes/serveur_powerdns.yml
- import_playbook: groupes/serveur_redis.yml
- import_playbook: groupes/serveur_prometheus.yml
- import_playbook: groupes/serveur_loki.yml
- import_playbook: groupes/serveur_nginx.yml
- import_playbook: groupes/serveur_rspamd.yml
- import_playbook: groupes/serveur_backup.yml
- import_playbook: groupes/serveur_dovecot.yml
- import_playbook: groupes/serveur_postfix.yml
# --- couche apps ---
- import_playbook: groupes/serveur_keycloak.yml
- import_playbook: groupes/serveur_oauth2_proxy.yml
- import_playbook: groupes/serveur_forgejo.yml
- import_playbook: groupes/serveur_icinga.yml
- import_playbook: groupes/serveur_grafana.yml
- import_playbook: groupes/serveur_collabora.yml
- import_playbook: groupes/serveur_web_frontal.yml
- import_playbook: groupes/serveur_web_dorsal.yml
- import_playbook: groupes/serveur_icingaweb2.yml
- import_playbook: groupes/serveur_nextcloud.yml
# --- couche agents ---
- import_playbook: groupes/client_metrique.yml
- import_playbook: groupes/client_journal.yml
- import_playbook: groupes/client_smtp.yml
- import_playbook: groupes/client_backup.yml
- import_playbook: groupes/client_unbound.yml

View file

@ -0,0 +1,9 @@
---
# Flux réseau du client de sauvegarde (restic -> dépôt hors-nœud). Voir docs/flux-conception.md.
flux:
- sens: egress
port: 22
protocole: tcp
pair: serveur_backup
chiffrement: ssh
raison: "Poussée des instantanés restic vers le dépôt hors-nœud, par SSH (clé dédiée)."

View file

@ -0,0 +1,9 @@
---
# Flux réseau du client journal (Alloy -> Loki). Voir docs/flux-conception.md.
flux:
- sens: egress
port: 3100
protocole: tcp
pair: serveur_loki
chiffrement: tls
raison: "Expédition des journaux par Alloy vers le collecteur central Loki (HTTPS, cert step-ca)."

View file

@ -0,0 +1,9 @@
---
# Flux réseau du client PKI (émission ACME + racine step-ca). Voir docs/flux-conception.md.
flux:
- sens: egress
port: 8443
protocole: tcp
pair: serveur_step_ca
chiffrement: tls-requis
raison: "Émission/renouvellement des certificats par ACME et récupération de la racine auprès de l'AC interne."

View file

@ -0,0 +1,9 @@
---
# Flux réseau du client SMTP (relais des notifications locales). Voir docs/flux-conception.md.
flux:
- sens: egress
port: 25
protocole: tcp
pair: serveur_postfix
chiffrement: starttls
raison: "Relais des notifications locales vers le MTA central (Postfix), STARTTLS."

View file

@ -0,0 +1,15 @@
---
# Flux réseau du résolveur local Unbound (opt-in). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: localhost
chiffrement: clair
raison: "Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1)."
- sens: egress
port: 53
protocole: tcp
pair: serveur_powerdns
chiffrement: clair
raison: "Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS)."

View file

@ -8,3 +8,8 @@ nftables_baseline_output_policy: "accept"
nftables_baseline_ssh_port: 22
nftables_baseline_extra_tcp_ports: []
nftables_baseline_extra_udp_ports: []
# Ruleset résolu, généré par `make flux` (scripts/resoudre_flux.py) : règles par
# source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il
# est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus.
nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft"

View file

@ -4,7 +4,27 @@
name: nftables
state: present
- name: Déployer la configuration nftables Set-OPS
- name: Vérifier la présence du ruleset résolu (généré par make flux)
ansible.builtin.stat:
path: "{{ nftables_baseline_ruleset_genere }}"
delegate_to: localhost
become: false
changed_when: false
register: nftables_baseline_ruleset_stat
- name: Déployer le ruleset nftables résolu (moindre privilège, généré)
ansible.builtin.copy:
src: "{{ nftables_baseline_ruleset_genere }}"
dest: /etc/nftables.conf
owner: root
group: root
mode: "0644"
notify: Reload nftables
when:
- nftables_baseline_enabled | bool
- nftables_baseline_ruleset_stat.stat.exists
- name: Déployer la configuration nftables Set-OPS (gabarit plat — repli)
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
@ -12,7 +32,9 @@
group: root
mode: "0644"
notify: Reload nftables
when: nftables_baseline_enabled | bool
when:
- nftables_baseline_enabled | bool
- not nftables_baseline_ruleset_stat.stat.exists
- name: Activer nftables si demandé
ansible.builtin.systemd:

View file

@ -0,0 +1,9 @@
---
# Flux réseau du dépôt de sauvegarde restic (hors-nœud). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 22
protocole: tcp
pair: client_backup
chiffrement: ssh
raison: "Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés."

View file

@ -0,0 +1,10 @@
---
# Flux réseau du socle Debian : le plan de gestion (SSH). Voir docs/flux-conception.md.
# CRITIQUE : sans cette règle, les nftables générés couperaient l'accès Ansible/SSH.
flux:
- sens: ingress
port: 22
protocole: tcp
pair: [flotte, externe]
chiffrement: ssh
raison: "Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense)."

View file

@ -0,0 +1,27 @@
---
# Flux réseau de Dovecot (IMAP + LMTP, mailstore). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 24
protocole: tcp
pair: serveur_postfix
chiffrement: tls-requis
raison: "Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify)."
- sens: ingress
port: 12345
protocole: tcp
pair: serveur_postfix
chiffrement: tls
raison: "Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot."
- sens: ingress
port: 993
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: "Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense."
- sens: egress
port: 636
protocole: tcp
pair: serveur_openldap
chiffrement: tls-requis
raison: "userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS)."

View file

@ -39,11 +39,11 @@ serveur_forgejo_admin_user: "forgejo-admin"
serveur_forgejo_admin_email: "admin@{{ domaine_interne }}"
serveur_forgejo_admin_password: "{{ vault_forgejo_admin | default('') }}" # rempli depuis la voute (vault_forgejo_admin)
# --- SSO OIDC (Keycloak, realm applicatif) — « Se connecter avec Chezlepro » ---
# --- SSO OIDC (Keycloak, realm applicatif) — « Se connecter avec <organisation> » ---
# Requiert client_pki (confiance step_ca) + résolution de l'hôte Keycloak (plancher/DNS).
# Le client OIDC doit exister dans le realm (serveur_keycloak_clients).
serveur_forgejo_oidc_actif: true
serveur_forgejo_oidc_nom: "chezlepro" # slug du callback : /user/oauth2/<nom>/callback
serveur_forgejo_oidc_nom: "{{ organisation | default('organisation') | lower | replace(' ', '-') }}" # slug du callback : /user/oauth2/<nom>/callback
serveur_forgejo_oidc_client_id: "forgejo"
serveur_forgejo_oidc_client_secret: "{{ vault_forgejo_oidc | default('') }}" # voûte (vault_forgejo_oidc)
serveur_forgejo_oidc_realm: "{{ identite_realm | default('chezlepro') }}"

View file

@ -0,0 +1,27 @@
---
# Flux réseau de Forgejo (forge Git). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 3000
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web + Git HTTP servis via l'edge (TLS terminé à l'edge)."
- sens: egress
port: 5432
protocole: tcp
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Base de données Forgejo (verify-full)."
- sens: egress
port: 25
protocole: tcp
pair: serveur_postfix
chiffrement: starttls
raison: "Notifications courriel (relais via le MTA Postfix)."
- sens: egress
port: 443
protocole: tcp
pair: edge
chiffrement: tls-requis
raison: "Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge)."

View file

@ -19,11 +19,11 @@ serveur_grafana_dashboards_dir: "/var/lib/grafana/dashboards"
serveur_grafana_admin_password: "{{ vault_grafana_admin | default('') }}" # rempli depuis la voute (vault_grafana_admin)
# --- SSO OIDC (Keycloak, realm applicatif) ---
# « Se connecter avec Chezlepro » : Grafana délègue l'auth à Keycloak (client confidentiel).
# « Se connecter avec <organisation> » : Grafana délègue l'auth à Keycloak (client confidentiel).
# Requiert client_pki sur ce nœud (confiance step_ca pour les appels serveur→Keycloak) +
# résolution de l'hôte Keycloak (edge). Le client OIDC doit exister dans le realm.
serveur_grafana_oidc_actif: true
serveur_grafana_oidc_nom: "Chezlepro"
serveur_grafana_oidc_nom: "{{ organisation | default('Organisation') }}"
serveur_grafana_oidc_client_id: "grafana"
serveur_grafana_oidc_client_secret: "{{ vault_grafana_oidc | default('') }}" # voûte (vault_grafana_oidc)
serveur_grafana_oidc_realm: "{{ identite_realm | default('chezlepro') }}"

View file

@ -0,0 +1,16 @@
---
# Flux réseau de Grafana (tableaux de bord). Voir docs/flux-conception.md.
# Prometheus et Loki sont co-localisés (datasources en localhost) : aucun flux inter-nœud.
flux:
- sens: ingress
port: 3000
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web servie via l'edge (TLS terminé à l'edge)."
- sens: egress
port: 443
protocole: tcp
pair: edge
chiffrement: tls-requis
raison: "Authentification OIDC auprès de Keycloak (via son FQDN publié à l'edge)."

View file

@ -0,0 +1,16 @@
---
# Flux réseau d'Icinga 2 (moteur de supervision). Voir docs/flux-conception.md.
# IcingaDB (Redis 6380) est co-localisé : consommé en localhost par le frontal Icinga Web 2.
flux:
- sens: ingress
port: 5665
protocole: tcp
pair: localhost
chiffrement: clair
raison: "API Icinga 2 consommée en local par Icinga Web 2 co-localisé."
- sens: egress
port: 5432
protocole: tcp
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Base relationnelle du moteur Icinga (verify-full)."

View file

@ -0,0 +1,22 @@
---
# Flux réseau d'Icinga Web 2 (frontal de supervision). Voir docs/flux-conception.md.
# Icinga 2 (API 5665) et IcingaDB (Redis 6380) sont co-localisés : consommés en localhost.
flux:
- sens: ingress
port: 8080
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy)."
- sens: egress
port: 5432
protocole: tcp
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Lecture d'IcingaDB (base relationnelle, verify-full)."
- sens: egress
port: 636
protocole: tcp
pair: serveur_openldap
chiffrement: tls-requis
raison: "Authentification des utilisateurs sur l'annuaire (LDAPS)."

View file

@ -0,0 +1,21 @@
---
# Flux réseau de Keycloak (SSO OIDC). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 8080
protocole: tcp
pair: edge
chiffrement: clair
raison: "Console et endpoints OIDC servis au navigateur et aux applications via l'edge (TLS terminé à l'edge)."
- sens: egress
port: 5432
protocole: tcp
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Persistance Keycloak dans PostgreSQL (verify-full)."
- sens: egress
port: 636
protocole: tcp
pair: serveur_openldap
chiffrement: tls-requis
raison: "Fédération de l'annuaire OpenLDAP (LDAPS)."

View file

@ -0,0 +1,15 @@
---
# Flux réseau de Loki (journaux). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 3100
protocole: tcp
pair: client_journal
chiffrement: tls
raison: "Réception des journaux poussés par Alloy (client_journal) en HTTPS (http_tls_config, cert step-ca)."
- sens: ingress
port: 3100
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Requêtes de Grafana co-localisé (datasource Loki en localhost)."

View file

@ -58,7 +58,7 @@ serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) |
# --- SSO OIDC (Keycloak) — app user_oidc ---
serveur_nextcloud_oidc_actif: true
serveur_nextcloud_oidc_nom: "Chezlepro"
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"
serveur_nextcloud_oidc_client_id: "nextcloud"
serveur_nextcloud_oidc_client_secret: "{{ vault_nextcloud_oidc | default('') }}"
serveur_nextcloud_oidc_realm: "{{ identite_realm | default('chezlepro') }}"
@ -81,7 +81,7 @@ serveur_nextcloud_trusted_domains:
# --- Identité visuelle Alliance Boréale ---
serveur_nextcloud_branding: true
serveur_nextcloud_theme_nom: "Chezlepro"
serveur_nextcloud_theme_nom: "{{ organisation | default('Organisation') }}"
serveur_nextcloud_theme_slogan: "Votre maison numérique souveraine"
serveur_nextcloud_theme_couleur: "#0e1640"
serveur_nextcloud_theme_ciel: true # thème custom « ciel boréal » (login + app)

View file

@ -0,0 +1,28 @@
---
# Flux réseau de Nextcloud (collaboration). Voir docs/flux-conception.md.
# Redis (6379) est co-localisé : cache/verrous en localhost.
flux:
- sens: ingress
port: 80
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web servie via l'edge (TLS terminé à l'edge)."
- sens: egress
port: 5432
protocole: tcp
pair: serveur_postgresql
chiffrement: tls-requis
raison: "Base de données Nextcloud (verify-full)."
- sens: egress
port: 443
protocole: tcp
pair: edge
chiffrement: tls-requis
raison: "Découverte OIDC auprès de Keycloak (via son FQDN publié à l'edge)."
- sens: egress
port: 9980
protocole: tcp
pair: serveur_collabora
chiffrement: tls-cible
raison: "Vérifications WOPI serveur->Collabora (édition en ligne). TLS interne = feuille de route edge->backends."

View file

@ -0,0 +1,21 @@
---
# Flux réseau d'oauth2-proxy (passerelle SSO devant une app sans OIDC natif). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 4180
protocole: tcp
pair: edge
chiffrement: clair
raison: "Point d'entrée SSO servi via l'edge (TLS terminé à l'edge) devant l'application protégée."
- sens: egress
port: 443
protocole: tcp
pair: edge
chiffrement: tls-requis
raison: "Émetteur OIDC (Keycloak) via son FQDN publié à l'edge : échange de jetons."
- sens: egress
port: 8080
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Relais vers l'application co-localisée protégée (upstream en localhost)."

View file

@ -0,0 +1,15 @@
---
# Flux réseau de l'annuaire OpenLDAP (slapd). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 636
protocole: tcp
pair: [serveur_keycloak, serveur_dovecot, serveur_icingaweb2, serveur_postfix]
chiffrement: tls-requis
raison: "LDAPS : fédération (Keycloak), userdb courriel (Dovecot), auth web (Icinga Web 2), tables virtuelles (Postfix)."
- sens: ingress
port: 389
protocole: tcp
pair: flotte
chiffrement: starttls
raison: "LDAP + STARTTLS pour les clients internes qui préfèrent la mise à niveau TLS sur 389."

View file

@ -0,0 +1,45 @@
---
# Flux réseau de Postfix (MTA). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 25
protocole: tcp
pair: [externe, client_smtp]
chiffrement: starttls
raison: "SMTP entrant : courrier externe (MX) et notifications internes (client_smtp)."
- sens: ingress
port: 587
protocole: tcp
pair: flotte
chiffrement: starttls
raison: "Soumission authentifiée (submission) pour les agents internes qui envoient du courrier."
- sens: egress
port: 24
protocole: tcp
pair: serveur_dovecot
chiffrement: tls-requis
raison: "Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié."
- sens: egress
port: 12345
protocole: tcp
pair: serveur_dovecot
chiffrement: tls
raison: "Validation SASL des identifiants de soumission contre Dovecot."
- sens: egress
port: 11332
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Filtre milter rspamd co-localisé (antispam + signature DKIM)."
- sens: egress
port: 636
protocole: tcp
pair: serveur_openldap
chiffrement: tls-requis
raison: "Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS)."
- sens: egress
port: 25
protocole: tcp
pair: externe
chiffrement: starttls
raison: "Relais sortant vers les MX distants (STARTTLS opportuniste)."

View file

@ -0,0 +1,15 @@
---
# Flux réseau du DNS autoritatif interne (PowerDNS). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS)."
- sens: ingress
port: 53
protocole: tcp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."

View file

@ -0,0 +1,15 @@
---
# Flux réseau de Prometheus (métriques). Voir docs/flux-conception.md.
flux:
- sens: egress
port: 9100
protocole: tcp
pair: client_metrique
chiffrement: tls
raison: "Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté."
- sens: ingress
port: 9090
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud)."

View file

@ -0,0 +1,9 @@
---
# Flux réseau de Redis. Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 6379
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud."

View file

@ -0,0 +1,15 @@
---
# Flux réseau de rspamd (antispam + DKIM, milter Postfix). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 11332
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Protocole milter consommé par Postfix co-localisé (analyse + signature DKIM). Local uniquement."
- sens: ingress
port: 11334
protocole: tcp
pair: localhost
chiffrement: clair
raison: "Interface de contrôle rspamd (statistiques, apprentissage) en local."

View file

@ -0,0 +1,9 @@
---
# Flux réseau de l'autorité de certification interne (step-ca). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 8443
protocole: tcp
pair: flotte
chiffrement: tls-requis
raison: "ACME + API step-ca : chaque nœud (client_pki) émet/renouvelle ses certificats et récupère la racine."

View file

@ -379,6 +379,8 @@ def main() -> int:
active_parser = subparsers.add_parser("verifier-actif", help="Verifie qu'un hote est actif.")
active_parser.add_argument("--hote", required=True)
subparsers.add_parser("lister-actifs", help="Liste les hotes actifs, un par ligne (pour la creation/deploiement de flotte).")
host_dependencies_parser = subparsers.add_parser(
"verifier-dependances-hote",
help="Verifie les prerequis actifs requis par les groupes d'un hote.",
@ -441,6 +443,9 @@ def main() -> int:
afficher_playbooks_hote(data, args.hote, args.dossier_playbooks)
elif args.command == "verifier-actif":
verifier_hote_actif(data, args.hote)
elif args.command == "lister-actifs":
for hote in sorted(enfants(data).get(GROUPE_HOTES_ACTIFS, {}).get("hosts", {}) or {}):
print(hote)
elif args.command == "verifier-dependances-hote":
exiger_dependances(dependencies, args.dependances)
verifier_dependances_hote(data, dependencies, args.hote)

216
scripts/orchestrer.py Normal file
View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Orchestrateur Set-OPS : couches + graphe -> playbooks/site.yml ordonne.
Genere le point d'entree general (site.yml) comme une SEQUENCE ORDONNEE
d'`import_playbook` des groupes, dans l'ordre de reconstruction :
couche (docs/couches-deploiement.yml, cle primaire)
puis tri topologique INTRA-couche (docs/dependances-groupes.yml).
C'est un artefact du MOTEUR (deterministe : univers des playbooks de groupe
x couches x graphe) aucune donnee d'instance. Ansible saute tout seul les
groupes sans hote actif, donc le meme site.yml sert toute instance.
Deux gardes de coherence (le generateur REFUSE sinon) :
1. bijection : chaque groupe deployable (playbooks/groupes/<g>.yml operationnel)
figure dans exactement une couche, et chaque groupe d'une couche a un playbook ;
2. pas d'arete « en arriere » : un prerequis (graphe) doit vivre dans une couche
<= celle du groupe qui en depend (sinon la classification est fausse).
Usage :
python3 scripts/orchestrer.py verifier # valide, n'ecrit rien (sortie != 0 si erreur)
python3 scripts/orchestrer.py ordre # imprime l'ordre (couche : groupes)
python3 scripts/orchestrer.py ecrire # (re)genere playbooks/site.yml
"""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
from inventory_rules import charger_dependances, est_groupe_operationnel
RACINE = Path(__file__).resolve().parents[1]
DOSSIER_GROUPES = RACINE / "playbooks" / "groupes"
FICHIER_COUCHES = RACINE / "docs" / "couches-deploiement.yml"
FICHIER_DEPENDANCES = RACINE / "docs" / "dependances-groupes.yml"
SITE = RACINE / "playbooks" / "site.yml"
class ErreurOrchestration(Exception):
"""Incoherence entre l'univers des groupes, les couches et le graphe."""
def univers_groupes() -> set[str]:
"""Les groupes deployables : un playbooks/groupes/<g>.yml operationnel."""
return {
p.stem
for p in DOSSIER_GROUPES.glob("*.yml")
if est_groupe_operationnel(p.stem)
}
def charger_couches(path: Path = FICHIER_COUCHES) -> list[dict]:
if not path.exists():
raise ErreurOrchestration(f"Registre des couches introuvable : {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
couches = data.get("couches")
if not isinstance(couches, list) or not couches:
raise ErreurOrchestration(f"{path} doit contenir une liste 'couches' non vide.")
for couche in couches:
if not isinstance(couche, dict) or "nom" not in couche:
raise ErreurOrchestration(f"Couche invalide (nom requis) dans {path}.")
if not isinstance(couche.get("groupes"), list):
raise ErreurOrchestration(f"Couche {couche.get('nom')!r} : 'groupes' doit etre une liste.")
return couches
def indice_couche(couches: list[dict]) -> dict[str, int]:
"""groupe -> indice de sa couche (0 = premiere deployee)."""
indice: dict[str, int] = {}
for i, couche in enumerate(couches):
for groupe in couche["groupes"]:
if groupe in indice:
raise ErreurOrchestration(
f"Groupe {groupe!r} classe dans deux couches "
f"({couches[indice[groupe]]['nom']} et {couche['nom']})."
)
indice[groupe] = i
return indice
def valider(couches: list[dict], dependances: dict) -> dict[str, int]:
"""Bijection univers<->couches + aucune arete en arriere. Renvoie groupe->couche."""
univers = univers_groupes()
indice = indice_couche(couches)
classes = set(indice)
non_classes = sorted(univers - classes)
if non_classes:
raise ErreurOrchestration(
"Groupes deployables non classes dans une couche : "
+ ", ".join(non_classes)
+ f"\n -> ajouter chacun a une couche dans {FICHIER_COUCHES.name}."
)
fantomes = sorted(classes - univers)
if fantomes:
raise ErreurOrchestration(
"Couches referencant des groupes sans playbook operationnel : "
+ ", ".join(fantomes)
+ f"\n -> playbooks/groupes/<groupe>.yml attendu, ou retirer du registre."
)
# Arete « en arriere » : n requiert m, mais m est deploye APRES n.
arrieres: list[str] = []
for groupe, config in dependances.items():
if groupe not in indice:
continue
for requis in config.get("requiert_groupes_actifs", []):
if requis in indice and indice[requis] > indice[groupe]:
arrieres.append(
f"{groupe} (couche {couches[indice[groupe]]['nom']}) "
f"requiert {requis} (couche {couches[indice[requis]]['nom']}, plus tardive)"
)
if arrieres:
raise ErreurOrchestration(
"Dependances incoherentes avec les couches (prerequis deploye trop tard) :\n - "
+ "\n - ".join(arrieres)
)
return indice
def _tri_topo_intra(groupes: list[str], dependances: dict) -> list[str]:
"""Ordonne des groupes d'UNE MEME couche : un prerequis (present ici) d'abord.
Depart stable = l'ordre du registre (couches-deploiement.yml).
"""
ensemble = set(groupes)
rang = {g: i for i, g in enumerate(groupes)} # ordre du registre
# aretes intra-couche : m -> n (m requis par n) ; on veut m avant n.
restant = {
n: {m for m in dependances.get(n, {}).get("requiert_groupes_actifs", []) if m in ensemble}
for n in groupes
}
ordre: list[str] = []
while restant:
prets = sorted((g for g, deps in restant.items() if not deps), key=lambda g: rang[g])
if not prets:
raise ErreurOrchestration(
"Cycle de dependances intra-couche : " + ", ".join(sorted(restant))
)
for g in prets:
ordre.append(g)
del restant[g]
for deps in restant.values():
deps.discard(g)
return ordre
def ordre_deploiement(couches: list[dict] | None = None, dependances: dict | None = None) -> list[tuple[str, list[str]]]:
"""Renvoie [(nom_couche, [groupes ordonnes]), ...] dans l'ordre de deploiement."""
couches = couches or charger_couches()
dependances = dependances if dependances is not None else charger_dependances(FICHIER_DEPENDANCES)
valider(couches, dependances)
resultat: list[tuple[str, list[str]]] = []
for couche in couches:
ordonnes = _tri_topo_intra(list(couche["groupes"]), dependances)
resultat.append((couche["nom"], ordonnes))
return resultat
def rendre_site(ordre: list[tuple[str, list[str]]]) -> str:
lignes = [
"---",
"# GENERE par scripts/orchestrer.py (make site) — NE PAS EDITER A LA MAIN.",
"# Point d'entree ordonne : deploie l'ecosysteme couche par couche, dans",
"# l'ordre de reconstruction. Regenerer apres tout changement de couches",
"# (docs/couches-deploiement.yml) ou du graphe (docs/dependances-groupes.yml).",
"# Ansible saute les groupes sans hote actif : le meme fichier sert toute instance.",
"",
]
for nom, groupes in ordre:
lignes.append(f"# --- couche {nom} ---")
for groupe in groupes:
lignes.append(f"- import_playbook: groupes/{groupe}.yml")
lignes.append("")
return "\n".join(lignes).rstrip() + "\n"
def ecrire(path: Path = SITE) -> None:
contenu = rendre_site(ordre_deploiement())
path.write_text(contenu, encoding="utf-8")
print(f"{path.relative_to(RACINE)} (re)genere. git est le filet : git diff / git checkout.")
def imprimer_ordre() -> None:
for i, (nom, groupes) in enumerate(ordre_deploiement()):
print(f"{i}. couche {nom}")
for groupe in groupes:
print(f" {groupe}")
def main(argv: list[str]) -> int:
commande = argv[0] if argv else "verifier"
try:
if commande == "verifier":
ordre_deploiement() # leve si incoherent
total = sum(len(g) for _, g in ordre_deploiement())
print(f"Orchestration coherente : {total} groupes classes, aucun cycle, aucune arete en arriere.")
elif commande == "ordre":
imprimer_ordre()
elif commande == "ecrire":
ecrire()
else:
print(f"Commande inconnue : {commande} (verifier | ordre | ecrire)", file=sys.stderr)
return 2
except ErreurOrchestration as erreur:
print(f"ERREUR orchestration :\n{erreur}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

279
scripts/resoudre_flux.py Normal file
View file

@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Resolveur des flux reseau Set-OPS : meta/flux.yml -> registre d'audit + apercus nftables.
Agrege les `roles/<role>/meta/flux.yml`, resout les `pair`, et produit :
- docs/registre-flux.md : la MATRICE D'AUDIT (niveau moteur, role->role : port,
sens, chiffrement, raison). Deterministe, sans donnee d'instance.
- des APERCUS nftables par hote (IP resolues, `ip saddr` = moindre privilege,
policy drop) sous <instance>/flux-genere/<hote>.nft. Fichiers GENERES et
inspectables NON actives (l'activation reste un geste dedie, teste par noeud).
NON destructif : n'active aucun pare-feu. Voir docs/flux-conception.md.
Usage :
python3 scripts/resoudre_flux.py registre # (re)genere docs/registre-flux.md
python3 scripts/resoudre_flux.py nftables # apercus par hote (requiert l'inventaire)
python3 scripts/resoudre_flux.py verifier # valide le schema de tous les flux.yml
"""
from __future__ import annotations
import glob
import os
import sys
from pathlib import Path
import yaml
from inventory_rules import est_groupe_operationnel
RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles"
REGISTRE = RACINE / "docs" / "registre-flux.md"
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
SENS = {"ingress", "egress"}
PROTO = {"tcp", "udp"}
CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-a"}
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive"}
GROUPE_EDGE = "serveur_nginx"
class ErreurFlux(Exception):
pass
def charger_flux() -> dict[str, list[dict]]:
"""{role: [flux, ...]} pour chaque role portant un meta/flux.yml."""
resultat: dict[str, list[dict]] = {}
for chemin in sorted(glob.glob(str(ROLES / "*" / "meta" / "flux.yml"))):
role = Path(chemin).parents[1].name
data = yaml.safe_load(Path(chemin).read_text(encoding="utf-8")) or {}
flux = data.get("flux")
if not isinstance(flux, list):
raise ErreurFlux(f"{chemin} : cle 'flux' (liste) attendue.")
resultat[role] = flux
return resultat
def _pairs(flux: dict) -> list:
pair = flux.get("pair")
return pair if isinstance(pair, list) else [pair]
def valider(flux_par_role: dict[str, list[dict]]) -> tuple[int, int]:
"""Valide le schema + la coherence de matrice. Leve ErreurFlux sinon."""
roles_existants = {p.name for p in ROLES.iterdir() if p.is_dir()}
erreurs: list[str] = []
ingress: dict[str, set] = {}
egress: list[tuple[str, str, object]] = []
total = 0
for role, flux in flux_par_role.items():
for i, fl in enumerate(flux):
total += 1
ref = f"{role}/meta/flux.yml[{i}]"
for cle in ("sens", "port", "protocole", "pair", "chiffrement", "raison"):
if cle not in fl:
erreurs.append(f"{ref} : cle manquante {cle}")
if fl.get("sens") not in SENS:
erreurs.append(f"{ref} : sens invalide {fl.get('sens')!r}")
if fl.get("protocole") not in PROTO:
erreurs.append(f"{ref} : protocole invalide {fl.get('protocole')!r}")
if fl.get("chiffrement") not in CHIFFREMENT:
erreurs.append(f"{ref} : chiffrement invalide {fl.get('chiffrement')!r}")
for p in _pairs(fl):
if p not in MOTS_PAIR and p not in roles_existants and not str(p).startswith(("serveur_", "client_")):
erreurs.append(f"{ref} : pair inconnu {p!r}")
if fl.get("sens") == "ingress":
ingress.setdefault(role, set()).add(fl.get("port"))
elif fl.get("sens") == "egress":
for p in _pairs(fl):
if str(p).startswith(("serveur_", "client_")):
egress.append((role, p, fl.get("port")))
for role, pair, port in egress:
if port not in ingress.get(pair, set()):
erreurs.append(f"matrice : {role} -> {pair}:{port} sans ingress correspondant sur {pair}")
if erreurs:
raise ErreurFlux("\n - ".join(["Flux incoherents :"] + erreurs))
return len(flux_par_role), total
# --- Registre d'audit (niveau moteur, sans instance) ---------------------------
def generer_registre(flux_par_role: dict[str, list[dict]]) -> str:
lignes = [
"# Registre des flux réseau — matrice d'audit (GÉNÉRÉ)",
"",
"> Généré par `scripts/resoudre_flux.py` (`make flux`) depuis les `roles/*/meta/flux.yml`.",
"> **Ne pas éditer à la main.** Matrice source→destination pour l'audit de sécurité et le",
"> label de certification. `ingress` = le rôle écoute ; `egress` = le rôle se connecte.",
"",
"| Rôle (propriétaire) | Sens | Port | Proto | Pair | Chiffrement | Raison |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
rangs = {s: i for i, s in enumerate(("ingress", "egress"))}
for role in sorted(flux_par_role):
for fl in sorted(flux_par_role[role], key=lambda f: (rangs.get(f.get("sens"), 9), f.get("port") or 0)):
pair = ", ".join(str(p) for p in _pairs(fl))
port = fl.get("port")
port = ", ".join(str(x) for x in port) if isinstance(port, list) else str(port)
lignes.append(
f"| `{role}` | {fl.get('sens')} | {port} | {fl.get('protocole')} "
f"| {pair} | {fl.get('chiffrement')} | {fl.get('raison', '')} |"
)
# Synthese chiffrement
compte: dict[str, int] = {}
for flux in flux_par_role.values():
for fl in flux:
compte[fl.get("chiffrement")] = compte.get(fl.get("chiffrement"), 0) + 1
lignes += ["", "## Synthèse chiffrement", ""]
for chi in sorted(compte):
lignes.append(f"- **{chi}** : {compte[chi]} flux")
return "\n".join(lignes) + "\n"
# --- Apercus nftables par hote (requiert l'inventaire) -------------------------
def _inventaire() -> Path:
for nom in ("principal", "production"):
p = INSTANCE / "inventories" / nom / "hosts.yml"
if p.exists():
return p
raise ErreurFlux(f"Inventaire introuvable sous {INSTANCE}/inventories/(principal|production).")
def _enfants(data: dict) -> dict:
return data.get("all", {}).get("children", {})
def _hotes_du_groupe(data: dict, groupe: str) -> dict:
return _enfants(data).get(groupe, {}).get("hosts", {}) or {}
def _sources_admin_ssh() -> list[str]:
"""CIDR d'administration SSH toujours autorises (intrant nftables_admin_ssh).
Garde anti-lockout : le contexte d'ou l'on administre (controleur/VPN) doit
rester joignable en SSH quelle que soit la resolution des flux.
"""
dossier = _inventaire().parent / "group_vars" / "all"
for fichier in sorted(dossier.glob("*.yml")):
if "vault" in fichier.name:
continue
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
sources = data["nftables_admin_ssh"]
return [str(s) for s in sources] if isinstance(sources, list) else [str(sources)]
return []
def _ip_par_hote(data: dict) -> dict[str, str]:
"""{hote: ansible_host} — l'IP vit dans hotes_actifs ; l'appartenance ailleurs est null."""
table: dict[str, str] = {}
for membres in _enfants(data).values():
for hote, v in (membres.get("hosts") or {}).items():
if isinstance(v, dict) and v.get("ansible_host"):
table[hote] = v["ansible_host"]
return table
def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict[str, str]) -> list[str]:
"""pair -> IP sources concretes (pour une regle ingress). [] = pas de regle inter-noeud."""
noms: set[str] = set()
for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
if p == "flotte":
noms |= actifs_noms
else:
groupe = GROUPE_EDGE if p == "edge" else p
noms |= {h for h in _hotes_du_groupe(data, groupe) if h in actifs_noms}
return sorted({ip_par_hote[h] for h in noms if h in ip_par_hote})
def generer_nftables(flux_par_role: dict[str, list[dict]]) -> list[Path]:
data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {}
actifs_noms = set(_hotes_du_groupe(data, "hotes_actifs"))
ip_par_hote = _ip_par_hote(data)
admin_ssh = _sources_admin_ssh()
regles_admin = [
f" ip saddr {{ {cidr} }} tcp dport 22 accept # administration (garde anti-lockout, intrant nftables_admin_ssh)"
for cidr in admin_ssh
]
sortie_dir = _inventaire().parent.parent.parent / "flux-genere"
sortie_dir.mkdir(parents=True, exist_ok=True)
ecrits: list[Path] = []
for hote in sorted(actifs_noms):
groupes = [g for g, membres in _enfants(data).items()
if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)]
regles: list[str] = list(regles_admin)
for g in sorted(groupes):
for fl in flux_par_role.get(g, []):
if fl.get("sens") != "ingress":
continue
ports = fl["port"] if isinstance(fl["port"], list) else [fl["port"]]
srcs = _resoudre_sources(data, fl.get("pair"), actifs_noms, ip_par_hote)
saddr = "" if not srcs else "ip saddr { " + ", ".join(srcs) + " } "
local = "localhost" in (fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")])
for port in ports:
if local and not srcs:
continue # lo deja accepte
regles.append(f" {saddr}{fl['protocole']} dport {port} accept # {g}: {fl.get('raison','')}")
contenu = _rendre_nft(hote, regles)
chemin = sortie_dir / f"{hote}.nft"
chemin.write_text(contenu, encoding="utf-8")
ecrits.append(chemin)
return ecrits
def _rendre_nft(hote: str, regles: list[str]) -> str:
corps = "\n".join(regles) if regles else " # (aucun flux ingress inter-noeud)"
return (
f"#!/usr/sbin/nft -f\n"
f"# GENERE par scripts/resoudre_flux.py depuis les meta/flux.yml — hote {hote}.\n"
f"# APERCU inspectable — NON active. L'activation reste un geste dedie, teste par noeud.\n\n"
f"flush ruleset\n\n"
f"table inet setops_flux {{\n"
f" chain input {{\n"
f" type filter hook input priority 0; policy drop;\n\n"
f" iif \"lo\" accept\n"
f" ct state established,related accept\n"
f" ct state invalid drop\n"
f" ip protocol icmp accept\n"
f" ip6 nexthdr icmpv6 accept\n\n"
f"{corps}\n"
f" }}\n\n"
f" chain forward {{ type filter hook forward priority 0; policy drop; }}\n"
f" chain output {{ type filter hook output priority 0; policy accept; }}\n"
f"}}\n"
)
def main(argv: list[str]) -> int:
commande = argv[0] if argv else "registre"
try:
flux = charger_flux()
if commande == "verifier":
r, t = valider(flux)
print(f"Flux coherents : {r} rôles, {t} flux, schéma + matrice OK.")
elif commande == "registre":
valider(flux)
REGISTRE.write_text(generer_registre(flux), encoding="utf-8")
print(f"{REGISTRE.relative_to(RACINE)} (re)généré depuis les meta/flux.yml.")
elif commande == "nftables":
valider(flux)
ecrits = generer_nftables(flux)
print(f"{len(ecrits)} aperçu(s) nftables générés (NON activés) :")
for p in ecrits:
print(f" {p}")
else:
print(f"Commande inconnue : {commande} (registre | nftables | verifier)", file=sys.stderr)
return 2
except ErreurFlux as e:
print(f"ERREUR flux :\n{e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))