dns public : chaque zone designe aussi le serveur de noms du site pair

Le contrat du site porte dns_public_noms_pairs : nom et adresse publique du
serveur de chaque pair actif, lus dans son plan et sa carte de frontiere par
les memes fonctions que pour le site lui-meme. Le gabarit de zone ecrit un NS
par serveur ; la zone qui contient un de ces noms en porte le A, a l'adresse
de son site. Sans pair, le rendu est identique a l'ancien.

Garde au role (nom, adresse publique, jamais redeclare) ; la preuve du DNS
public refuse un pair actif sans dns_public_nom ; dns_bascule compte les
serveurs designes et sonde la colle de chacun.

Deploye chez les deux locataires : les deux serveurs publics servent
chezlepro.ca et technolibre.ca au meme serial, deux NS signes.

CHANGELOG 151.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-14 04:44:24 -04:00
parent ee5aad5f41
commit d152e32328
7 changed files with 148 additions and 36 deletions

View file

@ -1,3 +1,33 @@
## 2026-10-14 (151) — Le second serveur de noms, désigné : `dns1.technolibre.ca`
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin de bout en
bout, depuis l'Internet : les deux serveurs publics (`69.70.26.62` et `51.79.45.193`, par le
relais) servent `chezlepro.ca` et `technolibre.ca` au même serial, chacune avec deux NS signés,
`dns1.chezlepro.ca` et `dns1.technolibre.ca`, et chaque `dns1` à l'adresse de son site. Un second
passage chez les deux locataires ne change rien. Témoins qui mordent : une adresse faussée chez
OPS-Technolibre rend `site-intrants-verifier` NON CONFORME ; le nom retiré du plan de
TechnoLibre fait refuser la preuve du DNS public.
**Le serveur de noms du site de TechnoLibre s'appelle `dns1.technolibre.ca`** — la nomenclature
de `dns1.chezlepro.ca` : `dns1` sous le domaine de l'organisation qui tient le site.
**Chaque zone publique désigne le serveur de son site ET celui de chaque pair.**
- Le contrat du site porte `dns_public_noms_pairs` : {nom : adresse publique} de chaque pair
actif, lus dans son plan (`dns_public_nom`) et sa carte de frontière (`opnsense_wan_ip`) par
les mêmes fonctions que pour le site lui-même, sous `sur_le_site`. Vérifié contre les
intrants du locataire, et déposé dans sa fiche.
- Le gabarit de zone écrit un NS par serveur ; la zone qui contient un de ces noms en porte le
A, à l'adresse de SON site. Sans pair, le rendu est identique octet pour octet à l'ancien.
- Gardé au rôle : nom bien formé, adresse publique, distinct du nôtre, jamais redéclaré.
- La preuve du DNS public refuse un pair actif qui ne nomme pas son serveur : ses zones n'en
désigneraient qu'un, en silence, alors qu'il les sert déjà.
- `dns_bascule.py` compte les serveurs désignés et sonde la colle de chacun.
**Reste** : la colle de `dns1.technolibre.ca` chez le registraire de technolibre.ca, et celle de
`dns1.chezlepro.ca` chez celui de chezlepro.ca, au moment des bascules. Au site de TechnoLibre,
`maurice` pousse encore ses journaux en HTTP : la racine du site (`ac-racine-site.crt`) n'est
pas publiée à côté de sa carte.
## 2026-10-14 (150) — La simulation de `genome-pousser` dit ce que la poussée ferait
**95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin : contre

View file

@ -105,6 +105,9 @@ serveur_powerdns_ip_publique: "{{ ip_publique_site | default('') }}"
serveur_powerdns_ip_expositions: "{{ ip_publique | default(ip_publique_site) | default('') }}"
# Le nom du serveur de noms du site (contrat du site) : NS et SOA de chaque zone publique.
serveur_powerdns_dns_public_nom: "{{ dns_public_nom | default('') }}"
# LES SERVEURS DE NOMS DES SITES PAIRS (2026-10-14) : {nom : adresse publique}, du contrat du
# site. Chaque zone publique les designe en plus du sien ; celle qui contient un nom en porte le A.
serveur_powerdns_dns_public_noms_pairs: "{{ dns_public_noms_pairs | default({}) }}"
# Le NOM de la cle TSIG, identique des deux cotes — derive du nom du depot du locataire,
# par la meme regle que `site_inventaire.py` (`tsig-<depot sans ops->`). `realpath`, parce
# que sur le poste l'inventaire passe par le lien `instance`.

View file

@ -82,13 +82,26 @@
# PERSONNE NE LE REDECLARE : un enregistrement a ce nom lui donnerait une seconde
# adresse, ou un CNAME a cote de son A.
- (serveur_powerdns_dns_public_nom | trim | regex_replace('\\.$', '')) not in (serveur_powerdns_fqdn_declares | from_json)
# LES SERVEURS DES SITES PAIRS, SOUS LES MEMES REGLES : un nom bien forme, une adresse
# publique, distinct du notre, et que personne ne redeclare.
- serveur_powerdns_dns_public_noms_pairs is mapping
- (serveur_powerdns_dns_public_noms_pairs | dict2items
| rejectattr('key', 'search', '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.?$') | list | length) == 0
- (serveur_powerdns_dns_public_noms_pairs.values()
| select('search', '^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)') | list | length) == 0
- (serveur_powerdns_dns_public_noms_pairs.values()
| reject('search', '^\d{1,3}(\.\d{1,3}){3}$') | list | length) == 0
- (serveur_powerdns_dns_public_noms_pairs.keys() | map('trim') | map('regex_replace', '\.$', '')
| intersect([serveur_powerdns_dns_public_nom | trim | regex_replace('\.$', '')]
+ (serveur_powerdns_fqdn_declares | from_json)) | list | length) == 0
fail_msg: >-
Zones publiques refusees : zone `.internal` declaree publique, adresse publique
absente ou privee ({{ serveur_powerdns_ip_publique | default('?') }}), serveur public
du site liste dans `allow-axfr-ips` (TSIG deviendrait decoratif), ou
`vault_tsig_dns_public` absent de la voute, `dns_public_nom` absent ou malforme
({{ serveur_powerdns_dns_public_nom | default('?') }}), ou redeclare dans les
enregistrements d'une zone.
enregistrements d'une zone ; ou un serveur de site pair malforme, a une adresse non
publique, ou redeclare ({{ serveur_powerdns_dns_public_noms_pairs | default({}) }}).
quiet: true
when: serveur_powerdns_zones_publiques | length > 0

View file

@ -21,10 +21,19 @@ $TTL {{ serveur_powerdns_ttl }}
de 256 bits ne revele rien d'elle. #}
{% set _cle = lookup('vars', 'vault_dnssec_' ~ (zone_publique | lower | regex_replace('[^a-z0-9]', '_')), default='') %}
; DNSSEC : {{ ('signee, cle ' ~ (_cle | hash('sha256'))[:12]) if ((domaines_publics[zone_publique].dnssec | default(false) | bool) and _cle) else 'non signee' }}
@ IN NS {{ _ns }}.
{% if _ns.endswith('.' ~ zone_publique) %}
{{ _ns[:-(zone_publique | length + 1)] }} IN A {{ serveur_powerdns_ip_publique }}
{% endif %}
{# ET LE SERVEUR DE CHAQUE SITE PAIR (2026-10-14). Il replique la zone par le tunnel entre les
sites ; sans son NS, il la servirait sans que personne le designe. Meme regle pour son A :
seule la zone qui contient son nom le porte, a l'adresse publique de SON site. #}
{% set _ns_tous = {_ns: serveur_powerdns_ip_publique} %}
{% for _n, _ip in (serveur_powerdns_dns_public_noms_pairs | default({})).items() %}
{% set _ = _ns_tous.update({(_n | trim | regex_replace('\\.$', '')): _ip}) %}
{% endfor %}
{% for _n in _ns_tous %}
@ IN NS {{ _n }}.
{% endfor %}
{% for _n, _ip in _ns_tous.items() if _n.endswith('.' ~ zone_publique) %}
{{ _n[:-(zone_publique | length + 1)] }} IN A {{ _ip }}
{% endfor %}
{# UNE ZONE PUBLIQUE NE PORTE QUE DES ADRESSES PUBLIQUES.

View file

@ -93,8 +93,13 @@ def _normaliser(rtype: str, rdata: str) -> str:
return rdata
def _du_plan(zone: str, conf: dict, ns: str, ip_publique: str) -> dict[tuple[str, str], set[str]]:
"""Ce que la zone rendue portera, par (FQDN, type). SOA et NS exceptes."""
def _du_plan(zone: str, conf: dict, ns: str, ip_publique: str,
pairs: dict[str, str] | None = None) -> dict[tuple[str, str], set[str]]:
"""Ce que la zone rendue portera, par (FQDN, type). SOA et NS exceptes.
`pairs` : {nom : adresse publique} des serveurs des sites pairs (contrat du site). Comme le
gabarit, la zone qui contient l'un de ces noms en porte le A.
"""
attendu: dict[tuple[str, str], set[str]] = {}
for e in conf.get("enregistrements") or []:
t = str(e["type"]).upper()
@ -104,8 +109,9 @@ def _du_plan(zone: str, conf: dict, ns: str, ip_publique: str) -> dict[tuple[str
elif t == "CAA":
v = f"0 {e['etiquette']} {v}"
attendu.setdefault((_rel_en_fqdn(zone, e["nom"]), t), set()).add(_normaliser(t, v))
if ns.endswith("." + zone):
attendu.setdefault((ns, "A"), set()).add(ip_publique)
for nom, ip in {ns: ip_publique, **(pairs or {})}.items():
if nom.endswith("." + zone):
attendu.setdefault((nom, "A"), set()).add(ip)
return attendu
@ -153,10 +159,12 @@ def _intrants(base: Path) -> dict:
def devis_zone(zone: str, conf: dict, ns: str, ip_publique: str, resolveur: str,
serveur: str | None) -> tuple[int, int, list[str]]:
serveur: str | None, pairs: dict[str, str] | None = None
) -> tuple[int, int, list[str]]:
"""Rend (pertes, mesures_impossibles, lignes)."""
lignes, echecs = [], 0
attendu = _du_plan(zone, conf, ns, ip_publique)
pairs = {k.rstrip("."): v for k, v in (pairs or {}).items()}
attendu = _du_plan(zone, conf, ns, ip_publique, pairs)
noms = sorted({fq for fq, _ in attendu} | {_rel_en_fqdn(zone, n) for n in NOMS_SONDES})
try:
@ -204,26 +212,32 @@ def devis_zone(zone: str, conf: dict, ns: str, ip_publique: str, resolveur: str,
# DEUX SERVEURS DE NOMS, OU PAS DE BASCULE. Le registre des `.ca` (CIRA) en exige deux,
# et un seul serveur fait de la moindre panne du site une disparition du domaine. La zone
# n'en designe qu'un tant que le serveur du site pair n'est pas actif.
lignes.append(f" PREALABLE : la zone ne designe qu'un serveur de noms ({ns}) ; il en faut deux "
f"— celui du site pair, quand `dns_public_pairs` sera actif.")
pertes += 1
# n'en designe qu'un tant que le serveur du site pair n'est pas actif — et nomme
# (`dns_public_nom` du pair, recu par le contrat : `dns_public_noms_pairs`).
serveurs = {ns: ip_publique, **pairs}
if len(serveurs) < 2:
lignes.append(f" PREALABLE : la zone ne designe qu'un serveur de noms ({ns}) ; il en faut deux "
f"— celui du site pair, quand `dns_public_pairs` sera actif.")
pertes += 1
else:
lignes.append(f" serveurs de noms designes : {', '.join(serveurs)}")
# LA COLLE. Le serveur de noms doit resoudre AVANT qu'un registraire le designe : sinon
# la zone basculee pointe vers un nom que personne ne trouve.
try:
a_ns = _interroger(resolveur, ns, "A")
if a_ns == {ip_publique}:
lignes.append(f" {ns} resout deja vers {ip_publique} : le registraire peut le designer.")
else:
lignes.append(f" PREALABLE : {ns} ne resout pas publiquement vers {ip_publique} "
f"(en ligne : {sorted(a_ns) or 'rien'}). La zone qui le contient doit "
f"le publier d'abord, et son registraire en tenir la colle.")
# Pour la zone qui CONTIENT le nom, c'est la bascule elle-meme qui le publie.
pertes += 0 if ns.endswith("." + zone) else 1
except MesureImpossible as exc:
echecs += 1
lignes.append(f" colle : MESURE IMPOSSIBLE — {exc}")
# LA COLLE. Chaque serveur de noms doit resoudre AVANT qu'un registraire le designe :
# sinon la zone basculee pointe vers un nom que personne ne trouve.
for nom, ip in serveurs.items():
try:
a_ns = _interroger(resolveur, nom, "A")
if a_ns == {ip}:
lignes.append(f" {nom} resout deja vers {ip} : le registraire peut le designer.")
else:
lignes.append(f" PREALABLE : {nom} ne resout pas publiquement vers {ip} "
f"(en ligne : {sorted(a_ns) or 'rien'}). La zone qui le contient doit "
f"le publier d'abord, et son registraire en tenir la colle.")
# Pour la zone qui CONTIENT le nom, c'est la bascule elle-meme qui le publie.
pertes += 0 if nom.endswith("." + zone) else 1
except MesureImpossible as exc:
echecs += 1
lignes.append(f" colle : MESURE IMPOSSIBLE — {exc}")
if serveur:
manquants = []
@ -277,7 +291,8 @@ def main() -> int:
total_pertes = total_echecs = 0
print(f"DEVIS DE BASCULE DNS — {base.name}, reference {args.resolveur}, serveur de noms {ns}")
for zone, conf in sorted(zones.items()):
pertes, echecs, lignes = devis_zone(zone, conf, ns, ip_publique, args.resolveur, args.serveur)
pertes, echecs, lignes = devis_zone(zone, conf, ns, ip_publique, args.resolveur, args.serveur,
intrants.get("dns_public_noms_pairs") or {})
verdict = ("MESURE INCOMPLETE" if echecs else
"rien ne serait perdu" if not pertes else f"{pertes} ecart(s) a regler")
print(f"\n{zone} — {verdict}")

View file

@ -4514,11 +4514,19 @@ def _dns_zones_servies(attendu: dict, servies: set, a_un_serveur: bool) -> list[
return fautes
def _dns_paires(declarations: dict) -> list[str]:
"""{site: [(pair, etat)]} -> fautes. Une relation vaut des deux cotes, ou pas du tout."""
def _dns_paires(declarations: dict, noms: dict | None = None) -> list[str]:
"""{site: [(pair, etat)]} -> fautes. Une relation vaut des deux cotes, ou pas du tout.
`noms` ({site: dns_public_nom}) : un pair ACTIF doit nommer son serveur (2026-10-14) — les
zones de l'autre site le designent en second NS. Sans nom, elles n'en designeraient qu'un,
en silence, alors que le serveur les sert deja.
"""
fautes = []
for site, liens in declarations.items():
for pair, etat in liens:
if noms is not None and etat == "actif" and pair in noms and not noms[pair]:
fautes.append(f"{site} replique avec {pair}, qui ne nomme pas son serveur "
f"(`dns_public_nom`) — les zones de {site} ne le designeraient pas")
retour = dict(declarations.get(pair, []))
if site not in retour:
fautes.append(f"{site} declare une replication avec {pair}, qui ne la declare pas — "
@ -4618,13 +4626,15 @@ def preuve_dns_public_tient() -> tuple[bool, str]:
if ip.startswith(_PRIVEE):
fautes.append(f"{eco} : `ip_publique_site` vaut {ip}, une adresse privee")
declarations = {}
declarations, noms = {}, {}
for d in sorted(RACINE.parent.glob("SITE-*")):
liens = _yaml_ou_vide(d / "plan" / "10-intrants.yml").get("dns_public_pairs") or []
intrants_site = _yaml_ou_vide(d / "plan" / "10-intrants.yml")
noms[d.name] = str(intrants_site.get("dns_public_nom") or "").strip()
liens = intrants_site.get("dns_public_pairs") or []
if liens:
declarations[d.name] = [(str(x.get("site")), str(x.get("etat", "actif")))
for x in liens if isinstance(x, dict)]
fautes += _dns_paires(declarations)
fautes += _dns_paires(declarations, noms)
gabarit = RACINE / "roles" / "serveur_powerdns" / "templates" / "pdns-public.conf.j2"
lignes = [l.strip() for l in gabarit.read_text(encoding="utf-8").splitlines()

View file

@ -83,6 +83,30 @@ def _ip_publique_site() -> str:
return str(_opnsense().get("opnsense_wan_ip") or "")
def _noms_dns_public_des_pairs() -> dict[str, str]:
"""{nom : adresse publique} du serveur de noms de chaque site pair ACTIF (2026-10-14).
LE SECOND NS D'UNE ZONE. Chaque zone publique designe le serveur de son site ET celui de
chaque pair qui la replique. Le nom vient du plan du pair (`dns_public_nom`), l'adresse de
sa frontiere (`opnsense_wan_ip`) — lus par les MEMES fonctions que pour ce site-ci, sous
`sur_le_site`. La zone qui CONTIENT un de ces noms en porte le A.
Un pair sans nom ou sans adresse ne rend rien : la preuve du DNS public le refuse a part.
"""
out: dict[str, str] = {}
for nom in underlay_mod.sites_pairs_actifs():
try:
with underlay_mod.sur_le_site(nom):
ns = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("dns_public_nom") or "").strip().rstrip(".")
ip = _ip_publique_site()
except FileNotFoundError:
continue
if ns and ip:
out[ns] = ip
return dict(sorted(out.items()))
LIBRE = "libre"
@ -172,6 +196,9 @@ def contrat() -> dict:
"ip_publique": ip_publique_de(instance_courante().name),
# SOUS QUEL NOM LE SITE FAIT AUTORITE — les NS et le SOA de chaque zone publique.
"dns_public_nom": str(intrants.get("dns_public_nom") or ""),
# ET LES NOMS DES SERVEURS DES SITES PAIRS (2026-10-14), avec leur adresse publique : les
# NS de plus de chaque zone, et le A de celui qui vit sous une zone du locataire.
"dns_public_noms_pairs": _noms_dns_public_des_pairs(),
# QUI A LE DROIT D'ENTRER EN SSH
# QUI A LE DROIT D'ENTRER EN SSH : le plan d'administration du site ET le tunnel
# WireGuard des admins, derive de sa declaration (jamais recopie).
@ -197,6 +224,7 @@ OU_LE_LOCATAIRE_LE_DIT = {
"ip_publique_site": ("all/10-intrants.yml", "ip_publique_site"),
"ip_publique": ("all/10-intrants.yml", "ip_publique"),
"dns_public_nom": ("all/10-intrants.yml", "dns_public_nom"),
"dns_public_noms_pairs": ("all/10-intrants.yml", "dns_public_noms_pairs"),
# CES DEUX-LA MANQUAIENT, ET LE SILENCE A COUTE (2026-09-13). Le site en expose SEPT ;
# cette table n'en comparait que CINQ. Le controle rapportait donc « CONFORME » avec
# un aplomb complet, sur un locataire dont le plan d'administration pointait encore
@ -223,6 +251,10 @@ def _normaliser(v) -> str:
"""
if isinstance(v, (list, tuple)):
return " ".join(sorted(str(x).strip().rstrip("/") for x in v))
# Un dictionnaire (`dns_public_noms_pairs`) : l'ordre des cles ne porte aucun sens non plus.
if isinstance(v, dict):
return " ".join(sorted(f"{str(k).strip().rstrip('.')}={str(x).strip()}"
for k, x in v.items()))
return str(v).strip().rstrip("/")