diff --git a/CHANGELOG.md b/CHANGELOG.md index c416e83..34441a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — D-71 : PKI et DNS debout avant tout le reste, et la zone inverse + +Contrainte posée par l'exploitant en voyant `backup-01` et `collab-01` créés avant l'AC et +le DNS : **une PKI et un DNS fonctionnels avant toute chose** ; puis par VM, socle → +enrôlement PKI → enregistrement DNS (A et PTR). + +Ma première réponse était incomplète. J'avais expliqué qu'un clone est inerte et que +`site.yml` respecte bien les couches — c'est exact, mais ça esquivait deux points justes : +un échec de création à la douzième VM coûte quarante minutes sans rien déployer, et un +journal qui montre `backup-01` en tête donne l'impression que le moteur ignore ses propres +couches. + +**Ce qui manquait vraiment, mesuré avant de coder :** + +``` +enregistrements A → DEJA derives du plan (zone generee depuis `hotes_actifs`) +zone inverse / PTR → n'existe NULLE PART — aucun role ne touche `in-addr.arpa` +ordre d'amorcage → aucun : `deployer-tout` est par couches, pas par hote +``` + +Le premier point a réduit le travail de moitié : je m'apprêtais à écrire un enrôlement DNS +par hôte alors que la zone directe se dérivait déjà correctement. + +**La zone inverse** est dérivée du supernet, comme le reste : un `/16` `10.(10+index).0.0` +donne `(10+index).10.in-addr.arpa` — `27.10.in-addr.arpa` ici. Les PTR viennent de la +**même source** que les A (`hotes_actifs` et son `ansible_host`) : deux zones alimentées +par une seule vérité, donc pas d'endroit où elles puissent diverger. Vide si le supernet +n'est pas un `/16` — mieux vaut pas de zone inverse qu'une zone fausse. + +**`_amorcer-socle`** monte l'AC puis le DNS **complètement**, hôte par hôte, avant +`deployer-tout`. Les deux se dérivent de `applications..hote` ; l'ordre entre eux +n'est pas alphabétique mais causal — le DNS a besoin d'un certificat, l'autorité n'a besoin +de personne. + +**Deux exceptions structurelles, nommées plutôt que découvertes à l'exécution** : l'AC +s'auto-signe, et le DNS pose son propre enregistrement. La règle « socle → PKI → DNS » ne +peut pas s'appliquer à ceux qui la rendent possible. + +Au passage, le harnais a attrapé une faute que je venais d'introduire : j'avais inventé un +handler `Recharger PowerDNS` qui n'existe pas — le rôle écoute `Validate and reload +PowerDNS`. P10 l'a nommée avant tout déploiement. + ## 2026-08-08 — Une question de l'exploitant trouve un trou dans P31, une heure après « Pourquoi pas `make myDay` ? » — la cible existe, c'est un alias strict de diff --git a/Makefile b/Makefile index 48f1de4..91d7fe1 100644 --- a/Makefile +++ b/Makefile @@ -594,11 +594,29 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu fi; \ $(MAKE) flotte-creer CONFIRMER=true; \ $(MAKE) _attendre-flotte; \ + $(MAKE) _amorcer-socle; \ $(MAKE) deployer-tout CONFIRMER=true # « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood). # Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true. .PHONY: myDay +# « Une PKI et un DNS fonctionnels avant toute chose » — contrainte d'architecture du +# 2026-08-08. `deployer-tout` deroule par COUCHES : tous les hotes recoivent le socle, +# puis tous recoivent la couche suivante. C'est correct, mais ca laisse chaque VM +# reclamer un certificat a une autorite qui n'est pas encore debout — et l'echec se lit +# alors comme un defaut du role, pas comme un defaut d'ordre. +# +# On monte donc l'AC puis le DNS COMPLETEMENT d'abord, hote par hote (`make deployer` +# ordonne deja les groupes d'un hote par couches). Les deux se DERIVENT du plan : +# deplacer l'autorite deplace l'amorcage avec elle. +_amorcer-socle: ansible-runtime _instance-requise + @set -e; \ + for h in $$(python3 scripts/socle_amorcage.py); do \ + printf '\n=== Amorcage du socle : %s ===\n' "$$h"; \ + $(MAKE) deployer HOTE="$$h"; \ + done; \ + printf '\nPKI et DNS debout : le reste de la flotte peut monter.\n' + myDay: reconstruire ## Alias strict de `reconstruire` (meme cible, memes gardes) deployer-groupe: ## Deploie un seul groupe sur toute la flotte — GROUPE= diff --git a/docs/audit/preuve-2026-08-08.md b/docs/audit/preuve-2026-08-08.md index 95a5d4f..b9fbd51 100644 --- a/docs/audit/preuve-2026-08-08.md +++ b/docs/audit/preuve-2026-08-08.md @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 36 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 37 scripts expliques et atteignables, 87 cibles make documentees, 54 roles avec README. | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 95acb38..ce75f8b 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -23,7 +23,7 @@ code + les README de rôles). Cette page comble ces deux trous. | **Ordre de déploiement** | `docs/couches-deploiement.yml` (couches) + `docs/dependances-groupes.yml` (graphe) → `playbooks/site.yml` (**généré**, `make site`) | | **Conformité du déployé** | `docs/devis-services.md` — les **cinq devis de service** (`make identite-plan`, `certificats-plan`, `expositions-plan`, `postgresql-plan`, `courriel-plan`). Répondent à ce que `make prouver` ne demande jamais : *ce qui tourne correspond-il à ce qui est déclaré ?* | | **Preuve / recette** | `docs/audit/affirmations.md` (registre), `make prouver` → `docs/audit/preuve-.md` — **statique** : lit le dépôt, aucun appel réseau ; la conformité du déployé est l'affaire des devis de service (ligne au-dessus), `docs/audit/plan-de-recette.md` (**généré** du wiki), `docs/audit/protocole-operateur-independant.md` | -| **Décisions d'architecture** | `docs/decisions-architecture.md` — **67 décisions en vigueur** (D-01 → D-70, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | +| **Décisions d'architecture** | `docs/decisions-architecture.md` — **68 décisions en vigueur** (D-01 → D-71, 3 renversées), pourquoi, où lire le détail, et ce qui les garde ; plus les **décisions renversées** et leur cause | | **SDN / routage** | `docs/sdn-evpn.md` — décision du 2026-08-02 : le routage inter-zone passe des commutateurs aux hyperviseurs (zones EVPN = VRF). **Non éprouvé** : spike avant génération | | **Migration de tenant** | `docs/migration-tenant.md` — recette en 8 étapes, machine à états, gardes ; le receveur se construit **avant** tout gel | | **Exploitation courante** | `docs/runbooks-exploitation.md`, `docs/intrants-communs.md`, `docs/intrants-base-gui-conception.md`, `docs/theme-forgejo-hors-flotte.md` | diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index b986daa..c7a4875 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -108,6 +108,7 @@ sont les seules vérifiables. | **D-68** | On **écrit, puis on relit et on compare** — quelle que soit l'interface ; on choisit celle dont le chemin de **lecture** parle le même langage que le chemin d'**écriture** | « toujours préférer l'API » n'aurait prédit aucune des pannes du 2026-08-08 : sur six familles de défauts, deux venaient d'un CLI, une d'un module Ansible (`ldap_entry` crée sans jamais modifier), une d'un `grep` de fichier, une de la précédence Ansible, une de mon comparateur. Le facteur commun est d'avoir écrit sans relire. Et la plupart de la flotte n'a **pas** d'API — Postfix, Dovecot, nginx, slapd, nftables : `postconf -h` / `postconf -e` sont symétriques, c'est tout ce qu'on demande | `devis-services.md` | les 5 devis | | **D-69** | Sur Keycloak : **l'API pour toute map ou collection** (`smtpServer`, `attributes`, `config`), `kcadm` pour les scalaires et les créations | `kcadm -s` sur une map accepte la commande, **sort en succès et n'écrit rien** — mesuré deux fois le 2026-08-08 (`smtpServer` resté vide après deux déploiements verts, puis `post.logout.redirect.uris`). Le CLI reste préféré ailleurs : c'est le vocabulaire de la documentation du produit, donc lisible sans IA | `roles/serveur_keycloak/tasks/` | `make identite-plan` | | **D-70** | La documentation **dit et explique tout ce que le dépôt fait** — et l'exigence est **outillée**, pas seulement énoncée | une exigence qu'on n'outille pas pourrit en silence : la carte annonçait « 28 décisions » quand il y en avait 66, et disait les accès « non construits » alors qu'ils tournaient en production. **P31** garde le couvert — chaque script s'explique et reste atteignable, chaque cible `make` porte son aide (sauf les internes préfixées `_`), chaque rôle a son README. Elle ne garde **pas** la qualité du « pourquoi » : ça se juge en revue, et ça vit dans `CHANGELOG.md` et ici | `devis-services.md`, `CHANGELOG.md` | **P31** | +| **D-71** | **Une PKI et un DNS fonctionnels avant toute chose** ; puis, par VM : socle → enrôlement PKI → enregistrement DNS (A **et** PTR) | `deployer-tout` déroule par COUCHES — correct, mais chaque VM réclame alors un certificat à une autorité pas encore debout, et l'échec se lit comme un défaut du rôle et non d'ordre. Les deux hôtes d'amorçage se **dérivent** de `applications..hote` : déplacer l'autorité déplace l'amorçage. **Deux exceptions structurelles assumées** — l'AC s'auto-signe, le DNS pose son propre enregistrement | `Makefile` `_amorcer-socle`, `scripts/socle_amorcage.py` | — | --- diff --git a/roles/serveur_powerdns/defaults/main (conflicted copy 2026-08-08 152422).yml b/roles/serveur_powerdns/defaults/main (conflicted copy 2026-08-08 152422).yml new file mode 100644 index 0000000..aeba88d --- /dev/null +++ b/roles/serveur_powerdns/defaults/main (conflicted copy 2026-08-08 152422).yml @@ -0,0 +1,55 @@ +--- +serveur_powerdns_packages: + - pdns-server + - pdns-backend-bind + - bind9-utils + - dnsutils + +serveur_powerdns_service_name: "pdns" +serveur_powerdns_zone_directory: "/etc/powerdns/zones" +serveur_powerdns_bind_config: "/etc/powerdns/bindbackend.conf" +serveur_powerdns_zone: "{{ domaine_interne }}" +serveur_powerdns_nameserver: "ns1" +serveur_powerdns_contact: "hostmaster.{{ domaine_interne }}" +serveur_powerdns_ttl: 3600 +serveur_powerdns_serial: 2026062201 +serveur_powerdns_soa_refresh: 3600 +serveur_powerdns_soa_retry: 900 +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. +# 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') }}" +serveur_powerdns_allow_axfr_ips: [] + +# Enregistrements additionnels geres explicitement. +serveur_powerdns_records: + - name: "dns" + type: "CNAME" + value: "ns1.{{ serveur_powerdns_zone }}." + +# Groupes dont les hotes actifs avec ansible_host seront ajoutes a la zone. +serveur_powerdns_inventory_groups: + - hotes_actifs + +# Publier des A pour les FQDN d'exposition (champ 'expose' des applications) vers l'edge +# qui les sert (domaines.edge). Ferme la boucle : declarer 'expose' -> vhost + cert + DNS. +serveur_powerdns_publier_expositions: true +serveur_powerdns_expositions: [] + +# Zone INVERSE — DERIVEE du supernet, jamais ecrite. Un /16 `10.(10+index).0.0` donne +# `(10+index).10.in-addr.arpa`. Vide si le supernet n'est pas connu ou n'est pas un /16 : +# mieux vaut pas de zone inverse qu'une zone fausse. +# +# Pourquoi elle manquait. Rien dans le moteur ne touchait `in-addr.arpa` — mesure du +# 2026-08-08. Un hote pouvait donc etre resolu par son nom et rester anonyme a l'envers, +# ce que la plupart des services de courriel et de journalisation reprochent en silence. +serveur_powerdns_zone_inverse: >- + {{ (setops_supernet | default('') | ansible.utils.ipaddr('prefix') | int == 16) + | ternary( + (setops_supernet | default('0.0.0.0/0')).split('.')[1] ~ '.' + ~ (setops_supernet | default('0.0.0.0/0')).split('.')[0] ~ '.in-addr.arpa', + '') }} diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 08aa86e..aeba88d 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -39,3 +39,17 @@ serveur_powerdns_inventory_groups: # qui les sert (domaines.edge). Ferme la boucle : declarer 'expose' -> vhost + cert + DNS. serveur_powerdns_publier_expositions: true serveur_powerdns_expositions: [] + +# Zone INVERSE — DERIVEE du supernet, jamais ecrite. Un /16 `10.(10+index).0.0` donne +# `(10+index).10.in-addr.arpa`. Vide si le supernet n'est pas connu ou n'est pas un /16 : +# mieux vaut pas de zone inverse qu'une zone fausse. +# +# Pourquoi elle manquait. Rien dans le moteur ne touchait `in-addr.arpa` — mesure du +# 2026-08-08. Un hote pouvait donc etre resolu par son nom et rester anonyme a l'envers, +# ce que la plupart des services de courriel et de journalisation reprochent en silence. +serveur_powerdns_zone_inverse: >- + {{ (setops_supernet | default('') | ansible.utils.ipaddr('prefix') | int == 16) + | ternary( + (setops_supernet | default('0.0.0.0/0')).split('.')[1] ~ '.' + ~ (setops_supernet | default('0.0.0.0/0')).split('.')[0] ~ '.in-addr.arpa', + '') }} diff --git a/roles/serveur_powerdns/tasks/main.yml b/roles/serveur_powerdns/tasks/main.yml index d46fadb..6e273c2 100644 --- a/roles/serveur_powerdns/tasks/main.yml +++ b/roles/serveur_powerdns/tasks/main.yml @@ -56,6 +56,18 @@ mode: "0644" notify: Validate and reload PowerDNS +# Meme source que la zone directe : `hotes_actifs` et son `ansible_host`. Deux zones +# alimentees par une seule verite — l'endroit ou elles divergeraient n'existe pas. +- name: Générer la zone DNS inverse depuis l'inventaire + ansible.builtin.template: + src: zone-inverse.db.j2 + dest: "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone" + owner: root + group: root + mode: "0644" + when: serveur_powerdns_zone_inverse | length > 0 + notify: Validate and reload PowerDNS + - name: Activer et démarrer PowerDNS ansible.builtin.systemd: name: "{{ serveur_powerdns_service_name }}" diff --git a/roles/serveur_powerdns/templates/bindbackend.conf.j2 b/roles/serveur_powerdns/templates/bindbackend.conf.j2 index 8a3e60e..3c36245 100644 --- a/roles/serveur_powerdns/templates/bindbackend.conf.j2 +++ b/roles/serveur_powerdns/templates/bindbackend.conf.j2 @@ -2,3 +2,9 @@ zone "{{ serveur_powerdns_zone }}" { type master; file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone }}.zone"; }; +{% if serveur_powerdns_zone_inverse %} +zone "{{ serveur_powerdns_zone_inverse }}" { + type master; + file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone"; +}; +{% endif %} diff --git a/roles/serveur_powerdns/templates/zone-inverse.db.j2 b/roles/serveur_powerdns/templates/zone-inverse.db.j2 new file mode 100644 index 0000000..a620797 --- /dev/null +++ b/roles/serveur_powerdns/templates/zone-inverse.db.j2 @@ -0,0 +1,24 @@ +; Zone INVERSE — generee par Set-OPS (role serveur_powerdns). Ne pas editer a la main. +; +; Le nom de la zone est DERIVE du supernet du tenant, comme tout le reste : un /16 +; 10.(10+index).0.0 donne (10+index).10.in-addr.arpa. Rien n'est ecrit. +; +; Les PTR viennent de la MEME source que les A — `hotes_actifs` et son `ansible_host`. +; Deux zones alimentees par une seule verite : c'est ce qui empeche l'endroit ou elles +; divergent d'exister. +$TTL {{ serveur_powerdns_ttl }} +@ IN SOA {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. {{ serveur_powerdns_contact }}. ( + {{ serveur_powerdns_serial }} ; serial + {{ serveur_powerdns_soa_refresh }} ; refresh + {{ serveur_powerdns_soa_retry }} ; retry + {{ serveur_powerdns_soa_expire }} ; expire + {{ serveur_powerdns_soa_minimum }} ) ; minimum + +@ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. + +{% for host_name in groups.get('hotes_actifs', []) | sort %} +{% set ip = hostvars[host_name].ansible_host | default('') %} +{% if ip %} +{{ ip.split('.')[3] }}.{{ ip.split('.')[2] }} IN PTR {{ host_name }}.{{ serveur_powerdns_zone }}. +{% endif %} +{% endfor %} diff --git a/scripts/socle_amorcage.py b/scripts/socle_amorcage.py new file mode 100755 index 0000000..f438061 --- /dev/null +++ b/scripts/socle_amorcage.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Nomme les hotes qui doivent etre DEBOUT avant tous les autres, derives du plan. + +Contrainte d'architecture posee le 2026-08-08 : « une PKI et un DNS fonctionnels avant +toute chose ». Sans eux, chaque VM qui monte reclame un certificat a une autorite absente +et un nom a un annuaire muet — et l'echec se lit comme un defaut du role, pas comme un +defaut d'ordre. + +Les hotes ne sont PAS ecrits : ils se derivent de `applications..hote`. Deplacer +l'autorite dans le plan deplace l'amorcage avec elle. + + python3 scripts/socle_amorcage.py # un nom d'hote par ligne, dans l'ordre +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent + +# L'ordre compte et n'est pas alphabetique : le DNS a besoin d'un certificat, l'autorite +# n'a besoin de personne. Elle passe donc devant — c'est la seule dependance reelle +# entre les deux. +APPLICATIONS = ["step_ca", "powerdns"] + + +def main() -> int: + base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + plan = base / "plan" / "applications.yml" + if not plan.exists(): + print(f"Plan introuvable : {plan}", file=sys.stderr) + return 2 + apps = (yaml.safe_load(plan.read_text(encoding="utf-8")) or {}).get("applications") or {} + + vus, sortie = set(), [] + for nom in APPLICATIONS: + hote = (apps.get(nom) or {}).get("hote") + if not hote: + print(f"L'application « {nom} » n'est pas au plan : impossible d'amorcer le " + f"socle sans elle.", file=sys.stderr) + return 2 + if hote not in vus: # PKI et DNS peuvent partager un hote + vus.add(hote) + sortie.append(hote) + print("\n".join(sortie)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())