site : un ecosysteme complet — PKI, DNS, forge, cache, runner

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 12:00:17 -04:00
parent a530d5f70e
commit 467b05cbc0
8 changed files with 177 additions and 7 deletions

View file

@ -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*

View file

@ -726,6 +726,13 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
@# 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; \
@ -737,12 +744,6 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
printf 'Groupes du site : %s\n' "$$connus"; \
exit 2; \
fi; \
@# LA VOUTE DU SITE, DERIVEE DU SYMLINK — 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 ce
@# `-e @`, le role echoue sur son assertion de secrets — et l'echec ne dit pas qu'il
@# manque un FICHIER, seulement que les valeurs sont vides.
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" \

View file

@ -36,7 +36,7 @@
| 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 : 8 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 81 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. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 88 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), 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. |

View 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

View file

@ -1,4 +1,23 @@
---
# 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

View file

@ -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

View file

@ -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:

View file

@ -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)