From 467b05cbc0427215302ee0b430e96e59343eac3d Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 25 Aug 2026 12:00:17 -0400 Subject: [PATCH] =?UTF-8?q?site=20:=20un=20ecosysteme=20complet=20?= =?UTF-8?q?=E2=80=94=20PKI,=20DNS,=20forge,=20cache,=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le site portait trois services sur les sept du modele `origine`. Sa forge servait LE GENOME EN CLAIR et aucune de ses machines n'avait de certificat — donc aucun chiffrement est-ouest, ce que la doctrine zero-confiance interdit. site-pki-01 (step-ca) et site-dns-01 (PowerDNS + resolveur colocalises) rejoignent les trois autres. Quatre defauts que le site a fait tomber, chacun invisible chez un tenant : - DNS bloque par notre propre default-deny. Un tenant a son resolveur DANS son reseau et ne traverse jamais la frontiere ; le site interroge la sienne. La regle est derivee de `site.dns_amorcage`, destination declaree, jamais `any`. - serveur_cache_site n'installe rien : il marque un cache et lit les variables de serveur_artefacts. Les defauts d'un role ne sont en portee que dans le play qui l'inclut — une dependance de role regle l'ordre ET la portee. - resoudre_idp partait meme avec OIDC desactive, et exigeait un plan. La resolution suit desormais l'usage. - le plancher /etc/hosts etait VIDE : `hotes_actifs` n'existait pas dans l'inventaire du site. Un role qui reussit en n'ecrivant rien est la pire forme d'echec. Une machine du site peut desormais se configurer (`variables:`), appliquee en dernier : ce qu'une machine declare d'elle-meme prime sur ce que le site declare pour toutes. Verifie et non suppose : systemd disait `active` mais rien n'ecoutait sur 443 — step-ca sert sur 8443. La zone souveraine resout et la recursion marche, mesurees sur la machine. 42 preuves vertes, ansible-lint profil production. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 60 +++++++++++++++++++++++++ Makefile | 13 +++--- docs/audit/preuve-2026-08-25.md | 2 +- roles/serveur_cache_site/meta/main.yml | 19 ++++++++ roles/serveur_cache_site/tasks/main.yml | 19 ++++++++ roles/serveur_forgejo/tasks/main.yml | 12 +++++ scripts/devis_opnsense.py | 36 +++++++++++++++ scripts/site_inventaire.py | 23 ++++++++++ 8 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 roles/serveur_cache_site/meta/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index b32c8e8..66c6236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Le SITE devient un écosystème complet : PKI, DNS, forge + +Le site portait **trois services sur les sept** que le modèle `origine` appelle le plus +petit écosystème complet. Ce n'était pas un choix : j'ai bâti le strict minimum pour y +déplacer la forge, signalé une fois que « le site n'a ni PKI ni DNS », puis continué. La +décision était celle de l'exploitant, et je l'avais tranchée par omission. + +Ce que ça coûtait : **la forge servait le génome en clair** — le code qui fabrique tous +les écosystèmes — et aucune machine du site n'avait de certificat, donc aucun chiffrement +est-ouest. La doctrine zéro-confiance l'interdit frontalement. + +`site-pki-01` (step-ca) et `site-dns-01` (PowerDNS + résolveur, colocalisés) rejoignent +les trois autres. Cinq machines, réparties sur les trois nœuds. + +### Quatre défauts que le site a fait tomber + +Le site est le **premier endroit qui n'a que le strict nécessaire** — pas de plan, pas de +Keycloak, pas de PostgreSQL, pas de rôles colocalisés. Chaque manque a révélé une +dépendance implicite qu'aucun tenant ne pouvait exposer : + +| défaut | où il se cachait | +|---|---| +| DNS bloqué par notre propre default-deny | un tenant a son résolveur *dans* son réseau et ne traverse jamais la frontière | +| `serveur_cache_site` sans dépendance déclarée | colocalisé avec `serveur_artefacts` chez patient 0 | +| OIDC résolu même désactivé | il y a toujours un Keycloak chez un tenant | +| plancher `/etc/hosts` vide | `hotes_actifs` n'existait pas dans l'inventaire du site | + +**Le DNS du site est dérivé, pas écrit à la main** : le devis lit `site.dns_amorcage` et +émet `SETOPS_SITE → SETOPS_RESOLVEUR_SITE` en 53/udp et 53/tcp. Destination **déclarée**, +jamais `any` — ouvrir le 53 vers le monde aurait été une sortie DNS non policée. + +*La panne ne ressemblait pas à un pare-feu : `resolv.conf` correct, Unbound qui écoute sur +toutes les interfaces, le port qui « répondait » à un test TCP naïf. On a cherché du côté +du résolveur pendant que c'était le filtre.* + +**`serveur_cache_site` déclare enfin sa dépendance.** Il n'installe rien — il marque un +cache comme racine de chaîne et lit pour ça les variables de `serveur_artefacts`. Or les +défauts d'un rôle ne sont en portée que *dans le play qui l'inclut*, et chaque groupe est +appliqué par son propre playbook. Déclarer les deux rôles sur la machine ne suffisait pas ; +une dépendance de rôle règle l'ordre **et** la portée. + +**On ne résout pas un fournisseur d'identité qu'on n'utilisera pas.** `resoudre_idp` +partait inconditionnellement et exigeait un plan. `serveur_forgejo_oidc_actif` dérivait +déjà de la présence de Keycloak : la résolution suit désormais l'usage. + +### Une machine du site peut se configurer + +Un tenant configure ses services par son plan. Le site n'en a pas — c'est tout le sens de +« un SITE n'est pas un plan » — mais ses machines ont quand même des choix à faire. D'où +une section `variables:` par machine, appliquée **en dernier** : ce qu'une machine déclare +d'elle-même prime sur ce que le site déclare pour toutes. La forge y déclare +`serveur_forgejo_bd: sqlite`, le DNS `serveur_powerdns_publier_expositions: false`. + +### Vérifié, pas supposé + +`systemd` disait `active` et la racine existait — mais **rien n'écoutait sur 443** : +step-ca sert sur **8443**. Le `changed=` d'Ansible n'est pas une preuve de service. La +zone souveraine résout (`site-forge-01.genese.internal → 10.0.3.31`) et la récursion +marche, mesurées depuis la machine. + ## 2026-08-25 — C'est le SITE qui détermine l'index d'un tenant **SITE et OPS sont deux classes distinctes.** Le site décide de la *fabric* et du *réseau* diff --git a/Makefile b/Makefile index 2bb40dd..b98c6fb 100644 --- a/Makefile +++ b/Makefile @@ -726,6 +726,13 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=- + `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 diff --git a/roles/serveur_forgejo/tasks/main.yml b/roles/serveur_forgejo/tasks/main.yml index f8a8282..bf6b672 100644 --- a/roles/serveur_forgejo/tasks/main.yml +++ b/roles/serveur_forgejo/tasks/main.yml @@ -2,11 +2,23 @@ # L'URL PUBLIQUE de l'IdP se derive de l'exposition declaree au plan, elle ne se # fabrique pas : `keycloak.` 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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index adaefb4..54b85e5 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -593,6 +593,42 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: } # 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({s for m in _machines_site for s in (m.get("services") or [])} | {"serveur_debian"}) for _role in _roles_site: diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 3a76e9a..61bd4ff 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -40,6 +40,17 @@ UTILISATEUR_DEFAUT = "ansible" # aucune regle de frontiere — la machine la plus puissante du site, la moins protegee. GROUPE_SOCLE = "serveur_debian" +# LE PLANCHER `/etc/hosts` SE DERIVE DE CE GROUPE, et de nul autre. +# +# `hosts_statiques` construit sa liste depuis `hotes_actifs` + `hotes_planifies` — des +# groupes qu'un inventaire de tenant fabrique, et que celui du site ne fabriquait pas. Le +# role tournait donc sans erreur et ecrivait un plancher VIDE : `localhost`, et rien +# d'autre. Un succes qui n'a rien fait, la pire forme d'echec. +# +# Le site n'a pas d'hotes « planifies » — une machine y est declaree ou elle n'existe pas. +# Elles vont donc toutes dans `hotes_actifs`. +GROUPE_FLOTTE = "hotes_actifs" + def inventaire() -> dict: u = U.charger() @@ -97,8 +108,20 @@ def inventaire() -> dict: # par exemple pour ne PAS chercher un plan la ou il n'y en a pas. "setops_site": True, **communes, + # CE QUE LA MACHINE DECLARE POUR SES PROPRES ROLES. + # + # Un tenant configure ses services par son plan, que `instancier` traduit en + # group_vars. Le site n'a pas de plan — c'est tout le sens de « un SITE n'est + # pas un plan » — mais ses machines ont quand meme des choix a faire : la + # forge du genome tourne en SQLite, sans annuaire ni PostgreSQL derriere elle. + # + # Ces valeurs viennent donc de la carte, a cote de la machine qu'elles + # concernent. En dernier pour qu'elles l'emportent : ce que la machine declare + # d'elle-meme prime sur ce que le site declare pour tous. + **(m.get("variables") or {}), } groupes.setdefault(GROUPE_SOCLE, []).append(nom) + groupes.setdefault(GROUPE_FLOTTE, []).append(nom) for service in m.get("services") or []: groupes.setdefault(service, []).append(nom)