Compare commits
No commits in common. "ac48b7650bb9f4bd423f205febb839ce832840a7" and "32e1f6fbcd6f04832b062525cdc8425cac96e30f" have entirely different histories.
ac48b7650b
...
32e1f6fbcd
107 changed files with 427 additions and 5072 deletions
1243
CHANGELOG.md
1243
CHANGELOG.md
File diff suppressed because it is too large
Load diff
127
Makefile
127
Makefile
|
|
@ -368,7 +368,18 @@ deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre
|
|||
printf '%s\n' 'Refus: aucun playbook applicable pour HOTE=$(HOTE).'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
$(VAULT_UNE_FOIS) \
|
||||
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
|
||||
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
|
||||
if [[ -t 0 ]]; then \
|
||||
read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \
|
||||
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
|
||||
export ANSIBLE_VAULT_PASSWORD_FILE="$$vf"; \
|
||||
trap 'rm -f "$$vf"' EXIT; \
|
||||
else \
|
||||
printf '%s\n' 'Refus: vault chiffre detecte mais aucun mot de passe (entree non interactive). Fournir ANSIBLE_VAULT_PASSWORD_FILE ou le champ vault de la GUI.'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
fi; \
|
||||
$(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \
|
||||
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
|
||||
for playbook in $$playbooks; do \
|
||||
|
|
@ -640,35 +651,6 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou
|
|||
# Un echec n'est pas avale : le code de retour de chaque hote est relu, et la cible sort
|
||||
# en erreur si l'un d'eux a echoue — sinon `_attendre-flotte` partirait sur une flotte
|
||||
# incomplete.
|
||||
# --- LE MOT DE PASSE DE LA VOUTE, DEMANDE UNE SEULE FOIS ----------------------
|
||||
#
|
||||
# Deux cibles longues en ont besoin : `deployer-tout` (une invocation ansible) et
|
||||
# `flotte-creer` (une invocation PAR MACHINE). La seconde redemandait le mot de passe a
|
||||
# chaque VM — quinze fois sur la reconstruction de Chezlepro, quatre en parallele, avec
|
||||
# la sortie redirigee vers des journaux : l'exploitant est harcele par des invites qu'il
|
||||
# ne voit meme pas.
|
||||
#
|
||||
# UN SEUL BLOC POUR LES DEUX. Deux facons de manipuler un secret finiraient par diverger,
|
||||
# et c'est le genre de divergence qu'on ne remarque qu'apres.
|
||||
#
|
||||
# `-t 0` : ne demander que si un humain est au clavier. Un appel non interactif refuse
|
||||
# clairement plutot que de pendre sur une invite que personne ne lit.
|
||||
define VAULT_UNE_FOIS
|
||||
vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \
|
||||
if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \
|
||||
if [[ -t 0 ]]; then \
|
||||
read -r -s -p 'Mot de passe du vault Ansible (demande une seule fois): ' mdp; echo; \
|
||||
vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \
|
||||
export ANSIBLE_VAULT_PASSWORD_FILE="$$vf"; \
|
||||
trap 'rm -f "$$vf"' EXIT; \
|
||||
else \
|
||||
printf '%s\n' 'Refus: vault chiffre detecte mais aucun mot de passe (entree non interactive). Fournir ANSIBLE_VAULT_PASSWORD_FILE ou le champ vault de la GUI.'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
fi;
|
||||
endef
|
||||
|
||||
|
||||
flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le plan — PARALLELE=n
|
||||
@set -e; \
|
||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
|
|
@ -680,7 +662,6 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
|||
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; \
|
||||
$(VAULT_UNE_FOIS) \
|
||||
printf 'Creation de %s VM, %s a la fois.\n' "$$(echo $$hotes | wc -w)" "$$par"; \
|
||||
n=0; \
|
||||
for h in $$hotes; do \
|
||||
|
|
@ -700,90 +681,6 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
|
|||
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'
|
||||
|
||||
# --- 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
|
||||
# ne derive de rien — il EST le terrain sur lequel les tenants derivent. Ses machines se
|
||||
# declarent dans `underlay.yml`, a cote des switches et des hyperviseurs qui les portent.
|
||||
#
|
||||
# D'ou des cibles distinctes de `flotte-*` : elles ne demandent AUCUNE instance montee.
|
||||
# Le site existe avant tout tenant, et doit pouvoir naitre quand aucun n'est encore la.
|
||||
#
|
||||
# Le clone lui-meme reste `cloner-vm` — le seul chemin eprouve, et il n'y en aura pas
|
||||
# deux. `site_machines.py` ne fait que traduire la declaration en ses parametres.
|
||||
.PHONY: site-decrire
|
||||
site-decrire: ## Les machines de l'hebergeur declarees dans l'underlay
|
||||
@python3 scripts/site_machines.py decrire
|
||||
|
||||
.PHONY: site-inventaire
|
||||
site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
|
||||
@ansible-inventory -i scripts/site_inventaire.py --graph
|
||||
|
||||
.PHONY: site-appliquer
|
||||
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
|
||||
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
|
||||
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
|
||||
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
|
||||
@# hote, n'a rien a faire, et sort avec 0 — un succes qui n'a rien fait. On refuse donc
|
||||
@# tout groupe qui n'existe pas DANS CET INVENTAIRE-CI.
|
||||
@#
|
||||
@# LA VOUTE DU SITE est derivee du symlink `underlay.yml`, jamais redeclaree. Les roles
|
||||
@# partages exigent leurs secrets (Forgejo : secret_key, internal_token, admin_password).
|
||||
@# Un tenant les tient de `group_vars/all/vault.yml` ; le site les tient de
|
||||
@# `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.
|
||||
@set -e; \
|
||||
if [[ ! -f "playbooks/groupes/$(GROUPE).yml" ]]; then \
|
||||
printf 'Refus: aucun playbook pour le groupe %s.\n' "$(GROUPE)"; exit 2; \
|
||||
fi; \
|
||||
connus="$$(python3 scripts/site_inventaire.py --list | python3 -c \
|
||||
'import json,sys; print(" ".join(k for k in json.load(sys.stdin) if k != "_meta"))')"; \
|
||||
if [[ " $$connus " != *" $(GROUPE) "* ]]; then \
|
||||
printf 'Refus: le groupe %s n existe pas au site.\n' "$(GROUPE)"; \
|
||||
printf 'Groupes du site : %s\n' "$$connus"; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
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)
|
||||
|
||||
.PHONY: site-creer
|
||||
site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
|
||||
@set -e; \
|
||||
if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
printf '%s\n' 'Refus: creation des VM de l hebergeur (clone Proxmox).'; \
|
||||
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
|
||||
exit 2; \
|
||||
fi; \
|
||||
machines="$$(python3 scripts/site_machines.py lister)"; \
|
||||
if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
|
||||
for m in $$machines; do \
|
||||
printf '\n=== Machine du site: %s ===\n' "$$m"; \
|
||||
t0=$$(date +%s); \
|
||||
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
|
||||
eval "$$params"; \
|
||||
$(MAKE) cloner-vm \
|
||||
HOTE="$$m" \
|
||||
VMID="$$SETOPS_VMID" \
|
||||
ADRESSE_IP="$$SETOPS_IP" \
|
||||
CIDR="$$SETOPS_CIDR" \
|
||||
PASSERELLE="$$SETOPS_PASSERELLE" \
|
||||
VLAN="$${SETOPS_VLAN:-}" \
|
||||
PONT_PROXMOX="$$SETOPS_PONT" \
|
||||
STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \
|
||||
TAILLE_DISQUE="$$SETOPS_DISQUE" \
|
||||
COEURS="$$SETOPS_COEURS" \
|
||||
MEMOIRE="$$SETOPS_MEMOIRE" \
|
||||
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
|
||||
VMID_MODELE="$$SETOPS_VMID_MODELE" \
|
||||
CLONE_COMPLET="$$SETOPS_CLONE_COMPLET" \
|
||||
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
|
||||
printf '=== %s : %s s ===\n' "$$m" "$$(( $$(date +%s) - t0 ))"; \
|
||||
done; \
|
||||
printf '\nLes machines du site sont creees.\n'
|
||||
|
||||
.PHONY: _attendre-flotte
|
||||
_attendre-flotte: ansible-runtime
|
||||
@set -e; \
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c
|
|||
| 1 | Le plancher, sans DNS | Sur un nœud : « commande » | 👁 observe | — |
|
||||
| 2 | Interroge l'autoritatif | Demande à PowerDNS directement : « commande » | 👁 observe | — |
|
||||
| 3 | Vois les couches | Compare getent hosts (plancher) et dig (DNS) : deux chemins, même IP. | 👁 observe | — |
|
||||
| 4 | Casse & répare | Sur un nœud sans client_resolveur, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le pla… | 🔨 casse-répare | — |
|
||||
| 4 | Casse & répare | Sur un nœud sans client_unbound, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le planc… | 🔨 casse-répare | — |
|
||||
|
||||
*Source : [DNS & résolution de noms](DNS-et-résolution) · § À toi de jouer.*
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
# Preuve de conformite — Set-OPS — 2026-08-24
|
||||
|
||||
> 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 (42 OK · 0 echec · 0 saute)
|
||||
|
||||
## Preuves
|
||||
|
||||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
|
||||
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : 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). |
|
||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 87 flux, schéma + matrice OK. |
|
||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
|
||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 35 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 : 23 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. |
|
||||
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
|
||||
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 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 : 7 reseau(x), aucune collision avec la plage tenant. |
|
||||
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 68 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
|
||||
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 80 regle(s). |
|
||||
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 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 | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, 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, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (125 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (22 genere(s) exempte(s)). |
|
||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 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) detiennent de l'etat, tous porteurs de `client_backup` (9 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 : 35 role(s) serveur/client tous nommes, 36 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, 27 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 : 45 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||
|
||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||
|
||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||
comme declarations d'intention, non comme preuves :
|
||||
|
||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||
contre une flotte vivante.
|
||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||
|
||||
_Rapport genere le 2026-08-24._
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Preuve de conformite — Set-OPS — 2026-08-25
|
||||
|
||||
> 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** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml`
|
||||
- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 1 saute)
|
||||
|
||||
## Preuves
|
||||
|
||||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
|
||||
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : 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). |
|
||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 92 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_oauth2_proxy |
|
||||
| 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 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
|
||||
| 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 : 18 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 14 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
|
||||
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 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 : 11 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, 3 tenant(s), 50 groupe(s), 79 regle(s). |
|
||||
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 hote(s) x 5 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 | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, 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, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). |
|
||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |
|
||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 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 : 35 role(s) serveur/client tous nommes, 36 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, 27 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 : 45 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. |
|
||||
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 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). |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||
|
||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||
|
||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||
comme declarations d'intention, non comme preuves :
|
||||
|
||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||
contre une flotte vivante.
|
||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||
|
||||
_Rapport genere le 2026-08-25._
|
||||
|
|
@ -75,7 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
|
|||
- ✅ **soldé** — README de rôles : **tous les rôles en ont un** (les 12 manquants écrits le
|
||||
2026-07-29 : `serveur_debian`, `hosts_statiques`, `resoudre_base`, `resoudre_annuaire`,
|
||||
`serveur_dovecot`, `serveur_postfix`, `serveur_rspamd`, `client_backup`, `serveur_backup`,
|
||||
`client_resolveur`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
|
||||
`client_unbound`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
|
||||
- ✅ **soldé** — `expose` **est** consommé au déploiement : `plan/applications.yml` →
|
||||
filtre `expositions_des_applications` → vhosts nginx dérivés
|
||||
(`roles/serveur_nginx/tasks/main.yml`, template `expositions.conf.j2`, drapeau
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Ce que la reconstruction couvre, par capacité :
|
|||
|---|---|---|
|
||||
| Socle et durcissement | `serveur_debian`, `serveur_durci` | clone du gabarit doré → machine conforme |
|
||||
| Confiance | `serveur_step_ca`, `client_pki` | mTLS avec SAN dérivés du plan, renouvellement |
|
||||
| Noms | `serveur_powerdns`, `client_resolveur` | autoritaire interne + résolveur local |
|
||||
| Noms | `serveur_powerdns`, `client_unbound` | autoritaire interne + résolveur local |
|
||||
| Identité | `serveur_openldap`, `serveur_keycloak` | LDAPS, SSO OIDC, **fédération LDAP automatisée** (`tasks/federation-ldap.yml`), exposé par le plan (`auth.<domaine>`) |
|
||||
| Passerelle SSO | `serveur_oauth2_proxy` | SSO devant une app sans OIDC natif (éprouvé sur Icinga Web 2) |
|
||||
| Données | `serveur_postgresql`, `serveur_redis` | bases et comptes dérivés du registre, TLS `verify-full` |
|
||||
|
|
@ -46,18 +46,15 @@ Ce que la reconstruction couvre, par capacité :
|
|||
| Observabilité | `serveur_prometheus`, `serveur_loki`, `serveur_grafana` | métriques, journaux, tableaux sous SSO |
|
||||
| Supervision | `serveur_icinga`, `serveur_icingaweb2` | Icinga 2 + IcingaDB + Web 2 + BPM, au SSO |
|
||||
| Forge | `serveur_forgejo` | Git + PostgreSQL + SSO OIDC |
|
||||
| Collaboration | `serveur_nextcloud`, `serveur_collabora` (**natif**, plus de conteneur) | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve |
|
||||
| Collaboration | `serveur_nextcloud`, `serveur_collabora` | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve |
|
||||
| Plateforme webapp | `serveur_web_frontal`, `serveur_web_dorsal` | sites statiques et webapps natives (venv + systemd + nginx), **zéro conteneur** |
|
||||
| Sauvegardes | `serveur_backup`, `client_backup` | restic hors-nœud, **restauration éprouvée** (2026-08-12 : la donnée revient) |
|
||||
| Exploitation | `serveur_ops` | le poste depuis lequel l'ecosysteme se reconstruit : Ansible epingle, genome clone depuis **sa propre forge**, cle SSH propre — **sans** la voute ni son mot de passe |
|
||||
| Source d'artefacts | `serveur_artefacts`, `client_artefacts` | cache apt de l'ecosysteme (apt-cacher-ng) : les paquets viennent de chez soi, pas de six serveurs etrangers — **mode hors ligne** pour prouver ce que le cache detient vraiment |
|
||||
| Runner de site | `serveur_ops_site` | le pouvoir de **materialiser** : creer et detruire des VM sur la fabric. Detient la voute du SITE, chiffree, et n'entre JAMAIS chez un tenant — reserve a l'ecosysteme de l'hebergeur |
|
||||
| Resolution | `serveur_resolveur`, `client_resolveur` | UN resolveur recursif par tenant (Unbound), qui recurse depuis la racine et delegue la zone souveraine a PowerDNS. Remplace les N demons locaux d'avant le 2026-08-24 |
|
||||
| Cache du site | `serveur_cache_site` | designe LE cache que les ecosystemes voisins prennent comme amont : Debian telecharge une fois pour toute la fabric, et le cache ne voit que des requetes agregees. Reserve a l'ecosysteme de l'hebergeur |
|
||||
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte |
|
||||
|
||||
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`
|
||||
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_resolveur`), `client_ldap`
|
||||
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_unbound`), `client_ldap`
|
||||
(login LDAP au niveau OS, hors design).
|
||||
|
||||
> **Ce que « éprouvé » ne dit pas.** La reconstruction prouve que le moteur mène une
|
||||
|
|
@ -156,7 +153,7 @@ boîtes (Dovecot). La coupure suit l'exposition, pas le logiciel.
|
|||
| Confiance PKI / ACME | `client_pki` | **tout hôte** (universelle) |
|
||||
| Métriques Prometheus | `client_metrique` | **tout hôte** (universelle) |
|
||||
| Journaux vers Loki | `client_journal` | **tout hôte** (universelle) |
|
||||
| Résolution locale (Unbound) | `client_resolveur` | **tout hôte** (universelle) |
|
||||
| Résolution locale (Unbound) | `client_unbound` | **tout hôte** (universelle) |
|
||||
| Relais SMTP | `client_smtp` | déclaré par hôte, dans le plan |
|
||||
| Sauvegarde restic | `client_backup` | déclaré par hôte — **obligatoire pour tout détenteur d'état** (P36) |
|
||||
|
||||
|
|
@ -273,7 +270,7 @@ L'ordre ci-dessous privilégie les dépendances structurantes avant les applicat
|
|||
- Tout service exposé en HTTP(S) doit prévoir son intégration avec `serveur_nginx`.
|
||||
- Tout service avec authentification humaine doit prévoir son intégration avec `serveur_keycloak`, sauf justification contraire.
|
||||
- Tout service générant des alertes ou notifications doit prévoir `client_smtp`.
|
||||
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_resolveur` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
|
||||
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_unbound` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
|
||||
- Tout hôte qui **détient de l'état** doit porter `client_backup` (P36 le refuse sinon).
|
||||
- Tout service utilisant un certificat interne doit dépendre de `client_pki`.
|
||||
- Tout rôle serveur doit documenter ses ports, secrets, sauvegardes, dépendances et groupes clients associés.
|
||||
|
|
|
|||
|
|
@ -39,9 +39,6 @@ couches:
|
|||
- serveur_postgresql
|
||||
- serveur_openldap
|
||||
- serveur_powerdns
|
||||
# Le resolveur du tenant vient APRES son autoritatif : il le prend en stub-zone,
|
||||
# et sa validation exige que la zone souveraine reponde deja.
|
||||
- serveur_resolveur
|
||||
- serveur_redis
|
||||
- serveur_prometheus
|
||||
- serveur_loki
|
||||
|
|
@ -53,12 +50,6 @@ couches:
|
|||
# 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
|
||||
# LA RACINE DE LA CHAINE DE CACHES, et elle appartient au SITE, pas au tenant :
|
||||
# VM du tenant -> cache du tenant -> cache du SITE -> Debian
|
||||
# Classee ici pour que son ordre soit dit, mais elle ne se deploie pas dans le meme
|
||||
# mouvement : elle vit dans l'underlay de l'hebergeur et se joint par
|
||||
# `scripts/site_inventaire.py`. Un tenant ne la deploie jamais — il la CONSOMME.
|
||||
- serveur_cache_site
|
||||
|
||||
- nom: apps
|
||||
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
|
||||
|
|
@ -76,9 +67,6 @@ couches:
|
|||
# Le poste d'exploitation vient APRES la forge : il clone le genome depuis
|
||||
# elle. Le placer plus tot le laisserait sans source.
|
||||
- serveur_ops
|
||||
# Le runner de SITE vient APRES le runner de tenant : il suppose les depots
|
||||
# clones. Il n'ajoute qu'un pouvoir -- celui de materialiser sur la fabric.
|
||||
- serveur_ops_site
|
||||
|
||||
- nom: agents
|
||||
raison: "Les intégrations clientes qui expédient vers les services centraux (métriques, journaux, courriel, sauvegardes, résolution locale). Déployées en dernier, quand leurs cibles sont debout."
|
||||
|
|
@ -87,5 +75,5 @@ couches:
|
|||
- client_journal
|
||||
- client_smtp
|
||||
- client_backup
|
||||
- client_resolveur
|
||||
- client_unbound
|
||||
- client_artefacts
|
||||
|
|
|
|||
|
|
@ -118,23 +118,6 @@ groupes:
|
|||
raison: "Un cache apt ne depend d'aucun service de l'ecosysteme : il ne fait que relayer et retenir."
|
||||
surveillance: "Verifier l'ecoute sur 3142, le taux de service depuis le journal, et l'espace du cache."
|
||||
|
||||
serveur_ops_site:
|
||||
requiert_groupes_actifs:
|
||||
- serveur_ops
|
||||
# LE RUNNER DE SITE EST ADDITIF : il suppose le poste d'exploitation en place, dont il
|
||||
# reutilise la racine, l'utilisateur et les depots clones. Seul, il n'aurait ni carte
|
||||
# de la fabric ni moteur pour agir.
|
||||
raison: "Le runner de site n'ajoute qu'un pouvoir a un poste d'exploitation existant : sans lui, rien a quoi l'attacher."
|
||||
surveillance: "Verifier que la voute du site est presente ET CHIFFREE, et que la carte de la fabric est lisible."
|
||||
|
||||
serveur_resolveur:
|
||||
requiert_groupes_actifs:
|
||||
- serveur_powerdns
|
||||
# Le resolveur prend la zone souveraine en STUB-ZONE : sans autoritatif, il ne saurait
|
||||
# resoudre aucun nom de l'ecosysteme, et sa propre validation echouerait.
|
||||
raison: "Le resolveur du tenant delegue la zone souveraine a l'autoritatif ; sans lui, il ne sait rien de l'ecosysteme."
|
||||
surveillance: "Verifier qu'il repond pour la zone interne ET pour un nom de l'Internet."
|
||||
|
||||
serveur_nextcloud:
|
||||
requiert_groupes_actifs:
|
||||
- serveur_postgresql
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ Le service DNS interne est la premiere capacite de plateforme.
|
|||
> `serveur_debian`) genere `/etc/hosts` sur **chaque** VM depuis l'inventaire : tout
|
||||
> l'ecosysteme se resout par nom **meme serveur DNS eteint** (et au bootstrap, avant
|
||||
> que PowerDNS ne soit la). PowerDNS devient une **commodite** (zone, externe,
|
||||
> dynamique), plus un point de defaillance. Le resolveur local `client_resolveur` est
|
||||
> dynamique), plus un point de defaillance. Le resolveur local `client_unbound` est
|
||||
> **optionnel** (opt-in, avec bascule validee) : sans lui, le plancher `/etc/hosts` suffit.
|
||||
|
||||
## Groupes
|
||||
|
||||
```text
|
||||
serveur_powerdns -> service DNS central PowerDNS Authoritative
|
||||
client_resolveur -> resolveur local optionnel (opt-in, bascule validee)
|
||||
client_unbound -> resolveur local optionnel (opt-in, bascule validee)
|
||||
```
|
||||
|
||||
`client_resolveur` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
|
||||
`client_unbound` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
|
||||
|
||||
## Zone initiale
|
||||
|
||||
|
|
@ -69,15 +69,15 @@ Quand `serveur_postgresql` sera stable, il sera possible de migrer vers un backe
|
|||
|
||||
## Résolveur local (optionnel)
|
||||
|
||||
Le role `client_resolveur` (opt-in) installe un résolveur récursif local qui, en stub-zone,
|
||||
Le role `client_unbound` (opt-in) installe un résolveur récursif local qui, en stub-zone,
|
||||
délègue les noms internes à PowerDNS et récurse le reste.
|
||||
|
||||
La bascule du resolver local est **protegee** (le role valide qu'Unbound répond AVANT de
|
||||
basculer `/etc/resolv.conf`) :
|
||||
|
||||
```yaml
|
||||
client_resolveur_apply: true
|
||||
client_resolveur_confirm: true
|
||||
client_unbound_apply: true
|
||||
client_unbound_confirm: true
|
||||
```
|
||||
|
||||
Sans ces deux variables, le role prépare Unbound mais ne modifie pas `/etc/resolv.conf`.
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# Filiation, mutualisation, émancipation
|
||||
|
||||
> **Pour qui :** l'**architecte** de la lignée et l'**hébergeur** qui abrite des
|
||||
> écosystèmes tiers. Décrit les trois âges d'un écosystème et ce qu'ils impliquent au plan.
|
||||
|
||||
## Le constat
|
||||
|
||||
Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une
|
||||
source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela
|
||||
n'existe la première seconde. Il emprunte donc à son hôte.
|
||||
|
||||
Jusqu'ici, le moteur a rencontré ce besoin **trois fois sans le nommer** :
|
||||
|
||||
```
|
||||
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
|
||||
serveur_ops_forge_externe « je lis mon génome ailleurs »
|
||||
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
|
||||
```
|
||||
|
||||
Trois astuces, une seule notion. Ce document la déclare.
|
||||
|
||||
## Les trois âges
|
||||
|
||||
```
|
||||
FILIATION l'enfant emprunte à son parent ce qu'il ne porte pas encore
|
||||
MUTUALISATION un état DURABLE et CHOISI — on emprunte parce qu'on le veut
|
||||
ÉMANCIPATION l'écosystème porte tout lui-même
|
||||
```
|
||||
|
||||
**L'émancipation n'est pas une obligation.** Un petit organisme peut rester mutualisé
|
||||
toute sa vie ; ce qui compte est qu'il *puisse* s'émanciper, et que la sortie ne soit ni
|
||||
un piège ni une reconstruction. C'est la différence entre un locataire et un captif.
|
||||
|
||||
## Ce que ça veut dire pour les modèles
|
||||
|
||||
Un modèle sans forge n'est pas un modèle amputé : c'est un écosystème **au premier âge**,
|
||||
dont le runner lit le génome chez son hôte. L'ajout d'une forge n'est pas une correction,
|
||||
c'est une **émancipation**.
|
||||
|
||||
```
|
||||
premier âge serveur_ops_forge_externe: true + l'adresse de la forge de l'hôte
|
||||
émancipé serveur_ops_forge_externe: false + sa propre serveur_forgejo au plan
|
||||
```
|
||||
|
||||
## Ce que ça veut dire pour l'hébergeur
|
||||
|
||||
L'hébergeur ne vend plus seulement des machines : il vend **l'abri pendant la jeunesse**.
|
||||
Chaque service mutualisé est une ligne de facture, et chaque émancipation est une décision
|
||||
du client — jamais une rupture technique.
|
||||
|
||||
Pour une clientèle d'OBNL et de coopératives, c'est le bon geste : on entre à bas coût, on
|
||||
grandit vers l'autonomie, et **on n'est jamais captif**, puisque le génome est déjà chez
|
||||
soi dès le premier jour.
|
||||
|
||||
## Ce qui est mutualisable, et ce qui ne l'est pas
|
||||
|
||||
| service | mutualisable | remarque |
|
||||
|---|---|---|
|
||||
| forge (génome) | oui | `serveur_ops_forge_externe` |
|
||||
| source d'artefacts | oui | `client_artefacts` se désactive seul si aucune n'existe |
|
||||
| sauvegarde | oui | `client_backup_cible` pointe déjà hors de l'écosystème |
|
||||
| observabilité | oui | l'hébergeur surveille ses locataires |
|
||||
| relais courriel | oui | |
|
||||
| DNS | partiellement | un sous-domaine délégué, pas la zone entière |
|
||||
| **PKI** | à trancher | une AC intermédiaire signée par l'hôte est possible, mais l'AC **définit l'identité** de l'écosystème |
|
||||
| **identité** (LDAP/SSO) | le plus intime | mutualiser l'annuaire, c'est confier ses gens |
|
||||
| **la voûte** | **jamais** | les secrets ne se mutualisent pas, à aucun âge |
|
||||
|
||||
## Ce que l'émancipation doit prouver
|
||||
|
||||
Basculer une déclaration ne suffit pas. Une émancipation est un **acte outillé** en trois
|
||||
temps :
|
||||
|
||||
1. **déclarer** — lever le drapeau au plan, déployer le service chez soi ;
|
||||
2. **migrer l'état** — le génome, les sauvegardes, les certificats existants ;
|
||||
3. **prouver que le lien est coupé** — et c'est le temps qu'on oublie.
|
||||
|
||||
Sans cette troisième preuve, on croirait s'être émancipé en restant dépendant sans le
|
||||
savoir. C'est exactement le défaut que ce dépôt traque partout ailleurs : un vert sur un
|
||||
périmètre vide. Une émancipation non prouvée est une émancipation non faite.
|
||||
|
||||
## Forme attendue d'une déclaration
|
||||
|
||||
Tout service mutualisable doit se déclarer de la même façon, pour qu'un exploitant lise
|
||||
l'état de son écosystème d'un coup d'œil plutôt qu'en fouillant chaque rôle :
|
||||
|
||||
```yaml
|
||||
<service>_externe: true # je l'emprunte
|
||||
<service>_amont: "https://…" # à qui
|
||||
```
|
||||
|
||||
`false` — ou l'absence du couple — signifie *chez moi*. Le rôle **refuse** de démarrer si
|
||||
le drapeau est levé sans que l'amont soit nommé : emprunter sans dire à qui, c'est ne rien
|
||||
déclarer du tout.
|
||||
|
||||
## État au 2026-08-24
|
||||
|
||||
Seule la forge porte cette déclaration complète (`serveur_ops`). Les autres services
|
||||
mutualisés le sont par des mécanismes antérieurs, cohérents mais non uniformes. Les
|
||||
aligner sur la forme ci-dessus reste à faire — et l'instrument de preuve de l'étape 3
|
||||
n'existe pas encore.
|
||||
|
|
@ -420,7 +420,7 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
|
|||
et `<PORT-TRUNK>` ; rien dans le modèle ne peut les deviner.
|
||||
- ~~**La sortie générale** n'est pas déclarée~~ — **réglé le 2026-08-02.** Elle est
|
||||
déclarée dans le registre, donc dérivée comme le reste : `serveur_debian` (le socle, porté
|
||||
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_resolveur`
|
||||
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_unbound`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ integration:
|
|||
sauf_role: serveur_step_ca # facultatif — voir « exemptions »
|
||||
```
|
||||
|
||||
**Facultative** — `client_backup`, `client_smtp`, `client_resolveur`. Là il y a un vrai choix,
|
||||
**Facultative** — `client_backup`, `client_smtp`, `client_unbound`. Là il y a un vrai choix,
|
||||
et il se déclare par serveur, dans `plan/serveurs.yml : integrations`.
|
||||
|
||||
**Pourquoi cette inversion.** Le plan portait 57 lignes d'intégration écrites à la main. 28
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ nftables baseline · fail2ban SSH · auditd · AppArmor · sysctl · unattended-
|
|||
journald (rétention) · core_dumps · systemd_ssh_auto.
|
||||
|
||||
### F. Endpoints des services centraux (les rôles `client_*` en dérivent)
|
||||
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_resolveur` (opt-in).
|
||||
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_unbound` (opt-in).
|
||||
- AC/PKI (`client_pki_ca_url` → infra-pki, provisioner).
|
||||
- IdM/LDAP : annuaire résolu par `resoudre_annuaire` (hôte + base DN dérivés du domaine).
|
||||
- Relais courriel (`client_smtp_relais` → edge-mta, MTA Postfix, port 25).
|
||||
|
|
@ -90,7 +90,7 @@ domaines publics, `edge`, autorité DNS, FQDN exposés.
|
|||
| Secrets Vault | **Constante** 🔒 | — (gérés à part, jamais en clair) |
|
||||
| `fuseau_horaire` | Défaut | par hôte (rare) |
|
||||
| `proxmox_clone_noeud` / `stockage` / `pont` | Défaut | par hôte (`serveurs.yml`) |
|
||||
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_resolveur`) | Défaut | par hôte / groupe |
|
||||
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_unbound`) | Défaut | par hôte / groupe |
|
||||
| Politiques durcissement (SSH, nftables, fail2ban, journald…) | Défaut | par hôte / groupe |
|
||||
| Relais SMTP, TLS internes | Défaut | par hôte / groupe |
|
||||
| `ciuser`, compte `ansible` | Défaut | rarement surchargé |
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ prouvés.
|
|||
- **Forge logicielle** : `serveur_forgejo`.
|
||||
- **Observabilité** : `serveur_prometheus`, `serveur_grafana`, `serveur_loki`, `serveur_icinga`,
|
||||
avec les clients `client_metrique`, `client_journal`, `client_supervision`.
|
||||
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_resolveur`.
|
||||
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_unbound`.
|
||||
- **Méta-rôles d'agrégation** : `identity`, `applications`, `database`, `web`, `monitoring`,
|
||||
`backup`, `storage`.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
| `client_metrique` | ingress | 9100 | tcp | serveur_prometheus | tls | Scrape des métriques par Prometheus (node_exporter en HTTPS). |
|
||||
| `client_pki` | egress | 8443 | tcp | serveur_step_ca | tls-requis | Émission/renouvellement des certificats par ACME et récupération de la racine auprès de l'AC interne. |
|
||||
| `client_smtp` | egress | 25 | tcp | serveur_postfix | starttls | Relais des notifications locales vers le MTA central (Postfix), STARTTLS. |
|
||||
| `client_resolveur` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
|
||||
| `client_resolveur` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
|
||||
| `client_resolveur` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
|
||||
| `client_resolveur` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
|
||||
| `client_unbound` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
|
||||
| `client_unbound` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
|
||||
| `client_unbound` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
|
||||
| `client_unbound` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
|
||||
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
|
||||
| `serveur_backup` | egress | 5665 | tcp | serveur_icinga | tls-requis | Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive. |
|
||||
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
|
||||
|
|
|
|||
|
|
@ -1,38 +1,19 @@
|
|||
---
|
||||
# client_artefacts — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
|
||||
#
|
||||
# Cette integration depend de `serveur_artefacts`, et sa metadonnee le declare
|
||||
# (`roles/client_artefacts/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
|
||||
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
|
||||
# service qui n'est pas encore la, ou qui redemarre au meme instant.
|
||||
#
|
||||
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
|
||||
#
|
||||
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
|
||||
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
|
||||
|
||||
- name: Intégration client_artefacts — le serveur d'abord
|
||||
hosts: client_artefacts:&serveur_artefacts
|
||||
- name: Appliquer le groupe client_artefacts
|
||||
hosts: client_artefacts
|
||||
become: true
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_artefacts
|
||||
module_defaults:
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
pre_tasks: &pre_artefacts
|
||||
|
||||
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:
|
||||
- client_artefacts
|
||||
|
||||
- name: Intégration client_artefacts — puis les hôtes qui s'y adressent
|
||||
hosts: client_artefacts:!serveur_artefacts
|
||||
become: true
|
||||
module_defaults: *defauts_artefacts
|
||||
gather_facts: true
|
||||
pre_tasks: *pre_artefacts
|
||||
roles:
|
||||
- client_artefacts
|
||||
|
|
|
|||
|
|
@ -1,38 +1,22 @@
|
|||
---
|
||||
# client_journal — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
|
||||
#
|
||||
# Cette integration depend de `serveur_loki`, et sa metadonnee le declare
|
||||
# (`roles/client_journal/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
|
||||
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
|
||||
# service qui n'est pas encore la, ou qui redemarre au meme instant.
|
||||
#
|
||||
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
|
||||
#
|
||||
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
|
||||
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
|
||||
|
||||
- name: Intégration client_journal — le serveur d'abord
|
||||
hosts: client_journal:&serveur_loki
|
||||
- name: Appliquer le groupe client_journal
|
||||
hosts: client_journal
|
||||
become: true
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_journal
|
||||
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
|
||||
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
|
||||
# tache apt du play en herite, y compris celles des roles inclus.
|
||||
module_defaults:
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
pre_tasks: &pre_journal
|
||||
|
||||
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:
|
||||
- client_journal
|
||||
|
||||
- name: Intégration client_journal — puis les hôtes qui s'y adressent
|
||||
hosts: client_journal:!serveur_loki
|
||||
become: true
|
||||
module_defaults: *defauts_journal
|
||||
gather_facts: true
|
||||
pre_tasks: *pre_journal
|
||||
roles:
|
||||
- client_journal
|
||||
|
|
|
|||
|
|
@ -1,38 +1,22 @@
|
|||
---
|
||||
# client_pki — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
|
||||
#
|
||||
# Cette integration depend de `serveur_step_ca`, et sa metadonnee le declare
|
||||
# (`roles/client_pki/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
|
||||
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
|
||||
# service qui n'est pas encore la, ou qui redemarre au meme instant.
|
||||
#
|
||||
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
|
||||
#
|
||||
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
|
||||
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
|
||||
|
||||
- name: Intégration client_pki — le serveur d'abord
|
||||
hosts: client_pki:&serveur_step_ca
|
||||
- name: Appliquer le groupe client_pki
|
||||
hosts: client_pki
|
||||
become: true
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_pki
|
||||
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
|
||||
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
|
||||
# tache apt du play en herite, y compris celles des roles inclus.
|
||||
module_defaults:
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
pre_tasks: &pre_pki
|
||||
|
||||
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:
|
||||
- client_pki
|
||||
|
||||
- name: Intégration client_pki — puis les hôtes qui s'y adressent
|
||||
hosts: client_pki:!serveur_step_ca
|
||||
become: true
|
||||
module_defaults: *defauts_pki
|
||||
gather_facts: true
|
||||
pre_tasks: *pre_pki
|
||||
roles:
|
||||
- client_pki
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
# client_resolveur — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
|
||||
#
|
||||
# Cette integration depend de `serveur_resolveur`, et sa metadonnee le declare
|
||||
# (`roles/client_resolveur/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
|
||||
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
|
||||
# service qui n'est pas encore la, ou qui redemarre au meme instant.
|
||||
#
|
||||
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
|
||||
#
|
||||
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
|
||||
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
|
||||
|
||||
- name: Intégration client_resolveur — le serveur d'abord
|
||||
hosts: client_resolveur:&serveur_resolveur
|
||||
become: true
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_resolveur
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
gather_facts: true
|
||||
pre_tasks: &pre_resolveur
|
||||
- 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:
|
||||
- client_resolveur
|
||||
|
||||
- name: Intégration client_resolveur — puis les hôtes qui s'y adressent
|
||||
hosts: client_resolveur:!serveur_resolveur
|
||||
become: true
|
||||
module_defaults: *defauts_resolveur
|
||||
gather_facts: true
|
||||
pre_tasks: *pre_resolveur
|
||||
roles:
|
||||
- client_resolveur
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
---
|
||||
- name: Appliquer le groupe serveur_resolveur
|
||||
hosts: serveur_resolveur
|
||||
- name: Appliquer le groupe client_unbound
|
||||
hosts: client_unbound
|
||||
become: true
|
||||
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
|
||||
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
|
||||
# tache apt du play en herite, y compris celles des roles inclus.
|
||||
module_defaults:
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
|
@ -16,4 +19,4 @@
|
|||
fail_msg: "Ce playbook est prévu pour Debian."
|
||||
|
||||
roles:
|
||||
- serveur_resolveur
|
||||
- client_unbound
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
- name: Appliquer le groupe serveur_cache_site
|
||||
hosts: serveur_cache_site
|
||||
become: true
|
||||
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_cache_site
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
# effet faute de `resolvconf` (qu'on ne peut pas installer sans résolution — la boucle
|
||||
# se referme). Mesuré le 2026-08-06 sur deux VM neuves.
|
||||
#
|
||||
# PASSAGE DE RELAIS : `client_resolveur` reprend ce fichier plus tard pour le basculer
|
||||
# PASSAGE DE RELAIS : `client_unbound` reprend ce fichier plus tard pour le basculer
|
||||
# vers `127.0.0.1`. La garde ci-dessous le respecte — sans elle, chaque déploiement
|
||||
# défairait la bascule, et deux rôles se disputeraient le même fichier sans fin.
|
||||
- name: Lire le résolveur en place
|
||||
|
|
@ -42,57 +42,18 @@
|
|||
mode: "0644"
|
||||
content: |
|
||||
# Géré par Set-OPS — résolveur d'amorçage (intrant `dns_amorcage`).
|
||||
# Remplacé par `client_resolveur` lorsqu'il bascule vers le résolveur local.
|
||||
# Remplacé par `client_unbound` lorsqu'il bascule vers le résolveur local.
|
||||
{% for serveur in dns_amorcage.split(',') if serveur | trim %}
|
||||
nameserver {{ serveur | trim }}
|
||||
{% endfor %}
|
||||
# L'AMORÇAGE NE DOIT PAS DÉFAIRE CE QUI EST DÉJÀ EN PLACE (2026-08-25).
|
||||
#
|
||||
# La garde ne protégeait que l'hôte du résolveur lui-même (`127.0.0.1`). Partout
|
||||
# ailleurs, le socle réécrivait `/etc/resolv.conf` avec `dns_amorcage` — donc
|
||||
# **défaisait la bascule** de `client_resolveur` à chaque passage.
|
||||
#
|
||||
# Ça ne s'était jamais vu chez un tenant : `dns_amorcage` y vaut l'adresse du
|
||||
# résolveur du tenant, si bien que réécrire remettait la même valeur. La
|
||||
# coïncidence masquait le défaut.
|
||||
#
|
||||
# Sur le site, les deux ont divergé quelques heures — l'amorçage pointait encore sur
|
||||
# la frontière alors que `client_resolveur` avait basculé les machines sur
|
||||
# `site-dns-01`. Chaque déploiement du socle les ramenait en arrière, en silence, et
|
||||
# ça ne s'est vu qu'en retirant la règle de pare-feu vers la frontière : les cinq
|
||||
# machines ont alors perdu la résolution d'un coup.
|
||||
#
|
||||
# On ne pose donc l'amorçage que si rien de sensé n'est déjà là : ni la loopback, ni
|
||||
# un résolveur déclaré de cet écosystème.
|
||||
vars:
|
||||
serveur_debian_resolv_texte: >-
|
||||
{{ serveur_debian_resolv_actuel.content | default('') | b64decode | string }}
|
||||
serveur_debian_resolveurs_declares: >-
|
||||
{{ (groups['serveur_resolveur'] | default([]))
|
||||
| map('extract', hostvars, 'ansible_host') | select | list }}
|
||||
when:
|
||||
- dns_amorcage is defined
|
||||
- dns_amorcage | string | length > 0
|
||||
- "'127.0.0.1' not in serveur_debian_resolv_texte"
|
||||
- serveur_debian_resolveurs_declares
|
||||
| select('in', serveur_debian_resolv_texte) | list | length == 0
|
||||
- "'127.0.0.1' not in (serveur_debian_resolv_actuel.content | default('') | b64decode | string)"
|
||||
|
||||
roles:
|
||||
# LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25).
|
||||
#
|
||||
# `hosts_statiques` venait APRÈS `common_packages`, qui commence par un `apt update`.
|
||||
# Or apt vise le cache PAR SON NOM — `site-cache-01.<domaine>` — ce qui est voulu :
|
||||
# une configuration agnostique de l'adressage.
|
||||
#
|
||||
# Le jour où les adresses changent, la boucle se referme : apt échoue faute de
|
||||
# résoudre, donc le socle n'atteint jamais `hosts_statiques`, donc le plancher reste
|
||||
# périmé, donc apt échoue. Mesuré le 2026-08-25 après le découpage du site en zones :
|
||||
# cinq machines bloquées, `/etc/hosts` vide de toute entrée du site.
|
||||
#
|
||||
# Le résolveur d'amorçage est déjà une `pre_task` pour exactement cette raison. Le
|
||||
# plancher est de la même nature : il ne s'installe pas, **il rend installable**.
|
||||
- hosts_statiques
|
||||
- common_packages
|
||||
- hosts_statiques
|
||||
- qemu_guest_agent
|
||||
- cloud_init
|
||||
- sudo_ansible
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
- name: Appliquer le groupe serveur_ops_site
|
||||
hosts: serveur_ops_site
|
||||
become: true
|
||||
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_ops_site
|
||||
|
|
@ -214,36 +214,11 @@
|
|||
| selectattr('vmid', 'equalto', proxmox_clone_vmid | int)
|
||||
| list | length > 0 }}
|
||||
|
||||
# OU VIT LE GABARIT — et ce n'est pas forcement ou va la VM (2026-08-25).
|
||||
#
|
||||
# L'URL de clonage visait `nodes/{{ proxmox_clone_noeud }}`, c'est-a-dire le noeud de
|
||||
# DESTINATION. Or l'API veut le noeud qui DETIENT le modele ; la destination se dit
|
||||
# par `target`. Cloner vers gandalf un gabarit qui vit sur asgard rendait donc :
|
||||
#
|
||||
# 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist
|
||||
#
|
||||
# Ca n'etait jamais apparu parce que toutes les VM naissaient sur le noeud du gabarit
|
||||
# et migraient ensuite. La premiere machine placee ailleurs l'a revele.
|
||||
- name: Localiser le noeud qui detient le gabarit
|
||||
ansible.builtin.set_fact:
|
||||
proxmox_noeud_gabarit: >-
|
||||
{{ ((proxmox_inventaire_vm.json.data | default([]))
|
||||
| selectattr('vmid', 'defined')
|
||||
| selectattr('vmid', 'equalto', proxmox_clone_vmid_modele | int)
|
||||
| map(attribute='node') | list | first) | default('', true) }}
|
||||
|
||||
- name: "Un noeud de la grappe detient bien le gabarit {{ proxmox_clone_vmid_modele }}"
|
||||
ansible.builtin.assert:
|
||||
that: proxmox_noeud_gabarit | length > 0
|
||||
fail_msg: >-
|
||||
Aucun noeud de la grappe ne detient le gabarit
|
||||
{{ proxmox_clone_vmid_modele }}. Sans lui, rien a cloner.
|
||||
|
||||
- name: Cloner la VM depuis le modele (API directe, cible par VMID)
|
||||
vars:
|
||||
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
||||
ansible.builtin.uri:
|
||||
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_noeud_gabarit }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
||||
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: >-
|
||||
|
|
@ -261,9 +236,6 @@
|
|||
# A la CREATION seulement : l'API ne sait pas changer le pool d'une VM
|
||||
# existante par cet appel (il faut passer par le membre du pool).
|
||||
pool: "{{ proxmox_clone_pool | default(omit, true) }}"
|
||||
# LA DESTINATION SE DIT ICI, pas dans l'URL. Omise quand elle coincide avec le
|
||||
# noeud du gabarit : l'API refuse un `target` inutile sur stockage non partage.
|
||||
target: "{{ proxmox_clone_noeud if proxmox_clone_noeud != proxmox_noeud_gabarit else omit }}"
|
||||
validate_certs: "{{ proxmox_validate_certs | default(false) | bool }}"
|
||||
status_code: 200
|
||||
register: proxmox_clone_lance
|
||||
|
|
@ -274,23 +246,6 @@
|
|||
no_log: true
|
||||
when: not proxmox_clone_deja_la
|
||||
|
||||
# L'ECHEC DU POST ETAIT AVALE. `failed_when: false` protege du 500 « VMID deja pris »,
|
||||
# mais il avalait AUSSI un refus reel — et la tache d'attente qui suit se bloquait
|
||||
# alors sur un clonage qui n'avait jamais commence. Un blocage sans message, la panne
|
||||
# la plus chere a diagnostiquer. On garde le masque pour l'en-tete, et on releve
|
||||
# l'echec ici, message de l'API compris.
|
||||
- name: "Le clonage a bien ete accepte par l'API"
|
||||
ansible.builtin.assert:
|
||||
that: (proxmox_clone_lance.status | default(0)) == 200
|
||||
fail_msg: >-
|
||||
L'API a refuse le clonage
|
||||
({{ proxmox_clone_lance.status | default('aucune reponse') }}) :
|
||||
{{ proxmox_clone_lance.json.message
|
||||
| default(proxmox_clone_lance.msg | default('sans message')) }}
|
||||
— gabarit {{ proxmox_clone_vmid_modele }} sur « {{ proxmox_noeud_gabarit }} »,
|
||||
destination « {{ proxmox_clone_noeud }} ».
|
||||
when: not proxmox_clone_deja_la
|
||||
|
||||
# ATTENDRE QUE LE CLONE SOIT REELLEMENT FINI, pas seulement demande.
|
||||
#
|
||||
# `POST .../clone` rend un UPID et la main IMMEDIATEMENT : Proxmox copie le disque en
|
||||
|
|
@ -307,20 +262,8 @@
|
|||
vars:
|
||||
proxmox_api_racine: "https://{{ proxmox_api_host_effectif }}:{{ proxmox_api_port_effectif | default(8006, true) }}"
|
||||
ansible.builtin.uri:
|
||||
# LE NOEUD VIENT DE L'UPID LUI-MEME, et de nulle part ailleurs (2026-08-25).
|
||||
#
|
||||
# Cette attente interrogeait `nodes/{{ proxmox_clone_noeud }}` — la DESTINATION.
|
||||
# Or un clonage inter-noeuds s'execute sur le noeud du GABARIT : l'UPID n'existait
|
||||
# pas la ou on le cherchait, l'API repondait en erreur, `failed_when: false`
|
||||
# l'avalait, et `until` n'etait jamais satisfait. Resultat : soixante tentatives a
|
||||
# dix secondes pour un clonage deja termine en 87 s — dix minutes d'attente
|
||||
# immobile, puis la suite qui reprend sans un mot.
|
||||
#
|
||||
# Un UPID s'ecrit `UPID:<noeud>:<pid>:...`. Le noeud y est deja ; le redemander a
|
||||
# une variable, c'est se donner une chance de se tromper.
|
||||
url: >-
|
||||
{{ proxmox_api_racine }}/api2/json/nodes/{{
|
||||
proxmox_clone_lance.json.data.split(':')[1]
|
||||
{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_clone_noeud
|
||||
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
|
||||
method: GET
|
||||
headers:
|
||||
|
|
@ -345,18 +288,10 @@
|
|||
Le clonage de {{ proxmox_clone_nom }} (VMID {{ proxmox_clone_vmid }}) ne s'est pas
|
||||
termine correctement : etat « {{ proxmox_clone_tache.json.data.status | default('?')
|
||||
}} », sortie « {{ proxmox_clone_tache.json.data.exitstatus | default('?') }} ».
|
||||
Regarder la tache sur
|
||||
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
|
||||
Regarder la tache sur {{ proxmox_clone_noeud }}.
|
||||
when:
|
||||
- not proxmox_clone_deja_la
|
||||
# `exitstatus | default('OK')` faisait passer pour un SUCCES une attente qui
|
||||
# n'avait jamais rien observe : quand l'API repondait en erreur — le cas du
|
||||
# clonage inter-noeuds interroge sur le mauvais noeud — il n'y avait aucun
|
||||
# `json.data`, donc aucun `exitstatus`, donc « OK » par defaut. On exige
|
||||
# desormais d'avoir VU la tache s'arreter, et bien s'arreter.
|
||||
- >-
|
||||
(proxmox_clone_tache.json.data.status | default('')) != 'stopped'
|
||||
or (proxmox_clone_tache.json.data.exitstatus | default('')) != 'OK'
|
||||
- proxmox_clone_tache.json.data.exitstatus | default('OK') != 'OK'
|
||||
|
||||
- name: Dire pourquoi le clonage a echoue, s'il a echoue
|
||||
ansible.builtin.fail:
|
||||
|
|
@ -438,21 +373,6 @@
|
|||
- proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0
|
||||
- proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0
|
||||
|
||||
# Un desarmement doit se VOIR. Sans ce message, l'ecart entre ce que l'ecosysteme
|
||||
# declare et ce que la VM recoit resterait muet — et c'est precisement le genre de
|
||||
# silence qui a coute une soiree de diagnostic le 2026-08-25.
|
||||
- name: Dire que le pare-feu Proxmox reste desarme (pont classique, pas un VNet SDN)
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
`proxmox_clone_parefeu_interface` vaut vrai, mais {{ proxmox_clone_nom }} nait
|
||||
sur `{{ proxmox_clone_pont }}` — un pont Linux, pas un VNet SDN. Le pare-feu
|
||||
Proxmox y jetterait le retour des flux en epingle (`ctstate INVALID`) : il reste
|
||||
desarme. La frontiere continue de policer ce segment.
|
||||
when:
|
||||
- proxmox_clone_parefeu_interface | default(false) | bool
|
||||
- proxmox_clone_pont is defined
|
||||
- proxmox_clone_pont is match('^vmbr[0-9]+$')
|
||||
|
||||
- name: Ajuster le reseau de la VM clonee
|
||||
community.general.proxmox_nic:
|
||||
api_host: "{{ proxmox_api_host_effectif }}"
|
||||
|
|
@ -466,36 +386,7 @@
|
|||
model: virtio
|
||||
bridge: "{{ proxmox_clone_pont }}"
|
||||
tag: "{{ proxmox_clone_vlan | int if proxmox_clone_vlan is defined and proxmox_clone_vlan | string | length > 0 else omit }}"
|
||||
# LE PARE-FEU PROXMOX NE S'ARME QUE SUR UN VNET SDN (2026-08-25).
|
||||
#
|
||||
# `firewall=1` fait passer tout le trafic ponte par conntrack. Sur un VNET SDN
|
||||
# c'est sans consequence : en EVPN le routage inter-VNet se fait dans le VRF, SUR
|
||||
# LE NOEUD. Le flux ne quitte jamais l'hyperviseur, conntrack en voit un chemin
|
||||
# coherent, et le devis est-ouest (`devis_proxmox_fw.py`) peut le policer.
|
||||
#
|
||||
# Sur un PONT CLASSIQUE route par une frontiere externe, le meme reglage devient
|
||||
# un piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en
|
||||
# EPINGLE : la trame sort par le lien physique, la frontiere la route, et elle
|
||||
# revient sur LE MEME PONT. La meme table conntrack voit alors les deux moities de
|
||||
# la connexion, classe le retour `INVALID`, et `PVEFW-FORWARD` le jette.
|
||||
#
|
||||
# MESURE DU 2026-08-25, sur le site decoupe en quatre zones : `site-ops-01` et
|
||||
# `site-pki-01` (asgard), `site-dns-01` et `site-cache-01` (gandalf) ne se
|
||||
# joignaient plus — 0/8 — alors que TOUTES les paires entre noeuds differents
|
||||
# etaient a 8/8. Douze tentatives faisaient monter le compteur `ctstate INVALID`
|
||||
# de +112 sur asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus.
|
||||
#
|
||||
# Le defaut se deguise en panne reseau : la poignee TCP aboutit (SYN/SYN-ACK
|
||||
# creent l'etat), et ce sont les paquets de DONNEES qui disparaissent. On accuse
|
||||
# le MTU, la frontiere, la zone. Rien de tout cela n'y est pour quelque chose.
|
||||
#
|
||||
# L'INTENTION DECLAREE NE SUFFIT DONC PAS : un tenant declare
|
||||
# `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme
|
||||
# valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui
|
||||
# retombe sur `vmbr1` naitrait arme sur un pont classique. On croise donc
|
||||
# l'intention avec le pont REELLEMENT utilise. Un pont Linux se nomme `vmbrN` ;
|
||||
# un VNet SDN porte le nom derive de sa zone (`t29serv`).
|
||||
firewall: "{{ (proxmox_clone_parefeu_interface | default(false) | bool) and (proxmox_clone_pont is not match('^vmbr[0-9]+$')) }}"
|
||||
firewall: "{{ proxmox_clone_parefeu_interface | default(false) | bool }}"
|
||||
# `mtu: 1` est la valeur Proxmox pour « HERITE DU PONT » (virtio uniquement).
|
||||
#
|
||||
# Sans elle, l'invite nait a 1500 quel que soit le pont. En SDN EVPN la zone est a
|
||||
|
|
|
|||
|
|
@ -74,5 +74,5 @@
|
|||
import_playbook: groupes/client_smtp.yml
|
||||
- name: Couche agents — groupe client_backup
|
||||
import_playbook: groupes/client_backup.yml
|
||||
- name: Couche agents — groupe client_resolveur
|
||||
import_playbook: groupes/client_resolveur.yml
|
||||
- name: Couche agents — groupe client_unbound
|
||||
import_playbook: groupes/client_unbound.yml
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
---
|
||||
# Collections Ansible requises par Set-OPS.
|
||||
# Installer : ansible-galaxy collection install -r requirements.yml
|
||||
#
|
||||
# CE FICHIER EST LA SEULE SOURCE POUR UN RUNNER (2026-08-24). Le poste d'exploitation
|
||||
# n'installe QUE ce qui est declare ici — il n'herite pas du gros lot qui traine sur le
|
||||
# poste du mainteneur. Une collection utilisee par un role et absente d'ici marche chez
|
||||
# le mainteneur et echoue partout ailleurs : c'est le genre d'ecart qu'on ne voit qu'en
|
||||
# portant le moteur sur une autre machine.
|
||||
collections:
|
||||
- name: community.postgresql
|
||||
- name: community.general
|
||||
# `serveur_backup` : ansible.posix.authorized_key. Utilisee depuis longtemps, jamais
|
||||
# declaree — donc absente du runner, qui n'aurait pas pu deployer les sauvegardes.
|
||||
- name: ansible.posix
|
||||
# `community.docker` a ete RETIREE le 2026-08-24 : `serveur_collabora` etait la seule
|
||||
# exception conteneurisee de la flotte, et il tourne desormais en natif (paquet coolwsd
|
||||
# du depot amont Collabora). Plus aucun role n'a besoin de Docker.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,6 @@
|
|||
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
|
||||
# raisonnement, et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
|
||||
#
|
||||
# Braquer apt sur un cache absent casse toute installation ulterieure — y compris celle
|
||||
# des roles qui suivent dans le meme deploiement.
|
||||
#
|
||||
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
|
||||
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
|
||||
serveur: serveur_artefacts
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote installe des paquets, et doit les prendre chez lui quand l'ecosysteme a
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@
|
|||
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
|
||||
# raisonnement, et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
|
||||
#
|
||||
# Un collecteur qui expedie vers un puits inexistant accumule en local, sans le dire.
|
||||
#
|
||||
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
|
||||
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
|
||||
serveur: serveur_loki
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote expedie ses journaux. Un incident se reconstitue depuis les traces
|
||||
|
|
|
|||
|
|
@ -3,15 +3,6 @@
|
|||
# serveur du plan : une ligne recopiee 14 fois n'existe que pour etre oubliee une
|
||||
# quinzieme. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
|
||||
#
|
||||
# AUCUNE. `client_metrique` pose un exportateur que Prometheus vient LIRE : il n'attend
|
||||
# personne, c'est le serveur qui vient a lui. La distinction compte — toutes les
|
||||
# integrations ne dependent pas d'un service debout.
|
||||
#
|
||||
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
|
||||
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
|
||||
serveur: ~
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote est mesure. Une machine hors supervision tombe sans que personne
|
||||
|
|
|
|||
|
|
@ -4,23 +4,8 @@ client_pki_paquets:
|
|||
|
||||
client_pki_steppath: "/etc/step"
|
||||
|
||||
# L'AC INTERNE SE DÉRIVE DU GROUPE QUI LA PORTE, elle ne se nomme pas (2026-08-25).
|
||||
#
|
||||
# Cette URL écrivait `infra-pki-01` EN DUR. Chez un tenant ça marchait toujours : la
|
||||
# nomenclature nomme sa PKI ainsi, donc le littéral et la réalité coïncidaient. Le SITE
|
||||
# est le premier à l'appeler autrement — `site-pki-01` — et les cinq machines ont essayé
|
||||
# de s'enrôler auprès d'un hôte qui n'existe nulle part :
|
||||
#
|
||||
# lookup infra-pki-01.genese.internal ... no such host
|
||||
#
|
||||
# Le message accusait le DNS, alors que c'était le nom qui était faux. Un littéral qui a
|
||||
# raison par coïncidence est un bogue qui attend son premier cas particulier.
|
||||
#
|
||||
# `groups['serveur_step_ca']` dit qui porte l'autorité, dans un tenant comme sur un site.
|
||||
# Pas de repli : sans autorité déclarée, s'adresser à un nom inventé produirait exactement
|
||||
# la panne qu'on vient de corriger. Le rôle refuse (voir tasks/main.yml).
|
||||
client_pki_ca_hote: "{{ (groups['serveur_step_ca'] | default([])) | first | default('', true) }}"
|
||||
client_pki_ca_url: "https://{{ client_pki_ca_hote }}.{{ domaine_interne }}:8443"
|
||||
# AC interne (serveur_step_ca / infra-pki-01).
|
||||
client_pki_ca_url: "https://infra-pki-01.{{ domaine_interne }}:8443"
|
||||
client_pki_provisioner: "admin@{{ domaine_interne }}"
|
||||
|
||||
# Identite de l'hote.
|
||||
|
|
@ -50,27 +35,6 @@ client_pki_depot_cle_fichier: "/etc/apt/keyrings/smallstep.asc"
|
|||
client_pki_depot_uri: "https://packages.smallstep.com/stable/debian"
|
||||
client_pki_depot_suite: "debs"
|
||||
|
||||
# QUI PEUT LIRE LA CLE PRIVEE (2026-08-25).
|
||||
#
|
||||
# Elle est en `0600 root:root`, et c'est le bon defaut. Les consommateurs TLS habituels —
|
||||
# nginx, Postfix, Dovecot — demarrent en root, lisent la cle, puis deprivilegient : ils
|
||||
# n'ont jamais eu besoin d'autre chose.
|
||||
#
|
||||
# Forgejo tourne en `git` DES LE DEPART. Sur la forge du site, qui sert le genome sans
|
||||
# edge devant elle, la cle etait donc illisible par le seul processus qui en a besoin.
|
||||
#
|
||||
# Un GROUPE lecteur et `0640` reglent ca sans ouvrir la cle a tout le monde. On declare le
|
||||
# groupe explicitement, service par service : elargir par defaut serait exactement le
|
||||
# genre de commodite qui finit par rendre une cle privee lisible par `nogroup`.
|
||||
client_pki_cle_groupe: "root"
|
||||
client_pki_cle_mode: "0600"
|
||||
|
||||
# LE CERTIFICAT, LUI, EST PUBLIC — et l'etait deja avant d'etre sur ce disque : il est
|
||||
# presente a chaque poignee de main, a quiconque se connecte. Le garder en `0600` ne
|
||||
# protegeait rien et empechait tout service non-root de le lire. Le certificat racine est
|
||||
# d'ailleurs deja en `0644` juste a cote, pour la meme raison.
|
||||
client_pki_cert_mode: "0644"
|
||||
|
||||
# Services a recharger apres un renouvellement de cert : les VRAIS consommateurs
|
||||
# (nginx sur l'edge, postfix/dovecot sur le mail, slapd sur l'annuaire). Sans ca,
|
||||
# le cert est renouvele sur disque mais le service sert l'ancien jusqu'a un reload.
|
||||
|
|
|
|||
|
|
@ -2,16 +2,6 @@
|
|||
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
|
||||
# raisonnement, et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
|
||||
#
|
||||
# L'AC doit exister ET repondre avant qu'un hote lui demande un certificat. Sur l'hote
|
||||
# qui la porte, le role recharge `step-ca` en reemettant son propre certificat :
|
||||
# pendant cette fenetre, elle ne repond plus. Mesure du 2026-08-25 : quatre clients en
|
||||
# echec simultane, message `TLS handshake timeout` — qui accuse le reseau, pas l'ordre.
|
||||
#
|
||||
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
|
||||
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
|
||||
serveur: serveur_step_ca
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote fait confiance a l'AC interne et porte un certificat : c'est le
|
||||
|
|
|
|||
|
|
@ -1,22 +1,4 @@
|
|||
---
|
||||
# SANS AUTORITÉ DÉCLARÉE, ON NE DEVINE PAS SON NOM.
|
||||
#
|
||||
# L'URL de l'AC se dérivait d'un littéral (`infra-pki-01`), vrai chez tout tenant par
|
||||
# coïncidence de nomenclature. Le premier écosystème à nommer sa PKI autrement a vu ses
|
||||
# cinq machines s'enrôler auprès d'un hôte inexistant, et le message accusait le DNS.
|
||||
#
|
||||
# Elle se dérive maintenant de `groups['serveur_step_ca']`. Si ce groupe est vide, il n'y
|
||||
# a pas d'autorité du tout : le dire ici vaut mieux que de laisser `step ca bootstrap`
|
||||
# échouer sur un nom tronqué, trois tâches plus loin, en parlant de résolution.
|
||||
- name: Une autorité de certification est-elle déclarée ?
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- client_pki_ca_hote | length > 0
|
||||
fail_msg: >-
|
||||
Aucun hôte ne porte `serveur_step_ca` dans cet inventaire : il n'y a pas d'autorité
|
||||
interne à qui demander un certificat. Déclarer une PKI, ou exempter cet écosystème
|
||||
de `client_pki` — mais avec une raison, jamais en silence.
|
||||
|
||||
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
|
||||
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
|
||||
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
|
||||
|
|
@ -197,32 +179,6 @@
|
|||
changed_when: true
|
||||
notify: Recharger les consommateurs du cert
|
||||
|
||||
# POSÉ À CHAQUE PASSAGE, PAS SEULEMENT À L'ÉMISSION.
|
||||
#
|
||||
# `step ca certificate` réécrit la clé avec ses propres droits, et le renouvellement
|
||||
# automatique aussi. Ne régler les droits qu'au moment où le certificat change les
|
||||
# perdrait au premier renouvellement — une panne qui surviendrait des semaines plus tard,
|
||||
# sans rapport visible avec cette tâche.
|
||||
- name: Donner accès à la clé privée au service qui doit la lire
|
||||
ansible.builtin.file:
|
||||
path: "{{ client_pki_cle }}"
|
||||
owner: root
|
||||
group: "{{ client_pki_cle_groupe }}"
|
||||
mode: "{{ client_pki_cle_mode }}"
|
||||
when: client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600'
|
||||
notify: Recharger les consommateurs du cert
|
||||
|
||||
# Le certificat est public. `step` l'ecrit en 0600 comme la cle ; on le rend lisible, sans
|
||||
# quoi un service non-root echoue sur le CERT apres avoir obtenu la CLE — et le message
|
||||
# parle de permission sur un fichier que rien ne justifie de proteger.
|
||||
- name: Rendre le certificat d'hôte lisible (il est public par nature)
|
||||
ansible.builtin.file:
|
||||
path: "{{ client_pki_cert }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "{{ client_pki_cert_mode }}"
|
||||
notify: Recharger les consommateurs du cert
|
||||
|
||||
- name: Deployer l'unite systemd de renouvellement
|
||||
ansible.builtin.template:
|
||||
src: cert-renewer@.service.j2
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
---
|
||||
# INTÉGRATION CLIENTE : désigner le résolveur de l'écosystème.
|
||||
#
|
||||
# CE RÔLE N'INSTALLE PLUS RIEN (2026-08-24). Il posait un Unbound sur CHAQUE VM — N démons
|
||||
# identiques, ~21 Mo chacun, pour quelques centaines de requêtes. Le résolveur est
|
||||
# désormais un service du tenant (`serveur_resolveur`), et ce rôle ne fait plus qu'une
|
||||
# chose : écrire `/etc/resolv.conf`.
|
||||
#
|
||||
# L'ancien nom mentait dès lors qu'il n'installait plus Unbound — d'où `client_resolveur`.
|
||||
|
||||
# DÉRIVÉ de l'inventaire : l'hôte qui porte le résolveur. Aucun résolveur au plan rend une
|
||||
# valeur vide, et le rôle ne touche à rien (voir `client_resolveur_actif`).
|
||||
client_resolveur_hote: >-
|
||||
{{ (groups['serveur_resolveur'] | default([]) | first | default('')) }}
|
||||
client_resolveur_adresse: >-
|
||||
{{ hostvars[client_resolveur_hote].ansible_host | default('')
|
||||
if client_resolveur_hote else '' }}
|
||||
|
||||
# L'INTÉGRATION SUIT L'EXISTENCE DU SERVICE — elle ne se déclare pas. Un écosystème sans
|
||||
# résolveur garde la résolution d'amorçage posée par cloud-init : c'est un choix valide,
|
||||
# pas une panne.
|
||||
client_resolveur_actif: "{{ (groups['serveur_resolveur'] | default([])) | length > 0 }}"
|
||||
|
||||
client_resolveur_zone_interne: "{{ domaine_interne }}"
|
||||
client_resolveur_resolv_conf: "/etc/resolv.conf"
|
||||
|
||||
# --- BASCULE PROTÉGÉE ---------------------------------------------------------
|
||||
#
|
||||
# Réécrire /etc/resolv.conf peut couper toute la flotte si la cible est mauvaise. Le rôle
|
||||
# exige donc une confirmation ET vérifie que le résolveur répond DÉJÀ — pour l'interne et
|
||||
# pour l'Internet — avant de basculer quoi que ce soit.
|
||||
client_resolveur_apply: false
|
||||
client_resolveur_confirm: false
|
||||
client_resolveur_attente_reconnexion: 120
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
# Flux réseau du client de résolution. Voir docs/flux-conception.md.
|
||||
#
|
||||
# Il n'écoute plus rien : depuis 2026-08-24, le résolveur est un service du tenant et
|
||||
# non un démon local. L'hôte ne fait qu'interroger.
|
||||
flux:
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: serveur_resolveur
|
||||
chiffrement: clair
|
||||
raison: "Résoudre auprès du résolveur de l'écosystème, et de personne d'autre."
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: serveur_resolveur
|
||||
chiffrement: clair
|
||||
raison: "Réponses longues et bascule TCP, obligatoires en DNS."
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
|
||||
# raisonnement, et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
|
||||
#
|
||||
# Basculer un hote sur un resolveur qui n'est pas encore pret le rend MUET, et avec lui
|
||||
# tout ce qui resout un nom. C'est la dependance la plus dangereuse du lot : le runner
|
||||
# qui devrait reparer tombe avec les autres.
|
||||
#
|
||||
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
|
||||
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
|
||||
serveur: serveur_resolveur
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote resout des noms, et doit le faire aupres du resolveur de son ecosysteme
|
||||
plutot que d'un tiers. Une machine restee sur la resolution d'amorcage envoie
|
||||
chacune de ses questions dehors, sans que rien ne le signale.
|
||||
# AUCUNE exemption, pas meme l'hote qui PORTE le resolveur : il se sert lui-meme, et
|
||||
# c'est le cas le plus simple. L'exempter reviendrait a dire que le resolveur ne se
|
||||
# fait pas confiance.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
---
|
||||
- name: Exiger un résolveur joignable quand l'intégration est active
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- client_resolveur_adresse | length > 0
|
||||
fail_msg: >-
|
||||
client_resolveur est actif mais aucun hôte ne porte `serveur_resolveur`, ou son
|
||||
adresse est introuvable. Déclarer le service au plan, ou forcer
|
||||
client_resolveur_actif=false.
|
||||
when: client_resolveur_actif | bool
|
||||
|
||||
- name: Refuser la bascule du résolveur sans confirmation explicite
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- client_resolveur_confirm | bool
|
||||
fail_msg: "Bascule refusée : définir client_resolveur_confirm=true (avec client_resolveur_apply=true)."
|
||||
when: client_resolveur_apply | bool
|
||||
|
||||
# ON VÉRIFIE AVANT DE BASCULER, PAS APRÈS. Un resolv.conf qui pointe vers un service muet
|
||||
# coupe l'hôte de tout — et le diagnostic, lui, exige de résoudre des noms.
|
||||
- name: Valider que le résolveur répond AVANT de basculer (interne + Internet)
|
||||
ansible.builtin.command: "dig @{{ client_resolveur_adresse }} {{ item.nom }} {{ item.type }} +short"
|
||||
register: client_resolveur_validation
|
||||
changed_when: false
|
||||
failed_when: client_resolveur_validation.stdout | trim == ''
|
||||
loop:
|
||||
- { nom: "{{ client_resolveur_zone_interne }}", type: "SOA" }
|
||||
- { nom: "deb.debian.org", type: "A" }
|
||||
loop_control:
|
||||
label: "{{ item.nom }} {{ item.type }}"
|
||||
when:
|
||||
- client_resolveur_actif | bool
|
||||
- client_resolveur_apply | bool
|
||||
- client_resolveur_confirm | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Désigner le résolveur de l'écosystème
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ client_resolveur_resolv_conf }}"
|
||||
content: |
|
||||
# Généré par Set-OPS (rôle client_resolveur). Résolveur de l'écosystème.
|
||||
search {{ client_resolveur_zone_interne }}
|
||||
nameserver {{ client_resolveur_adresse }}
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when:
|
||||
- client_resolveur_actif | bool
|
||||
- client_resolveur_apply | bool
|
||||
- client_resolveur_confirm | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
# RETIRER L'ANCIEN DÉMON LOCAL — APRÈS la bascule, jamais avant.
|
||||
#
|
||||
# Sans cette tâche, les N Unbound qu'on voulait supprimer resteraient installés et actifs :
|
||||
# le rôle aurait changé le `resolv.conf` sans rien économiser, et la raison même du
|
||||
# changement serait perdue. Une intégration qui ne sait pas se retirer est un piège
|
||||
# différé.
|
||||
#
|
||||
# L'ORDRE COMPTE. Retirer le paquet avant d'avoir écrit le nouveau `resolv.conf` couperait
|
||||
# l'hôte de toute résolution au milieu du play — et le diagnostic, lui, a besoin de
|
||||
# résoudre des noms.
|
||||
#
|
||||
# L'HÔTE QUI PORTE LE RÉSOLVEUR EST ÉPARGNÉ, évidemment : c'est le même paquet.
|
||||
- name: Retirer le résolveur local devenu inutile
|
||||
ansible.builtin.apt:
|
||||
name: unbound
|
||||
state: absent
|
||||
purge: false
|
||||
when:
|
||||
- client_resolveur_actif | bool
|
||||
- client_resolveur_apply | bool
|
||||
- client_resolveur_confirm | bool
|
||||
- inventory_hostname not in (groups['serveur_resolveur'] | default([]))
|
||||
- not ansible_check_mode
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# client_resolveur
|
||||
# client_unbound
|
||||
|
||||
**Résolveur local** par nœud (Unbound) : cache + récursion, avec une **stub-zone** vers
|
||||
l'autoritatif interne (PowerDNS). Rend la résolution *dynamique* sans casser Internet.
|
||||
|
|
@ -21,8 +21,8 @@ Changer le résolveur d'un nœud à distance, c'est risquer de le rendre muet. L
|
|||
donc **deux** interrupteurs, et vérifie avant d'agir :
|
||||
|
||||
```yaml
|
||||
client_resolveur_apply: true
|
||||
client_resolveur_confirm: true
|
||||
client_unbound_apply: true
|
||||
client_unbound_confirm: true
|
||||
```
|
||||
|
||||
Sans les deux, le rôle installe et démarre Unbound mais **ne touche pas** à `/etc/resolv.conf`
|
||||
|
|
@ -33,18 +33,18 @@ qu'ensuite. Même doctrine que la règle 4 d'`AGENTS.md` : action risquée = con
|
|||
## Variables
|
||||
| Variable | Défaut | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `client_resolveur_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
|
||||
| `client_resolveur_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
|
||||
| `client_resolveur_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
|
||||
| `client_resolveur_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
|
||||
| `client_resolveur_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
|
||||
| `client_unbound_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
|
||||
| `client_unbound_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
|
||||
| `client_unbound_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
|
||||
| `client_unbound_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
|
||||
| `client_unbound_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
|
||||
|
||||
## Flux (`meta/flux.yml`)
|
||||
`53/udp` entrant en **localhost** · `53/tcp` sortant vers `serveur_powerdns`.
|
||||
|
||||
## Notes / limites
|
||||
- Transitaires vides = Unbound **récurse lui-même** (racine + DNSSEC). Si la récursion
|
||||
sortante est bloquée par le réseau, renseigner `client_resolveur_transitaires`.
|
||||
sortante est bloquée par le réseau, renseigner `client_unbound_transitaires`.
|
||||
- Le rôle exige l'IP de l'autoritatif interne (assertion) : `serveur_powerdns` doit être
|
||||
dans l'inventaire avec un `ansible_host`.
|
||||
- DoT (DNS chiffré) n'est pas encore branché — c'est un des flux restants du zéro-confiance.
|
||||
28
roles/client_unbound/defaults/main.yml
Normal file
28
roles/client_unbound/defaults/main.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
# Résolveur local Unbound par nœud : cache + récursion (ou forward), avec une
|
||||
# stub-zone vers l'autoritatif interne (PowerDNS). Rend la résolution dynamique
|
||||
# (au lieu du plancher /etc/hosts statique) sans casser Internet. La bascule de
|
||||
# /etc/resolv.conf est PROTÉGÉE (apply + confirm) — bascule du résolveur validée avant application.
|
||||
client_unbound_paquets:
|
||||
- unbound
|
||||
- bind9-dnsutils # dig, pour valider avant de basculer le resolver
|
||||
client_unbound_service: "unbound"
|
||||
client_unbound_ecoute: "127.0.0.1"
|
||||
|
||||
# Zone interne autoritative (PowerDNS) — résolue via une stub-zone.
|
||||
client_unbound_zone_interne: "{{ domaine_interne }}"
|
||||
# IP du serveur autoritatif interne. Dérivée du groupe serveur_powerdns.
|
||||
client_unbound_dns_autoritatif: "{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if groups.get('serveur_powerdns') else '' }}"
|
||||
|
||||
# Transitaires pour '.' (Internet). Vide => Unbound recurse lui-même (racine + DNSSEC).
|
||||
# À renseigner si la récursion sortante est bloquée (forward vers l'upstream réseau).
|
||||
client_unbound_transitaires: []
|
||||
|
||||
# Bascule de /etc/resolv.conf vers 127.0.0.1 : les DEUX requis (garde-fou anti-coupure).
|
||||
client_unbound_apply: false
|
||||
client_unbound_confirm: false
|
||||
client_unbound_resolv_conf: "/etc/resolv.conf"
|
||||
|
||||
# Delai maximal pour que l'hote reponde apres le redemarrage d'Unbound. Depasser reste
|
||||
# un ECHEC : on attend une machine qui revient, pas une panne qu'on tait.
|
||||
client_unbound_attente_reconnexion: 180
|
||||
18
roles/client_unbound/handlers/main.yml
Normal file
18
roles/client_unbound/handlers/main.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
# `ignore_unreachable` : le redemarrage a deja fait perdre la connexion en cours de
|
||||
# deploiement — « Connection timed out during banner exchange », le 2026-08-09 sur
|
||||
# web-dorsal-01. La CAUSE n'est pas etablie : `usedns no` ecarte la resolution inverse
|
||||
# de sshd, et le journal du deploiement rate a ete ecrase avant d'etre lu. Ce que le
|
||||
# depot sait deja de ce symptome (Makefile, `_attendre-hote`) : a travers la frontiere,
|
||||
# le TCP s'etablit par proxy SYN et l'echec se lit ainsi meme quand l'hote n'est
|
||||
# simplement pas la.
|
||||
#
|
||||
# On ne masque donc pas une panne : une perte ICI n'abandonne plus les couches
|
||||
# suivantes, et `wait_for_connection` exige que l'hote revienne pour de bon. S'il ne
|
||||
# revient pas, la tache suivante echoue — franchement.
|
||||
- name: Redémarrer unbound
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ client_unbound_service }}"
|
||||
state: restarted
|
||||
when: not ansible_check_mode
|
||||
ignore_unreachable: true
|
||||
32
roles/client_unbound/meta/flux.yml
Normal file
32
roles/client_unbound/meta/flux.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
# Flux réseau du résolveur local Unbound (opt-in). Voir docs/flux-conception.md.
|
||||
flux:
|
||||
- sens: ingress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: localhost
|
||||
chiffrement: clair
|
||||
raison: "Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1)."
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: serveur_powerdns
|
||||
chiffrement: clair
|
||||
raison: "Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS)."
|
||||
|
||||
# Récursion sortante : `client_unbound_transitaires` est vide par défaut, donc Unbound
|
||||
# interroge lui-même la racine puis les autoritatifs — c'est le choix souverain, sans
|
||||
# dépendre du résolveur d'un fournisseur. Renseigner des transitaires rendrait ces deux
|
||||
# flux inutiles, mais les laisser déclarés ne coûte rien et évite une panne muette.
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: externe
|
||||
chiffrement: clair
|
||||
raison: "Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est."
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
chiffrement: clair
|
||||
raison: "Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC)."
|
||||
16
roles/client_unbound/meta/integration.yml
Normal file
16
roles/client_unbound/meta/integration.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
# Politique d'integration. Declaree ICI, une fois, plutot que recopiee sur chaque
|
||||
# serveur du plan. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
|
||||
integration:
|
||||
universelle: true
|
||||
raison: >-
|
||||
Tout hote doit resoudre des noms — ne serait-ce que pour `apt`. Le DNS
|
||||
autoritatif du tenant (PowerDNS) ne repond QUE pour la zone souveraine et
|
||||
refuse le reste : sans resolveur recursif, une machine ne peut meme pas
|
||||
s'installer. `client_unbound` delegue la zone interne a l'autoritatif et
|
||||
recurse depuis la racine, sans dependre du resolveur d'un fournisseur.
|
||||
# Le serveur autoritatif est exempte : PowerDNS occupe deja le port 53 sur cet
|
||||
# hote. Y ajouter Unbound produirait un conflit de liaison, pas un resolveur.
|
||||
# Il resout par le resolveur d'amorcage (`proxmox_clone_dns`), qui lui suffit :
|
||||
# ses seuls besoins externes sont apt et NTP.
|
||||
sauf_role: serveur_powerdns
|
||||
83
roles/client_unbound/tasks/main.yml
Normal file
83
roles/client_unbound/tasks/main.yml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
- name: Exiger l'IP de l'autoritatif interne (PowerDNS)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- client_unbound_dns_autoritatif | length > 0
|
||||
fail_msg: "client_unbound_dns_autoritatif requis (aucun hôte serveur_powerdns actif ?)."
|
||||
|
||||
- name: Installer Unbound
|
||||
ansible.builtin.apt:
|
||||
name: "{{ client_unbound_paquets }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Déployer la configuration Unbound (stub-zone interne + récursion)
|
||||
ansible.builtin.template:
|
||||
src: setops.conf.j2
|
||||
dest: /etc/unbound/unbound.conf.d/setops.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Redémarrer unbound
|
||||
|
||||
- name: Valider la configuration Unbound
|
||||
ansible.builtin.command: unbound-checkconf
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Activer et démarrer Unbound
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ client_unbound_service }}"
|
||||
enabled: true
|
||||
state: started
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Appliquer les redémarrages Unbound avant toute validation/bascule
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# Attendre une CONDITION — que l'hote reponde — et non une duree. Si la connexion a
|
||||
# saute pendant le redemarrage, on la retablit ici ; sinon la tache passe aussitot.
|
||||
- name: Exiger que l hote reponde avant de poursuivre
|
||||
ansible.builtin.wait_for_connection:
|
||||
timeout: "{{ client_unbound_attente_reconnexion }}"
|
||||
sleep: 5
|
||||
when: not ansible_check_mode
|
||||
|
||||
# --- Bascule du resolver : PROTÉGÉE + validée (ne casse jamais un nœud) ---
|
||||
- name: Refuser la bascule du resolver sans confirmation explicite
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- client_unbound_confirm | bool
|
||||
fail_msg: "Bascule refusée : définir client_unbound_confirm=true (avec client_unbound_apply=true)."
|
||||
when: client_unbound_apply | bool
|
||||
|
||||
- name: Valider qu'Unbound résout AVANT de basculer (interne + Internet)
|
||||
ansible.builtin.command: "dig @127.0.0.1 {{ item.nom }} {{ item.type }} +short"
|
||||
register: client_unbound_validation
|
||||
changed_when: false
|
||||
failed_when: client_unbound_validation.stdout | trim == ''
|
||||
loop:
|
||||
- { nom: "{{ client_unbound_zone_interne }}", type: "SOA" }
|
||||
- { nom: "deb.debian.org", type: "A" }
|
||||
loop_control:
|
||||
label: "{{ item.nom }} {{ item.type }}"
|
||||
when:
|
||||
- client_unbound_apply | bool
|
||||
- client_unbound_confirm | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Basculer /etc/resolv.conf vers Unbound local (127.0.0.1)
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ client_unbound_resolv_conf }}"
|
||||
content: |
|
||||
# Généré par Set-OPS (rôle client_unbound). Résolveur local Unbound.
|
||||
search {{ client_unbound_zone_interne }}
|
||||
nameserver 127.0.0.1
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when:
|
||||
- client_unbound_apply | bool
|
||||
- client_unbound_confirm | bool
|
||||
- not ansible_check_mode
|
||||
25
roles/client_unbound/templates/setops.conf.j2
Normal file
25
roles/client_unbound/templates/setops.conf.j2
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Géré par Set-OPS (rôle client_unbound). Ne pas éditer à la main.
|
||||
# Résolveur local : cache + récursion (ou forward), stub-zone vers l'autoritatif interne.
|
||||
server:
|
||||
interface: {{ client_unbound_ecoute }}
|
||||
access-control: 127.0.0.0/8 allow
|
||||
hide-identity: yes
|
||||
hide-version: yes
|
||||
prefetch: yes
|
||||
do-ip6: no
|
||||
# La zone interne n'est pas signée DNSSEC (PowerDNS autoritatif, dnssec off).
|
||||
domain-insecure: "{{ client_unbound_zone_interne }}"
|
||||
|
||||
# Zone interne : déléguée à l'autoritatif (PowerDNS).
|
||||
stub-zone:
|
||||
name: "{{ client_unbound_zone_interne }}"
|
||||
stub-addr: {{ client_unbound_dns_autoritatif }}
|
||||
{% if client_unbound_transitaires | length > 0 %}
|
||||
|
||||
# Internet : forward vers l'upstream réseau (au lieu de la récursion directe).
|
||||
forward-zone:
|
||||
name: "."
|
||||
{% for t in client_unbound_transitaires %}
|
||||
forward-addr: {{ t }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
|
@ -50,28 +50,3 @@ common_packages_list:
|
|||
# Attente du verrou dpkg (secondes). Genereux : au premier demarrage, les mises a
|
||||
# jour automatiques de Debian le tiennent par vagues pendant plusieurs minutes.
|
||||
common_packages_lock_timeout: 300
|
||||
|
||||
# --- QUI DÉCIDE DE CE QUI S'INSTALLE (2026-08-25) -----------------------------
|
||||
#
|
||||
# Debian arme `unattended-upgrades` et les minuteurs `apt-daily` par défaut. Set-OPS
|
||||
# applique DÉJÀ les mises à jour, explicitement, dans ce rôle. **Deux mécanismes font le
|
||||
# même travail sur la même flotte, et se disputent le même verrou dpkg.**
|
||||
#
|
||||
# Sur une machine tranquille ça ne se voit jamais. Après six redémarrages et des heures
|
||||
# sans réseau — ce qu'a vécu le site le 2026-08-25 — `unattended-upgrades` rattrape tout
|
||||
# son retard et tient le verrou 48 minutes. `lock_timeout` vaut 300 s : les déploiements
|
||||
# échouent, trois fois de suite, sur une course que personne n'a décidée.
|
||||
#
|
||||
# Allonger l'attente serait un pansement : on patienterait plus longtemps devant un
|
||||
# concurrent qu'on n'a pas voulu. On désarme donc, et Set-OPS reste seul maître de ce qui
|
||||
# s'installe — un écosystème souverain ne laisse pas un démon décider seul quand redémarrer
|
||||
# ses services.
|
||||
#
|
||||
# CONTREPARTIE ASSUMÉE, ET ELLE EST RÉELLE : les correctifs de sécurité ne s'appliquent
|
||||
# plus qu'au passage de Set-OPS. C'est tenable si les déploiements sont réguliers, et ça
|
||||
# devient une dette sinon. Mettre à `false` pour rendre la main à Debian.
|
||||
common_packages_desarmer_maj_auto: true
|
||||
common_packages_unites_maj_auto:
|
||||
- apt-daily.timer
|
||||
- apt-daily-upgrade.timer
|
||||
- unattended-upgrades.service
|
||||
|
|
|
|||
|
|
@ -6,21 +6,6 @@
|
|||
#
|
||||
# Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris
|
||||
# ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher.
|
||||
# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS.
|
||||
#
|
||||
# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement
|
||||
# libérer — l'ordre n'est pas cosmétique. `state: stopped` en plus de `masked` : masquer
|
||||
# empêche un démarrage futur, pas celui qui est déjà en cours.
|
||||
- name: Désarmer les mises à jour automatiques de Debian (Set-OPS les applique lui-même)
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
masked: true
|
||||
loop: "{{ common_packages_unites_maj_auto }}"
|
||||
when: common_packages_desarmer_maj_auto | bool
|
||||
failed_when: false # une unite absente n'est pas une faute : elle est desarmee
|
||||
|
||||
- name: Mettre à jour le cache APT
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ C'est ce qui rend l'**ordre de reconstruction** possible : appliqué dans la cou
|
|||
déploiement d'une instance neuve.
|
||||
|
||||
Les trois couches de résolution : ce plancher → PowerDNS autoritatif → Unbound local
|
||||
(`client_resolveur`, opt-in). Cf. `docs/dns-interne.md`.
|
||||
(`client_unbound`, opt-in). Cf. `docs/dns-interne.md`.
|
||||
|
||||
## Variables
|
||||
| Variable | Défaut | Rôle |
|
||||
|
|
|
|||
|
|
@ -1,32 +1,4 @@
|
|||
---
|
||||
# CLOUD-INIT REECRIT /etc/hosts A CHAQUE DEMARRAGE (2026-08-24).
|
||||
#
|
||||
# Le gabarit dore laisse `manage_etc_hosts: true`. A chaque boot, cloud-init regenere
|
||||
# /etc/hosts depuis son propre modele — et efface le plancher de resolution que ce role
|
||||
# vient de poser. La machine perd alors la seule facon qu'elle a de nommer ses voisines
|
||||
# sans DNS, et personne ne le voit : rien n'echoue tant qu'on ne demande pas un nom.
|
||||
#
|
||||
# CONSTATE SUR infra-dns-01 : redemarre a 18h53 lors d'un deplacement de disque, il a
|
||||
# perdu ses six entrees de flotte. Le defaut est apparu deux jours plus tard, sous la
|
||||
# forme d'un `apt update` qui n'arrivait plus a resoudre le cache d'artefacts — un
|
||||
# message qui ne parlait pas du tout du vrai probleme.
|
||||
#
|
||||
# On le desactive par un fragment plutot qu'en editant cloud.cfg : le gabarit reste
|
||||
# intact, et `apt purge cloud-init` ou une remise a zero rend la machine a son etat
|
||||
# d'origine.
|
||||
- name: Empêcher cloud-init de réécrire le plancher à chaque démarrage
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg
|
||||
content: |
|
||||
# GÉNÉRÉ par Set-OPS (rôle hosts_statiques). NE PAS éditer à la main.
|
||||
# /etc/hosts est le PLANCHER DE RÉSOLUTION de l'écosystème, dérivé de l'inventaire.
|
||||
# Laisser cloud-init le régénérer au démarrage l'effacerait en silence.
|
||||
manage_etc_hosts: false
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: hosts_statiques_actif | bool
|
||||
|
||||
# Alias d'exposition (best-effort) : dérivés du plan si celui-ci est disponible.
|
||||
- name: Vérifier la présence des registres du plan (sur le nœud de contrôle)
|
||||
ansible.builtin.stat:
|
||||
|
|
@ -44,29 +16,18 @@
|
|||
- hosts_statiques_publier_expositions | bool
|
||||
- setops_plan_dir is defined
|
||||
|
||||
# CHAQUE REGISTRE EST CHARGÉ POUR LUI-MÊME (2026-08-25).
|
||||
#
|
||||
# La condition exigeait que les DEUX fichiers existent (`length == 2`). Un tout-ou-rien
|
||||
# sans raison : `domaines.yml` déclare les domaines PUBLICS, et un écosystème peut
|
||||
# légitimement n'en avoir aucun — c'est le cas du SITE, dont tous les services sont
|
||||
# internes.
|
||||
#
|
||||
# Conséquence : `applications.yml` n'était pas chargé non plus, la dérivation rendait une
|
||||
# liste vide, et le plancher s'écrivait SANS AUCUN ALIAS — sans erreur. Le certificat de
|
||||
# la forge portait `forge.genese.internal` et ce nom ne résolvait nulle part.
|
||||
#
|
||||
# On charge donc ce qui existe, fichier par fichier. Un registre absent n'est pas une
|
||||
# faute ; il est simplement absent.
|
||||
- name: Charger les registres applications et domaines (si présents)
|
||||
ansible.builtin.include_vars:
|
||||
file: "{{ item.item }}"
|
||||
loop: "{{ hosts_statiques_plan.results | default([]) }}"
|
||||
file: "{{ item }}"
|
||||
loop:
|
||||
- "{{ setops_plan_dir }}/applications.yml"
|
||||
- "{{ setops_plan_dir }}/domaines.yml"
|
||||
loop_control:
|
||||
label: "{{ item.item | basename }}"
|
||||
label: "{{ item | basename }}"
|
||||
when:
|
||||
- hosts_statiques_actif | bool
|
||||
- hosts_statiques_publier_expositions | bool
|
||||
- item.stat.exists | default(false)
|
||||
- (hosts_statiques_plan.results | default([]) | selectattr('stat.exists', 'defined') | selectattr('stat.exists') | list | length) == 2
|
||||
|
||||
- name: Dériver les alias d'exposition (FQDN exposés → edge qui les sert)
|
||||
ansible.builtin.set_fact:
|
||||
|
|
|
|||
|
|
@ -45,21 +45,3 @@ serveur_artefacts_hors_ligne: false
|
|||
# Journalisation : apt-cacher-ng tient un journal des requêtes servies. C'est lui qui
|
||||
# permet de répondre à « ce paquet, l'avons-nous servi ou est-il venu du dehors ? ».
|
||||
serveur_artefacts_journal: "/var/log/apt-cacher-ng"
|
||||
|
||||
# --- LE CHAINAGE DES CACHES (2026-08-24) --------------------------------------
|
||||
#
|
||||
# Chaque écosystème garde SON cache, et celui-ci prend le cache de l'hébergeur comme
|
||||
# amont. Debian n'est alors téléchargé qu'UNE FOIS pour tout le site :
|
||||
#
|
||||
# VM du tenant -> cache du tenant -> cache du SITE -> Debian
|
||||
#
|
||||
# POURQUOI PAS UN CACHE UNIQUE OÙ TOUTES LES VM TAPENT. Il aurait fallu ouvrir un flux de
|
||||
# chaque machine de chaque tenant vers un hôte d'un autre tenant — N×M chemins à travers
|
||||
# le default-deny. Avec le chaînage il n'y en a qu'UN par écosystème, entre deux caches.
|
||||
#
|
||||
# Et l'étanchéité y gagne : le cache du site ne voit que des requêtes AGRÉGÉES, jamais
|
||||
# quelle machine installe quoi.
|
||||
#
|
||||
# Vide = ce cache va directement à l'amont Debian. C'est le cas du cache du SITE lui-même,
|
||||
# et de tout écosystème qui s'est émancipé (docs/filiation-emancipation.md).
|
||||
serveur_artefacts_amont: ""
|
||||
|
|
|
|||
|
|
@ -16,15 +16,3 @@ flux:
|
|||
pair: externe
|
||||
chiffrement: clair
|
||||
raison: "Remplir le cache depuis les dépôts Debian amont (deb.debian.org, security)."
|
||||
# LE CHAINAGE, cote consommateur. Un seul port, et seulement vers un CACHE — jamais
|
||||
# d'une VM vers le cache d'un autre tenant.
|
||||
#
|
||||
# L'INGRESS CORRESPONDANT N'EST PAS ICI : il appartient a `serveur_cache_site`, le
|
||||
# cache designe du site. Sans cette separation, chaque cache aurait accepte chaque
|
||||
# autre — un maillage complet, visible au devis du 2026-08-24.
|
||||
- sens: egress
|
||||
port: 3142
|
||||
protocole: tcp
|
||||
pair: voisins_site
|
||||
chiffrement: clair
|
||||
raison: "Prendre le cache du site comme amont, plutot que d'aller chez Debian."
|
||||
|
|
|
|||
|
|
@ -17,9 +17,3 @@ Offlinemode: 1
|
|||
{% else %}
|
||||
Offlinemode: 0
|
||||
{% endif %}
|
||||
|
||||
{% if serveur_artefacts_amont %}
|
||||
# CHAÎNAGE : ce cache passe par celui de l'hébergeur plutôt que d'aller chez Debian.
|
||||
# Debian n'est ainsi téléchargé qu'une fois pour tout le site.
|
||||
Proxy: {{ serveur_artefacts_amont }}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
# serveur_cache_site
|
||||
|
||||
**Le cache d'artefacts du SITE** : celui que les écosystèmes voisins prennent comme amont,
|
||||
pour que Debian ne soit téléchargé qu'une fois pour toute la fabric.
|
||||
|
||||
## Le chaînage
|
||||
|
||||
```
|
||||
VM du tenant → cache du tenant → cache du SITE → Debian
|
||||
```
|
||||
|
||||
Chaque écosystème **garde le sien** — il reste maître de ses paquets et s'en émancipe d'un
|
||||
drapeau — mais il ne va plus chercher chez Debian.
|
||||
|
||||
**Un cache unique où toutes les VM taperaient** aurait demandé un flux de chaque machine
|
||||
de chaque tenant vers un hôte d'un autre tenant : N×M chemins à travers le default-deny.
|
||||
Avec le chaînage il n'y en a **qu'un par écosystème**, entre deux caches.
|
||||
|
||||
Et l'étanchéité y gagne : le cache du site ne voit que des requêtes **agrégées**, jamais
|
||||
quelle machine installe quoi.
|
||||
|
||||
## Pourquoi deux rôles plutôt qu'un drapeau
|
||||
|
||||
Les flux se déclarent dans `meta/flux.yml`, qui est **statique**. Un rôle unique aurait dû
|
||||
déclarer l'ingress inter-tenant pour **tous** les caches — et le devis l'a montré :
|
||||
|
||||
```
|
||||
+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS un maillage complet,
|
||||
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS chaque cache acceptant chaque autre
|
||||
```
|
||||
|
||||
Avec la séparation, l'ingress n'existe qu'ici, et un seul cache est joignable.
|
||||
|
||||
## Qui peut le déclarer
|
||||
|
||||
**L'écosystème de l'hébergeur**, celui qui exploite la fabric. Un tenant ordinaire qui le
|
||||
déclarerait s'offrirait à servir ses voisins sans y être invité.
|
||||
|
||||
C'est le même patron que `serveur_ops_site` : le **runner de site** prépare le terrain —
|
||||
VNets, routes, flux — pour que les plans des tenants tiennent. Aucun écosystème n'ouvre de
|
||||
porte chez un autre ; c'est le site qui autorise.
|
||||
|
||||
## Ce qu'il vérifie
|
||||
|
||||
**Qu'il ne se chaîne pas lui-même.** Il est le bout de la chaîne : le laisser pointer sur
|
||||
un amont ferait tourner les paquets en rond entre deux caches, ou les ferait sortir du site
|
||||
sans que personne ne l'ait décidé.
|
||||
|
||||
**Que le cache écoute vraiment.** Le rôle n'installe rien : sa seule prise sur le réel est
|
||||
de vérifier que le service qu'il désigne existe. Sans ça, il attribuerait une
|
||||
responsabilité à un port que personne n'écoute.
|
||||
|
||||
## Ce que ce rôle ne fait pas
|
||||
|
||||
Il n'installe ni ne configure apt-cacher-ng — c'est `serveur_artefacts`. Il **attribue une
|
||||
responsabilité** et la rend lisible dans le plan.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
# LE CACHE D'ARTEFACTS DU SITE — nomme `cache_site` et non `artefacts_site`.
|
||||
#
|
||||
# POURQUOI CE NOM COURT (2026-08-24). OPNsense refuse un nom d'alias de 32 caracteres ou
|
||||
# plus, et le schema `SETOPS_<tenant>_<ROLE>` ne laisse que 17 caracteres au role :
|
||||
#
|
||||
# SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE 36 refuse a l'application
|
||||
# SETOPS_PATI29_SRV_ARTEFACTS_SITE 32 refuse encore, d'un caractere
|
||||
# SETOPS_PATI29_SRV_CACHE_SITE 28 passe
|
||||
#
|
||||
# La contrainte n'etait ecrite nulle part et s'est manifestee a l'APPLICATION, pas au
|
||||
# devis. Elle l'est desormais, et le devis REFUSE plutot que de promettre un objet que la
|
||||
# cible rejettera. — celui que les autres écosystèmes prennent comme amont.
|
||||
#
|
||||
# Même patron que `serveur_ops_site` : le rôle n'installe rien de plus, il **attribue une
|
||||
# responsabilité** et la rend lisible dans le plan. `serveur_artefacts` fait le travail ;
|
||||
# celui-ci déclare que ce cache-là sert au-delà de son propre écosystème.
|
||||
#
|
||||
# POURQUOI DEUX RÔLES PLUTÔT QU'UN DRAPEAU. Les flux se déclarent dans `meta/flux.yml`,
|
||||
# qui est STATIQUE : un rôle unique aurait dû déclarer l'ingress inter-tenant pour TOUS
|
||||
# les caches. Le devis obtenu le montrait — un maillage complet où chaque cache acceptait
|
||||
# chaque autre, alors qu'un seul doit être joignable.
|
||||
#
|
||||
# QUI PEUT LE DÉCLARER : l'écosystème de l'hébergeur, celui qui exploite la fabric. Un
|
||||
# tenant ordinaire qui le déclarerait s'offrirait à servir ses voisins sans y être invité.
|
||||
#
|
||||
# Le cache du site va DIRECTEMENT chez Debian : il est le bout de la chaîne.
|
||||
serveur_cache_site_amont_attendu: ""
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
# Position de ce role dans la directive d'authentification (D-38..D-41).
|
||||
# Voir docs/authentification.md. Gardee par la preuve P29.
|
||||
authentification:
|
||||
portee: sans-auth-humaine
|
||||
mecanisme: aucun
|
||||
formulaire_local: sans-objet
|
||||
secours: "Acces SSH a l'hote"
|
||||
raison: >-
|
||||
Un cache de paquets ne sert que des machines. Ce qui le protege n'est pas une
|
||||
authentification mais la LISTE de ses pairs : seuls les tenants de ce site le
|
||||
joignent, et la frontiere le fait respecter. Ce qu'il sert est public par nature.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
# Empreinte ressources — ce role n'installe rien. Il attribue une responsabilite a un
|
||||
# cache qui existe deja, et la rend lisible dans le plan.
|
||||
setops_empreinte:
|
||||
coeurs: 0
|
||||
memoire_mo: 0
|
||||
disque_go: 0
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
---
|
||||
# Flux réseau du cache d'artefacts DU SITE. Voir docs/flux-conception.md.
|
||||
#
|
||||
# UN SEUL FLUX, et c'est tout son sens : accepter les caches des écosystèmes voisins.
|
||||
# `serveur_artefacts` porte le reste (servir sa propre flotte, remplir depuis Debian).
|
||||
#
|
||||
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte` (mon
|
||||
# écosystème), ni `externe` — qui signifie « depuis l'Internet » et aurait publié le cache
|
||||
# au monde entier.
|
||||
#
|
||||
# LA RÈGLE APPARTIENT AU SITE : c'est son runner qui prépare le terrain, pas un tenant qui
|
||||
# ouvrirait une porte chez un autre.
|
||||
flux:
|
||||
- sens: ingress
|
||||
port: 3142
|
||||
protocole: tcp
|
||||
pair: voisins_site
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
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.
|
||||
|
||||
# LA RACINE DOIT POUVOIR REMPLIR. Ce cache n'a pas d'amont interne — c'est LUI l'amont
|
||||
# de tous les autres. Sans flux sortant, la chaine entiere se termine sur un cache vide :
|
||||
#
|
||||
# VM du tenant -> cache du tenant -> cache du SITE -> (rien)
|
||||
#
|
||||
# Il manquait, et ca ne se serait vu qu'au premier `apt update` d'un ecosysteme neuf —
|
||||
# c'est-a-dire au pire moment.
|
||||
- sens: egress
|
||||
port: 80
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
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.
|
||||
- sens: egress
|
||||
port: 443
|
||||
protocole: tcp
|
||||
pair: externe
|
||||
chiffrement: tls-requis
|
||||
raison: >-
|
||||
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.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
# CE RÔLE EN EXIGE UN AUTRE, ET C'EST À ANSIBLE DE LE SAVOIR.
|
||||
#
|
||||
# `serveur_cache_site` n'installe rien : il MARQUE un cache comme racine de la chaîne et
|
||||
# vérifie qu'il n'a pas d'amont. Pour cela il lit les variables de `serveur_artefacts`,
|
||||
# qui pose apt-cacher-ng.
|
||||
#
|
||||
# Or les valeurs par défaut d'un rôle ne sont en portée que DANS LE PLAY QUI L'INCLUT.
|
||||
# Déclarer les deux rôles sur la machine ne suffisait donc pas : chaque groupe est appliqué
|
||||
# par son propre playbook, et le marqueur ne voyait jamais `serveur_artefacts_port`.
|
||||
#
|
||||
# Une dépendance de rôle règle les deux choses d'un coup : Ansible applique l'installateur
|
||||
# AVANT le marqueur, dans le MÊME play, donc ses variables sont en portée. Et l'ordre
|
||||
# cesse de dépendre de la façon dont on lance le déploiement.
|
||||
#
|
||||
# Chez patient 0 les deux vivaient sur le même hôte et étaient appliqués ensemble : la
|
||||
# dépendance existait déjà, elle n'était simplement écrite nulle part.
|
||||
dependencies:
|
||||
- role: serveur_artefacts
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
# CE RÔLE MARQUE UN CACHE ; IL N'EN INSTALLE PAS.
|
||||
#
|
||||
# Il présuppose `serveur_artefacts` sur le même hôte — c'est lui qui pose apt-cacher-ng —
|
||||
# et lit ses variables. Chez patient 0 les deux vivaient sur `forge-01`, donc la
|
||||
# dépendance ne se voyait pas. La première machine à ne porter que le marqueur l'a
|
||||
# révélée, et le message était : « variable non définie : `serveur_artefacts_port` ».
|
||||
#
|
||||
# Ce message ne dit pas qu'il manque un RÔLE. Il envoie chercher une faute de frappe dans
|
||||
# un fichier de variables, alors que la cause est une déclaration incomplète, un cran
|
||||
# au-dessus. On le dit donc nous-mêmes, et en premier.
|
||||
- name: Le rôle qui installe le cache est-il bien là ?
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- serveur_artefacts_port is defined
|
||||
fail_msg: >-
|
||||
`serveur_cache_site` marque un cache existant, il n'en installe aucun.
|
||||
Cet hôte doit AUSSI porter `serveur_artefacts`, qui pose apt-cacher-ng et définit
|
||||
`serveur_artefacts_port`. Ajouter les deux rôles à sa déclaration, dans cet ordre.
|
||||
|
||||
# UN CACHE DE SITE QUI SE CHAÎNERAIT SUR UN AUTRE SERAIT UNE BOUCLE.
|
||||
#
|
||||
# Il est le bout de la chaîne : les caches des tenants viennent à lui, et lui va chez
|
||||
# Debian. Le laisser pointer sur un amont ferait tourner les paquets en rond entre deux
|
||||
# caches, ou pire, hors du site sans que personne ne l'ait décidé.
|
||||
- name: Exiger que le cache du site aille directement à l'amont
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (serveur_artefacts_amont | default('')) == serveur_cache_site_amont_attendu
|
||||
fail_msg: >-
|
||||
Ce cache est déclaré cache du SITE : il doit aller directement chez Debian.
|
||||
`serveur_artefacts_amont` doit rester vide — sinon les caches se chaînent en boucle.
|
||||
|
||||
# ÉCRIRE, PUIS RELIRE (D-68). Le rôle n'installe rien : sa seule prise sur le réel est de
|
||||
# vérifier que le service qu'il désigne existe vraiment. Sans ça, il attribuerait une
|
||||
# responsabilité à un port que personne n'écoute.
|
||||
- name: Le cache écoute-t-il vraiment ?
|
||||
ansible.builtin.wait_for:
|
||||
host: "127.0.0.1"
|
||||
port: "{{ serveur_artefacts_port }}"
|
||||
timeout: 30
|
||||
when: not ansible_check_mode
|
||||
|
|
@ -1,69 +1,27 @@
|
|||
# serveur_collabora
|
||||
|
||||
**Collabora Online, en natif** — paquet `coolwsd`, systemd, derrière l'edge nginx. Aucun
|
||||
conteneur.
|
||||
Serveur d'édition de documents **Collabora Online (CODE)**, protocole WOPI, consommé
|
||||
par Nextcloud (`richdocuments`). **Codifié depuis le spike du 2026-07-05** (Docker, prouvé).
|
||||
|
||||
## Pourquoi ce rôle a été réécrit
|
||||
## Ce que le rôle fait
|
||||
- Installe Docker + lance le conteneur `collabora/code` (`restart: always`) ;
|
||||
- publie sur `127.0.0.1:9980` (Nextcloud co-localisé) **ET** l'IP LAN `:9980` (l'edge) — gotcha spike : bind localhost seul ⇒ **502** depuis l'edge ;
|
||||
- `aliasgroup1` = le domaine Nextcloud (`cloud.<domaine>`) — Collabora n'accepte le WOPI que de cet hôte ;
|
||||
- `ssl.enable=false` + `ssl.termination=true` (TLS terminé à l'edge) ;
|
||||
- attend la santé (`/hosting/capabilities` = 200).
|
||||
|
||||
Il lançait `collabora/code` dans Docker. C'était la **seule exception conteneurisée** de
|
||||
la flotte, et elle contredisait le principe fondateur : logiciel libre en natif, systemd +
|
||||
paquets + nginx. Elle traînait en plus une dépendance entière — `community.docker` — qui
|
||||
n'était même pas déclarée dans `requirements.yml`, donc absente de tout runner.
|
||||
## Dépendances / exposition (à fournir)
|
||||
- **Vhost edge WebSocket-aware** : `bureau.<domaine>` → ce nœud:9980 avec `map $http_upgrade`
|
||||
+ en-têtes `Upgrade`/`Connection` + `proxy_read_timeout` long. **Le vhost générique ne suffit pas**
|
||||
(l'édition live échoue sans WebSocket) ;
|
||||
- FQDN `bureau.<domaine>` dans les SAN du cert edge ;
|
||||
- port 9980 à firewaller (voir `meta/flux.yml` : ingress depuis l'edge seulement).
|
||||
|
||||
Depuis la réécriture, **plus aucun rôle du dépôt n'a besoin de Docker**.
|
||||
## Docker vs natif
|
||||
Le spike a prouvé **Docker** → on codifie Docker. Le **CODE natif** (paquets Collabora,
|
||||
`loolwsd`) serait plus « Set-OPS » (natif, pas de moteur Docker) mais exigerait **son propre
|
||||
spike**. Décision à trancher.
|
||||
|
||||
## Ce qui a été éprouvé avant d'écrire
|
||||
|
||||
Doctrine du dépôt : *éprouver l'outil avant le rôle*. Sur une Debian 13.6 réelle, sans
|
||||
rien installer :
|
||||
|
||||
```
|
||||
apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
|
||||
libgcc1 (exigé par le paquet) -> fourni par libgcc-s1 sur trixie
|
||||
le dépôt CODE-deb -> PLAT : Packages et Release à la racine, pas de dists/
|
||||
```
|
||||
|
||||
Le paquet livre aussi `/etc/nginx/snippets/coolwsd.conf` et un profil AppArmor — deux
|
||||
choses que cette flotte sait exploiter, contrairement à une image opaque.
|
||||
|
||||
## La configuration : par surcharge, pas par réécriture
|
||||
|
||||
Le `coolwsd.xml` livré fait **439 lignes richement commentées**. Le remplacer par un
|
||||
gabarit maison obligerait à suivre son évolution à chaque version amont, et ferait perdre
|
||||
les commentaires qui expliquent chaque réglage.
|
||||
|
||||
`coolwsd` accepte des surcharges `--o:<clé>=<valeur>`. Le rôle pose donc un **fragment
|
||||
systemd** : la configuration de Set-OPS tient en un fichier lisible, celle du paquet reste
|
||||
intacte, et `apt purge` rend la machine à son état d'origine.
|
||||
|
||||
## La console d'administration est FERMÉE
|
||||
|
||||
Elle expose un formulaire identifiant/mot de passe hors de tout SSO, avec un secret à
|
||||
créer, faire tourner et surveiller — pour une fonction dont personne n'a besoin au
|
||||
quotidien. Même raisonnement que `serveur_forgejo_connexion_locale`.
|
||||
|
||||
L'ouvrir (`serveur_collabora_admin_actif: true`) exige alors `vault_collabora_admin` en
|
||||
voûte, et de l'ajouter au gabarit de l'instance.
|
||||
|
||||
## Variables principales
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `serveur_collabora_port` | `9980` | écoute, derrière l'edge |
|
||||
| `serveur_collabora_server_name` | `bureau.<domaine>` | nom externe — vide, coolwsd le déduit mal derrière un mandataire |
|
||||
| `serveur_collabora_domaine_nextcloud` | `cloud.<domaine>` | l'hôte WOPI autorisé |
|
||||
| `serveur_collabora_ssl_termination` | `true` | TLS terminé à l'edge, comme le reste de la flotte |
|
||||
| `serveur_collabora_admin_actif` | `false` | console d'administration |
|
||||
|
||||
## Ce que ce rôle vérifie
|
||||
|
||||
Il interroge `/hosting/capabilities` après démarrage. Un service « active » qui ne répond
|
||||
pas là serait découvert par Nextcloud **devant un usager**, à la première ouverture de
|
||||
document.
|
||||
|
||||
## Ce qui n'est pas encore prouvé
|
||||
|
||||
L'**installabilité** est établie par simulation sur Debian 13.6. Le **fonctionnement** ne
|
||||
l'est pas : aucun écosystème de la flotte ne fait tourner Collabora aujourd'hui. La
|
||||
première mise en service devra vérifier l'édition d'un document de bout en bout, depuis
|
||||
Nextcloud.
|
||||
## Robustesse
|
||||
- **Épingler `serveur_collabora_tag`** en prod (pas de `:latest` surprise) ;
|
||||
- surveillance Icinga sur `/hosting/capabilities`.
|
||||
|
|
|
|||
|
|
@ -1,87 +1,22 @@
|
|||
---
|
||||
# COLLABORA ONLINE, EN NATIF — plus de conteneur.
|
||||
#
|
||||
# Ce rôle lançait `collabora/code` dans Docker, ce qui contredisait le principe fondateur
|
||||
# du dépôt : logiciel libre **en natif**, systemd + paquets + nginx, zéro conteneur. C'était
|
||||
# la seule exception de la flotte, et elle traînait une dépendance entière
|
||||
# (`community.docker`) qui n'était même pas déclarée dans `requirements.yml`.
|
||||
#
|
||||
# CE QUI A ÉTÉ ÉPROUVÉ AVANT D'ÉCRIRE CE RÔLE (2026-08-24), sur une Debian 13.6 réelle,
|
||||
# sans rien installer :
|
||||
#
|
||||
# apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
|
||||
# libgcc1 (exigé) -> fourni par libgcc-s1 sur trixie
|
||||
# le dépôt CODE-deb est PLAT -> Packages et Release à la racine, pas de dists/
|
||||
#
|
||||
# Le paquet livre aussi `/etc/nginx/snippets/coolwsd.conf` et un profil AppArmor — deux
|
||||
# choses que cette flotte sait déjà exploiter, contrairement à une image opaque.
|
||||
# =========================================================================
|
||||
# serveur_collabora — serveur d'édition de documents (Collabora Online / CODE),
|
||||
# protocole WOPI, consommé par Nextcloud (richdocuments). Codifié depuis le spike
|
||||
# du 2026-07-05 (Docker, prouvé). Le CODE natif (paquets Collabora) reste une
|
||||
# option future — nécessiterait son propre spike.
|
||||
# =========================================================================
|
||||
serveur_collabora_conteneur: "collabora"
|
||||
serveur_collabora_image: "collabora/code"
|
||||
serveur_collabora_tag: "latest" # À ÉPINGLER en prod (robustesse / pas d'upgrade surprise)
|
||||
|
||||
serveur_collabora_paquets:
|
||||
- coolwsd
|
||||
serveur_collabora_service: "coolwsd"
|
||||
serveur_collabora_port: 9980
|
||||
# L'edge (autre hôte) doit joindre le conteneur → publier sur localhost ET l'IP LAN
|
||||
# (gotcha spike : bind 127.0.0.1 seul => 502 depuis l'edge). Firewaller via le registre des flux.
|
||||
serveur_collabora_lan_ip: "{{ ansible_default_ipv4.address | default(ansible_host) }}"
|
||||
|
||||
# --- LE DÉPÔT AMONT ----------------------------------------------------------
|
||||
#
|
||||
# Dépôt PLAT : `Suites: ./` et pas de composant. Une source classique (`deb … stable
|
||||
# main`) ne le trouverait pas.
|
||||
serveur_collabora_depot_uri: "https://www.collaboraoffice.com/repos/CollaboraOnline/CODE-deb"
|
||||
serveur_collabora_depot_cle_url: "https://www.collaboraoffice.com/downloads/gpg/collaboraonline-release-keyring.gpg"
|
||||
serveur_collabora_depot_cle_fichier: "/etc/apt/keyrings/collaboraonline.gpg"
|
||||
|
||||
# --- CONFIGURATION : PAR SURCHARGE, PAS PAR RÉÉCRITURE -----------------------
|
||||
#
|
||||
# Le `coolwsd.xml` livré fait 439 lignes richement commentées. Le remplacer par un gabarit
|
||||
# maison, ce serait s'engager à suivre son évolution à chaque version amont — et perdre
|
||||
# les commentaires qui expliquent chaque réglage.
|
||||
#
|
||||
# `coolwsd` accepte des surcharges `--o:<clé>=<valeur>` sur sa ligne de commande. On pose
|
||||
# donc un fragment systemd : la configuration de Set-OPS tient en un seul fichier lisible,
|
||||
# le fichier du paquet reste intact, et `apt purge` rend la machine à son état d'origine.
|
||||
serveur_collabora_fragment: "/etc/systemd/system/coolwsd.service.d/setops.conf"
|
||||
|
||||
# TLS TERMINÉ À L'EDGE, comme tout le reste de la flotte : `serveur_nginx` présente le
|
||||
# certificat step-ca, Collabora écoute en clair derrière lui.
|
||||
serveur_collabora_ssl_enable: false
|
||||
serveur_collabora_ssl_termination: true
|
||||
|
||||
# Nom externe, tel que le navigateur l'atteint. Vide = déduit de la requête, ce qui échoue
|
||||
# derrière un mandataire inverse — d'où la valeur explicite.
|
||||
serveur_collabora_server_name: "bureau.{{ domaine_interne }}"
|
||||
|
||||
# Hôtes WOPI autorisés — qui a le droit de faire éditer un document.
|
||||
#
|
||||
# Les défauts du paquet couvrent déjà les plages privées (10/8, 172.16/12, 192.168/16,
|
||||
# loopback), donc l'adressage dérivé de Set-OPS. On ajoute le FQDN de Nextcloud pour que
|
||||
# l'autorisation soit LISIBLE dans le plan plutôt que déduite d'un masque.
|
||||
# Domaine Nextcloud autorisé (aliasgroup) — Collabora n'accepte le WOPI que de cet hôte.
|
||||
serveur_collabora_domaine_nextcloud: "cloud.{{ domaine_interne }}"
|
||||
serveur_collabora_aliasgroup: "https://{{ serveur_collabora_domaine_nextcloud | regex_escape }}:443"
|
||||
|
||||
# --- CONSOLE D'ADMINISTRATION : FERMÉE PAR DÉFAUT ----------------------------
|
||||
#
|
||||
# Elle sert à surveiller les sessions et les documents ouverts. Personne n'en a besoin au
|
||||
# quotidien, et elle expose un formulaire identifiant/mot de passe de plus — hors de tout
|
||||
# SSO, avec un secret à créer, à faire tourner et à surveiller.
|
||||
#
|
||||
# Même raisonnement que `serveur_forgejo_connexion_locale` : une porte ouverte en
|
||||
# permanence contourne la politique centrale et ne se révoque nulle part. On la ferme, et
|
||||
# l'on garde l'accès par SSH à l'hôte — le mécanisme de secours retenu par ce dépôt.
|
||||
#
|
||||
# L'ouvrir exige alors un secret en voûte, et de l'ajouter au gabarit de l'instance.
|
||||
# LE MOT DE PASSE N'EST PAS TIRE D'UNE CLE DE VOUTE PAR DEFAUT, ET C'EST VOULU.
|
||||
#
|
||||
# `scripts/voute.py` exige de la voute TOUTE reference `vault_*` trouvee dans un role
|
||||
# actif — c'est ce qui garantit qu'aucun secret n'est oublie. Mais une porte FERMEE ne
|
||||
# doit pas reclamer de clef : nommer ici `vault_collabora_admin` obligerait chaque
|
||||
# ecosysteme installant Collabora a inventer, chiffrer et faire tourner un secret pour
|
||||
# une console que personne n'ouvre.
|
||||
#
|
||||
# Qui veut la console la declare chez lui :
|
||||
#
|
||||
# # group_vars/serveur_collabora.yml de l'instance
|
||||
# serveur_collabora_admin_actif: true
|
||||
# serveur_collabora_admin_mot_de_passe: "{{ vault_collabora_admin }}"
|
||||
#
|
||||
# et ajoute `vault_collabora_admin` a son gabarit de voute — ou P18 le lui rappellera.
|
||||
serveur_collabora_admin_actif: false
|
||||
serveur_collabora_admin_utilisateur: "collabora-admin"
|
||||
serveur_collabora_admin_mot_de_passe: ""
|
||||
# TLS terminé à l'edge : Collabora sert en HTTP derrière le proxy.
|
||||
serveur_collabora_extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
- name: Redemarrer Collabora
|
||||
when: not ansible_check_mode
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_collabora_service }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
|
@ -1,109 +1,39 @@
|
|||
---
|
||||
# LA CONDITION EST ECRITE D'UN SEUL TENANT, ET C'EST DELIBERE.
|
||||
#
|
||||
# `scripts/verifier_intrants.py` lit les `assert` de la forme `var | length > 0` comme un
|
||||
# CONTRAT : l'instance doit fournir cette valeur. Et il ignore volontairement les `when:`
|
||||
# — un intrant exige sous condition reste un intrant exige.
|
||||
#
|
||||
# Ecrite en deux morceaux (`that: var | length > 0` + `when: actif`), cette garde
|
||||
# promettait donc un secret pour une console FERMEE, et P32 le refusait a juste titre.
|
||||
# Ecrite en une implication, elle dit ce qu'elle veut vraiment dire : SI la console est
|
||||
# ouverte, ALORS un mot de passe. Ne pas la « simplifier » en la recoupant en deux.
|
||||
- name: Exiger un secret si — et seulement si — la console d'administration est ouverte
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not (serveur_collabora_admin_actif | bool) or (serveur_collabora_admin_mot_de_passe | length > 0)
|
||||
fail_msg: >-
|
||||
La console d'administration est ouverte mais son mot de passe est vide.
|
||||
Le renseigner depuis la voûte de l'instance, ou refermer la console
|
||||
(`serveur_collabora_admin_actif: false`).
|
||||
|
||||
- name: Assurer le repertoire des trousseaux apt
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# Récupérée UNE FOIS, comme la clé Smallstep : une plateforme souveraine ne recontacte pas
|
||||
# un serveur étranger à chaque déploiement pour une clé déjà installée. Une rotation amont
|
||||
# ne passe pas inaperçue pour autant — `apt` refuse alors le dépôt, bruyamment.
|
||||
- name: Cette ressource est-elle deja recuperee ? (cle Collabora)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_collabora_depot_cle_fichier }}"
|
||||
register: serveur_collabora_cle_presente
|
||||
|
||||
- name: Telecharger la cle de signature Collabora
|
||||
when: not serveur_collabora_cle_presente.stat.exists
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ serveur_collabora_depot_cle_url }}"
|
||||
dest: "{{ serveur_collabora_depot_cle_fichier }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
timeout: 30
|
||||
register: serveur_collabora_cle
|
||||
retries: 5
|
||||
delay: 6
|
||||
until: serveur_collabora_cle is succeeded
|
||||
|
||||
# DEPOT PLAT : `Suites: ./`, aucun composant. Une source classique ne le trouverait pas.
|
||||
- name: Ajouter le depot apt Collabora
|
||||
ansible.builtin.template:
|
||||
src: collabora.sources.j2
|
||||
dest: /etc/apt/sources.list.d/collabora.sources
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Installer Collabora Online
|
||||
when: not ansible_check_mode # dry-run : le depot n'est pas reellement ajoute
|
||||
- name: Installer Docker (moteur du conteneur Collabora)
|
||||
ansible.builtin.apt:
|
||||
name: "{{ serveur_collabora_paquets }}"
|
||||
name: docker.io
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Assurer le repertoire du fragment systemd
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_collabora_fragment | dirname }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# LA CONFIGURATION DE SET-OPS TIENT EN UN FICHIER, et le coolwsd.xml du paquet reste
|
||||
# intact. Voir defaults/main.yml pour le raisonnement.
|
||||
- name: Deployer la surcharge Set-OPS
|
||||
ansible.builtin.template:
|
||||
src: setops.conf.j2
|
||||
dest: "{{ serveur_collabora_fragment }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0640"
|
||||
notify: Redemarrer Collabora
|
||||
|
||||
- name: Appliquer les redemarrages avant de verifier
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Activer et demarrer Collabora
|
||||
when: not ansible_check_mode
|
||||
- name: Activer et demarrer Docker
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_collabora_service }}"
|
||||
name: docker
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# ECRIRE, PUIS RELIRE (D-68). Un service « active » qui ne repond pas a `capabilities`
|
||||
# est un vert sur un perimetre vide : Nextcloud le decouvrirait a la premiere ouverture
|
||||
# de document, c'est-a-dire devant un usager.
|
||||
- name: Collabora repond-il vraiment ?
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Lancer le conteneur Collabora CODE (WOPI, publie sur localhost + IP LAN)
|
||||
community.docker.docker_container:
|
||||
name: "{{ serveur_collabora_conteneur }}"
|
||||
image: "{{ serveur_collabora_image }}:{{ serveur_collabora_tag }}"
|
||||
state: started
|
||||
pull: true
|
||||
restart_policy: always
|
||||
ports:
|
||||
- "127.0.0.1:{{ serveur_collabora_port }}:9980"
|
||||
- "{{ serveur_collabora_lan_ip }}:{{ serveur_collabora_port }}:9980"
|
||||
env:
|
||||
aliasgroup1: "{{ serveur_collabora_aliasgroup }}"
|
||||
extra_params: "{{ serveur_collabora_extra_params }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Attendre que Collabora reponde (capabilities)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ serveur_collabora_port }}/hosting/capabilities"
|
||||
status_code: [200]
|
||||
timeout: 20
|
||||
register: serveur_collabora_capacites
|
||||
status_code: 200
|
||||
register: serveur_collabora_sante
|
||||
until: serveur_collabora_sante.status == 200
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: serveur_collabora_capacites is succeeded
|
||||
when: not ansible_check_mode
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
# GÉNÉRÉ par Set-OPS (rôle serveur_collabora). NE PAS éditer à la main.
|
||||
#
|
||||
# Dépôt PLAT : `Suites: ./` et aucun composant. Constaté le 2026-08-24 — `Packages` et
|
||||
# `Release` vivent à la racine, il n'y a pas de `dists/`.
|
||||
Types: deb
|
||||
URIs: {{ serveur_collabora_depot_uri }}
|
||||
Suites: ./
|
||||
Signed-By: {{ serveur_collabora_depot_cle_fichier }}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# GÉNÉRÉ par Set-OPS (rôle serveur_collabora). NE PAS éditer à la main.
|
||||
#
|
||||
# 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.
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/coolwsd --version --o:sys_template_path=/opt/cool/systemplate \
|
||||
--o:child_root_path=/opt/cool/child-roots \
|
||||
--o:file_server_root_path=/usr/share/coolwsd \
|
||||
--o:ssl.enable={{ serveur_collabora_ssl_enable | bool | lower }} \
|
||||
--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: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 %}
|
||||
|
|
@ -25,24 +25,6 @@ serveur_forgejo_branding: false
|
|||
serveur_forgejo_http_addr: "0.0.0.0"
|
||||
serveur_forgejo_http_port: 3000
|
||||
|
||||
# --- SERVIR TLS SOI-MEME, QUAND IL N'Y A PAS D'EDGE DEVANT (2026-08-25) --------
|
||||
#
|
||||
# Le patron habituel est un nginx d'edge qui termine le TLS et parle en clair a la forge
|
||||
# sur la boucle locale. Le gabarit posait donc `ROOT_URL = https://…` sans jamais
|
||||
# configurer de certificat : c'etait vrai PARCE QUE quelqu'un d'autre s'en chargeait.
|
||||
#
|
||||
# La forge du SITE n'a pas d'edge devant elle — le site n'en a pas — et elle porte LE
|
||||
# GENOME. Le code qui fabrique tous les ecosystemes voyageait donc en clair sur le
|
||||
# reseau, alors que l'AC du site tourne a trois adresses de la.
|
||||
#
|
||||
# `false` par defaut : chez un tenant, rien ne change, l'edge garde son role. C'est
|
||||
# l'ecosysteme sans edge qui doit le demander.
|
||||
serveur_forgejo_tls: false
|
||||
# Le certificat de la MACHINE, pose par `client_pki` — pas un cert propre a la forge.
|
||||
# Une machine, une identite : c'est ce que l'AC a signe, avec ses SAN.
|
||||
serveur_forgejo_tls_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
|
||||
serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
|
||||
|
||||
# Base de donnees : resolue depuis le registre par le groupe consommateur.
|
||||
serveur_forgejo_groupe: "serveur_forgejo"
|
||||
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement
|
||||
|
|
|
|||
|
|
@ -1,42 +1,12 @@
|
|||
---
|
||||
# Flux réseau de Forgejo (forge Git). Voir docs/flux-conception.md.
|
||||
flux:
|
||||
# LE PORT DE LA FORGE N'EST PAS LE MÊME PARTOUT (2026-08-25).
|
||||
#
|
||||
# `3000` est le port qu'elle écoute DERRIÈRE un edge, en clair : le nginx termine le TLS
|
||||
# et lui parle sur la boucle locale. C'était vrai de toutes les forges tant qu'il y avait
|
||||
# toujours un edge devant.
|
||||
#
|
||||
# La forge du SITE n'en a pas — le site n'a pas d'edge — et sert son propre TLS sur le
|
||||
# port du schéma, `443`. Écrire `3000` en dur ici serait donc devenu faux pour elle,
|
||||
# sans que rien ne le signale : ce flux ne traverse pas la frontière, donc aucun devis
|
||||
# ne s'en plaint. Une déclaration fausse qui ne gêne personne est une déclaration qui
|
||||
# dérive jusqu'au jour où quelqu'un s'y fie.
|
||||
#
|
||||
# `derive` dit la vérité : le port vient de `serveur_forgejo_http_port`, et
|
||||
# `verifier_ports` sait qu'il n'y a pas d'écoute fixe à comparer.
|
||||
# DEUX ENTREES, PARCE QUE CE SONT DEUX SITUATIONS — pas une ligne qui dirait « ça
|
||||
# dépend ». Le vocabulaire des flux n'a pas de mot pour l'indécision, et c'est tant
|
||||
# mieux : un flux se lit pour savoir ce qui circule et comment. Une valeur ambiguë
|
||||
# rendrait l'audit impossible sans aller lire le déploiement.
|
||||
- sens: ingress
|
||||
port: 3000
|
||||
protocole: tcp
|
||||
pair: edge
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
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.
|
||||
- sens: ingress
|
||||
port: derive
|
||||
protocole: tcp
|
||||
pair: flotte
|
||||
chiffrement: tls
|
||||
raison: >-
|
||||
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.
|
||||
raison: "Interface web + Git HTTP servis via l'edge (TLS terminé à l'edge)."
|
||||
- sens: egress
|
||||
port: 5432
|
||||
protocole: tcp
|
||||
|
|
|
|||
|
|
@ -2,23 +2,11 @@
|
|||
|
||||
# L'URL PUBLIQUE de l'IdP se derive de l'exposition declaree au plan, elle ne se
|
||||
# fabrique pas : `keycloak.<domaine>` n'est publie nulle part (voir resoudre_idp).
|
||||
#
|
||||
# ON NE RESOUT PAS UN FOURNISSEUR QU'ON N'UTILISERA PAS (2026-08-25).
|
||||
#
|
||||
# Cette resolution partait INCONDITIONNELLEMENT, y compris quand OIDC est desactive.
|
||||
# `resoudre_idp` lit les registres du plan (`applications.yml`, `domaines.yml`) et exige
|
||||
# que l'IdP y declare une exposition. Sur une forge SANS Keycloak — celle du site, qui
|
||||
# porte le genome et n'a pas d'annuaire — il n'y a ni Keycloak ni plan : le role echouait
|
||||
# sur `'setops_plan_dir' is undefined`, un message qui ne dit rien de la vraie cause.
|
||||
#
|
||||
# `serveur_forgejo_oidc_actif` derive deja de la presence du groupe `serveur_keycloak`.
|
||||
# Il suffisait de s'en servir ici aussi : la resolution suit l'usage.
|
||||
- name: Resoudre le fournisseur d'identite (role partage)
|
||||
ansible.builtin.include_role:
|
||||
name: resoudre_idp
|
||||
vars:
|
||||
resoudre_idp_realm: "{{ serveur_forgejo_oidc_realm }}"
|
||||
when: serveur_forgejo_oidc_actif | bool
|
||||
- name: Refuser de fermer la connexion locale sur un Forgejo anterieur a la v10
|
||||
# `ENABLE_INTERNAL_SIGNIN` a ete ajoute en v10 (suivi amont, issue 7476). Sur une
|
||||
# version anterieure il est ignore EN SILENCE : le role croirait avoir ferme la porte
|
||||
|
|
@ -323,34 +311,6 @@
|
|||
GITEA_WORK_DIR: "{{ serveur_forgejo_data }}"
|
||||
no_log: true
|
||||
|
||||
# ON VERIFIE L'ETAT, ON NE FAIT PAS CONFIANCE AU DRAPEAU (2026-08-25).
|
||||
#
|
||||
# La creation passe deja `--must-change-password=false` — corrige le 2026-08-23, sur la
|
||||
# premiere forge de patient 0. **Forgejo 16 l'a ignore** : le compte de la forge du SITE
|
||||
# est ne avec le drapeau POSE malgre l'option.
|
||||
#
|
||||
# La panne qui en resulte n'accuse rien de juste : toute requete d'API rend **403**, pas
|
||||
# 401. Le jeton est valide, l'identite reconnue, la requete rejetee quand meme — on
|
||||
# cherche un scope, un droit, une politique, et la cause est un drapeau sur le compte.
|
||||
#
|
||||
# D'ou cette tache : elle ne demande pas a la creation de bien se comporter, elle CONSTATE
|
||||
# l'etat voulu et le retablit. Idempotente, et sans effet quand la creation a tenu parole.
|
||||
- name: Le compte d'administration ne doit exiger aucun changement de mot de passe
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ serveur_forgejo_binaire }} admin user must-change-password --unset
|
||||
{{ serveur_forgejo_admin_user }}
|
||||
--config {{ serveur_forgejo_config }}
|
||||
become: true
|
||||
become_user: "{{ serveur_forgejo_utilisateur }}"
|
||||
environment:
|
||||
FORGEJO_WORK_DIR: "{{ serveur_forgejo_data }}"
|
||||
register: forgejo_mcp
|
||||
changed_when: "'Updated 1 users' in (forgejo_mcp.stdout | default(''))"
|
||||
failed_when:
|
||||
- forgejo_mcp.rc != 0
|
||||
- "'not found' not in (forgejo_mcp.stderr | default(''))"
|
||||
|
||||
- name: Configurer la source OAuth2 SSO (Keycloak, idempotent)
|
||||
ansible.builtin.shell:
|
||||
executable: /bin/bash
|
||||
|
|
|
|||
|
|
@ -6,19 +6,9 @@ WORK_PATH = {{ serveur_forgejo_data }}
|
|||
|
||||
[server]
|
||||
DOMAIN = {{ serveur_forgejo_hostname }}
|
||||
{# ROOT_URL EST GRAVEE DANS LES URL DE CLONAGE, les webhooks et les courriels. Un port
|
||||
qui n'est pas celui du schema y reste pour toujours : chaque ecosysteme descendant
|
||||
porterait `:3000` dans son `origin`. On ne l'ecrit donc que s'il est vraiment atypique. #}
|
||||
ROOT_URL = https://{{ serveur_forgejo_hostname }}{{ (':' ~ serveur_forgejo_http_port | string) if (serveur_forgejo_tls and serveur_forgejo_http_port | int != 443) else '' }}/
|
||||
ROOT_URL = https://{{ serveur_forgejo_hostname }}/
|
||||
HTTP_ADDR = {{ serveur_forgejo_http_addr }}
|
||||
HTTP_PORT = {{ serveur_forgejo_http_port }}
|
||||
{% if serveur_forgejo_tls %}
|
||||
; TLS SERVI PAR LA FORGE ELLE-MEME : il n'y a pas d'edge devant elle. Le certificat est
|
||||
; celui de la MACHINE, pose et renouvele par `client_pki` depuis l'AC interne.
|
||||
PROTOCOL = https
|
||||
CERT_FILE = {{ serveur_forgejo_tls_cert }}
|
||||
KEY_FILE = {{ serveur_forgejo_tls_cle }}
|
||||
{% endif %}
|
||||
SSH_DOMAIN = {{ serveur_forgejo_hostname }}
|
||||
|
||||
[database]
|
||||
|
|
|
|||
|
|
@ -10,28 +10,6 @@ User={{ serveur_forgejo_utilisateur }}
|
|||
Group={{ serveur_forgejo_utilisateur }}
|
||||
WorkingDirectory={{ serveur_forgejo_data }}
|
||||
ExecStart={{ serveur_forgejo_binaire }} web --config {{ serveur_forgejo_config }}
|
||||
; RELOAD GRACIEUX PAR SIGHUP (2026-08-25).
|
||||
;
|
||||
; Sans `ExecReload`, systemd repond « Job type reload is not applicable for unit
|
||||
; forgejo.service » — et le handler qui recharge les consommateurs apres un
|
||||
; renouvellement de certificat ECHOUE. Le cert serait renouvele sur disque et servi
|
||||
; perime en memoire : la cicatrice est deja dans ce depot, on ne la refait pas.
|
||||
;
|
||||
; Forgejo traite SIGHUP comme un redemarrage gracieux : il reprend ses ecoutes, donc
|
||||
; relit le certificat, sans couper les connexions en cours.
|
||||
{% if serveur_forgejo_http_port | int < 1024 %}
|
||||
; SE LIER A UN PORT PRIVILEGIE SANS ETRE ROOT.
|
||||
;
|
||||
; Forgejo tourne en `{{ serveur_forgejo_utilisateur }}` des le demarrage — il ne peut donc
|
||||
; pas ouvrir un port < 1024 sans y etre autorise. `CAP_NET_BIND_SERVICE` accorde
|
||||
; exactement ce droit, et rien d'autre : pas de root, pas de setuid.
|
||||
;
|
||||
; Sans elle, le service echoue au `listen` avec « permission denied » sur le PORT, un
|
||||
; message qui ressemble a s'y meprendre a celui d'un fichier illisible.
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
{% endif %}
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=USER={{ serveur_forgejo_utilisateur }}
|
||||
|
|
|
|||
|
|
@ -44,62 +44,16 @@ serveur_ops_ansible: "ansible-core>=2.18,<2.19"
|
|||
serveur_ops_forge_hote: >-
|
||||
{{ (groups['serveur_forgejo'] | default([]) | first | default('')) }}
|
||||
serveur_ops_forge_url: >-
|
||||
{{ serveur_ops_forge_amont if (serveur_ops_forge_externe | bool)
|
||||
else 'https://forge.' ~ domaine_interne }}
|
||||
https://forge.{{ domaine_interne }}
|
||||
serveur_ops_forge_organisation: "genome"
|
||||
|
||||
# --- FILIATION, MUTUALISATION, ÉMANCIPATION ----------------------------------
|
||||
#
|
||||
# Un écosystème naît FILS : il n'héberge pas encore tout ce dont il a besoin, et emprunte
|
||||
# à son hôte. Il peut rester ainsi — la MUTUALISATION est un état durable et choisi, pas
|
||||
# une infirmité — ou devenir AUTOPORTANT le jour où il le décide.
|
||||
#
|
||||
# La forge est le premier service concerné. `false` : le génome est lu sur la forge de
|
||||
# CET écosystème (état émancipé). `true` : il est lu chez l'hôte (état natal, ou
|
||||
# mutualisation assumée) — et l'écosystème n'a alors pas besoin d'héberger de forge.
|
||||
#
|
||||
# CE N'EST PAS UN RABAIS. Un modèle sans forge n'est pas un modèle amputé : c'est un
|
||||
# écosystème au premier âge, dont le runner lit le génome chez son parent. L'émancipation
|
||||
# consiste à lever ce drapeau, déployer sa propre forge, et PROUVER que le lien est coupé.
|
||||
#
|
||||
# Cette variable était citée par `docs/dependances-groupes.yml` et par le README sans
|
||||
# exister nulle part (constaté le 2026-08-24) : une exemption qui ne pouvait jamais
|
||||
# s'appliquer, donc un écosystème sans forge que le registre refusait quand même.
|
||||
serveur_ops_forge_externe: false
|
||||
serveur_ops_forge_amont: ""
|
||||
|
||||
# --- LA CONFIANCE ENVERS UNE FORGE D'UN AUTRE TENANT -------------------------
|
||||
#
|
||||
# Lire le genome chez un voisin suppose de faire confiance a SON autorite — or chaque
|
||||
# ecosysteme a la sienne, et `client_pki` n'etablit la confiance que dans celle du tenant.
|
||||
#
|
||||
# ON NE POSE PAS CETTE RACINE DANS LE MAGASIN SYSTEME. L'y mettre ferait confiance a cette
|
||||
# AC pour TOUT ce que la machine contacte, alors qu'on ne lui demande qu'une chose : servir
|
||||
# un depot git. `git config http.<url>.sslCAInfo` limite la confiance a CETTE url — une
|
||||
# porte, pas un trousseau.
|
||||
#
|
||||
# Chemin d'un certificat racine PUBLIC, sur le controleur. Vide = rien a poser (forge du
|
||||
# meme ecosysteme, ou AC publiquement reconnue).
|
||||
serveur_ops_forge_amont_ac: ""
|
||||
serveur_ops_forge_amont_ac_depot: "{{ serveur_ops_racine }}/.ac-amont.crt"
|
||||
|
||||
# Les dépôts du génome, et où ils atterrissent. Les noms sont ceux que la forge porte
|
||||
# (minuscules) ; les destinations reprennent la disposition du poste du mainteneur, où
|
||||
# le moteur et les instances sont des dossiers frères — c'est cette fraternité que
|
||||
# `scripts/instances.py` découvre.
|
||||
#
|
||||
# AUCUN DÉFAUT NE NOMME UN ÉCOSYSTÈME PARTICULIER (2026-08-24).
|
||||
#
|
||||
# La première version listait ici `ops-patient0`. Tout écosystème qui aurait déployé un
|
||||
# poste sans déclarer ses propres dépôts aurait donc cloné le génome de **patient 0** —
|
||||
# silencieusement, et en croyant piloter le sien. C'est la faute que ce dépôt combat sous
|
||||
# tous ses déguisements : un défaut plausible qui rend un résultat faux sans rien dire.
|
||||
#
|
||||
# Le moteur est le seul défaut légitime : il est le même pour tous. Le dépôt d'instance,
|
||||
# lui, est propre à l'écosystème et DOIT être déclaré — la tâche d'exigence refuse de
|
||||
# poursuivre sans lui.
|
||||
serveur_ops_depots:
|
||||
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
|
||||
- { depot: "ops-patient0", dest: "OPS-Patient0", role: "instance" }
|
||||
|
||||
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
|
||||
#
|
||||
|
|
@ -114,10 +68,7 @@ serveur_ops_depots:
|
|||
# CE QUE LE POSTE N'AURA PAS POUR AUTANT : `underlay.vault.yml`, qui porte les secrets du
|
||||
# monde physique. Il est hors dépôt (gitignore), donc absent du génome. Le poste lit la
|
||||
# CARTE de la fabric, pas ses clés.
|
||||
|
||||
# Le dossier de l'instance pilotée, cible du lien `instance`. Sans valeur, le rôle
|
||||
# refuse : un poste qui ne sait pas quel tenant il pilote n'a rien à piloter.
|
||||
serveur_ops_instance: ""
|
||||
serveur_ops_instance: "OPS-Patient0"
|
||||
# Vide = le poste ne pose pas de lien `underlay.yml` (il configure, il n'engendre pas).
|
||||
serveur_ops_underlay: ""
|
||||
|
||||
|
|
@ -139,17 +90,6 @@ serveur_ops_cle_generer: true
|
|||
# devant son propre tiers.
|
||||
#
|
||||
# Effet de bord recherché : le poste devient déployable **hors ligne**.
|
||||
# LE CACHE SUIT LE CONTENU DE `requirements.yml`, PAS SON EXISTENCE (2026-08-24).
|
||||
#
|
||||
# La premiere version gardait le telechargement par `creates: <cache>/requirements.yml` :
|
||||
# une collection AJOUTEE au fichier n'aurait jamais ete recuperee, puisque le temoin
|
||||
# existait deja. Le runner serait reste en retard sur le moteur, en silence -- exactement
|
||||
# le defaut qui a fait qu'`ansible.posix` manquait sans que rien ne le dise.
|
||||
#
|
||||
# L'empreinte du fichier entre dans le chemin : changer une ligne de `requirements.yml`
|
||||
# cree un cache neuf, donc un telechargement. Les anciens caches restent (ils ne genent
|
||||
# pas) et documentent ce que les versions precedentes exigeaient.
|
||||
serveur_ops_cache_collections: >-
|
||||
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections-{{
|
||||
lookup('file', serveur_ops_requirements_controleur) | hash('sha1') | truncate(12, true, '') }}
|
||||
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections
|
||||
serveur_ops_requirements_controleur: "{{ playbook_dir }}/../../requirements.yml"
|
||||
|
|
|
|||
|
|
@ -25,15 +25,3 @@ flux:
|
|||
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.
|
||||
#
|
||||
# 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."
|
||||
|
|
|
|||
|
|
@ -1,23 +1,12 @@
|
|||
---
|
||||
# UN POSTE QUI NE SAIT PAS QUEL ÉCOSYSTÈME IL PILOTE N'A RIEN À PILOTER.
|
||||
#
|
||||
# On exige explicitement un dépôt d'instance, plutôt que de retomber sur un défaut : le
|
||||
# défaut nommait patient 0, et un écosystème distrait aurait cloné le génome d'un autre
|
||||
# sans qu'aucune erreur ne le signale.
|
||||
- name: Exiger les intrants du poste d'exploitation
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- domaine_interne | default('') | length > 0
|
||||
- (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | list | length) == 1
|
||||
- (serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1
|
||||
- serveur_ops_instance | default('') | length > 0
|
||||
- not (serveur_ops_forge_externe | bool) or (serveur_ops_forge_amont | length > 0)
|
||||
- serveur_ops_depots | length > 0
|
||||
fail_msg: >-
|
||||
serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, au moins
|
||||
un dépôt `role: instance`, et `serveur_ops_instance` (le dossier que le lien
|
||||
`instance` doit désigner). Et si `serveur_ops_forge_externe` est levé — écosystème
|
||||
au premier âge, qui lit son génome chez son hôte — `serveur_ops_forge_amont` doit
|
||||
nommer cette forge. Déclarer le tout dans group_vars/serveur_ops.yml.
|
||||
serveur_ops exige `domaine_interne` et au moins un dépôt dans
|
||||
`serveur_ops_depots` (le moteur au minimum).
|
||||
|
||||
- name: Installer l'outillage du poste
|
||||
ansible.builtin.apt:
|
||||
|
|
@ -68,42 +57,14 @@
|
|||
delay: 6
|
||||
until: serveur_ops_pip is succeeded
|
||||
|
||||
# LA CONFIANCE, AVANT LE CLONE — et limitee a une seule url.
|
||||
#
|
||||
# Voir defaults/main.yml : on ne pose pas cette racine dans le magasin systeme. `git
|
||||
# config http.<url>.sslCAInfo` ne l'applique qu'aux echanges avec CETTE forge.
|
||||
- name: Deposer la racine de la forge amont
|
||||
ansible.builtin.copy:
|
||||
src: "{{ serveur_ops_forge_amont_ac }}"
|
||||
dest: "{{ serveur_ops_forge_amont_ac_depot }}"
|
||||
owner: "{{ serveur_ops_utilisateur }}"
|
||||
group: "{{ serveur_ops_utilisateur }}"
|
||||
mode: "0644"
|
||||
when:
|
||||
- serveur_ops_forge_amont_ac | length > 0
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Limiter la confiance a la forge amont, et a elle seule
|
||||
community.general.git_config:
|
||||
name: "http.{{ serveur_ops_forge_url }}/.sslCAInfo"
|
||||
scope: global
|
||||
value: "{{ serveur_ops_forge_amont_ac_depot }}"
|
||||
become: true
|
||||
become_user: "{{ serveur_ops_utilisateur }}"
|
||||
when:
|
||||
- serveur_ops_forge_amont_ac | length > 0
|
||||
- not ansible_check_mode
|
||||
|
||||
# --- LE GÉNOME, DEPUIS SA PROPRE FORGE ---------------------------------------
|
||||
#
|
||||
# Cloné en anonyme : les dépôts du génome sont lisibles sur la forge de l'écosystème, et
|
||||
# le poste n'a donc AUCUN justificatif à détenir pour se reconstruire. C'est délibéré —
|
||||
# un secret de moins sur la machine qui en concentre déjà beaucoup.
|
||||
#
|
||||
# La confiance TLS vient de `client_pki` quand la forge est celle de CET écosystème
|
||||
# (racine step-ca dans le magasin système). Quand elle est celle d'un voisin, elle vient
|
||||
# de la tâche ci-dessus — limitée à cette seule url. Sans l'une ou l'autre, `git clone`
|
||||
# refuse le certificat, et c'est bien qu'il refuse.
|
||||
# La confiance TLS vient de `client_pki` (racine step-ca dans le magasin système). Sans
|
||||
# lui, `git clone` refuse le certificat de la forge — et c'est bien qu'il refuse.
|
||||
- name: Cloner le génome depuis la forge de l'écosystème
|
||||
ansible.builtin.git:
|
||||
repo: "{{ serveur_ops_forge_url }}/{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git"
|
||||
|
|
@ -167,7 +128,6 @@
|
|||
when: not ansible_check_mode
|
||||
|
||||
- name: Deposer les collections sur le poste
|
||||
register: serveur_ops_depot_collections
|
||||
ansible.builtin.copy:
|
||||
src: "{{ serveur_ops_cache_collections }}/"
|
||||
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
|
||||
|
|
@ -188,21 +148,10 @@
|
|||
-r requirements.yml
|
||||
-p {{ serveur_ops_racine }}/.ansible/collections
|
||||
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
||||
# LE GARDE-FOU SUIVAIT LA MAUVAISE CHOSE (2026-08-24). Il etait `creates: …/community/general` :
|
||||
# une collection AJOUTEE a requirements.yml n'aurait jamais ete installee, puisque le
|
||||
# repertoire temoin existait deja. Le runner serait reste en retard sur le moteur, en
|
||||
# silence. On suit desormais le DEPOT des archives : si les collections deposees
|
||||
# changent, on reinstalle.
|
||||
changed_when: true
|
||||
creates: "{{ serveur_ops_racine }}/.ansible/collections/ansible_collections/community/general"
|
||||
become: true
|
||||
become_user: "{{ serveur_ops_utilisateur }}"
|
||||
# DEUX `when` SUR UNE MEME TACHE, C'EST UN SEUL — LE DERNIER (2026-08-24). En ajoutant
|
||||
# la condition sur le depot, j'ai laisse celle du check-mode juste en dessous : YAML
|
||||
# garde la derniere cle et jette l'autre, en silence. `ansible-lint` l'a vu ; personne
|
||||
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
||||
when:
|
||||
- serveur_ops_depot_collections is changed
|
||||
- not ansible_check_mode
|
||||
when: not ansible_check_mode
|
||||
|
||||
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
|
||||
# défaut ; l'exploitant le bascule comme sur le poste du mainteneur.
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
# serveur_ops_site
|
||||
|
||||
**Le runner de SITE** : celui qui matérialise. Il crée des VM sur la fabric, et rien d'autre.
|
||||
|
||||
## Pourquoi ce rôle existe séparément
|
||||
|
||||
Le travail d'un runner se divise en trois, et la ligne de partage est celle des voûtes :
|
||||
|
||||
| | voûte requise | portée | rôle |
|
||||
|---|---|---|---|
|
||||
| calculer — plan → inventaire | aucune | tenant | `serveur_ops` |
|
||||
| configurer — rôles sur ses machines | **tenant** | tenant | `serveur_ops` |
|
||||
| matérialiser — créer/détruire des VM | **SITE** | fabric | **ce rôle** |
|
||||
|
||||
**Un runner par tenant qui matérialiserait** mettrait la voûte du SITE en N exemplaires —
|
||||
le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.
|
||||
|
||||
**Un runner unique qui ferait tout** devrait entrer en SSH chez tous les tenants, donc
|
||||
traverser le default-deny inter-tenant. Et il rendrait l'**émancipation impossible** : un
|
||||
écosystème dont le runner appartient à l'hébergeur ne peut pas se rebâtir sans lui.
|
||||
|
||||
Deux pouvoirs, deux rôles, **aucun omnipotent**. Ce rôle crée des VM vides et n'entre
|
||||
jamais chez un tenant ; `serveur_ops` habille des machines et ne touche jamais la fabric.
|
||||
|
||||
## Qui a le droit de le déclarer
|
||||
|
||||
**L'écosystème de l'hébergeur**, celui qui exploite la fabric. Un tenant ordinaire qui le
|
||||
déclarerait s'arrogerait un pouvoir sur ses voisins.
|
||||
|
||||
## Ce qu'il dépose, et comment
|
||||
|
||||
La voûte du SITE, **chiffrée**, en `0600`. Le mot de passe n'est jamais stocké : le
|
||||
Makefile ajoute `--ask-vault-pass` quand aucun fichier de mot de passe n'est défini, et
|
||||
l'exploitant le tape au moment d'agir.
|
||||
|
||||
`decrypt: false` est **obligatoire** sur la copie. Sans lui, Ansible déchiffre la source
|
||||
quand il détient le mot de passe — constaté le 2026-08-24 : 776 octets en clair au lieu de
|
||||
3465 chiffrés, sur une machine où ils n'avaient rien à faire. Le rôle **relit l'en-tête**
|
||||
après avoir écrit et refuse si le fichier n'est pas chiffré.
|
||||
|
||||
## Colocalisation
|
||||
|
||||
Rien n'oblige à lui donner sa propre machine : chez l'hébergeur, les deux pouvoirs
|
||||
résident légitimement au même endroit, et une machine de plus dans un écosystème
|
||||
volontairement maigre se paie. Le déclarer séparément rend le pouvoir **visible**, ce qui
|
||||
est l'essentiel.
|
||||
|
||||
Chez un tenant qui n'est pas l'hébergeur, la question ne se pose pas : le rôle n'a pas à
|
||||
y être.
|
||||
|
||||
## Variables
|
||||
|
||||
| Variable | Rôle |
|
||||
|---|---|
|
||||
| `serveur_ops_site_depot` | dossier du dépôt SITE chez le runner (cloné par `serveur_ops`) |
|
||||
| `serveur_ops_site_voute_source` | chemin de `underlay.vault.yml` sur le contrôleur |
|
||||
| `serveur_ops_site_voute_deposer` | `false` pour un runner qui lit la carte sans détenir les clés |
|
||||
|
||||
## Ce que ce rôle ne fait pas
|
||||
|
||||
Il n'installe rien, n'ouvre aucun port, ne sauvegarde rien. Il ne fait qu'**attribuer un
|
||||
pouvoir** — et le rendre lisible dans le plan.
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
---
|
||||
# LE RUNNER DE SITE — celui qui MATÉRIALISE.
|
||||
#
|
||||
# Le travail d'un runner se divise en trois, et les deux premières portées appartiennent
|
||||
# au TENANT tandis que la troisième appartient à la FABRIC :
|
||||
#
|
||||
# calculer plan -> inventaire aucune voûte portée OPS (serveur_ops)
|
||||
# configurer rôles sur ses machines voûte du TENANT portée OPS (serveur_ops)
|
||||
# matérialiser créer/détruire des VM voûte du SITE portée SITE (ce rôle)
|
||||
#
|
||||
# POURQUOI LA SÉPARATION. Si chaque écosystème portait le pouvoir de matérialiser, la
|
||||
# voûte du SITE se retrouverait en N exemplaires — le secret le plus dangereux du système,
|
||||
# recopié autant de fois qu'il y a de locataires. C'est précisément ce que la séparation
|
||||
# des voûtes (2026-08-22) cherchait à éviter.
|
||||
#
|
||||
# Et l'inverse serait pire : un runner UNIQUE qui ferait tout devrait entrer en SSH chez
|
||||
# tous les tenants, donc traverser le default-deny inter-tenant — et rendrait toute
|
||||
# émancipation impossible, puisqu'un écosystème ne pourrait plus se rebâtir sans son hôte.
|
||||
#
|
||||
# Deux pouvoirs, deux rôles, aucun omnipotent : ce rôle crée des VM vides et n'entre
|
||||
# jamais chez un tenant ; `serveur_ops` habille des machines et ne touche jamais la fabric.
|
||||
#
|
||||
# QUI PEUT LE DÉCLARER : l'écosystème de l'HÉBERGEUR, celui qui exploite la fabric. Un
|
||||
# tenant ordinaire qui le déclarerait s'arrogerait un pouvoir sur ses voisins.
|
||||
|
||||
serveur_ops_site_utilisateur: "setops"
|
||||
serveur_ops_site_racine: "/opt/setops"
|
||||
|
||||
# Le dépôt du SITE, tel qu'il atterrit chez le runner. Il doit avoir été cloné par
|
||||
# `serveur_ops` (entrée `role: hebergeur` de `serveur_ops_depots`).
|
||||
serveur_ops_site_depot: ""
|
||||
|
||||
# --- LA VOÛTE DU SITE --------------------------------------------------------
|
||||
#
|
||||
# Déposée CHIFFRÉE, jamais en clair. Le mot de passe n'est pas stocké : le Makefile
|
||||
# ajoute `--ask-vault-pass` quand aucun fichier de mot de passe n'est défini, et
|
||||
# l'exploitant le tape au moment d'agir.
|
||||
#
|
||||
# `decrypt: no` EST OBLIGATOIRE sur la copie. Sans lui, Ansible DÉCHIFFRE la source quand
|
||||
# il détient le mot de passe — mesuré le 2026-08-24, 776 octets en clair au lieu de 3465
|
||||
# chiffrés, sur une machine où ils n'avaient rien à faire.
|
||||
serveur_ops_site_voute_source: ""
|
||||
serveur_ops_site_voute_deposer: true
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
# Position de ce role dans la directive d'authentification (D-38..D-41).
|
||||
# Voir docs/authentification.md. Gardee par la preuve P29.
|
||||
authentification:
|
||||
portee: sans-auth-humaine
|
||||
mecanisme: aucun
|
||||
formulaire_local: sans-objet
|
||||
secours: "Acces SSH a l'hote, puis `sudo -iu setops`"
|
||||
raison: >-
|
||||
Ce role n'expose aucune interface : il DETIENT un pouvoir au lieu d'en offrir un.
|
||||
Ce qui le protege n'est pas une authentification mais le fait que la voute deposee
|
||||
reste CHIFFREE et que son mot de passe soit tape a l'execution, jamais stocke.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
# Empreinte ressources — ce rôle n'ajoute qu'un fichier et des appels d'API. Il se
|
||||
# colocalise avec `serveur_ops`, dont il partage la racine et l'utilisateur.
|
||||
setops_empreinte:
|
||||
coeurs: 0
|
||||
memoire_mo: 0
|
||||
disque_go: 0
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
# Flux réseau du runner de SITE. Voir docs/flux-conception.md.
|
||||
#
|
||||
# Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de
|
||||
# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS
|
||||
# chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles
|
||||
# existent.
|
||||
flux:
|
||||
- sens: egress
|
||||
port: 8006
|
||||
protocole: tcp
|
||||
pair: fabric
|
||||
chiffrement: tls-requis
|
||||
raison: >-
|
||||
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.
|
||||
|
||||
# SSH VERS LES HYPERVISEURS. L'API Proxmox ne fait pas tout : deplacer un disque,
|
||||
# lire une configuration reseau, poser un pont — cela passe par le shell du nœud.
|
||||
# Le declarer separement de l'API rend visible que ce sont DEUX pouvoirs distincts.
|
||||
- sens: egress
|
||||
port: 22
|
||||
protocole: tcp
|
||||
pair: fabric
|
||||
chiffrement: ssh
|
||||
raison: >-
|
||||
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.
|
||||
|
||||
# L'API DE LA FRONTIERE. Le runner du site pose les regles et les alias qui laissent
|
||||
# passer les flux d'un tenant : sans ce flux, il prepare un terrain injoignable.
|
||||
- sens: egress
|
||||
port: 443
|
||||
protocole: tcp
|
||||
pair: fabric
|
||||
chiffrement: tls-requis
|
||||
raison: >-
|
||||
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.
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
---
|
||||
- name: Exiger les intrants du runner de site
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- serveur_ops_site_depot | length > 0
|
||||
- (not (serveur_ops_site_voute_deposer | bool)) or (serveur_ops_site_voute_source | length > 0)
|
||||
fail_msg: >-
|
||||
serveur_ops_site exige `serveur_ops_site_depot` (le dossier du dépôt SITE chez le
|
||||
runner, cloné par serveur_ops) et, si le dépôt de la voûte est demandé,
|
||||
`serveur_ops_site_voute_source` (le chemin de `underlay.vault.yml` sur le contrôleur).
|
||||
|
||||
- name: Le dépôt du SITE est-il bien là ?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.yml"
|
||||
register: serveur_ops_site_carte
|
||||
|
||||
- name: Refuser si la carte de la fabric manque
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- serveur_ops_site_carte.stat.exists
|
||||
fail_msg: >-
|
||||
{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.yml est absent.
|
||||
Déclarer ce dépôt dans `serveur_ops_depots` (role: hebergeur) et rejouer serveur_ops :
|
||||
un runner de site sans carte ne sait pas sur quoi il matérialise.
|
||||
when: not ansible_check_mode
|
||||
|
||||
# LES CLÉS DU MONDE PHYSIQUE, DE DROIT ET NON PAR EMPRUNT.
|
||||
#
|
||||
# `decrypt: no` : voir defaults/main.yml. Le fichier reste chiffré ; seul le mot de passe,
|
||||
# tapé à l'exécution, l'ouvre.
|
||||
- name: Déposer la voûte du SITE (chiffrée)
|
||||
ansible.builtin.copy:
|
||||
src: "{{ serveur_ops_site_voute_source }}"
|
||||
dest: "{{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.vault.yml"
|
||||
owner: "{{ serveur_ops_site_utilisateur }}"
|
||||
group: "{{ serveur_ops_site_utilisateur }}"
|
||||
mode: "0600"
|
||||
decrypt: false
|
||||
when:
|
||||
- serveur_ops_site_voute_deposer | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
# ÉCRIRE, PUIS RELIRE (D-68). Une voûte déchiffrée par accident est une fuite silencieuse :
|
||||
# le fichier existe, le rôle se dit satisfait, et les clés du cluster dorment en clair.
|
||||
- name: Relire l'en-tête de la voûte déposée
|
||||
ansible.builtin.command:
|
||||
cmd: "head -c 21 {{ serveur_ops_site_racine }}/{{ serveur_ops_site_depot }}/underlay.vault.yml"
|
||||
register: serveur_ops_site_entete
|
||||
changed_when: false
|
||||
when:
|
||||
- serveur_ops_site_voute_deposer | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Exiger que la voûte déposée soit CHIFFRÉE
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'$ANSIBLE_VAULT' in serveur_ops_site_entete.stdout"
|
||||
fail_msg: >-
|
||||
La voûte déposée n'est PAS chiffrée — elle a été déchiffrée en transit.
|
||||
Vérifier `decrypt: false` sur la copie. Détruire le fichier au shred et
|
||||
considérer les justificatifs du cluster comme exposés.
|
||||
when:
|
||||
- serveur_ops_site_voute_deposer | bool
|
||||
- not ansible_check_mode
|
||||
|
|
@ -19,20 +19,10 @@ serveur_powerdns_soa_expire: 1209600
|
|||
serveur_powerdns_soa_minimum: 3600
|
||||
# L'adresse du tenant SEULEMENT, pas `0.0.0.0` : lier toutes les interfaces occupe aussi
|
||||
# `127.0.0.1:53`, ou un resolveur recursif local voudrait s'installer. Ce n'est pas un
|
||||
# probleme theorique — c'est la raison pour laquelle `client_resolveur` exempte cet hote.
|
||||
# probleme theorique — c'est la raison pour laquelle `client_unbound` exempte cet hote.
|
||||
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
|
||||
# LE RESOLVEUR DU TENANT PARTAGE CET HOTE (2026-08-24). Quand c'est le cas, l'autoritatif
|
||||
# lui laisse le port 53 de l'adresse LAN et se replie sur la loopback, sur un autre port :
|
||||
# le resolveur devient la SEULE porte, et personne n'interroge l'autoritatif directement.
|
||||
#
|
||||
# Derive de l'inventaire, pas declare a la main : l'exploitant ne doit pas avoir a se
|
||||
# souvenir de deplacer un port parce qu'il a ajoute un role.
|
||||
serveur_powerdns_colocalise_resolveur: >-
|
||||
{{ inventory_hostname in (groups['serveur_resolveur'] | default([])) }}
|
||||
serveur_powerdns_listen_port: "{{ 5300 if (serveur_powerdns_colocalise_resolveur | bool) else 53 }}"
|
||||
serveur_powerdns_listen_addresses: >-
|
||||
{{ ['127.0.0.1'] if (serveur_powerdns_colocalise_resolveur | bool)
|
||||
else [ansible_host | default('0.0.0.0')] }}
|
||||
serveur_powerdns_listen_addresses:
|
||||
- "{{ ansible_host | default('0.0.0.0') }}"
|
||||
serveur_powerdns_allow_axfr_ips: []
|
||||
|
||||
# Enregistrements additionnels geres explicitement.
|
||||
|
|
|
|||
|
|
@ -1,31 +1,21 @@
|
|||
---
|
||||
# Flux réseau du DNS autoritatif interne (PowerDNS). Voir docs/flux-conception.md.
|
||||
# LE PORT DE L'AUTORITATIF EST DÉRIVÉ DEPUIS LE 2026-08-24, et c'est pour ça qu'il est
|
||||
# déclaré `derive` plutôt que `53` :
|
||||
#
|
||||
# seul sur son hôte -> 53 sur l'adresse du tenant
|
||||
# partageant l'hôte avec le résolveur -> 5300 sur la loopback, derrière lui
|
||||
#
|
||||
# Le résolveur devient alors la seule porte, et personne n'interroge l'autoritatif
|
||||
# directement. `verifier_ports` traite un port non numérique comme « pas une écoute fixe »
|
||||
# — ce qui est exactement le cas : les deux services lient le port 53 mais sur des
|
||||
# ADRESSES différentes, ce qu'une comparaison de ports seuls ne peut pas distinguer.
|
||||
flux:
|
||||
- sens: ingress
|
||||
port: derive
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur."
|
||||
raison: "Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS)."
|
||||
- sens: ingress
|
||||
port: derive
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Idem en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
|
||||
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
|
||||
|
||||
# L'autoritatif ne RÉCURSE pour personne — mais il doit résoudre POUR LUI-MÊME : `apt`,
|
||||
# NTP, les certificats. Il est le seul hôte exempté de `client_resolveur` (PowerDNS occupe
|
||||
# NTP, les certificats. Il est le seul hôte exempté de `client_unbound` (PowerDNS occupe
|
||||
# déjà son port 53), donc rien d'autre ne lui ouvre ce flux. Sans cette déclaration, la
|
||||
# frontière jette ses requêtes et la machine ne peut même pas s'installer — mesuré le
|
||||
# 2026-08-06, sur la VM qui devait justement servir de DNS au tenant.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
Ne PAS le redeclarer ici : sinon PowerDNS refuse « multiple backends 'bind' ». #}
|
||||
bind-config={{ serveur_powerdns_bind_config }}
|
||||
local-address={{ serveur_powerdns_listen_addresses | join(',') }}
|
||||
# Le port suit l'adresse : 53 quand l'autoritatif est seul sur son hote, 5300 quand le
|
||||
# resolveur du tenant partage la machine et prend le port 53 de l'adresse LAN.
|
||||
local-port={{ serveur_powerdns_listen_port }}
|
||||
{% if serveur_powerdns_allow_axfr_ips | length > 0 %}
|
||||
allow-axfr-ips={{ serveur_powerdns_allow_axfr_ips | join(',') }}
|
||||
disable-axfr=no
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
# serveur_resolveur
|
||||
|
||||
**Le résolveur de l'écosystème** : un seul Unbound pour tout le tenant, récursif depuis
|
||||
les serveurs racine, qui délègue la zone souveraine à PowerDNS.
|
||||
|
||||
## Pourquoi un seul, et pourquoi ici
|
||||
|
||||
Avant le 2026-08-24, `client_unbound` posait un Unbound sur **chaque VM**. Ça marchait et
|
||||
c'était parfaitement étanche — mais c'était N démons identiques pour un service unique.
|
||||
|
||||
Mesure prise avant de décider : **~21 Mo de RSS par VM**, et **28 à 189 requêtes** servies
|
||||
depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère,
|
||||
c'est un démon au lieu de quatorze, et **un endroit à regarder** au lieu de quatorze.
|
||||
|
||||
## Pourquoi pas sur la frontière
|
||||
|
||||
Un résolveur partagé par tout le SITE — l'OPNsense — aurait été le geste le plus économe.
|
||||
Il devrait alors connaître la zone interne de **chaque tenant** : sans vues par réseau
|
||||
soigneusement réglées, le tenant A résoudrait les noms du tenant B. C'est l'étanchéité
|
||||
qu'on protège partout ailleurs.
|
||||
|
||||
Et un tenant dont le résolveur vit chez l'hébergeur **ne peut plus s'émanciper avec**
|
||||
(voir `docs/filiation-emancipation.md`).
|
||||
|
||||
La récursion est générique ; **la zone interne ne l'est pas**. C'est ce qui fait du
|
||||
résolveur un service de tenant, et non de fabric.
|
||||
|
||||
## La cohabitation avec l'autoritatif
|
||||
|
||||
Les deux vivent sur `infra-dns-01` et voudraient le port 53. Le partage est **dérivé, pas
|
||||
déclaré** — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
|
||||
rôle :
|
||||
|
||||
```
|
||||
resolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
|
||||
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
|
||||
```
|
||||
|
||||
Personne n'interroge l'autoritatif directement. Le résolveur le prend en `stub-zone`.
|
||||
|
||||
## Aucun transitaire
|
||||
|
||||
`forward-addr` reste vide : Unbound récurse depuis la racine. Poser un transitaire
|
||||
reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
|
||||
2026-08-23 avait justement supprimé.
|
||||
|
||||
## Ce qu'il vérifie avant de se déclarer bon
|
||||
|
||||
Il interroge sa propre adresse pour la **zone interne** *et* pour un nom de l'**Internet**.
|
||||
Un résolveur « active » qui ne répond pas est le pire des cas : toute la flotte pointe vers
|
||||
lui, et découvre la panne en essayant de résoudre — c'est-à-dire au moment où le
|
||||
diagnostic lui-même devient impossible.
|
||||
|
||||
## Variables principales
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `serveur_resolveur_ecoutes` | loopback + IP du tenant | où il répond |
|
||||
| `serveur_resolveur_reseaux_autorises` | le supernet du tenant | qui a le droit de demander |
|
||||
| `serveur_resolveur_autoritatif` | `127.0.0.1:5300` | la zone souveraine |
|
||||
| `serveur_resolveur_transitaires` | `[]` | vide = récursion depuis la racine |
|
||||
|
||||
## Ce que ce rôle ne fait pas
|
||||
|
||||
Il ne bascule aucun `/etc/resolv.conf` — c'est le travail de `client_resolveur`, qui
|
||||
valide que ce service répond **avant** de désigner qui que ce soit.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
# LE RÉSOLVEUR DE L'ÉCOSYSTÈME — un seul, pour tout le tenant.
|
||||
#
|
||||
# Avant : un Unbound sur CHAQUE VM, écoutant sur 127.0.0.1. Ça marchait, et c'était
|
||||
# parfaitement étanche — mais c'était N démons identiques pour un service unique.
|
||||
#
|
||||
# MESURE DU 2026-08-24, avant de décider : ~21 Mo de RSS par VM, et entre 28 et 189
|
||||
# requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ;
|
||||
# ce qu'on récupère, c'est un démon au lieu de quatorze, et un endroit à regarder au lieu
|
||||
# de quatorze.
|
||||
#
|
||||
# POURQUOI PAS SUR LA FRONTIÈRE (OPNsense), qui aurait été le geste le plus économe : un
|
||||
# résolveur partagé par tout le SITE devrait connaître la zone interne de CHAQUE tenant.
|
||||
# Sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B
|
||||
# — l'étanchéité qu'on protège partout ailleurs. Et un tenant dont le résolveur vit chez
|
||||
# l'hébergeur ne peut plus s'émanciper avec (docs/filiation-emancipation.md).
|
||||
#
|
||||
# La récursion est générique ; la ZONE INTERNE ne l'est pas. C'est ce qui fait du
|
||||
# résolveur un service de TENANT, et non de fabric.
|
||||
|
||||
serveur_resolveur_paquets:
|
||||
- unbound
|
||||
serveur_resolveur_service: "unbound"
|
||||
|
||||
# Il écoute pour toute la flotte du tenant — plus seulement pour lui-même.
|
||||
serveur_resolveur_ecoutes:
|
||||
- "127.0.0.1"
|
||||
- "{{ ansible_host }}"
|
||||
|
||||
# QUI A LE DROIT DE L'INTERROGER : le supernet du tenant, dérivé du seed comme tout le
|
||||
# reste. Un résolveur ouvert au monde est un relais d'amplification.
|
||||
serveur_resolveur_reseaux_autorises:
|
||||
- "{{ setops_supernet | default('127.0.0.0/8') }}"
|
||||
|
||||
# La zone souveraine et son autoritatif. PowerDNS se replie sur la loopback quand il
|
||||
# partage cet hôte (voir serveur_powerdns/defaults), pour lui laisser le port 53.
|
||||
serveur_resolveur_zone_interne: "{{ domaine_interne }}"
|
||||
serveur_resolveur_autoritatif: "127.0.0.1"
|
||||
serveur_resolveur_autoritatif_port: 5300
|
||||
|
||||
# AUCUN TRANSITAIRE : Unbound récurse depuis les serveurs racine. Poser un transitaire ici
|
||||
# reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
|
||||
# 2026-08-23 avait justement supprimé.
|
||||
serveur_resolveur_transitaires: []
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
- name: Redémarrer le résolveur
|
||||
when: not ansible_check_mode
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_resolveur_service }}"
|
||||
state: restarted
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
# Position de ce role dans la directive d'authentification (D-38..D-41).
|
||||
# Voir docs/authentification.md. Gardee par la preuve P29.
|
||||
authentification:
|
||||
portee: sans-auth-humaine
|
||||
mecanisme: aucun
|
||||
formulaire_local: sans-objet
|
||||
secours: "Acces SSH a l'hote"
|
||||
raison: >-
|
||||
Un resolveur DNS ne sert que des machines et n'expose aucune interface humaine.
|
||||
Ce qui le protege n'est pas une authentification mais sa LISTE D'ACCES : il ne
|
||||
repond qu'au supernet du tenant. Un resolveur ouvert au monde serait un relais
|
||||
d'amplification, pas un service mal authentifie.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
# Empreinte ressources — mesure du 2026-08-24 : ~21 Mo de RSS pour un Unbound servant
|
||||
# une VM. Servir la flotte entiere n'y change presque rien : le cout est le cache, et le
|
||||
# trafic mesure etait de quelques centaines de requetes.
|
||||
setops_empreinte:
|
||||
coeurs: 0
|
||||
memoire_mo: 64
|
||||
disque_go: 0
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
# Flux réseau du résolveur du tenant. Voir docs/flux-conception.md.
|
||||
flux:
|
||||
- sens: ingress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Toute la flotte du tenant résout ici — et nulle part ailleurs."
|
||||
- sens: ingress
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Réponses longues et bascule TCP, obligatoires en DNS."
|
||||
- sens: egress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: externe
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie
|
||||
ses questions à personne.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
---
|
||||
- name: Exiger la zone souveraine et un réseau autorisé
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- serveur_resolveur_zone_interne | length > 0
|
||||
- serveur_resolveur_reseaux_autorises | length > 0
|
||||
fail_msg: >-
|
||||
serveur_resolveur exige `domaine_interne` et au moins un réseau dans
|
||||
`serveur_resolveur_reseaux_autorises`. Un résolveur sans liste d'accès serait
|
||||
soit muet, soit un relais ouvert.
|
||||
|
||||
- name: Installer le résolveur
|
||||
ansible.builtin.apt:
|
||||
name: "{{ serveur_resolveur_paquets }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Déployer la configuration Set-OPS
|
||||
ansible.builtin.template:
|
||||
src: setops.conf.j2
|
||||
dest: /etc/unbound/unbound.conf.d/setops.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Redémarrer le résolveur
|
||||
|
||||
- name: Valider la configuration avant de la servir
|
||||
ansible.builtin.command: unbound-checkconf
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Activer et démarrer le résolveur
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_resolveur_service }}"
|
||||
enabled: true
|
||||
state: started
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Appliquer les redémarrages avant de vérifier
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# ÉCRIRE, PUIS RELIRE (D-68). Un résolveur « active » qui ne répond pas est le pire des
|
||||
# cas : toute la flotte pointe vers lui, et découvre la panne en essayant de résoudre.
|
||||
- name: Répond-il vraiment, pour l'interne ET pour l'Internet ?
|
||||
ansible.builtin.command: "dig @{{ ansible_host }} {{ item.nom }} {{ item.type }} +short"
|
||||
register: serveur_resolveur_controle
|
||||
changed_when: false
|
||||
failed_when: serveur_resolveur_controle.stdout | trim == ''
|
||||
loop:
|
||||
- { nom: "{{ serveur_resolveur_zone_interne }}", type: "SOA" }
|
||||
- { nom: "deb.debian.org", type: "A" }
|
||||
loop_control:
|
||||
label: "{{ item.nom }} {{ item.type }}"
|
||||
when: not ansible_check_mode
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# GÉNÉRÉ par Set-OPS (rôle serveur_resolveur). NE PAS éditer à la main.
|
||||
server:
|
||||
{% for a in serveur_resolveur_ecoutes %}
|
||||
interface: {{ a }}
|
||||
{% endfor %}
|
||||
{% for r in serveur_resolveur_reseaux_autorises %}
|
||||
access-control: {{ r }} allow
|
||||
{% endfor %}
|
||||
access-control: 127.0.0.0/8 allow
|
||||
hide-identity: yes
|
||||
hide-version: yes
|
||||
prefetch: yes
|
||||
do-ip6: no
|
||||
# La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC.
|
||||
domain-insecure: "{{ serveur_resolveur_zone_interne }}"
|
||||
# UNBOUND REFUSE D'INTERROGER UNE LOOPBACK PAR DÉFAUT (2026-08-24).
|
||||
#
|
||||
# `do-not-query-localhost` vaut `yes` d'origine — une protection contre les boucles.
|
||||
# Or l'autoritatif de l'écosystème vit désormais SUR 127.0.0.1:5300, derrière ce
|
||||
# résolveur. Sans cette ligne, toute la zone souveraine rend SERVFAIL.
|
||||
#
|
||||
# Et la panne était MASQUÉE : le plancher /etc/hosts répondait pour les noms de la
|
||||
# flotte, si bien qu'un `getent hosts forge.genese.internal` semblait prouver que la
|
||||
# résolution marchait. Seul un `dig` explicite l'a montrée.
|
||||
do-not-query-localhost: no
|
||||
|
||||
stub-zone:
|
||||
name: "{{ serveur_resolveur_zone_interne }}"
|
||||
stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }}
|
||||
{% if serveur_resolveur_transitaires %}
|
||||
|
||||
forward-zone:
|
||||
name: "."
|
||||
{% for f in serveur_resolveur_transitaires %}
|
||||
forward-addr: {{ f }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
|
@ -2,40 +2,3 @@
|
|||
ssh_baseline_password_authentication: "no"
|
||||
ssh_baseline_permit_root_login: "no"
|
||||
ssh_baseline_pubkey_authentication: "yes"
|
||||
|
||||
# --- QUI A LE DROIT D'ENTRER --------------------------------------------------
|
||||
#
|
||||
# La cle du compte d'administration est posee par CLOUD-INIT, a la creation de la VM.
|
||||
# Il n'existait donc aucun moyen declaratif d'en autoriser une SECONDE sur des machines
|
||||
# deja nees — constate le 2026-08-24 : le poste d'exploitation fabriquait sa propre paire
|
||||
# depuis son premier deploiement, et n'a jamais pu joindre un seul hote
|
||||
# (`Permission denied (publickey)`).
|
||||
#
|
||||
# CHAQUE ENTREE PORTE SON `etat`, ET C'EST DELIBERE. Une liste purement additive ne sait
|
||||
# pas RETIRER : une cle autorisee une fois le resterait pour toujours, et « revoquer »
|
||||
# voudrait dire se connecter a la main sur chaque machine. Ici, passer une entree a
|
||||
# `absent` la retire au prochain passage, sur toute la flotte.
|
||||
#
|
||||
# ON N'UTILISE PAS `exclusive: true` : il effacerait la cle posee par cloud-init si le
|
||||
# plan ne la reprenait pas, et fermerait la flotte a tout le monde d'un seul deploiement.
|
||||
# Le prix de ce choix est qu'une cle posee HORS du plan ne sera pas detectee ici.
|
||||
#
|
||||
# ssh_baseline_cles_admin:
|
||||
# - cle: "ssh-ed25519 AAAA… setops@ops-01.exemple.internal"
|
||||
# pourquoi: "poste d'exploitation — configure la flotte"
|
||||
# etat: present # `absent` pour revoquer
|
||||
ssh_baseline_admin_user: "{{ sudo_ansible_admin_user | default('ansible') }}"
|
||||
ssh_baseline_cles_admin: []
|
||||
|
||||
# LES FICHIERS QU'UNE NOMENCLATURE ABANDONNEE A LAISSES DERRIERE ELLE.
|
||||
#
|
||||
# Ce role a porte le nom `chezlepro` avant de porter celui du moteur. Le renommage a
|
||||
# change le fichier DEPOSE sans retirer le precedent : les deux coexistent sur toute
|
||||
# machine anterieure au changement, et sshd retient la PREMIERE valeur qu'il rencontre —
|
||||
# donc l'ancienne, l'ordre lexical la placant devant.
|
||||
#
|
||||
# Cette liste est le registre de ces mues. On n'y ajoute que des noms qu'on a REELLEMENT
|
||||
# deposes un jour : retirer un fichier qu'on n'a jamais ecrit serait s'arroger le droit de
|
||||
# supprimer la configuration de quelqu'un d'autre.
|
||||
ssh_baseline_fichiers_perimes:
|
||||
- 10-chezlepro.conf
|
||||
|
|
|
|||
|
|
@ -12,25 +12,6 @@
|
|||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# UN RENOMMAGE DE ROLE LAISSE SON ANCIEN FICHIER EN VIGUEUR (2026-08-25).
|
||||
#
|
||||
# Ce role s'appelait `chezlepro` et deposait `10-chezlepro.conf`. Renomme en `setops`, il
|
||||
# depose `10-setops.conf` — et l'ancien EST RESTE. Mesure sur forge-01 : les deux paires
|
||||
# coexistent, et elles ne disent pas la meme chose (`MaxSessions 2` contre `10`).
|
||||
#
|
||||
# sshd retient LA PREMIERE valeur rencontree pour chaque mot-cle, et lit les drop-ins dans
|
||||
# l'ordre lexical : c'est donc l'ANCIEN fichier qui gagne. Une configuration qu'on croit
|
||||
# avoir remplacee reste aux commandes, en silence, et sa trace se lira un jour comme une
|
||||
# lenteur inexplicable pendant un deploiement parallele.
|
||||
#
|
||||
# Retirer un fichier qu'on ne depose plus fait partie du travail de le deposer.
|
||||
- name: Retirer les configurations SSH d'une nomenclature abandonnee
|
||||
ansible.builtin.file:
|
||||
path: "/etc/ssh/sshd_config.d/{{ item }}"
|
||||
state: absent
|
||||
loop: "{{ ssh_baseline_fichiers_perimes }}"
|
||||
notify: Validate and reload ssh
|
||||
|
||||
- name: Déployer la configuration SSH Set-OPS
|
||||
ansible.builtin.template:
|
||||
src: 10-setops.conf.j2
|
||||
|
|
@ -45,15 +26,3 @@
|
|||
name: ssh
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Voir defaults/main.yml pour le raisonnement (etat par entree, pas d'`exclusive`).
|
||||
- name: Autoriser — ou révoquer — les clés d'administration déclarées au plan
|
||||
ansible.posix.authorized_key:
|
||||
user: "{{ ssh_baseline_admin_user }}"
|
||||
key: "{{ item.cle }}"
|
||||
state: "{{ item.etat | default('present') }}"
|
||||
comment: "{{ item.pourquoi | default(omit) }}"
|
||||
loop: "{{ ssh_baseline_cles_admin }}"
|
||||
loop_control:
|
||||
label: "{{ item.pourquoi | default(item.cle[:40]) }} → {{ item.etat | default('present') }}"
|
||||
when: not ansible_check_mode
|
||||
|
|
|
|||
|
|
@ -28,13 +28,3 @@ ssh_hardening_login_grace_time: "20s"
|
|||
# dans `ansible.cfg`) : ces valeurs sont la marge, pas la contrainte.
|
||||
ssh_hardening_max_startups: "10:30:60"
|
||||
ssh_hardening_max_sessions: 10
|
||||
|
||||
# MEME MUE, MEME DETTE : ce role a depose `20-chezlepro-hardening.conf` avant de porter le
|
||||
# nom du moteur. L'ancien fichier survit au renommage et, l'ordre lexical le placant
|
||||
# devant, c'est LUI que sshd retient pour chaque mot-cle en double. Mesure sur forge-01 :
|
||||
# `MaxSessions 2` (ancien) l'emportait sur `MaxSessions 10` (courant).
|
||||
#
|
||||
# On n'y liste que des noms qu'on a reellement deposes : supprimer un fichier qu'on n'a
|
||||
# jamais ecrit serait effacer la configuration d'un autre.
|
||||
ssh_hardening_fichiers_perimes:
|
||||
- 20-chezlepro-hardening.conf
|
||||
|
|
|
|||
|
|
@ -1,14 +1,4 @@
|
|||
---
|
||||
# VOIR defaults/main.yml : un renommage de role laisse son ancien fichier EN VIGUEUR, et
|
||||
# l'ordre lexical le place devant le nouveau. Retirer ce qu'on ne depose plus fait partie
|
||||
# du travail de deposer.
|
||||
- name: Retirer le hardening SSH d'une nomenclature abandonnee
|
||||
ansible.builtin.file:
|
||||
path: "/etc/ssh/sshd_config.d/{{ item }}"
|
||||
state: absent
|
||||
loop: "{{ ssh_hardening_fichiers_perimes }}"
|
||||
notify: Validate and reload ssh
|
||||
|
||||
- name: Déployer le hardening SSH additionnel
|
||||
ansible.builtin.template:
|
||||
src: 20-setops-hardening.conf.j2
|
||||
|
|
|
|||
|
|
@ -64,10 +64,6 @@ NOEUD_SORTIE = "<NOEUD-DE-SORTIE-EVPN>"
|
|||
IF_TRANSIT = "<IF-TRANSIT>"
|
||||
IF_WAN = "<IF-WAN>"
|
||||
IF_GESTION = "<IF-GESTION>"
|
||||
# L'interface ou arrive le trafic des MACHINES DU SITE (vlan 30 / vmbr3). Une regle
|
||||
# posee sur la mauvaise interface ne correspond jamais : le trafic du site ne penetre pas
|
||||
# le pare-feu par le lien de transit des tenants, mais par sa propre patte.
|
||||
IF_SITE = "<IF-SITE>"
|
||||
|
||||
|
||||
def depot_hebergeur() -> Path | None:
|
||||
|
|
@ -226,24 +222,12 @@ ROLE_SOCLE = "serveur_debian"
|
|||
|
||||
|
||||
def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict]]:
|
||||
"""[(role, flux)] pour tout flux que la FRONTIERE doit porter, trie.
|
||||
|
||||
Deux familles, et pas une :
|
||||
|
||||
`externe` — le nord-sud : l'Internet d'un cote, un tenant de l'autre.
|
||||
`voisins_site` — l'est-ouest INTER-TENANT. Deux tenants de la meme fabric vivent
|
||||
sur des VLAN distincts, routes par la frontiere : leur trafic la
|
||||
traverse, donc elle doit le porter.
|
||||
|
||||
OUBLIER LA SECONDE, C'ETAIT DECLARER UN FLUX QUE PERSONNE N'APPLIQUE (2026-08-24). Le
|
||||
mot `voisins_site` etait accepte par la validation, le devis rendait zero regle, et
|
||||
rien ne le signalait — le chainage des caches n'aurait simplement jamais fonctionne.
|
||||
"""
|
||||
"""[(role, flux)] pour tout flux dont le pair inclut `externe`, trie."""
|
||||
retenus = [
|
||||
(role, fl)
|
||||
for role, flux in sorted(flux_par_role.items())
|
||||
for fl in flux
|
||||
if "externe" in _pairs(fl) or "voisins_site" in _pairs(fl)
|
||||
if "externe" in _pairs(fl)
|
||||
]
|
||||
retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port"))))
|
||||
return retenus
|
||||
|
|
@ -265,40 +249,9 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
|
|||
return sorted({ips[h] for h in _hotes_du_groupe(data, role) if h in ips})
|
||||
|
||||
|
||||
# OPNsense refuse un nom d'alias de 32 caracteres ou plus. La contrainte n'etait nulle
|
||||
# part, et elle s'est manifestee a l'APPLICATION — pas au devis :
|
||||
#
|
||||
# SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE (36)
|
||||
# « The name must start with a letter [...] and be less than 32 characters »
|
||||
#
|
||||
# L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un
|
||||
# objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis.
|
||||
LONGUEUR_MAX_ALIAS = 31
|
||||
|
||||
# Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour
|
||||
# qu'un seul schema de lecture serve aux deux couches.
|
||||
ABREGES = (("SERVEUR_", "SRV_"), ("CLIENT_", "CLI_"))
|
||||
|
||||
|
||||
def nom_alias(role: str, etiquette: str) -> str:
|
||||
"""Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx.
|
||||
|
||||
Abrege SEULEMENT si le nom complet depasse — un nom court reste lisible tel quel, et
|
||||
la flotte existante n'est pas renommee pour la commodite d'un seul role.
|
||||
"""
|
||||
nom = f"SETOPS_{etiquette}_{role.upper()}"
|
||||
if len(nom) <= LONGUEUR_MAX_ALIAS:
|
||||
return nom
|
||||
court = role.upper()
|
||||
for long, bref in ABREGES:
|
||||
court = court.replace(long, bref)
|
||||
nom = f"SETOPS_{etiquette}_{court}"
|
||||
if len(nom) > LONGUEUR_MAX_ALIAS:
|
||||
# Mieux vaut un devis qui refuse qu'un devis qui promet l'impossible.
|
||||
raise SystemExit(
|
||||
f"Nom d'alias trop long pour OPNsense ({len(nom)} > {LONGUEUR_MAX_ALIAS}) : {nom}.\n"
|
||||
f"Raccourcir le nom du role `{role}`.")
|
||||
return nom
|
||||
"""Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx."""
|
||||
return f"SETOPS_{etiquette}_{role.upper()}"
|
||||
|
||||
|
||||
def _instance_active() -> tuple[str, dict] | tuple[None, None]:
|
||||
|
|
@ -310,51 +263,6 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]:
|
|||
return None, None
|
||||
|
||||
|
||||
def _plan_site() -> Path | None:
|
||||
"""Le `plan/` du SITE, a cote de son `underlay.yml`."""
|
||||
c = underlay_mod.chemin()
|
||||
return (c.resolve().parent / "plan") if c else None
|
||||
|
||||
|
||||
def _lire_plan_site(nom: str) -> dict:
|
||||
d = _plan_site()
|
||||
f = (d / nom) if d else None
|
||||
if not f or not f.is_file():
|
||||
return {}
|
||||
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def _machines_du_plan_site() -> list[dict]:
|
||||
srv = (_lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
|
||||
return [{**s, "nom": nom} for nom, s in srv.items()
|
||||
if str(s.get("etat", "actif")) == "actif"]
|
||||
|
||||
|
||||
def _ports_du_plan_site() -> dict[str, str]:
|
||||
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
|
||||
|
||||
UN PORT NON NUMERIQUE N'EST PAS UNE ECOUTE (voir `resoudre_flux`). `derive` dit que le
|
||||
port depend du deploiement : la forge ecoute 3000 derriere un edge, 443 quand elle
|
||||
sert son propre TLS. Le rendre tel quel produirait une regle que le boitier refuse —
|
||||
on l'a deja paye sur le pare-feu est-ouest.
|
||||
"""
|
||||
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
|
||||
out: dict[str, str] = {}
|
||||
for app in apps.values():
|
||||
if app.get("groupe") and app.get("port"):
|
||||
out[str(app["groupe"])] = str(app["port"])
|
||||
return out
|
||||
|
||||
|
||||
def _services_du_plan_site() -> dict[str, list[str]]:
|
||||
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for app in apps.values():
|
||||
if app.get("hote") and app.get("groupe"):
|
||||
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
|
||||
return out
|
||||
|
||||
|
||||
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||
"""Le devis, en structure — rendue ensuite en texte ou en JSON."""
|
||||
flux_par_role = charger_flux()
|
||||
|
|
@ -363,15 +271,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
if_wan = _ou_marqueur(_intr, "opnsense_if_wan", IF_WAN)
|
||||
if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT)
|
||||
if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION)
|
||||
if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE)
|
||||
# UNE PATTE PAR ZONE DU SITE (2026-08-25). Le site n'est plus un `/24` plat : chaque
|
||||
# nature d'autorite a son VLAN, et le trafic penetre le pare-feu par SA patte. Une
|
||||
# regle posee sur la mauvaise interface ne correspond jamais.
|
||||
if_zones = dict(_intr.get("opnsense_if_zones") or {})
|
||||
|
||||
def _if_de(reseau: str | None) -> str:
|
||||
"""L'interface ou arrive le trafic de cette zone ; l'ancienne patte a defaut."""
|
||||
return if_zones.get(str(reseau or ""), if_site)
|
||||
nom_actif, nomenclature_active = _instance_active()
|
||||
|
||||
# L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet.
|
||||
|
|
@ -522,23 +421,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
|
||||
if admin_par_if[etiquette]["wan"]:
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan))
|
||||
elif entrant and "voisins_site" in _pairs(fl):
|
||||
# UN FLUX ENTRE TENANTS DU MEME SITE.
|
||||
#
|
||||
# Ce n'est ni `flotte` (mon ecosysteme) ni `externe` (l'Internet) : le
|
||||
# paquet vient d'un VOISIN pose sur la meme fabric. Il arrive par le meme
|
||||
# lien de transit que tout le reste — les tenants ne se distinguent pas
|
||||
# par une interface mais par leur ALIAS SOURCE, deja construit plus haut
|
||||
# (`SETOPS_TENANT_<etiquette>`).
|
||||
#
|
||||
# LA REGLE APPARTIENT AU SITE, PAS A L'UN DES DEUX TENANTS. C'est le
|
||||
# runner de site qui prepare le terrain : il configure les VNets, les
|
||||
# routes et les flux pour que les plans des tenants tiennent. Aucun
|
||||
# ecosysteme n'ouvre de porte chez un autre.
|
||||
portees = [
|
||||
(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
|
||||
for nom2, p2, n2 in tenants if nom2 != nom
|
||||
]
|
||||
elif entrant:
|
||||
# Un service publie est joint DEPUIS INTERNET par le WAN, et le plus
|
||||
# souvent aussi depuis le VLAN d'administration — le poste de
|
||||
|
|
@ -566,16 +448,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
portees = [("any", if_wan)]
|
||||
if admin_par_if[etiquette]["gestion"] and fl.get("poste", True):
|
||||
portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion))
|
||||
elif "voisins_site" in _pairs(fl):
|
||||
# SORTANT VERS UN VOISIN : la destination est NOMMEE, pas « tout sauf
|
||||
# l'interne ». Rendre `!SETOPS_INTERNES` ici aurait ouvert le port vers
|
||||
# l'INTERNET — mesure du 2026-08-24, visible au devis avant application.
|
||||
#
|
||||
# Une regle par voisin : une regle porte une destination.
|
||||
portees = [
|
||||
(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
|
||||
for nom2, p2, n2 in tenants if nom2 != nom
|
||||
]
|
||||
else:
|
||||
portees = [("any", if_transit)]
|
||||
for source, interface in portees:
|
||||
|
|
@ -588,11 +460,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
# lui-meme pour qu'il apparaisse dans l'identite de la regle — sans
|
||||
# quoi la version niee et la version ouverte auraient la meme cle et
|
||||
# la seconde ne remplacerait jamais la premiere.
|
||||
# Sortant vers un voisin : la destination est le voisin lui-meme,
|
||||
# portee par `source` puisque `portees` la transporte. Sortant vers
|
||||
# l'Internet : « tout sauf l'interne ».
|
||||
"destination": destination if entrant
|
||||
else (source if "voisins_site" in _pairs(fl) else "!SETOPS_INTERNES"),
|
||||
"destination": destination if entrant else "!SETOPS_INTERNES",
|
||||
"ports": _ports(fl),
|
||||
"chiffrement": fl.get("chiffrement"),
|
||||
"role": role,
|
||||
|
|
@ -601,177 +469,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
})
|
||||
|
||||
intrants = intrants_frontiere()
|
||||
# --- LES MACHINES DU SITE -------------------------------------------------
|
||||
#
|
||||
# Elles ne sont dans AUCUN plan de tenant : la boucle ci-dessus ne pouvait donc pas
|
||||
# les voir, et le devis rendait zero regle pour elles sans rien signaler. Un devis
|
||||
# muet sur une machine qui existe n'est pas un devis.
|
||||
#
|
||||
# Leur trafic arrive par la patte du site (`if_site`), pas par le lien de transit.
|
||||
# Une regle posee sur la mauvaise interface ne correspond jamais — c'est la panne la
|
||||
# plus silencieuse de cette couche.
|
||||
_u = underlay_mod.charger()
|
||||
# LES MACHINES DU SITE VIENNENT DE SON PLAN, plus de l'underlay (2026-08-25).
|
||||
#
|
||||
# En deplacant le plan hors de `underlay.yml`, j'ai vide `machines()` sans reprendre
|
||||
# ici : le devis a cesse de voir le site et a propose de RETIRER ses 36 objets — tous
|
||||
# ses alias et toutes ses regles. Aucune preuve ne couvre le devis de la frontiere du
|
||||
# site, donc `make prouver` restait vert. C'est le plan avant application qui l'a
|
||||
# attrape, et rien d'autre ne l'aurait fait.
|
||||
_machines_site = _machines_du_plan_site()
|
||||
_services_site = _services_du_plan_site()
|
||||
_ports_site = _ports_du_plan_site()
|
||||
if _machines_site:
|
||||
_res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)}
|
||||
_sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"])
|
||||
for m in _machines_site if m.get("reseau") in _res_site})
|
||||
alias["SETOPS_SITE"] = {
|
||||
"type": "network",
|
||||
"contenu": _sous_site,
|
||||
"description": "Reseau des machines de l'hebergeur (runner de SITE, cache racine)",
|
||||
}
|
||||
# LA FABRIC — ce que le runner du site doit joindre pour MATERIALISER : l'API des
|
||||
# hyperviseurs, leur shell, l'API de la frontiere. C'est du RFC 1918 : le rendre
|
||||
# par `!SETOPS_INTERNES` (la forme de « vers Internet ») l'aurait exclu tout en
|
||||
# ayant l'air d'ouvrir le flux. D'ou le mot `fabric`, et cet alias-ci.
|
||||
_fabric = sorted({str(h["ip"]) for h in underlay_mod.hotes(_u)
|
||||
if h.get("role") in ("hyperviseur", "frontiere") and h.get("ip")})
|
||||
alias["SETOPS_FABRIC"] = {
|
||||
"type": "host",
|
||||
"contenu": _fabric,
|
||||
"description": "Materiel de l'hebergeur : hyperviseurs et frontiere — "
|
||||
"ce que le runner du SITE pilote pour materialiser",
|
||||
}
|
||||
# LE PLAN D'ADMINISTRATION, source du SSH vers le site. Sans lui, la regle
|
||||
# n'aurait pas de source : l'omettre ferme l'acces, l'ouvrir a `any` exposerait
|
||||
# le SSH du runner — la machine la plus puissante du site — a l'Internet.
|
||||
_admin_site = [str(r["sous_reseau"]) for r in underlay_mod.reseaux(_u)
|
||||
if r.get("nom") == "management" and r.get("sous_reseau")]
|
||||
if _admin_site:
|
||||
alias["SETOPS_ADMIN_SITE"] = {
|
||||
"type": "network",
|
||||
"contenu": _admin_site,
|
||||
"description": "Plan d'administration — seule source du SSH vers le site",
|
||||
}
|
||||
# Le socle vaut aussi pour le site : ses machines sont des Debian de la flotte,
|
||||
# et c'est lui qui porte leur SSH. `site_inventaire.py` les y range.
|
||||
# LE SITE RESOUT CHEZ SA PASSERELLE, ET IL FAUT LE DIRE (2026-08-25).
|
||||
#
|
||||
# Un tenant a son propre resolveur DANS son reseau : son DNS ne traverse jamais la
|
||||
# frontiere, et aucun flux ne le declare. Le site n'en a pas — trois machines ne
|
||||
# justifient pas un service de plus — il interroge donc la frontiere elle-meme.
|
||||
#
|
||||
# Sans cette regle, le default-deny bloque la requete, et la panne ne ressemble pas
|
||||
# a un pare-feu : `/etc/resolv.conf` est correct, Unbound ecoute bien sur toutes les
|
||||
# interfaces, le port repond au test TCP — et `apt update` echoue quand meme. On a
|
||||
# cherche du cote du resolveur pendant que c'etait le filtre.
|
||||
#
|
||||
# La destination est l'adresse DECLAREE dans `site.dns_amorcage`, pas `any` : ouvrir
|
||||
# le 53 vers le monde depuis le site serait une sortie DNS non policee.
|
||||
_resolveur = str((_u.get("site") or {}).get("dns_amorcage") or "").strip()
|
||||
if _resolveur:
|
||||
alias["SETOPS_RESOLVEUR_SITE"] = {
|
||||
"type": "host",
|
||||
"contenu": [_resolveur],
|
||||
"description": "Resolveur des machines du site — leur passerelle, "
|
||||
"declaree dans `site.dns_amorcage`",
|
||||
}
|
||||
for _proto in ("udp", "tcp"):
|
||||
regles.append({
|
||||
"sens": "out",
|
||||
"interface": if_site,
|
||||
"protocole": _proto,
|
||||
"source": "SETOPS_SITE",
|
||||
"destination": "SETOPS_RESOLVEUR_SITE",
|
||||
"ports": ["53"],
|
||||
"chiffrement": "clair",
|
||||
"role": "site",
|
||||
"tenant": "SITE",
|
||||
"raison": "Resolution DNS des machines du site aupres de leur "
|
||||
"passerelle : le site n'a pas de resolveur a lui.",
|
||||
})
|
||||
|
||||
_roles_site = sorted({g for gs in _services_site.values() for g in gs}
|
||||
| {"serveur_debian"})
|
||||
for _role in _roles_site:
|
||||
_ips = sorted({str(m["ip"]) for m in _machines_site
|
||||
if _role == "serveur_debian"
|
||||
or _role in _services_site.get(m["nom"], [])})
|
||||
_a = nom_alias(_role, "SITE")
|
||||
alias[_a] = {
|
||||
"type": "host",
|
||||
"contenu": _ips,
|
||||
"description": f"Machines du site portant {_role}",
|
||||
}
|
||||
for _fl in flux_par_role.get(_role, []):
|
||||
_entrant = _fl.get("sens") == "ingress"
|
||||
_pairs_fl = _pairs(_fl)
|
||||
if _entrant and "voisins_site" in _pairs_fl:
|
||||
# Un tenant qui s'adresse au site : le paquet penetre le pare-feu par
|
||||
# le lien de TRANSIT, pas par la patte du site. Une regle par voisin,
|
||||
# parce qu'une regle porte une source.
|
||||
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
|
||||
for _n2, p2, n2 in tenants]
|
||||
elif _entrant and "flotte" in _pairs_fl:
|
||||
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
|
||||
#
|
||||
# `flotte` veut dire « les machines de mon ecosysteme ». Dans un `/24`
|
||||
# plat, ce trafic ne traversait rien : le cache, la forge, le
|
||||
# resolveur et l'AC se joignaient en L2, sans qu'aucune regle
|
||||
# n'existe — ni ne manque.
|
||||
#
|
||||
# Une zone par autorite change cela : une machine qui interroge le
|
||||
# resolveur ou tire un paquet du cache passe desormais par la
|
||||
# frontiere. Ce qui n'avait pas besoin d'etre dit doit l'etre.
|
||||
#
|
||||
# Une regle PAR ZONE SOURCE : le paquet penetre le pare-feu par la
|
||||
# patte de la zone d'ou il part, et une regle posee ailleurs ne
|
||||
# correspondrait jamais. La destination reste nommee — le role vise,
|
||||
# jamais la zone entiere.
|
||||
_portees = [("SETOPS_SITE", z) for z in
|
||||
sorted({_if_de(m.get("reseau")) for m in _machines_site})]
|
||||
elif _entrant and "22" in _ports(_fl) and _admin_site:
|
||||
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
|
||||
# frontiere, pas par celle du site : c'est la que le paquet penetre
|
||||
# le pare-feu. Une regle posee ailleurs ne correspondrait jamais.
|
||||
_portees = [("SETOPS_ADMIN_SITE", if_gestion)]
|
||||
elif _entrant:
|
||||
continue # rien d'autre n'entre chez le site depuis l'exterieur
|
||||
else:
|
||||
# Une portee par ZONE ou ce role est present : deux machines d'un meme
|
||||
# role dans deux zones differentes ne partagent pas leur patte.
|
||||
_zones = sorted({_if_de(m.get("reseau")) for m in _machines_site
|
||||
if _role == "serveur_debian"
|
||||
or _role in _services_site.get(m["nom"], [])})
|
||||
_portees = [(_a, z) for z in _zones] or [(_a, if_site)]
|
||||
# `derive` se resout par le port declare au plan ; sans lui, on n'emet
|
||||
# rien plutot qu'une regle que le boitier refusera.
|
||||
_p = _ports(_fl)
|
||||
if any(not str(x).isdigit() for x in _p):
|
||||
_reel = _ports_site.get(_role)
|
||||
if not _reel:
|
||||
# SUR STDERR, JAMAIS STDOUT : ce script rend du JSON, et une
|
||||
# note au milieu le rend illisible a `appliquer_opnsense`.
|
||||
print(f"note : {_role} declare un port `{'/'.join(_p)}` que le "
|
||||
f"plan du site ne resout pas — aucune regle emise.",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
_p = [_reel]
|
||||
for _src, _itf in _portees:
|
||||
regles.append({
|
||||
"sens": "in" if _entrant else "out",
|
||||
"interface": _itf,
|
||||
"protocole": _fl.get("protocole", "tcp"),
|
||||
"source": _src if _entrant else _a,
|
||||
"destination": _a if _entrant else (
|
||||
"SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"),
|
||||
"ports": _p,
|
||||
"chiffrement": _fl.get("chiffrement"),
|
||||
"role": _role,
|
||||
"tenant": "SITE",
|
||||
"raison": _fl.get("raison", ""),
|
||||
})
|
||||
|
||||
transit = transit_underlay()
|
||||
saut = prochain_saut(transit)
|
||||
# UNE ROUTE PAR SOUS-RESEAU REELLEMENT ATTRIBUE, et non une par supernet.
|
||||
|
|
@ -818,19 +515,6 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
|||
}
|
||||
for nom, pfx, n in tenants
|
||||
]
|
||||
# LE SITE AUSSI SORT. Son reseau est directement attache a la frontiere (OPT2), donc
|
||||
# le mode automatique d'OPNsense le couvrirait — mais nous ne sommes plus en mode
|
||||
# automatique : declarer les tenants a la main l'a bascule. Compter sur un mode qu'on
|
||||
# a soi-meme quitte, c'est le genre de supposition qui se paie au premier `apt update`
|
||||
# du cache racine, quand plus rien ne se telecharge et que tout a l'air correct.
|
||||
if _machines_site and "SETOPS_SITE" in alias:
|
||||
nat.append({
|
||||
"interface": if_wan,
|
||||
"source": "SETOPS_SITE",
|
||||
"destination": "any",
|
||||
"cible": "wanip",
|
||||
"tenant": "SITE",
|
||||
})
|
||||
|
||||
# Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois :
|
||||
# elle sert au prochain saut des routes ET au commentaire qui l'explique.
|
||||
|
|
|
|||
|
|
@ -90,30 +90,9 @@ def _verifier_unicite(noms: list[str], tenant: str) -> None:
|
|||
vus[nom] = role
|
||||
|
||||
|
||||
# Les ports DERIVES ne sont pas des ecoutes fixes : ils dependent de ce que l'hote porte.
|
||||
# Le pare-feu de l'hyperviseur, lui, ne connait que des nombres.
|
||||
#
|
||||
# CONSTATE LE 2026-08-24, A L'APPLICATION : `dport: derive` est parti tel quel vers l'API,
|
||||
# qui a repondu « invalid port 'derive' » -- six regles refusees. `verifier_ports` savait
|
||||
# deja sauter un port non numerique ; ce generateur-ci ne le savait pas.
|
||||
#
|
||||
# CE QUE CA VEUT DIRE POUR POWERDNS, le seul role concerne : depuis que le resolveur du
|
||||
# tenant est la seule porte, l'autoritatif n'ecoute que sur 127.0.0.1:5300. Aucune regle
|
||||
# est-ouest n'a d'objet pour lui — sauter est donc la BONNE reponse, pas un contournement.
|
||||
#
|
||||
# Les ports sautes sont RECENSES et affiches avec le devis : un flux qu'on n'applique pas
|
||||
# doit se voir, sinon c'est un trou silencieux.
|
||||
PORTS_DERIVES: set[str] = set()
|
||||
|
||||
|
||||
def _ports(fl: dict) -> list[str]:
|
||||
p = fl["port"]
|
||||
tous = [str(x) for x in (p if isinstance(p, list) else [p])]
|
||||
fixes = [x for x in tous if x.isdigit()]
|
||||
for x in tous:
|
||||
if not x.isdigit():
|
||||
PORTS_DERIVES.add(x)
|
||||
return fixes
|
||||
return [str(x) for x in (p if isinstance(p, list) else [p])]
|
||||
|
||||
|
||||
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||
|
|
@ -391,15 +370,6 @@ def main(argv: list[str]) -> int:
|
|||
f"{total_g} groupe(s), {total_r} regle(s).")
|
||||
return 0
|
||||
print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis))
|
||||
# UN FLUX QU'ON N'APPLIQUE PAS DOIT SE VOIR. Sauter un port derive est la bonne
|
||||
# reponse, mais le taire en ferait un trou silencieux.
|
||||
if PORTS_DERIVES and not args.json:
|
||||
print(f"\n note : {len(PORTS_DERIVES)} port(s) DERIVE(s) non rendus ici — "
|
||||
f"{', '.join(sorted(PORTS_DERIVES))}.")
|
||||
print(" Un port derive depend de ce que l'hote porte ; le pare-feu de "
|
||||
"l'hyperviseur ne connait que des nombres.")
|
||||
print(" Cas connu : PowerDNS derriere le resolveur du tenant n'ecoute que sur "
|
||||
"127.0.0.1 — aucune regle est-ouest n'a d'objet pour lui.")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -87,20 +87,8 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
|
|||
if not r or not r.get("sous_reseau"):
|
||||
return []
|
||||
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
|
||||
# LE SEGMENT DE GESTION PEUT NE PAS ETRE ETIQUETE (2026-08-25).
|
||||
#
|
||||
# Un switch declare son adresse sur un reseau ; si ce reseau est un SEGMENT PHYSIQUE
|
||||
# (`segment_physique: true`, aucune encapsulation), il n'existe aucune `interface
|
||||
# VlanN` a configurer — l'adresse appartient a l'interface de gestion native du
|
||||
# boitier, dont le nom depend du modele. On le DIT plutot que d'inventer une syntaxe :
|
||||
# un devis qui promet une commande fausse est pire qu'un devis qui se tait.
|
||||
if r.get("vlan") is None:
|
||||
out = [f"! gestion {nom_switch} : le segment '{r['nom']}' n'est pas etiquete —",
|
||||
f"! {h['ip']} {net.netmask} va sur l'interface de gestion native du",
|
||||
"! boitier (nom dependant du modele), pas sur une interface VLAN."]
|
||||
else:
|
||||
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
|
||||
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
|
||||
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
|
||||
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
|
||||
gw = r.get("passerelle")
|
||||
if gw:
|
||||
porteur = next((x for x in underlay_mod.hotes(underlay)
|
||||
|
|
@ -110,25 +98,6 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
# UN SEGMENT SANS ETIQUETTE N'APPARTIENT A AUCUNE FABRIC DE COMMUTATEUR (2026-08-25).
|
||||
#
|
||||
# Un reseau peut se declarer `segment_physique: true` : le plan d'administration de
|
||||
# Chezlepro arrive sur un PORT dedie de la frontiere, sans encapsulation, et ne traverse
|
||||
# aucun switch — mesure du 2026-08-24, trois sondes muettes avec temoin positif. Il n'a
|
||||
# donc pas de cle `vlan`.
|
||||
#
|
||||
# Ce devis emettait `r['vlan']` en une vingtaine d'endroits. Le premier segment non
|
||||
# etiquete a leve un `KeyError`, l'API du panneau a rendu une 500, et la vue Reseau de
|
||||
# l'interface est restee VIDE — une panne qui ne ressemble a rien, deux couches plus haut
|
||||
# que sa cause. Colmater les vingt appels aurait laisse le vingt-et-unieme.
|
||||
#
|
||||
# On filtre donc A LA SOURCE, et une seule fois : ce fichier ne configure que des
|
||||
# commutateurs, et un commutateur n'a rien a dire d'un segment qui ne l'atteint pas.
|
||||
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
|
||||
return [r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
|
||||
if r.get("vlan") is not None]
|
||||
|
||||
|
||||
def section_underlay(underlay: dict | None) -> list[str]:
|
||||
"""VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay.
|
||||
|
||||
|
|
@ -137,7 +106,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
|
|||
leurs VLAN sur ces switches serait faux, et les mettre dans leurs trunks aussi.
|
||||
"""
|
||||
fabric = underlay_mod.fabric_du_routeur(underlay)
|
||||
reseaux = reseaux_de_fabric(underlay, fabric)
|
||||
reseaux = underlay_mod.reseaux_de_fabric(underlay, fabric)
|
||||
if not reseaux:
|
||||
return []
|
||||
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"]
|
||||
|
|
@ -183,7 +152,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
|
|||
for autre in underlay_mod.fabriques(underlay):
|
||||
if autre == fabric:
|
||||
continue
|
||||
r_autres = reseaux_de_fabric(underlay, autre)
|
||||
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre)
|
||||
out.append(f"! HORS PERIMETRE — fabric '{autre}' : "
|
||||
+ ", ".join(f"{r['nom']} (VLAN {r['vlan']})" for r in r_autres))
|
||||
out.append("! Portee par des switches distincts, sans cable commun avec celle-ci :")
|
||||
|
|
@ -314,7 +283,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
|
|||
if not r_nom:
|
||||
return []
|
||||
fabric = underlay_mod.fabric_du_routeur(underlay)
|
||||
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric)
|
||||
mgmt = next((r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
|
||||
if r.get("passerelle")), None)
|
||||
# SOURCE UNIQUE : `switches_acces()` decide qui est un switch d'acces — reseau de
|
||||
# management ET role `switch`. Une copie locale de ce filtre avait laisse les
|
||||
|
|
@ -337,7 +306,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
|
|||
# Le lien de frontiere N'EST PLUS reserve au routeur : depuis que les noeuds de
|
||||
# sortie EVPN y ont une patte, un hyperviseur branche sur un switch d'acces doit
|
||||
# pouvoir l'atteindre. Le sauter ici ferait tomber son trafic sortant en silence.
|
||||
for r in reseaux_de_fabric(underlay, fabric):
|
||||
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
|
||||
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
|
||||
if underlay_mod.routage_tenants(underlay) == "sdn":
|
||||
out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.")
|
||||
|
|
@ -388,7 +357,7 @@ def reseaux_par_via(underlay: dict | None, role: str) -> dict[str, list[dict]]:
|
|||
"""
|
||||
par_via: dict[str, dict[str, dict]] = {}
|
||||
fab = underlay_mod.fabric_du_routeur(underlay)
|
||||
connus = {r.get("nom"): r for r in reseaux_de_fabric(underlay, fab)
|
||||
connus = {r.get("nom"): r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
|
||||
if r.get("vlan") is not None}
|
||||
for h in underlay_mod.hotes(underlay):
|
||||
if h.get("role") != role or not h.get("reseau"):
|
||||
|
|
@ -413,7 +382,7 @@ def vlans_du_role(underlay: dict | None, role: str,
|
|||
noms = {h.get("reseau") for h in underlay_mod.hotes(underlay)
|
||||
if h.get("role") == role and h.get("reseau")}
|
||||
fab = underlay_mod.fabric_du_routeur(underlay)
|
||||
reseaux = [r for r in reseaux_de_fabric(underlay, fab)
|
||||
reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
|
||||
if r.get("nom") in noms and r.get("vlan") is not None]
|
||||
if not reseaux and repli:
|
||||
reseaux = [repli]
|
||||
|
|
@ -517,7 +486,7 @@ def switch_route(underlay: dict | None) -> bool:
|
|||
cas d'erreur — disparait d'elle-meme.
|
||||
"""
|
||||
r_nom = underlay_mod.routeur(underlay)
|
||||
for r in reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
|
||||
for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
|
||||
gw = r.get("passerelle")
|
||||
if not gw:
|
||||
continue
|
||||
|
|
@ -767,7 +736,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
|
|||
# perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote
|
||||
# declare ; un lien de fabric porte ce qui doit traverser.
|
||||
vlans_fabric = [str(r["vlan"])
|
||||
for r in reseaux_de_fabric(
|
||||
for r in underlay_mod.reseaux_de_fabric(
|
||||
underlay, underlay_mod.fabric_du_routeur(underlay))
|
||||
if r.get("vlan") is not None]
|
||||
# En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que
|
||||
|
|
@ -784,7 +753,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
|
|||
# Aucun hyperviseur declare : on ne peut rien deriver de leurs rattachements.
|
||||
# Emettre TOUT l'underlay est le repli sur, et il faut le dire — un devis muet
|
||||
# ferait croire qu'il n'y a pas de port a configurer, ce qui est faux.
|
||||
par_via_hyp = {"": [r for r in reseaux_de_fabric(
|
||||
par_via_hyp = {"": [r for r in underlay_mod.reseaux_de_fabric(
|
||||
underlay, underlay_mod.fabric_du_routeur(underlay)) if r.get("vlan") is not None]}
|
||||
out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
|
||||
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",
|
||||
|
|
|
|||
|
|
@ -172,47 +172,6 @@ def generer() -> dict:
|
|||
sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
|
||||
index_tenant = nomenclature.get("index")
|
||||
|
||||
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).
|
||||
#
|
||||
# `deriver_nomenclature(...) or {}` avale l'echec : une fonction absente de
|
||||
# `nomenclature.fonctions` rend un dictionnaire vide, et la machine entre dans
|
||||
# l'inventaire avec `ansible_host: None` et `vlan: None`. La generation se declare
|
||||
# REUSSIE. La panne n'apparait qu'au deploiement, sous une forme incomprehensible —
|
||||
# Ansible tentant de joindre une adresse qui n'existe pas.
|
||||
#
|
||||
# Constate en ajoutant `ops-01` aux modeles : `presence-web` range son socle en zone 1
|
||||
# (« Fondations ») et n'a pas de categorie 4. La machine a ete posee, la fonction non,
|
||||
# et rien ne l'a signale.
|
||||
#
|
||||
# On refuse en BLOC plutot qu'a la premiere fautive : l'exploitant corrige son plan
|
||||
# une fois, pas une machine a la fois.
|
||||
# CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24).
|
||||
#
|
||||
# J'y avais ajoute une branche « adressage declare » pour y faire entrer les machines
|
||||
# d'un SITE. C'etait le debut d'une confusion : un site et un tenant ne sont pas deux
|
||||
# variantes d'une meme chose. Un site est le TERRAIN, un tenant ce qui y pousse.
|
||||
#
|
||||
# Le symptome etait visible tout de suite — une branche `si l'adresse est declaree`,
|
||||
# un `nomenclature.yml` de site reduit a une coquille vide, et un site exclu des devis
|
||||
# par ABSENCE d'index plutot que par nature. Une exclusion fondee sur un manque est
|
||||
# une exclusion qu'un ajout innocent casserait.
|
||||
#
|
||||
# Les machines de l'hebergeur se declarent desormais dans `underlay.yml`, a cote des
|
||||
# switches et des hyperviseurs — du mobilier de fabric. Ce qui se partage entre les
|
||||
# deux mondes, ce sont les ROLES, pas la forme du plan.
|
||||
sans_fonction = [
|
||||
nom for nom, srv in serveurs.items()
|
||||
if not (deriver_nomenclature(str(srv.get("fonction", "")),
|
||||
fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip")
|
||||
]
|
||||
if sans_fonction:
|
||||
connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)"
|
||||
raise SystemExit(
|
||||
"Machines sans adresse derivable — leur `fonction` n'est pas declaree dans "
|
||||
"`nomenclature.fonctions` :\n"
|
||||
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
|
||||
+ f"Fonctions connues de ce plan : {connues}")
|
||||
|
||||
children: dict = {
|
||||
"modeles_vm": {"hosts": {}},
|
||||
"hotes_actifs": {"hosts": {}},
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ CHAMPS_PROXMOX: tuple[tuple, ...] = (
|
|||
("SETOPS_NOEUD", "proxmox_noeud", False),
|
||||
("SETOPS_COEURS", "proxmox_coeurs", False),
|
||||
("SETOPS_MEMOIRE", "proxmox_memoire", False),
|
||||
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_resolveur`
|
||||
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound`
|
||||
# bascule ensuite `/etc/resolv.conf` vers 127.0.0.1. Mais sans lui, la VM nait sans
|
||||
# resolution de noms et `apt` ne peut meme pas installer Unbound — l'amorcage
|
||||
# n'aboutit jamais. Le DNS autoritatif du tenant ne convient PAS : il refuse tout ce
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ def integrations_universelles(racine: Path | None = None) -> dict[str, dict]:
|
|||
Une integration universelle n'est PAS recopiee sur chaque serveur du plan : le
|
||||
role la declare une fois dans roles/<role>/meta/integration.yml, et tout hote
|
||||
la recoit. Le plan ne porte plus que les integrations reellement facultatives
|
||||
(client_backup, client_smtp, client_resolveur) et les exemptions.
|
||||
(client_backup, client_smtp, client_unbound) et les exemptions.
|
||||
|
||||
Pourquoi : recopiee 14 fois, la meme ligne finit par manquer une quinzieme —
|
||||
et un hote non supervise, non journalise ou sans certificat ne proteste pas.
|
||||
|
|
@ -480,18 +480,7 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str |
|
|||
for fqdn in (app.get("expose") or []):
|
||||
dom = domaine_parent(fqdn, doms)
|
||||
conf = doms.get(dom, {}) if dom else {}
|
||||
# SANS EDGE DECLARE, LE SERVICE SE SERT LUI-MEME (2026-08-25).
|
||||
#
|
||||
# `edge` se resout par le DOMAINE : chez un tenant, `domaines.yml` dit quel
|
||||
# nginx front telle zone, et tout FQDN de cette zone y aboutit. Le SITE n'a
|
||||
# pas d'edge — chaque service ecoute lui-meme, avec son propre certificat.
|
||||
#
|
||||
# Sans ce repli, la derivation rendait `edge: None` : le gabarit de
|
||||
# `hosts_statiques` cherchait alors `groups[None]`, ne trouvait rien, et
|
||||
# ecrivait un plancher SANS alias. Le certificat de la forge portait
|
||||
# `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct
|
||||
# sur un nom que personne ne pouvait appeler.
|
||||
edge_dom = conf.get("edge") or app.get("groupe")
|
||||
edge_dom = conf.get("edge")
|
||||
if edge is not None and edge_dom != edge:
|
||||
continue
|
||||
resultat.append({
|
||||
|
|
|
|||
|
|
@ -928,222 +928,6 @@ def preuve_parente_inscrite() -> tuple[bool, str]:
|
|||
return r.returncode == 0, (sortie[-1] if sortie else "")
|
||||
|
||||
|
||||
def preuve_devis_frontiere_du_site() -> tuple[bool, str]:
|
||||
"""Le devis de la frontiere VOIT les machines du site — lues independamment.
|
||||
|
||||
POURQUOI CETTE PREUVE EXISTE (2026-08-25). En deplacant le plan du site hors de
|
||||
`underlay.yml`, `underlay.machines()` s'est vide sans que `devis_opnsense` soit
|
||||
rebranche. Le devis a cesse de voir le site et a propose de RETIRER 36 objets — tous
|
||||
ses alias et toutes ses regles. La frontiere aurait laisse tomber la forge du genome,
|
||||
le cache racine et le runner du site, d'un seul `CONFIRMER=true`.
|
||||
|
||||
Rien ne l'a signale : le harnais est reste vert, `ansible-lint` aussi. Seule la
|
||||
lecture MANUELLE du plan avant application l'a attrape.
|
||||
|
||||
ELLE LIT LE PLAN ELLE-MEME, ET C'EST TOUT SON INTERET. Un premier jet passait par
|
||||
`devis_opnsense._machines_du_plan_site()` — la fonction meme dont la panne etait a
|
||||
detecter. Eprouve sur la regression reelle, il SE TAISAIT : les deux voyaient le vide,
|
||||
et la preuve concluait « rien a prouver ». Une preuve qui partage la source de ce
|
||||
qu'elle verifie ne verifie rien.
|
||||
|
||||
Elle se tait quand il n'y a pas de plan de site — un depot sans underlay monte n'a
|
||||
rien a prouver ici — mais jamais quand le plan existe et que le devis l'ignore.
|
||||
"""
|
||||
import ipaddress
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
import underlay as underlay_mod
|
||||
import devis_opnsense as D
|
||||
import devis_reseau
|
||||
|
||||
# --- LE TEMOIN : le plan, lu directement, sans passer par le devis ---------
|
||||
chemin = underlay_mod.chemin()
|
||||
plan = (chemin.resolve().parent / "plan") if chemin else None
|
||||
f_srv = (plan / "serveurs.yml") if plan else None
|
||||
if not f_srv or not f_srv.is_file():
|
||||
return True, "Aucun plan de site (pas de `plan/serveurs.yml`) : rien a exiger."
|
||||
serveurs = (yaml.safe_load(f_srv.read_text(encoding="utf-8")) or {}).get("serveurs") or {}
|
||||
attendus = {n: s for n, s in serveurs.items()
|
||||
if str(s.get("etat", "actif")) == "actif"}
|
||||
if not attendus:
|
||||
return True, "Plan de site sans machine active : rien a exiger."
|
||||
|
||||
f_app = plan / "applications.yml"
|
||||
apps = ((yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
|
||||
) if f_app.is_file() else {}
|
||||
roles_par_hote: dict[str, list[str]] = {}
|
||||
for app in apps.values():
|
||||
if app.get("hote") and app.get("groupe"):
|
||||
roles_par_hote.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
|
||||
|
||||
# --- CE QUE LE DEVIS PRODUIT, au meme perimetre que `make frontiere-plan` --
|
||||
devis = D.construire(devis_reseau.decouvrir_du_site())
|
||||
alias = devis.get("alias") or {}
|
||||
regles = devis.get("regles") or []
|
||||
manques: list[str] = []
|
||||
|
||||
reseaux = [str(x) for x in ((alias.get("SETOPS_SITE") or {}).get("contenu") or [])]
|
||||
if not reseaux:
|
||||
manques.append(f"`SETOPS_SITE` est absent ou vide, alors que le plan declare "
|
||||
f"{len(attendus)} machine(s) : le devis ne voit pas le site")
|
||||
else:
|
||||
for nom, srv in attendus.items():
|
||||
if not any(ipaddress.ip_address(str(srv["ip"])) in
|
||||
ipaddress.ip_network(r, strict=False) for r in reseaux):
|
||||
manques.append(f"{nom} ({srv['ip']}) n'est dans aucun sous-reseau de "
|
||||
f"`SETOPS_SITE`")
|
||||
|
||||
for hote, groupes in roles_par_hote.items():
|
||||
if hote not in attendus:
|
||||
continue
|
||||
for g in groupes:
|
||||
attendu = D.nom_alias(g, "SITE")
|
||||
if attendu not in alias:
|
||||
manques.append(f"{hote} porte `{g}` au plan, mais l'alias `{attendu}` "
|
||||
f"n'est pas au devis")
|
||||
|
||||
n_site = sum(1 for r in regles if str(r.get("tenant")) == "SITE")
|
||||
if not n_site:
|
||||
manques.append("aucune regle du devis ne porte sur le site : la frontiere ne "
|
||||
"laisserait rien passer vers ses machines")
|
||||
|
||||
if manques:
|
||||
return False, ("Le devis de la frontiere ne voit pas le site :\n - "
|
||||
+ "\n - ".join(manques))
|
||||
return True, (f"Devis de la frontiere : {len(attendus)} machine(s) du plan retrouvees, "
|
||||
f"{n_site} regle(s) du site.")
|
||||
|
||||
|
||||
def preuve_ordre_des_integrations() -> tuple[bool, str]:
|
||||
"""Une integration applique SON SERVEUR avant ceux qui s'y adressent.
|
||||
|
||||
POURQUOI (2026-08-25). `client_pki` s'executait sur tous les hotes en parallele. Sur
|
||||
celui qui porte l'autorite, le role reemet le certificat de `step-ca` et RECHARGE le
|
||||
service ; les quatre autres l'interrogeaient dans cette fenetre et echouaient ensemble
|
||||
sur `net/http: TLS handshake timeout` — un message qui accuse le reseau alors que la
|
||||
cause est une course de dix secondes.
|
||||
|
||||
Ce n'est pas propre a la PKI. Toute integration a la meme forme : un `client_*`
|
||||
s'adresse a un `serveur_*`, et l'ordre compte. Le cas le plus dangereux n'est pas le
|
||||
certificat mais le RESOLVEUR — un hote bascule sur un resolveur pas encore pret
|
||||
devient muet, et le runner qui devrait le reparer tombe avec lui.
|
||||
|
||||
La dependance se declare dans `roles/<client>/meta/integration.yml` (`serveur:`), et
|
||||
le playbook de groupe en est le miroir. Cette preuve refuse l'ecart entre les deux :
|
||||
sans elle, la declaration deviendrait un commentaire, et l'ordre une coincidence.
|
||||
|
||||
`serveur: ~` est une reponse valable — `client_metrique` pose un exportateur que
|
||||
Prometheus vient LIRE, il n'attend personne. Toutes les integrations ne dependent pas
|
||||
d'un service debout, et le dire explicitement vaut mieux que l'omettre.
|
||||
"""
|
||||
manques: list[str] = []
|
||||
vus = 0
|
||||
for meta in sorted((RACINE / "roles").glob("client_*/meta/integration.yml")):
|
||||
role = meta.parent.parent.name
|
||||
d = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("integration") or {}
|
||||
if "serveur" not in d:
|
||||
manques.append(f"{role} : `meta/integration.yml` ne declare pas `serveur:` — "
|
||||
f"on ne peut donc pas savoir si son ordre est correct "
|
||||
f"(mettre `~` si l'integration n'attend aucun service)")
|
||||
continue
|
||||
srv = d.get("serveur")
|
||||
pb = RACINE / "playbooks" / "groupes" / f"{role}.yml"
|
||||
if not pb.is_file():
|
||||
continue
|
||||
texte = pb.read_text(encoding="utf-8")
|
||||
if not srv:
|
||||
continue # rien a ordonner, et c'est declare
|
||||
vus += 1
|
||||
if f"hosts: {role}:&{srv}" not in texte:
|
||||
manques.append(f"{role} : declare dependre de `{srv}`, mais son playbook "
|
||||
f"n'applique pas d'abord `{role}:&{srv}`")
|
||||
if f"hosts: {role}:!{srv}" not in texte:
|
||||
manques.append(f"{role} : son playbook n'exclut pas `{srv}` du second play — "
|
||||
f"le serveur serait reconfigure deux fois, et l'ordre perdu")
|
||||
if "any_errors_fatal: true" not in texte:
|
||||
manques.append(f"{role} : sans `any_errors_fatal` sur le premier play, un "
|
||||
f"serveur en echec laisse ses clients s'y raccrocher pour rien")
|
||||
if manques:
|
||||
return False, ("L'ordre des integrations n'est pas garanti :\n - "
|
||||
+ "\n - ".join(manques))
|
||||
return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.")
|
||||
|
||||
|
||||
def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]:
|
||||
"""Le pare-feu Proxmox ne s'arme que sur un VNet SDN, jamais sur un pont classique.
|
||||
|
||||
POURQUOI (2026-08-25). `firewall=1` fait passer tout le trafic ponte par conntrack.
|
||||
Sur un VNet SDN c'est sans consequence : en EVPN le routage inter-VNet se fait dans le
|
||||
VRF, SUR LE NOEUD, et le flux ne quitte jamais l'hyperviseur.
|
||||
|
||||
Sur un pont classique route par une frontiere externe, le meme reglage devient un
|
||||
piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en EPINGLE :
|
||||
la trame sort par le lien physique, la frontiere la route, elle revient sur LE MEME
|
||||
PONT. La meme table conntrack voit les deux moities de la connexion, classe le retour
|
||||
`INVALID`, et `PVEFW-FORWARD` le jette.
|
||||
|
||||
Mesure : sur le site decoupe en quatre zones, les paires intra-noeud etaient a 0/8 et
|
||||
toutes les paires inter-noeuds a 8/8 ; douze tentatives faisaient monter le compteur
|
||||
`ctstate INVALID` de +112 sur asgard et +116 sur gandalf. `firewall=0` pose, il ne
|
||||
bouge plus. Le defaut se deguise en panne reseau : la poignee TCP aboutit, et ce sont
|
||||
les paquets de DONNEES qui disparaissent — on accuse le MTU, la frontiere, la zone.
|
||||
|
||||
L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare
|
||||
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur
|
||||
PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui retombe sur
|
||||
`vmbr1` naitrait arme sur un pont classique.
|
||||
|
||||
Cette preuve n'inspecte pas le texte du playbook : elle EVALUE l'expression qu'il
|
||||
porte, sur quatre cas. Le cas « intention vraie sur un VNet » doit rendre VRAI —
|
||||
sinon une expression constamment fausse passerait la preuve sans rien garantir.
|
||||
"""
|
||||
import jinja2
|
||||
|
||||
pb = RACINE / "playbooks" / "proxmox" / "cloner_vm_debian.yml"
|
||||
if not pb.is_file():
|
||||
return False, f"{pb.relative_to(RACINE)} est introuvable."
|
||||
lignes = [l for l in pb.read_text(encoding="utf-8").splitlines()
|
||||
if re.match(r"\s*firewall:\s*", l)]
|
||||
if len(lignes) != 1:
|
||||
return False, (f"{pb.relative_to(RACINE)} porte {len(lignes)} ligne(s) "
|
||||
f"`firewall:` — la preuve en attend exactement une, sinon elle ne "
|
||||
f"sait pas laquelle gouverne la carte.")
|
||||
expr = lignes[0].split("firewall:", 1)[1].strip().strip('"').strip("'")
|
||||
|
||||
# Jinja2 seul ne connait ni le test `match` ni le filtre `bool` — ils viennent
|
||||
# d'Ansible. On les redonne a l'identique, sinon la preuve echouerait sur l'outil
|
||||
# plutot que sur ce qu'elle mesure.
|
||||
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
|
||||
env.tests["match"] = lambda v, motif: re.match(motif, str(v)) is not None
|
||||
env.filters["bool"] = lambda v: (v if isinstance(v, bool)
|
||||
else str(v).strip().lower() in ("true", "yes", "on", "1"))
|
||||
|
||||
cas = [ # (intention, pont, attendu, pourquoi)
|
||||
(True, "t29serv", True, "un VNet SDN : le routage inter-VNet reste sur le noeud"),
|
||||
(True, "vmbr3", False, "un pont classique : le retour en epingle serait jete"),
|
||||
(True, "vmbr1", False, "le pont par defaut d'un tenant, si l'hote y retombe"),
|
||||
(False, "t29serv", False, "l'ecosysteme ne demande pas de filtrage est-ouest"),
|
||||
]
|
||||
manques: list[str] = []
|
||||
for intention, pont, attendu, pourquoi in cas:
|
||||
try:
|
||||
rendu = env.from_string(expr).render(
|
||||
proxmox_clone_parefeu_interface=intention, proxmox_clone_pont=pont)
|
||||
except jinja2.UndefinedError as e:
|
||||
manques.append(f"intention={intention} pont={pont} : l'expression reclame "
|
||||
f"une variable que la preuve ne fournit pas ({e})")
|
||||
continue
|
||||
obtenu = str(rendu).strip().lower() in ("true", "yes", "1")
|
||||
if obtenu is not attendu:
|
||||
manques.append(f"intention={intention} pont={pont} : rend {obtenu}, "
|
||||
f"attendu {attendu} — {pourquoi}")
|
||||
if manques:
|
||||
return False, ("Le pare-feu Proxmox n'est pas correctement croise avec le pont :"
|
||||
"\n - " + "\n - ".join(manques))
|
||||
return True, ("Le pare-feu Proxmox ne s'arme que sur un VNet SDN "
|
||||
f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).")
|
||||
|
||||
|
||||
|
||||
def preuve_glossaire_enseigne() -> tuple[bool, str]:
|
||||
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
|
||||
|
||||
|
|
@ -1482,12 +1266,6 @@ PREUVES: list[dict] = [
|
|||
"func": preuve_resolution_unique},
|
||||
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
|
||||
"func": preuve_edge_porte_ses_noms},
|
||||
{"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [],
|
||||
"func": preuve_ordre_des_integrations},
|
||||
{"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs",
|
||||
"refs": [], "func": preuve_parefeu_proxmox_sdn_seulement},
|
||||
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
|
||||
"func": preuve_devis_frontiere_du_site},
|
||||
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
|
||||
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
|
||||
]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue