From 192b7ad71342f19f5d0662d4dab26ca325b83255 Mon Sep 17 00:00:00 2001 From: kguerineau Date: Tue, 30 Jun 2026 23:15:57 +0200 Subject: [PATCH] [IMP] Remove jnl file if error in log file --- agents/dns_agent.py | 178 +++++++++++++++++++++++++ backend/routers/dns.py | 31 +++++ frontend/src/components/DNSManager.jsx | 24 +++- 3 files changed, 232 insertions(+), 1 deletion(-) diff --git a/agents/dns_agent.py b/agents/dns_agent.py index f88e50b..17ed88d 100644 --- a/agents/dns_agent.py +++ b/agents/dns_agent.py @@ -1053,6 +1053,170 @@ HANDLERS = { } +# ────────────────────────────────────────────── +# BIND journal sync watcher +# ────────────────────────────────────────────── + +def _get_redis(): + """Retourne la connexion Redis globale si disponible.""" + import builtins + return getattr(builtins, "_netadmin_redis", None) + + +def fix_journal_out_of_sync(zone_name: str, cfg: dict) -> bool: + """ + Supprime le fichier .jnl désynchronisé et recharge la zone. + Appelé automatiquement quand BIND log "journal out of sync with zone". + Retourne True si la correction a réussi. + """ + zones_dir = cfg.get("zones_dir", "/etc/bind/zones") + rndc_cmd = cfg.get("rndc_cmd", "rndc") + + # Chercher le .jnl dans zones_dir et ses parents courants + search_dirs = list({ + zones_dir, + str(Path(zones_dir).parent), # /etc/bind + zones_dir.replace("/zones", "/master"), # /etc/bind/master + zones_dir.replace("/zones", "/pri"), # /etc/bind/pri + }) + + deleted = None + for d in search_dirs: + for jnl in [ + Path(d) / f"db.{zone_name}.jnl", + Path(d) / f"{zone_name}.jnl", + Path(d) / f"db.{zone_name}.db.jnl", + ]: + if jnl.exists(): + try: + jnl.unlink() + deleted = jnl + log.info(f"Journal supprimé : {jnl}") + break + except Exception as e: + log.error(f"Impossible de supprimer {jnl}: {e}") + return False + if deleted: + break + + if not deleted: + # Dernier recours : find dans tout zones_dir + try: + result = subprocess.run( + ["find", zones_dir, "-name", f"*{zone_name}*.jnl"], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().splitlines(): + p = Path(line.strip()) + if p.exists(): + p.unlink() + deleted = p + log.info(f"Journal supprimé (find) : {p}") + break + except Exception: + pass + + if not deleted: + log.warning(f"Fichier .jnl introuvable pour '{zone_name}'") + + # Recharger BIND + try: + result = subprocess.run( + [rndc_cmd, "reload", zone_name], + capture_output=True, text=True, timeout=15 + ) + if result.returncode == 0: + log.info(f"Zone '{zone_name}' rechargée après correction du journal") + else: + subprocess.run([rndc_cmd, "reconfig"], capture_output=True, timeout=15) + log.warning(f"rndc reload {zone_name} a échoué, reconfig lancé") + + # Publier une notification sur Redis + try: + import datetime + _notif = { + "type": "journal_fixed", + "zone": zone_name, + "jnl_file": str(deleted) if deleted else "inconnu", + "timestamp": datetime.datetime.now().isoformat(), + "message": f"Journal BIND désynchronisé corrigé automatiquement pour {zone_name}", + } + _r = _get_redis() + if _r: + _r.publish("dns.notifications", json.dumps(_notif)) + _r.lpush("dns.notifications.history", json.dumps(_notif)) + _r.ltrim("dns.notifications.history", 0, 49) # garder 50 max + except Exception as ne: + log.warning(f"Impossible de publier la notification: {ne}") + + return True + except Exception as e: + log.error(f"Erreur rndc après suppression du journal: {e}") + return False + + +def watch_bind_logs(cfg: dict, stop_event): + """ + Thread qui surveille les logs BIND et corrige automatiquement + les erreurs "journal out of sync with zone". + """ + import time + + bind_log = cfg.get("bind_log", "/var/log/bind/default") + check_interval = cfg.get("journal_watch_interval", 30) # secondes + + log.info(f"Journal watcher démarré — surveillance de {bind_log} toutes les {check_interval}s") + + # Regex pour détecter l'erreur + jnl_re = re.compile( + r"zone\s+([^/]+)/IN.*journal out of sync with zone", + re.IGNORECASE + ) + + last_pos = 0 + last_size = 0 + + while not stop_event.is_set(): + try: + log_path = Path(bind_log) + if not log_path.exists(): + stop_event.wait(check_interval) + continue + + current_size = log_path.stat().st_size + + # Log rotaté (fichier plus petit qu'avant) → recommencer du début + if current_size < last_size: + last_pos = 0 + + last_size = current_size + + if current_size == last_pos: + stop_event.wait(check_interval) + continue + + # Lire les nouvelles lignes depuis la dernière position + with open(bind_log, "r", errors="replace") as f: + f.seek(last_pos) + new_lines = f.read() + last_pos = f.tell() + + for line in new_lines.splitlines(): + m = jnl_re.search(line) + if m: + zone_name = m.group(1).strip().rstrip(".") + log.warning(f"Journal out of sync détecté pour '{zone_name}' — correction automatique…") + fix_journal_out_of_sync(zone_name, cfg) + + except Exception as e: + log.error(f"Journal watcher error: {e}") + + stop_event.wait(check_interval) + + log.info("Journal watcher arrêté") + + + # ────────────────────────────────────────────── # Main loop # ────────────────────────────────────────────── @@ -1087,8 +1251,22 @@ def run(cfg: dict): pubsub = r.pubsub() pubsub.subscribe("dns.commands") log.info("Subscribed to dns.commands — waiting for messages…") + # Start BIND journal sync watcher thread + import threading + stop_event = threading.Event() + watcher_thread = threading.Thread( + target=watch_bind_logs, + args=(cfg, stop_event), + daemon=True, + name="journal-watcher" + ) + watcher_thread.start() + # Announce reconnection so the backend can flush the pending queue import socket as _socket + # Rendre la connexion Redis accessible au journal watcher + import builtins + builtins._netadmin_redis = r r.publish("agent.ready", json.dumps({"service": "dns", "host": _socket.gethostname()})) log.info(f"Published agent.ready for service 'dns'") diff --git a/backend/routers/dns.py b/backend/routers/dns.py index d53fecb..55bff6d 100644 --- a/backend/routers/dns.py +++ b/backend/routers/dns.py @@ -939,6 +939,37 @@ def rollback_zone( } +@router.get("/notifications") +def get_notifications(user: dict = Depends(get_current_user)): + """Retourne les dernières notifications de l'agent DNS.""" + try: + from broker import get_redis + r = get_redis() + items = r.lrange("dns.notifications.history", 0, 49) + import json as _json + notifications = [] + for item in items: + try: + notifications.append(_json.loads(item)) + except Exception: + pass + return {"notifications": notifications} + except Exception as e: + return {"notifications": [], "error": str(e)} + + +@router.delete("/notifications") +def clear_notifications(user: dict = Depends(get_current_user)): + """Efface l'historique des notifications.""" + try: + from broker import get_redis + r = get_redis() + r.delete("dns.notifications.history") + return {"success": True} + except Exception as e: + raise HTTPException(500, str(e)) + + @router.post("/sync") def sync_endpoint( zone: str | None = None, diff --git a/frontend/src/components/DNSManager.jsx b/frontend/src/components/DNSManager.jsx index 2d7c900..aa174f1 100644 --- a/frontend/src/components/DNSManager.jsx +++ b/frontend/src/components/DNSManager.jsx @@ -42,7 +42,8 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { const [rawContent, setRawContent] = useState(""); const [rawSaving, setRawSaving] = useState(false); const [rawLoading, setRawLoading] = useState(false); - const [rawError, setRawError] = useState(null); + const [rawError, setRawError] = useState(null); + const [notifications, setNotifications] = useState([]); const [copied, setCopied] = useState(false); const [confirmZone, setConfirmZone] = useState(null); const [confirmRec, setConfirmRec] = useState(null); @@ -125,6 +126,27 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { } }; + const loadNotifications = useCallback(async () => { + try { + const data = await api("/dns/notifications"); + setNotifications(data.notifications || []); + } catch(e) { /* silencieux */ } + }, [api]); + + const clearNotifications = async () => { + try { + await api("/dns/notifications", { method: "DELETE" }); + setNotifications([]); + } catch(e) { setError(e.message); } + }; + + // Poll notifications toutes les 30s + useEffect(() => { + loadNotifications(); + const timer = setInterval(loadNotifications, 30000); + return () => clearInterval(timer); + }, [loadNotifications]); + const loadSnapshots = async (zone) => { setSnapshotModal(true); setSnapLoading(true);