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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 13:25:37 -04:00
parent efb1b3d334
commit 1bd211d62b
9 changed files with 112 additions and 13 deletions

View file

@ -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.<domaine>` : 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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.<domaine>` 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.")

View file

@ -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"},

View file

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

View file

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