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:
parent
ee5aad5f41
commit
d152e32328
7 changed files with 148 additions and 36 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue