deploy/dns-failover.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Failover DNS Cloudflare pour *.mayoraz-net.ch — côté VPS.
Armé UNIQUEMENT si /opt/caddy/cf.env contient CF_API_TOKEN et CF_ZONE_ID
(token avec permission Zone.DNS Edit). Sinon : exit 2 avec un message
clair — le script ne s'exécute jamais « à moitié ».
Modes :
dns-failover switch "raison libre" -> A/AAAA des hostnames surveillés
vers 198.51.100.1 / 100::6400:1 (TEST-NET, noires) : Cloudflare
ne peut plus proxifier vers l'origine morte, sa page d'erreur en
remplace chaque site (maintenance-level, TLS intact).
dns-failover restore -> retour à 132.243.200.40 + purge
des AAAA (source de vérité : seulement IPv4 du VPS).
dns-failover status -> état courant des records.
Idempotent : re-basculer sans changement ne fait rien (flag proxy conservé).
"""
import json
import os
import sys
import urllib.request
from pathlib import Path
CF_ENV = Path("/opt/caddy/cf.env")
HOSTNAMES = [
"auth", "jellyfin", "jellyseerr", "n8n", "pdf", "terminal", "radarr",
"sonarr", "prowlarr", "bazarr", "qbittorrent", "portainer", "proxmox",
"pihole", "logs", "garmin-map", "grafana", "status", "files", "kylie",
"vektor", "vault", "immich", "proxmenux", "pegaprox", "prometheus",
"bitmagnet",
]
ZONE_NAME = "mayoraz-net.ch"
VPS_IP = "132.243.200.40"
BLACKHOLE_V4 = "198.51.100.1"
BLACKHOLE_V6 = "100::6400:1"
API = "https://api.cloudflare.com/client/v4"
def load_creds() -> tuple[str, str]:
if not CF_ENV.exists():
print("NON ARMÉ : /opt/caddy/cf.env absent.")
print("Pour armer : créer cf.env avec CF_API_TOKEN=<token Zone.DNS Edit> et CF_ZONE_ID=<id zone mayoraz-net.ch>, chmod 600.")
raise SystemExit(2)
creds = {}
for line in CF_ENV.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
creds[k.strip()] = v.strip()
token, zone = creds.get("CF_API_TOKEN", ""), creds.get("CF_ZONE_ID", "")
if not token or not zone:
print("NON ARMÉ : CF_API_TOKEN / CF_ZONE_ID manquants dans cf.env")
raise SystemExit(2)
return token, zone
def cf_request(method: str, path: str, token: str, body: dict | None = None) -> dict:
req = urllib.request.Request(
f"{API}{path}",
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
method=method,
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
if not data.get("success"):
raise RuntimeError(f"API CF: {json.dumps(data.get('errors'))[:300]}")
return data["result"]
def zone_records(token: str, zone: str) -> list[dict]:
out, page = [], 1
while True:
res = cf_request("GET", f"/zones/{zone}/dns_records?per_page=100&page={page}", token)
out.extend(res)
if len(res) < 100:
return out
page += 1
def main() -> int:
if len(sys.argv) < 2 or sys.argv[1] not in {"switch", "restore", "status"}:
print(__doc__)
return 1
mode = sys.argv[1]
token, zone = load_creds()
records = zone_records(token, zone)
targets = [r for r in records if r["name"] == ZONE_NAME or r["name"].endswith(f".{ZONE_NAME}")]
changed = 0
if mode == "status":
for r in targets:
print(f"{r['name']:40} {r['type']:5} {r['content']:40} proxy={r['proxied']}")
return 0
if mode == "switch":
reason = " ".join(sys.argv[2:]) or "raison non précisée"
wanted = {4: BLACKHOLE_V4, 28: BLACKHOLE_V6}
else:
wanted = {4: VPS_IP, 28: None} # restore : AAAA supprimés
by_name = {}
for r in targets:
by_name.setdefault(r["name"], []).append(r)
for name, recs in by_name.items():
for rtype, ip in wanted.items():
existing = [r for r in recs if r["type"] == ("A" if rtype == 4 else "AAAA")]
if ip is None:
for r in existing:
cf_request("DELETE", f"/zones/{zone}/dns_records/{r['id']}", token)
print(f"DELETE {name} AAAA {r['content']}")
changed += 1
continue
correct = [r for r in existing if r["content"] == ip]
if correct:
continue
if existing:
r = existing[0]
cf_request("PATCH", f"/zones/{zone}/dns_records/{r['id']}", token,
{"content": ip})
print(f"PATCH {name} {r['type']} {r['content']} -> {ip}")
for extra in existing[1:]:
cf_request("DELETE", f"/zones/{zone}/dns_records/{extra['id']}", token)
print(f"DELETE doublon {name} {extra['type']} {extra['content']}")
else:
cf_request("POST", f"/zones/{zone}/dns_records", token,
{"type": "A" if rtype == 4 else "AAAA", "name": name,
"content": ip, "ttl": 300, "proxied": True})
print(f"CREATE {name} {'A' if rtype == 4 else 'AAAA'} {ip} (proxy)")
changed += 1
print(f"--- {changed} changement(s) DNS ({mode}). Raison : {reason if mode == 'switch' else 'retour à la normale'}")
return 0
if __name__ == "__main__":
sys.exit(main())