diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5cf45..2676e9f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index dc00bcc..12b3a0a 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -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-`). `realpath`, parce # que sur le poste l'inventaire passe par le lien `instance`. diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml index a91bc54..35a35df 100644 --- a/roles/serveur_powerdns/tasks/zones-publiques.yml +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -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 diff --git a/roles/serveur_powerdns/templates/zone-publique.db.j2 b/roles/serveur_powerdns/templates/zone-publique.db.j2 index 048e642..0bdb88a 100644 --- a/roles/serveur_powerdns/templates/zone-publique.db.j2 +++ b/roles/serveur_powerdns/templates/zone-publique.db.j2 @@ -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. diff --git a/scripts/dns_bascule.py b/scripts/dns_bascule.py index 286a87c..1199114 100644 --- a/scripts/dns_bascule.py +++ b/scripts/dns_bascule.py @@ -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}") diff --git a/scripts/prouver.py b/scripts/prouver.py index 61e0373..0b070d5 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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() diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index 9264b7e..a2e2477 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -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("/")