Compare commits
33 commits
32e1f6fbcd
...
ac48b7650b
| Author | SHA1 | Date | |
|---|---|---|---|
| ac48b7650b | |||
| 1a7c042e5b | |||
| 158c3b314a | |||
| ea67b75a15 | |||
| b60946f19f | |||
| 43666cff6e | |||
| add94f2cbd | |||
| 467b05cbc0 | |||
| a530d5f70e | |||
| fcde343ef0 | |||
| a6713ffdcd | |||
| 0b6c036f8c | |||
| 6715f0d5c5 | |||
| dc92f01fe4 | |||
| bf35b4f528 | |||
| 6e126dd8a9 | |||
| 93dde5b4f2 | |||
| 9960cfd68e | |||
| 85ef6e107e | |||
| 9c173f52ba | |||
| da211bd81b | |||
| bf8e27ef85 | |||
| a1997774be | |||
| fa7e656844 | |||
| c391d1ed30 | |||
| 6547eb2fac | |||
| 12d938cdb0 | |||
| db2d6f6bf0 | |||
| 70e57076e8 | |||
| 1363ebff59 | |||
| 2b55761fba | |||
| f248bb084f | |||
| e6c5f9f539 |
107 changed files with 5074 additions and 429 deletions
1243
CHANGELOG.md
1243
CHANGELOG.md
File diff suppressed because it is too large
Load diff
127
Makefile
127
Makefile
|
|
@ -368,18 +368,7 @@ 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_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; \
|
||||
$(VAULT_UNE_FOIS) \
|
||||
$(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \
|
||||
$(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \
|
||||
for playbook in $$playbooks; do \
|
||||
|
|
@ -651,6 +640,35 @@ 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 \
|
||||
|
|
@ -662,6 +680,7 @@ 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 \
|
||||
|
|
@ -681,6 +700,90 @@ 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_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 | — |
|
||||
| 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 | — |
|
||||
|
||||
*Source : [DNS & résolution de noms](DNS-et-résolution) · § À toi de jouer.*
|
||||
|
||||
|
|
|
|||
78
docs/audit/preuve-2026-08-24.md
Normal file
78
docs/audit/preuve-2026-08-24.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# 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._
|
||||
81
docs/audit/preuve-2026-08-25.md
Normal file
81
docs/audit/preuve-2026-08-25.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 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_unbound`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
|
||||
`client_resolveur`, `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_unbound` | autoritaire interne + résolveur local |
|
||||
| Noms | `serveur_powerdns`, `client_resolveur` | 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,15 +46,18 @@ 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` | 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` (**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 |
|
||||
| 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_unbound`), `client_ldap`
|
||||
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_resolveur`), `client_ldap`
|
||||
(login LDAP au niveau OS, hors design).
|
||||
|
||||
> **Ce que « éprouvé » ne dit pas.** La reconstruction prouve que le moteur mène une
|
||||
|
|
@ -153,7 +156,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_unbound` | **tout hôte** (universelle) |
|
||||
| Résolution locale (Unbound) | `client_resolveur` | **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) |
|
||||
|
||||
|
|
@ -270,7 +273,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_unbound` — **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_resolveur` — **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,6 +39,9 @@ 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
|
||||
|
|
@ -50,6 +53,12 @@ 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)."
|
||||
|
|
@ -67,6 +76,9 @@ 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."
|
||||
|
|
@ -75,5 +87,5 @@ couches:
|
|||
- client_journal
|
||||
- client_smtp
|
||||
- client_backup
|
||||
- client_unbound
|
||||
- client_resolveur
|
||||
- client_artefacts
|
||||
|
|
|
|||
|
|
@ -118,6 +118,23 @@ 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_unbound` est
|
||||
> dynamique), plus un point de defaillance. Le resolveur local `client_resolveur` est
|
||||
> **optionnel** (opt-in, avec bascule validee) : sans lui, le plancher `/etc/hosts` suffit.
|
||||
|
||||
## Groupes
|
||||
|
||||
```text
|
||||
serveur_powerdns -> service DNS central PowerDNS Authoritative
|
||||
client_unbound -> resolveur local optionnel (opt-in, bascule validee)
|
||||
client_resolveur -> resolveur local optionnel (opt-in, bascule validee)
|
||||
```
|
||||
|
||||
`client_unbound` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
|
||||
`client_resolveur` (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_unbound` (opt-in) installe un résolveur récursif local qui, en stub-zone,
|
||||
Le role `client_resolveur` (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_unbound_apply: true
|
||||
client_unbound_confirm: true
|
||||
client_resolveur_apply: true
|
||||
client_resolveur_confirm: true
|
||||
```
|
||||
|
||||
Sans ces deux variables, le role prépare Unbound mais ne modifie pas `/etc/resolv.conf`.
|
||||
|
|
|
|||
101
docs/filiation-emancipation.md
Normal file
101
docs/filiation-emancipation.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# 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_unbound`
|
||||
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_resolveur`
|
||||
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_unbound`. Là il y a un vrai choix,
|
||||
**Facultative** — `client_backup`, `client_smtp`, `client_resolveur`. 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_unbound` (opt-in).
|
||||
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_resolveur` (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_unbound`) | Défaut | par hôte / groupe |
|
||||
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_resolveur`) | 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_unbound`.
|
||||
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_resolveur`.
|
||||
- **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_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). |
|
||||
| `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). |
|
||||
| `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,19 +1,38 @@
|
|||
---
|
||||
- name: Appliquer le groupe client_artefacts
|
||||
hosts: client_artefacts
|
||||
# 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
|
||||
become: true
|
||||
module_defaults:
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_artefacts
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
pre_tasks: &pre_artefacts
|
||||
- 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,22 +1,38 @@
|
|||
---
|
||||
- name: Appliquer le groupe client_journal
|
||||
hosts: client_journal
|
||||
# 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
|
||||
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:
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_journal
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
pre_tasks: &pre_journal
|
||||
- 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,22 +1,38 @@
|
|||
---
|
||||
- name: Appliquer le groupe client_pki
|
||||
hosts: client_pki
|
||||
# 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
|
||||
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:
|
||||
any_errors_fatal: true
|
||||
module_defaults: &defauts_pki
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
pre_tasks: &pre_pki
|
||||
- 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
|
||||
|
|
|
|||
38
playbooks/groupes/client_resolveur.yml
Normal file
38
playbooks/groupes/client_resolveur.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
# 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
|
||||
15
playbooks/groupes/serveur_cache_site.yml
Normal file
15
playbooks/groupes/serveur_cache_site.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
- 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_unbound` reprend ce fichier plus tard pour le basculer
|
||||
# PASSAGE DE RELAIS : `client_resolveur` 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,18 +42,57 @@
|
|||
mode: "0644"
|
||||
content: |
|
||||
# Géré par Set-OPS — résolveur d'amorçage (intrant `dns_amorcage`).
|
||||
# Remplacé par `client_unbound` lorsqu'il bascule vers le résolveur local.
|
||||
# Remplacé par `client_resolveur` 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_actuel.content | default('') | b64decode | string)"
|
||||
- "'127.0.0.1' not in serveur_debian_resolv_texte"
|
||||
- serveur_debian_resolveurs_declares
|
||||
| select('in', serveur_debian_resolv_texte) | list | length == 0
|
||||
|
||||
roles:
|
||||
- common_packages
|
||||
# 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
|
||||
- qemu_guest_agent
|
||||
- cloud_init
|
||||
- sudo_ansible
|
||||
|
|
|
|||
15
playbooks/groupes/serveur_ops_site.yml
Normal file
15
playbooks/groupes/serveur_ops_site.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
---
|
||||
- name: Appliquer le groupe client_unbound
|
||||
hosts: client_unbound
|
||||
- name: Appliquer le groupe serveur_resolveur
|
||||
hosts: serveur_resolveur
|
||||
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
|
||||
|
|
@ -19,4 +16,4 @@
|
|||
fail_msg: "Ce playbook est prévu pour Debian."
|
||||
|
||||
roles:
|
||||
- client_unbound
|
||||
- serveur_resolveur
|
||||
|
|
@ -214,11 +214,36 @@
|
|||
| 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_clone_noeud }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
||||
url: "{{ proxmox_api_racine }}/api2/json/nodes/{{ proxmox_noeud_gabarit }}/qemu/{{ proxmox_clone_vmid_modele | int }}/clone"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: >-
|
||||
|
|
@ -236,6 +261,9 @@
|
|||
# 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
|
||||
|
|
@ -246,6 +274,23 @@
|
|||
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
|
||||
|
|
@ -262,8 +307,20 @@
|
|||
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_noeud
|
||||
{{ proxmox_api_racine }}/api2/json/nodes/{{
|
||||
proxmox_clone_lance.json.data.split(':')[1]
|
||||
}}/tasks/{{ proxmox_clone_lance.json.data | urlencode }}/status
|
||||
method: GET
|
||||
headers:
|
||||
|
|
@ -288,10 +345,18 @@
|
|||
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_noeud }}.
|
||||
Regarder la tache sur
|
||||
« {{ proxmox_clone_lance.json.data.split(':')[1] | default('?') }} ».
|
||||
when:
|
||||
- not proxmox_clone_deja_la
|
||||
- proxmox_clone_tache.json.data.exitstatus | default('OK') != 'OK'
|
||||
# `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'
|
||||
|
||||
- name: Dire pourquoi le clonage a echoue, s'il a echoue
|
||||
ansible.builtin.fail:
|
||||
|
|
@ -373,6 +438,21 @@
|
|||
- 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 }}"
|
||||
|
|
@ -386,7 +466,36 @@
|
|||
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 }}"
|
||||
firewall: "{{ proxmox_clone_parefeu_interface | default(false) | bool }}"
|
||||
# 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]+$')) }}"
|
||||
# `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_unbound
|
||||
import_playbook: groupes/client_unbound.yml
|
||||
- name: Couche agents — groupe client_resolveur
|
||||
import_playbook: groupes/client_resolveur.yml
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
---
|
||||
# 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,6 +2,14 @@
|
|||
# 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,6 +2,13 @@
|
|||
# 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,6 +3,15 @@
|
|||
# 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,8 +4,23 @@ client_pki_paquets:
|
|||
|
||||
client_pki_steppath: "/etc/step"
|
||||
|
||||
# AC interne (serveur_step_ca / infra-pki-01).
|
||||
client_pki_ca_url: "https://infra-pki-01.{{ domaine_interne }}:8443"
|
||||
# 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"
|
||||
client_pki_provisioner: "admin@{{ domaine_interne }}"
|
||||
|
||||
# Identite de l'hote.
|
||||
|
|
@ -35,6 +50,27 @@ 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,6 +2,16 @@
|
|||
# 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,4 +1,22 @@
|
|||
---
|
||||
# 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.
|
||||
|
|
@ -179,6 +197,32 @@
|
|||
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,4 +1,4 @@
|
|||
# client_unbound
|
||||
# client_resolveur
|
||||
|
||||
**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_unbound_apply: true
|
||||
client_unbound_confirm: true
|
||||
client_resolveur_apply: true
|
||||
client_resolveur_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_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` |
|
||||
| `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` |
|
||||
|
||||
## 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_unbound_transitaires`.
|
||||
sortante est bloquée par le réseau, renseigner `client_resolveur_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.
|
||||
34
roles/client_resolveur/defaults/main.yml
Normal file
34
roles/client_resolveur/defaults/main.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
# 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
|
||||
18
roles/client_resolveur/meta/flux.yml
Normal file
18
roles/client_resolveur/meta/flux.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
# 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."
|
||||
21
roles/client_resolveur/meta/integration.yml
Normal file
21
roles/client_resolveur/meta/integration.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
# 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.
|
||||
75
roles/client_resolveur/tasks/main.yml
Normal file
75
roles/client_resolveur/tasks/main.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
- 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,28 +0,0 @@
|
|||
---
|
||||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
# `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
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
# 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)."
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
# 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
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 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,3 +50,28 @@ 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,6 +6,21 @@
|
|||
#
|
||||
# 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_unbound`, opt-in). Cf. `docs/dns-interne.md`.
|
||||
(`client_resolveur`, opt-in). Cf. `docs/dns-interne.md`.
|
||||
|
||||
## Variables
|
||||
| Variable | Défaut | Rôle |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,32 @@
|
|||
---
|
||||
# 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:
|
||||
|
|
@ -16,18 +44,29 @@
|
|||
- 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 }}"
|
||||
loop:
|
||||
- "{{ setops_plan_dir }}/applications.yml"
|
||||
- "{{ setops_plan_dir }}/domaines.yml"
|
||||
file: "{{ item.item }}"
|
||||
loop: "{{ hosts_statiques_plan.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item | basename }}"
|
||||
label: "{{ item.item | basename }}"
|
||||
when:
|
||||
- hosts_statiques_actif | bool
|
||||
- hosts_statiques_publier_expositions | bool
|
||||
- (hosts_statiques_plan.results | default([]) | selectattr('stat.exists', 'defined') | selectattr('stat.exists') | list | length) == 2
|
||||
- item.stat.exists | default(false)
|
||||
|
||||
- name: Dériver les alias d'exposition (FQDN exposés → edge qui les sert)
|
||||
ansible.builtin.set_fact:
|
||||
|
|
|
|||
|
|
@ -45,3 +45,21 @@ 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,3 +16,15 @@ 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,3 +17,9 @@ 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 %}
|
||||
|
|
|
|||
56
roles/serveur_cache_site/README.md
Normal file
56
roles/serveur_cache_site/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 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.
|
||||
28
roles/serveur_cache_site/defaults/main.yml
Normal file
28
roles/serveur_cache_site/defaults/main.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
# 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: ""
|
||||
12
roles/serveur_cache_site/meta/authentification.yml
Normal file
12
roles/serveur_cache_site/meta/authentification.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
# 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.
|
||||
7
roles/serveur_cache_site/meta/empreinte.yml
Normal file
7
roles/serveur_cache_site/meta/empreinte.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
# 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
|
||||
46
roles/serveur_cache_site/meta/flux.yml
Normal file
46
roles/serveur_cache_site/meta/flux.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
# 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.
|
||||
19
roles/serveur_cache_site/meta/main.yml
Normal file
19
roles/serveur_cache_site/meta/main.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
# 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
|
||||
42
roles/serveur_cache_site/tasks/main.yml
Normal file
42
roles/serveur_cache_site/tasks/main.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
# 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,27 +1,69 @@
|
|||
# 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é).
|
||||
**Collabora Online, en natif** — paquet `coolwsd`, systemd, derrière l'edge nginx. Aucun
|
||||
conteneur.
|
||||
|
||||
## 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).
|
||||
## Pourquoi ce rôle a été réécrit
|
||||
|
||||
## 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).
|
||||
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.
|
||||
|
||||
## 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.
|
||||
Depuis la réécriture, **plus aucun rôle du dépôt n'a besoin de Docker**.
|
||||
|
||||
## Robustesse
|
||||
- **Épingler `serveur_collabora_tag`** en prod (pas de `:latest` surprise) ;
|
||||
- surveillance Icinga sur `/hosting/capabilities`.
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -1,22 +1,87 @@
|
|||
---
|
||||
# =========================================================================
|
||||
# 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)
|
||||
# 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_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) }}"
|
||||
|
||||
# Domaine Nextcloud autorisé (aliasgroup) — Collabora n'accepte le WOPI que de cet hôte.
|
||||
# --- 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.
|
||||
serveur_collabora_domaine_nextcloud: "cloud.{{ domaine_interne }}"
|
||||
serveur_collabora_aliasgroup: "https://{{ serveur_collabora_domaine_nextcloud | regex_escape }}:443"
|
||||
|
||||
# TLS terminé à l'edge : Collabora sert en HTTP derrière le proxy.
|
||||
serveur_collabora_extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
|
||||
# --- 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: ""
|
||||
|
|
|
|||
7
roles/serveur_collabora/handlers/main.yml
Normal file
7
roles/serveur_collabora/handlers/main.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
- name: Redemarrer Collabora
|
||||
when: not ansible_check_mode
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_collabora_service }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
|
@ -1,39 +1,109 @@
|
|||
---
|
||||
- name: Installer Docker (moteur du conteneur Collabora)
|
||||
# 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
|
||||
ansible.builtin.apt:
|
||||
name: docker.io
|
||||
name: "{{ serveur_collabora_paquets }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Activer et demarrer Docker
|
||||
- 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
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
name: "{{ serveur_collabora_service }}"
|
||||
enabled: true
|
||||
state: started
|
||||
when: not ansible_check_mode
|
||||
daemon_reload: true
|
||||
|
||||
- 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 }}"
|
||||
# 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: Attendre que Collabora reponde (capabilities)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ serveur_collabora_port }}/hosting/capabilities"
|
||||
status_code: 200
|
||||
register: serveur_collabora_sante
|
||||
until: serveur_collabora_sante.status == 200
|
||||
status_code: [200]
|
||||
timeout: 20
|
||||
register: serveur_collabora_capacites
|
||||
retries: 12
|
||||
delay: 5
|
||||
when: not ansible_check_mode
|
||||
until: serveur_collabora_capacites is succeeded
|
||||
|
|
|
|||
8
roles/serveur_collabora/templates/collabora.sources.j2
Normal file
8
roles/serveur_collabora/templates/collabora.sources.j2
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# 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 }}
|
||||
17
roles/serveur_collabora/templates/setops.conf.j2
Normal file
17
roles/serveur_collabora/templates/setops.conf.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# 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,6 +25,24 @@ 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,12 +1,42 @@
|
|||
---
|
||||
# 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 servis via l'edge (TLS terminé à l'edge)."
|
||||
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.
|
||||
- sens: egress
|
||||
port: 5432
|
||||
protocole: tcp
|
||||
|
|
|
|||
|
|
@ -2,11 +2,23 @@
|
|||
|
||||
# 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
|
||||
|
|
@ -311,6 +323,34 @@
|
|||
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,9 +6,19 @@ WORK_PATH = {{ serveur_forgejo_data }}
|
|||
|
||||
[server]
|
||||
DOMAIN = {{ serveur_forgejo_hostname }}
|
||||
ROOT_URL = https://{{ 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 '' }}/
|
||||
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,6 +10,28 @@ 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,16 +44,62 @@ serveur_ops_ansible: "ansible-core>=2.18,<2.19"
|
|||
serveur_ops_forge_hote: >-
|
||||
{{ (groups['serveur_forgejo'] | default([]) | first | default('')) }}
|
||||
serveur_ops_forge_url: >-
|
||||
https://forge.{{ domaine_interne }}
|
||||
{{ serveur_ops_forge_amont if (serveur_ops_forge_externe | bool)
|
||||
else '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) ------------------------------------------------
|
||||
#
|
||||
|
|
@ -68,7 +114,10 @@ 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.
|
||||
serveur_ops_instance: "OPS-Patient0"
|
||||
|
||||
# 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: ""
|
||||
# Vide = le poste ne pose pas de lien `underlay.yml` (il configure, il n'engendre pas).
|
||||
serveur_ops_underlay: ""
|
||||
|
||||
|
|
@ -90,6 +139,17 @@ 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
|
||||
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections-{{
|
||||
lookup('file', serveur_ops_requirements_controleur) | hash('sha1') | truncate(12, true, '') }}
|
||||
serveur_ops_requirements_controleur: "{{ playbook_dir }}/../../requirements.yml"
|
||||
|
|
|
|||
|
|
@ -25,3 +25,15 @@ 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,12 +1,23 @@
|
|||
---
|
||||
# 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 | 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)
|
||||
fail_msg: >-
|
||||
serveur_ops exige `domaine_interne` et au moins un dépôt dans
|
||||
`serveur_ops_depots` (le moteur au minimum).
|
||||
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.
|
||||
|
||||
- name: Installer l'outillage du poste
|
||||
ansible.builtin.apt:
|
||||
|
|
@ -57,14 +68,42 @@
|
|||
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` (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.
|
||||
# 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.
|
||||
- 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"
|
||||
|
|
@ -128,6 +167,7 @@
|
|||
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/"
|
||||
|
|
@ -148,10 +188,21 @@
|
|||
-r requirements.yml
|
||||
-p {{ serveur_ops_racine }}/.ansible/collections
|
||||
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
||||
creates: "{{ serveur_ops_racine }}/.ansible/collections/ansible_collections/community/general"
|
||||
# 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
|
||||
become: true
|
||||
become_user: "{{ serveur_ops_utilisateur }}"
|
||||
when: not ansible_check_mode
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
62
roles/serveur_ops_site/README.md
Normal file
62
roles/serveur_ops_site/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# 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.
|
||||
43
roles/serveur_ops_site/defaults/main.yml
Normal file
43
roles/serveur_ops_site/defaults/main.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
# 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
|
||||
12
roles/serveur_ops_site/meta/authentification.yml
Normal file
12
roles/serveur_ops_site/meta/authentification.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
# 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.
|
||||
7
roles/serveur_ops_site/meta/empreinte.yml
Normal file
7
roles/serveur_ops_site/meta/empreinte.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
# 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
|
||||
40
roles/serveur_ops_site/meta/flux.yml
Normal file
40
roles/serveur_ops_site/meta/flux.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
# 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.
|
||||
64
roles/serveur_ops_site/tasks/main.yml
Normal file
64
roles/serveur_ops_site/tasks/main.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
- 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,10 +19,20 @@ 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_unbound` exempte cet hote.
|
||||
# probleme theorique — c'est la raison pour laquelle `client_resolveur` exempte cet hote.
|
||||
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
|
||||
serveur_powerdns_listen_addresses:
|
||||
- "{{ ansible_host | default('0.0.0.0') }}"
|
||||
# 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_allow_axfr_ips: []
|
||||
|
||||
# Enregistrements additionnels geres explicitement.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
---
|
||||
# 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: 53
|
||||
port: derive
|
||||
protocole: udp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS)."
|
||||
raison: "Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur."
|
||||
- sens: ingress
|
||||
port: 53
|
||||
port: derive
|
||||
protocole: tcp
|
||||
pair: flotte
|
||||
chiffrement: clair
|
||||
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
|
||||
raison: "Idem 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_unbound` (PowerDNS occupe
|
||||
# NTP, les certificats. Il est le seul hôte exempté de `client_resolveur` (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,6 +2,9 @@
|
|||
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
|
||||
|
|
|
|||
66
roles/serveur_resolveur/README.md
Normal file
66
roles/serveur_resolveur/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 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.
|
||||
44
roles/serveur_resolveur/defaults/main.yml
Normal file
44
roles/serveur_resolveur/defaults/main.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
# 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: []
|
||||
6
roles/serveur_resolveur/handlers/main.yml
Normal file
6
roles/serveur_resolveur/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: Redémarrer le résolveur
|
||||
when: not ansible_check_mode
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ serveur_resolveur_service }}"
|
||||
state: restarted
|
||||
13
roles/serveur_resolveur/meta/authentification.yml
Normal file
13
roles/serveur_resolveur/meta/authentification.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
# 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.
|
||||
8
roles/serveur_resolveur/meta/empreinte.yml
Normal file
8
roles/serveur_resolveur/meta/empreinte.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
# 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
|
||||
23
roles/serveur_resolveur/meta/flux.yml
Normal file
23
roles/serveur_resolveur/meta/flux.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
# 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.
|
||||
55
roles/serveur_resolveur/tasks/main.yml
Normal file
55
roles/serveur_resolveur/tasks/main.yml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
- 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
|
||||
37
roles/serveur_resolveur/templates/setops.conf.j2
Normal file
37
roles/serveur_resolveur/templates/setops.conf.j2
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 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,3 +2,40 @@
|
|||
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,6 +12,25 @@
|
|||
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
|
||||
|
|
@ -26,3 +45,15 @@
|
|||
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,3 +28,13 @@ 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,4 +1,14 @@
|
|||
---
|
||||
# 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,6 +64,10 @@ 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:
|
||||
|
|
@ -222,12 +226,24 @@ ROLE_SOCLE = "serveur_debian"
|
|||
|
||||
|
||||
def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict]]:
|
||||
"""[(role, flux)] pour tout flux dont le pair inclut `externe`, trie."""
|
||||
"""[(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.
|
||||
"""
|
||||
retenus = [
|
||||
(role, fl)
|
||||
for role, flux in sorted(flux_par_role.items())
|
||||
for fl in flux
|
||||
if "externe" in _pairs(fl)
|
||||
if "externe" in _pairs(fl) or "voisins_site" in _pairs(fl)
|
||||
]
|
||||
retenus.sort(key=lambda rf: (rf[1].get("sens", ""), rf[0], str(rf[1].get("port"))))
|
||||
return retenus
|
||||
|
|
@ -249,9 +265,40 @@ 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."""
|
||||
return f"SETOPS_{etiquette}_{role.upper()}"
|
||||
"""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
|
||||
|
||||
|
||||
def _instance_active() -> tuple[str, dict] | tuple[None, None]:
|
||||
|
|
@ -263,6 +310,51 @@ 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()
|
||||
|
|
@ -271,6 +363,15 @@ 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.
|
||||
|
|
@ -421,6 +522,23 @@ 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
|
||||
|
|
@ -448,6 +566,16 @@ 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:
|
||||
|
|
@ -460,7 +588,11 @@ 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.
|
||||
"destination": destination if entrant else "!SETOPS_INTERNES",
|
||||
# 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"),
|
||||
"ports": _ports(fl),
|
||||
"chiffrement": fl.get("chiffrement"),
|
||||
"role": role,
|
||||
|
|
@ -469,6 +601,177 @@ 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.
|
||||
|
|
@ -515,6 +818,19 @@ 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,9 +90,30 @@ 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"]
|
||||
return [str(x) for x in (p if isinstance(p, list) else [p])]
|
||||
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
|
||||
|
||||
|
||||
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||
|
|
@ -370,6 +391,15 @@ 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,8 +87,20 @@ 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)
|
||||
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
|
||||
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
|
||||
# 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"]
|
||||
gw = r.get("passerelle")
|
||||
if gw:
|
||||
porteur = next((x for x in underlay_mod.hotes(underlay)
|
||||
|
|
@ -98,6 +110,25 @@ 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.
|
||||
|
||||
|
|
@ -106,7 +137,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 = underlay_mod.reseaux_de_fabric(underlay, fabric)
|
||||
reseaux = reseaux_de_fabric(underlay, fabric)
|
||||
if not reseaux:
|
||||
return []
|
||||
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"]
|
||||
|
|
@ -152,7 +183,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
|
|||
for autre in underlay_mod.fabriques(underlay):
|
||||
if autre == fabric:
|
||||
continue
|
||||
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre)
|
||||
r_autres = 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 :")
|
||||
|
|
@ -283,7 +314,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 underlay_mod.reseaux_de_fabric(underlay, fabric)
|
||||
mgmt = next((r for r in 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
|
||||
|
|
@ -306,7 +337,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 underlay_mod.reseaux_de_fabric(underlay, fabric):
|
||||
for r in 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.")
|
||||
|
|
@ -357,7 +388,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 underlay_mod.reseaux_de_fabric(underlay, fab)
|
||||
connus = {r.get("nom"): r for r in 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"):
|
||||
|
|
@ -382,7 +413,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 underlay_mod.reseaux_de_fabric(underlay, fab)
|
||||
reseaux = [r for r in 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]
|
||||
|
|
@ -486,7 +517,7 @@ def switch_route(underlay: dict | None) -> bool:
|
|||
cas d'erreur — disparait d'elle-meme.
|
||||
"""
|
||||
r_nom = underlay_mod.routeur(underlay)
|
||||
for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
|
||||
for r in reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
|
||||
gw = r.get("passerelle")
|
||||
if not gw:
|
||||
continue
|
||||
|
|
@ -736,7 +767,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 underlay_mod.reseaux_de_fabric(
|
||||
for r in 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
|
||||
|
|
@ -753,7 +784,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 underlay_mod.reseaux_de_fabric(
|
||||
par_via_hyp = {"": [r for r in 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,6 +172,47 @@ 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_unbound`
|
||||
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_resolveur`
|
||||
# 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_unbound) et les exemptions.
|
||||
(client_backup, client_smtp, client_resolveur) 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,7 +480,18 @@ 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 {}
|
||||
edge_dom = conf.get("edge")
|
||||
# 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")
|
||||
if edge is not None and edge_dom != edge:
|
||||
continue
|
||||
resultat.append({
|
||||
|
|
|
|||
|
|
@ -928,6 +928,222 @@ 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.
|
||||
|
||||
|
|
@ -1266,6 +1482,12 @@ 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