Poser le registre des domaines publics (primaire cache)

Registre declaratif docs/domaines.yml pour la publication externe des
hostnames, symetrique de docs/bases-donnees.yml. Modele d'autorite :
primaire cache + secondaires. Le bloc « exposition » est le binding
nom public -> service interne (cible) -> edge, destine a trois
consommateurs (zone publique, vhosts nginx, ACME).

- inventory_rules.py : charger_domaines, valider_domaines,
  fqdn_exposition, expositions_du_groupe, AUTORITES_DNS.
- scripts/domaines.py : CLI miroir (lister / verifier).
- Makefile : cibles domaines / domaines-verifier + validation dans
  inventaire-verifier.

Premiere entree reelle : forge.alliance-boreale.ca -> serveurs_forgejo.
Secondaires, mail et DNSSEC laisses a renseigner (structurel, pas
encore operationnel ; l'interne chezlepro.internal n'est pas touche).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-22 20:29:29 -04:00
parent 5dd3215d91
commit d7938d4c5a
5 changed files with 175 additions and 1 deletions

View file

@ -3,6 +3,7 @@
## 2026-06-22
### Ajouté
- Registre déclaratif des domaines publics `docs/domaines.yml` (publication externe des hostnames), symétrique de `docs/bases-donnees.yml`. Modèle d'autorité retenu : **primaire caché + secondaires** (PowerDNS backend PostgreSQL + API non exposé, secondaires publics via AXFR/TSIG). Bloc **`exposition`** = binding `nom public → service interne (cible) → edge`, destiné à trois consommateurs (génération de zone publique, vhosts `serveurs_nginx`, noms à certifier ACME). Validation partagée dans `inventory_rules.py` (`charger_domaines`, `valider_domaines`, `fqdn_exposition`, `expositions_du_groupe` ; autorités connues `primaire-cache|auto-heberge|delegue`, `edge` requis, FQDN uniques). CLI miroir `scripts/domaines.py` (`lister`/`verifier`) + cibles `make domaines` / `make domaines-verifier`, et validation intégrée dans `make inventaire-verifier`. Première entrée réelle : `forge.alliance-boreale.ca → serveurs_forgejo` (DNSSEC, secondaires et enregistrements mail laissés à renseigner).
- Formalisation de l'identité du dépôt : `AGENTS.md` (nouvelle section « Mission et identité ») et `README.md` (section « Mission ») actent que `Set-OPS` **définit et construit l'écosystème numérique souverain de Chezlepro Inc.** — piliers (identité, confiance, nommage/adressage, données, communication, observabilité/supervision, applicatif), propriétés (souverain, déclaratif/convergent) et limites honnêtes (pas d'auto-remédiation ; en grande partie défini/validé avant déploiement réel). Le template Debian 13 reste la fondation, pas la finalité.
- Gestion des bases de données dans la GUI et la CLI : vue « Bases » de `make inventaire-ui` présentant les serveurs de BD, les bases applicatives et leurs **chaînes de connexion** (mot de passe masqué), avec gestion complète (ajout / édition / retrait) et endpoint `POST /api/bases` (validé, jeton). CLI `scripts/bases_donnees.py` (`lister` / `verifier` / `ajouter-serveur` / `ajouter-base` / `retirer-*`) + cibles `make bases` et `make bases-verifier`. Le registre est désormais validé dans `make inventaire-verifier`. GUI et CLI partagent les mêmes règles (`inventory_rules`).
- Rôle `serveurs_forgejo` (forge Git, Phase 5) : binaire officiel Forgejo (version épinglée + lien symbolique), utilisateur `git`, **base PostgreSQL via le registre** (4e consommateur, entrée `forgejo`), publication derrière `serveurs_nginx` (`HTTP_ADDR=127.0.0.1`, `INSTALL_LOCK`), secrets (BD + `SECRET_KEY` + `INTERNAL_TOKEN` + admin) via Vault, mailer vers `serveurs_sendmail`, compte administrateur initial créé une fois. Détails d'installation tirés de la doc officielle Forgejo. Activation de l'entrée `forgejo` dans `docs/bases-donnees.yml`. Playbook de groupe et `group_vars` production associés.

View file

@ -371,14 +371,21 @@ inventaire-verifier: ansible-runtime
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) verifier-playbooks --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES)
python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES)
python3 scripts/bases_donnees.py verifier
python3 scripts/domaines.py verifier
.PHONY: bases bases-verifier
.PHONY: bases bases-verifier domaines domaines-verifier
bases:
python3 scripts/bases_donnees.py lister
bases-verifier:
python3 scripts/bases_donnees.py verifier
domaines:
python3 scripts/domaines.py lister
domaines-verifier:
python3 scripts/domaines.py verifier
inventaire-lister: ansible-runtime
ansible-inventory -i $(FICHIER_INVENTAIRE) --list

43
docs/domaines.yml Normal file
View file

@ -0,0 +1,43 @@
---
# Registre des domaines publics Set-OPS (publication externe des hostnames).
#
# Symetrique de docs/bases-donnees.yml : une source unique, plusieurs consommateurs.
#
# Modele d'autorite retenu : PRIMAIRE CACHE + SECONDAIRES.
# - Le primaire (PowerDNS, backend PostgreSQL + API) detient la zone faisant
# autorite mais N'EST PAS expose a Internet (NOTIFY + AXFR seulement).
# - Les "secondaires" sont la face publique : ce sont eux dans les NS + la glue
# chez le registraire. Ils tirent la zone du primaire (AXFR authentifie TSIG).
#
# Le bloc "exposition" est le binding nom-public <-> service interne, frere du
# binding appli<->base : nom public -> service interne (cible) -> edge.
# Il est lu par : (1) la generation de la zone publique (nom -> IP de l'edge),
# (2) la generation des vhosts serveurs_nginx, (3) ACME (noms a certifier).
#
# Champs par domaine :
# autorite : primaire-cache | auto-heberge | delegue
# edge : groupe Ansible qui porte l'IP publique de facade (le reverse proxy)
# secondaires : NS publics (FQDN) faisant face a Internet (a renseigner)
# dnssec : signature de la zone (jalon ulterieur ; false au depart)
# ttl : TTL par defaut de la zone
# mail : mx / spf / dmarc (lies a serveurs_sendmail) ; vides si pas de mail
# exposition : liste de { nom, cible, type }
# nom : etiquette publiee ("@" = apex du domaine) ex. forge
# cible : groupe interne servi ex. serveurs_forgejo
# type : web (vhost derriere l'edge) | direct | autre defaut: web
domaines_publics:
alliance-boreale.ca:
autorite: primaire-cache
edge: serveurs_nginx
secondaires: [] # FQDN des NS publics secondaires - a renseigner
dnssec: false # jalon ulterieur (DS chez le registraire d'abord)
ttl: 3600
mail:
mx: []
spf: ""
dmarc: ""
exposition:
- nom: forge # forge.alliance-boreale.ca
cible: serveurs_forgejo
type: web

52
scripts/domaines.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Lit et valide le registre des domaines publics Set-OPS (docs/domaines.yml)."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from inventory_rules import charger_domaines, fqdn_exposition, valider_domaines
RACINE = Path(__file__).resolve().parents[1]
FICHIER = RACINE / "docs/domaines.yml"
def lister(registre: dict) -> None:
domaines = registre.get("domaines_publics", {})
if not domaines:
print("Aucun domaine public declare.")
return
for nom_domaine, conf in domaines.items():
secondaires = conf.get("secondaires") or []
dnssec = "oui" if conf.get("dnssec") else "non"
print(f"{nom_domaine} [autorite {conf.get('autorite', '?')}, edge {conf.get('edge', '-')}, dnssec {dnssec}]")
print(f" secondaires: {', '.join(secondaires) if secondaires else '(aucun)'}")
for entree in conf.get("exposition") or []:
fqdn = fqdn_exposition(nom_domaine, entree.get("nom", ""))
print(f" exposition: {fqdn} -> {entree.get('cible', '?')} ({entree.get('type', 'web')})")
def main() -> int:
parser = argparse.ArgumentParser(description="Registre des domaines publics Set-OPS.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Affiche domaines, autorite et expositions.")
sub.add_parser("verifier", help="Valide la coherence du registre.")
args = parser.parse_args()
try:
registre = charger_domaines(FICHIER)
if args.commande == "lister":
lister(registre)
elif args.commande == "verifier":
valider_domaines(registre)
print("Registre des domaines valide.")
except Exception as exc:
print(f"erreur: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -82,6 +82,77 @@ def chaine_connexion(entree: dict, serveur: dict, secret: str = "***") -> str:
return f"{type_bd}://{entree.get('proprietaire', '?')}:{secret}@{hote}:{port}/{entree.get('base', '?')}"
AUTORITES_DNS = {"primaire-cache", "auto-heberge", "delegue"}
def charger_domaines(path: Path | None) -> dict:
"""Charge le registre des domaines publics (domaines_publics)."""
if not path or not path.exists():
return {"domaines_publics": {}}
with path.open("r", encoding="utf-8") as fichier:
data = yaml.safe_load(fichier) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} ne contient pas une table YAML.")
data.setdefault("domaines_publics", {})
return data
def fqdn_exposition(domaine: str, nom: str) -> str:
"""FQDN publie pour une etiquette ('@' ou le domaine lui-meme = apex)."""
nom = str(nom).strip()
if nom in ("@", "", domaine):
return domaine
return f"{nom}.{domaine}"
def valider_domaines(registre: dict) -> None:
"""Valide la coherence du registre des domaines publics."""
domaines = registre.get("domaines_publics") or {}
if not isinstance(domaines, dict):
raise ValueError("domaines_publics doit etre une table.")
fqdns: set[str] = set()
for nom_domaine, conf in domaines.items():
if not isinstance(conf, dict):
raise ValueError(f"Domaine '{nom_domaine}': table attendue.")
autorite = str(conf.get("autorite", "")).strip()
if autorite not in AUTORITES_DNS:
attendu = ", ".join(sorted(AUTORITES_DNS))
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.")
secondaires = conf.get("secondaires") or []
if not isinstance(secondaires, list):
raise ValueError(f"Domaine '{nom_domaine}': 'secondaires' doit etre une liste.")
exposition = conf.get("exposition") or []
if not isinstance(exposition, list):
raise ValueError(f"Domaine '{nom_domaine}': 'exposition' doit etre une liste.")
for entree in exposition:
if not isinstance(entree, dict):
raise ValueError(f"Domaine '{nom_domaine}': entree d'exposition invalide (table attendue).")
for champ in ("nom", "cible"):
if not str(entree.get(champ, "")).strip():
raise ValueError(f"Domaine '{nom_domaine}': exposition, champ '{champ}' requis.")
fqdn = fqdn_exposition(nom_domaine, entree["nom"])
if fqdn in fqdns:
raise ValueError(f"Exposition en double: {fqdn}")
fqdns.add(fqdn)
def expositions_du_groupe(registre: dict, groupe: str) -> list[dict]:
"""Expositions publiques dont la cible est un groupe interne donne."""
resultat = []
for nom_domaine, conf in (registre.get("domaines_publics") or {}).items():
for entree in (conf.get("exposition") or []):
if entree.get("cible") == groupe:
resultat.append({
"domaine": nom_domaine,
"fqdn": fqdn_exposition(nom_domaine, entree.get("nom", "")),
"type": entree.get("type", "web"),
"edge": conf.get("edge"),
})
return resultat
def charger_nomenclature(path: Path | None) -> dict:
"""Charge le registre de nomenclature (domaines, categories, adressage)."""
if not path or not path.exists():