From d7938d4c5aa6b086e982ab5978b974ee9ab529ac Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 22 Jun 2026 20:29:29 -0400 Subject: [PATCH] Poser le registre des domaines publics (primaire cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + Makefile | 9 ++++- docs/domaines.yml | 43 +++++++++++++++++++++++ scripts/domaines.py | 52 ++++++++++++++++++++++++++++ scripts/inventory_rules.py | 71 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 docs/domaines.yml create mode 100644 scripts/domaines.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0f2dd..1985989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile index 68ed6c2..a0993f0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/domaines.yml b/docs/domaines.yml new file mode 100644 index 0000000..bbae793 --- /dev/null +++ b/docs/domaines.yml @@ -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 diff --git a/scripts/domaines.py b/scripts/domaines.py new file mode 100644 index 0000000..a782a7b --- /dev/null +++ b/scripts/domaines.py @@ -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()) diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 6b10663..1cf1b9c 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -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():