From f05f505b8833d46ee5c87212d6dd1d7d280cd3c6 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 7 Jul 2026 03:08:09 -0400 Subject: [PATCH] =?UTF-8?q?Reconstruction=20propre=20:=20orchestrateur=20o?= =?UTF-8?q?rdonn=C3=A9,=20registre=20des=20flux=20+=20pare-feu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 132 ++++++++++ Makefile | 150 +++++++++++- docs/couches-deploiement.yml | 72 ++++++ docs/dependances-groupes.yml | 33 ++- docs/flux-conception.md | 5 +- docs/registre-flux.md | 80 +++++++ playbooks/site.yml | 56 ++++- roles/client_backup/meta/flux.yml | 9 + roles/client_journal/meta/flux.yml | 9 + roles/client_pki/meta/flux.yml | 9 + roles/client_smtp/meta/flux.yml | 9 + roles/client_unbound/meta/flux.yml | 15 ++ roles/nftables_baseline/defaults/main.yml | 5 + roles/nftables_baseline/tasks/main.yml | 26 +- roles/serveur_backup/meta/flux.yml | 9 + roles/serveur_debian/meta/flux.yml | 10 + roles/serveur_dovecot/meta/flux.yml | 27 +++ roles/serveur_forgejo/defaults/main.yml | 4 +- roles/serveur_forgejo/meta/flux.yml | 27 +++ roles/serveur_grafana/defaults/main.yml | 4 +- roles/serveur_grafana/meta/flux.yml | 16 ++ roles/serveur_icinga/meta/flux.yml | 16 ++ roles/serveur_icingaweb2/meta/flux.yml | 22 ++ roles/serveur_keycloak/meta/flux.yml | 21 ++ roles/serveur_loki/meta/flux.yml | 15 ++ roles/serveur_nextcloud/defaults/main.yml | 4 +- roles/serveur_nextcloud/meta/flux.yml | 28 +++ roles/serveur_oauth2_proxy/meta/flux.yml | 21 ++ roles/serveur_openldap/meta/flux.yml | 15 ++ roles/serveur_postfix/meta/flux.yml | 45 ++++ roles/serveur_powerdns/meta/flux.yml | 15 ++ roles/serveur_prometheus/meta/flux.yml | 15 ++ roles/serveur_redis/meta/flux.yml | 9 + roles/serveur_rspamd/meta/flux.yml | 15 ++ roles/serveur_step_ca/meta/flux.yml | 9 + scripts/inventory_host.py | 5 + scripts/orchestrer.py | 216 +++++++++++++++++ scripts/resoudre_flux.py | 279 ++++++++++++++++++++++ 38 files changed, 1433 insertions(+), 24 deletions(-) create mode 100644 docs/couches-deploiement.yml create mode 100644 docs/registre-flux.md create mode 100644 roles/client_backup/meta/flux.yml create mode 100644 roles/client_journal/meta/flux.yml create mode 100644 roles/client_pki/meta/flux.yml create mode 100644 roles/client_smtp/meta/flux.yml create mode 100644 roles/client_unbound/meta/flux.yml create mode 100644 roles/serveur_backup/meta/flux.yml create mode 100644 roles/serveur_debian/meta/flux.yml create mode 100644 roles/serveur_dovecot/meta/flux.yml create mode 100644 roles/serveur_forgejo/meta/flux.yml create mode 100644 roles/serveur_grafana/meta/flux.yml create mode 100644 roles/serveur_icinga/meta/flux.yml create mode 100644 roles/serveur_icingaweb2/meta/flux.yml create mode 100644 roles/serveur_keycloak/meta/flux.yml create mode 100644 roles/serveur_loki/meta/flux.yml create mode 100644 roles/serveur_nextcloud/meta/flux.yml create mode 100644 roles/serveur_oauth2_proxy/meta/flux.yml create mode 100644 roles/serveur_openldap/meta/flux.yml create mode 100644 roles/serveur_postfix/meta/flux.yml create mode 100644 roles/serveur_powerdns/meta/flux.yml create mode 100644 roles/serveur_prometheus/meta/flux.yml create mode 100644 roles/serveur_redis/meta/flux.yml create mode 100644 roles/serveur_rspamd/meta/flux.yml create mode 100644 roles/serveur_step_ca/meta/flux.yml create mode 100644 scripts/orchestrer.py create mode 100644 scripts/resoudre_flux.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 243224d..7f38da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.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/.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=….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/.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é diff --git a/Makefile b/Makefile index eed039a..3b947fd 100644 --- a/Makefile +++ b/Makefile @@ -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.//.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.//.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; \ diff --git a/docs/couches-deploiement.yml b/docs/couches-deploiement.yml new file mode 100644 index 0000000..9b5d2fd --- /dev/null +++ b/docs/couches-deploiement.yml @@ -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/.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 diff --git a/docs/dependances-groupes.yml b/docs/dependances-groupes.yml index 753ea9f..261cfaa 100644 --- a/docs/dependances-groupes.yml +++ b/docs/dependances-groupes.yml @@ -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." + diff --git a/docs/flux-conception.md b/docs/flux-conception.md index 786b53a..88d7f7d 100644 --- a/docs/flux-conception.md +++ b/docs/flux-conception.md @@ -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 ``` diff --git a/docs/registre-flux.md b/docs/registre-flux.md new file mode 100644 index 0000000..9f53117 --- /dev/null +++ b/docs/registre-flux.md @@ -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 diff --git a/playbooks/site.yml b/playbooks/site.yml index a179247..56d07f8 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -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 diff --git a/roles/client_backup/meta/flux.yml b/roles/client_backup/meta/flux.yml new file mode 100644 index 0000000..ec0fa14 --- /dev/null +++ b/roles/client_backup/meta/flux.yml @@ -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)." diff --git a/roles/client_journal/meta/flux.yml b/roles/client_journal/meta/flux.yml new file mode 100644 index 0000000..55655c6 --- /dev/null +++ b/roles/client_journal/meta/flux.yml @@ -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)." diff --git a/roles/client_pki/meta/flux.yml b/roles/client_pki/meta/flux.yml new file mode 100644 index 0000000..c2a9c5b --- /dev/null +++ b/roles/client_pki/meta/flux.yml @@ -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." diff --git a/roles/client_smtp/meta/flux.yml b/roles/client_smtp/meta/flux.yml new file mode 100644 index 0000000..4e2b18d --- /dev/null +++ b/roles/client_smtp/meta/flux.yml @@ -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." diff --git a/roles/client_unbound/meta/flux.yml b/roles/client_unbound/meta/flux.yml new file mode 100644 index 0000000..62c2cce --- /dev/null +++ b/roles/client_unbound/meta/flux.yml @@ -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)." diff --git a/roles/nftables_baseline/defaults/main.yml b/roles/nftables_baseline/defaults/main.yml index 5b23e1f..162fd74 100644 --- a/roles/nftables_baseline/defaults/main.yml +++ b/roles/nftables_baseline/defaults/main.yml @@ -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" diff --git a/roles/nftables_baseline/tasks/main.yml b/roles/nftables_baseline/tasks/main.yml index e9ac5c7..98be463 100644 --- a/roles/nftables_baseline/tasks/main.yml +++ b/roles/nftables_baseline/tasks/main.yml @@ -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: diff --git a/roles/serveur_backup/meta/flux.yml b/roles/serveur_backup/meta/flux.yml new file mode 100644 index 0000000..ebb4b2f --- /dev/null +++ b/roles/serveur_backup/meta/flux.yml @@ -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." diff --git a/roles/serveur_debian/meta/flux.yml b/roles/serveur_debian/meta/flux.yml new file mode 100644 index 0000000..b4504f8 --- /dev/null +++ b/roles/serveur_debian/meta/flux.yml @@ -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)." diff --git a/roles/serveur_dovecot/meta/flux.yml b/roles/serveur_dovecot/meta/flux.yml new file mode 100644 index 0000000..ee59b84 --- /dev/null +++ b/roles/serveur_dovecot/meta/flux.yml @@ -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)." diff --git a/roles/serveur_forgejo/defaults/main.yml b/roles/serveur_forgejo/defaults/main.yml index 5a73cfe..716f13e 100644 --- a/roles/serveur_forgejo/defaults/main.yml +++ b/roles/serveur_forgejo/defaults/main.yml @@ -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 » --- # 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//callback +serveur_forgejo_oidc_nom: "{{ organisation | default('organisation') | lower | replace(' ', '-') }}" # slug du callback : /user/oauth2//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') }}" diff --git a/roles/serveur_forgejo/meta/flux.yml b/roles/serveur_forgejo/meta/flux.yml new file mode 100644 index 0000000..de75fd8 --- /dev/null +++ b/roles/serveur_forgejo/meta/flux.yml @@ -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)." diff --git a/roles/serveur_grafana/defaults/main.yml b/roles/serveur_grafana/defaults/main.yml index a8657bb..979db72 100644 --- a/roles/serveur_grafana/defaults/main.yml +++ b/roles/serveur_grafana/defaults/main.yml @@ -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 » : 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') }}" diff --git a/roles/serveur_grafana/meta/flux.yml b/roles/serveur_grafana/meta/flux.yml new file mode 100644 index 0000000..dedf548 --- /dev/null +++ b/roles/serveur_grafana/meta/flux.yml @@ -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)." diff --git a/roles/serveur_icinga/meta/flux.yml b/roles/serveur_icinga/meta/flux.yml new file mode 100644 index 0000000..359af1f --- /dev/null +++ b/roles/serveur_icinga/meta/flux.yml @@ -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)." diff --git a/roles/serveur_icingaweb2/meta/flux.yml b/roles/serveur_icingaweb2/meta/flux.yml new file mode 100644 index 0000000..60692f6 --- /dev/null +++ b/roles/serveur_icingaweb2/meta/flux.yml @@ -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)." diff --git a/roles/serveur_keycloak/meta/flux.yml b/roles/serveur_keycloak/meta/flux.yml new file mode 100644 index 0000000..b477f75 --- /dev/null +++ b/roles/serveur_keycloak/meta/flux.yml @@ -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)." diff --git a/roles/serveur_loki/meta/flux.yml b/roles/serveur_loki/meta/flux.yml new file mode 100644 index 0000000..f639c3d --- /dev/null +++ b/roles/serveur_loki/meta/flux.yml @@ -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)." diff --git a/roles/serveur_nextcloud/defaults/main.yml b/roles/serveur_nextcloud/defaults/main.yml index 5e26447..9c384a3 100644 --- a/roles/serveur_nextcloud/defaults/main.yml +++ b/roles/serveur_nextcloud/defaults/main.yml @@ -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) diff --git a/roles/serveur_nextcloud/meta/flux.yml b/roles/serveur_nextcloud/meta/flux.yml new file mode 100644 index 0000000..ca146e4 --- /dev/null +++ b/roles/serveur_nextcloud/meta/flux.yml @@ -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." diff --git a/roles/serveur_oauth2_proxy/meta/flux.yml b/roles/serveur_oauth2_proxy/meta/flux.yml new file mode 100644 index 0000000..6941d13 --- /dev/null +++ b/roles/serveur_oauth2_proxy/meta/flux.yml @@ -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)." diff --git a/roles/serveur_openldap/meta/flux.yml b/roles/serveur_openldap/meta/flux.yml new file mode 100644 index 0000000..35800e3 --- /dev/null +++ b/roles/serveur_openldap/meta/flux.yml @@ -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." diff --git a/roles/serveur_postfix/meta/flux.yml b/roles/serveur_postfix/meta/flux.yml new file mode 100644 index 0000000..302f656 --- /dev/null +++ b/roles/serveur_postfix/meta/flux.yml @@ -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)." diff --git a/roles/serveur_powerdns/meta/flux.yml b/roles/serveur_powerdns/meta/flux.yml new file mode 100644 index 0000000..e91a354 --- /dev/null +++ b/roles/serveur_powerdns/meta/flux.yml @@ -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)." diff --git a/roles/serveur_prometheus/meta/flux.yml b/roles/serveur_prometheus/meta/flux.yml new file mode 100644 index 0000000..f796a1d --- /dev/null +++ b/roles/serveur_prometheus/meta/flux.yml @@ -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)." diff --git a/roles/serveur_redis/meta/flux.yml b/roles/serveur_redis/meta/flux.yml new file mode 100644 index 0000000..51b234a --- /dev/null +++ b/roles/serveur_redis/meta/flux.yml @@ -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." diff --git a/roles/serveur_rspamd/meta/flux.yml b/roles/serveur_rspamd/meta/flux.yml new file mode 100644 index 0000000..eb946b9 --- /dev/null +++ b/roles/serveur_rspamd/meta/flux.yml @@ -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." diff --git a/roles/serveur_step_ca/meta/flux.yml b/roles/serveur_step_ca/meta/flux.yml new file mode 100644 index 0000000..1da2751 --- /dev/null +++ b/roles/serveur_step_ca/meta/flux.yml @@ -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." diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index 2312100..60740cc 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -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) diff --git a/scripts/orchestrer.py b/scripts/orchestrer.py new file mode 100644 index 0000000..6c631ba --- /dev/null +++ b/scripts/orchestrer.py @@ -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/.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/.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/.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:])) diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py new file mode 100644 index 0000000..4173d05 --- /dev/null +++ b/scripts/resoudre_flux.py @@ -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//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 /flux-genere/.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:]))