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:
parent
5dd3215d91
commit
d7938d4c5a
5 changed files with 175 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
9
Makefile
9
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
|
||||
|
||||
|
|
|
|||
43
docs/domaines.yml
Normal file
43
docs/domaines.yml
Normal 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
52
scripts/domaines.py
Normal 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())
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
Reference in a new issue