296 lines
13 KiB
Python
296 lines
13 KiB
Python
|
|
#!/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())
|