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:
parent
a530d5f70e
commit
467b05cbc0
8 changed files with 177 additions and 7 deletions
60
CHANGELOG.md
60
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*
|
||||
|
|
|
|||
13
Makefile
13
Makefile
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue