diff --git a/CHANGELOG.md b/CHANGELOG.md index 08be3cc..838a541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Un SITE a bien un plan + +J'ai passé la journée à répéter qu'un SITE n'est pas un plan. **C'était faux**, et le +dépôt le démontrait à mesure : j'ai reconstruit un plan morceau par morceau dans +`underlay.yml` — machines, services, variables, intégrations — sans jamais le nommer. + +Ce qui est vrai, c'est qu'un site ne **dérive** pas. Un tenant tire tout de son `index` ; +un site déclare ses adresses, parce qu'il *est* le terrain. Il ne passe donc pas par +`instancier` et ne partage pas la superclasse du tenant. Mais « ne pas dériver » n'est +pas « ne pas avoir de plan » — et faute d'avoir fait la distinction, chaque manque est +arrivé par surprise au lieu d'être prévisible. + +``` +SITE-Chezlepro/ + plan/10-intrants.yml domaine, organisation, rebond, clé, amorçage, exemptions + plan/serveurs.yml 5 machines — adressage DÉCLARÉ, pas dérivé + plan/applications.yml 7 services, leur placement, et leurs `expose:` + underlay.yml la fabric seule : ce qui est MESURÉ +``` + +`underlay.yml` décrit ce qui est **mesuré**, le plan ce qui est **voulu**. Les groupes +viennent du registre des applications, comme chez un tenant. Les gardes ont suivi le plan +— une garde loin de ce qu'elle garde finit par garder autre chose — et quatre contrôles +négatifs les éprouvent, dont une collision d'IP avec la frontière. + +### Ce que le site a fait tomber, et qu'aucun tenant ne pouvait révéler + +Le site est le premier écosystème qui n'a que le strict nécessaire : pas de plan (jusqu'à +aujourd'hui), pas de Keycloak, pas de PostgreSQL, pas d'edge, pas de rôles colocalisés. + +| défaut | pourquoi il était invisible | +|---|---| +| `client_pki` codait `infra-pki-01` **en dur** | la nomenclature d'un tenant nomme toujours sa PKI ainsi — le littéral avait raison par coïncidence | +| aucun moyen pour un service non-root de lire la clé | nginx, Postfix, Dovecot lisent en root avant de déprivilégier ; Forgejo tourne en `git` d'emblée | +| le certificat — **public par nature** — restait en `0600` | même cause | +| l'unité Forgejo n'avait pas d'`ExecReload` | chez un tenant c'est nginx qui consomme le cert, et il sait se recharger | +| le rôle Forgejo ne savait pas servir TLS | il y avait toujours un edge devant | +| le certificat ne couvrait pas le nom de **service** | `expose:` n'existait pas, faute de plan | +| chargement des registres en **tout-ou-rien** | `domaines.yml` existe toujours chez un tenant | +| le flux déclarait `3000` en dur | vrai tant qu'il y avait toujours un edge | + +*Le message d'erreur accusait presque toujours autre chose : le DNS quand c'était un nom +faux, une permission de fichier quand c'était un port privilégié, rien du tout quand le +plancher s'écrivait vide.* + +### `ROOT_URL` est gravée dans les URL de clonage + +La forge servait son TLS sur `3000` — le port qu'elle écoute *derrière* un edge. Coût +réel : chaque écosystème descendant aurait porté `:3000` dans son `origin`, pour toujours. +Elle sert désormais sur **443**, `ROOT_URL = https://forge.genese.internal/`, avec +`CAP_NET_BIND_SERVICE` dans son unité — Forgejo tournant en `git` ne peut pas se lier à un +port privilégié autrement, et l'échec parlait de « permission denied » sur le *port*. + +### Sans edge déclaré, le service se sert lui-même + +`expositions_des_applications` résolvait l'edge par le **domaine**. Sans `domaines.yml`, +elle rendait `edge: None`, le gabarit cherchait `groups[None]` et écrivait un plancher +**sans alias, sans erreur**. Le repli dit une vérité générale, et corrige aussi un silence +chez les tenants dont un domaine ne déclare pas d'edge. + +Chaîne complète, vérifiée depuis le réseau : `expose:` → `/etc/hosts` → SAN du certificat +→ `Verify return code: 0 (ok)` → `https://forge.genese.internal/ → 200`. + ## 2026-08-25 — Un SITE n'a pas d'index : le vestige est retiré Le site portait `index: 17`. Ça n'a jamais voulu dire « voici mon adressage » — un site diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index 12aca86..8ae6cbb 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -21,7 +21,7 @@ | 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, 91 flux, schéma + matrice OK. | +| 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. | @@ -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, 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. | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 65 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. | @@ -44,7 +44,7 @@ | 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 : 13 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). | +| 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). | diff --git a/roles/client_pki/defaults/main.yml b/roles/client_pki/defaults/main.yml index 3794dc8..a72080d 100644 --- a/roles/client_pki/defaults/main.yml +++ b/roles/client_pki/defaults/main.yml @@ -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. diff --git a/roles/client_pki/tasks/main.yml b/roles/client_pki/tasks/main.yml index 8208c01..7769eda 100644 --- a/roles/client_pki/tasks/main.yml +++ b/roles/client_pki/tasks/main.yml @@ -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 diff --git a/roles/hosts_statiques/tasks/main.yml b/roles/hosts_statiques/tasks/main.yml index 07c52e7..abe3ee7 100644 --- a/roles/hosts_statiques/tasks/main.yml +++ b/roles/hosts_statiques/tasks/main.yml @@ -44,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: diff --git a/roles/serveur_forgejo/defaults/main.yml b/roles/serveur_forgejo/defaults/main.yml index ad5639e..b39687d 100644 --- a/roles/serveur_forgejo/defaults/main.yml +++ b/roles/serveur_forgejo/defaults/main.yml @@ -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 diff --git a/roles/serveur_forgejo/meta/flux.yml b/roles/serveur_forgejo/meta/flux.yml index de75fd8..5716ed2 100644 --- a/roles/serveur_forgejo/meta/flux.yml +++ b/roles/serveur_forgejo/meta/flux.yml @@ -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 diff --git a/roles/serveur_forgejo/templates/app.ini.j2 b/roles/serveur_forgejo/templates/app.ini.j2 index 45a5cb8..2cb4fe4 100644 --- a/roles/serveur_forgejo/templates/app.ini.j2 +++ b/roles/serveur_forgejo/templates/app.ini.j2 @@ -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] diff --git a/roles/serveur_forgejo/templates/forgejo.service.j2 b/roles/serveur_forgejo/templates/forgejo.service.j2 index 6ba45f6..19f6a8e 100644 --- a/roles/serveur_forgejo/templates/forgejo.service.j2 +++ b/roles/serveur_forgejo/templates/forgejo.service.j2 @@ -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 }} diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index d144a25..0de7b24 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -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({ diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 61bd4ff..dde4f2b 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -29,7 +29,9 @@ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import yaml # noqa: E402 import underlay as U # noqa: E402 +from inventory_rules import integrations_universelles # noqa: E402 # L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer. UTILISATEUR_DEFAUT = "ansible" @@ -52,6 +54,31 @@ GROUPE_SOCLE = "serveur_debian" GROUPE_FLOTTE = "hotes_actifs" +def plan_dir() -> Path | None: + """Le `plan/` du SITE, a cote de son `underlay.yml` — derive du symlink, jamais redit. + + UN SITE A BIEN UN PLAN (2026-08-25). Ce qui le distingue d'un tenant n'est pas + l'absence de plan mais l'absence de DERIVATION : un tenant tire son adressage de son + `index`, un site le declare, parce qu'il EST le terrain. Il ne passe donc pas par + `instancier` — mais il a bien des machines, des services et des intrants a declarer. + + Faute d'avoir fait cette distinction, ce plan a d'abord ete reconstruit morceau par + morceau dans `underlay.yml`, sans etre nomme : chaque manque est arrive par surprise + au lieu d'etre previsible. `underlay.yml` decrit ce qui est MESURE (la fabric) ; le + plan decrit ce qui est VOULU. + """ + c = U.chemin() + return (c.resolve().parent / "plan") if c else None + + +def _charger(nom: str) -> dict: + d = plan_dir() + 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 inventaire() -> dict: u = U.charger() if u is None: @@ -59,73 +86,132 @@ def inventaire() -> dict: # etre lance sans site sans que ca ressemble a une panne. return {"_meta": {"hostvars": {}}} - # La carte est-elle seulement valide ? Un inventaire tire d'un underlay fautif - # produirait des machines plausibles et fausses — le pire des deux mondes. - erreurs = [e for e in U.valider(u) if e.startswith("machine")] - if erreurs: - raise SystemExit("underlay.yml refuse :\n - " + "\n - ".join(erreurs)) - - # L'IDENTITE DU SITE. Un tenant tire `domaine_interne` et `organisation` de son plan, - # via `instancier`. Un site n'a pas de plan : il les declare dans sa carte, et c'est - # ici qu'ils deviennent des variables d'inventaire. Sans eux, un role partage comme - # `serveur_forgejo` construirait des noms d'hote a partir de rien. - site = u.get("site") or {} - communes: dict = {} - if site.get("domaine"): - communes["domaine_interne"] = site["domaine"] - if site.get("organisation"): - communes["organisation"] = site["organisation"] - # LE REBOND, DECLARE PLUTOT QUE PASSE EN LIGNE DE COMMANDE. Le poste n'a pas de route - # vers le segment du site ; la frontiere y est adjacente. Le jour ou une route directe - # existe, on retire `rebond:` de la carte et rien d'autre ne bouge. - # Le socle ne pose `/etc/resolv.conf` que si cette variable existe. Sans elle, les - # machines naissent sans resolveur — et l'echec ressemble a un probleme de reseau. - if site.get("dns_amorcage"): - communes["dns_amorcage"] = str(site["dns_amorcage"]) - if site.get("cle_ssh"): - communes["ansible_ssh_private_key_file"] = site["cle_ssh"] - if site.get("rebond"): - communes["ansible_ssh_common_args"] = ( - f"-o ProxyJump={site['rebond']} -o StrictHostKeyChecking=no") + intrants = _charger("10-intrants.yml") + serveurs = (_charger("serveurs.yml") or {}).get("serveurs") or {} + applications = (_charger("applications.yml") or {}).get("applications") or {} + if not serveurs: + return {"_meta": {"hostvars": {}}} par_reseau = {r.get("nom"): r for r in U.reseaux(u)} + + # Ce que TOUTE machine du site recoit, des intrants du plan. + communes: dict = {} + for cle in ("domaine_interne", "organisation", "dns_amorcage"): + if intrants.get(cle): + communes[cle] = str(intrants[cle]) + if intrants.get("cle_ssh"): + communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"] + if intrants.get("rebond"): + communes["ansible_ssh_common_args"] = ( + f"-o ProxyJump={intrants['rebond']} -o StrictHostKeyChecking=no") + + # Les integrations universelles, moins celles que le site ne peut pas honorer. + exemptees = dict(intrants.get("integrations_exemptes") or {}) + universelles = [r for r in integrations_universelles() if r not in exemptees] + + # LES EXPOSITIONS : le nom du SERVICE, pas celui de la machine. Un nom de service + # survit au demenagement du service ; un nom de machine, non. + # + # `edge` nomme le GROUPE qui sert ce FQDN — c'est ce qu'attend `hosts_statiques`. + # Chez un tenant c'est l'edge nginx, qui termine le TLS pour tout le monde. Le site + # n'a pas d'edge : chaque service se sert lui-meme, donc le groupe est le sien. La + # forme reste la meme, ce qui la remplit change. + expositions: list[dict] = [] + for nom_app, app in applications.items(): + for fqdn in (app.get("expose") or []): + expositions.append({"fqdn": str(fqdn), "edge": str(app.get("groupe") or ""), + "application": nom_app}) + + # --- LES GARDES DU PLAN, LA OU LE PLAN EST LU ---------------------------- + # + # Elles vivaient dans `underlay.valider()` tant que les machines habitaient la carte. + # Le plan les emporte avec lui : une garde loin de ce qu'elle garde finit par garder + # autre chose. Elles refusent ici, avant que quoi que ce soit ne soit clone. + erreurs: list[str] = [] + noeuds = {h.get("nom") for h in U.hotes(u) if h.get("role") == "hyperviseur"} + vus_ip: dict[str, str] = {str(h.get("ip")): str(h.get("nom")) for h in U.hotes(u)} + vus_vmid: dict[int, str] = {} + for nom, srv in serveurs.items(): + if str(srv.get("etat", "actif")) != "actif": + continue + r = par_reseau.get(srv.get("reseau")) + if not r: + erreurs.append(f"serveur '{nom}': reseau '{srv.get('reseau')}' inconnu de " + f"`underlay.yml` — la fabric est la source de ces valeurs") + continue + if not str(r.get("pont", "") or "").strip(): + erreurs.append(f"serveur '{nom}': le reseau '{r.get('nom')}' ne declare aucun " + f"`pont:` — aucun pont d'hyperviseur ne le porte, une VM y " + f"naitrait sourde") + if srv.get("noeud") not in noeuds: + erreurs.append(f"serveur '{nom}': noeud '{srv.get('noeud')}' n'est pas un " + f"hyperviseur declare dans `underlay.hotes`") + ip = str(srv.get("ip") or "") + if ip in vus_ip: + erreurs.append(f"serveur '{nom}': IP {ip} deja prise par '{vus_ip[ip]}'") + vus_ip[ip] = nom + vmid = srv.get("vmid") + if not isinstance(vmid, int): + erreurs.append(f"serveur '{nom}': `vmid` absent ou non entier") + elif vmid in vus_vmid: + erreurs.append(f"serveur '{nom}': vmid {vmid} en double avec " + f"'{vus_vmid[vmid]}'") + else: + vus_vmid[vmid] = nom + for nom_app, app in applications.items(): + if app.get("hote") not in serveurs: + erreurs.append(f"application '{nom_app}': hote '{app.get('hote')}' n'est pas " + f"declare dans `plan/serveurs.yml`") + sans_service = [n for n in serveurs + if not any(a.get("hote") == n for a in applications.values())] + if sans_service: + erreurs.append("serveur(s) sans aucune application : " + ", ".join(sorted(sans_service)) + + " — une machine du site existe POUR un role") + if erreurs: + raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs)) + hostvars: dict[str, dict] = {} groupes: dict[str, list[str]] = {} - for m in U.machines(u): - nom = m["nom"] - r = par_reseau.get(m.get("reseau"), {}) + for nom, srv in serveurs.items(): + if str(srv.get("etat", "actif")) != "actif": + continue + r = par_reseau.get(srv.get("reseau")) or {} hostvars[nom] = { - "ansible_host": str(m["ip"]), - "ansible_user": m.get("utilisateur", UTILISATEUR_DEFAUT), - # De quoi qu'un role puisse se situer sans redemander la carte. - "site_reseau": m.get("reseau"), + "ansible_host": str(srv["ip"]), + "ansible_user": srv.get("utilisateur", UTILISATEUR_DEFAUT), + "site_reseau": srv.get("reseau"), "site_pont": r.get("pont"), - "site_noeud": m.get("noeud"), - "proxmox_vmid": m.get("vmid"), - # CE QUI DISTINGUE CES MACHINES DE TOUTES LES AUTRES. Un role partage entre - # un site et un tenant peut avoir besoin de savoir de quel cote il tourne — - # par exemple pour ne PAS chercher un plan la ou il n'y en a pas. + "site_noeud": srv.get("noeud"), + "proxmox_vmid": srv.get("vmid"), + # L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ; + # le site le tient du reseau ou vivent ses machines. Meme sens, autre source. + # `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger. + "setops_supernet": str(r.get("sous_reseau") or ""), + # Un role partage peut avoir besoin de savoir de quel cote il tourne. "setops_site": True, + # Le plan du site, pour les roles qui lisent des registres. + "setops_plan_dir": str(plan_dir() or ""), + "hosts_statiques_expositions": expositions, **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 {}), + # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le + # plan declare pour toutes. + **(srv.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) + for integ in universelles: + groupes.setdefault(integ, []).append(nom) + for integ in (srv.get("integrations") or []): + groupes.setdefault(str(integ), []).append(nom) - out: dict = {g: {"hosts": h} for g, h in groupes.items()} + # LES GROUPES VIENNENT DU REGISTRE DES APPLICATIONS, comme chez un tenant. + for app in applications.values(): + hote, groupe = app.get("hote"), app.get("groupe") + if hote in hostvars and groupe: + groupes.setdefault(str(groupe), []).append(hote) + + out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()} out["site"] = {"hosts": sorted(hostvars)} out["_meta"] = {"hostvars": hostvars} return out diff --git a/scripts/site_machines.py b/scripts/site_machines.py index c7cb5bb..8aa5b07 100755 --- a/scripts/site_machines.py +++ b/scripts/site_machines.py @@ -62,7 +62,44 @@ def materialisation() -> dict: def _machines() -> dict[str, dict]: - return {m["nom"]: m for m in U.machines(U.charger())} + """Les machines du PLAN du site — plus de l'underlay (2026-08-25). + + Elles y avaient vecu quelques heures, faute d'avoir vu qu'un SITE A BIEN UN PLAN. Ce + qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence de DERIVATION. + `underlay.yml` decrit ce qui est MESURE ; le plan, ce qui est VOULU. + """ + import site_inventaire as SI + d = SI.plan_dir() + f = (d / "serveurs.yml") if d else None + if not f or not f.is_file(): + raise SystemExit( + "Aucun `plan/serveurs.yml` a cote de `underlay.yml` : le site n'a pas de plan, " + "il n'y a donc aucune machine a materialiser.") + import yaml + srv = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("serveurs") or {} + return {nom: {**s, "nom": nom} for nom, s in srv.items() + if str(s.get("etat", "actif")) == "actif"} + + +def _services_par_hote() -> dict[str, list[str]]: + """Quels roles chaque machine porte — lu dans `plan/applications.yml`. + + Les services ne vivent plus sur la machine mais dans le registre des applications, + comme chez un tenant : une machine dit OU elle est, une application dit CE QU'ELLE + FAIT et sur quelle machine. La jointure se fait ici. + """ + import site_inventaire as SI + import yaml + d = SI.plan_dir() + f = (d / "applications.yml") if d else None + if not f or not f.is_file(): + return {} + apps = (yaml.safe_load(f.read_text(encoding="utf-8")) 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 {h: sorted(g) for h, g in out.items()} def _reseau_de(machine: dict) -> dict: @@ -135,10 +172,11 @@ def main() -> int: return 0 print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format( "machine", "reseau", "adresse", "noeud", "vmid", "services")) + services = _services_par_hote() for nom, m in ms.items(): print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format( nom, str(m.get("reseau")), str(m.get("ip")), str(m.get("noeud")), - str(m.get("vmid")), ", ".join(m.get("services") or []))) + str(m.get("vmid")), ", ".join(services.get(nom, [])))) else: for k, v in parametres(a.machine).items(): print(f"{k}={shlex.quote(v)}") diff --git a/scripts/underlay.py b/scripts/underlay.py index e56131a..e822d39 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -21,9 +21,11 @@ Trois cas, et un seul est une erreur : - dans le supernet d'un AUTRE tenant -> ERREUR : collision entre sites - hors de tout supernet (10.0.x, 192.168.x) -> conforme (heritage, et stockage) -Le site declare son index par `underlay.index`. **Sans lui**, on retombe sur la regle -stricte d'avant D-77 (aucun chevauchement) : c'est le comportement sur pour un -underlay qui n'a pas encore migre. +UN SITE N'A PAS D'INDEX (2026-08-25). Il en portait un, et c'etait un vestige : il ne +derive rien, ses machines vivent sur un reseau de fabric. Cette valeur ne disait qu'une +chose — quel supernet de tenant est le sien — et elle le disait pour le site ENTIER alors +qu'un seul reseau etait concerne. L'exception se declare desormais sur le RESEAU, et elle +NOMME le tenant : `bande_basse_de: OPS-Chezlepro`, verifie contre le registre `tenants:`. DESTINATION OU CHEMIN (D-78). Ce qui sort de l'espace derive n'est pas « le stockage » mais tout reseau qui n'est jamais une DESTINATION. Le critere n'est pas la presence de @@ -104,22 +106,17 @@ def hotes(underlay: dict | None) -> list[dict]: def machines(underlay: dict | None) -> list[dict]: - """LES MACHINES DE L'HEBERGEUR — le site, et rien d'autre. + """TOUJOURS VIDE : les machines du site vivent dans son PLAN (2026-08-25). - UN SITE N'EST PAS UN PLAN (2026-08-24). Un tenant se DERIVE : de son seul `index` - descendent son supernet, ses VLAN, ses VMID, ses VNet. Un site ne derive de rien — - il n'a pas d'index, il EST le terrain sur lequel les tenants derivent. + Elles ont habite `underlay.yml` quelques heures, faute d'avoir vu qu'un SITE A BIEN + UN PLAN. Ce qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence + de DERIVATION : un tenant tire son adressage de son `index`, un site le declare. - Les faire passer par le generateur de tenants imposait une branche « si l'adresse est - declaree, elle gagne » : deux mondes dans une meme moulinette, un `nomenclature.yml` - de site vide de sens, et un site exclu des devis par ABSENCE d'index plutot que par - nature. Une exclusion fondee sur un manque casse au premier ajout innocent. - - Ces machines sont donc du MOBILIER DE FABRIC, declare a cote des switches et des - hyperviseurs qui les portent. Meme fichier, meme carte, meme secrets. Ce qui se - partage avec les tenants, ce sont les ROLES — pas la forme du plan. + Cet accesseur reste pour qu'un appelant oublie ne trouve pas un attribut manquant, + mais il ne ment pas : la carte ne porte plus de machines. Voir + `site_inventaire.plan_dir()` et `plan/serveurs.yml`. """ - return (underlay or {}).get("machines", []) or [] + return [] FABRIC_DEFAUT = "principal" @@ -717,54 +714,6 @@ def valider(underlay: dict | None, except (ValueError, KeyError): erreurs.append(f"hote '{hn}': IP absente ou invalide") - # --- LES MACHINES DE L'HEBERGEUR ---------------------------------------- - # - # Rien ici ne se derive : tout est declare, et donc tout se verifie. La garde est - # plus stricte que celle des tenants pour cette raison meme — un tenant qui se trompe - # d'adresse ne peut pas, sa nomenclature le lui interdit ; un site, si. - occupees = {h.get("ip"): h.get("nom", "?") for h in hotes(underlay)} - vus_vmid: dict[int, str] = {} - noms_hyperviseurs = {h.get("nom") for h in hotes(underlay) - if h.get("role") == "hyperviseur"} - for m in machines(underlay): - mn = m.get("nom", "?") - r = par_nom.get(m.get("reseau")) - if not r: - erreurs.append(f"machine '{mn}': reseau '{m.get('reseau')}' inconnu") - continue - # LE PONT EST LA QUESTION QU'ON NE PEUT PAS ESQUIVER. Une machine se rattache a un - # pont d'hyperviseur ; un reseau que AUCUN pont ne porte ne peut voir naitre - # aucune VM. C'est le cas mesure du plan d'administration de Chezlepro : la - # frontiere l'a sur un port a elle, les hyperviseurs n'y touchent pas. Refuser ici - # est la seule facon de ne pas creer une VM sourde. - if not str(r.get("pont", "") or "").strip(): - erreurs.append( - f"machine '{mn}': le reseau '{r.get('nom')}' ne declare aucun `pont:` — " - f"aucun pont d'hyperviseur ne le porte, une VM y naitrait sourde") - if m.get("noeud") not in noms_hyperviseurs: - erreurs.append(f"machine '{mn}': noeud '{m.get('noeud')}' n'est pas un " - f"hyperviseur declare dans `hotes`") - try: - ip = ipaddress.ip_address(m["ip"]) - if ip not in ipaddress.ip_network(r["sous_reseau"], strict=False): - erreurs.append(f"machine '{mn}': IP {m.get('ip')} hors de {r['sous_reseau']}") - except (ValueError, KeyError): - erreurs.append(f"machine '{mn}': IP absente ou invalide") - else: - if str(ip) in occupees: - erreurs.append(f"machine '{mn}': IP {ip} deja prise par " - f"'{occupees[str(ip)]}'") - occupees[str(ip)] = mn - vmid = m.get("vmid") - if not isinstance(vmid, int): - erreurs.append(f"machine '{mn}': `vmid` absent ou non entier") - elif vmid in vus_vmid: - erreurs.append(f"machine '{mn}': vmid {vmid} en double avec '{vus_vmid[vmid]}'") - else: - vus_vmid[vmid] = mn - if not (m.get("services") or []): - erreurs.append(f"machine '{mn}': aucun `services:` — une machine du site " - f"existe POUR un role, sinon elle n'a pas lieu d'etre") return erreurs