From 1bd211d62b9fd7ae0c41365528fa75fcacd613bd Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 29 Sep 2026 13:25:37 -0400 Subject: [PATCH] resolution_interne : entre machines du site, chaque nom mene a son service L edge expose les interfaces web aux personnes ; les VM du site vont directement au service. Declare par domaine (service | edge, defaut edge) ; plancher, zone et certificat du service suivent. Pose au site : 35 planchers sur 35 conformes ; locataires inchanges. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 37 +++++++++++++++++++++ docs/audit/schema-plan.json | 9 +++++ roles/client_pki/defaults/main.yml | 12 +++++++ roles/hosts_statiques/templates/hosts.j2 | 8 +++-- roles/serveur_powerdns/templates/zone.db.j2 | 12 +++---- scripts/inventory_rules.py | 14 +++++++- scripts/schema_plan.py | 8 +++++ scripts/tests/test_empreinte_plancher.py | 11 ++++-- scripts/tests/test_expositions_sans_port.py | 14 +++++++- 9 files changed, 112 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06dd83a..83fb734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service + +**La règle, énoncée par l'exploitant** : l'edge sert à exposer des interfaces web ; les +connexions entre les VM du site ne passent pas par lui. Les anciens planchers du site la +respectaient (`forge`, `observatoire`, `vigie` → les services eux-mêmes) ; c'est la +dérivation qui avait tort, en envoyant à l'edge tout nom d'un domaine qui en a un. Ma +recommandation de (31) — rejouer ces planchers par l'edge — tombe. + +**Déclarée par domaine, pas imposée partout.** Chez les locataires, les machines passent +encore par l'edge pour `auth.` : Keycloak n'écoute qu'en clair derrière lui, et le +canal serveur-à-serveur de l'OIDC (Grafana, oauth2-proxy) en dépend. Nouveau champ de +`domaines.yml` : `resolution_interne: service | edge` (absent = `edge`). Le site déclare +`service`. + +- `expositions_des_applications` rend `resout_vers` ; une exposition qui se sert elle-même + (sans port, ou sans edge) mène toujours au service. +- Le plancher (`hosts.j2`) et la zone (`zone.db.j2`, A et empreinte `_plancher`) suivent + la même règle ; `test_empreinte_plancher.py` couvre un nom résolu vers son service. +- `client_pki` ajoute au certificat du service le nom qui mène à lui : sans cela, une + reconstruction de `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`, + que le runner du site appelle en direct. +- Validation (`valider_domaines`) et schéma : valeur hors `edge`/`service` refusée. Pas de + `defaut` au schéma : le GUI l'aurait écrit à chaque sauvegarde (`test_rendu_gui` l'a vu). + +**Posé au site, après simulation.** Zone : `console`, `forge`, `observatoire`, `vigie` → +leurs services (10.37.31.11, .33.11, .36.11, .36.11). Planchers des 9 machines : `console` +et `site-dnspub-01` ajoutés ; `site-dnspub-01` ramené aux services. Cache d'Unbound vidé. +Le runner du site joint la forge par son nom, en direct (`ls-remote` : tête publiée) ; les +deux locataires ouvrent leur session SFTP de sauvegarde. Sonde `plancher` : 35 machines sur +35 conformes (13 + 13 + 9). Chez les locataires, rien ne change (simulation : 0 enregistrement, +13 planchers sur 13 intacts). Les runners des locataires joignaient déjà la forge du site en +direct, par son adresse. + +**À savoir.** Au prochain passage de `client_pki` au site, `site-ops-01` recevra +`console.genese.internal` dans son certificat (le nom mène désormais à lui) : réémission +normale. + ## 2026-09-29 (31) — Sonde `plancher` ; elle a trouvé une régression que j'avais causée au site, corrigée à la source **La sonde `plancher` (rôle `client_sante`, sur toute machine qui rapporte).** Le plancher diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index 02ed33f..170a8c1 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -288,6 +288,15 @@ "description": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx).", "x-source-valeurs": "groupes_edge" }, + "resolution_interne": { + "type": "string", + "title": "Resolution interne", + "description": "`service` : le plancher et la zone menent au service lui-meme, l'edge ne sert qu'aux personnes.", + "enum": [ + "edge", + "service" + ] + }, "secondaires": { "type": "array", "title": "Secondaires", diff --git a/roles/client_pki/defaults/main.yml b/roles/client_pki/defaults/main.yml index 963591f..6f5b788 100644 --- a/roles/client_pki/defaults/main.yml +++ b/roles/client_pki/defaults/main.yml @@ -43,6 +43,12 @@ client_pki_cle: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.key" # ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le # service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un # service rendu ailleurs serait une usurpation, pas une commodite. +# +# ET LE SERVICE LUI-MEME, QUAND LES MACHINES Y VONT DIRECTEMENT (2026-09-29). Avec +# `resolution_interne: service` (le site), le plancher et la zone menent le nom au service, +# pas a l'edge : c'est lui qui doit le porter. Sans cela, une reconstruction de +# `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`, et le runner du +# site — qui y va directement — aurait bute sur le nom. client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}" client_pki_sans: >- {{ ([client_pki_nom_cert, @@ -52,6 +58,12 @@ client_pki_sans: >- | selectattr('edge', 'defined') | selectattr('fqdn', 'defined') | selectattr('edge', 'in', group_names) + | map(attribute='fqdn') | list) + + (client_pki_expositions + | selectattr('fqdn', 'defined') + | selectattr('resout_vers', 'defined') + | selectattr('resout_vers', 'equalto', 'service') + | selectattr('hote', 'equalto', inventory_hostname) | map(attribute='fqdn') | list)) | select | unique | list }} diff --git a/roles/hosts_statiques/templates/hosts.j2 b/roles/hosts_statiques/templates/hosts.j2 index fc307fa..623ac5c 100644 --- a/roles/hosts_statiques/templates/hosts.j2 +++ b/roles/hosts_statiques/templates/hosts.j2 @@ -19,10 +19,12 @@ ff02::2 ip6-allrouters {% if hosts_statiques_publier_expositions | default(false) and hosts_statiques_expositions %} # --- Alias d'exposition (FQDN publics → l'edge qui les sert) --- +{# `resout_vers: service` — entre machines on va au service, l'edge sert aux personnes + (voir `expositions_des_applications`). La zone PowerDNS suit la MEME regle. #} {% for expo in hosts_statiques_expositions %} -{% set edge_hosts = groups.get(expo.edge, []) %} -{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %} -{{ "%-15s" | format(hostvars[edge_hosts[0]].ansible_host) }} {{ expo.fqdn }} +{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %} +{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %} +{{ "%-15s" | format(hostvars[cibles[0]].ansible_host) }} {{ expo.fqdn }} {% endif %} {% endfor %} {% endif %} diff --git a/roles/serveur_powerdns/templates/zone.db.j2 b/roles/serveur_powerdns/templates/zone.db.j2 index 1ff2438..8c30b51 100644 --- a/roles/serveur_powerdns/templates/zone.db.j2 +++ b/roles/serveur_powerdns/templates/zone.db.j2 @@ -46,9 +46,9 @@ $TTL {{ serveur_powerdns_ttl }} {% if serveur_powerdns_publier_expositions | default(false) %} {% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == serveur_powerdns_zone %} -{% set edge_hosts = groups.get(expo.edge, []) %} -{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %} -{{ expo.fqdn[:-(serveur_powerdns_zone | length + 1)] }} IN A {{ hostvars[edge_hosts[0]].ansible_host }} +{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %} +{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %} +{{ expo.fqdn[:-(serveur_powerdns_zone | length + 1)] }} IN A {{ hostvars[cibles[0]].ansible_host }} {% endif %} {% endfor %} {% endif %} @@ -73,9 +73,9 @@ $TTL {{ serveur_powerdns_ttl }} {% endfor %} {% if serveur_powerdns_publier_expositions | default(false) %} {% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == serveur_powerdns_zone %} -{% set edge_hosts = groups.get(expo.edge, []) %} -{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %} -{% set _ = _plancher.append(expo.fqdn ~ ' ' ~ hostvars[edge_hosts[0]].ansible_host) %} +{% set cibles = [expo.hote] if expo.resout_vers | default('edge') == 'service' else groups.get(expo.edge, []) %} +{% if cibles and cibles[0] in hostvars and hostvars[cibles[0]].ansible_host is defined %} +{% set _ = _plancher.append(expo.fqdn ~ ' ' ~ hostvars[cibles[0]].ansible_host) %} {% endif %} {% endfor %} {% endif %} diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index bff3bd9..04953fa 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -594,10 +594,19 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str | # plancher pour etre joignable ; le plancher est un filet, pas le sol. if not dom and "." in str(fqdn): dom = str(fqdn).split(".", 1)[1] + # OU MENE LE NOM, VU DE L'INTERIEUR (2026-09-29). L'edge expose les interfaces + # web aux PERSONNES ; entre machines d'un meme ecosysteme, on va au service + # lui-meme — c'est la regle du site. Chez un locataire, les machines passent + # encore par l'edge pour un service qui n'ecoute qu'en clair derriere lui + # (Keycloak : `auth.` n'existe en HTTPS que sur l'edge) : le choix + # se DECLARE donc par domaine, `resolution_interne: service | edge` (defaut). + # Une exposition qui se sert elle-meme (sans port, ou sans edge) mene au service. + resout_vers = ("service" if conf.get("resolution_interne") == "service" + or edge_dom != conf.get("edge") else "edge") resultat.append({ "fqdn": fqdn, "application": app_id, "groupe": app.get("groupe"), "hote": app.get("hote"), "port": app.get("port"), - "edge": edge_dom, "domaine": dom, + "edge": edge_dom, "domaine": dom, "resout_vers": resout_vers, # Exposition WebSocket-aware (ex. Collabora) : le vhost ajoute # alors l'upgrade Upgrade/Connection. Declare par l'app (websocket: true). "websocket": bool(app.get("websocket")), @@ -853,6 +862,9 @@ def valider_domaines(registre: dict) -> None: raise ValueError(f"Domaine '{nom_domaine}': autorite '{autorite}' inconnue (attendu: {attendu}).") if not str(conf.get("edge", "")).strip(): raise ValueError(f"Domaine '{nom_domaine}': champ 'edge' requis.") + if conf.get("resolution_interne", "edge") not in ("edge", "service"): + raise ValueError(f"Domaine '{nom_domaine}': 'resolution_interne' vaut `edge` ou " + f"`service` (trouve : {conf.get('resolution_interne')!r}).") secondaires = conf.get("secondaires") or [] if not isinstance(secondaires, list): raise ValueError(f"Domaine '{nom_domaine}': 'secondaires' doit etre une liste.") diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index bbde5f7..afdeeb7 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -197,6 +197,14 @@ REGISTRES: dict = { "edge": {"type": "string", "requis": True, "libelle": "Edge", "source_valeurs": "groupes_edge", "aide": "Le GROUPE Ansible qui sert cette zone (ex. serveur_nginx)."}, + # OU MENENT LES NOMS, VU DES MACHINES (2026-09-29). L'edge expose aux personnes ; + # `service` : entre machines, chaque nom mene au service lui-meme (le site). + # PAS DE `defaut` ICI : le formulaire l'ecrirait a chaque sauvegarde, et charger puis + # sauvegarder changerait le plan. Absent vaut `edge`, dans le moteur. + "resolution_interne": {"type": "string", "enum": ["edge", "service"], + "libelle": "Resolution interne", + "aide": "`service` : le plancher et la zone menent au service " + "lui-meme, l'edge ne sert qu'aux personnes."}, "secondaires": {"type": "array", "items": {"type": "string"}, "libelle": "Secondaires", "aide": "Serveurs DNS secondaires de la zone."}, "dnssec": {"type": "boolean", "libelle": "DNSSEC"}, diff --git a/scripts/tests/test_empreinte_plancher.py b/scripts/tests/test_empreinte_plancher.py index 69cfaec..560c61f 100644 --- a/scripts/tests/test_empreinte_plancher.py +++ b/scripts/tests/test_empreinte_plancher.py @@ -48,6 +48,9 @@ def contexte() -> dict: {"fqdn": f"console.{DOMAINE}", "domaine": DOMAINE, "edge": "serveur_nginx"}, {"fqdn": f"vigie.{DOMAINE}", "domaine": DOMAINE, "edge": "serveur_nginx"}, {"fqdn": "www.exemple.ca", "domaine": "exemple.ca", "edge": "serveur_nginx"}, + # `resolution_interne: service` : ce nom mene au service, pas a l'edge. + {"fqdn": f"forge.{DOMAINE}", "domaine": DOMAINE, "edge": "serveur_nginx", + "hote": "mon-01", "resout_vers": "service"}, ] return { "groups": {"hotes_actifs": ["mon-01", "edge-01", "dns-01", "sans-ip"], @@ -96,10 +99,14 @@ def main() -> int: print(f"ECHEC la zone publie « {publie} », la sonde calcule « {calcule} » sur le plancher " f"rendu du MEME inventaire : les deux gabarits ne comptent plus la meme chose.") echecs += 1 - if not publie.startswith("n=6 "): - print(f"ECHEC attendu 6 paires (4 hotes avec adresse dont 1 planifie, 2 expositions du " + if not publie.startswith("n=7 "): + print(f"ECHEC attendu 7 paires (4 hotes avec adresse dont 1 planifie, 3 expositions du " f"domaine) ; publie : {publie}") echecs += 1 + if f"10.9.20.21 forge.{DOMAINE}" not in plancher or f"forge IN A 10.9.20.21" not in zone: + print("ECHEC `resout_vers: service` : forge doit mener a mon-01 (10.9.20.21) au plancher " + "ET dans la zone.") + echecs += 1 ampute = "\n".join(l for l in plancher.splitlines() if "console." not in l) if empreinte_sonde(ampute) == publie: print("ECHEC controle negatif : un plancher sans `console` rend la meme empreinte.") diff --git a/scripts/tests/test_expositions_sans_port.py b/scripts/tests/test_expositions_sans_port.py index d837da1..f986460 100644 --- a/scripts/tests/test_expositions_sans_port.py +++ b/scripts/tests/test_expositions_sans_port.py @@ -35,7 +35,19 @@ def main() -> int: if sans_edge["forge.ex.internal"] != "serveur_forgejo": print(f"ECHEC sans edge declare, le service doit se servir lui-meme : {sans_edge}") return 1 - print("OK — avec port : l'edge du domaine ; sans port : le service lui-meme.") + vues = {e["fqdn"]: e["resout_vers"] for e in expositions_des_applications(APPS, DOMS)} + if vues != {"forge.ex.internal": "edge", "sauvegarde.ex.internal": "service", + "pki.ex.internal": "service"}: + print(f"ECHEC resolution par defaut : attendu l'edge pour ce qu'il sert, obtenu {vues}") + return 1 + service = {"domaines_publics": {"ex.internal": {"edge": "serveur_nginx", + "resolution_interne": "service"}}} + vues = {e["fqdn"]: e["resout_vers"] for e in expositions_des_applications(APPS, service)} + if set(vues.values()) != {"service"}: + print(f"ECHEC `resolution_interne: service` : tout nom doit mener au service ({vues})") + return 1 + print("OK — avec port : l'edge du domaine ; sans port : le service lui-meme ; " + "`resolution_interne: service` : chaque nom mene a son service.") return 0