Set-OPS-Public/scripts/dns_bascule.py

296 lines
13 KiB
Python
Raw Normal View History

#!/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())