#!/usr/bin/env python3 """Devis de bascule DNS — la zone du plan contre le DNS public en service. Aucune ecriture. POURQUOI CE SCRIPT EXISTE (2026-09-16). Basculer les serveurs de noms d'un domaine chez le registraire remplace, d'un coup, TOUT ce que le monde lit de ce domaine. Si la zone du plan oublie un MX, le courriel s'arrete ; si elle oublie un nom, un service disparait. Rien ne le signale a l'avance : le premier temoin est un client dont le message revient. Ce devis repond a une seule question, avant le geste : « si le registraire suivait nos serveurs de noms maintenant, qu'est-ce qui changerait ? » La reponse attendue est « rien », ou une liste d'ajouts voulus. CE QU'IL COMPARE. Pour chaque zone `autorite: primaire-cache` de l'instance visee, chaque nom declare au plan ET chaque nom d'une liste de sondes usuelles, type par type (A, AAAA, CNAME, MX, TXT, CAA). D'un cote le plan, de l'autre un resolveur public (9.9.9.9 par defaut) — et, avec `--serveur`, ce que notre propre serveur sert. CE QU'IL NE PEUT PAS VOIR. Sans transfert de zone (Namespro et Cloudflare le refusent), un nom que personne ne sonde reste invisible. Le devis le dit a chaque rapport : un export de la zone chez le fournisseur actuel est la seule preuve d'exhaustivite. UNE MESURE QUI ECHOUE N'EST PAS UNE ABSENCE. Un resolveur qui ne repond pas, ou repond SERVFAIL, rend « mesure impossible » — jamais « absent en ligne ». La meme confusion a deja fabrique trois faux diagnostics dans ce depot. Usage : python3 scripts/dns_bascule.py # l'instance visee, via 9.9.9.9 python3 scripts/dns_bascule.py --serveur 10.37.37.21 python3 scripts/dns_bascule.py --zone chezlepro.ca Code de sortie : 0 si aucune zone ne perdrait rien ; 1 sinon ; 2 si une mesure a echoue. """ from __future__ import annotations import argparse import shlex import subprocess import sys from pathlib import Path import yaml sys.path.insert(0, str(Path(__file__).resolve().parent)) from inventory_rules import instance_courante, valider_domaines # noqa: E402 TYPES = ("A", "AAAA", "CNAME", "MX", "TXT", "CAA") # Les noms qu'on interroge meme quand le plan ne les declare pas : c'est ainsi qu'on trouve # ce que le plan a oublie. Un releve, pas une garantie (voir « ce qu'il ne peut pas voir »). NOMS_SONDES = ( "@", "www", "mail", "mx", "smtp", "imap", "pop", "webmail", "autoconfig", "autodiscover", "_dmarc", "_mta-sts", "mta-sts", "_smtp._tls", "default._domainkey", "google._domainkey", "selector1._domainkey", "selector2._domainkey", "setops._domainkey", "ns1", "ns2", "ns", "dns", "dns1", "dns2", "vpn", "git", "forge", "cloud", "nextcloud", "collabora", "bureau", "auth", "sso", "api", "blog", "docs", "wiki", "status", "ftp", "lists", "matrix", "chat", ) # CE QU'ON ABANDONNE EN CONNAISSANCE DE CAUSE. Les marques de propriete qu'external-dns pose # chez Cloudflare : hors de Cloudflare, rien ne les lit. Elles sont rapportees, pas comptees. ABANDONS_CONNUS = ( ("TXT", "heritage=external-dns,", "marque de propriete d'external-dns, lue par lui seul"), ) def _rel_en_fqdn(zone: str, nom: str) -> str: return zone if nom in ("@", "", zone) else f"{nom}.{zone}" def _normaliser(rtype: str, rdata: str) -> str: """Une forme comparable d'une donnee, qu'elle vienne du plan ou de `dig`.""" rdata = rdata.strip() if rtype in ("CNAME", "NS"): return rdata.rstrip(".").lower() if rtype == "MX": prio, _, cible = rdata.partition(" ") return f"{int(prio)} {cible.strip().rstrip('.').lower()}" if rtype == "TXT": # `dig` rend les tranches entre guillemets ; un resolveur les recolle. Le plan, lui, # ecrit la valeur nue (le validateur y refuse les guillemets) : la decouper aux # espaces les effacerait — eprouve, `v=spf1 mx` devenait `v=spf1mx`. if not rdata.startswith('"'): return rdata try: return "".join(shlex.split(rdata)) except ValueError: return rdata if rtype == "CAA": drapeau, etiquette, valeur = (rdata.split(None, 2) + ["", "", ""])[:3] return f"{int(drapeau)} {etiquette.lower()} {valeur.strip().strip(chr(34))}" if rtype == "AAAA": import ipaddress return str(ipaddress.ip_address(rdata)) 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.""" attendu: dict[tuple[str, str], set[str]] = {} for e in conf.get("enregistrements") or []: t = str(e["type"]).upper() v = str(e["valeur"]).strip() if t == "MX": v = f"{e['priorite']} {v}" 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) return attendu class MesureImpossible(Exception): pass def _interroger(serveur: str, fqdn: str, rtype: str) -> set[str]: hote, _, port = serveur.partition(":") cmd = ["dig", "+noall", "+answer", "+comments", "+time=4", "+tries=2", f"@{hote}", fqdn, rtype] if port: cmd[1:1] = ["-p", port] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) except (OSError, subprocess.TimeoutExpired) as exc: raise MesureImpossible(f"{fqdn} {rtype} @{serveur} : {exc}") from exc statut = next((ligne.split("status:")[1].split(",")[0].strip() for ligne in r.stdout.splitlines() if "status:" in ligne), None) if r.returncode != 0 or statut not in ("NOERROR", "NXDOMAIN"): detail = statut or (r.stdout.strip().splitlines() or ["sans reponse"])[-1] raise MesureImpossible(f"{fqdn} {rtype} @{serveur} : {detail}") vus = set() for ligne in r.stdout.splitlines(): if not ligne or ligne.startswith(";"): continue champs = ligne.split(None, 4) # SEULEMENT CE QUI EST AU NOM ET AU TYPE DEMANDES : un A demande sur un CNAME rend # aussi la chaine suivie, qui appartient a un autre nom. if len(champs) == 5 and champs[0].rstrip(".").lower() == fqdn.lower() and champs[3] == rtype: vus.add(_normaliser(rtype, champs[4])) return vus def _abandon(rtype: str, valeur: str) -> str | None: for t, prefixe, raison in ABANDONS_CONNUS: if t == rtype and valeur.startswith(prefixe): return raison return None def _intrants(base: Path) -> dict: f = base / "inventories" / "principal" / "group_vars" / "all" / "10-intrants.yml" return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}) if f.is_file() else {} def devis_zone(zone: str, conf: dict, ns: str, ip_publique: str, resolveur: str, serveur: str | None) -> tuple[int, int, list[str]]: """Rend (pertes, mesures_impossibles, lignes).""" lignes, echecs = [], 0 attendu = _du_plan(zone, conf, ns, ip_publique) noms = sorted({fq for fq, _ in attendu} | {_rel_en_fqdn(zone, n) for n in NOMS_SONDES}) try: ns_actuels = sorted(v.rstrip(".") for v in _interroger(resolveur, zone, "NS")) lignes.append(f" delegation actuelle : {', '.join(ns_actuels) or '(aucune)'}") if ns in ns_actuels: lignes.append(" deja basculee : le devis compare alors la zone a elle-meme.") except MesureImpossible as exc: echecs += 1 lignes.append(f" delegation : MESURE IMPOSSIBLE — {exc}") identiques, ajouts, differences, en_ligne_seul, abandons = [], [], [], [], [] for fq in noms: for t in TYPES: try: en_ligne = _interroger(resolveur, fq, t) except MesureImpossible as exc: echecs += 1 lignes.append(f" MESURE IMPOSSIBLE : {exc}") continue plan = attendu.get((fq, t), set()) if not plan and not en_ligne: continue gardes = {v for v in en_ligne if not _abandon(t, v)} for v in en_ligne - gardes: abandons.append(f"{fq} {t} {v[:60]} — {_abandon(t, v)}") if plan == gardes: identiques.append(f"{fq} {t}") elif not gardes: ajouts.append(f"{fq} {t} {', '.join(sorted(plan))}") elif not plan: en_ligne_seul.append(f"{fq} {t} {', '.join(sorted(gardes))}") else: differences.append(f"{fq} {t} : plan {sorted(plan)} / en ligne {sorted(gardes)}") pertes = len(differences) + len(en_ligne_seul) lignes.append(f" identiques : {len(identiques)}") for titre, liste in (("PERDU A LA BASCULE (en ligne, absent du plan)", en_ligne_seul), ("CHANGE A LA BASCULE", differences), ("ajoute par la bascule (voulu ?)", ajouts), ("abandonne en connaissance de cause", abandons)): if liste: lignes.append(f" {titre} : {len(liste)}") lignes += [f" - {x}" for x in liste] # 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 # 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}") if serveur: manquants = [] for (fq, t), valeurs in sorted(attendu.items()): try: servi = _interroger(serveur, fq, t) except MesureImpossible as exc: echecs += 1 manquants.append(f"MESURE IMPOSSIBLE : {exc}") continue if servi != valeurs: manquants.append(f"{fq} {t} : plan {sorted(valeurs)} / servi {sorted(servi)}") if manquants: pertes += len([m for m in manquants if not m.startswith("MESURE")]) lignes.append(f" NOTRE SERVEUR ({serveur}) NE SERT PAS LE PLAN : {len(manquants)}") lignes += [f" - {m}" for m in manquants] else: lignes.append(f" notre serveur ({serveur}) sert exactement le plan.") return pertes, echecs, lignes def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--resolveur", default="9.9.9.9", help="resolveur public de reference") ap.add_argument("--serveur", help="notre serveur, pour verifier qu'il sert le plan (IP[:port])") ap.add_argument("--zone", help="une seule zone") args = ap.parse_args() base = instance_courante() if not base.exists(): print("REFUS : aucune instance montee (symlink `instance/` ou SETOPS_INSTANCE).") return 2 base = base.resolve() registre = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {} valider_domaines(registre) intrants = _intrants(base) ns = str(intrants.get("dns_public_nom") or "").rstrip(".") ip_publique = str(intrants.get("ip_publique_site") or "") if not ns or not ip_publique: print("REFUS : `dns_public_nom` ou `ip_publique_site` absent des intrants du locataire " "(make site-intrants). Sans eux, on ne sait pas ce que la zone rendra.") return 2 zones = {z: c for z, c in (registre.get("domaines_publics") or {}).items() if c.get("autorite") == "primaire-cache" and (not args.zone or z == args.zone)} if not zones: print(f"Aucune zone `primaire-cache` dans {base.name}" + (f" nommee {args.zone}" if args.zone else "") + " : rien a basculer.") return 2 if args.zone else 0 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) 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}") print("\n".join(lignes)) total_pertes += pertes total_echecs += echecs print("\nLIMITE : seuls les noms declares et sondes sont compares. Un export de la zone chez " "le fournisseur actuel reste la seule preuve d'exhaustivite.") if total_echecs: return 2 return 1 if total_pertes else 0 if __name__ == "__main__": sys.exit(main())