Compare commits

..

No commits in common. "main" and "v2026.10.04" have entirely different histories.

177 changed files with 1444 additions and 16886 deletions

3
.gitignore vendored
View file

@ -9,9 +9,6 @@ facts_cache/
**/vault.yml
# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul.
/underlay.yml
# Contexte actif de CETTE machine (`site:<depot>` ou `locataire:<depot>`) : propre a chaque
# poste et a chaque runner, jamais versionne. Voir scripts/contexte.py.
/contexte
*.secret
*.pem
*.key

1028
AGENTS.md

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,3 +4,24 @@
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
et le respecter intégralement avant toute modification.
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
## Les cinq règles absolues
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
`make instancier` → `make instancier-appliquer`.
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
ou stockage hors dépôt uniquement.
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.

133
Makefile
View file

@ -317,16 +317,10 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_inventory_host.py
python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_verifier_depot.py
python3 scripts/tests/test_apps_nextcloud.py
python3 scripts/tests/test_verrou_runner.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py
python3 scripts/tests/test_portee_console.py
python3 scripts/tests/test_forge_cles.py
python3 scripts/tests/test_devis_placement.py
python3 scripts/tests/test_sonder.py
python3 scripts/tests/test_fraicheur_icinga.py
@ -335,24 +329,10 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_empreinte_plancher.py
python3 scripts/tests/test_expositions_sans_port.py
python3 scripts/tests/test_proxmox_fw_externe.py
python3 scripts/tests/test_libelles_parefeu.py
python3 scripts/tests/test_conditions_site.py
python3 scripts/tests/test_relais_du_site.py
python3 scripts/tests/test_sasl_local.py
python3 scripts/tests/test_dovecot_sonde_et_imap.py
python3 scripts/tests/test_parite_tls_site.py
python3 scripts/tests/test_exportateur_pg_tls.py
python3 scripts/tests/test_frontiere_pki.py
python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py
python3 scripts/tests/test_underlay_tunnel.py
python3 scripts/tests/test_face_publique_relais.py
python3 scripts/tests/test_mode_nat_sortant.py
python3 scripts/tests/test_verifier_flux_site.py
python3 scripts/tests/test_frontiere_refus.py
python3 scripts/tests/test_contexte.py
python3 scripts/tests/test_appels_locataire.py
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine
@ -629,7 +609,7 @@ site: ansible-runtime ## Regenere playbooks/site.yml depuis les couches et le gr
site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declarees
python3 scripts/orchestrer.py verifier
.PHONY: flux flux-verifier fiches-site-deposer
.PHONY: flux flux-verifier
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
python3 scripts/exporter_cles.py --recenser
@ -765,8 +745,8 @@ proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prou
.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm
.PHONY: placement-plan sdn-plan sdn-appliquer
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) [TENANT=<depot>]
python3 scripts/devis_placement.py $(if $(TENANT),--locataire $(TENANT))
placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture)
python3 scripts/devis_placement.py
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
python3 scripts/appliquer_sdn.py
@ -889,17 +869,6 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
python3 scripts/site_intrants.py --verifier
face-reseau-publier: ## Chaque locataire publie sa face reseau pour son site (a commiter chez lui, apres `make flux`)
@# LE LOCATAIRE PUBLIE, LE SITE LIT (2026-10-05). Le site lisait les fichiers internes de ses
@# locataires ; il lit desormais `face-reseau.yml`. P92 refuse une face perimee.
python3 scripts/contexte.py --publier-faces
fiches-site-deposer: ## Le site depose chez chacun de ses locataires la fiche qu'il lui destine (a commiter chez eux)
@# LE SITE DEPOSE, LE LOCATAIRE LIT (2026-10-05). Le runner d'un locataire n'a pas le depot
@# de son site : ce que le site lui attribue et lui offre lui parvient par ce fichier,
@# `fiche-site.yml`, que l'instancier lit. P91 refuse une fiche perimee.
python3 scripts/contexte.py --deposer-fiches
.PHONY: vpn-admin-plan vpn-admin-appliquer
vpn-admin-plan: ## Acces WireGuard des admins : ce que la frontiere porte face au plan (aucune ecriture)
python3 scripts/vpn_admin.py plan
@ -949,7 +918,7 @@ flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml
valider: ansible-runtime ## Passe la recette de validation sur la flotte
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/valider.yml
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant temoins-etat
.PHONY: restauration-etat restauration-renoncer sauvegarder-maintenant
sauvegarder-maintenant: ansible-runtime ## Depose l'etat de chaque noeud TOUT DE SUITE (avant de raser) — HOTE=<hote> optionnel
@set -e; $(VAULT_UNE_FOIS) \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
@ -963,15 +932,6 @@ restauration-etat: ansible-runtime ## Ce qui attend dans le depot, et ce que cha
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
$(if $(HOTE),--limit "$(HOTE)")
# LES TEMOINS (2026-10-07) — l'etat remis est-il celui d'avant ? Compare l'etat vivant a
# l'instantane etiquete au rasage (`sauvegarder-maintenant`). `SOURCE=candidat` compare au
# dernier instantane d'avant la naissance du noeud, faute d'etiquette. Lecture seule.
temoins-etat: ansible-runtime ## L'etat vivant est-il celui d'avant le rasage ? (lecture seule) — HOTE= SOURCE=candidat optionnels
@set -e; $(VAULT_UNE_FOIS) \
ansible-playbook -i $(INVENTAIRE_PRODUCTION) playbooks/backup/restauration.yml \
-e restauration_action=temoins $(if $(SOURCE),-e restauration_source="$(SOURCE)") \
$(if $(HOTE),--limit "$(HOTE)")
restauration-renoncer: ansible-runtime ## Ecarte l'etat anterieur d'un jeu, sans le remettre — HOTE= JEU= CONFIRMER=true
@set -e; \
if [[ "$(CONFIRMER)" != "true" || -z "$(HOTE)" || -z "$(JEU)" ]]; then \
@ -1166,23 +1126,14 @@ define VAULT_UNE_FOIS
endef
# LE SITE MATERIALISE UN LOCATAIRE NOMME, D'APRES SA FACE (2026-10-05, docs/conception-contextes.md,
# materialisation M2). Avec `TENANT=<depot>`, la liste des machines et leurs parametres de clonage
# viennent de `face-reseau.yml` que le locataire publie — rendus a l'octet pres comme
# `parametres-proxmox` (P93) — et aucune instance n'est montee. Sans `TENANT`, l'ancienne lecture
# de l'instance montee (le poste, le laboratoire).
LISTE_ACTIFS = $(if $(TENANT),python3 scripts/contexte.py --hotes-actifs $(TENANT),python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) lister-actifs)
PARAMS_CLONAGE = $(if $(TENANT),python3 scripts/contexte.py --parametres-clonage $(TENANT) --hote,python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote)
INSTANCE_OU_LOCATAIRE = $(if $(TENANT),,_instance-requise)
flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
@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="$$($(LISTE_ACTIFS))"; \
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; \
par="$(PARALLELE)"; par="$${par:-4}"; \
tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
@ -1191,7 +1142,7 @@ flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte de
n=0; \
for h in $$hotes; do \
printf '\n=== Creation VM: %s ===\n' "$$h"; \
( $(MAKE) creer-vm HOTE="$$h" $(if $(TENANT),TENANT="$(TENANT)") > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
( $(MAKE) creer-vm HOTE="$$h" > "$$tmp/$$h.log" 2>&1; printf '%s' "$$?" > "$$tmp/$$h.rc" ) & \
n=$$((n+1)); \
if (( n >= par )); then wait -n || true; n=$$((n-1)); fi; \
done; \
@ -1206,13 +1157,6 @@ flotte-creer: $(INSTANCE_OU_LOCATAIRE) ## Cree les VM manquantes de la flotte de
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
printf '\nToutes les VM actives sont creees.\n'
locataire-creer: ## Le SITE cree les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true [PARALLELE=n]
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
@$(MAKE) --no-print-directory flotte-creer TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" PARALLELE="$(PARALLELE)"
locataire-raser: ## DESTRUCTIF : le SITE rase les VM d'un locataire d'apres sa face reseau — TENANT=<depot> CONFIRMER=true INSTANCE=<nom court> [HOTE=h]
@if [[ -z "$(TENANT)" ]]; then printf '%s\n' 'Refus: TENANT=<depot du locataire> requis.'; exit 2; fi
@$(MAKE) --no-print-directory raser TENANT="$(TENANT)" CONFIRMER="$(CONFIRMER)" INSTANCE="$(INSTANCE)" HOTE="$(HOTE)"
# --- LES MACHINES DU SITE -----------------------------------------------------
#
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
@ -1250,8 +1194,6 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
@# `underlay.vault.yml`, qui vit a cote de sa carte et hors depot. Sans le `-e @`, le
@# role echoue sur son assertion — et l'echec ne dit pas qu'il manque un FICHIER,
@# seulement que les valeurs sont vides.
@# `SETOPS_UNDERLAY` (2026-10-13) : la carte lue par l'inventaire DESIGNE aussi la voute.
@# Lire le symlink seul aurait charge les secrets de Chezlepro contre TechnoLibre.
@set -e; \
if [[ ! -f "playbooks/groupes/$(GROUPE).yml" ]]; then \
printf 'Refus: aucun playbook pour le groupe %s.\n' "$(GROUPE)"; exit 2; \
@ -1263,7 +1205,7 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
printf 'Groupes du site : %s\n' "$$connus"; \
exit 2; \
fi; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$(GROUPE).yml" \
"$${supp[@]}" $(ARGS)
@ -1285,12 +1227,6 @@ fiches: ## Regenere la fiche de chaque role (docs/roles/) — ROLE=<nom> pour un
.PHONY: site-deployer-tout
site-deployer-tout: ansible-runtime ## Deploie TOUT le site dans l'ordre des couches — CONFIRMER=true
@# L'AMORCAGE DU CACHE (2026-10-13). Toute machine du site s'amorce sur le cache des son
@# socle — or le cache n'existe qu'apres le sien. Aucune COUCHE ne peut le dire : une couche
@# vaut pour toutes les machines a la fois. Le premier site construit depuis zero
@# (TechnoLibre) l'a montre ; la machine du cache passe donc d'abord, seule, par son socle
@# et son cache. Sur un site deja monte, ce passage ne change rien. Les regles nftables
@# generees sont exigees avant tout (`verifier_flux_site.py`) : sans elles, repli generique.
@# LE SITE AVAIT L'ORCHESTRATION, PAS LE MOYEN DE LA LANCER (2026-09-14).
@#
@# `playbooks/site.yml` est genere par `orchestrer.py` et ordonne les couches pour
@ -1320,21 +1256,11 @@ site-deployer-tout: ansible-runtime ## Deploie TOUT le site dans l'ordre des cou
exit 2; \
fi; \
python3 scripts/verifier_genome_a_jour.py || exit 2; \
python3 scripts/verifier_flux_site.py || exit 2; \
python3 scripts/orchestrer.py verifier; \
python3 scripts/orchestrer.py ecrire; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
mode="$$([[ -n "$(MODE_CHECK)" ]] && printf -- '--check --diff' || true)"; \
cache="$$(python3 scripts/site_inventaire.py --list | python3 -c \
'import json,sys; print(" ".join((json.load(sys.stdin).get("serveur_cache_site") or {}).get("hosts") or []))')"; \
if [[ -n "$$cache" ]]; then \
printf '\n=== Amorcage : le cache du site d abord (%s) ===\n' "$$cache"; \
for g in serveur_debian serveur_durci serveur_artefacts serveur_cache_site; do \
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$$g.yml" \
--limit serveur_cache_site "$${supp[@]}" $$mode $(ARGS); \
done; \
fi; \
ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \
--limit "$(GROUPE_HOTES_ACTIFS)" "$${supp[@]}" $$mode $(ARGS)
@ -1388,7 +1314,7 @@ gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige r
.PHONY: genome-etat
genome-etat: ## Ce que la forge du site porte, face au poste — ne corrige rien, regarde
@set -e; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/genome_etat.yml "$${supp[@]}" $(ARGS)
@ -1403,31 +1329,11 @@ publier: ## Publie le genome d'un geste : eregion PUIS la forge du site, et le v
.PHONY: publier
genome-pousser: ## Le runner du site pousse le genome sur la forge du site — DEPOT=<nom> pour un seul
@set -e; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
.PHONY: forge-cles
forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lecture que sa face publie — CONFIRMER=true pour ecrire
@set -e; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS)
.PHONY: forge-comptes
forge-comptes: ## Les comptes de la forge du site : un par mainteneur (plan du site), et le lecteur de la console — CONFIRMER=true pour ecrire
@set -e; \
voute="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/forge_comptes.yml "$${supp[@]}" $(ARGS)
.PHONY: forge-etancheite
forge-etancheite: ansible-runtime ## La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ? (mesure, aucune ecriture)
python3 scripts/forge_etancheite.py
.PHONY: depots-perimes
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :
@ -1499,9 +1405,6 @@ site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
VMID_MODELE="$${SETOPS_VMID_MODELE:-$(VMID_MODELE)}" \
CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \
CLE_SSH_PUBLIQUE="$${SETOPS_CLE_PUBLIQUE:-$(CLE_SSH_PUBLIQUE)}" \
DNS="$${SETOPS_DNS:-$(DNS)}" \
DOMAINE_RECHERCHE="$${SETOPS_DOMAINE:-$(DOMAINE_RECHERCHE)}" \
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \
done; \
@ -1519,8 +1422,8 @@ _attendre-flotte: ansible-runtime
printf 'Flotte joignable.\n'
.PHONY: reconstruire
raser: ansible-runtime $(INSTANCE_OU_LOCATAIRE) ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom> [TENANT=<depot>]
@python3 scripts/raser.py $(if $(TENANT),--locataire $(TENANT)) $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
raser: ansible-runtime _instance-requise ## DESTRUCTIF : detruit les VM derivees du plan — exige CONFIRMER=true ET INSTANCE=<nom>
@python3 scripts/raser.py $(if $(INSTANCE),--instance $(INSTANCE)) $(if $(HOTE),--hote $(HOTE)) $(if $(filter true,$(CONFIRMER)),--confirmer)
# `make flux` D'ABORD, et ce n'est pas une precaution : sans lui, `instance/flux-genere/`
# est VIDE et le socle deploie nftables en `policy drop` SANS AUCUNE REGLE DERIVEE. La
@ -1702,7 +1605,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
-e proxmox_clone_nom="$(HOTE)" \
-e proxmox_clone_vmid="$(VMID)" \
-e proxmox_clone_ipconfig0="$$ipconfig" \
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py $(if $(TENANT),--pool-du-locataire $(TENANT),--pool-actif)))" \
-e proxmox_clone_pool="$(if $(POOL),$(POOL),$$(python3 scripts/devis_proxmox_pools.py --pool-actif))" \
); \
[[ -n "$(VMID_MODELE)" ]] && extra_vars+=( -e proxmox_clone_vmid_modele="$(VMID_MODELE)" ); \
[[ -n "$(NOEUD_PROXMOX)" ]] && extra_vars+=( -e proxmox_clone_noeud="$(NOEUD_PROXMOX)" ); \
@ -1731,7 +1634,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
done; \
done; \
if [[ -z "$$vault_file" && -L underlay.yml ]]; then \
vault_file="$$(dirname "$$(readlink -f "$${SETOPS_UNDERLAY:-underlay.yml}")")/underlay.vault.yml"; \
vault_file="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
fi; \
if [[ -n "$$vault_file" && -f "$$vault_file" ]]; then \
read -r premiere_ligne < "$$vault_file" || true; \
@ -1745,13 +1648,13 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE=<nom>
fi; \
ansible-playbook -i localhost, $(PLAYBOOK_PROXMOX_CLONER_VM) "$${vault_args[@]}" "$${extra_vars[@]}"
creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom> [TENANT=<depot>]
creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — HOTE=<nom>
@set -e; \
if [[ -z "$(HOTE)" ]]; then \
printf '%s\n' 'Refus: relancer avec HOTE=nom_hote (declare dans le plan).'; \
exit 2; \
fi; \
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
eval "$$params"; \
export SETOPS_CLES_AMORCAGE; \
$(MAKE) cloner-vm \
@ -1792,7 +1695,7 @@ creer-vm: $(INSTANCE_OU_LOCATAIRE) ## Cree une VM et attend qu'elle soit joignab
@# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init
@# et dpkg se guettent juste avant de configurer, par celui qui va configurer.
@if [[ "$(ATTENDRE)" != "false" ]]; then \
params="$$($(PARAMS_CLONAGE) $(HOTE))"; \
params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \
eval "$$params"; \
python3 scripts/attendre_materialisation.py \
--vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \

View file

@ -1,130 +0,0 @@
# Preuve de conformite — Set-OPS — 2026-10-09
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 119 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (47 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 201 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 102 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (137 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 304 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (94 preuves, 69 roles, 42 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 144 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-10-09._

View file

@ -1,131 +0,0 @@
# Preuve de conformite — Set-OPS — 2026-10-11
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (95 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 3 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 123 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 35 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) et 7 zone(s) de site : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 4 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 108 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 81 scripts expliques et atteignables, 154 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (150 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 51 document(s) declarent leur lecteur (48 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 77 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 175 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 103 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (141 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 278 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (95 preuves, 69 roles, 42 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e9215a2` (publie le 2026-10-07). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 11 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 4 ecosysteme(s) (OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d'amorcage, sorties du |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 148 etapes, 153 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
| P87 | La frontiere designe chaque locataire par les identites qu'il publie | — | ✅ OK | 2 locataire(s) : la frontiere les designe par les identites qu'ils publient (supernet, administration, tunnel, groupes, routes, sortie). |
| P88 | Ce qui entre depuis l'Internet chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : regles WAN, redirections et tunnel disent exactement ce que leur face reseau ouvre a tous. |
| P89 | Ce que l'administration atteint chez un locataire, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : par la gestion, le VPN et le tunnel, l'administration atteint exactement ce que leur face reseau lui ouvre. |
| P90 | Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui | — | ✅ OK | 2 locataire(s) : la frontiere laisse sortir exactement ce que leur face reseau publie. |
| P91 | La fiche du site est deposee a jour, et l'inventaire se genere sans le site | — | ✅ OK | 2 locataire(s) : fiche du site deposee a jour, et inventaire genere sans le site identique a l'octet pres. |
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
| P95 | Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait | — | ✅ OK | 2 locataire(s) : depot et cle de lecture publies, cle connue de leur flotte. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-10-11._

View file

@ -1,5 +1,5 @@
---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: e9215a2
date: 2026-10-07
source: 3fe7e3c
date: 2026-09-29

View file

@ -98,7 +98,7 @@ Une directive qu'aucune garde ne vérifie finit par ne plus être vraie. Chaque
| `socle-identite` | **est** la chaîne d'identité, ne peut pas se déléguer à elle-même | keycloak, openldap |
| `ldap-direct` | protocole non-OIDC lié à LDAP | dovecot, postfix |
| `interne-sans-auth` | interface joignable **dans** le tenant sans authentification, non exposée | prometheus, loki |
| `sans-auth-humaine` | aucun point d'authentification humaine | 24 |
| `sans-auth-humaine` | aucun point d'authentification humaine | 23 |
La preuve refuse **l'oubli et le mensonge** : un rôle sans déclaration, une portée
inventée, un `web-sso` sans accès de secours ou sans posture de formulaire, un `web-sso

View file

@ -23,10 +23,10 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 70 | `roles/*/` |
| README de rôles | 70 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 47 | `docs/*.md` |
| pièces d'audit | 53 | `docs/audit/*` |
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 45 | `docs/*.md` |
| pièces d'audit | 51 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
| Sujet | Documents |
|---|---|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) → `docs/depots-prives-conception.md` (chaque runner ne lit que ses dépôts ; chantier) |
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) |
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |

View file

@ -66,7 +66,6 @@ Ce que la reconstruction couvre, par capacité :
| Resolveur du site | `serveur_resolveur_site` | designe LE resolveur que les ecosystemes de ce site interrogent tant qu'ils n'ont pas le leur. Marqueur : `serveur_resolveur` installe. |
| Depot de sauvegarde du site | `serveur_backup_site` | designe LE depot ou les ecosystemes de ce site posent leur etat tant qu'ils n'ont pas le leur. Marqueur : `serveur_backup` installe. |
| DNS public du site | `serveur_dns_public` | **secondaire** public de toutes les zones `autorite: primaire-cache` des locataires du site : le locataire ecrit, le site sert. Transfert signe TSIG, aucune adresse de confiance, aucune zone `.internal`. Phase 1 : non expose a Internet |
| Relais de la face publique | `serveur_relais` | un VPS **hors du site** qui loue ses adresses fixes quand le site n'a qu'une adresse DHCP : il repond a l'ARP de son hebergeur pour elles (proxy) et les **route** en /32 dans un tunnel WireGuard que la frontiere initie. Aucune traduction : l'adresse du client arrive intacte. Inventorie depuis la carte, comme un hyperviseur |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp`, `client_sante` | collecte, relais et rapport de santé sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`
@ -202,7 +201,7 @@ dans le plan.
## Ordre de déploiement — le raisonnement
> **L'ordre exécutable n'est pas ici.** Il vit dans `docs/couches-deploiement.yml` et
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (43 groupes classés, aucun
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (42 groupes classés, aucun
> cycle, aucune arête en arrière) et que `make reconstruire` suit. Ce qui suit en est le
> **raisonnement**, utile pour comprendre pourquoi cet ordre-là — et pour placer un
> service nouveau. Les phases sont franchies : les « intégrations à prévoir » ci-dessous

View file

@ -1,279 +0,0 @@
# Contextes : un tronc commun, deux classes (SITE et LOCATAIRE)
> **Pour qui :** le **mainteneur** — comment le moteur sait s'il sert un site ou un locataire, et ce que les deux s'apprennent l'un à l'autre.
> **Statut : arrêtée avec l'exploitant le 2026-10-04.** Rien n'est encore construit ; les
> décisions sont au §7, le chemin au §6.
## 1. Le problème, mesuré
Le moteur ne sait pas dans quel contexte il tourne : **chaque script le devine**. Relevé du
2026-10-04 : **33 scripts** font leur propre déduction, à partir de cinq indices différents.
| Indice | Ce qu'on en déduit | Scripts |
|---|---|---|
| lien `instance/` ou `SETOPS_INSTANCE` | « un locataire est monté » | 23 |
| lien `underlay.yml` ou `SETOPS_UNDERLAY` | « un site est monté » ; son plan est à côté | 8 |
| `SETOPS_INVENTAIRE` | quel inventaire de locataire lire | 8 |
| `../*/plan/nomenclature.yml` | « la fédération », les locataires frères | 11 |
| `../SITE-*/underlay.yml` | « les sites » | 2 |
Les indices ne concordent pas toujours, et chaque désaccord a déjà produit un défaut silencieux :
- **2026-08-14** : `frontiere-plan` voulait poser sur la frontière de Technolibre les règles de
Chezlepro. « La fédération » valait « les locataires de ce site », jusqu'au second site.
- **2026-09-16** : la console du runner du site affichait zéro machine, sans erreur. Elle
cherchait un inventaire de locataire là où il n'y en a pas.
- **2026-10-04** : `make ci`, sur le poste, mélangeait le modèle public et les écosystèmes
réels. P74 lisait `SETOPS_UNDERLAY` (le modèle) ; P82 lisait le lien `underlay.yml` et les
dossiers frères (le site réel).
Les rôles Ansible, eux, ne posent pas ce problème : ils sont **déjà** le tronc commun. Un même
`serveur_postgresql` sert au site et chez un locataire.
## 2. Le modèle
```
Ecosysteme (tronc commun)
/ \
Site Locataire
\ /
`-- contrat --' (associations : un site A des locataires,
un locataire A un site)
```
### 2.1 Le tronc commun : `Ecosysteme`
Ce que tout écosystème possède, quel que soit son contexte :
- un **nom** et un **dépôt** (`SITE-Chezlepro`, `OPS-Technolibre`) ;
- une **voûte** et sa clé (`~/.config/setops-vault-<dépôt>`) ;
- un **plan** (`<dépôt>/plan/`) et un **index**, dont dérive son adressage (le site
aussi depuis le 2026-09-20) ;
- des **machines**, déployées par les **mêmes rôles** : socle, durcissement, PKI, journaux,
métriques, supervision, sauvegarde de son propre état ;
- une **filiation** : le moteur et le commit dont il descend ;
- les **preuves communes** : lint, rendu des gabarits, adressage dérivé, etc.
Méthodes abstraites, que chaque classe **surcharge** : `inventaire()`, `machines()`,
`preuves()`, `verbes()`, `console()`.
### 2.2 `Site(Ecosysteme)`
- **Déclaration** : `underlay.yml` (le matériel, les réseaux `site` et `fabric`) et `plan/`
(`10-intrants.yml`, serveurs, applications, domaines, bases).
- **Inventaire** : dynamique (`site_inventaire.py`). Le site ne dérive rien d'un plan de services ;
sa déclaration est sa forme finale.
- **Ce qu'il porte en propre** : le matériel (hyperviseurs, commutateurs, frontière),
la matérialisation des VM (Proxmox), le SDN, le pare-feu Proxmox, la frontière OPNsense,
le DNS public, le dépôt des sauvegardes des locataires, le cache et les artefacts, la forge
du génome.
- **Relation** : `locataires()`, la liste de `underlay.tenants` résolue en objets
`Locataire`. Le site n'en lit que la **face réseau** (§2.4).
### 2.3 `Locataire(Ecosysteme)`
- **Déclaration** : `plan/` (nomenclature, serveurs, applications, bases, domaines).
- **Inventaire** : généré (`instancier.py` → `hosts.yml`). C'est la **méta-classe** de
[`meta-classe.md`](meta-classe.md) : une définition qui engendre toute la flotte.
- **Ce qu'il porte en propre** : la configuration de ses services, la remise au client.
- **Relation** : `site()`, l'hébergeur que nomme `parente.yml`, résolu en objet `Site`. Le
locataire n'en lit que les **intrants exposés** (§2.4).
### 2.4 Ce que le site et le locataire s'apprennent l'un à l'autre
Les deux entités **s'informent mutuellement**. Relevé du 2026-10-04 : qui décide de chaque
information, où elle vit, et comment elle parvient à l'autre.
**Ce que chacun a sous la main.** Le runner du site porte le moteur, son dépôt **et ceux de
ses locataires** (sans leurs voûtes). Le runner d'un locataire ne porte que le moteur et
**son propre** dépôt. Le poste porte tout.
#### Le site informe le locataire, par trois canaux
**Canal 1 : des copies écrites à la main** dans le dépôt du locataire.
| Information | Décidée par | Tenue chez le site dans | Copiée chez le locataire dans | Contrôle |
|---|---|---|---|---|
| son **index** | le site | `underlay.yml` → `tenants` | `plan/nomenclature.yml` (`index`) | `underlay valider` |
| son **adresse publique** | le site | `opnsense.yml` → `opnsense_ips_publiques` | `10-intrants.yml` (`ip_publique`) | — |
| les **10 intrants de service** : résolveur, cache, binaires, forge du génome, cible de sauvegarde, DNS public, plan d'administration, passerelle | le site (dérivés de son plan, par `site_intrants.py`) | son plan | `10-intrants.yml`, et `serveur_ops.yml` pour la forge | `site_intrants.py --verifier`, seulement là où les deux dépôts sont présents (le poste) |
| sa **racine de confiance** | le site | `ac-racine-site.crt` | le même fichier, copié | — |
| *hors contrat* : un dépôt de la forge du site désigné par son adresse | — | — | `serveur_web_dorsal.yml` (Chezlepro) | **aucun** |
**Canal 2 : une lecture directe, au moment de générer l'inventaire.** `instancier.py` ouvre
l'`underlay.yml` et le plan du site pour écrire le `hosts.yml` du locataire. Mesuré sur
Technolibre, inventaire généré avec puis sans le site monté : **quatre variables changent**.
| Variable du locataire | Avec le site monté | Sans le site |
|---|---|---|
| `chrony_serveurs` | `10.0.4.1` (la frontière) | absente |
| `proxmox_pont` | `t23appl` (le VNet SDN) | absente |
| `proxmox_etiquette_vlan` | aucune (le SDN étiquette) | `1236` |
| `serveur_resolveur_zones_deleguees` | `genese.internal` → `10.37.34.11` | absente |
**Canal 3 : le réseau.** Le runner du locataire **tire** son génome de la forge du site ; il est
né de l'**insémination** par le runner du site.
#### Le locataire informe le site : le site lit et recalcule
| Information | Décidée par | Tenue chez le locataire dans | Parvient au site par |
|---|---|---|---|
| ses **zones** et son adressage | dérivés de l'index | `plan/nomenclature.yml` | le site **lit le fichier** |
| les **VM à matérialiser** | le locataire | `plan/serveurs.yml` → `hosts.yml` | le site **lit les fichiers** (placement, clonage, pools, SDN) |
| ses **flux** | ses rôles et son plan | `meta/flux.yml` des rôles (moteur), croisés avec son `hosts.yml` | le site **recalcule** lui-même, avec **sa** version du moteur et la totalité de l'inventaire du locataire → frontière, NAT, pare-feu Proxmox |
| ses **domaines publics** | le locataire | `plan/domaines.yml` (+ `applications.yml`, `serveurs.yml`) | le site **lit les fichiers** → DNS public secondaire |
| sa **clé de sauvegarde** | le locataire | `inventories/*/group_vars/serveur_backup.yml` | le site **lit le fichier** → compte Unix sur le dépôt |
| ses **accès d'administration** | le locataire | `plan/acces.yml`, `nftables_admin_ssh` | le site **lit les fichiers** → pairs WireGuard, règles d'administration |
Le SDN, lui, ne prend aucun flux : il ne filtre pas. Il ne reçoit que l'index, dont il dérive
la zone, les 6 VNets et les 6 sous-réseaux.
#### À l'exécution, entre machines
Ces échanges-là passent par le réseau, pas par les dépôts. Ils sont déjà déclarés en flux :
le locataire **dépose** ses sauvegardes chez le site (SFTP), **tire** ses paquets, ses binaires
et son génome, **entre** par le tunnel d'administration du site ; le site **réplique** les
zones publiques du locataire (AXFR signé TSIG).
#### Ce que le relevé montre
1. **Le site fouille l'intérieur du locataire.** Six fichiers de son plan et de son inventaire,
`group_vars` compris. Rien ne dit ce que le locataire **accepte** de montrer. Renommer un
champ chez le locataire casse le site sans bruit.
2. **Les flux sont calculés deux fois**, par le locataire pour ses `nftables` et par le site pour
la frontière et Proxmox, chacun avec **sa** version du moteur. Ils concordent tant que les deux
runners tiennent le même commit (c'était le cas le 2026-10-04), mais rien ne l'impose.
3. **Le locataire vit de copies** : douze valeurs et un certificat, recopiés à la main, plus une
valeur hors contrat. La garde qui compare ne tourne que sur le poste ; le runner du locataire
ne peut pas savoir que sa copie a vieilli.
4. **L'inventaire d'un locataire dépend du site monté au moment de le générer.** Généré sur le
runner du locataire, qui n'a pas le dépôt du site, il perdrait son serveur de temps, son SDN
et sa délégation DNS. Ça ne s'est jamais vu, parce que l'inventaire est toujours généré sur le
poste puis versionné.
5. **Deux décisions du site** (l'index, l'adresse publique) vivent en double.
#### Proposition : deux fiches, une dans chaque sens
Chacun **publie** ce qu'il donne à l'autre, dans une fiche **générée** par le moteur, jamais
écrite à la main. Chacun ne lit que la fiche que l'autre lui destine. Plus aucune lecture
croisée, plus aucun recalcul.
- **La fiche du site pour un locataire.** Tout ce que le site lui **attribue** (index, adresse
publique) et lui **offre** : les 10 intrants, sa racine de confiance, et ce que l'instancier
allait lire en douce (serveur de temps, délégation DNS, mode SDN et nom des VNets). Une fiche
**par locataire** : aucun ne voit le plan du site ni ses voisins. L'instancier ne lit plus que
cette fiche, et l'inventaire devient **identique où qu'on le génère**.
- **La face réseau du locataire.** Ce qu'il **demande** au site : VM à matérialiser, zones,
domaines publics, clé de sauvegarde publique, accès d'administration, et **ses flux déjà
résolus** (adresses, ports, protocoles), ceux avec l'extérieur pour la frontière et ceux de
chaque VM pour Proxmox. Le locataire génère déjà ses flux résolus (`flux-genere/*.nft`,
`*.connectivite.json`) : la face réseau en est la partie destinée au site. Le site ne
recalcule plus rien : il applique ce que le locataire publie, après l'avoir confronté à sa
propre politique.
- **Chaque fiche porte l'empreinte de sa source**, et une preuve de chaque côté vérifie que la
fiche reçue correspond à ce que l'autre a publié.
**Où en est l'étape 2 (2026-10-04).** La fiche du site (P84), les faits de la face réseau
(P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures
croisées produisent. La première mesure des flux a trouvé une information que le locataire
jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il
les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités
(P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites.
**L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et
l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face
réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox, la frontière et la
découverte des locataires du site la lisent. La matérialisation aussi : la face publie les
paramètres de clonage (P93), sauf la clé du runner du site. Le site l'ajoute lui-même en
clonant le runner du locataire (`clonage_rendu`, 2026-10-11) : elle ne transite plus par la
face, et un locataire peut calculer sa face sans le plan de son site ; `locataire-creer`, `locataire-raser` et `placement-plan TENANT=`
nomment leur locataire au lieu de le monter, et visent les mêmes machines, à l'argument près de
la ligne `ansible-playbook` (P94, `test_appels_locataire.py`) ; `reconstruire-locataire` les
emploie. Reste : la preuve par reconstruction.
Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe
n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle.
#### Ce que les fiches donnent : la portabilité
Un locataire qui change de site, pour un déménagement, un plan de reprise ou une émancipation,
n'a plus qu'à **recevoir la fiche de son nouveau site**. Son dépôt ne contient plus rien
d'interne à l'ancien : ni copie d'adresse, ni inventaire généré avec l'ancien site monté. En
face, le nouveau site n'a qu'à lire sa **face réseau**. Aujourd'hui, la même bascule demande de
corriger des copies dans plusieurs fichiers, puis de régénérer l'inventaire avec le nouveau site
monté sur le poste.
## 3. Le poste : un sélecteur
Aujourd'hui, le poste monte les deux contextes **en même temps** (`instance/` + `underlay.yml`),
et `ConsolePoste` hérite de `ConsoleLocataire`. Désormais :
- **Le poste choisit un contexte actif** : un site **ou** un locataire. La console ouvre celui-là,
et seulement celui-là.
- **Une opération qui traverse les deux** nomme ses objets au lieu de les deviner.
`reconstruire-locataire` en est l'exemple : le site matérialise, puis le locataire monte.
L'orchestration devient `site.materialiser(locataire)` puis `locataire.monter()`.
- **Les runners ne changent pas** : celui du site n'a qu'un `Site`, celui d'un locataire qu'un
`Locataire`. Leur contexte est désormais **dit**, plus déduit.
## 4. Qui surcharge quoi
| Méthode | Tronc commun | Site | Locataire |
|---|---|---|---|
| `inventaire()` | abstraite | script dynamique (`underlay.yml`) | `hosts.yml` généré du plan |
| `machines()` | abstraite | VM du site + équipements | VM du plan |
| `adressage()` | dérivé de l'index | zones `site` dérivées, liens `fabric` écrits | 6 zones dérivées |
| `sauvegardes()` | son propre état, vérifié par restauration | + héberge les dépôts des locataires | dépose chez son site |
| `supervision()` | sondes déclarées par les rôles | + matériel, fabric, frontière | — |
| `raser()` / `reconstruire()` | — | `site_raser.py` | `raser.py`, `reconstruire_locataire.py` |
| `preuves()` | preuves communes | + preuves de site (P23, P74, P82…) | + preuves de locataire |
| `verbes()` | `verifier`, `publier`… | `site-*`, `frontiere-*`, `proxmox-*` | `appliquer`, `instancier`, `remise-*` |
| `console()` | — | console SITE | console LOCATAIRE |
## 5. Ce qui ne change pas
- Les **rôles Ansible**, déjà communs.
- Les **formats de plan** : pas dans ce chantier.
- La **doctrine** d'`AGENTS.md`.
## 6. Le chemin, chaque pas prouvé avant le suivant
1. **`scripts/contexte.py`** : `Ecosysteme`, `Site`, `Locataire`, `contexte_actif()`,
`Site.charger(nom)`, `Locataire.charger(nom)`. Tests unitaires. Rien ne l'utilise encore.
2. **Les deux fiches**, générées à côté de l'existant sans rien remplacer :
`site.fiche_pour(locataire)` et `locataire.face_reseau()`. Une preuve vérifie que chaque
fiche dit **exactement** ce que les lectures croisées d'aujourd'hui produisent.
3. **Les consommateurs basculent sur les fiches**, un par un : l'instancier sur la fiche du site
(l'inventaire généré doit rester identique, octet pour octet) ; la frontière, Proxmox, le DNS
public et les comptes de sauvegarde sur la face réseau (chaque devis doit rester inchangé).
4. **`prouver.py`** : chaque preuve déclare son contexte (commun, site, locataire) et reçoit son
écosystème du module.
5. **Les autres scripts**, un par un, vérifiés par `make verifier` et par un devis inchangé.
6. **Une preuve « aucune devinette, aucune lecture croisée »** : les indices du §1, et toute
ouverture d'un fichier de l'autre contexte, interdits hors de `contexte.py`.
7. **Les verbes du Makefile** rangés par contexte.
8. **Les consoles** : le sélecteur et deux consoles (chantier suivant).
9. **OPS-Modele** : un locataire modèle, et sans doute un site modèle, vérifiés chacun dans son
contexte.
Après les étapes 3 et 5, une reconstruction prouve que la flotte n'a pas bougé.
## 7. Décisions et questions ouvertes
### Tranché par l'exploitant
- **Deux classes, `Site` et `Locataire`, qui héritent d'un tronc commun** (2026-10-04).
- **Le poste est un sélecteur** : un contexte actif à la fois (2026-10-04).
- **On commence par le moteur**, les consoles viennent ensuite (2026-10-04).
- **Le site dépose sa fiche dans le dépôt du locataire** (2026-10-04), comme il y amorce déjà
son runner. Le runner du locataire n'a besoin d'aucun accès au dépôt du site, et ne voit
ni le plan du site ni ses voisins.
- **Le contexte actif se nomme dans un fichier `contexte` explicite** (2026-10-04), une seule
valeur : `site:SITE-Chezlepro` ou `locataire:OPS-Technolibre`. Un sélecteur qui monte deux
liens à la fois contredirait sa propre règle. Les liens `instance/` et `underlay.yml` restent
le temps de la bascule, lus par le seul `contexte.py`.
- **Un modèle SITE public, `SITE-Modele`**, à côté d'`OPS-Modele` (2026-10-04). Sans site, la
CI ne peut exercer ni les preuves de site (P74, P81, P82) ni la fiche que le site dépose chez
le locataire.
- **Le site a aussi son `parente.yml`** (2026-10-04) : la filiation est dans le tronc commun.

View file

@ -23,19 +23,6 @@ couches:
- serveur_debian
- serveur_durci
# LE DEPOT DE L'ETAT AVANT TOUT CE QUI EN RESTAURE (2026-10-13). L'AC, les bases, la
# supervision demandent au depot « mon incarnation precedente attend-elle ? » au moment de
# naitre — et refusent, a juste titre, un depot ILLISIBLE (conclure « rien a restaurer »
# faute de le joindre ferait naitre neuf ce qui attend). Range dans `services`, le depot
# n'existait pas encore : le premier site construit depuis zero (TechnoLibre) s'est arrete
# sur sa PKI. Il ne depend que du socle.
- nom: depot_etat
raison: "Le depot de sauvegarde : il doit repondre (vide ou non) avant que l'AC, les bases et la supervision ne naissent."
groupes:
- serveur_backup
# Le depot du site, meme nature : il recoit l'etat des locataires et le sien.
- serveur_backup_site
- nom: pki_racine
raison: "L'autorité de certification interne (step_ca) : la racine de confiance de tous les flux TLS est-ouest."
groupes:
@ -88,6 +75,7 @@ couches:
- serveur_rspamd
- serveur_dovecot
- serveur_postfix
- serveur_backup
# La source d'artefacts vient AVANT ceux qui installent des paquets — c'est tout
# son objet. Placee plus tard, elle serait remplie apres avoir servi.
- serveur_artefacts
@ -102,10 +90,8 @@ couches:
- serveur_forge_site
# Le resolveur du site, meme nature : prete aux locataires pendant leur jeunesse.
- serveur_resolveur_site
# LE RELAIS (2026-10-13) : la face publique d'un site qui n'a qu'une adresse DHCP. Un
# equipement HORS du site (VPS), inventorie depuis la carte comme un hyperviseur. Il
# vient avant tout ce qui se publie : sans lui, une adresse publique ne mene nulle part.
- serveur_relais
# Le depot de sauvegarde du site, meme nature : il recoit l'etat des locataires.
- serveur_backup_site
# Le DNS public du site vient APRES les services : il tire les zones que les
# autoritatifs des locataires ecrivent, et n'a rien a servir avant eux.
- serveur_dns_public

View file

@ -1,149 +0,0 @@
# Dépôts privés sur la forge du site — conception du chantier
> **Pour qui :** le mainteneur qui décide de ce chantier puis le conduit, et quiconque se demande
> pourquoi un runner ne lit que ses propres dépôts.
Préparé le 2026-10-11. Décisions D1 à D3 prises le même jour par Daniel : les trois recommandations.
## 1. Le constat
Sur la forge du site, l'organisation `genome` porte cinq dépôts, tous **publics** :
| Dépôt | Contenu | Qui en a besoin |
|---|---|---|
| `set-ops-public` | le moteur | tous les runners |
| `set-ops-modeles` | les modèles génériques | le runner du site |
| `site-chezlepro` | la carte de la fabric, le plan du site | le runner du site |
| `ops-chezlepro` | le plan, l'inventaire et la face de Chezlepro | son runner, le runner du site |
| `ops-technolibre` | le plan, l'inventaire et la face de Technolibre | son runner, le runner du site |
Mesuré le 2026-10-11 : depuis le runner de Technolibre, `git ls-remote` sans identifiant lit la
tête de `ops-chezlepro` (`b4fbd85`) et de `site-chezlepro` (`51dae24`). Chaque locataire peut donc
lire le plan de l'autre, et la carte du site. **Aucun n'en a besoin** : chaque runner de locataire
ne clone que le moteur et son propre dépôt ; ce que le site lui donne arrive par la fiche déposée
dans son dépôt.
D'où ça vient : le 2026-09-12, à la première reconstruction du site, les dépôts créés privés ont
cassé le clonage anonyme des runners. Ils sont passés publics, avec l'argument que seuls ceux
qui ont franchi la frontière joignent la forge (`scripts/forge_amorcer.py`). Les autres locataires
en font partie.
## 2. La cible
- **Publics** : `set-ops-public` et `set-ops-modeles`. Ils sont génériques, identiques pour tous.
- **Privés** : `site-chezlepro` et chaque `ops-<locataire>`.
- **Le runner du site** lit et pousse tous les dépôts avec sa clé, déjà enregistrée comme clé de
déploiement en écriture par `genome_pousser.yml`. Mesuré le 2026-10-11 : il lit les cinq dépôts
en SSH, avec une vérification stricte de la clé d'hôte. Rien à créer pour lui.
- **Le runner d'un locataire** lit son seul dépôt, avec une clé de déploiement **en lecture
seule** : la clé publique de son runner, déjà déclarée au plan (`ssh_baseline_cles_admin`) et
dont la privée lui est remise à l'armement depuis sa voûte (`vault_runner_cle_ssh`).
- **Aucun secret ne change de main.** Le locataire publie la clé publique de son runner dans sa
face réseau ; le site l'enregistre sur le dépôt de ce locataire, avec ses propres
identifiants d'administration de la forge (`vault_forgejo_admin`, déjà dans sa voûte).
### Éprouvé le 2026-10-11 (étape 1)
Clés de lecture enregistrées par `make forge-cles`, flux posé sur `site-forge-01` et à la
frontière. Depuis chaque runner, `git ls-remote` en SSH, clé d'hôte de la forge épinglée (lue
sur sa machine par l'accès d'administration) :
| Depuis | son dépôt | l'autre locataire | `site-chezlepro` | `set-ops-public` |
|---|---|---|---|---|
| runner de Technolibre | lu (`0172dd8`) | refusé | refusé | refusé |
| runner de Chezlepro | lu (`74d5567`) | refusé | refusé | refusé |
Le refus vient de la forge (« Deploy Key … is not authorized to read »), **alors que les dépôts
sont encore publics** : une clé de déploiement ne vaut que pour le dépôt qui l'a enregistrée.
Conséquence : le moteur, public, continue de se cloner en HTTPS sans identifiant ; seuls les
dépôts privés passent par SSH.
## 3. Le nœud : l'insémination
Le site amorce le runner d'un locataire **avant** que celui-ci ait reçu sa clé stable (elle
arrive à l'armement, geste humain, après). Pendant l'insémination, le rôle `serveur_ops` clone
pourtant le dépôt du locataire : avec un dépôt privé, ce clonage échoue. Même chose au premier
déploiement du runner du site, dont la clé est neuve.
**Remède proposé : le colis.** Quand un runner ne peut pas encore lire un dépôt privé, le
contrôleur qui joue `serveur_ops` (le runner du site pendant l'insémination, le poste
ailleurs) lui apporte ce dépôt en `git bundle`. Le mécanisme existe déjà : `genome_colis.py` porte
le génome du poste au runner du site. Le runner clone le colis, puis pointe `origin` vers l'URL
SSH de la forge. Ses tirages suivants passent par sa clé, une fois armé.
## 4. Les décisions à prendre
**D1 — Comment le runner d'un locataire lit-il son dépôt ?**
- *Clé de déploiement en SSH (recommandé).* Le runner continue de tirer lui-même ; il garde son
autonomie (émancipation, `make` lancé sur le runner, garde `verifier_genome_a_jour`). Coût : un
flux nouveau, du runner de chaque locataire vers la forge du site, en SSH.
- *Colis seulement.* Aucun flux nouveau, aucune clé de déploiement pour les locataires : le
contrôleur apporte toujours le dépôt. Mais le runner ne tire plus son dépôt lui-même, et sa
garde de génome n'a plus de forge à laquelle se comparer.
**D2 — Sur quelle écoute SSH ?**
- *Le `sshd` de la machine, port 22 (recommandé pour commencer).* Le runner du site et le poste
l'utilisent déjà pour pousser ; l'utilisateur `git` y est contraint par la forge. Le flux
n'ouvre que les adresses des runners de locataires, pas leurs supernets.
- *Le serveur SSH intégré de Forgejo, sur un port dédié.* Il n'expose pas le `sshd` de la
machine, mais sa cohabitation avec le `sshd` existant reste à éprouver avant de s'y fier.
**D3 — La preuve.** Ce chantier touche l'insémination : je propose de le prouver par une
reconstruction complète de Technolibre (raser, créer, inséminer, armer, monter), dépôts
privés, avant de reprendre le lot 3b.
## 5. Ce qui change dans le code
État au 2026-10-11 : tout ce tableau est écrit, sauf la bascule elle-même. La clé d'hôte de
la forge est déclarée dans le plan du site (`forge_ssh_cle_hote`), offerte par la fiche
(`serveur_ops_forge_cle_hote`) et copiée par chaque locataire. Le moteur reste cloné en HTTPS.
| Pièce | Changement |
|---|---|
| `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` |
| `roles/serveur_ops` | URL SSH et clé du runner pour les dépôts privés ; colis du contrôleur quand le runner ne peut pas encore les lire ; clé d'hôte de la forge épinglée |
| `contexte.py` (face) | la face publie la clé publique de lecture du runner (`genome.cle_lecture`) |
| `contexte.py` (fiche) | la fiche du site publie la clé d'hôte SSH de sa forge, que le runner du locataire épingle |
| nouvelle cible `forge-cles` | le site enregistre, d'après les faces, la clé de lecture de chaque locataire sur son dépôt, et retire celles qui ne sont plus publiées |
| `forge_amorcer.py` | crée les dépôts privés comme privés |
| flux | `serveur_forge_site` : entrée SSH depuis les runners des locataires hébergés (paire nouvelle, résolue depuis les faces) ; `serveur_ops` : sortie correspondante |
| `genome_etat.yml` | lit la forge par SSH ou authentifié : l'API anonyme répondrait 404 pour un dépôt privé |
| `sonde-genome-servi` | ne compte plus que les dépôts publics (au moins le moteur) : c'est ce qu'un écosystème neuf clone sans rien détenir ; les privés sont suivis par `genome-etat`, `forge-cles` et les runners |
Inchangés : `sonde-genome-a-jour` (son repère ne compare que `set-ops-public`), le poste (il
pousse déjà en SSH), la console (`etat_forge` lit `origin`, qui deviendra l'URL SSH).
## 6. Les preuves à écrire
- **Hors ligne (`make prouver`)** : chaque dépôt `instance`, `tenant` ou `hebergeur` est déclaré
privé ; chaque face publie une clé de lecture, et cette clé est dans `ssh_baseline_cles_admin`.
- **Sur le terrain (`make forge-etancheite`)**, avec témoins :
- sans identifiant, un dépôt privé refuse et un dépôt public répond ;
- le runner d'un locataire lit son dépôt et **ne lit pas** celui de l'autre ;
- les clés de déploiement de chaque dépôt sont exactement : runner du site en écriture, runner
du locataire en lecture.
- **Par reconstruction** : D3.
## 7. L'ordre de bascule
**Fait le 2026-10-11, étapes 1 à 5.** Bascule par `make forge-cles CONFIRMER=true`, mesurée
ETANCHE par `make forge-etancheite`. Preuve par reconstruction de Technolibre (§D3) : le runner
neuf a reçu son dépôt en colis à l'insémination (il n'avait pas encore de clé), l'a tiré en SSH
une fois armé, et la flotte est remontée avec ses témoins d'état intacts. La reconstruction a
d'abord révélé un dossier SSH manquant sur une machine neuve (corrigé, `3fc16c8`).
Chaque étape se vérifie avant la suivante, et la bascule se défait d'un appel par dépôt
(repasser public).
1. **Éprouver** sur la forge du site, sans rien basculer : enregistrer la clé de lecture du
runner de Technolibre sur `ops-technolibre`, ouvrir le flux pour ce seul runner, et mesurer
qu'il lit son dépôt en SSH.
2. **Construire** le code et les preuves (§5, §6) ; `make verifier`.
3. **Déployer sans basculer** : flux et frontière (`make frontiere-appliquer CONFIRMER=true`,
lancé par Daniel), clés de déploiement, remotes SSH sur les trois runners. Les dépôts sont
encore publics : chaque runner doit déjà tirer en SSH.
4. **Basculer** `site-chezlepro`, `ops-chezlepro` et `ops-technolibre` en privé. Mesurer avec
`make forge-etancheite`, puis la veille des trois consoles.
5. **Prouver** par la reconstruction de Technolibre (D3).
6. Reprendre le lot 3b : les demandes vivent alors dans le dépôt de chaque locataire, lisibles
par lui et par le site seulement.

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 95 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 83 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -22,7 +22,7 @@ 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 | ssh | tls-cible | wireguard | 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
@ -66,31 +66,6 @@ Un **résolveur** (miroir de `instancier`) agrège, par serveur, les `flux.yml`
- **le registre d'audit** : `docs/registre-flux.md` (généré) + une cible `make flux`.
## Activation prudente
### Descriptions conservées par les pare-feux
La `raison` non vide du flux et son rôle alimentent les champs natifs : `comment`
pour nftables et Proxmox, `description` pour le filtrage OPNsense (`descr` pour ses
redirections). Un commentaire de fichier `# ...` ne survit pas au chargement nftables.
Les règles communes (administration, connexions établies, refus) portent aussi leur motif.
Les libellés sont sur une ligne. Le commentaire nftables est borné à 127 octets UTF-8 ;
OPNsense garde sa clé `setops:...` complète et borne le texte qui suit pour rester dans
255 octets. Une raison abrégée reste disponible en entier dans le registre des flux.
Le commentaire Proxmox conserve aussi le pair nommé dans le devis.
Une correction de description apparaît dans `make proxmox-fw-plan` et
`make frontiere-plan`. Son application met à jour le champ sur la règle existante,
puis le relit : elle ne recrée pas la règle et ne modifie ni son activation ni sa
politique. Les confirmations habituelles restent requises pour appliquer ces devis.
`make test` vérifie notamment cette convergence et les limites des champs.
Références des champs natifs : [nftables](https://netfilter.org/projects/nftables/manpage.html),
[API Proxmox](https://github.com/proxmox/pve-firewall/blob/master/src/PVE/API2/Firewall/Rules.pm),
[descriptions OPNsense](https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/DescriptionField.php).
### Activer le filtrage
Activer nftables = **action destructive** (peut couper l'accès) → confirmation explicite +
déploiement graduel (garder l'accès SSH/Ansible, tester par nœud).
@ -144,7 +119,7 @@ Le registre confondait deux situations :
| Situation | Exemple | `partage` |
|---|---|---|
| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 24 (LMTP réseau) | absent |
| le rôle **ouvre** l'écoute | `serveur_dovecot` lie 12345 (SASL réseau) | absent |
| le rôle **décrit** celle d'un autre | `serveur_backup` emprunte le sshd de `serveur_debian` | `true` |
Sans cette distinction, la seule co-location légitime de la flotte — `tcp/22` sur le
@ -155,3 +130,4 @@ ignorée, ce qui est pire que de ne pas l'avoir.
**Corollaire à retenir** : un port qu'on **subit** (le défaut amont d'un logiciel) doit être
imposé et déclaré comme les autres. Celui d'Alloy ne l'était pas, et c'est la seule raison
pour laquelle la collision a pu durer des semaines.

View file

@ -430,12 +430,3 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
l'énonce désormais au lieu de le poser en silence.
- **La sortie générale n'existe plus — 2026-10-10.** Le 123 était déjà passé à la frontière
(2026-09-11). Le 443 et le 80 du socle sont devenus conditionnels (`seulement_si:
artefacts_amorcage` vide) : le site et ses locataires prennent apt par le cache du site,
et ne les reçoivent plus. Mesurée à la frontière, cette sortie ne servait plus qu'à ce
qu'aucun rôle ne déclarait : télémétrie de Grafana, d'Alloy et de Loki et vérification
de version de Collabora (coupées à la source), cartes de rspamd et repère du génome
(déclarés dans `serveur_rspamd` et `serveur_ops_site`). Chaque sortie vers Internet porte
désormais le nom du rôle qui en a besoin. `test_conditions_site.py` refuse qu'un rôle
universel en rouvre une sans condition.

View file

@ -23,7 +23,7 @@
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud, Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. |
| `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). |
@ -31,9 +31,9 @@
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont d'artefacts. L'intégrité vient de la signature des paquets, pas du transport. |
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). |
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site et ses locataires passent par le cache du site et ne la reçoivent pas. |
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. |
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
@ -43,8 +43,8 @@
| `serveur_dns_public` | egress | 5300 | udp | primaires_dns_locataires | clair | SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change. Sans lui, le secondaire garde sa premiere copie pour toujours. |
| `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_forge_site` | ingress | 22 | tcp | runners_locataires | ssh | Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui du site. |
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
@ -66,7 +66,7 @@
| `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 | 1514 | tcp | fabric | tls-requis | Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. |
| `serveur_loki` | ingress | 1514 | tcp | fabric | clair | Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part. |
| `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_loki` | ingress | 3100 | tcp | fabric | tls | Journaux des hyperviseurs. La machine qui PORTE les VM ecrivait ses journaux nulle part ailleurs que sur son propre disque — invisibles le jour ou c'est elle qui casse. |
@ -86,10 +86,11 @@
| `serveur_ops` | ingress | 8090 | tcp | edge, admin | clair | Console d'exploitation servie par l'edge (TLS terminé à l'edge), et joignable depuis le plan d'administration là où il n'y a pas d'edge. Le GUI lui-même reste sur la boucle locale : c'est nginx qui authentifie devant. |
| `serveur_ops` | egress | 22 | tcp | flotte | ssh | Piloter la flotte — c'est la raison d'être du poste. |
| `serveur_ops` | egress | 443 | tcp | edge | tls-requis | Cloner et resynchroniser le génome depuis la forge de l'écosystème. |
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
| `serveur_ops_site` | egress | 443 | tcp | externe | tls-requis | Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca) pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant. |
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
@ -97,10 +98,11 @@
| `serveur_postfix` | ingress | 587 | tcp | flotte, externe | starttls | Soumission authentifiée (STARTTLS obligatoire) : agents internes et clients de courriel des utilisateurs. |
| `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, et mots de passe de soumission vérifiés par le Dovecot local (LDAPS). |
| `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_postgresql` | ingress | 9187 | tcp | serveur_prometheus | tls | Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. |
| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | clair | Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. En clair pour l'instant, contrairement a `client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`. |
| `serveur_powerdns` | ingress | 5300 | tcp | dns_public_site | clair | AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement. |
| `serveur_powerdns` | ingress | 5300 | udp | dns_public_site | clair | Interrogation du SOA par le secondaire du site avant chaque transfert. |
| `serveur_powerdns` | ingress | derive | udp | flotte | clair | Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur. |
@ -109,11 +111,9 @@
| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). |
| `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_prometheus` | egress | 9100 | tcp | frontiere | tls | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. |
| `serveur_prometheus` | egress | 9100 | tcp | frontiere | clair | Scrape de l'exportateur de la frontiere (os-node_exporter), sur sa seule patte de supervision : l'equipement qui voit passer TOUT le trafic n'avait ni temperature ni charge dans la supervision. |
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
| `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_relais` | ingress | 22 | tcp | externe | ssh | La console d'administration, sur l'adresse PROPRE du VPS seulement. Les adresses relayees ne menent jamais a ce SSH : elles partent dans le tunnel. |
| `serveur_relais` | ingress | derive | udp | externe | wireguard | La frontiere du site ouvre le tunnel depuis son adresse DHCP (port derive : 51800 + index du site). Seule sa cle est admise ; le reste de l'Internet ne recoit aucune reponse. |
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
| `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `serveur_resolveur` | egress | 53 | udp | externe | clair | Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie ses questions à personne. |
@ -121,8 +121,6 @@
| `serveur_resolveur_site` | ingress | 53 | tcp | voisins_site | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
| `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_rspamd` | egress | 80 | tcp | externe | clair | Carte de hachages SURBL (sa-update.surbl.org), publiee en clair par l'amont. Une carte alteree ne peut que mal classer un courriel ; elle n'execute rien. |
| `serveur_rspamd` | egress | 443 | tcp | externe | tls-requis | Cartes de rspamd (maps.rspamd.com) : messageries gratuites et jetables, redirecteurs, listes blanches DMARC/SPF/DKIM, hameconnage. Sans elles l'antispam garde les copies du paquet, qui vieillissent. |
| `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, serveur_web_frontal | clair | Front nginx local des webapps et des sites statiques, relaye par l'edge (noms internes) et par le web frontal (noms publics). |
| `serveur_web_dorsal` | egress | 443 | tcp | flotte | tls | git clone/pull du depot de chaque app (Forgejo souverain) au deploiement. |
@ -132,11 +130,10 @@
## Synthèse chiffrement
- **clair** : 49 flux
- **clair** : 51 flux
- **n-a** : 8 flux
- **ssh** : 10 flux
- **ssh** : 8 flux
- **starttls** : 6 flux
- **tls** : 10 flux
- **tls-cible** : 2 flux
- **tls-requis** : 37 flux
- **wireguard** : 1 flux
- **tls-requis** : 36 flux

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole :
```
make prouver le dépôt est-il cohérent avec lui-même (95 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (83 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -94,10 +94,6 @@ variables:
invite: "Armer sans marquer la pause"
valeurs: [oui]
facultatif: true
SOURCE:
invite: "Comparer a quel instantane (vide = celui d'avant rasage)"
valeurs: [candidat]
facultatif: true
VMID:
invite: "Identifiant Proxmox de la VM"
DIALECTE:
@ -213,32 +209,6 @@ runbooks:
- cible: site-intrants
nature: mesure
pourquoi: "Ce que ce site expose a ses locataires, derive et non declare deux fois."
- cible: fiches-site-deposer
nature: ecriture
pourquoi: >-
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
dans le depot de chaque locataire.
- cible: forge-cles
nature: ecriture
fixes: {CONFIRMER: "true"}
pourquoi: >-
Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et
retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive.
- cible: forge-comptes
nature: ecriture
fixes: {CONFIRMER: "true"}
pourquoi: >-
Un compte par mainteneur declare, ouvert sur les seuls depots de ses locataires :
c'est par un ticket qu'il demande au site. Et le lecteur de la console du site, qui
met ces demandes dans « A traiter ».
- cible: forge-etancheite
nature: mesure
portee: poste
pourquoi: >-
Sans identifiant, chaque depot prive exige une authentification et le moteur
repond ; chaque runner de locataire lit son depot et pas celui d'un autre. Du
poste, qui voit le site et les runners.
- cible: site-verifier
nature: mesure
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"
@ -248,34 +218,6 @@ runbooks:
- cible: routes-fabric-etat
nature: mesure
pourquoi: "Une route de zone manquante est invisible jusqu'au premier invite qui la traverse."
- cible: locataire-creer
nature: ecriture
variables: [TENANT, PARALLELE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Cloner les VM d'un locataire NOMME, d'apres la face reseau qu'il publie : le
runner du site materialise sans monter son depot. Memes machines, memes
parametres que `flotte-creer` sur l'instance montee (P93, P94).
# UNE SEULE MACHINE, A LA DEMANDE D'UN MAINTENEUR (2026-10-11). `locataire-creer`
# clone TOUTES les VM manquantes ; une demande de locataire en vise une. Les
# parametres viennent de la meme face reseau (`clonage`), par le meme chemin
# (`PARAMS_CLONAGE`). Sans `TENANT`, `creer-vm` exige une instance montee — que le
# site n'a pas : l'oubli se refuse, il ne cree rien de travers.
- cible: creer-vm
nature: ecriture
variables: [TENANT, HOTE]
duree: "~4 min 30"
pourquoi: >-
Cloner UNE machine d'un locataire nomme, d'apres sa face reseau publiee. Elle
n'existe pour le site que si son mainteneur l'a mise au plan, a applique
l'inventaire, puis a republie sa face.
- cible: locataire-raser
nature: destructif
variables: [TENANT, INSTANCE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
Detruire les VM d'un locataire NOMME, d'apres sa face : les memes VMID que le
plan derive (P94). Son nom court s'ecrit en toutes lettres, comme pour `raser`.
- cible: site-raser
nature: destructif
variables: [SITE]
@ -378,12 +320,6 @@ runbooks:
pourquoi: >-
Regenerer le registre des flux et les regles nftables depuis les `meta/flux.yml`
des roles. Tout ce qui suit en descend.
- cible: face-reseau-publier
nature: ecriture
pourquoi: >-
Publier ce que ce locataire demande a son site (`face-reseau.yml`) : ses machines,
ses zones, ses flux deja resolus. Le site ne lit plus que ce fichier. Apres `flux`,
et a commiter dans le depot du locataire.
- cible: devis-proxmox-fw
nature: mesure
pourquoi: "Le pare-feu est-ouest intra-locataire, derive du registre des flux."
@ -556,8 +492,7 @@ runbooks:
pourquoi: >-
Deposer l'etat de chaque noeud JUSTE AVANT de raser. La reconstruction remet le
dernier instantane anterieur a la naissance des machines : sans ce depot, c'est
celui de la nuit, et la journee est perdue. Il est etiquete « avant-raser » et
garde jusqu'a la reconstruction suivante : c'est contre lui que jugent les temoins.
celui de la nuit, et la journee est perdue.
- cible: raser
nature: destructif
portee: poste
@ -580,14 +515,6 @@ runbooks:
pourquoi: >-
Ce que chaque jeu est devenu : restaure (et depuis quel instantane), neuf, en
place. Un jeu EN ATTENTE bloque la sauvegarde de son noeud — c'est voulu.
- cible: temoins-etat
nature: mesure
variables: [HOTE, SOURCE]
pourquoi: >-
L'etat vivant est-il celui d'avant le rasage ? Une perte, ou une identite changee
(cles de l'AC, DKIM, instanceid de Nextcloud, mot de passe d'une entree de
l'annuaire), est un ecart. Le bilan dit ce que les roles ont fait ; les temoins
mesurent ce qui est revenu.
- cible: restauration-renoncer
nature: destructif
variables: [HOTE, JEU]

View file

@ -142,31 +142,16 @@ marqueur dans `/etc/setops/restauration/<jeu>` ; ce qui a été remplacé est mi
écarté : sinon `restic forget --keep-daily` chasserait l'instantané d'avant au profit de
l'état neuf du même jour.
**L'instantané d'avant rasage est étiqueté** `avant-raser` (depuis le 2026-10-07) et la
rétention le garde **jusqu'à la reconstruction suivante**, qui lui retire l'étiquette. Sans
elle, le premier dépôt de la machine reconstruite le chassait le jour même : après la
reconstruction de Technolibre (M4), il ne restait rien à quoi comparer l'état remis.
**Les témoins** comparent l'état vivant à cet instantané. Un écart, c'est une **perte**
(fichier, courriel, entrée de l'annuaire, base, rôle, table, ligne d'historique, ou une
base dont plus aucune ligne d'avant ne subsiste) ou une **identité changée** : clés et
certificats de l'AC, AC d'Icinga et environnement d'Icinga DB, clé DKIM, `instanceid` de
Nextcloud, mot de passe d'une entrée de l'annuaire. PostgreSQL se compare par clé (première
colonne de chaque table), pas par nombre de lignes. Le reste (un courriel reçu depuis, une table de sessions, le bayes de rspamd,
un cache) est listé sans être un écart. La reconstruction les lance à son étape `bilan`.
Les gestes :
```
make sauvegarder-maintenant # JUSTE AVANT de raser : étiqueté « avant-raser »
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu, et si son instantané est encore au dépôt
make temoins-etat [HOTE=...] # l'état vivant contre l'instantané d'avant rasage
make temoins-etat SOURCE=candidat # faute d'étiquette : contre le dernier d'avant la naissance
make sauvegarder-maintenant # JUSTE AVANT de raser : sinon on perd la journée
make restauration-etat [HOTE=...] # ce que chaque jeu est devenu
make restauration-renoncer HOTE=.. JEU=.. CONFIRMER=true # écarter un état, sans le remettre
-e client_backup_restauration_instantane=<id> # imposer un instantané, par hôte
```
Sur un nœud, sans Ansible : `setops-restaurer etat`, `setops-restaurer temoins`, et les répétitions qui ne touchent à
Sur un nœud, sans Ansible : `setops-restaurer etat`, et les répétitions qui ne touchent à
rien — `setops-restaurer annuaire --essai <base_dn>`, `base <nom> --vers epreuve`,
`fichiers --vers /var/tmp/epreuve <chemin>`.

View file

@ -6,10 +6,6 @@
# make sauvegarder-maintenant [HOTE=...]
# deposer TOUT DE SUITE, et attendre que ce soit fait. A faire juste avant `raser` :
# sinon la reconstruction remet l'etat de la derniere nuit, et perd la journee ;
# make temoins-etat [HOTE=...] [SOURCE=candidat]
# l'etat VIVANT est-il celui d'avant le rasage ? Compare a l'instantane etiquete
# « avant-raser » (lecture seule). Une perte, ou une identite changee (cles de l'AC,
# DKIM, instanceid, mot de passe d'une entree de l'annuaire), fait echouer le jeu ;
# make restauration-renoncer HOTE=<hote> JEU=<jeu> CONFIRMER=true
# ECARTER un etat anterieur sans le remettre. La sauvegarde du noeud, qui refusait
# de deposer pour ne pas le chasser de la retention, reprend — et l'etat d'avant
@ -24,7 +20,6 @@
vars:
restauration_action: etat
restauration_jeu: ""
restauration_source: avant-raser
tasks:
- name: Refuser un renoncement sans jeu nomme
ansible.builtin.assert:
@ -54,66 +49,14 @@
register: restauration_unite
when: restauration_action == 'sauvegarder'
# LE DEPOT D'AVANT RASAGE EST ETIQUETE (2026-10-07), et la retention le garde jusqu'a la
# reconstruction suivante. Un noeud qui n'a pas encore cette unite deposerait SANS
# etiquette, et la premiere sauvegarde d'apres reconstruction le chasserait : c'est
# exactement ce qui s'est passe a M4. On refuse plutot que de deposer a moitie.
- name: L'unite du depot d'avant rasage est-elle deployee ?
ansible.builtin.stat:
path: /etc/systemd/system/setops-sauvegarde-avant-raser.service
register: restauration_unite_avant_raser
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
- name: Refuser un depot que la retention ne garderait pas
ansible.builtin.assert:
that:
- restauration_unite_avant_raser.stat.exists
fail_msg: >-
setops-sauvegarde-avant-raser.service absente : redeployer client_backup
(make appliquer GROUPE=client_backup) avant de raser.
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
- name: Deposer maintenant, etiquete avant rasage
- name: Deposer maintenant
ansible.builtin.systemd:
name: setops-sauvegarde-avant-raser.service
name: setops-sauvegarde.service
state: started
when:
- restauration_action == 'sauvegarder'
- restauration_unite.stat.exists
# Code 4 = rien a comparer (aucun etat, ou aucun instantane d'avant rasage) : ce n'est
# pas un echec. Code 1 = ecart : on affiche d'abord, on echoue ensuite.
- name: Temoins — l'etat vivant contre l'instantane d'avant rasage
ansible.builtin.command:
argv: >-
{{ ['/usr/local/sbin/setops-restaurer', 'temoins']
+ (['--candidat'] if restauration_source == 'candidat' else []) }}
register: restauration_temoins
changed_when: false
failed_when: false
when: restauration_action == 'temoins'
- name: Temoins
ansible.builtin.debug:
msg: "{{ (restauration_temoins.stdout_lines | default([])) + (restauration_temoins.stderr_lines | default([])) }}"
when: restauration_action == 'temoins'
- name: Temoins — verdict
ansible.builtin.assert:
that:
- restauration_temoins.rc in [0, 4]
fail_msg: >-
{{ 'ECART : une perte, ou une identite changee (voir ci-dessus).'
if restauration_temoins.rc == 1 else
'setops-restaurer temoins a echoue (code ' ~ restauration_temoins.rc ~ ') : '
~ 'client_backup est-il deploye ?' }}
quiet: true
when: restauration_action == 'temoins'
- name: Ecarter l'etat anterieur de ce jeu
ansible.builtin.command:
argv: [/usr/local/sbin/setops-restaurer, acter, "{{ restauration_jeu }}", abandonne]

View file

@ -78,16 +78,6 @@
when:
- artefacts_amorcage is defined
- artefacts_amorcage | string | length > 0
- not (artefacts_amorcage_direct | default(false) | bool)
# LA MACHINE QUI PORTE LE CACHE VA DROIT CHEZ DEBIAN (2026-10-13). Le fichier herite du
# gabarit vise un cache qui n'existe pas encore — le sien — ou une adresse perimee :
# l'inventaire le dit par `artefacts_amorcage_direct`, et on le retire.
- name: Retirer l'amorcage sur la machine qui porte le cache
ansible.builtin.file:
path: /etc/apt/apt.conf.d/00-setops-amorcage-artefacts
state: absent
when: artefacts_amorcage_direct | default(false) | bool
# RETIRER CE QUI DESIGNE UN CACHE QUI N'EXISTE PLUS (2026-09-10).
#

View file

@ -1,19 +0,0 @@
---
- name: Appliquer le groupe serveur_relais
hosts: serveur_relais
become: true
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_relais

View file

@ -1,43 +0,0 @@
---
# LE SITE ENREGISTRE LA CLE DE LECTURE DE CHAQUE LOCATAIRE SUR SON DEPOT (2026-10-11).
#
# Voir `docs/depots-prives-conception.md` et `scripts/forge_cles.py`. Le script tourne SUR le
# runner du site : il y lit les faces que le site tire (et rien d'autre de ses locataires), et
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
# l'environnement, et n'est jamais affiche.
#
# make forge-cles montre ce qui serait fait
# make forge-cles CONFIRMER=true l'ecrit, puis relit
- name: Les cles de lecture des locataires, sur la forge du site
hosts: serveur_ops_site
gather_facts: false
become: true
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
vars:
forge_cles_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
forge_cles_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
forge_cles_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
tasks:
- name: Exiger le mot de passe d'administration de la forge (voute du site)
ansible.builtin.assert:
that: vault_forgejo_admin | default('') | length > 0
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
no_log: true
- name: Confronter les faces aux cles de la forge
ansible.builtin.command:
argv: "{{ ['python3', 'scripts/forge_cles.py']
+ (['--confirmer'] if (forge_cles_confirmer | bool) else []) }}"
chdir: "{{ forge_cles_racine }}/{{ forge_cles_moteur }}"
environment:
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
PATH: "{{ forge_cles_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
check_mode: false
register: forge_cles
changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout or 'rendu ' in forge_cles.stdout"
failed_when: forge_cles.rc not in [0]
- name: Ce que la forge porte
ansible.builtin.debug:
msg: "{{ forge_cles.stdout_lines }}"

View file

@ -1,47 +0,0 @@
---
# LES COMPTES DE LA FORGE DU SITE (2026-10-11, lot 3b).
#
# Voir `docs/depots-prives-conception.md` et `scripts/forge_comptes.py`. Le script tourne SUR le
# runner du site : il y lit le plan du site (`mainteneurs`) et les faces de ses locataires, et
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
# l'environnement, et n'est jamais affiche.
#
# make forge-comptes montre ce qui serait fait
# make forge-comptes CONFIRMER=true l'ecrit, puis relit
- name: Les comptes de la forge du site
hosts: serveur_ops_site
gather_facts: false
become: true
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
vars:
forge_comptes_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
forge_comptes_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
forge_comptes_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
tasks:
- name: Exiger le mot de passe d'administration de la forge (voute du site)
ansible.builtin.assert:
that: vault_forgejo_admin | default('') | length > 0
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
no_log: true
- name: Confronter le plan du site aux comptes de la forge
ansible.builtin.command:
argv: "{{ ['python3', 'scripts/forge_comptes.py']
+ (['--confirmer'] if (forge_comptes_confirmer | bool) else []) }}"
chdir: "{{ forge_comptes_racine }}/{{ forge_comptes_moteur }}"
environment:
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
PATH: "{{ forge_comptes_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
check_mode: false
register: forge_comptes
# LES LIGNES D'ACTION, PAS DES MOTS : « jeton a creer », dans le plan, contient « jeton »
# et faisait compter un changement alors que rien n'etait ecrit (2026-10-11).
changed_when: forge_comptes.stdout is search('(?m)^ (cree|collab|retire|equipe|jeton) ')
# 1 = un ecart DIT (un courriel a remplir, par exemple) : il s'affiche, il n'empeche pas
# le reste. 2 = le script a refuse de travailler.
failed_when: forge_comptes.rc not in [0, 1]
- name: Ce que la forge porte
ansible.builtin.debug:
msg: "{{ forge_comptes.stdout_lines }}"

View file

@ -61,11 +61,6 @@
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_compares }}"

View file

@ -22,7 +22,6 @@
#
# make genome-pousser tous les depots portes par le runner
# make genome-pousser DEPOT=<nom> un seul (le nom du depot sur la forge)
# make genome-pousser ARGS=--check ce que la poussee ferait, depot par depot, sans rien ecrire
#
# L'ECRITURE S'AUTHENTIFIE, LA LECTURE NON. `serveur_ops` clone en anonyme, par doctrine.
# Pousser est un autre acte : le runner s'y presente avec SA cle — celle que `serveur_ops`
@ -96,181 +95,6 @@
# depot par depot, et l'exploitant doit aller cliquer. La cible s'en charge : la
# declaration est idempotente (422 = deja la), et sa portee est exactement ce qu'on
# pousse, jamais plus large.
# UNE FORGE NEUVE, UN RUNNER NEUF (2026-10-13, premier site construit depuis zero). Le
# runner du site ne fabrique sa cle qu'APRES avoir clone le genome, et il ne peut cloner
# que ce que cette commande a pousse — qui exige sa cle. Personne n'allait au bout. On
# fabrique donc la cle ici si elle manque, A L'IDENTIQUE de `serveur_ops` (meme commande,
# meme commentaire, `creates:`) : le role la retrouvera et ne la refera pas.
- name: Assurer le répertoire SSH du runner
ansible.builtin.file:
path: "{{ genome_racine }}/.ssh"
state: directory
mode: "0700"
- name: Fabriquer la clé du runner si elle n'existe pas encore
ansible.builtin.command:
cmd: >-
ssh-keygen -t {{ serveur_ops_cle_type | default('ed25519') }} -N ''
-C "setops@{{ inventory_hostname }}.{{ domaine_interne }}"
-f {{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}
creates: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}"
# L'ORGANISATION ET SES DEPOTS N'ETAIENT CREES PAR PERSONNE : au site de Chezlepro, une
# fois, a la main. Meme forme qu'elle : organisation PUBLIQUE ; le moteur et les modeles
# PUBLICS (le runner les clone en anonyme) ; la carte du site et les plans des
# locataires PRIVES (`serveur_ops_roles_prives`, lus en SSH par cle de deploiement).
- name: L'organisation du génome existe-t-elle ?
ansible.builtin.uri:
url: "https://forge.{{ domaine_interne }}/api/v1/orgs/{{ serveur_ops_forge_organisation | default('genome') }}"
validate_certs: false
status_code: [200, 404]
# UNE LECTURE, DONC AUSSI EN SIMULATION : sans elle, `--check` sautait la sonde et la
# tache suivante echouait sur un statut absent (2026-10-14).
check_mode: false
register: genome_org
- name: Créer l'organisation du génome
ansible.builtin.uri:
url: "https://forge.{{ domaine_interne }}/api/v1/orgs"
method: POST
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
body_format: json
body:
username: "{{ serveur_ops_forge_organisation | default('genome') }}"
visibility: public
status_code: 201
no_log: true
when: genome_org.status == 404
- name: Quels dépôts poussés existent déjà sur la forge ?
ansible.builtin.uri:
url: "https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation | default('genome') }}/{{ item.depot }}"
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
no_log: true
check_mode: false
register: genome_depots_forge
# LA SIMULATION DIT CE QUE LA POUSSEE FERAIT, PUIS S'ARRETE (2026-10-14).
#
# Tout ce qui suit est `uri` en ecriture ou `command` : `--check` le saute, et le verdict
# final comparait alors une forge intacte a des colis jamais recus. On ne lit donc que la
# forge et le poste, on compare, et on rend la main avant la premiere ecriture.
- name: Simulation — la branche que porte la forge
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.item.depot }}/branches/{{ item.item.branche | default('main') }}
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_depots_forge.results }}"
loop_control:
label: "{{ item.item.depot }}"
register: genome_sim_forge
retries: 4
delay: 3
until: genome_sim_forge is succeeded
check_mode: false
no_log: true
when:
- ansible_check_mode
- item.status == 200
# `--left-right` : a gauche ce que la forge porte et que le poste n'a pas (le runner
# refusera, `--ff-only`), a droite ce qui serait pousse. rc 128 : le poste ne connait
# pas le commit de la forge — l'avance rapide est impossible a juger d'ici.
- name: Simulation — l'écart entre la forge et le poste
ansible.builtin.command:
argv:
- git
- -C
- "{{ genome_freres }}/{{ item.item.item.dest }}"
- rev-list
- --count
- --left-right
- "{{ item.json.commit.id }}...refs/heads/{{ item.item.item.branche | default('main') }}"
delegate_to: localhost
become: false
loop: "{{ genome_sim_forge.results }}"
loop_control:
label: "{{ item.item.item.depot }}"
register: genome_sim_ecart
check_mode: false
changed_when: false
failed_when: false
when:
- ansible_check_mode
- item.status | default(0) == 200
- name: Simulation — ce que la poussée ferait
vars:
genome_sim_lignes: >-
{%- set out = [] -%}
{%- if genome_org.status == 404 -%}
{%- set _ = out.append('organisation ' ~ (serveur_ops_forge_organisation | default('genome')) ~ ' : à créer') -%}
{%- endif -%}
{%- for r in genome_sim_ecart.results -%}
{%- set d = r.item.item.item.depot -%}
{%- if r.item.item.status == 404 -%}
{%- set _ = out.append(d ~ ' : dépôt à créer, puis tout poussé') -%}
{%- elif r.item.status | default(0) == 404 -%}
{%- set _ = out.append(d ~ ' : dépôt vide, tout poussé') -%}
{%- elif r.rc != 0 -%}
{%- set _ = out.append(d ~ ' : la forge porte ' ~ r.item.json.commit.id[:7]
~ ', inconnu du poste — avance rapide impossible à juger d ici') -%}
{%- else -%}
{%- set lr = r.stdout.split() | map('int') | list -%}
{%- if lr[0] > 0 -%}
{%- set _ = out.append(d ~ ' : la forge a ' ~ lr[0] ~ ' commit(s) absent(s) du poste — le runner REFUSERA (--ff-only)') -%}
{%- elif lr[1] == 0 -%}
{%- set _ = out.append(d ~ ' : à jour (' ~ r.item.json.commit.id[:7] ~ ')') -%}
{%- else -%}
{%- set _ = out.append(d ~ ' : ' ~ r.item.json.commit.id[:7] ~ ' → ' ~ lr[1] ~ ' commit(s) en avance rapide') -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{ out }}
ansible.builtin.debug:
msg: "{{ genome_sim_lignes }}"
when: ansible_check_mode
- name: Simulation — s'arrêter avant la première écriture
ansible.builtin.meta: end_host
when: ansible_check_mode
- name: Créer les dépôts qui manquent
ansible.builtin.uri:
url: "https://forge.{{ domaine_interne }}/api/v1/orgs/{{ serveur_ops_forge_organisation | default('genome') }}/repos"
method: POST
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
body_format: json
body:
name: "{{ item.item.depot }}"
private: "{{ item.item.role in (serveur_ops_roles_prives | default(['instance', 'tenant', 'hebergeur'])) }}"
default_branch: "{{ item.item.branche | default('main') }}"
auto_init: false
status_code: 201
loop: "{{ genome_depots_forge.results }}"
loop_control:
label: "{{ item.item.depot }}"
no_log: true
when: item.status == 404
- name: Lire la clé publique du runner
ansible.builtin.slurp:
src: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}.pub"
@ -336,7 +160,6 @@
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.dest }}"
register: genome_emballe
changed_when: true
- name: Porter les colis jusqu'au runner
@ -392,16 +215,11 @@
- name: Demander à la forge ce qu'elle porte maintenant
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
# répond. Lecture authentifiée depuis le 2026-10-11 (dépôts privés).
# répond, et la lecture est anonyme — aucun justificatif n'entre ici.
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
status_code: [200]
loop: "{{ genome_a_pousser }}"
@ -437,50 +255,6 @@
label: "{{ item.depot }}"
index_var: idx
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
# depot, ce qui est juste quand le poste n'en a pas non plus.
- name: Demander à la forge les étiquettes qu'elle porte
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/git/refs/tags
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_forge_etiquettes
retries: 4
delay: 3
until: genome_forge_etiquettes is succeeded
changed_when: false
- name: La forge porte-t-elle toutes les releases du poste ?
vars:
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
genome_etiquettes_forge: >-
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
ansible.builtin.assert:
that:
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
fail_msg: >-
{{ item.depot }} : la forge n'a pas
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
success_msg: >-
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
index_var: idx
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
#
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le

View file

@ -21,17 +21,13 @@
# vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja
# (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent
# (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge.
# LA CARTE DESIGNEE (2026-10-13) : `SETOPS_UNDERLAY` quand il est pose, le symlink sinon.
# Lire le symlink seul aurait clone les machines du site de TechnoLibre sur le cluster
# de Chezlepro, avec ses jetons — l'inventaire, lui, suivait deja la variable.
setops_carte_underlay: "{{ lookup('ansible.builtin.env', 'SETOPS_UNDERLAY') | default(playbook_dir ~ '/../../underlay.yml', true) }}"
setops_proxmox_hebergeur: "{{ setops_carte_underlay | realpath | dirname }}/proxmox-hebergeur.yml"
setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml"
# LA VOUTE DE L'UNDERLAY — les secrets du MONDE PHYSIQUE (jeton d'API du cluster).
# Meme derivation que le fichier ci-dessus : le symlink `underlay.yml` designe deja
# l'hebergeur, on n'a rien a redeclarer. Depuis le 2026-08-22 ces secrets ne vivent
# plus dans la voute d'un tenant : recopies chez chacun, ils ne pouvaient plus etre
# revoques isolement (docs/frontiere-physique-virtuel.md).
setops_voute_underlay: "{{ setops_carte_underlay | realpath | dirname }}/underlay.vault.yml"
setops_voute_underlay: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/underlay.vault.yml"
tasks:
- name: Verifier les fichiers de variables Proxmox optionnels

View file

@ -22,11 +22,7 @@
# vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja
# (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent
# (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge.
# LA CARTE DESIGNEE (2026-10-13) : `SETOPS_UNDERLAY` quand il est pose, le symlink sinon.
# Lire le symlink seul aurait clone les machines du site de TechnoLibre sur le cluster
# de Chezlepro, avec ses jetons — l'inventaire, lui, suivait deja la variable.
setops_carte_underlay: "{{ lookup('ansible.builtin.env', 'SETOPS_UNDERLAY') | default(playbook_dir ~ '/../../underlay.yml', true) }}"
setops_proxmox_hebergeur: "{{ setops_carte_underlay | realpath | dirname }}/proxmox-hebergeur.yml"
setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml"
# cibles_vmid : liste d'entiers, passée en -e (sinon dérivée des hôtes actifs).
cibles_vmid: "{{ groups['hotes_actifs'] | map('extract', hostvars, 'proxmox_vmid') | map('int') | list }}"

View file

@ -11,12 +11,6 @@
- name: Couche socle — groupe serveur_durci
import_playbook: groupes/serveur_durci.yml
# --- couche depot_etat ---
- name: Couche depot_etat — groupe serveur_backup
import_playbook: groupes/serveur_backup.yml
- name: Couche depot_etat — groupe serveur_backup_site
import_playbook: groupes/serveur_backup_site.yml
# --- couche pki_racine ---
- name: Couche pki_racine — groupe serveur_step_ca
import_playbook: groupes/serveur_step_ca.yml
@ -56,6 +50,8 @@
import_playbook: groupes/serveur_nginx.yml
- name: Couche services — groupe serveur_rspamd
import_playbook: groupes/serveur_rspamd.yml
- name: Couche services — groupe serveur_backup
import_playbook: groupes/serveur_backup.yml
- name: Couche services — groupe serveur_artefacts
import_playbook: groupes/serveur_artefacts.yml
- name: Couche services — groupe serveur_cache_site
@ -64,8 +60,8 @@
import_playbook: groupes/serveur_forge_site.yml
- name: Couche services — groupe serveur_resolveur_site
import_playbook: groupes/serveur_resolveur_site.yml
- name: Couche services — groupe serveur_relais
import_playbook: groupes/serveur_relais.yml
- name: Couche services — groupe serveur_backup_site
import_playbook: groupes/serveur_backup_site.yml
- name: Couche services — groupe serveur_dns_public
import_playbook: groupes/serveur_dns_public.yml
- name: Couche services — groupe serveur_resolveur

View file

@ -11,23 +11,11 @@
gather_facts: false
become: false
tasks:
# UN PROMETHEUS QUI VIENT DE REDEMARRER N'A PAS ENCORE RELEVE SES CIBLES (2026-10-08).
# Depuis qu'obs-01 est sauvegarde, son premier depot (copie a froid, en fin de
# deploiement) relance Prometheus : a la reconstruction de Technolibre, la recette l'a
# interroge 18 s apres, et deux cibles n'avaient pas encore de releve reussi — saines
# quelques minutes plus tard. On redemande donc, une minute au plus, avant de juger :
# une vraie panne echoue toujours, une minute plus tard.
- name: Interroger l'API des cibles actives (jusqu'a ce que toutes soient relevees, une minute au plus)
- name: Interroger l'API des cibles actives
ansible.builtin.uri:
url: "http://localhost:9090/api/v1/targets?state=active"
return_content: true
register: prom_targets
until: >-
((prom_targets.content | default('{}') | from_json).get('data', {}).get('activeTargets', [])
| rejectattr('health', 'equalto', 'up') | list | length == 0)
or (prom_targets.attempts | default(0) | int >= 6)
retries: 6
delay: 10
- name: Analyser up / down
vars:

View file

@ -45,7 +45,6 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
| `client_backup_repo` | `sftp:restic@<cible>:<hôte>` | Un sous-dossier par nœud |
| `client_backup_staging` | `/var/backups/setops` | Préparation des dumps |
| `client_backup_retention` | `--keep-daily 7 --keep-weekly 4 --keep-monthly 6` | `restic forget` |
| `client_backup_etiquette_avant_raser` | `avant-raser` | étiquette du dépôt d'avant rasage, gardée par la rétention jusqu'à la reconstruction suivante |
| `client_backup_horaire` | `*-*-* 02:30:00` | Timer systemd |
| `client_backup_jobs` | `[]` | **À déclarer par nœud** |
@ -59,8 +58,6 @@ Sans eux, le rôle refuse de s'exécuter (assertion).
- Restauration : **faite par la reconstruction elle-même** depuis le 2026-09-30 — chaque rôle
propriétaire inclut `tasks/restaurer.yml` et remet l'état de l'incarnation précédente ;
l'outil de nœud est `setops-restaurer` (cf. `docs/runbooks-exploitation.md` §5.0).
- Témoins : `setops-restaurer temoins` (comparateur `setops-temoins`) confronte l'état vivant
à l'instantané d'avant rasage ; `make temoins-etat`, et l'étape `bilan` de la reconstruction.
## Prérequis
- `serveur_backup` déployé, et sa `serveur_backup_pubkey` correspondant à la clé privée de

View file

@ -20,10 +20,6 @@ client_backup_staging: "/var/backups/setops"
# Rétention (restic forget).
client_backup_retention: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
# L'instantane d'avant rasage (`make sauvegarder-maintenant`) porte cette etiquette, et la
# retention la garde EN PLUS de la regle ci-dessus : jusqu'a la reconstruction suivante
# (decision de l'exploitant, 2026-10-07). Voir `templates/sauvegarder.sh.j2`.
client_backup_etiquette_avant_raser: "avant-raser"
# Planification (timer systemd).
client_backup_horaire: "*-*-* 02:30:00"
@ -83,39 +79,6 @@ client_backup_catalogue:
# PAS DE `serveur_web_frontal` (retire le 2026-09-30) : le frontal RELAIE, il ne sert
# rien lui-meme — ses vhosts, son WAF et sa page 404 se redeploient depuis le depot.
# `/srv/web` venait de l'epoque ou il servait du statique ; ses instantanes etaient vides.
# LES METRIQUES ET LES JOURNAUX (2026-10-07). Ils repartaient de zero a chaque
# reconstruction. Ils s'ecrivent en continu : une copie A FROID (service arrete le temps
# de copier, `setops-copie-a-froid`) les pose dans le repertoire de preparation, et c'est
# cette copie que restic emporte. `vivant` dit ou vit la base, pour la restauration et les
# temoins, qui la comparent a l'instantane.
serveur_prometheus:
nom: prometheus
vivant: "{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_prometheus_service | default('prometheus') }}
{{ serveur_prometheus_donnees | default('/var/lib/prometheus/metrics2') }}
{{ client_backup_staging }}/prometheus/metrics2 --blocs-immuables
chemins: ["{{ client_backup_staging }}/prometheus"]
serveur_loki:
nom: loki
vivant: "{{ serveur_loki_chemin | default('/var/lib/loki') }}"
commande: >-
/usr/local/sbin/setops-copie-a-froid
{{ serveur_loki_service | default('loki') }}
{{ serveur_loki_chemin | default('/var/lib/loki') }}
{{ client_backup_staging }}/loki/donnees --lier chunks --copier chunks/index
chemins: ["{{ client_backup_staging }}/loki"]
serveur_icinga:
# L'AC D'ICINGA FAIT L'IDENTITE DE SA BASE (2026-10-07). Icinga 2 derive l'environnement
# d'Icinga DB de la cle publique de son AC, et chaque identifiant d'hote, de service et
# d'historique de cet environnement. Recreee a chaque reconstruction, elle rendait
# l'historique restaure orphelin : on l'avait donc jete (decision du 2026-09-30, revue
# le 2026-10-07 — l'historique, c'est la disponibilite qu'on doit pouvoir prouver).
nom: icinga
chemins:
- "{{ (serveur_icinga_ca | default('/var/lib/icinga2/ca/ca.crt')) | dirname }}"
- /var/lib/icinga2/icingadb.env
serveur_web_dorsal:
nom: web_dorsal
chemins: ["{{ serveur_web_dorsal_racine | default('/srv/webapp') }}"]

View file

@ -1,84 +0,0 @@
#!/bin/bash
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
#
# setops-copie-a-froid SERVICE SOURCE DESTINATION [--blocs-immuables] [--lier REL]... [--copier REL]...
#
# Copie COHERENTE d'une base qui ecrit en continu (Prometheus, Loki) : le service est
# arrete le temps de la copie, puis redemarre — QUOI QU'IL ARRIVE a la copie, et seulement
# s'il tournait avant. La destination n'est remplacee qu'une fois la copie complete.
#
# POURQUOI A FROID (decision de l'exploitant, 2026-10-07 : « on ne prend pas de risque »).
# Copier a chaud un WAL qu'on ecrit, c'est sauvegarder un fichier a moitie ecrit. L'autre
# voie — ne garder que les blocs fermes — perdait jusqu'aux deux dernieres heures de
# metriques a chaque reconstruction. Mesure au site : 2,9 Go de Prometheus se lisent en
# 4,2 s, son WAL seul en 0,3 s ; Loki entier en 2 s.
#
# `--blocs-immuables` : les sous-dossiers nommes par un ULID (26 caracteres, `01…`) sont
# les blocs de Prometheus, que rien ne reecrit apres leur fermeture. On les recopie par
# LIENS DURS — instantane, sans place — et le reste (WAL, `chunks_head`) par une vraie
# copie. Si la destination est sur un autre systeme de fichiers, on copie tout.
#
# `--lier REL` lie tout un sous-dossier ; `--copier REL` y copie vraiment un sous-dossier qui,
# lui, bouge (2026-10-07). Pour Loki : `--lier chunks --copier chunks/index`. Ses morceaux sont
# adresses par leur contenu (`debut:fin:somme`, decode sur site-mon-01) et ne bougent jamais ;
# mais `chunks/index/` loge aussi `delete_requests.gz`, la base du compacteur, mesuree
# reecrite trois semaines apres sa naissance. La lier, c'etait laisser la sauvegarde changer
# avec elle. Copier 6 550 morceaux arretait Loki 13 s ; les lier prend 40 ms.
set -euo pipefail
service="${1:?service}"
source="${2:?source}"
dest="${3:?destination}"
shift 3
blocs="" lier=() copier=()
while (( $# )); do
case "$1" in
--blocs-immuables) blocs=1; shift ;;
--lier) lier+=("$2"); shift 2 ;;
--copier) copier+=("$2"); shift 2 ;;
*) echo "copie a froid : option inconnue : $1" >&2; exit 2 ;;
esac
done
[[ -d "${source}" ]] || { echo "copie a froid : ${source} n'existe pas — rien a copier." >&2; exit 0; }
tmp="${dest}.en-cours"
rm -rf "${tmp}"
mkdir -p "$(dirname "${dest}")"
actif=0
if systemctl is-active --quiet "${service}"; then actif=1; fi
relancer() { if (( actif )); then systemctl start "${service}"; fi; }
debut=$(date +%s%N)
if (( actif )); then systemctl stop "${service}"; fi
trap relancer EXIT
lie() { cp -al "$1" "$2" 2>/dev/null || { rm -rf "$2"; cp -a "$1" "$2"; }; }
mkdir -p "${tmp}"
for e in "${source}"/* "${source}"/.[!.]*; do
[[ -e "${e}" ]] || continue
n="$(basename "${e}")"
if [[ -n "${blocs}" && -d "${e}" && ${#n} -eq 26 && "${n}" == 01* ]] \
|| [[ " ${lier[*]:-} " == *" ${n} "* ]]; then
lie "${e}" "${tmp}/${n}"
else
cp -a "${e}" "${tmp}/${n}"
fi
done
# Ce qui bouge, a l'interieur d'un dossier lie : remplace par une vraie copie.
for c in "${copier[@]:+${copier[@]}}"; do
if [[ -e "${source}/${c}" ]]; then
rm -rf "${tmp:?}/${c}"
mkdir -p "$(dirname "${tmp}/${c}")"
cp -a "${source}/${c}" "${tmp}/${c}"
fi
done
trap - EXIT
relancer
fin=$(date +%s%N)
rm -rf "${dest}"
mv "${tmp}" "${dest}"
echo "copie a froid de ${source} : ${service} $( (( actif )) && echo "arrete (jusqu a la relance, rejeu du WAL non compris) $(( (fin - debut) / 1000000 )) ms" || echo "n'etait pas actif")"

View file

@ -1,432 +0,0 @@
#!/usr/bin/env python3
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
VERDICTS, PAR JEU :
IDENTIQUE rien ne diffère ;
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
perdue ; des métriques qui ne couvrent plus l'avant-rasage ; des journaux
dont aucun morceau d'avant ne subsiste. Exactement ce que la reconstruction du
2026-09-30 avait perdu sans que rien ne le dise.
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
"""
from __future__ import annotations
import argparse
import base64
import collections
import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
"creatorsname", "contextcsn"}
class Rapport:
def __init__(self, jeu: str) -> None:
self.jeu = jeu
self.ecarts: list[str] = [] # pertes et identités changées
self.changes: list[str] = [] # changements ordinaires
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
self.nouveaux: list[str] = []
self.compte = 0 # objets comparés
def verdict(self) -> str:
if self.ecarts:
return "ECART"
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
def lignes(self, unite: str) -> list[str]:
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
("retire", self.retires), ("nouveau", self.nouveaux)):
for x in liste[:LISTE_MAX]:
out.append(f" {titre:<8} {x}")
if len(liste) > LISTE_MAX:
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
return out
def empreinte(p: Path) -> str:
h = hashlib.sha256()
with p.open("rb") as f:
for bloc in iter(lambda: f.read(1 << 20), b""):
h.update(bloc)
return h.hexdigest()
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
if racine.is_file():
return {racine.name: empreinte(racine)}
out: dict[str, str] = {}
if not racine.is_dir():
return out
for p in sorted(racine.rglob("*")):
if p.is_file() and not p.is_symlink():
rel = p.relative_to(racine).as_posix()
out[normaliser(rel) if normaliser else rel] = empreinte(p)
return out
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
strict, perte_stricte) -> None:
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
r.compte += len(avant)
for rel, h in sorted(avant.items()):
if rel not in vivant:
if perte_stricte(rel):
r.ecarts.append(f"perdu : {rel}")
else:
r.retires.append(rel)
elif vivant[rel] != h:
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
def jamais(_rel: str) -> bool:
return False
def toujours(_rel: str) -> bool:
return True
# --- Les jeux de fichiers ------------------------------------------------------------
def maildir(rel: str) -> str:
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
return re.sub(r":2,[A-Za-z]*$", "", rel)
def est_message(rel: str) -> bool:
return "/msg/" in f"/{rel}"
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
r = Rapport(jeu)
for c in chemins:
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
if jeu == "step_ca":
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
perte_stricte=identite)
elif jeu == "icinga":
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
perte_stricte=identite)
elif jeu == "courriel":
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
strict=jamais, perte_stricte=est_message)
elif jeu == "rspamd":
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
elif jeu == "nextcloud":
if c.rstrip("/").endswith("/config"):
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=jamais)
else:
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=fichier)
else:
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
perte_stricte=toujours)
return r
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
def lire(p: Path) -> str:
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
return m.group(1) if m else ""
a, v = lire(avant), lire(vivant)
if a and a != v:
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
# --- Les bases copiees a froid : Prometheus et Loki (2026-10-07) ----------------------
#
# Leurs FICHIERS ne se comparent pas : Prometheus fusionne ses blocs, Loki compacte son
# index, et la retention (15 j, 31 j) en retire chaque jour. On compare ce qui compte.
def blocs_prometheus(racine: Path) -> list[tuple[int, int]]:
out = []
for meta in racine.glob("01*/meta.json") if racine.is_dir() else []:
try:
m = json.loads(meta.read_text())
out.append((int(m["minTime"]), int(m["maxTime"])))
except (ValueError, KeyError):
continue
return sorted(out)
def segments_wal(racine: Path) -> list[str]:
return sorted(p.name for p in (racine / "wal").glob("[0-9]*")) if (racine / "wal").is_dir() else []
def jeu_prometheus(avant: Path, vivant: Path) -> Rapport:
"""Les métriques vivantes couvrent-elles encore l'avant-rasage ?
Le repère : le DÉBUT du bloc le plus récent de l'instantané — quelques heures avant le
rasage. Un bloc vivant doit couvrir cet instant. La rétention ne l'a pas retiré (il a
moins de 15 jours) ; une base qui n'a pas été restaurée ne peut pas le couvrir : elle
est née après la reconstruction. Une base de moins de deux heures n'a pas encore de
bloc : son WAL fait alors le repère, et doit se retrouver dans la base vivante, ou y
avoir été fermé en bloc.
"""
r = Rapport("prometheus")
a, v = blocs_prometheus(avant), blocs_prometheus(vivant)
r.compte = len(a)
if a:
t = max(debut for debut, _ in a)
if not any(debut <= t < fin for debut, fin in v):
r.ecarts.append(f"aucun bloc vivant ne couvre {t} (debut du dernier bloc d'avant) : "
f"metriques d'avant non restaurees")
if v and min(d for d, _ in v) > min(d for d, _ in a):
r.changes.append(f"plus ancienne donnee : {min(d for d, _ in a)} -> {min(d for d, _ in v)} (retention)")
else:
wal = segments_wal(avant)
if wal and not v and wal[0] not in segments_wal(vivant):
r.ecarts.append(f"le WAL d'avant ({wal[0]}...) n'est pas dans la base vivante, et elle n'a "
f"aucun bloc : metriques d'avant non restaurees")
if len(v) != len(a):
r.changes.append(f"{len(a)} bloc(s) avant, {len(v)} maintenant (fusions, retention, nouveaux)")
return r
def jeu_loki(avant: Path, vivant: Path) -> Rapport:
"""Des morceaux de journal d'avant subsistent-ils ? Aucun : non restaurés.
La rétention (31 j) en retire chaque jour : une perte PARTIELLE est listée. Une perte
TOTALE, elle, ne vient que d'une base qui n'a pas été remise.
"""
r = Rapport("loki")
# LES MORCEAUX, PAS L'INDEX (2026-10-08). `chunks/index/` loge l'index TSDB et la base du
# compacteur : il les fusionne et les remplace. A la reconstruction de Technolibre, trois
# de ces fichiers « d'avant » manquaient, et le temoin les disait retires « (retention ?) »
# — aucun journal ne l'etait (Loki rendait ceux d'avant le rasage, minute par minute).
morceaux = lambda racine: {rel for rel in arbre(racine / "chunks") if not rel.startswith("index/")} # noqa: E731
a = morceaux(avant)
v = morceaux(vivant)
r.compte = len(a)
if a and not (a & v):
r.ecarts.append(f"aucun des {len(a)} morceau(x) de journal d'avant ne subsiste : journaux non restaures")
elif a - v:
r.retires.append(f"{len(a - v)} morceau(x) d'avant retire(s) (retention ?)")
if v - a:
r.nouveaux.append(f"{len(v - a)} morceau(x) nouveau(x)")
return r
# --- L'annuaire ----------------------------------------------------------------------
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
out: dict[str, dict[str, list[str]]] = {}
for bloc in deplie.split("\n\n"):
attrs: dict[str, list[str]] = {}
dn = ""
for ligne in bloc.splitlines():
if not ligne or ligne.startswith("#") or ":" not in ligne:
continue
nom, _, val = ligne.partition(":")
if val.startswith(":"):
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
else:
val = val.strip()
if nom.lower() == "dn":
dn = val.lower()
elif nom.lower() not in OPERATIONNELS:
attrs.setdefault(nom.lower(), []).append(val)
if dn:
out[dn] = {k: sorted(v) for k, v in attrs.items()}
return out
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
r = Rapport("openldap")
if not avant_ldif.is_file():
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
return r
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
r.compte = len(a)
for dn, attrs in sorted(a.items()):
if dn not in v:
r.ecarts.append(f"perdue : {dn}")
continue
if attrs == v[dn]:
continue
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
# remis à la valeur d'amorçage sans que rien ne le dise.
if attrs.get("userpassword") != v[dn].get("userpassword"):
r.ecarts.append(f"IDENTITE userPassword : {dn}")
diff = sorted(k for k in set(attrs) | set(v[dn])
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
if diff:
r.changes.append(f"{dn} ({', '.join(diff)})")
r.nouveaux.extend(sorted(set(v) - set(a)))
return r
# --- PostgreSQL ----------------------------------------------------------------------
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
cles: dict[tuple[str, str, str], list[str]] = {}
base, table = "", None
for ligne in texte.splitlines():
if table is not None:
if ligne == "\\.":
table = None
else:
cles[table].append(ligne.split("\t", 1)[0])
elif ligne.startswith("\\connect "):
base = ligne.split()[-1]
else:
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
if m:
table = (base, m.group(1), m.group(2).strip())
cles[table] = []
return bases, roles, cles
def psql(base: str, sql: str) -> list[str] | None:
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
return r.stdout.splitlines() if r.returncode == 0 else None
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
ajoute, on n'y retire pas.
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
"""
r = Rapport("postgresql")
if not dump.is_file():
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
return r
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
for b in sorted(bases - vivantes):
r.ecarts.append(f"base perdue : {b}")
for x in sorted(roles - roles_vivants):
r.ecarts.append(f"role perdu : {x}")
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
for (b, t, col), avant in sorted(cles.items()):
if b not in vivantes or not avant:
continue
r.compte += 1
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
if vivant is None:
r.ecarts.append(f"table perdue : {b}.{t}")
continue
a, v = set(avant), set(vivant)
communes = len(a & v)
par_base[b][0] += len(a)
par_base[b][1] += communes
perdues, nouvelles = len(a) - communes, len(v - a)
if perdues and "history" in t:
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
elif perdues or nouvelles:
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
for b, (n, communes) in sorted(par_base.items()):
if n and not communes:
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
return r
# --- Programme -----------------------------------------------------------------------
def main(argv: list[str] | None = None, interroger=psql) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
ap.add_argument("--instantane", required=True)
ap.add_argument("--heure", default="")
ap.add_argument("--hote", default="")
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
ap.add_argument("--vivant", action="append", default=[],
help="NOM=CHEMIN : ou vit la base d'un jeu copie a froid")
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
ap.add_argument("--ldif-vivant", type=Path, default=None)
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
a = ap.parse_args(argv)
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
if not a.jeu:
print(" SANS OBJET : ce noeud ne detient aucun etat.")
return 0
ecart = False
vivants = dict(v.partition("=")[::2] for v in a.vivant)
for spec in a.jeu:
nom, _, chemins = spec.partition("=")
# La copie a froid est le SEUL sous-dossier de la preparation du jeu.
copie = lambda: next((d for d in sorted((a.avant / chemins.split(":")[0].lstrip("/")).glob("*")) # noqa: E731
if d.is_dir()), a.avant / "absent")
if nom == "prometheus" and nom in vivants:
r, unite = jeu_prometheus(copie(), Path(vivants[nom])), "bloc(s)"
elif nom == "loki" and nom in vivants:
r, unite = jeu_loki(copie(), Path(vivants[nom])), "morceau(x)"
elif nom == "openldap":
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
elif nom == "postgresql":
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
else:
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
print("\n".join(r.lignes(unite)))
ecart = ecart or bool(r.ecarts)
print("ECART : une perte, ou une identite qui a change." if ecart
else "Aucun ecart : rien de perdu, l'identite est intacte.")
return 1 if ecart else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -66,13 +66,3 @@
owner: root
group: root
mode: "0700"
# LES TEMOINS (2026-10-07) : `setops-restaurer temoins` extrait l'instantane d'avant rasage,
# puis ce comparateur le confronte a l'etat vivant. Lecture seule.
- name: Déployer le comparateur des témoins (setops-temoins)
ansible.builtin.copy:
src: setops-temoins.py
dest: /usr/local/sbin/setops-temoins
owner: root
group: root
mode: "0700"

View file

@ -56,17 +56,6 @@
group: root
mode: "0700"
# LA COPIE A FROID (2026-10-07) : les jeux qui s'ecrivent en continu (Prometheus, Loki)
# arretent leur service le temps de copier. L'outil est deploye avec le script qui l'appelle.
- name: Déployer l'outil de copie à froid
when: client_backup_jobs | length > 0
ansible.builtin.copy:
src: setops-copie-a-froid.sh
dest: /usr/local/sbin/setops-copie-a-froid
owner: root
group: root
mode: "0700"
- name: Déployer l'unité et le timer systemd
when: client_backup_jobs | length > 0
ansible.builtin.template:
@ -78,7 +67,6 @@
loop:
- { s: setops-sauvegarde.service.j2, d: setops-sauvegarde.service }
- { s: setops-sauvegarde.timer.j2, d: setops-sauvegarde.timer }
- { s: setops-sauvegarde-avant-raser.service.j2, d: setops-sauvegarde-avant-raser.service }
- name: Activer le timer de sauvegarde
when:
@ -127,10 +115,8 @@
state: absent
loop:
- /usr/local/sbin/setops-sauvegarder.sh
- /usr/local/sbin/setops-copie-a-froid
- /etc/systemd/system/setops-sauvegarde.service
- /etc/systemd/system/setops-sauvegarde.timer
- /etc/systemd/system/setops-sauvegarde-avant-raser.service
- /usr/local/sbin/setops-verifier-mon-depot.sh
- /usr/local/sbin/setops-verifier-restauration.sh
- /etc/systemd/system/setops-verification-depot.service

View file

@ -28,17 +28,12 @@
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
# --depuis CHEMIN_DANS_L_INSTANTANE (un seul CHEMIN) : ce qui a ete sauvegarde
# ailleurs que la ou ca vit (copie a froid)
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
# --essai (slapadd -u a blanc, rien n'est touche)
# base [opts] NOM rejouer une base depuis le pg_dumpall
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
# garde (pour la sauvegarde) refuser si un etat attend
# temoins [--candidat | --instantane ID] l'etat vivant contre l'instantane d'avant rasage
# (par defaut le dernier etiquete « avant-raser ») ; lecture seule ; code 1 = ecart,
# 4 = rien a comparer
# Options communes : --instantane ID.
set -euo pipefail
@ -57,12 +52,6 @@ CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
# Ou vit la base des jeux copies a froid (Prometheus, Loki) : leur instantane porte la
# copie, pas la base. Les temoins comparent l'une a l'autre.
declare -A VIVANT_JEU=({% for j in client_backup_jobs if j.vivant is defined %} ["{{ j.nom }}"]="{{ j.vivant }}"{% endfor %} )
dire() { printf '%s\n' "$*" >&2; }
mourir() { dire "setops-restaurer: $*"; exit 1; }
@ -105,27 +94,6 @@ if c:
' "$(naissance)" "${impose}"
}
# « ID HEURE » du dernier instantane etiquete avant rasage, ou rien ; code 2 = depot illisible.
avant_raser() {
local json rc
set +e
json=$(restic snapshots --host "${MOI}" --tag "${ETIQUETTE_AVANT_RASER}" --json 2>/dev/null)
rc=$?
set -e
if (( rc == 10 )); then return 0; fi
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
printf '%s' "${json}" | python3 -c '
import json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
if s:
print(s[-1]["short_id"], s[-1]["time"][:19])
'
}
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
INSTANTANE=""
resoudre_instantane() {
@ -159,28 +127,22 @@ vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
}
cmd_fichiers() {
local proprio="" remplacer=0 vers="" depuis="" chemins=() src dest n
local proprio="" remplacer=0 vers="" chemins=() src dest n
while (( $# )); do
case "$1" in
--proprietaire) proprio="$2"; shift 2 ;;
--remplacer) remplacer=1; shift ;;
--vers) vers="$2"; shift 2 ;;
--depuis) depuis="$2"; shift 2 ;;
--instantane) INSTANTANE="$2"; shift 2 ;;
*) chemins+=("$1"); shift ;;
esac
done
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
# LA COPIE A FROID (2026-10-07) : l'instantane porte la copie posee dans le repertoire de
# preparation, pas la base. `--depuis` dit ou la prendre ; le CHEMIN, ou la remettre.
# (Un second element, plutot qu'une longueur : la sequence accolade-diese ouvre un
# commentaire Jinja dans ce gabarit.)
if [[ -n "${depuis}" && -n "${chemins[1]:-}" ]]; then mourir "fichiers --depuis : un seul chemin"; fi
resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
if [[ -n "${depuis}" ]]; then extraire "${depuis}"; else extraire "${chemins[@]}"; fi
extraire "${chemins[@]}"
for c in "${chemins[@]}"; do
src="${TMP}${depuis:-${c}}"
src="${TMP}${c}"
dest="${vers}${c}"
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
if ! vide "${dest}"; then
@ -312,79 +274,24 @@ cmd_choisir() {
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
}
# LE CANDIDAT EST CALCULE MAINTENANT (2026-10-07). Il dit ce qu'une restauration lancee a
# cette minute choisirait, pas ce qui a ete restaure : au bilan de M4, il nommait
# l'instantane de la veille, et l'on a cru un moment que la journee etait perdue. Ce qui a
# ete restaure est dans le marqueur de chaque jeu ; on dit desormais s'il est encore au depot.
cmd_etat() {
local c a ids id present
local c
echo "hote : ${MOI}"
echo "naissance : $(date -d "@$(naissance)" -Is)"
c=$(candidat "") || exit 2
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
# guillemet ouvrant, et tout le script devient illisible.)
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
echo "candidat : ${c} (ce que choisirait une restauration lancee maintenant)"
a=$(avant_raser) || exit 2
echo "avant-raser: ${a:-aucun}"
ids=$(restic snapshots --host "${MOI}" --json 2>/dev/null \
| python3 -c 'import json, sys; print(" ".join(x["short_id"] for x in json.load(sys.stdin) or []))' \
2>/dev/null || true)
echo "candidat : ${c}"
for j in "${JEUX[@]}"; do
if [[ -f "${MARQUES}/${j}" ]]; then
id=$(sed -n 's/^instantane=//p' "${MARQUES}/${j}")
present=""
if [[ -n "${id}" ]]; then
if [[ " ${ids} " == *" ${id} "* ]]; then present="(au depot)"; else present="(RETIRE du depot)"; fi
fi
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")${present}"
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")"
else
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
fi
done
}
# LES TEMOINS (2026-10-07). L'etat vivant est-il celui d'avant le rasage ? On extrait
# l'instantane d'avant (etiquete, sinon le candidat ou celui qu'on impose), on lit
# l'annuaire vivant, et `setops-temoins` compare. Rien n'est ecrit hors du repertoire jetable.
cmd_temoins() {
local source="avant-raser" c heure base chemins=() args=()
while (( $# )); do
case "$1" in
--candidat) source="candidat"; shift ;;
--instantane) INSTANTANE="$2"; source="impose"; shift 2 ;;
*) mourir "temoins : option inconnue : $1" ;;
esac
done
if [[ -z "${JEUX[*]:-}" ]]; then echo "SANS OBJET : ${MOI} ne detient aucun etat."; exit 4; fi
case "${source}" in
avant-raser) c=$(avant_raser) || exit 2 ;;
candidat) c=$(candidat "") || exit 2 ;;
impose) c=$(candidat "${INSTANTANE}") || exit 2 ;;
esac
if [[ -z "${c}" ]]; then echo "SANS OBJET : aucun instantane ${source} pour ${MOI}."; exit 4; fi
INSTANTANE="${c%% *}"
heure="${c#* }"
for j in "${JEUX[@]}"; do
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
chemins+=("${morceaux[@]}")
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
if [[ -n "${VIVANT_JEU[${j}]:-}" ]]; then args+=(--vivant "${j}=${VIVANT_JEU[${j}]}"); fi
done
extraire "${chemins[@]}"
if [[ -s "${TMP}${LDIF}" ]]; then
base=$(sed -n 's/^dn: //p' "${TMP}${LDIF}" | head -n 1)
slapcat -b "${base}" > "${TMP}/vivant-annuaire.ldif" || mourir "slapcat ${base} a echoue"
args+=(--ldif "${LDIF}" --ldif-vivant "${TMP}/vivant-annuaire.ldif")
fi
set +e
/usr/local/sbin/setops-temoins --avant "${TMP}" --instantane "${INSTANTANE}" \
--heure "${heure}" --hote "${MOI}" --dump "${DUMP_PG}" "${args[@]}"
local rc=$?
set -e
exit "${rc}"
}
cmd_garde() {
local attente=() c
for j in "${JEUX[@]}"; do
@ -412,6 +319,5 @@ case "${sous}" in
base) cmd_base "$@" ;;
acter) cmd_acter "$@" ;;
garde) cmd_garde ;;
temoins) cmd_temoins "$@" ;;
*) mourir "sous-commande inconnue : ${sous}" ;;
esac

View file

@ -11,18 +11,6 @@ export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
# L'INSTANTANE D'AVANT RASAGE (2026-10-07). `--avant-raser` (unite
# `setops-sauvegarde-avant-raser`, lancee par `make sauvegarder-maintenant`) etiquette le
# depot « {{ client_backup_etiquette_avant_raser }} », et la retention garde cette etiquette.
# Sans elle, le premier depot de la machine reconstruite, le meme jour, chassait l'instantane
# d'avant par `--keep-daily` : apres la reconstruction de Technolibre (M4), il ne restait
# rien a quoi comparer l'etat remis. Un seul porte l'etiquette, le dernier : il est garde
# jusqu'a la reconstruction suivante, qui la lui retire.
AVANT_RASER=0
if [[ "${1:-}" == "--avant-raser" ]]; then AVANT_RASER=1; fi
ETIQUETTES=(--tag setops)
if (( AVANT_RASER )); then ETIQUETTES+=(--tag "{{ client_backup_etiquette_avant_raser }}"); fi
# 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30)
# Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni
# remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un
@ -72,24 +60,7 @@ if (( N_PRESENTS == 0 )); then
echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2
exit 0
fi
restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}"
# 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans
# la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde.
if (( AVANT_RASER )); then
ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \
| python3 -c 'import json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
print(" ".join(x["id"] for x in s[:-1]))')
if [[ -n "${ANCIENS}" ]]; then
read -r -a ANCIENS_IDS <<< "${ANCIENS}"
restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}"
fi
fi
restic backup --host "{{ inventory_hostname }}" --tag setops "${PRESENTS[@]}"
# 4. Rétention.
restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune
restic forget {{ client_backup_retention }} --prune

View file

@ -1,13 +0,0 @@
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
# Le depot d'avant rasage : etiquete, garde jusqu'a la reconstruction suivante.
# Lance par `make sauvegarder-maintenant` (playbooks/backup/restauration.yml), jamais par un minuteur.
[Unit]
Description=Sauvegarde Set-OPS d'avant rasage (restic hors-nœud, etiquetee)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setops-sauvegarder.sh --avant-raser
Nice=10
IOSchedulingClass=idle

View file

@ -56,17 +56,9 @@ rapporter() { # $1=code $2=texte
maintenant=$(date +%s)
# LE PLUS RECENT DE TOUS LES GROUPES, PAS LE PREMIER (2026-10-08). `--latest 1` rend le
# dernier instantane de CHAQUE groupe — machine ET liste de chemins. Ajouter un jeu change la
# liste : deux groupes, et `d[0]` etait l'ancien, fige. Sur site-mon-01, des le lendemain de
# l'arrivee de prometheus, loki et icinga : « EN RETARD : 26 h » sur une sauvegarde de 2 h 30,
# et « PERIME » promis a 50 h. On prend donc le plus recent de tous.
json=$(restic snapshots --latest 1 --json 2>/dev/null)
horodatage=$(printf '%s' "${json}" | python3 -c \
'import sys,json,re,datetime
d=json.load(sys.stdin) or []
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
if [[ -z "${horodatage}" ]]; then
# ON NE DISTINGUE PAS ICI « depot vide » de « depot injoignable », et c'est voulu :
# dans les deux cas il n'y a PAS de sauvegarde exploitable, et c'est ce qui compte.

View file

@ -20,6 +20,3 @@ client_backup_groupes_etat:
- serveur_nextcloud
- serveur_rspamd
- serveur_web_dorsal
- serveur_icinga
- serveur_prometheus
- serveur_loki

View file

@ -8,12 +8,7 @@ client_journal_config: "/etc/alloy/config.alloy"
# Point de collecte Loki (obs-01, serveur_loki, VLAN 14).
# Hôte Loki dérivé du groupe serveur_loki (FQDN via le plancher) ; override par instance au besoin.
# LE SCHEMA SUIT L'INTERRUPTEUR TLS (2026-10-12) — meme defaut que Grafana le 2026-09-28 :
# une URL `http://` ecrite a cote d'un `client_journal_loki_tls: true` parlerait en clair a
# un Loki qui n'ecoute qu'en HTTPS.
client_journal_loki_url: >-
{{ 'https' if client_journal_loki_tls | bool else 'http' }}://{{
(groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push
client_journal_loki_url: "http://{{ (groups.get('serveur_loki') or ['obs-01']) | first }}.{{ domaine_interne }}:3100/loki/api/v1/push"
# Depot apt officiel Grafana (partage avec serveur_loki / serveur_grafana).
client_journal_depot_cle_url: "{{ client_journal_depot_schema }}://apt.grafana.com/gpg-full.key"
@ -23,16 +18,10 @@ client_journal_depot_source: "deb [signed-by=/etc/apt/keyrings/grafana.asc] {{ c
# --- TLS (zero-confiance) : Alloy pousse vers Loki en https + verifie root_ca ---
client_journal_loki_tls: false
client_journal_loki_ca: "/etc/step/certs/root_ca.crt"
# UNE MACHINE SANS PKI PEUT QUAND MEME VERIFIER (2026-10-12). Un hyperviseur n'a pas
# `client_pki` : aucune racine sous /etc/step. Pour verifier Loki, il lui suffit du
# certificat PUBLIC de l'autorite — pas d'un certificat a lui. Non vide, ce contenu est
# pose a `client_journal_loki_ca` (au site, `ac-racine-site.crt` lu par l'inventaire).
client_journal_loki_ca_contenu: ""
# Port de l'interface HTTP locale d'Alloy (diagnostic). DECLARE ici parce que son defaut
# amont — 12345 — entrait en collision avec `serveur_dovecot_sasl_port`, qui valait 12345
# lui aussi (SASL reseau, pour la soumission :587 ; retire le 2026-10-12 — il passait en
# clair). Le port reste declare : le defaut amont, lui, n'a pas change.
# amont — 12345 — entre en collision avec `serveur_dovecot_sasl_port`, qui vaut 12345 lui
# aussi et qui est un choix DELIBERE de Set-OPS (SASL reseau, pour la soumission :587).
#
# Sur `infra-mail-01`, les deux cohabitent : le premier demarre gagne, l'autre boucle sur
# « bind: address already in use ». Constate le 2026-08-08 lors de la premiere
@ -92,17 +81,6 @@ client_journal_syslog_protocole: "tcp"
# du site — jamais fabrique ici.
client_journal_syslog_emetteur: "frontiere"
# LE RECEPTEUR SYSLOG EN TLS, AUTHENTIFIANT SES CLIENTS (2026-10-12). Le syslog de la
# frontiere passait en clair. Vrai : Alloy sert le certificat de la machine (client_pki,
# recopie la ou `alloy` le lit, et a chaque renouvellement) et EXIGE un certificat client
# signe par une autorite de `client_journal_syslog_clients_ca_contenu` — au site, celle de
# la frontiere seule : personne d'autre ne peut y ecrire.
client_journal_syslog_tls: false
client_journal_syslog_tls_dir: "/etc/alloy/tls"
client_journal_syslog_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
client_journal_syslog_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
client_journal_syslog_clients_ca_contenu: ""
# LE JOURNAL D'AUDIT, EXPEDIE COMME LE RESTE (2026-09-11).
#
# DERIVE DE L'APPARTENANCE AU GROUPE, pas declare par instance : `auditd` vient avec

View file

@ -184,104 +184,6 @@
append: true
notify: Redemarrer alloy
# --- RECEPTEUR SYSLOG EN TLS (2026-10-12) --------------------------------------------
# Meme pont que node_exporter, Loki, Dovecot : le certificat de la machine, recopie la ou le
# service le lit, recopie a chaque renouvellement. Avant la configuration : Alloy redemarre
# avec des fichiers deja en place.
- name: Syslog TLS — repertoire des certificats (alloy)
ansible.builtin.file:
path: "{{ client_journal_syslog_tls_dir }}"
state: directory
owner: "{{ client_journal_utilisateur }}"
group: "{{ client_journal_utilisateur }}"
mode: "0700"
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- name: Syslog TLS — script de synchronisation du certificat
ansible.builtin.template:
src: setops-alloy-cert-sync.sh.j2
dest: /usr/local/sbin/setops-alloy-cert-sync
owner: root
group: root
mode: "0755"
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- name: Syslog TLS — unites de synchronisation (service + path)
ansible.builtin.template:
src: "{{ item.s }}"
dest: "{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: "setops-alloy-cert-sync.service.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.service" }
- { s: "setops-alloy-cert-sync.path.j2", d: "/etc/systemd/system/setops-alloy-cert-sync.path" }
loop_control:
label: "{{ item.d | basename }}"
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- name: Syslog TLS — activer la surveillance du certificat (path)
ansible.builtin.systemd:
name: setops-alloy-cert-sync.path
enabled: true
state: started
daemon_reload: true
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- not ansible_check_mode
- name: Syslog TLS — synchroniser le certificat maintenant
ansible.builtin.command: /usr/local/sbin/setops-alloy-cert-sync
changed_when: false
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- not ansible_check_mode
# SEULS CES EMETTEURS PEUVENT ECRIRE. Sans certificat d'autorite, le recepteur n'accepterait
# aucun client : on refuse de deployer plutot que d'eteindre la journalisation de la
# frontiere en silence. Un CERTIFICAT, pas seulement une valeur non vide — et cette forme-la
# echappe a P32, qui ne lit pas les `when:` et aurait exige la variable de chaque locataire.
- name: Syslog TLS — exiger les autorites des emetteurs admis
ansible.builtin.assert:
that:
- client_journal_syslog_clients_ca_contenu is search('BEGIN CERTIFICATE')
fail_msg: >-
client_journal_syslog_tls est vrai, mais aucune autorite d'emetteur n'est fournie
(client_journal_syslog_clients_ca_contenu) : le recepteur refuserait tout le monde.
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- name: Syslog TLS — poser les autorites des emetteurs admis
ansible.builtin.copy:
content: "{{ client_journal_syslog_clients_ca_contenu }}"
dest: "{{ client_journal_syslog_tls_dir }}/clients.crt"
owner: "{{ client_journal_utilisateur }}"
group: "{{ client_journal_utilisateur }}"
mode: "0644"
notify: Redemarrer alloy
when:
- client_journal_syslog_ecoute | length > 0
- client_journal_syslog_tls | bool
- name: Poser l'autorite qui signe Loki (machine sans PKI)
ansible.builtin.copy:
content: "{{ client_journal_loki_ca_contenu }}"
dest: "{{ client_journal_loki_ca }}"
owner: root
group: root
mode: "0644"
notify: Redemarrer alloy
when: client_journal_loki_ca_contenu | length > 0
- name: Deployer la configuration Alloy
ansible.builtin.template:
src: config.alloy.j2
@ -292,17 +194,12 @@
notify: Redemarrer alloy
# Le port d'ecoute est IMPOSE ici plutot que subi : voir defaults/main.yml.
#
# AUCUN RAPPORT D'USAGE (mesure du 2026-10-10). Par defaut, Alloy envoie a
# stats.grafana.org la liste de ses composants actifs : relevé a la frontiere depuis
# CHAQUE machine des trois ecosystemes, ~70 connexions par jour et par locataire. Il
# passait par la sortie 443 generale du socle, qui n'existe plus.
- name: Imposer le port de l interface locale d Alloy, sans rapport d usage
- name: Imposer le port de l interface locale d Alloy
ansible.builtin.lineinfile:
path: /etc/default/alloy
regexp: "^CUSTOM_ARGS="
line: >-
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }} --disable-reporting"
CUSTOM_ARGS="--server.http.listen-addr={{ client_journal_http_adresse }}:{{ client_journal_http_port }}"
create: false
notify: Redemarrer alloy

View file

@ -141,16 +141,6 @@ loki.source.syslog "frontiere" {
protocol = "{{ client_journal_syslog_protocole }}"
label_structured_data = true
labels = { job = "syslog", host = "{{ client_journal_syslog_emetteur }}" }
{% if client_journal_syslog_tls | bool %}
// TLS, ET UN CERTIFICAT CLIENT EXIGE : un `ca_file` suffit a l'exiger dans ce
// recepteur (`client_auth_type` n'y est pas accepte — `alloy validate`, 2026-10-12).
tls_config {
cert_file = "{{ client_journal_syslog_tls_dir }}/alloy.crt"
key_file = "{{ client_journal_syslog_tls_dir }}/alloy.key"
ca_file = "{{ client_journal_syslog_tls_dir }}/clients.crt"
min_version = "TLS12"
}
{% endif %}
}
forward_to = [loki.relabel.syslog.receiver]
}

View file

@ -1,10 +0,0 @@
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
[Unit]
Description=Set-OPS — surveille le certificat step_ca du recepteur syslog d'Alloy
[Path]
PathChanged={{ client_journal_syslog_tls_source_cert }}
Unit=setops-alloy-cert-sync.service
[Install]
WantedBy=multi-user.target

View file

@ -1,7 +0,0 @@
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
[Unit]
Description=Set-OPS — synchronise le certificat step_ca vers le recepteur syslog d'Alloy
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setops-alloy-cert-sync

View file

@ -1,18 +0,0 @@
#!/bin/sh
# Gere par Set-OPS (role client_journal). Ne pas editer a la main.
# Recopie le certificat step_ca (client_pki, root:root) la ou `alloy` le lit, pour le
# recepteur syslog en TLS. Idempotent ; appele au deploiement ET au renouvellement (.path).
set -eu
SRC_CERT="{{ client_journal_syslog_tls_source_cert }}"
SRC_KEY="{{ client_journal_syslog_tls_source_cle }}"
DST="{{ client_journal_syslog_tls_dir }}"
[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0
install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0644 "$SRC_CERT" "$DST/alloy.crt"
install -o {{ client_journal_utilisateur }} -g {{ client_journal_utilisateur }} -m 0600 "$SRC_KEY" "$DST/alloy.key"
# Le recepteur lit son certificat au demarrage : un nouveau certificat sur disque resterait
# sans effet jusqu'au prochain redemarrage (renouvellement-cert-recharger-consommateur).
systemctl try-restart {{ client_journal_service }} 2>/dev/null || true

View file

@ -110,11 +110,6 @@
when: not (common_packages_desarmer_maj_auto | bool)
failed_when: false # une unite absente sur cette image n'est pas une faute
# AVANT TOUTE OPERATION APT : pas de rattrapage herite du gabarit (voir minuteurs.yml).
- name: Empecher le rattrapage des minuteurs apt herite du gabarit
ansible.builtin.include_tasks: minuteurs.yml
when: not (common_packages_desarmer_maj_auto | bool)
- name: Mettre à jour le cache APT
ansible.builtin.apt:
update_cache: true

View file

@ -1,69 +0,0 @@
---
# LE RATTRAPAGE HERITE DU GABARIT (2026-10-08).
#
# `apt-daily.timer` et `apt-daily-upgrade.timer` sont PERSISTANTS : systemd garde la date
# de leur dernier passage (`/var/lib/systemd/timers/stamp-*`) et rattrape un passage
# manque. Une VM clonee herite de ces traces — celle d'`apt-daily` datait du 2026-06-21,
# jour de fabrication du gabarit. Elle « rattrape » donc des son demarrage, avec un delai
# aleatoire jusqu'a 60 min : a la reconstruction de Technolibre, `apt-daily-upgrade` est
# parti cinq minutes apres la naissance d'`infra-pki-01`, en pleine serie d'operations apt
# du role. Il tenait le verrou du CACHE DES ARCHIVES, qu'`apt-get` n'attend jamais
# (`DPkg::Lock::Timeout` ne couvre que dpkg) :
#
# 'apt-get autoremove' failed: E: Could not get lock /var/cache/apt/archives/lock.
#
# Une trace plus ancienne que la naissance de la machine n'est pas la sienne : on la remet a
# l'heure, et on relance le minuteur, qui recalcule son prochain passage (le matin suivant)
# sans rattraper. Les mises a jour automatiques restent armees (decision du 2026-09-11) ;
# seul disparait un passage herite. Sur une machine en service, la trace est recente :
# rien ne change.
- name: Naissance de la machine (cle d'hote SSH, posee au premier demarrage)
ansible.builtin.stat:
path: /etc/ssh/ssh_host_ed25519_key.pub
register: common_packages_naissance
- name: Traces des minuteurs apt persistants
ansible.builtin.stat:
path: "/var/lib/systemd/timers/stamp-{{ item }}"
loop: "{{ common_packages_unites_maj_auto | select('match', '.*[.]timer$') | list }}"
register: common_packages_traces
- name: Remettre a l'heure une trace heritee du gabarit
ansible.builtin.file:
path: "{{ item.stat.path }}"
state: touch
mode: "0644"
loop: >-
{{ common_packages_traces.results | selectattr('stat.exists')
| selectattr('stat.mtime', 'lt', common_packages_naissance.stat.mtime | default(0))
| list }}
loop_control:
label: "{{ item.item }}"
register: common_packages_traces_remises
when: common_packages_naissance.stat.exists
# Relancer recalcule le prochain passage d'apres la trace neuve. Relancer un minuteur ne
# declenche pas sa cible.
- name: Relancer les minuteurs dont la trace a ete remise
ansible.builtin.systemd_service:
name: "{{ item.item.item }}"
state: restarted
loop: "{{ common_packages_traces_remises.results | default([]) | selectattr('changed') | list }}"
loop_control:
label: "{{ item.item.item }}"
# UN PASSAGE DEJA PARTI se termine avant nos operations apt, plutot que de se les disputer.
# `is-active` rend 0 si l'une au moins des unites tourne.
# `check_mode: false` : une LECTURE. Sautee en simulation, elle n'avait jamais de `rc`, la
# condition `until` ne se remplissait pas, et `--check` echouait la apres 30 essais sur
# chaque machine (mesure le 2026-10-13, premiere simulation du site de TechnoLibre).
- name: Attendre la fin d'un passage apt automatique deja en cours
ansible.builtin.command:
argv: ["systemctl", "is-active", "--quiet", "apt-daily.service", "apt-daily-upgrade.service"]
register: common_packages_passage
check_mode: false
changed_when: false
failed_when: false
until: common_packages_passage.rc != 0
retries: 30
delay: 10

View file

@ -17,12 +17,3 @@ nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inve
# La liste des flux que la sonde `connectivite` teste chaque minute, generee par `make flux`
# a cote du ruleset, depuis les MEMES regles (voir templates/sonde-connectivite.sh.j2).
nftables_baseline_connectivite_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.connectivite.json"
# LA PASSERELLE N'EST PAS UNE PORTE (2026-10-05). Ports qui ne doivent JAMAIS repondre sur la
# passerelle par defaut de la machine — la sonde `connectivite` le verifie chaque minute.
# Chez un locataire en SDN, la passerelle de zone est l'HYPERVISEUR lui-meme, dans le VRF du
# locataire : une API Proxmox qui y repondrait contournerait la frontiere. Fermee aujourd'hui
# (mesure du 2026-10-05 : ECONNREFUSED) ; la garde dit si elle s'ouvre, quelle qu'en soit la
# cause (`net.ipv4.tcp_l3mdev_accept`, un pare-feu d'hote retire...). Sur une machine du site,
# la passerelle est la frontiere, qui n'ecoute pas sur ces ports.
nftables_baseline_connectivite_passerelle_interdits: [8006]

View file

@ -7,25 +7,25 @@ table inet setops_filter {
chain input {
type filter hook input priority 0; policy {{ nftables_baseline_input_policy }};
iif "lo" accept comment "socle: communications locales"
ct state established,related accept comment "socle: connexions etablies et associees"
ct state invalid drop comment "socle: paquets invalides"
iif "lo" accept
ct state established,related accept
ct state invalid drop
{% if nftables_baseline_allow_icmp | bool %}
ip protocol icmp accept comment "socle: diagnostic et erreurs reseau IPv4"
ip6 nexthdr icmpv6 accept comment "socle: diagnostic et erreurs reseau IPv6"
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
{% endif %}
{% if nftables_baseline_allow_ssh | bool %}
tcp dport {{ nftables_baseline_ssh_port }} accept comment "administration: acces SSH du gabarit de repli"
tcp dport {{ nftables_baseline_ssh_port }} accept
{% endif %}
{% for port in nftables_baseline_extra_tcp_ports %}
tcp dport {{ port }} accept comment "repli: port TCP supplementaire declare par l'instance"
tcp dport {{ port }} accept
{% endfor %}
{% for port in nftables_baseline_extra_udp_ports %}
udp dport {{ port }} accept comment "repli: port UDP supplementaire declare par l'instance"
udp dport {{ port }} accept
{% endfor %}
}

View file

@ -33,21 +33,10 @@
# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle
# posee : mieux vaut l'apprendre avant.
#
# CE QU'ELLE GARDE AUSSI (2026-10-05) : la passerelle par defaut ne doit repondre sur aucun
# port de `nftables_baseline_connectivite_passerelle_interdits` (l'API Proxmox, 8006). Chez un
# locataire en SDN, cette passerelle est l'hyperviseur, dans le VRF du locataire ; une API qui y
# repondrait contournerait la frontiere. Ici, `ouvert` prouve bien quelque chose : entre la
# machine et sa passerelle, aucun relais. Ouvert -> CRITIQUE ; refuse, rejete, delai -> attendu.
# Passerelle introuvable -> AVERTISSEMENT : une garde qui ne s'exerce pas doit le dire.
#
# Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md).
# `CONNECTIVITE_JSON=<fichier>` impose une autre liste : c'est le controle negatif.
# `CONNECTIVITE_PASSERELLE=<adresse>` vise une autre « passerelle » : le controle negatif de la
# garde (une adresse dont un port interdit repond doit la faire crier).
exec python3 - "${CONNECTIVITE_JSON:-/usr/local/lib/setops/connectivite.json}" <<'PY'
import concurrent.futures, errno, ipaddress, json, os, socket, subprocess, sys
INTERDITS = {{ nftables_baseline_connectivite_passerelle_interdits | to_json }}
import concurrent.futures, errno, ipaddress, json, socket, subprocess, sys
try:
d = json.load(open(sys.argv[1], encoding="utf-8"))
@ -89,23 +78,6 @@ with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
resultats = list(ex.map(essai, sorties))
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")]
# La passerelle n'est pas une porte.
def passerelle_par_defaut():
if os.environ.get("CONNECTIVITE_PASSERELLE"):
return os.environ["CONNECTIVITE_PASSERELLE"]
try:
for l in subprocess.run(["ip", "-4", "route", "show", "default"], capture_output=True,
text=True, timeout=5).stdout.splitlines():
c = l.split()
if "via" in c:
return c[c.index("via") + 1]
except (OSError, subprocess.TimeoutExpired):
pass
return None
gw = passerelle_par_defaut()
porte = [p for p in INTERDITS if gw and tcp(gw, p) == "ouvert"]
personne = [(f, r) for f, r in resultats if r == "refuse"]
ouverts = len(resultats) - len(coupes) - len(personne)
@ -160,19 +132,12 @@ def nomme(xs):
return ", ".join(f"{f['vers']}:{f['port'] if f['proto'] != 'icmp' else 'ping'} ({r})" for f, r in xs[:5]) \
+ (f" et {len(xs) - 5} autre(s)" if len(xs) > 5 else "")
perf = (f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 "
f"hors_regles={len(hors)};;;0 passerelle_ouverte={len(porte)};;;0")
if porte:
print(f"PASSERELLE OUVERTE : {gw} repond sur {', '.join(map(str, porte))} — la passerelle de zone "
f"(l'hyperviseur, chez un locataire) expose ce qui doit rester derriere la frontiere. {perf}")
sys.exit(2)
perf = f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 hors_regles={len(hors)};;;0"
if coupes:
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
sys.exit(2)
avert = []
info = ""
if INTERDITS and not gw:
avert.append("passerelle par defaut introuvable — la garde de la passerelle ne s'exerce pas")
if personne:
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
f"{nomme(personne)}")

View file

@ -19,13 +19,9 @@ quarante minutes sur la **première** machine.
| | |
|---|---|
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud et ses applications, oauth2-proxy) | ce rôle — le **dépôt de binaires** (`/var/lib/setops/artefacts-directs`, servi sous `/setops-binaires` sur le même port) |
| **ce que le runner installe** (roues Python, collections Ansible, `verrou-runner.yml`) | ce rôle — le même dépôt de binaires |
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | le **cache du contrôleur** : il télécharge une fois et pousse par SSH |
Les rôles consommateurs prennent ces fichiers au dépôt du site d'abord, vérifient leur somme
(ou leur signature), et ne sortent sur Internet qu'en repli, en le disant. Depuis le
2026-10-09, une reconstruction ne demande plus rien à PyPI, Galaxy ni à l'app store de
Nextcloud quand le dépôt est rempli.
Deux mécanismes parce que ce sont deux problèmes : on ne sert pas un dépôt apt par `scp`.
## Ce qu'il ne couvre pas encore

View file

@ -199,14 +199,6 @@
label: "{{ item }}"
when: serveur_artefacts_directs_actif | bool
# CE QUE LE RUNNER INSTALLE (2026-10-09) : ses roues Python et ses collections, verrouillees
# a l'octet dans `verrou-runner.yml`, a la racine du moteur. Lues, pas recopiees.
- name: Lire le verrou du runner (roues Python, collections Ansible)
ansible.builtin.include_vars:
file: "{{ role_path }}/../../verrou-runner.yml"
name: serveur_artefacts_verrou_runner
when: serveur_artefacts_directs_actif | bool
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
@ -219,18 +211,10 @@
ansible.builtin.get_url:
url: "{{ item.url }}"
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
checksum: "{{ ('sha256:' ~ item.sha256) if item.sha256 is defined else omit }}"
mode: "0644"
force: false
timeout: 60
# Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
# Et le verrou du runner (2026-10-09). Un artefact qui declare sa somme est verifie ici :
# le site ne tient pas un fichier altere en chemin.
loop: >-
{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([]))
+ (serveur_artefacts_verrou_runner.python | default([]))
+ (serveur_artefacts_verrou_runner.collections | default([])) }}
loop: "{{ serveur_artefacts_directs }}"
loop_control:
label: "{{ item.nom }}"
register: serveur_artefacts_directs_tires

View file

@ -62,14 +62,9 @@ for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do
continue
fi
# Le plus recent de tous les groupes (machine ET liste de chemins), pas le premier : voir
# `client_backup/templates/verifier-mon-depot.sh.j2` (2026-10-08).
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
horodatage=$(printf '%s' "${json}" | python3 -c \
'import sys,json,re,datetime
d=json.load(sys.stdin) or []
t=lambda x: datetime.datetime.fromisoformat(re.sub(r"Z$","+00:00",re.sub(r"(\.\d{6})\d+",r"\1",x["time"]))).timestamp()
print(max(d,key=t)["time"] if d else "")' 2>/dev/null)
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
if [[ -z "${horodatage}" ]]; then
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
continue

View file

@ -42,6 +42,5 @@ flux:
pair: externe
chiffrement: tls-requis
raison: >-
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga), et le
dépôt de binaires (roues PyPI, collections Galaxy, versions GitHub, Nextcloud,
Forgejo). Le site les relaie pour que la flotte n'ait pas à sortir elle-même.
Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache
les relaie pour que la flotte n'ait pas à sortir elle-même.

View file

@ -3,10 +3,6 @@
# Surcharge de l'unité du paquet. Le `coolwsd.xml` livré (439 lignes commentées) reste
# intact : le remplacer par un gabarit maison obligerait à suivre son évolution à chaque
# version amont, et ferait perdre les commentaires qui expliquent chaque réglage.
#
# AUCUNE VÉRIFICATION DE VERSION (mesure du 2026-10-10) : par défaut coolwsd interroge
# collabora.co.uk toutes les 10 heures, et le dit aux utilisateurs par une fenêtre. Les
# mises à jour arrivent par apt ; `fetch_update_check=0` coupe l'appel, la fenêtre suit.
[Service]
ExecStart=
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
@ -16,8 +12,6 @@ ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate
--o:ssl.termination={{ serveur_collabora_ssl_termination | bool | lower }} \
--o:server_name={{ serveur_collabora_server_name }} \
--o:storage.wopi.alias_group[0].host=https://{{ serveur_collabora_domaine_nextcloud }}:443 \
--o:fetch_update_check=0 \
--o:allow_update_popup=false \
--o:admin_console.enable={{ serveur_collabora_admin_actif | bool | lower }}{% if serveur_collabora_admin_actif | bool %} \
--o:admin_console.username={{ serveur_collabora_admin_utilisateur }} \
--o:admin_console.password={{ serveur_collabora_admin_mot_de_passe }}{% endif %}

View file

@ -9,39 +9,22 @@ flux:
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)."
# --- Sortie apt directe : SEULEMENT SANS AMONT D'ARTEFACTS (2026-10-10) ---
#
# C'ÉTAIT UNE SORTIE 80/443 VERS TOUT INTERNET, POUR TOUTE MACHINE. Sa raison, « dépôts
# apt », n'était plus vraie chez les locataires : leurs 26 machines prennent TOUTES leurs
# sources en `http://` par le cache du site (`artefacts_amorcage`), aucune n'a de dépôt
# HTTPS. Mesuré à la frontière, elle ne servait plus qu'à ce qu'aucun rôle ne déclarait :
# télémétrie de Grafana, d'Alloy et de Loki, vérification de version de Collabora,
# cartes de rspamd, repère du génome. Les trois premiers sont coupés à la source ; les
# deux derniers ont désormais leur propre flux, dans leur rôle.
#
# ELLE NE RESTE QUE LÀ OÙ IL N'Y A AUCUN AMONT. Le site non plus ne la reçoit pas : ses
# machines visent son propre cache depuis le 2026-09-14 (`site_inventaire.py`,
# `artefacts_amorcage`), et le cache, lui, porte ses propres sorties 80/443
# (`serveur_cache_site`, `serveur_artefacts`). Seul un écosystème sans aucune source
# d'artefacts — un laboratoire isolé — prend encore ses paquets en direct.
# --- Sortie générale de la flotte ---
# Portée par le socle parce qu'elle vaut pour TOUS les hôtes, sans exception.
# Non déclarée, la frontière la bloquerait : son `block out` final est un vrai
# default-deny, et la flotte perdrait ses mises à jour et son horloge en silence.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en direct, pour un écosystème SANS aucune source d'artefacts. Le site
et ses locataires passent par le cache du site et ne la reçoivent pas.
raison: "Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre."
- sens: egress
port: 80
protocole: tcp
pair: externe
chiffrement: clair
seulement_si: {variable: artefacts_amorcage, non_vide: false}
raison: >-
Dépôts apt en clair et redirections des miroirs, pour un écosystème SANS amont
d'artefacts. L'intégrité vient de la signature des paquets, pas du transport.
raison: "Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport)."
# --- Decouverte de MTU de chemin (PMTUD) ---
# L'overlay EVPN plafonne a 1450 : tout ce qui traverse la frontiere depend de ces
# deux messages. Bloques, la connexion s'etablit, les petites requetes passent et les

View file

@ -34,15 +34,16 @@ bout-en-bout en mono-domaine.
| `serveur_dovecot_ldap_filter` | `(&(objectClass=inetOrgPerson)(mail=%{user}))` | Login = adresse courriel |
| `serveur_dovecot_tls_actif` | `true` | Pont de certificat step_ca |
| `serveur_dovecot_lmtp_reseau` | `true` | LMTP :24 pour un MTA **distant** (edge-mta) |
| `serveur_dovecot_sasl_reseau` | `false` | Auth SASL réseau :12345 pour la soumission :587 |
## Flux (`meta/flux.yml`)
`993/tcp` IMAPS (utilisateurs) · `24/tcp` LMTP ← Postfix (TLS vérifié) ·
`636/tcp` → OpenLDAP (userdb/passdb).
`12345/tcp` SASL ← Postfix · `636/tcp` → OpenLDAP (userdb/passdb).
## Notes / limites
- **Mono-domaine** : le chemin ne conserve pas le domaine. Le multi-domaine demandera de le
préserver des deux côtés (raffinement Étape B).
- LMTP **réseau** doit être restreint au nœud MTA par pare-feu — c'est ce que
- LMTP et SASL **réseau** doivent être restreints au nœud MTA par pare-feu — c'est ce que
fait `nftables_baseline` à partir de `meta/flux.yml` (`pair: serveur_postfix`).
- Si Postfix est **co-localisé**, le rôle bascule sur les sockets sous le chroot
(`/var/spool/postfix/private`) plutôt que sur le réseau (détection automatique).
@ -51,13 +52,3 @@ bout-en-bout en mono-domaine.
## Prérequis
- `serveur_dovecot requiert serveur_openldap actif` (`docs/dependances-groupes.yml`).
- `client_pki` appliqué sur le nœud (couche `pki_client`, avant la couche `services`).
- **Authentification de la soumission : locale à Postfix** (2026-10-12). Le port SASL
réseau `12345` est retiré : le client SASL Dovecot de Postfix ne chiffre pas, et les mots
de passe y passaient en clair. `serveur_postfix` appelle `tasks/auth_locale.yml`, qui
pose sur sa machine un Dovecot d'**authentification seule** (cœur + LDAP, aucun port),
joint par un socket local et vérifiant sur l'annuaire en LDAPS.
- **IMAP en TLS direct seulement** (2026-10-12) : l'écoute en clair 143, ouverte par défaut,
est éteinte (`inet_listener imap { port = 0 }`) ; seul le 993 répond.
- **Sonde « boites »** : `doveadm auth lookup` du compte d'amorçage (`amorcage_acces_courriel`),
c'est-à-dire une recherche dans le **passdb** LDAP, sans mot de passe. `doveadm user` ne
prouvait rien : le userdb `static` (`static_allow_all_users`) répond pour n'importe quel nom.

View file

@ -40,14 +40,11 @@ serveur_dovecot_postfix_socket_dir: "/var/spool/postfix/private"
serveur_dovecot_lmtp_reseau: true
serveur_dovecot_lmtp_port: 24
# --- Authentification de la soumission : LOCALE A POSTFIX, plus jamais par le reseau ---
# Jusqu'au 2026-10-12, Postfix (edge-mta) interrogeait ce Dovecot sur :12345 ; le client
# SASL Dovecot de Postfix ne chiffre pas, et les mots de passe passaient en clair. Ce port
# n'existe plus : `serveur_postfix` pose a cote de lui un Dovecot d'authentification seule
# (`tasks/auth_locale.yml`), qui verifie sur l'annuaire en LDAPS.
serveur_dovecot_auth_locale_paquets:
- dovecot-core
- dovecot-ldap
# --- Auth SASL réseau : pour la soumission :587 servie par un Postfix DISTANT (edge-mta).
# Dovecot vérifie le mot de passe (passdb LDAP prouvé) pour Postfix. À restreindre au MTA
# par pare-feu ; le trafic d'auth devrait être isolé/chiffré en prod.
serveur_dovecot_sasl_reseau: false
serveur_dovecot_sasl_port: 12345
# TLS sur le port LMTP. Doit rester ACCORDE avec `serveur_postfix_lmtp_tls_verify` du
# cote emetteur : l'un sans l'autre, la livraison interne s'arrete — soit elle part en
@ -55,13 +52,7 @@ serveur_dovecot_auth_locale_paquets:
serveur_dovecot_lmtp_tls: true
# --- Sonde de supervision -----------------------------------------------------------
# UN COMPTE QUI DOIT EXISTER, sous son IDENTIFIANT DE CONNEXION (l'attribut `mail` : c'est
# ce que cherche le filtre de connexion). Le trouver prouve la chaine : demon, configuration,
# liaison LDAPS, filtre. Variable, donc mise en defaut avec un nom inexistant sans toucher a
# l'annuaire.
#
# Le compte d'amorcage, pas un nom ecrit (2026-10-12) : ce defaut valait `daniel`, qui
# n'existe chez aucun locataire — et la sonde, qui interrogeait le userdb statique, le disait
# trouve. Le compte d'amorcage est le seul que Set-OPS sait exister ; son adresse est un
# intrant obligatoire de toute instance.
serveur_dovecot_sonde_utilisateur: "{{ amorcage_acces_courriel | default('') }}"
# UN COMPTE QUI DOIT EXISTER. Le resoudre prouve la chaine entiere : demon, configuration,
# bind LDAP, mappage userdb. Variable, donc mise en defaut avec un nom inexistant sans
# toucher a l'annuaire.
serveur_dovecot_sonde_utilisateur: "daniel"

View file

@ -7,6 +7,12 @@ flux:
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

View file

@ -1,77 +0,0 @@
---
# AUTHENTIFICATION LOCALE POUR POSTFIX (2026-10-12) — voir templates/auth-locale.conf.j2.
#
# Appelee par `serveur_postfix` la ou la soumission existe, APRES que Postfix a demarre :
# le socket vit dans `/var/spool/postfix/private`, que Postfix cree a son premier demarrage.
# Ni IMAP, ni LMTP, ni aucun port : la machine n'entre pas dans le groupe `serveur_dovecot`,
# et aucun de ses flux ne s'ouvre ici.
- name: Authentification locale — resoudre l'annuaire (role partage)
ansible.builtin.include_role:
name: resoudre_annuaire
vars:
# Le compte de Dovecot, comme sur la machine des boites : meme verification, meme droit.
resoudre_annuaire_bind_cn: "dovecot"
resoudre_annuaire_secret: "vault_ldap_bind_dovecot"
- name: Authentification locale — adopter la connexion annuaire
ansible.builtin.set_fact:
serveur_dovecot_ldap_uris: "{{ resoudre_annuaire_uri }}:{{ resoudre_annuaire_port }}"
serveur_dovecot_ldap_base: "{{ resoudre_annuaire_base_dn }}"
serveur_dovecot_ldap_bind_dn: "{{ resoudre_annuaire_bind_dn }}"
serveur_dovecot_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
no_log: true
- name: Authentification locale — exiger le mot de passe de liaison (Vault)
ansible.builtin.assert:
that:
- serveur_dovecot_ldap_bind_password | length > 0
fail_msg: "vault_ldap_bind_dovecot requis : l'authentification locale se lie a l'annuaire sous le compte de Dovecot."
# Le coeur et le pilote LDAP, RIEN d'autre : sans `dovecot-imapd` ni `dovecot-lmtpd`, aucun
# protocole n'est declare et Dovecot ne lance aucun service de connexion.
- name: Authentification locale — installer Dovecot (coeur + LDAP)
ansible.builtin.apt:
name: "{{ serveur_dovecot_auth_locale_paquets }}"
state: present
update_cache: true
register: serveur_dovecot_auth_locale_apt
- name: Authentification locale — assurer le repertoire du socket (sous le chroot Postfix)
ansible.builtin.file:
path: "{{ serveur_dovecot_postfix_socket_dir }}"
state: directory
owner: postfix
group: root
mode: "0700"
- name: Authentification locale — deployer la configuration (drop-in Dovecot 2.4)
ansible.builtin.template:
src: auth-locale.conf.j2
dest: /etc/dovecot/conf.d/99-setops.conf
owner: root
group: root
mode: "0640"
no_log: true # contient le mot de passe de liaison LDAP
notify: Redémarrer dovecot
- name: Authentification locale — desactiver l'auth systeme (LDAP est l'unique autorite)
ansible.builtin.replace:
path: /etc/dovecot/conf.d/10-auth.conf
regexp: '^!include auth-system\.conf\.ext'
replace: '#!include auth-system.conf.ext'
notify: Redémarrer dovecot
# En simulation, le paquet qu'on vient d'« installer » n'a pose aucun fichier.
when: not (ansible_check_mode and serveur_dovecot_auth_locale_apt is changed)
- name: Authentification locale — valider la configuration (doveconf)
ansible.builtin.command: doveconf -n
changed_when: false
when: not ansible_check_mode
- name: Authentification locale — activer et demarrer Dovecot
when: not ansible_check_mode
ansible.builtin.systemd:
name: "{{ serveur_dovecot_service }}"
enabled: true
state: started
daemon_reload: true

View file

@ -15,18 +15,6 @@
serveur_dovecot_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
no_log: true
# UNE INSTANCE QUI DEMANDE ENCORE L'ANCIEN PORT (2026-10-12). `serveur_dovecot_sasl_reseau`
# ouvrait :12345, ou Postfix envoyait les mots de passe de soumission EN CLAIR. Le gabarit
# ne le lit plus ; une instance qui le pose encore croirait l'avoir — on le dit.
- name: Refuser l'authentification reseau retiree (serveur_dovecot_sasl_reseau)
ansible.builtin.assert:
that:
- not (serveur_dovecot_sasl_reseau | default(false) | bool)
fail_msg: >-
`serveur_dovecot_sasl_reseau` est retire (2026-10-12) : il faisait passer les mots de
passe de soumission en clair. Postfix verifie desormais par un Dovecot local
(serveur_dovecot/tasks/auth_locale.yml). Retire la ligne des group_vars de l'instance.
- name: Exiger le mot de passe de bind LDAP (Vault)
ansible.builtin.assert:
that:

View file

@ -23,16 +23,6 @@ ssl_server_cert_file = {{ serveur_dovecot_tls_dir }}/cert.pem
ssl_server_key_file = {{ serveur_dovecot_tls_dir }}/key.pem
ssl_min_protocol = TLSv1.2
{# LE 143 NE S'OUVRE PLUS (2026-10-12). L'IMAP en clair ecoutait par defaut a cote du 993 :
le pare-feu le bloquait et `ssl = required` refusait toute connexion avant STARTTLS, mais
le port repondait. Directive : tout transit chiffre — on n'offre que le TLS direct (993,
RFC 8314). `port = 0` eteint l'ecoute ; eprouve sur banc Debian 13 (le 993 reste). #}
service imap-login {
inet_listener imap {
port = 0
}
}
# --- Auth / annuaire LDAP (serveur_openldap) ---
auth_mechanisms = plain login
ldap_uris = {{ serveur_dovecot_ldap_uris }}
@ -79,6 +69,16 @@ service lmtp {
}
{% endif %}
{% if serveur_dovecot_sasl_reseau %}
# --- Auth SASL réseau (soumission :587 depuis le MTA distant edge-mta) ---
# À restreindre au nœud MTA par pare-feu.
service auth {
inet_listener sasl {
port = {{ serveur_dovecot_sasl_port }}
}
}
{% endif %}
# --- Sockets pour Postfix (remise LMTP + auth SASL) ---
# Seulement si Postfix est co-localisé (l'utilisateur postfix existe). Sinon Dovecot
# tourne en autonome (IMAP) ; les sockets seront ajoutés au déploiement de serveur_postfix.

View file

@ -1,43 +0,0 @@
# Géré par Set-OPS (rôle serveur_dovecot, authentification locale). Ne pas éditer à la main.
# Syntaxe Dovecot 2.4.
{#
AUTHENTIFICATION SEULE, A COTE DE POSTFIX (2026-10-12).
Postfix confiait la verification des mots de passe de soumission a la machine des
boites, par `smtpd_sasl_path = inet:<mailstore>:12345`. Le client SASL Dovecot de
Postfix ne sait pas chiffrer : chaque mot de passe traversait le reseau en clair, et le
registre le disait `tls`. Directive du 2026-10-12 : tout transit chiffre.
On ne chiffre pas ce flux, on le supprime. Ce Dovecot-ci ne sert ni IMAP ni LMTP (aucun
paquet de protocole n'est installe) et n'ecoute AUCUN port : Postfix lui parle par un
socket local, et il verifie le mot de passe sur l'annuaire en LDAPS — le chemin que
Postfix emprunte deja sur cette machine. Le mot de passe ne voyage plus que dans TLS.
Eprouve sur banc Debian 13 avant d'ecrire le role : processus `dovecot`, `auth`,
`anvil`, `log`, `config` seulement ; 235 avec le bon mot de passe, 535 avec un faux,
AUTH non offert hors TLS.
#}
dovecot_config_version = 2.4.0
# Garder l'adresse complète (user@domaine) comme identité — domaines virtuels.
auth_username_format = %{user}
auth_mechanisms = plain login
ldap_uris = {{ serveur_dovecot_ldap_uris }}
ldap_base = {{ serveur_dovecot_ldap_base }}
ldap_auth_dn = {{ serveur_dovecot_ldap_bind_dn }}
ldap_auth_dn_password = {{ serveur_dovecot_ldap_bind_password }}
{# Le meme passdb que la machine des boites : une identite, un mot de passe, une regle.
Pas de userdb : une verification SASL n'en consulte aucun. #}
passdb ldap {
ldap_filter = {{ serveur_dovecot_ldap_filter }}
ldap_bind = yes
}
service auth {
unix_listener {{ serveur_dovecot_postfix_socket_dir }}/auth {
user = postfix
group = postfix
mode = 0660
}
}

View file

@ -1,33 +1,22 @@
#!/bin/bash
# GENERE par Set-OPS (role serveur_dovecot). Ne pas editer a la main.
#
# SONDE « boites » — Dovecot repond et trouve un compte dans l'annuaire
# SONDE « boites » — Dovecot repond et resout un utilisateur dans l'annuaire
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `serveur_dovecot_sonde_utilisateur` (compte inexistant).
set -uo pipefail
UTILISATEUR={{ serveur_dovecot_sonde_utilisateur | quote }}
UTILISATEUR={{ serveur_dovecot_sonde_utilisateur }}
[[ -n "${UTILISATEUR}" ]] || { echo "Aucun compte a chercher (serveur_dovecot_sonde_utilisateur vide)."; exit 3; }
systemctl is-active --quiet dovecot || { echo "Dovecot n'est pas actif."; exit 2; }
# LE PASSDB, PAS LE USERDB (2026-10-12). La sonde demandait `doveadm user`, et le userdb est
# `static` avec `static_allow_all_users` (requis par la remise LMTP) : il repond pour
# N'IMPORTE QUEL nom. Elle disait « daniel » resolu chez deux locataires ou ce compte
# n'existe pas — verte quoi qu'il arrive.
#
# `doveadm auth lookup` interroge le passdb : Dovecot cherche le compte dans l'annuaire,
# sous SON compte de liaison, en LDAPS, avec le filtre de connexion. Aucune tentative de
# mot de passe — rien ne compte pour le verrouillage du compte. Mesure : 0 s'il existe,
# 67 s'il n'existe pas, une erreur si l'annuaire ne repond plus.
sortie=$(doveadm auth lookup "${UTILISATEUR}" 2>&1)
rc=$?
if [[ ${rc} -eq 67 ]]; then
echo "Dovecot ne trouve pas « ${UTILISATEUR} » dans l'annuaire : plus personne de ce nom ne peut se connecter."
exit 2
elif [[ ${rc} -ne 0 ]] || ! printf '%s' "${sortie}" | grep -q '^passdb: '; then
echo "Dovecot n'interroge plus l'annuaire (rc=${rc}) : ${sortie:0:70}"
# ON RESOUT UN UTILISATEUR REEL, PAS UN PING DE SERVICE. C'est le bind LDAP qui tombe le
# plus souvent, et il ne se voit que quand on demande un compte : le demon reste debout,
# le port reste ouvert, et toute authentification echoue.
sortie=$(doveadm user "${UTILISATEUR}" 2>&1)
if ! printf '%s' "${sortie}" | grep -qE '^uid[[:space:]]'; then
echo "Dovecot ne resout pas « ${UTILISATEUR} » — l'annuaire ne repond plus : ${sortie:0:70}"
exit 2
fi
echo "Dovecot : « ${UTILISATEUR} » trouve dans l'annuaire (passdb LDAP), service actif."
echo "Dovecot : « ${UTILISATEUR} » resolu dans l'annuaire, service actif."

View file

@ -12,10 +12,7 @@ serveur_forge_site_port: "{{ serveur_forgejo_port | default(443) }}"
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
serveur_forge_site_organisation: "genome"
# COMBIEN DE DEPOTS PUBLICS AU MINIMUM. La sonde lit l'API SANS identifiant : elle voit ce
# qu'un ecosysteme neuf peut cloner sans rien detenir. Depuis le 2026-10-11, la carte du
# site et les plans des locataires sont PRIVES (`docs/depots-prives-conception.md`) : il
# reste au moins le moteur. Les depots prives se surveillent ailleurs — `make genome-etat`,
# `make forge-cles`, et chaque runner qui les tire. On ne fixe pas un compte exact : le
# nombre de depots change, la sonde ne doit pas changer avec.
serveur_forge_site_sonde_depots_minimum: 1
# COMBIEN DE DEPOTS AU MINIMUM. Un site sert au moins le moteur et sa propre carte ; en
# dessous de deux, la forge n'a pas de quoi faire naitre un ecosysteme. On ne fixe pas un
# compte exact : le nombre de locataires change, la sonde ne doit pas changer avec.
serveur_forge_site_sonde_depots_minimum: 2

View file

@ -39,22 +39,3 @@ flux:
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
# de plus sur l'écoute d'un autre ».
partage: true
# LA LECTURE DE SON DEPOT PAR LE RUNNER D'UN LOCATAIRE (2026-10-11,
# `docs/depots-prives-conception.md`). Les depots des locataires deviennent prives : chacun
# se lit avec une cle de deploiement en lecture seule, et une cle de deploiement ne passe
# que par SSH. `runners_locataires` : les seuls runners, lus dans les faces publiees — pas
# les supernets. L'utilisateur `git` de ce `sshd` est contraint par la forge : la cle n'y
# ouvre aucune session, seulement le depot qui l'a enregistree.
- sens: ingress
port: 22
protocole: tcp
pair: runners_locataires
chiffrement: ssh
raison: >-
Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de
deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui
du site.
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le `sshd` du socle, comme
# `serveur_ops_tenant` (voir P33).
partage: true

View file

@ -75,7 +75,7 @@ nombre=${lecture%% *}
vides=${lecture#*|}
if (( nombre < MINIMUM )); then
echo "La forge du genome ne sert que ${nombre} depot(s) public(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner."
echo "La forge du genome ne porte que ${nombre} depot(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner."
exit 2
fi

View file

@ -47,16 +47,8 @@ serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_host
serveur_forgejo_groupe: "serveur_forgejo"
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement
# Relais courriel : DERIVE de l'inventaire, l'hote qui porte `serveur_postfix` (2026-10-12).
#
# Le defaut nommait `infra-mail-01`, la machine des BOITES d'un locataire, ou rien n'ecoute
# le port 25 ; au site, ce nom n'existe meme pas. La forge du site n'a donc jamais remis un
# seul courriel : « mot de passe oublie » repondait 200 et le journal disait
# `lookup infra-mail-01.genese.internal ... server misbehaving`. Sans relais dans
# l'ecosysteme, la valeur est vide et le courriel de la forge s'eteint (`app.ini`).
serveur_forgejo_smtp: >-
{{ ((groups['serveur_postfix'] | default([]) | first) ~ '.' ~ domaine_interne)
if (groups['serveur_postfix'] | default([])) else '' }}
# Relais courriel.
serveur_forgejo_smtp: "infra-mail-01.{{ domaine_interne }}"
# Secrets OBLIGATOIRES (Ansible Vault).
serveur_forgejo_secret_key: "{{ vault_forgejo_secret_key | default('') }}" # rempli depuis la voute (vault_forgejo_secret_key)

View file

@ -68,11 +68,8 @@ ACCOUNT_LINKING = auto
{% endif %}
[mailer]
ENABLED = {{ 'true' if serveur_forgejo_smtp else 'false' }}
{# STARTTLS EXIGE, CERTIFICAT VERIFIE (2026-10-12). `smtp` seul remettait en clair :
Forgejo le signalait (« insecure SMTP protocol to non-local address »). Le relais
porte un certificat de l'autorite de l'ecosysteme, que la machine reconnait. #}
PROTOCOL = smtp+starttls
ENABLED = true
PROTOCOL = smtp
SMTP_ADDR = {{ serveur_forgejo_smtp }}
SMTP_PORT = 25
FROM = forge@{{ domaine_interne }}

View file

@ -11,17 +11,6 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }}
Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }}
Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }}
Environment=GF_USERS_ALLOW_SIGN_UP=false
{# GRAFANA N'APPELLE PERSONNE (mesure du 2026-10-10). Par defaut il interroge grafana.com
(versions, greffons) et envoie ses compteurs d'usage a stats.grafana.org : ~124 et ~70
connexions par jour, depuis le site et depuis chaque locataire. Les avatars viennent de
gravatar.com, les nouvelles de grafana.com. Rien de tout cela ne sert une console
interne ; les mises a jour arrivent par apt, comme le reste. #}
Environment=GF_ANALYTICS_REPORTING_ENABLED=false
Environment=GF_ANALYTICS_CHECK_FOR_UPDATES=false
Environment=GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
Environment=GF_ANALYTICS_FEEDBACK_LINKS_ENABLED=false
Environment=GF_SECURITY_DISABLE_GRAVATAR=true
Environment=GF_NEWS_NEWS_FEED_ENABLED=false
{# LA SYNTHESE COMME PAGE D'ACCUEIL (2026-10-04) : a la connexion, la question « faut-il
agir ? » plutot que la liste des tableaux. Le fichier est pose par ce role (tableau de
synthese) ; sans lui, Grafana revient a son accueil par defaut, sans erreur. #}

View file

@ -155,41 +155,6 @@
# (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un
# certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache
# comment elle s'appelle.
# L'AC D'UNE INCARNATION PRECEDENTE REVIENT AVANT `api setup` (2026-10-07). Icinga 2 en
# derive l'environnement d'Icinga DB, donc l'identifiant de chaque hote, service et ligne
# d'historique. `api setup` la reutilise si elle est la (« Found CA, skipping and using the
# existing one », mesure sur mon-01) ; sinon il en fabrique une, et l'historique restaure
# par serveur_postgresql deviendrait orphelin. Aucun paquet n'en cree : c'est donc ICI.
- name: L'AC d'Icinga existe-t-elle deja ?
ansible.builtin.stat:
path: "{{ serveur_icinga_ca }}"
register: serveur_icinga_ca_presente
- name: Restauration — l'AC d'une incarnation precedente ?
ansible.builtin.include_role:
name: client_backup
tasks_from: restaurer.yml
vars:
client_backup_restaurer_jeu: serveur_icinga
client_backup_restaurer_vierge: "{{ not serveur_icinga_ca_presente.stat.exists }}"
- name: Restauration — remettre l'AC et l'environnement d'Icinga DB
changed_when: true
ansible.builtin.command:
argv: >-
{{ ['/usr/local/sbin/setops-restaurer', 'fichiers',
'--instantane', client_backup_restauration.instantane,
'--proprietaire', 'nagios:nagios'] + client_backup_restauration.chemins }}
when: client_backup_restauration.etat == 'a_restaurer'
- name: Restauration — acter l'AC remise
ansible.builtin.include_role:
name: client_backup
tasks_from: acter.yml
vars:
client_backup_acter_etat: restaure
when: client_backup_restauration.etat == 'a_restaurer'
- name: Configurer l'API Icinga 2
ansible.builtin.command:
cmd: icinga2 api setup
@ -209,20 +174,12 @@
enabled: true
state: started
# SUR UNE BASE VIERGE SEULEMENT (2026-10-07). Le marqueur vit sur mon-01 : une machine
# reconstruite ne l'a plus, alors que la base, elle, revient restauree par serveur_postgresql
# (historique compris). Rejouer le schema par-dessus y ajouterait une version et des erreurs
# « already exists » que psql taisait. La base dit elle-meme si elle a deja son schema.
- name: Importer le schema Icinga DB dans PostgreSQL (une fois, base vierge)
- name: Importer le schema Icinga DB dans PostgreSQL (une fois)
ansible.builtin.shell:
cmd: >-
export PGPASSWORD='{{ serveur_icinga_db_password }}';
deja=$(psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
-d {{ serveur_icinga_entree.base }} -tAX
-c "select to_regclass('public.icingadb_schema') is not null");
if [ "$deja" != t ]; then
PGPASSWORD='{{ serveur_icinga_db_password }}'
psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }}
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}; fi
-d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }}
&& touch /etc/icingadb/.schema-imported
creates: /etc/icingadb/.schema-imported
no_log: true

View file

@ -79,6 +79,89 @@
passe deviné : renseigner la clé dans la voûte de cet écosystème.
when: serveur_icingaweb2_auth == 'db'
# LE CLIENT POSTGRESQL DES MODULES `community.postgresql`, SUR CETTE MACHINE (2026-10-03).
#
# Les requetes ci-dessous s'executent sur l'hote de la vigie, pas sur le serveur de base.
# Au site, les deux sont la meme machine : `serveur_postgresql` y avait deja pose
# `python3-psycopg2`, et l'absence ne se voyait pas. Chez un locataire, la vigie vit sur
# `mon-01` et la base sur `data-sql-01` — premier passage apres que la base est devenue
# commune a tous les modes : `No module named 'psycopg2'`, masque par `no_log`.
- name: Installer le client PostgreSQL de Python (modules community.postgresql)
ansible.builtin.apt:
name: python3-psycopg2
state: present
# LE SCHEMA NE SE CHARGE QU'UNE FOIS. Le rejouer sur une base deja peuplee echouerait sur
# les objets existants — on lit donc d'abord si la table des comptes est la.
- name: La base de la console est-elle déjà en place ?
community.postgresql.postgresql_query:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
query: "SELECT to_regclass('public.icingaweb_user') IS NOT NULL AS presente"
register: serveur_icingaweb2_schema_etat
changed_when: false
no_log: true
when:
- not ansible_check_mode
- name: Charger le schéma de la console (comptes, préférences, migrations)
community.postgresql.postgresql_script:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
path: "{{ serveur_icingaweb2_schema }}"
no_log: true
when:
- not ansible_check_mode
- not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false))
# LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE, PAS UN QU'ON CHOISIT. `password_hash()`
# de PHP est exactement la fonction qu'Icinga Web 2 appelle a la connexion ; produire un
# hachage d'un autre outil donnerait une base valide et une connexion impossible.
- name: Hacher le mot de passe du compte d'amorçage
ansible.builtin.command:
argv:
- php
- -r
- 'echo password_hash($argv[1], PASSWORD_DEFAULT);'
- "{{ serveur_icingaweb2_admin_motdepasse }}"
register: serveur_icingaweb2_hachage
changed_when: false
no_log: true
when:
- serveur_icingaweb2_auth == 'db'
- not ansible_check_mode
# CREE S'IL MANQUE, JAMAIS ECRASE. Un mot de passe change dans l'interface appartient a
# celui qui l'a change : le moteur ne le reprend pas au deploiement suivant. Il ne pose
# que le compte qui permet d'entrer la premiere fois.
- name: Poser le compte d'amorçage s'il n'existe pas
community.postgresql.postgresql_query:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
query: >-
INSERT INTO icingaweb_user (name, active, password_hash)
VALUES (%s, 1, %s) ON CONFLICT (name) DO NOTHING
positional_args:
- "{{ serveur_icingaweb2_admin_utilisateur }}"
- "{{ serveur_icingaweb2_hachage.stdout }}"
no_log: true
when:
- serveur_icingaweb2_auth == 'db'
- not ansible_check_mode
# ON NE RESOUT PAS UN ANNUAIRE QUAND ON N'EN A PAS (2026-09-14).
#
# `resoudre_annuaire` etait appele SANS CONDITION. Au site — qui n'a ni LDAP ni Keycloak —
@ -176,94 +259,6 @@
| map('regex_replace', '^(?:Purg|Remv) (\\S+).*$', '\\1') | list)
is subset(serveur_icingaweb2_apache_restes)
# LA BASE ET LE COMPTE D'AMORCAGE APRES LES PAQUETS (2026-10-13). Ce bloc venait AVANT
# l'installation : il lit `/usr/share/icingaweb2/schema/pgsql.schema.sql` et hache par `php`,
# deux choses que les paquets apportent. Invisible la ou la console etait deja installee ;
# sur la premiere machine neuve (site de TechnoLibre), le schema n'existait pas encore.
# LE CLIENT POSTGRESQL DES MODULES `community.postgresql`, SUR CETTE MACHINE (2026-10-03).
#
# Les requetes ci-dessous s'executent sur l'hote de la vigie, pas sur le serveur de base.
# Au site, les deux sont la meme machine : `serveur_postgresql` y avait deja pose
# `python3-psycopg2`, et l'absence ne se voyait pas. Chez un locataire, la vigie vit sur
# `mon-01` et la base sur `data-sql-01` — premier passage apres que la base est devenue
# commune a tous les modes : `No module named 'psycopg2'`, masque par `no_log`.
- name: Installer le client PostgreSQL de Python (modules community.postgresql)
ansible.builtin.apt:
name: python3-psycopg2
state: present
# LE SCHEMA NE SE CHARGE QU'UNE FOIS. Le rejouer sur une base deja peuplee echouerait sur
# les objets existants — on lit donc d'abord si la table des comptes est la.
- name: La base de la console est-elle déjà en place ?
community.postgresql.postgresql_query:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
query: "SELECT to_regclass('public.icingaweb_user') IS NOT NULL AS presente"
register: serveur_icingaweb2_schema_etat
changed_when: false
no_log: true
when:
- not ansible_check_mode
- name: Charger le schéma de la console (comptes, préférences, migrations)
community.postgresql.postgresql_script:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
path: "{{ serveur_icingaweb2_schema }}"
no_log: true
when:
- not ansible_check_mode
- not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false))
# LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE, PAS UN QU'ON CHOISIT. `password_hash()`
# de PHP est exactement la fonction qu'Icinga Web 2 appelle a la connexion ; produire un
# hachage d'un autre outil donnerait une base valide et une connexion impossible.
- name: Hacher le mot de passe du compte d'amorçage
ansible.builtin.command:
argv:
- php
- -r
- 'echo password_hash($argv[1], PASSWORD_DEFAULT);'
- "{{ serveur_icingaweb2_admin_motdepasse }}"
register: serveur_icingaweb2_hachage
changed_when: false
no_log: true
when:
- serveur_icingaweb2_auth == 'db'
- not ansible_check_mode
# CREE S'IL MANQUE, JAMAIS ECRASE. Un mot de passe change dans l'interface appartient a
# celui qui l'a change : le moteur ne le reprend pas au deploiement suivant. Il ne pose
# que le compte qui permet d'entrer la premiere fois.
- name: Poser le compte d'amorçage s'il n'existe pas
community.postgresql.postgresql_query:
login_host: "{{ serveur_icingaweb2_db_host }}"
login_user: "{{ serveur_icingaweb2_db_utilisateur }}"
login_password: "{{ serveur_icingaweb2_db_motdepasse }}"
login_db: "{{ serveur_icingaweb2_db_base }}"
ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}"
ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}"
query: >-
INSERT INTO icingaweb_user (name, active, password_hash)
VALUES (%s, 1, %s) ON CONFLICT (name) DO NOTHING
positional_args:
- "{{ serveur_icingaweb2_admin_utilisateur }}"
- "{{ serveur_icingaweb2_hachage.stdout }}"
no_log: true
when:
- serveur_icingaweb2_auth == 'db'
- not ansible_check_mode
- name: Ajouter www-data au groupe icingaweb2 (lecture de la config)
ansible.builtin.user:
name: www-data

View file

@ -45,10 +45,7 @@ flux:
port: 1514
protocole: tcp
pair: fabric
# EN TLS, CLIENT AUTHENTIFIE (2026-10-12) : Alloy sert le certificat de la machine et
# exige un certificat emis par l'autorite de la frontiere ; la frontiere verifie Alloy
# contre la racine du site (scripts/frontiere_pki.py).
chiffrement: tls-requis
chiffrement: clair
raison: >-
Journaux de la frontiere, en TLS mutuel. Elle n'accueille aucun agent et n'emet que du
syslog ; sans ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part.
Journaux de la frontiere. Elle n'accueille aucun agent et n'emet que du syslog ; sans
ce flux, le seul equipement qui voit passer TOUT le trafic n'ecrit nulle part.

View file

@ -195,56 +195,6 @@
group: loki
mode: "0750"
# --- LA BASE D'UNE INCARNATION PRECEDENTE (2026-10-07) --------------------------------
#
# Les journaux repartaient de zero a chaque reconstruction. client_backup en garde une
# copie a froid (jeu `loki`) ; on la remet ICI, avant que Loki ne demarre sur sa
# configuration : une base sans aucun morceau de journal est une base neuve. Service
# arrete pendant la remise ; « Activer et demarrer Loki » plus bas le relance.
- name: La base de Loki a-t-elle deja des morceaux de journal ?
ansible.builtin.find:
paths: "{{ serveur_loki_chemin }}/chunks"
recurse: true
register: serveur_loki_morceaux
- name: Restauration — la base d'une incarnation precedente ?
ansible.builtin.include_role:
name: client_backup
tasks_from: restaurer.yml
vars:
client_backup_restaurer_jeu: serveur_loki
client_backup_restaurer_vierge: "{{ serveur_loki_morceaux.matched == 0 }}"
- name: Restauration — remettre la base, service arrete
when: client_backup_restauration.etat == 'a_restaurer'
block:
- name: Restauration — arreter Loki
ansible.builtin.systemd:
name: "{{ serveur_loki_service }}"
state: stopped
- name: Restauration — remettre la copie a froid a sa place
changed_when: true
ansible.builtin.command:
argv:
- /usr/local/sbin/setops-restaurer
- fichiers
- --instantane
- "{{ client_backup_restauration.instantane }}"
- --depuis
- "{{ client_backup_restauration.chemins[0] }}/donnees"
- --remplacer
- --proprietaire
- loki:loki
- "{{ serveur_loki_chemin }}"
- name: Restauration — acter la base remise
ansible.builtin.include_role:
name: client_backup
tasks_from: acter.yml
vars:
client_backup_acter_etat: restaure
# --- TLS : synchroniser le cert step_ca AVANT d'activer http_tls_config ---
- name: TLS — repertoire des certificats (loki)
ansible.builtin.file:

View file

@ -39,7 +39,3 @@ compactor:
limits_config:
retention_period: {{ serveur_loki_retention }}
{# Pas de rapport d'usage a stats.grafana.org (actif par defaut, mesure du 2026-10-10). #}
analytics:
reporting_enabled: false

View file

@ -74,31 +74,6 @@ serveur_nextcloud_http_port: 80
# IP de l'edge → trusted_proxies (pour voir la vraie IP client, pas throttler l'edge).
serveur_nextcloud_edge_ip: "{{ hostvars[(groups['serveur_nginx'] | default([]) | first) | default('')].ansible_host | default('') }}"
# --- LES APPLICATIONS NE VIENNENT PLUS DE L'APP STORE (2026-10-08) -------------------
#
# `occ app:install` les tirait d'apps.nextcloud.com a chaque construction. Le 2026-10-08,
# l'app store servait son catalogue (11,7 Mo) a 77 ko/s — 18,8 Mo/s ailleurs depuis la meme
# machine : Nextcloud a abandonne, et la reconstruction de Technolibre s'est arretee. Le
# site tient desormais ces archives dans son depot de binaires, comme celle de Nextcloud
# (`serveur_artefacts`), et le role les installe sans l'app store.
#
# EPINGLEES, VERIFIEES DEUX FOIS : la somme de l'archive (ci-dessous), puis la signature de
# son code (`occ integrity:check-app`, contre l'autorite de signature de Nextcloud, hors
# ligne ; eprouve : une ligne ajoutee a un fichier donne INVALID_HASH). Versions relevees
# sur Chezlepro, ou elles tournent sur Nextcloud 34.0.4. `nom` et `url` portent la version
# en toutes lettres : `test_apps_nextcloud.py` verifie qu'elles disent la meme.
serveur_nextcloud_apps:
- id: user_oidc
version: "8.11.0"
nom: "nextcloud-app-user_oidc-8.11.0.tar.gz"
url: "https://github.com/nextcloud-releases/user_oidc/releases/download/v8.11.0/user_oidc-v8.11.0.tar.gz"
sha256: "887920504c2f0e0d055abd66225ee7b66d756f50fe5a3739779d6b2ce108633f"
- id: richdocuments
version: "11.1.2"
nom: "nextcloud-app-richdocuments-11.1.2.tar.gz"
url: "https://github.com/nextcloud-releases/richdocuments/releases/download/v11.1.2/richdocuments-v11.1.2.tar.gz"
sha256: "96766a94cf417a716e47ed89c6ff2411c54954caa916afcdddc4d9668dffb557"
# --- SSO OIDC (Keycloak) — app user_oidc ---
serveur_nextcloud_oidc_actif: true
serveur_nextcloud_oidc_nom: "{{ organisation | default('Organisation') }}"

View file

@ -1,125 +0,0 @@
---
# UNE APPLICATION NEXTCLOUD, SANS L'APP STORE (2026-10-08) — voir `serveur_nextcloud_apps`.
#
# entree : serveur_nextcloud_app_id (user_oidc, richdocuments)
#
# Le depot du SITE d'abord, l'amont ensuite (meme patron que l'archive de Nextcloud,
# `installer.yml`) ; la somme verifiee dans les deux cas ; puis la signature du code ; puis
# l'activation. Une version installee qui differe de l'epinglee ARRETE : une mise a niveau
# d'application est une decision (et un `occ upgrade`), pas un effet de bord.
- name: "Application Nextcloud — l'application epinglee ({{ serveur_nextcloud_app_id }})"
ansible.builtin.set_fact:
serveur_nextcloud_app: >-
{{ serveur_nextcloud_apps | selectattr('id', 'equalto', serveur_nextcloud_app_id) | first }}
- name: "Application Nextcloud — deja presente ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.slurp:
src: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
register: serveur_nextcloud_app_info
failed_when: false
- name: "Application Nextcloud — la version presente est-elle l'epinglee ? ({{ serveur_nextcloud_app_id }})"
vars:
presente: >-
{{ (serveur_nextcloud_app_info.content | b64decode
| regex_search('<version>([^<]+)</version>', '\\1') | default([''], true) | first)
if serveur_nextcloud_app_info.content is defined else '' }}
ansible.builtin.assert:
that:
- presente | length == 0 or presente == serveur_nextcloud_app.version
fail_msg: >-
{{ serveur_nextcloud_app.id }} {{ presente }} est installee, {{ serveur_nextcloud_app.version }}
est epinglee. Une mise a niveau d'application se decide (et passe par `occ upgrade`) :
ajuster `serveur_nextcloud_apps`, ou mettre a niveau a la main.
quiet: true
- name: "Application Nextcloud — l'obtenir et la deposer ({{ serveur_nextcloud_app_id }})"
when: serveur_nextcloud_app_info.content is not defined
block:
- name: "Application Nextcloud — le cache du controleur ({{ serveur_nextcloud_app_id }})"
ansible.builtin.file:
path: "{{ serveur_nextcloud_cache_local }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont.
# La somme est verifiee ici aussi : un fichier altere chez le site ne passe pas.
- name: "Application Nextcloud — au depot du site, avant de sortir sur Internet ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ setops_depot_binaires }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 30
delegate_to: localhost
become: false
register: serveur_nextcloud_app_site
failed_when: false
when: setops_depot_binaires | default('') | length > 0
- name: "Application Nextcloud — dans le cache du controleur ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.stat:
path: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum_algorithm: sha256
register: serveur_nextcloud_app_cache
delegate_to: localhost
become: false
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
- name: "Application Nextcloud — absente du depot du site : prise en amont ({{ serveur_nextcloud_app_id }})"
ansible.builtin.debug:
msg: >-
{{ serveur_nextcloud_app.nom }} n'est pas au depot du site
({{ setops_depot_binaires | default('aucun depot declare') }}) : prise sur Internet.
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — en amont (repli) ({{ serveur_nextcloud_app_id }})"
ansible.builtin.get_url:
url: "{{ serveur_nextcloud_app.url }}"
dest: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
checksum: "sha256:{{ serveur_nextcloud_app.sha256 }}"
mode: "0644"
timeout: 60
delegate_to: localhost
become: false
when: not (serveur_nextcloud_app_cache.stat.exists
and serveur_nextcloud_app_cache.stat.checksum == serveur_nextcloud_app.sha256)
- name: "Application Nextcloud — deposer le code dans apps/ ({{ serveur_nextcloud_app_id }})"
ansible.builtin.unarchive:
src: "{{ serveur_nextcloud_cache_local }}/{{ serveur_nextcloud_app.nom }}"
dest: "{{ serveur_nextcloud_racine }}/apps"
owner: "{{ serveur_nextcloud_utilisateur }}"
group: "{{ serveur_nextcloud_utilisateur }}"
creates: "{{ serveur_nextcloud_racine }}/apps/{{ serveur_nextcloud_app.id }}/appinfo/info.xml"
- name: "Application Nextcloud — signature du code, puis activation ({{ serveur_nextcloud_app_id }})"
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
environment:
PGSSLMODE: "{{ serveur_nextcloud_pg_sslmode }}"
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
# Contre l'autorite de signature de Nextcloud, sans reseau. Rien a dire = intact.
- name: "Application Nextcloud — le code est-il celui que Nextcloud a signe ? ({{ serveur_nextcloud_app_id }})"
ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "integrity:check-app", "{{ serveur_nextcloud_app.id }}"]
register: serveur_nextcloud_app_integrite
changed_when: false
failed_when: serveur_nextcloud_app_integrite.rc != 0 or serveur_nextcloud_app_integrite.stdout | trim | length > 0
- name: "Application Nextcloud — activer ({{ serveur_nextcloud_app_id }})"
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
APP="{{ serveur_nextcloud_app.id }}"
if $OCC app:list --enabled | grep -qw "$APP"; then echo SETOPS_OK
else $OCC app:enable "$APP" && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_app_active
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_app_active.stdout"

View file

@ -1,10 +1,4 @@
---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app richdocuments (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: richdocuments
- name: Collabora / Office (occ, en www-data avec TLS PG)
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -13,6 +7,16 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
- name: Installer + activer l'app richdocuments (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw richdocuments; then echo SETOPS_OK; else $OCC app:install richdocuments && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_office_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_office_app.stdout"
- name: URL serveur -> Collabora (locale, rapide)
ansible.builtin.command:
argv: ["php", "{{ serveur_nextcloud_racine }}/occ", "config:app:set", "richdocuments", "wopi_url", "--value={{ serveur_nextcloud_wopi_url }}"]

View file

@ -1,10 +1,4 @@
---
# L'APPLICATION VIENT DU DEPOT DU SITE, PLUS DE L'APP STORE (2026-10-08) : `app_tierce.yml`.
- name: Installer l'app user_oidc (depot du site, somme, signature, activation)
ansible.builtin.include_tasks: app_tierce.yml
vars:
serveur_nextcloud_app_id: user_oidc
- name: SSO OIDC (occ, en www-data avec TLS PG)
become: true
become_user: "{{ serveur_nextcloud_utilisateur }}"
@ -13,6 +7,16 @@
PGSSLROOTCERT: "{{ serveur_nextcloud_ca_racine }}"
when: not ansible_check_mode
block:
- name: Installer + activer l'app user_oidc (idempotent)
ansible.builtin.shell:
executable: /bin/bash
cmd: |
set -eo pipefail
OCC="php {{ serveur_nextcloud_racine }}/occ"
if $OCC app:list | grep -qw user_oidc; then echo SETOPS_OK; else $OCC app:install user_oidc && echo SETOPS_CHANGED; fi
register: serveur_nextcloud_oidc_app
changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_oidc_app.stdout"
- name: Ne plus annoncer le formulaire de connexion locale
# Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par
# l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml.

View file

@ -30,7 +30,7 @@ serveur_ops_paquets:
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
serveur_ops_ansible: "ansible-core==2.18.19"
serveur_ops_ansible: "ansible-core>=2.18,<2.19"
# --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
#
@ -101,29 +101,6 @@ serveur_ops_forge_amont_ac_depot: "{{ serveur_ops_racine }}/.ac-amont.crt"
serveur_ops_depots:
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
# --- LES DEPOTS PRIVES (2026-10-11, `docs/depots-prives-conception.md`) --------
#
# Le plan d'un ecosysteme et la carte du site ne regardent que leur runner — et le runner du
# site. Sur la forge du site, ils sont PRIVES : chaque runner les lit en SSH, avec SA cle,
# enregistree comme cle de deploiement en lecture sur son seul depot (`make forge-cles`).
# Le moteur et les modeles restent publics, et se clonent comme avant, en HTTPS anonyme :
# une cle de deploiement ne vaut que pour le depot qui l'a enregistree (mesure du jour).
serveur_ops_roles_prives: ["instance", "tenant", "hebergeur"]
# La cle d'hote SSH de la forge, epinglee : le runner sait a qui il parle avant de presenter
# sa cle. Au site, elle vient de son plan (`forge_ssh_cle_hote`) ; chez un locataire, de sa
# fiche (`serveur_ops_forge_cle_hote`, copie verifiee par `make site-intrants`).
serveur_ops_forge_cle_hote: ""
serveur_ops_forge_ssh_hote: "{{ serveur_ops_forge_url | urlsplit('hostname') }}"
serveur_ops_forge_known_hosts: "{{ serveur_ops_racine }}/.ssh/known_hosts_forge"
serveur_ops_cle_privee: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}"
# Ou le CONTROLEUR fabrique ses colis avant de les porter (jamais dans un depot).
serveur_ops_colis_local: "{{ lookup('env', 'TMPDIR') | default('/tmp', true) }}"
# La commande SSH des depots prives, posee dans LEUR config git (`core.sshCommand`) : la
# console, la garde du genome et `make monter` la reprennent sans rien savoir de tout ceci.
serveur_ops_ssh_prive: >-
ssh -i {{ serveur_ops_cle_privee }} -o BatchMode=yes -o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }}
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
#
# `instance` dit QUEL tenant on pilote.

View file

@ -41,26 +41,24 @@ flux:
pair: flotte
chiffrement: ssh
raison: "Piloter la flotte — c'est la raison d'être du poste."
# UN LOCATAIRE NE PARLE PAS A L'HYPERVISEUR — ET CE N'EST PLUS DECLARE ICI (2026-10-05).
# L'HYPERVISEUR N'EST PAS DANS L'ÉCOSYSTÈME : il appartient au monde physique, de
# l'autre côté de la frontière. C'est donc un pair `externe`, et ce flux est le seul
# par lequel un écosystème peut en engendrer un autre.
- sens: egress
port: 8006
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: "API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant."
# LIRE LE GENOME CHEZ UN VOISIN. La forge du genome se mutualise par SITE : le moteur,
# les modeles et la fabric sont le meme contenu pour tous les tenants. Seul le plan
# `OPS-xxx` differe.
#
# Ce fichier portait une sortie 8006 vers `externe` : « API de l'hyperviseur : créer et
# cloner les VM d'un écosystème descendant ». Mesure du 2026-10-05, depuis trois machines de
# deux locataires : AUCUN paquet ne passait. La frontiere rendait cette sortie vers « tout
# sauf les plages privees », et les hyperviseurs sont en plages privees ; elle refusait donc
# (journal : `block`, refus audible du lien de transit). La regle etait morte, et sa lecture
# mentait : elle disait qu'un locataire pouvait creer des VM.
#
# Materialiser est un pouvoir du SITE (`serveur_ops_site`, sa voute porte l'API Proxmox).
# Une emancipation qui en aurait besoin se declarera le jour ou elle existera.
# LE GENOME SE CLONE A LA FORGE DU SITE — ET CE N'EST PLUS DECLARE ICI (2026-10-05).
#
# Ce fichier portait une sortie 443 vers `voisins_site`, ecrite quand la forge du genome
# vivait chez un locataire voisin (2026-08-24). Elle est au SITE depuis ; le pair n'avait
# pas suivi. `voisins_site` vu d'un locataire, ce sont les AUTRES locataires : la frontiere
# ouvrait donc le 443 du runner de chaque locataire vers tout le supernet de l'autre — un
# passage entre locataires que rien ne justifiait, la ou elle doit refuser par defaut.
# Trouve en confrontant la frontiere a la face reseau (docs/conception-contextes.md).
#
# Le clonage passe par la regle que declare `serveur_forge_site` (son entree depuis
# `voisins_site`, vue du site : ses locataires). La forge DE TRAVAIL d'un ecosysteme,
# celle qui sert ses gens, reste un service du tenant, sans rapport avec ce flux.
# La forge DE TRAVAIL d'un ecosysteme -- celle qui sert ses gens -- reste un service du
# tenant, et n'a rien a voir avec ce flux.
- sens: egress
port: 443
protocole: tcp
pair: voisins_site
chiffrement: tls-requis
raison: "Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne."

View file

@ -108,139 +108,79 @@
become: false
run_once: true
# LE VERROU DU RUNNER (2026-10-09) — `verrou-runner.yml`, a la racine du moteur.
# LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas.
#
# Les roues ne sont plus resolues par `pip download` contre PyPI, a chaque passage : elles
# sont ECRITES, une par une, avec leur somme, et le SITE les tient dans son depot de
# binaires. Le runner de Technolibre portait deux versions de six bibliotheques — PyPI avait
# bouge entre l'insemination et le montage. Desormais, ce qui s'installe est ce que le
# verrou nomme, et rien d'autre.
- name: Lire le verrou du runner (roues Python, collections Ansible)
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/../../verrou-runner.yml"
name: serveur_ops_verrou
# `pip download` rend les roues du systeme qui telecharge. `ansible-core`, `proxmoxer` et
# `requests` sont universelles (`py3-none-any`), mais `pyyaml` porte du C compile : une
# roue `cp313` posee sur un python 3.12 ne s'installe pas. L'echec serait « No matching
# distribution found » a l'installation — un message qui accuse le depot local alors que
# le coupable est l'ecart entre deux machines. On le mesure ici, ou on peut encore le dire.
- name: Lire le Python du controleur
ansible.builtin.command:
argv: ["python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"]
delegate_to: localhost
become: false
run_once: true
changed_when: false
# LIRE UNE VERSION N'EST PAS CHANGER, ET `--check` RENDAIT LA GARDE MUETTE (2026-09-14).
#
# Sans `check_mode: false`, cette commande est sautee en mode idempotent : la garde qui
# suit compare alors contre du VIDE et refuse.
#
# Le controleur tourne Python et site-ops-01 tourne Python 3.13
#
# L'espace apres « Python » est tout le diagnostic — il n'y avait rien a comparer. Un
# essai a blanc rendait donc un echec sur le runner, alors que le deploiement reel passe.
check_mode: false
register: serveur_ops_python_controleur
# LA CIBLE DOIT PORTER LE PYTHON DES ROUES VERROUILLEES. `pyyaml`, `cffi`, `markupsafe`...
# portent du C compile pour un Python donne : une roue `cp313` posee sur un 3.12 ne
# s'installe pas, et l'echec accuserait le depot local. Le controleur, lui, ne compile plus
# rien : il ne fait que transporter des fichiers ecrits.
- name: Exiger que la cible porte le Python des roues verrouillees
- name: Exiger que le controleur et la cible partagent leur version de Python
ansible.builtin.assert:
that:
- ansible_python_version.split('.')[0:2] | join('.') == serveur_ops_verrou.python_cible | string
- serveur_ops_python_controleur.stdout | trim
== ansible_python_version.split('.')[0:2] | join('.')
fail_msg: >-
{{ inventory_hostname }} tourne Python {{ ansible_python_version }} ; les roues de
`verrou-runner.yml` sont faites pour Python {{ serveur_ops_verrou.python_cible }}.
Monter le verrou (nouvelles roues, nouvelles sommes), ou garder la cible sur ce Python.
Le controleur tourne Python {{ serveur_ops_python_controleur.stdout | trim }} et
{{ inventory_hostname }} tourne Python
{{ ansible_python_version.split('.')[0:2] | join('.') }}. Les roues telechargees ne
s'installeraient pas sur la cible, et l'echec accuserait le depot local au lieu de
cet ecart. Materialiser depuis un controleur de meme famille — pendant une
insemination, c'est le runner du SITE, qui porte le meme Debian que la machine
qu'il amorce.
# LE DEPOT DU SITE D'ABORD, L'AMONT ENSUITE — la somme verifiee dans les deux cas.
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont, et on
# le dit (tache suivante).
- name: Prendre les roues verrouillees au depot du site
ansible.builtin.get_url:
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
dest: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
checksum: "sha256:{{ item.sha256 }}"
mode: "0644"
timeout: 30
loop: "{{ serveur_ops_verrou.python }}"
loop_control:
label: "{{ item.nom }}"
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur). Meme piege que pour ansible-galaxy le 2026-08-23.
- name: Telecharger les roues sur le controleur (une seule fois par empreinte)
ansible.builtin.command:
argv:
- "python3"
- "-m"
- "pip"
- "download"
- "--dest"
- "{{ serveur_ops_cache_roues }}"
- "--requirement"
- "{{ serveur_ops_roues_source }}"
- "{{ serveur_ops_ansible }}"
- "pyyaml"
delegate_to: localhost
become: false
run_once: true
failed_when: false
when:
- setops_depot_binaires | default('') | length > 0
- not ansible_check_mode
- name: Les roues verrouillees sont-elles dans le cache du controleur ?
ansible.builtin.stat:
path: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
checksum_algorithm: sha256
loop: "{{ serveur_ops_verrou.python }}"
loop_control:
label: "{{ item.nom }}"
delegate_to: localhost
become: false
run_once: true
register: serveur_ops_roues_cache
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
- name: Dire les roues absentes du depot du site (prises en amont)
ansible.builtin.debug:
msg: >-
{{ serveur_ops_roues_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
— absentes du depot du site ({{ setops_depot_binaires | default('aucun depot declare') }}) : prises sur Internet.
run_once: true
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
when:
- not ansible_check_mode
- serveur_ops_roues_cache.results | rejectattr('stat.exists') | list | length > 0
- name: Prendre en amont les roues que le site n'a pas (repli)
ansible.builtin.get_url:
url: "{{ item.item.url }}"
dest: "{{ serveur_ops_cache_roues }}/{{ item.item.nom }}"
checksum: "sha256:{{ item.item.sha256 }}"
mode: "0644"
timeout: 60
# ABSENTE OU ALTEREE : `pip --no-index` ne verifie pas les sommes, un fichier present mais
# different passerait. On le reprend, et `get_url` le verifie.
loop: "{{ serveur_ops_roues_cache.results }}"
loop_control:
label: "{{ item.item.nom }}"
delegate_to: localhost
become: false
run_once: true
when:
- not ansible_check_mode
- not item.stat.exists or item.stat.checksum != item.item.sha256
# LES ROUES DU VERROU, ET ELLES SEULES. Copier le cache entier deposait aussi les versions
# d'hier (deux `cryptography`, deux `urllib3`...) ; `--no-index` choisissait alors la plus
# haute, que personne n'avait choisie.
# LE DOSSIER D'ABORD (2026-10-09). L'ancienne copie du cache ENTIER (`src: dossier/`) creait
# sa destination ; une copie fichier par fichier ne la cree pas. Sur un runner neuf, la
# reconstruction de Technolibre s'est arretee la (« Destination directory … does not exist »)
# — invisible en simulant sur un runner ou le dossier existait deja.
- name: Assurer le depot des roues sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_roues_depot }}"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0755"
- name: Deposer sur le runner les roues du verrou
ansible.builtin.copy:
src: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
dest: "{{ serveur_ops_roues_depot }}/{{ item.nom }}"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
loop: "{{ serveur_ops_verrou.python }}"
loop_control:
label: "{{ item.nom }}"
register: serveur_ops_roues_telechargees
changed_when: "'Saved' in serveur_ops_roues_telechargees.stdout"
# LE GARDE-FOU SUIT LE RESULTAT, PAS LE GESTE (lecon du 2026-08-27) : on saute quand le
# cache PORTE deja des roues, pas quand une commande a deja tourne un jour.
when: not ansible_check_mode
- name: Relever les roues deposees sur le runner
ansible.builtin.find:
paths: "{{ serveur_ops_roues_depot }}"
patterns: "*.whl"
register: serveur_ops_roues_deposees
- name: Retirer du runner les roues que le verrou ne nomme pas
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: >-
{{ serveur_ops_roues_deposees.files
| rejectattr('path', 'search', '/(' ~ (serveur_ops_verrou.python | map(attribute='nom')
| map('regex_escape') | join('|')) ~ ')$') | list }}
loop_control:
label: "{{ item.path | basename }}"
- name: Deposer les roues sur le runner
ansible.builtin.copy:
src: "{{ serveur_ops_cache_roues }}/"
dest: "{{ serveur_ops_roues_depot }}/"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
directory_mode: "0755"
when: not ansible_check_mode
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
@ -349,8 +289,7 @@
version: "{{ item.branche | default('main') }}"
update: true
force: false
# LES DEPOTS PUBLICS SEULEMENT (2026-10-11). Les prives suivent, plus bas, en SSH.
loop: "{{ serveur_ops_depots | rejectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop: "{{ serveur_ops_depots }}"
loop_control:
label: "{{ item.depot }} → {{ item.dest }}"
become: true
@ -361,186 +300,6 @@
delay: 6
until: serveur_ops_clone is succeeded
# --- LES DEPOTS PRIVES : EN SSH, OU EN COLIS TANT QUE LE RUNNER NE PEUT PAS ---------------
#
# (2026-10-11, `docs/depots-prives-conception.md`.) Le plan d'un ecosysteme et la carte du
# site sont prives sur la forge du site. Le runner les lit en SSH, avec SA cle, que le site a
# enregistree comme cle de deploiement sur ce seul depot (`make forge-cles`).
#
# MAIS PAS ENCORE A L'INSEMINATION. Le site amorce le runner d'un locataire AVANT l'armement,
# donc avant que la voute lui remette sa cle stable : la forge ne le connait pas. Le
# controleur qui joue ce role — le runner du site, ou le poste — porte deja ce depot, frere
# du moteur ; il le lui apporte en COLIS (`git bundle`). Le runner pointe ensuite `origin`
# vers la forge en SSH : une fois arme, il tire lui-meme. Rien n'est devine : si le
# controleur n'a pas le depot, on refuse.
- name: Exiger la cle d'hote de la forge pour lire un depot prive
ansible.builtin.assert:
that: serveur_ops_forge_cle_hote | length > 0
fail_msg: >-
Ce runner lit des depots prives ({{ serveur_ops_depots
| selectattr('role', 'in', serveur_ops_roles_prives) | map(attribute='depot') | join(', ') }}),
mais `serveur_ops_forge_cle_hote` est vide. Au site : `forge_ssh_cle_hote` dans son plan.
Chez un locataire : la copier depuis sa fiche (`make site-intrants`).
when: serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list | length > 0
# LE DOSSIER SSH D'ABORD (mesure du 2026-10-11, insemination de Technolibre). Il n'etait cree
# que plus bas, avec la cle du poste : sur un runner existant il etait la, et les trois
# deploiements comme l'epreuve du colis n'ont rien vu. Une machine NEUVE s'est arretee ici :
# « Destination directory /opt/setops/.ssh does not exist ». La tache du bas reste, inchangee.
- name: Assurer le repertoire SSH du poste (avant d'y epingler la forge)
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.ssh"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0700"
when: serveur_ops_forge_cle_hote | length > 0
- name: Epingler la cle d'hote de la forge
ansible.builtin.copy:
content: "{{ serveur_ops_forge_ssh_hote }} {{ serveur_ops_forge_cle_hote | trim }}\n"
dest: "{{ serveur_ops_forge_known_hosts }}"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
when: serveur_ops_forge_cle_hote | length > 0
- name: Le runner lit-il deja chaque depot prive en SSH ?
ansible.builtin.command:
argv: [git, ls-remote, "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git", HEAD]
environment:
GIT_SSH_COMMAND: "{{ serveur_ops_ssh_prive }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop_control:
label: "{{ item.depot }}"
register: serveur_ops_prives_ssh
changed_when: false
failed_when: false
check_mode: false
- name: Tirer en SSH les depots prives que le runner peut lire
ansible.builtin.git:
repo: "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
dest: "{{ serveur_ops_racine }}/{{ item.item.dest }}"
version: "{{ item.item.branche | default('main') }}"
key_file: "{{ serveur_ops_cle_privee }}"
ssh_opts: "-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }} -o IdentitiesOnly=yes"
accept_hostkey: false
update: true
force: false
loop: "{{ serveur_ops_prives_ssh.results | selectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (SSH)"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_prives_tires
- name: Faire le colis des depots prives que le runner ne lit pas encore (controleur)
ansible.builtin.command:
argv: [git, -C, "{{ serveur_ops_freres }}/{{ item.item.dest }}", bundle, create,
"{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle",
"refs/heads/{{ item.item.branche | default('main') }}"]
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
delegate_to: localhost
become: false
changed_when: false
when: not ansible_check_mode
vars:
serveur_ops_freres: "{{ role_path | dirname | dirname | dirname }}"
register: serveur_ops_colis_faits
- name: Assurer le dossier des colis sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.colis"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0700"
when:
- not ansible_check_mode
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length > 0
- name: Porter les colis au runner
ansible.builtin.copy:
src: "{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle"
dest: "{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0600"
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
when: not ansible_check_mode
- name: Recevoir chaque colis (clone neuf, ou avance rapide), puis viser la forge en SSH
ansible.builtin.shell: |
set -eu
d="{{ serveur_ops_racine }}/{{ item.item.dest }}"
c="{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
b="{{ item.item.branche | default('main') }}"
# `git bundle verify` EXIGE UN DEPOT (mesure du 2026-10-11, a l'epreuve du colis) : hors
# d'un depot il refuse, et c'est justement le cas de l'insemination. Le clone verifie le
# colis lui-meme (un colis tronque y echoue) ; un depot existant le verifie avant de tirer.
if [ ! -d "$d/.git" ]; then
git clone -q --branch "$b" "$c" "$d"
echo "CLONE"
else
git -C "$d" bundle verify -q "$c" >/dev/null
avant=$(git -C "$d" rev-parse HEAD)
git -C "$d" fetch -q "$c" "refs/heads/$b:refs/remotes/colis/$b"
git -C "$d" merge -q --ff-only "colis/$b"
[ "$avant" = "$(git -C "$d" rev-parse HEAD)" ] || echo "AVANCE"
fi
git -C "$d" remote set-url origin "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
git -C "$d" fetch -q origin 2>/dev/null && git -C "$d" branch -q --set-upstream-to "origin/$b" || true
rm -f "$c"
args:
executable: /bin/bash
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_colis_recus
changed_when: "'CLONE' in serveur_ops_colis_recus.stdout or 'AVANCE' in serveur_ops_colis_recus.stdout"
- name: Dire quels depots prives sont venus par colis
ansible.builtin.debug:
msg: >-
{{ item.item.depot }} : le runner ne le lit pas encore en SSH ({{ (item.stderr | default('')
| trim).splitlines()[:1] | join('') }}). Apporte en colis par le controleur. S'il est
arme et que ca dure : `make forge-cles` au site, et la cle de sa voute.
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }}"
- name: Retirer les colis qui resteraient (un passage interrompu en laisse)
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.colis"
state: absent
when:
- not ansible_check_mode
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length == 0
- name: Poser la commande SSH de la forge dans chaque depot prive
community.general.git_config:
name: core.sshCommand
scope: local
repo: "{{ serveur_ops_racine }}/{{ item.dest }}"
value: "{{ serveur_ops_ssh_prive | trim }}"
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop_control:
label: "{{ item.depot }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
#
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas
@ -577,107 +336,48 @@
become: false
run_once: true
# LES COLLECTIONS DU VERROU (2026-10-09), meme chemin que les roues : le depot du site
# d'abord, Galaxy en repli dit, la somme verifiee. `collection download` sortait sur Galaxy
# a chaque runner neuf.
- name: Prendre les collections verrouillees au depot du site
ansible.builtin.get_url:
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
dest: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
checksum: "sha256:{{ item.sha256 }}"
mode: "0644"
timeout: 30
loop: "{{ serveur_ops_verrou.collections }}"
loop_control:
label: "{{ item.nom }}"
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur), et `cmd` la lit comme un separateur d'arguments.
# Constate le 2026-08-23 : ansible-galaxy recevait « /home/…/Espace » puis
# « Chezlepro/… » comme deux arguments, et refusait — « positional collection_name arg
# and --requirements-file are mutually exclusive ». Le message ne parlait pas du tout du
# vrai probleme.
- name: Telecharger les collections dans le cache du controleur (une seule fois)
ansible.builtin.command:
argv:
- "ansible-galaxy"
- "collection"
- "download"
- "-r"
- "{{ serveur_ops_requirements_controleur }}"
- "-p"
- "{{ serveur_ops_cache_collections }}"
creates: "{{ serveur_ops_cache_collections }}/requirements.yml"
delegate_to: localhost
become: false
run_once: true
failed_when: false
when:
- setops_depot_binaires | default('') | length > 0
- not ansible_check_mode
when: not ansible_check_mode
- name: Les collections verrouillees sont-elles dans le cache du controleur ?
ansible.builtin.stat:
path: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
checksum_algorithm: sha256
loop: "{{ serveur_ops_verrou.collections }}"
loop_control:
label: "{{ item.nom }}"
delegate_to: localhost
become: false
run_once: true
register: serveur_ops_collections_cache
- name: Dire les collections absentes du depot du site (prises sur Galaxy)
ansible.builtin.debug:
msg: >-
{{ serveur_ops_collections_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
— absentes du depot du site : prises sur Internet.
run_once: true
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
when:
- not ansible_check_mode
- serveur_ops_collections_cache.results | rejectattr('stat.exists') | list | length > 0
- name: Prendre sur Galaxy les collections que le site n'a pas (repli)
ansible.builtin.get_url:
url: "{{ item.item.url }}"
dest: "{{ serveur_ops_cache_collections }}/{{ item.item.nom }}"
checksum: "sha256:{{ item.item.sha256 }}"
mode: "0644"
timeout: 60
loop: "{{ serveur_ops_collections_cache.results }}"
loop_control:
label: "{{ item.item.nom }}"
delegate_to: localhost
become: false
run_once: true
when:
- not ansible_check_mode
- not item.stat.exists or item.stat.checksum != item.item.sha256
- name: Assurer le depot des collections sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.collections-hors-ligne"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0755"
- name: Deposer sur le poste les collections du verrou
- name: Deposer les collections sur le poste
register: serveur_ops_depot_collections
ansible.builtin.copy:
src: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/{{ item.nom }}"
src: "{{ serveur_ops_cache_collections }}/"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
loop: "{{ serveur_ops_verrou.collections }}"
loop_control:
label: "{{ item.nom }}"
when: not ansible_check_mode
# Le meme format que celui qu'ecrivait `collection download` : des archives en RELATIF,
# que l'installation lit depuis ce dossier (`chdir`).
- name: Ecrire la liste hors ligne des collections du verrou
register: serveur_ops_depot_collections_liste
ansible.builtin.copy:
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/requirements.yml"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
content: |
# Gere par Set-OPS (role serveur_ops), d'apres verrou-runner.yml. Ne pas editer.
collections:
{% for c in serveur_ops_verrou.collections %}
- name: {{ c.nom }}
version: {{ (c.nom | replace('.tar.gz', '')).split('-') | last }}
{% endfor %}
directory_mode: "0755"
when: not ansible_check_mode
# `chdir` OBLIGATOIRE : le `requirements.yml` produit par `collection download` nomme les
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
# LE GARDE-FOU DOIT SUIVRE LE RESULTAT, PAS LE GESTE (2026-08-27). La condition
# d'installation ne regardait que le DEPOT des archives. Le jour ou l'on a corrige le
# CHEMIN d'installation, les archives etaient inchangees : le role a donc conclu qu'il
# n'y avait rien a faire, et les collections sont restees a l'ancien endroit. Le meme
# piege qu'en 2026-08-24, deplace d'un cran. On mesure desormais la DESTINATION.
- name: Voir si les collections sont deja la ou le moteur les cherche
ansible.builtin.stat:
path: >-
@ -731,7 +431,6 @@
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
when:
- serveur_ops_depot_collections is changed
or serveur_ops_depot_collections_liste is changed
or not serveur_ops_collections_en_place.stat.exists
- not ansible_check_mode

View file

@ -70,16 +70,3 @@ flux:
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de
resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
transporte aucun secret : la voute est remise par un humain.
# LE REPERE DU GENOME (2026-10-10). La sonde `sonde-genome-a-jour` compare la forge du
# site a eregion par un `git ls-remote` anonyme, toutes les 15 minutes. Elle sortait par
# la sortie 443 generale du socle, qui n'existe plus : la declarer ici la rend lisible.
# Sans Internet, la sonde passe en INCONNU (code 3) — rien ne casse.
- sens: egress
port: 443
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: >-
Repere du genome : `git ls-remote` anonyme sur eregion (forge.alliance-boreale.ca)
pour dire si la forge du site a pris du retard. Lecture seule, sans identifiant.

View file

@ -227,23 +227,6 @@
- serveur_ops_tenant_ssh_cle | length > 0
- not ansible_check_mode
# LA CLE QUI LIT LE DEPOT DU GENOME (2026-10-11). Le site l'enregistre sur le depot de ce
# locataire d'apres sa face (`serveur_ops_tenant_cle_publique`) : si la voute en remettait une
# autre, le runner arme ne pourrait plus tirer son plan, et l'echec viendrait bien plus tard.
- name: Exiger que la cle du runner soit celle que la face publie pour lire le genome
ansible.builtin.assert:
that:
- (serveur_ops_tenant_ssh_publique.stdout.split()[:2] | join(' '))
== (serveur_ops_tenant_cle_publique.split()[:2] | join(' '))
fail_msg: >-
La cle SSH du runner (issue de la voute) n'est PAS `serveur_ops_tenant_cle_publique` :
la forge du site n'autorise que celle-ci a lire le depot de ce locataire. Corriger la
declaration (puis republier la face), ou la voute.
when:
- serveur_ops_tenant_ssh_cle | length > 0
- serveur_ops_tenant_cle_publique | default('') | length > 0
- not ansible_check_mode
- name: Dire qu'aucune identite n'est conservee
ansible.builtin.debug:
msg: >-

View file

@ -38,13 +38,12 @@ serveur_postfix_mailstore_hote: ""
serveur_postfix_lmtp_port: 24
# --- Soumission :587 (clients authentifiés) ---
# SASL délégué à un Dovecot d'AUTHENTIFICATION SEULE, sur cette machine, par socket local
# (`serveur_dovecot/tasks/auth_locale.yml`) ; il vérifie sur l'annuaire en LDAPS. Jusqu'au
# 2026-10-12, Postfix interrogeait le Dovecot du mailstore sur :12345 — en clair, faute de
# TLS dans ce protocole.
# SASL délégué à Dovecot (passdb LDAP prouvé) sur le nœud mail-store, via son auth réseau.
# 587 (STARTTLS requis) et 465 (TLS direct) ; seuls les authentifiés relaient. Publics depuis le
# 2026-09-29 : les clients de courriel y arrivent par l'adresse publique du locataire.
serveur_postfix_submission_actif: false
serveur_postfix_sasl_hote: "{{ serveur_postfix_mailstore_hote }}"
serveur_postfix_sasl_port: 12345
# --- Annuaire LDAP (validation domaines/boîtes/alias) ---
# Connexion à l'annuaire (serveur, base, bind_dn, bind_password) : fournie par le rôle partagé
@ -60,26 +59,6 @@ serveur_postfix_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansi
serveur_postfix_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
serveur_postfix_tls_dir: "/etc/postfix/tls"
# TOUT CE QUI TRANSITE EST CHIFFRE, A L'INTERNE COMME A L'EXTERNE (directive du 2026-10-12).
#
# Un RELAIS ne parle qu'a des emetteurs de son ecosysteme (la forge) et a des MX distants :
# il peut EXIGER le TLS des deux cotes. `may` laissait une forge sans STARTTLS remettre en
# clair, sans que rien ne le dise. Le serveur de courriel COMPLET d'un locataire reste en
# `may` pour l'instant : un MX public qui exige le TLS refuse le courrier des serveurs qui ne
# l'offrent pas (RFC 3207) — c'est une decision de l'Etape B, pas un reglage par defaut.
serveur_postfix_tls_entrant: "{{ 'encrypt' if serveur_postfix_mode == 'relais' else 'may' }}"
# EN SORTIE, LE RELAIS VERIFIE AUSSI QUI IL JOINT (2026-10-12) : `encrypt` chiffrait vers un
# serveur dont il ne verifiait pas l'identite (« Untrusted » au journal). Il n'ecrit qu'a
# quelques destinataires connus ; un MX au certificat invalide fait refuser le message au lieu
# de le livrer, et c'est voulu. Mesure avant : mx.chezlepro.ca et Gmail « Verified ».
serveur_postfix_tls_sortant: "{{ 'verify' if serveur_postfix_mode == 'relais' else 'may' }}"
# Les autorites PUBLIQUES du systeme : celles des MX de l'Internet, pas celle de l'ecosysteme.
# UN FICHIER, PAS UN DOSSIER : `smtp` tourne en chroot ; `CAfile` est lu AVANT d'y entrer,
# `CApath` dedans — sur une copie que Debian refait au redemarrage, et qui vieillit entre deux.
serveur_postfix_tls_ca_publiques: "/etc/ssl/certs/ca-certificates.crt"
# Une ligne par session TLS au journal : ce qui se dit chiffre doit pouvoir se MESURER.
serveur_postfix_tls_journal: 1
# Zero-confiance : verifier le TLS de la livraison LMTP vers Dovecot (cert step-ca).
serveur_postfix_lmtp_tls_verify: false
serveur_postfix_lmtp_tls_cafile: "/etc/step/certs/root_ca.crt"

View file

@ -36,6 +36,12 @@ flux:
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
@ -47,7 +53,7 @@ flux:
protocole: tcp
pair: serveur_openldap
chiffrement: tls-requis
raison: "Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire, et mots de passe de soumission vérifiés par le Dovecot local (LDAPS)."
raison: "Tables virtuelles (domaines/alias/boîtes) résolues sur l'annuaire (LDAPS)."
- sens: egress
port: 25
protocole: tcp

View file

@ -192,7 +192,7 @@
# 465 (`submissions`, TLS DIRECT, 2026-09-29) A COTE DU 587 (STARTTLS). La RFC 8314 recommande
# le TLS direct pour la soumission : aucun echange en clair avant le chiffrement. Memes
# restrictions que le 587 : authentifie, sinon refuse.
- name: Activer la soumission :587 et :465 (clients authentifiés, SASL Dovecot local)
- name: Activer la soumission :587 et :465 (clients authentifiés, SASL Dovecot distant)
ansible.builtin.blockinfile:
path: /etc/postfix/master.cf
marker: "# {mark} SETOPS SUBMISSION 587"
@ -202,7 +202,7 @@
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_sasl_path=inet:{{ serveur_postfix_sasl_hote }}:{{ serveur_postfix_sasl_port }}
-o smtpd_sasl_security_options=noanonymous
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
@ -211,7 +211,7 @@
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_sasl_path=inet:{{ serveur_postfix_sasl_hote }}:{{ serveur_postfix_sasl_port }}
-o smtpd_sasl_security_options=noanonymous
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
@ -231,17 +231,6 @@
state: started
daemon_reload: true
# LES MOTS DE PASSE NE QUITTENT PLUS LA MACHINE (2026-10-12). `smtpd_sasl_path` visait
# `inet:<mailstore>:12345`, que le client SASL Dovecot de Postfix ne sait pas chiffrer :
# chaque soumission y envoyait son mot de passe en clair. Le socket `private/auth` est
# servi par un Dovecot d'authentification seule, pose ici, qui verifie en LDAPS. Apres le
# demarrage de Postfix : c'est lui qui cree `private/` a sa premiere mise en route.
- name: Authentification de la soumission — Dovecot local (aucun port)
ansible.builtin.include_role:
name: serveur_dovecot
tasks_from: auth_locale.yml
when: serveur_postfix_submission_actif | bool
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.

View file

@ -22,9 +22,7 @@ local_recipient_maps =
qu'elle sorte au nom du SITE — pas d'un locataire. #}
relayhost =
{# `mynetworks` seul decide qui peut faire relayer : la loopback, donc les services de
cette machine, et les machines dont un role declare un flux vers ce relais (la forge ;
au site, `site_inventaire.reseaux_confiance_relais`). Rien d'autre : un relais ouvert
est repere et exploite en heures. #}
cette machine, et rien d'autre. Un relais ouvert est repere et exploite en heures. #}
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
{% else %}
# --- Boîtes virtuelles : validation LDAP + remise LMTP vers le nœud mail-store (Dovecot) ---
@ -39,14 +37,9 @@ lmtp_host_lookup = native
# --- TLS (certificat step_ca) ---
smtpd_tls_cert_file = {{ serveur_postfix_tls_dir }}/cert.pem
smtpd_tls_key_file = {{ serveur_postfix_tls_dir }}/key.pem
smtpd_tls_security_level = {{ serveur_postfix_tls_entrant }}
smtpd_tls_security_level = may
smtpd_tls_protocols = >=TLSv1.2, !SSLv2, !SSLv3
smtpd_tls_mandatory_protocols = >=TLSv1.2
smtpd_tls_loglevel = {{ serveur_postfix_tls_journal }}
smtp_tls_security_level = {{ serveur_postfix_tls_sortant }}
smtp_tls_CAfile = {{ serveur_postfix_tls_ca_publiques }}
smtp_tls_mandatory_protocols = >=TLSv1.2
smtp_tls_loglevel = {{ serveur_postfix_tls_journal }}
smtp_tls_security_level = may
{% if serveur_postfix_lmtp_tls_verify | default(false) %}
# Zero-confiance : livraison LMTP vers Dovecot chiffree ET verifiee (cert step-ca).
# `wrappermode` = TLS des la connexion : c'est ce que sait offrir un `inet_listener`

View file

@ -72,12 +72,6 @@ serveur_postgresql_exportateur_service: prometheus-postgres-exporter
serveur_postgresql_exportateur_port: 9187
serveur_postgresql_exportateur_utilisateur: setops_metriques
serveur_postgresql_exportateur_mot_de_passe: "{{ vault_pg_exportateur | default('') }}"
# L'EXPORTATEUR SERT EN TLS QUAND LA BASE LE FAIT (2026-10-12). Meme interrupteur que
# PostgreSQL, pas un second : la machine a alors son certificat (client_pki), et Prometheus
# lit ce meme interrupteur (`meta/metriques.yml`, `tls_si`) pour choisir son schema. Deux
# interrupteurs pourraient diverger — l'un servirait en TLS, l'autre lirait en clair.
serveur_postgresql_exportateur_tls_dir: "/etc/prometheus-postgres-exporter/tls"
serveur_postgresql_exportateur_web_config: "/etc/prometheus-postgres-exporter/web-config.yml"
# --- RESTAURATION (2026-09-30) — voir tasks/main.yml ---------------------------------
#
@ -86,13 +80,11 @@ serveur_postgresql_exportateur_web_config: "/etc/prometheus-postgres-exporter/we
# configuration vont ensemble, et le code de ses applications (user_oidc,
# richdocuments) n'est pas sauvegarde : restauree avant son installation, la base
# les dirait installees sans qu'elles le soient, et `occ app:install` refuserait ;
#
# PLUS `icinga` (2026-10-07). On l'excluait : l'historique de supervision etait lie a
# l'environnement d'Icinga, que l'AC d'Icinga, recreee a chaque reconstruction, renouvelait
# — restauree, la base serait restee orpheline. L'AC est desormais sauvegardee et remise
# (jeu `icinga` de client_backup) : l'environnement survit, et l'historique avec lui.
# - icinga : l'historique de supervision, lie a l'environnement d'Icinga, que rien ne
# sauvegarde. Il repart de zero, comme le reste de la supervision.
serveur_postgresql_restauration_consommateurs_exclus:
- nextcloud
- icinga
serveur_postgresql_bases_restaurables: >-
{{ ((serveur_postgresql_registre | default([]))

View file

@ -20,12 +20,20 @@ flux:
port: 9187
protocole: tcp
pair: [serveur_prometheus]
# DETTE FERMEE (2026-10-12). Inscrite `clair` le 2026-09-14 : l'exportateur sert
# desormais en HTTPS (`--web.config.file`, certificat de la machine resynchronise a chaque
# renouvellement), et Prometheus verifie contre l'autorite des que
# `serveur_postgresql_tls_actif` est vrai — partout, au site comme chez les locataires.
chiffrement: tls
# `clair`, ET C'EST UNE DETTE QU'ON ECRIT PLUTOT QUE DE LA TAIRE (2026-09-14).
#
# `client_metrique` sert deja ses metriques en TLS, certificat synchronise par
# `client_pki`. Cet exportateur-ci ne le fait pas encore : il faudrait lui poser un
# `--web.config.file` et l'abonner au meme renouvellement.
#
# CE QUI TRANSITE N'EST PAS ANODIN : nombre de connexions, tailles des bases, ratios
# de cache. Pas de donnee applicative, mais l'etat interne d'un serveur de donnees.
# Le flux ne sort pas de la zone, et la frontiere ne le laisse passer que depuis
# l'observatoire — ce n'est pas une excuse, c'est ce qui rend la dette tenable en
# attendant.
chiffrement: clair
raison: >-
Metriques PostgreSQL lues par l'observatoire, en HTTPS verifie. Series, pas verdicts :
ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui
grossissent — n'a que le graphe pour se faire voir.
Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive
lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a
que le graphe pour se faire voir. En clair pour l'instant, contrairement a
`client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`.

View file

@ -29,11 +29,6 @@ exportateur:
service: prometheus-postgres-exporter
port: 9187
job: postgresql
# LE SCHEMA SUIT UN INTERRUPTEUR DE LA CIBLE (2026-10-12). Prometheus lit cette variable
# dans les hostvars de chaque hote du groupe : toutes vraies, il collecte en HTTPS et
# verifie contre l'autorite ; sinon en HTTP. Le role, lui, sert en TLS sur le meme
# interrupteur — une seule valeur decide des deux bouts.
tls_si: serveur_postgresql_tls_actif
# LES PANNEAUX : ce qui merite d'etre REGARDE DANS LE TEMPS.
#

View file

@ -330,100 +330,6 @@
state: present
when: serveur_postgresql_exportateur_actif | bool
# --- TLS DE L'EXPORTATEUR (2026-10-12) -----------------------------------------------
# Le 9187 etait le seul port de metriques en clair, chez les locataires comme au site :
# une dette inscrite au registre le 2026-09-14. Meme pont que `client_metrique` : le
# certificat de la machine, copie la ou `prometheus` le lit, recopie a chaque renouvellement.
- name: Exportateur TLS — repertoire des certificats (prometheus)
ansible.builtin.file:
path: "{{ serveur_postgresql_exportateur_tls_dir }}"
state: directory
owner: prometheus
group: prometheus
mode: "0700"
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- name: Exportateur TLS — script de synchronisation du certificat
ansible.builtin.template:
src: setops-pg-exportateur-cert-sync.sh.j2
dest: /usr/local/sbin/setops-pg-exportateur-cert-sync
owner: root
group: root
mode: "0755"
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- name: Exportateur TLS — unites de synchronisation (service + path)
ansible.builtin.template:
src: "{{ item.s }}"
dest: "{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: "setops-pg-exportateur-cert-sync.service.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.service" }
- { s: "setops-pg-exportateur-cert-sync.path.j2", d: "/etc/systemd/system/setops-pg-exportateur-cert-sync.path" }
loop_control:
label: "{{ item.d | basename }}"
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- name: Exportateur TLS — activer la surveillance du certificat (path)
ansible.builtin.systemd:
name: setops-pg-exportateur-cert-sync.path
enabled: true
state: started
daemon_reload: true
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- not ansible_check_mode
- name: Exportateur TLS — synchroniser le certificat maintenant
ansible.builtin.command: /usr/local/sbin/setops-pg-exportateur-cert-sync
changed_when: false
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- not ansible_check_mode
- name: Exportateur TLS — garde-fou, ne pas activer sans certificat en place
ansible.builtin.stat:
path: "{{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key"
register: serveur_postgresql_exportateur_cle
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- not ansible_check_mode
- name: Exportateur TLS — exiger le certificat synchronise
ansible.builtin.assert:
that:
- serveur_postgresql_exportateur_cle.stat.exists
fail_msg: >-
Certificat de l'exportateur absent ({{ serveur_postgresql_exportateur_tls_dir }}) :
la machine porte-t-elle client_pki ? TLS non active, plutot qu'un exportateur muet.
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
- not ansible_check_mode
- name: Exportateur TLS — deployer la web-config
ansible.builtin.template:
src: web-config-exportateur.yml.j2
dest: "{{ serveur_postgresql_exportateur_web_config }}"
owner: root
group: prometheus
mode: "0640"
notify: Redemarrer l exportateur de metriques
when:
- serveur_postgresql_exportateur_actif | bool
- serveur_postgresql_tls_actif | bool
# LA CHAINE DE CONNEXION NE PASSE PAS PAR LA LIGNE DE COMMANDE. Un `DATA_SOURCE_NAME` en
# argument serait lisible dans `ps` par tout le monde sur la machine ; dans un fichier a
# 0600, il ne l'est que par root et le service.
@ -434,9 +340,7 @@
DATA_SOURCE_NAME="postgresql://{{ serveur_postgresql_exportateur_utilisateur
}}:{{ serveur_postgresql_exportateur_mot_de_passe
}}@127.0.0.1:5432/postgres?sslmode=disable"
ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}{{
(' --web.config.file=' ~ serveur_postgresql_exportateur_web_config)
if serveur_postgresql_tls_actif | bool else '' }}"
ARGS="--web.listen-address=:{{ serveur_postgresql_exportateur_port }}"
dest: /etc/default/prometheus-postgres-exporter
owner: root
group: root

View file

@ -1,10 +0,0 @@
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
[Unit]
Description=Set-OPS — surveille le certificat step_ca de l'exportateur PostgreSQL
[Path]
PathChanged={{ serveur_postgresql_tls_source_cert }}
Unit=setops-pg-exportateur-cert-sync.service
[Install]
WantedBy=multi-user.target

View file

@ -1,7 +0,0 @@
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
[Unit]
Description=Set-OPS — synchronise le certificat step_ca vers l'exportateur PostgreSQL
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setops-pg-exportateur-cert-sync

View file

@ -1,20 +0,0 @@
#!/bin/sh
# Gere par Set-OPS (role serveur_postgresql). Ne pas editer a la main.
# Synchronise le cert step_ca (client_pki, root:root) vers un emplacement lisible par
# 'prometheus' (l'utilisateur de l'exportateur). Cle en 0600. Idempotent ; appele au
# deploiement ET au renouvellement (unite .path).
set -eu
SRC_CERT="{{ serveur_postgresql_tls_source_cert }}"
SRC_KEY="{{ serveur_postgresql_tls_source_cle }}"
DST="{{ serveur_postgresql_exportateur_tls_dir }}"
[ -f "$SRC_CERT" ] && [ -f "$SRC_KEY" ] || exit 0
install -o prometheus -g prometheus -m 0644 "$SRC_CERT" "$DST/exportateur.crt"
install -o prometheus -g prometheus -m 0600 "$SRC_KEY" "$DST/exportateur.key"
# REDEMARRAGE, pas rechargement : meme famille que node_exporter (client_metrique), dont
# l'ExecReload Debian envoie un SIGHUP que le binaire ne sait pas honorer. `try-restart` :
# ne demarre pas un exportateur qu'on aurait volontairement arrete.
systemctl try-restart {{ serveur_postgresql_exportateur_service }} 2>/dev/null || true

View file

@ -1,5 +0,0 @@
# Gere par Set-OPS (role serveur_postgresql). TLS de l'exportateur (exporter-toolkit).
# Cert/cle step_ca synchronises par setops-pg-exportateur-cert-sync (proprietaire prometheus).
tls_server_config:
cert_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.crt
key_file: {{ serveur_postgresql_exportateur_tls_dir }}/exportateur.key

Some files were not shown because too many files have changed in this diff Show more