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:
parent
02bbba61b3
commit
f05f505b88
38 changed files with 1433 additions and 24 deletions
132
CHANGELOG.md
132
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/<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
150
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.<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; \
|
||||
|
|
|
|||
72
docs/couches-deploiement.yml
Normal file
72
docs/couches-deploiement.yml
Normal 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
|
||||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
80
docs/registre-flux.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
9
roles/client_backup/meta/flux.yml
Normal file
9
roles/client_backup/meta/flux.yml
Normal 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)."
|
||||
9
roles/client_journal/meta/flux.yml
Normal file
9
roles/client_journal/meta/flux.yml
Normal 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)."
|
||||
9
roles/client_pki/meta/flux.yml
Normal file
9
roles/client_pki/meta/flux.yml
Normal 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."
|
||||
9
roles/client_smtp/meta/flux.yml
Normal file
9
roles/client_smtp/meta/flux.yml
Normal 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."
|
||||
15
roles/client_unbound/meta/flux.yml
Normal file
15
roles/client_unbound/meta/flux.yml
Normal 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)."
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
9
roles/serveur_backup/meta/flux.yml
Normal file
9
roles/serveur_backup/meta/flux.yml
Normal 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."
|
||||
10
roles/serveur_debian/meta/flux.yml
Normal file
10
roles/serveur_debian/meta/flux.yml
Normal 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)."
|
||||
27
roles/serveur_dovecot/meta/flux.yml
Normal file
27
roles/serveur_dovecot/meta/flux.yml
Normal 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)."
|
||||
|
|
@ -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') }}"
|
||||
|
|
|
|||
27
roles/serveur_forgejo/meta/flux.yml
Normal file
27
roles/serveur_forgejo/meta/flux.yml
Normal 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)."
|
||||
|
|
@ -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') }}"
|
||||
|
|
|
|||
16
roles/serveur_grafana/meta/flux.yml
Normal file
16
roles/serveur_grafana/meta/flux.yml
Normal 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)."
|
||||
16
roles/serveur_icinga/meta/flux.yml
Normal file
16
roles/serveur_icinga/meta/flux.yml
Normal 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)."
|
||||
22
roles/serveur_icingaweb2/meta/flux.yml
Normal file
22
roles/serveur_icingaweb2/meta/flux.yml
Normal 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)."
|
||||
21
roles/serveur_keycloak/meta/flux.yml
Normal file
21
roles/serveur_keycloak/meta/flux.yml
Normal 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)."
|
||||
15
roles/serveur_loki/meta/flux.yml
Normal file
15
roles/serveur_loki/meta/flux.yml
Normal 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)."
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
28
roles/serveur_nextcloud/meta/flux.yml
Normal file
28
roles/serveur_nextcloud/meta/flux.yml
Normal 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."
|
||||
21
roles/serveur_oauth2_proxy/meta/flux.yml
Normal file
21
roles/serveur_oauth2_proxy/meta/flux.yml
Normal 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)."
|
||||
15
roles/serveur_openldap/meta/flux.yml
Normal file
15
roles/serveur_openldap/meta/flux.yml
Normal 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."
|
||||
45
roles/serveur_postfix/meta/flux.yml
Normal file
45
roles/serveur_postfix/meta/flux.yml
Normal 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)."
|
||||
15
roles/serveur_powerdns/meta/flux.yml
Normal file
15
roles/serveur_powerdns/meta/flux.yml
Normal 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)."
|
||||
15
roles/serveur_prometheus/meta/flux.yml
Normal file
15
roles/serveur_prometheus/meta/flux.yml
Normal 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)."
|
||||
9
roles/serveur_redis/meta/flux.yml
Normal file
9
roles/serveur_redis/meta/flux.yml
Normal 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."
|
||||
15
roles/serveur_rspamd/meta/flux.yml
Normal file
15
roles/serveur_rspamd/meta/flux.yml
Normal 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."
|
||||
9
roles/serveur_step_ca/meta/flux.yml
Normal file
9
roles/serveur_step_ca/meta/flux.yml
Normal 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."
|
||||
|
|
@ -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
216
scripts/orchestrer.py
Normal 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
279
scripts/resoudre_flux.py
Normal 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:]))
|
||||
Loading…
Reference in a new issue