1324 lines
48 KiB
Python
1324 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
dns_agent.py — NetAdmin DNS Agent
|
|
Runs on the BIND server. Subscribes to Redis dns.commands,
|
|
writes zone files and calls rndc, then publishes ACK to dns.acks.
|
|
|
|
Install on the BIND server:
|
|
pip install redis pyyaml dnspython
|
|
python3 dns_agent.py --config /etc/netadmin/config.yaml
|
|
|
|
Systemd unit: see docs/dns-agent.service
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import ssl
|
|
import redis
|
|
import yaml
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [dns-agent] %(levelname)s %(message)s"
|
|
)
|
|
log = logging.getLogger("dns-agent")
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Config
|
|
# ──────────────────────────────────────────────
|
|
|
|
def load_config(path: str) -> dict:
|
|
with open(path) as f:
|
|
raw = yaml.safe_load(f) or {}
|
|
|
|
cfg = {}
|
|
|
|
# Section dns_agent
|
|
cfg.update(raw.get("dns_agent", {}))
|
|
|
|
# Section redis → mapped to redis_* keys
|
|
redis_cfg = raw.get("redis", {})
|
|
if redis_cfg:
|
|
cfg["redis_host"] = redis_cfg.get("host", "localhost")
|
|
cfg["redis_port"] = int(redis_cfg.get("port", 6379))
|
|
cfg["redis_password"] = redis_cfg.get("password", "")
|
|
cfg["redis_db"] = int(redis_cfg.get("db", 0))
|
|
# mTLS — config.yaml uses ssl_ca_cert / ssl_certfile / ssl_keyfile
|
|
if redis_cfg.get("ssl"):
|
|
cfg["tls"] = {
|
|
"enabled": True,
|
|
"ca_cert": redis_cfg.get("ssl_ca_cert", ""),
|
|
"client_cert": redis_cfg.get("ssl_certfile", ""),
|
|
"client_key": redis_cfg.get("ssl_keyfile", ""),
|
|
"check_hostname": redis_cfg.get("check_hostname", False),
|
|
}
|
|
|
|
return cfg
|
|
|
|
|
|
|
|
def build_redis_ssl_context(tls_cfg: dict) -> "ssl.SSLContext | None":
|
|
"""Build an mTLS SSLContext from the agent tls config dict."""
|
|
import ssl
|
|
if not tls_cfg.get("enabled", False):
|
|
return None
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
ctx.load_verify_locations(cafile=tls_cfg["ca_cert"])
|
|
ctx.load_cert_chain(
|
|
certfile=tls_cfg["client_cert"],
|
|
keyfile=tls_cfg["client_key"]
|
|
)
|
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
if not tls_cfg.get("check_hostname", True):
|
|
ctx.check_hostname = False
|
|
return ctx
|
|
|
|
DEFAULT_CFG = {
|
|
"zones_dir": "/etc/bind/zones",
|
|
"named_conf_local": "/etc/bind/named.conf.local",
|
|
"rndc_cmd": "rndc",
|
|
"checkzone_cmd": "named-checkzone",
|
|
"key_dir": "/etc/bind/keys", # DNSSEC key directory
|
|
"dnssec_policy": "default", # BIND9 dnssec-policy name
|
|
"redis_host": "localhost",
|
|
"redis_port": 6379,
|
|
"redis_password": "",
|
|
"redis_db": 0,
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Zone file generation
|
|
# ──────────────────────────────────────────────
|
|
|
|
def _fmt_ttl(ttl: int | None, zone_ttl: int) -> str:
|
|
"""Return tab-separated TTL field or empty string if equals zone default."""
|
|
if ttl is None or ttl == zone_ttl:
|
|
return ""
|
|
return f"{ttl}\t"
|
|
|
|
|
|
def _fmt_value(rtype: str, value: str) -> str:
|
|
"""Normalize record value for zone file."""
|
|
if rtype == "TXT":
|
|
# Strip existing outer quotes, re-split into 255-byte chunks
|
|
raw = value
|
|
if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2:
|
|
raw = raw[1:-1]
|
|
raw = re.sub(r'"\s+"', "", raw)
|
|
raw = raw.replace('\\"', '"')
|
|
encoded = raw.encode("utf-8")
|
|
chunks = []
|
|
for i in range(0, len(encoded), 255):
|
|
chunk = encoded[i:i+255].decode("utf-8", errors="replace")
|
|
chunk = chunk.replace('"', '\\"')
|
|
chunks.append(f'"{chunk}"')
|
|
return " ".join(chunks) if chunks else '""'
|
|
if rtype in ("CNAME", "MX", "NS", "PTR") and not value.endswith("."):
|
|
# Si la valeur contient un point, c'est un FQDN partiel → ajouter le .
|
|
# Si pas de point, c'est un nom relatif à la zone → ne pas toucher
|
|
if "." in value:
|
|
return value + "."
|
|
# nom relatif (ex: srvweb) — BIND ajoutera le domaine de la zone
|
|
return value
|
|
return value
|
|
|
|
|
|
def build_zone_content(zone: dict) -> str:
|
|
"""Generate a complete zone file from the zone payload."""
|
|
name = zone["zone"]
|
|
admin = zone.get("admin", "hostmaster")
|
|
ttl = zone.get("ttl", 3600)
|
|
refresh = zone.get("refresh", 43200)
|
|
retry = zone.get("retry", 3600)
|
|
expire = zone.get("expire", 2419200)
|
|
negative_ttl = zone.get("negative_ttl", 3600)
|
|
admin_fqdn = admin if admin.endswith(".") else admin + "."
|
|
|
|
serial = _next_serial(name)
|
|
|
|
lines = [
|
|
f"; Zone file for {name} — managed by NetAdmin",
|
|
f"; DO NOT EDIT MANUALLY",
|
|
f"$ORIGIN {name}.",
|
|
f"$TTL {ttl}",
|
|
f"@\tIN\tSOA\tns1.{name}. {admin_fqdn} (",
|
|
f"\t\t\t{serial:<12}; Serial",
|
|
f"\t\t\t{refresh:<12}; Refresh",
|
|
f"\t\t\t{retry:<12}; Retry",
|
|
f"\t\t\t{expire:<12}; Expire",
|
|
f"\t\t\t{negative_ttl} )\t; Negative TTL",
|
|
"",
|
|
]
|
|
|
|
for rec in zone.get("records", []):
|
|
rtype = rec["type"]
|
|
rname = rec["name"] or "@"
|
|
rvalue = _fmt_value(rtype, rec["value"])
|
|
ttl_f = _fmt_ttl(rec.get("ttl"), ttl)
|
|
priority = rec.get("priority")
|
|
active = rec.get("active", True)
|
|
|
|
if rtype in ("MX", "SRV") and priority is not None:
|
|
line = f"{rname}\t{ttl_f}IN\t{rtype}\t{priority}\t{rvalue}"
|
|
else:
|
|
line = f"{rname}\t{ttl_f}IN\t{rtype}\t{rvalue}"
|
|
|
|
if not active:
|
|
lines.append(f"; [DISABLED] {line}")
|
|
else:
|
|
lines.append(line)
|
|
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _next_serial(zone_name: str) -> str:
|
|
"""Generate YYYYMMDDnn serial, incrementing from existing if same day."""
|
|
import datetime
|
|
today = datetime.date.today().strftime("%Y%m%d")
|
|
# Try to read existing serial
|
|
# (we don't have the path here, handled at write time)
|
|
return f"{today}01"
|
|
|
|
|
|
def _read_existing_serial(path: Path) -> str | None:
|
|
if not path.exists():
|
|
return None
|
|
for line in path.read_text().splitlines():
|
|
nc = line.split(";")[0].strip()
|
|
try:
|
|
val = int(nc)
|
|
if len(str(val)) == 10:
|
|
return str(val)
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _compute_serial(path: Path) -> str:
|
|
import datetime
|
|
today = datetime.date.today().strftime("%Y%m%d")
|
|
existing = _read_existing_serial(path)
|
|
if existing and existing.startswith(today):
|
|
counter = int(existing[8:]) + 1
|
|
return f"{today}{counter:02d}"
|
|
return f"{today}01"
|
|
|
|
|
|
def write_zone_file(zone: dict, zones_dir: str) -> Path:
|
|
"""Write zone file, preserving/incrementing serial."""
|
|
name = zone["zone"]
|
|
path = Path(zones_dir) / f"db.{name}"
|
|
|
|
# Compute correct serial
|
|
serial = _compute_serial(path)
|
|
|
|
# Build content with correct serial
|
|
content = build_zone_content(zone)
|
|
# Replace the placeholder serial
|
|
content = re.sub(r'(\d{10})\s*;\s*Serial', f"{serial}\t\t\t; Serial", content)
|
|
|
|
Path(zones_dir).mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|
|
log.info(f"Zone file written: {path} (serial {serial})")
|
|
return path
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# named.conf.local management
|
|
# ──────────────────────────────────────────────
|
|
|
|
def ensure_zone_in_named_conf(zone_name: str, zone_file: Path, named_conf: str):
|
|
path = Path(named_conf)
|
|
if not path.exists():
|
|
path.write_text("")
|
|
content = path.read_text()
|
|
if f'zone "{zone_name}"' in content:
|
|
return # already present
|
|
entry = f'\nzone "{zone_name}" {{\n type master;\n file "{zone_file}";\n}};\n'
|
|
path.write_text(content + entry)
|
|
log.info(f"Added zone '{zone_name}' to {named_conf}")
|
|
|
|
|
|
def remove_zone_from_named_conf(zone_name: str, named_conf: str):
|
|
path = Path(named_conf)
|
|
if not path.exists():
|
|
return
|
|
content = path.read_text()
|
|
pattern = rf'\nzone\s+"{re.escape(zone_name)}"\s*\{{[^}}]+\}};\n'
|
|
new = re.sub(pattern, "", content, flags=re.DOTALL)
|
|
if new != content:
|
|
path.write_text(new)
|
|
log.info(f"Removed zone '{zone_name}' from {named_conf}")
|
|
|
|
|
|
def rndc(cmd: str, zone: str, rndc_cmd: str):
|
|
try:
|
|
result = subprocess.run(
|
|
[rndc_cmd, cmd, zone],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if result.returncode != 0:
|
|
log.warning(f"rndc {cmd} {zone}: {result.stderr.strip()}")
|
|
else:
|
|
log.info(f"rndc {cmd} {zone}: OK")
|
|
except FileNotFoundError:
|
|
log.warning(f"rndc not found at '{rndc_cmd}' — zone changes written but not reloaded")
|
|
except Exception as e:
|
|
log.warning(f"rndc error: {e}")
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# Command handlers
|
|
# ──────────────────────────────────────────────
|
|
|
|
def check_zone_file(zone_name: str, path: Path, checkzone_cmd: str) -> tuple[bool, str]:
|
|
"""
|
|
Valide un fichier de zone avec named-checkzone.
|
|
Retourne (ok, message).
|
|
Traite les warnings critiques (MX→IP, CNAME apex) comme des erreurs.
|
|
"""
|
|
FATAL_WARNINGS = [
|
|
"MX is an address", # MX pointant vers une IP
|
|
"is a CNAME and is also referred to by a NS/MX record",
|
|
"CNAME and other data", # CNAME coexistant avec d'autres records
|
|
"has no address records", # hostname sans A/AAAA (si strict)
|
|
]
|
|
try:
|
|
result = subprocess.run(
|
|
[checkzone_cmd, zone_name, str(path)],
|
|
capture_output=True, text=True, timeout=15
|
|
)
|
|
output = (result.stdout + result.stderr).strip()
|
|
|
|
if result.returncode != 0:
|
|
log.error(f"named-checkzone {zone_name}: FAILED\n{output}")
|
|
return False, output
|
|
|
|
# Vérifier les warnings fatals même si returncode == 0
|
|
fatal = [line for line in output.splitlines()
|
|
if any(w in line for w in FATAL_WARNINGS)]
|
|
if fatal:
|
|
msg = f"Zone invalide (warnings critiques) :\n" + "\n".join(fatal)
|
|
log.error(f"named-checkzone {zone_name}: {msg}")
|
|
return False, msg
|
|
|
|
log.info(f"named-checkzone {zone_name}: OK")
|
|
return True, "OK"
|
|
|
|
except FileNotFoundError:
|
|
log.warning(f"named-checkzone non trouvé à '{checkzone_cmd}' — validation ignorée")
|
|
return True, "named-checkzone non disponible"
|
|
except Exception as e:
|
|
log.error(f"named-checkzone error: {e}")
|
|
return False, str(e)
|
|
|
|
|
|
def snapshot_zone(zone_name: str, zones_dir: str) -> Path | None:
|
|
"""
|
|
Sauvegarde le fichier de zone actuel dans zones_dir/history/
|
|
avant chaque modification. Retourne le chemin du snapshot ou None.
|
|
"""
|
|
from datetime import datetime
|
|
src = Path(zones_dir) / f"db.{zone_name}"
|
|
if not src.exists():
|
|
return None
|
|
history_dir = Path(zones_dir) / "history"
|
|
history_dir.mkdir(parents=True, exist_ok=True)
|
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
snapshot = history_dir / f"db.{zone_name}.{ts}"
|
|
snapshot.write_text(src.read_text())
|
|
log.info(f"Snapshot zone '{zone_name}' → {snapshot.name}")
|
|
# Purge: garder seulement les 20 derniers snapshots par zone
|
|
all_snaps = sorted(history_dir.glob(f"db.{zone_name}.*"))
|
|
for old in all_snaps[:-20]:
|
|
old.unlink()
|
|
log.debug(f"Purged old snapshot: {old.name}")
|
|
return snapshot
|
|
|
|
|
|
def rollback_zone(zone_name: str, zones_dir: str, snapshot_name: str | None = None) -> tuple[bool, str]:
|
|
"""
|
|
Restaure un snapshot. Si snapshot_name est None, prend le plus récent.
|
|
Retourne (ok, message).
|
|
"""
|
|
history_dir = Path(zones_dir) / "history"
|
|
if snapshot_name:
|
|
snap = history_dir / snapshot_name
|
|
if not snap.exists():
|
|
return False, f"Snapshot '{snapshot_name}' introuvable"
|
|
else:
|
|
snaps = sorted(history_dir.glob(f"db.{zone_name}.*"))
|
|
if not snaps:
|
|
return False, f"Aucun snapshot disponible pour '{zone_name}'"
|
|
snap = snaps[-1]
|
|
|
|
dest = Path(zones_dir) / f"db.{zone_name}"
|
|
dest.write_text(snap.read_text())
|
|
log.info(f"Rollback '{zone_name}' ← {snap.name}")
|
|
return True, snap.name
|
|
|
|
|
|
|
|
def handle_apply_zone(payload: dict, cfg: dict) -> dict:
|
|
try:
|
|
zone_name = payload["zone"]
|
|
zones_dir = cfg["zones_dir"]
|
|
checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone")
|
|
|
|
# 1. Snapshot de la version actuelle avant toute modification
|
|
snap = snapshot_zone(zone_name, zones_dir)
|
|
|
|
# 2. Écriture du nouveau fichier de zone
|
|
path = write_zone_file(payload, zones_dir)
|
|
ensure_zone_in_named_conf(zone_name, path, cfg["named_conf_local"])
|
|
|
|
# 3. Validation avec named-checkzone
|
|
ok, msg = check_zone_file(zone_name, path, checkzone_cmd)
|
|
if not ok:
|
|
# Restaurer le snapshot automatiquement
|
|
if snap:
|
|
path.write_text(snap.read_text())
|
|
log.warning(f"Zone '{zone_name}' restaurée depuis {snap.name} (checkzone KO)")
|
|
return {
|
|
"success": False,
|
|
"error": f"Fichier de zone invalide — rndc reload annulé.\n{msg}",
|
|
"checkzone_output": msg,
|
|
"snapshot_restored": snap.name if snap else None,
|
|
}
|
|
|
|
# 4. Reload BIND seulement si le fichier est valide
|
|
rndc("reload", zone_name, cfg["rndc_cmd"])
|
|
return {
|
|
"success": True,
|
|
"snapshot": snap.name if snap else None,
|
|
"serial": payload.get("serial"),
|
|
}
|
|
except Exception as e:
|
|
log.error(f"apply_zone error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def handle_delete_zone(payload: dict, cfg: dict) -> dict:
|
|
try:
|
|
zone_name = payload["zone"]
|
|
remove_zone_from_named_conf(zone_name, cfg["named_conf_local"])
|
|
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
|
|
if path.exists():
|
|
path.unlink()
|
|
# Archive DNSSEC keys for this zone
|
|
key_dir = cfg.get("key_dir", "/etc/bind/keys")
|
|
_archive_zone_keys(zone_name, key_dir)
|
|
try:
|
|
subprocess.run([cfg["rndc_cmd"], "reconfig"], capture_output=True, timeout=10)
|
|
except Exception:
|
|
pass
|
|
log.info(f"Zone '{zone_name}' deleted")
|
|
return {"success": True}
|
|
except Exception as e:
|
|
log.error(f"delete_zone error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def handle_get_zone_raw(payload: dict, cfg: dict) -> dict:
|
|
try:
|
|
zone_name = payload["zone"]
|
|
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
|
|
if not path.exists():
|
|
return {"success": False, "error": f"Zone file not found: {path}"}
|
|
return {"success": True, "content": path.read_text(), "path": str(path)}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def handle_save_zone_raw(payload: dict, cfg: dict) -> dict:
|
|
try:
|
|
zone_name = payload["zone"]
|
|
content = payload["content"]
|
|
zones_dir = cfg["zones_dir"]
|
|
checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone")
|
|
path = Path(zones_dir) / f"db.{zone_name}"
|
|
|
|
# 1. Snapshot avant modification
|
|
snap = snapshot_zone(zone_name, zones_dir)
|
|
|
|
# 2. Écriture du contenu dans un fichier temporaire pour validation
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(content)
|
|
|
|
# 3. Validation avec named-checkzone sur le fichier temporaire
|
|
ok, msg = check_zone_file(zone_name, tmp, checkzone_cmd)
|
|
if not ok:
|
|
tmp.unlink(missing_ok=True)
|
|
return {
|
|
"success": False,
|
|
"error": f"Fichier de zone invalide :\n{msg}",
|
|
"checkzone_output": msg,
|
|
}
|
|
|
|
# 4. Remplacement du fichier réel seulement si valide
|
|
tmp.rename(path)
|
|
rndc("reload", zone_name, cfg["rndc_cmd"])
|
|
return {
|
|
"success": True,
|
|
"content": path.read_text(),
|
|
"snapshot": snap.name if snap else None,
|
|
}
|
|
except Exception as e:
|
|
log.error(f"save_zone_raw error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
|
|
def handle_get_state(payload: dict, cfg: dict) -> dict:
|
|
"""Read all zones from named.conf.local + their zone files via dnspython.
|
|
|
|
Returns the full authoritative state so the backend can sync its SQLite.
|
|
Payload:
|
|
{} → all zones
|
|
{"zone": "example.com"} → one zone only
|
|
|
|
Response:
|
|
{
|
|
"success": true,
|
|
"zones": [
|
|
{
|
|
"name": "example.com",
|
|
"admin": "hostmaster.example.com",
|
|
"ttl": 3600,
|
|
"refresh": 43200,
|
|
"retry": 3600,
|
|
"expire": 2419200,
|
|
"negative_ttl": 3600,
|
|
"records": [
|
|
{"name":"www","type":"A","ttl":null,"value":"1.2.3.4","priority":null},
|
|
...
|
|
]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
import dns.zone as dz
|
|
import dns.rdatatype
|
|
|
|
filter_zone = payload.get("zone")
|
|
zones_dir = cfg["zones_dir"]
|
|
named_conf = cfg["named_conf_local"]
|
|
|
|
# Read zone names from named.conf.local
|
|
all_zone_names = []
|
|
try:
|
|
nc_text = Path(named_conf).read_text()
|
|
all_zone_names = re.findall(r'zone\s+"([^"]+)"', nc_text)
|
|
except Exception as e:
|
|
return {"success": False, "error": f"Cannot read {named_conf}: {e}"}
|
|
|
|
if filter_zone:
|
|
all_zone_names = [z for z in all_zone_names if z == filter_zone]
|
|
|
|
result = []
|
|
for zone_name in all_zone_names:
|
|
path = Path(zones_dir) / f"db.{zone_name}"
|
|
if not path.exists():
|
|
log.warning(f"Zone file missing for {zone_name}: {path}")
|
|
continue
|
|
|
|
try:
|
|
z = dz.from_file(str(path), origin=zone_name, check_origin=False,
|
|
relativize=False)
|
|
except Exception as e:
|
|
log.warning(f"Cannot parse {path}: {e}")
|
|
continue
|
|
|
|
# Read $TTL and SOA fields
|
|
ttl = 3600
|
|
admin = "hostmaster"
|
|
refresh = 43200
|
|
retry = 3600
|
|
expire = 2419200
|
|
negative_ttl = 3600
|
|
|
|
raw_lines = path.read_text().splitlines()
|
|
in_soa = False
|
|
soa_nums = []
|
|
for line in raw_lines:
|
|
stripped = line.strip().split(";")[0].strip()
|
|
upper = stripped.upper()
|
|
if re.match(r'^\$TTL\s+', stripped, re.IGNORECASE):
|
|
try:
|
|
ttl = int(stripped.split()[1])
|
|
except (IndexError, ValueError):
|
|
pass
|
|
if "SOA" in upper and not in_soa:
|
|
in_soa = True
|
|
parts = stripped.split()
|
|
idx = next((i for i,p in enumerate(parts) if p.upper()=="SOA"), None)
|
|
if idx is not None and idx + 2 < len(parts):
|
|
admin = parts[idx+2].rstrip(".")
|
|
for token in (parts[idx+3:] if idx else []):
|
|
if token in ("(",")"): continue
|
|
try: soa_nums.append(int(token))
|
|
except ValueError: pass
|
|
elif in_soa:
|
|
for token in stripped.split():
|
|
if token == ")": in_soa = False; break
|
|
try: soa_nums.append(int(token))
|
|
except ValueError: pass
|
|
if not in_soa and soa_nums:
|
|
break
|
|
|
|
if len(soa_nums) >= 5:
|
|
refresh = soa_nums[1]
|
|
retry = soa_nums[2]
|
|
expire = soa_nums[3]
|
|
negative_ttl = soa_nums[4]
|
|
|
|
origin_suffix = f".{zone_name}."
|
|
# Parse records (skip SOA)
|
|
records = []
|
|
for name_obj, node in z.nodes.items():
|
|
# Keep full FQDN — normalize apex zone name to @
|
|
name_str = str(name_obj)
|
|
if name_str == f"{zone_name}.":
|
|
name_str = "@"
|
|
# else: keep full FQDN (e.g. wapt.infolix.fr.)
|
|
for rdataset in node.rdatasets:
|
|
rdtype = dns.rdatatype.to_text(rdataset.rdtype)
|
|
if rdtype == "SOA":
|
|
continue
|
|
rec_ttl = rdataset.ttl if rdataset.ttl != ttl else None
|
|
for rdata in rdataset:
|
|
value = rdata.to_text()
|
|
priority = None
|
|
if rdtype == "MX":
|
|
parts = value.split(None, 1)
|
|
priority = int(parts[0])
|
|
value = parts[1] if len(parts) > 1 else ""
|
|
elif rdtype == "SRV":
|
|
parts = value.split(None, 3)
|
|
priority = int(parts[0]) if parts else None
|
|
records.append({
|
|
"name": name_str,
|
|
"type": rdtype,
|
|
"ttl": rec_ttl,
|
|
"value": value,
|
|
"priority": priority,
|
|
"active": True,
|
|
})
|
|
|
|
# Parse disabled records from comments: "; [DISABLED] name ttl IN type value"
|
|
disabled_re = re.compile(
|
|
r'^;\s*\[DISABLED\]\s+(\S+)\s+(?:(\d+)\s+)?IN\s+(\w+)\s+(.*)', re.IGNORECASE
|
|
)
|
|
for line in raw_lines:
|
|
m = disabled_re.match(line.strip())
|
|
if not m:
|
|
continue
|
|
rname, rec_ttl_str, rdtype, rvalue = m.groups()
|
|
rname = rname if rname != "@" else ""
|
|
rdtype = rdtype.upper()
|
|
rec_ttl = int(rec_ttl_str) if rec_ttl_str else None
|
|
priority = None
|
|
if rdtype == "MX":
|
|
parts = rvalue.split(None, 1)
|
|
if len(parts) == 2:
|
|
try: priority = int(parts[0]); rvalue = parts[1]
|
|
except ValueError: pass
|
|
records.append({
|
|
"name": rname,
|
|
"type": rdtype,
|
|
"ttl": rec_ttl,
|
|
"value": rvalue.strip(),
|
|
"priority": priority,
|
|
"active": False,
|
|
})
|
|
|
|
result.append({
|
|
"name": zone_name,
|
|
"admin": admin,
|
|
"ttl": ttl,
|
|
"refresh": refresh,
|
|
"retry": retry,
|
|
"expire": expire,
|
|
"negative_ttl": negative_ttl,
|
|
"records": records,
|
|
})
|
|
log.info(f"get_state: parsed {zone_name} ({len(records)} records)")
|
|
|
|
return {"success": True, "zones": result}
|
|
|
|
def handle_reload_zone(payload: dict, cfg: dict) -> dict:
|
|
"""Force rndc reload on a zone without rewriting the file."""
|
|
try:
|
|
zone_name = payload["zone"]
|
|
rndc("reload", zone_name, cfg["rndc_cmd"])
|
|
log.info(f"Zone '{zone_name}' reloaded via rndc")
|
|
return {"success": True}
|
|
except Exception as e:
|
|
log.error(f"reload_zone error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
|
|
def _update_zone_dnssec_in_named_conf(zone_name: str, named_conf: str,
|
|
dnssec_policy: str, enable: bool,
|
|
key_dir: str = "/etc/bind/keys") -> bool:
|
|
"""
|
|
Add or remove dnssec-policy + inline-signing in the zone block.
|
|
Returns True if the file was modified.
|
|
"""
|
|
path = Path(named_conf)
|
|
if not path.exists():
|
|
return False
|
|
|
|
content = path.read_text()
|
|
# Find the zone block
|
|
import re
|
|
pattern = re.compile(
|
|
rf'(zone\s+"{re.escape(zone_name)}"\s*\{{)([^}}]+)(\}};)',
|
|
re.DOTALL
|
|
)
|
|
m = pattern.search(content)
|
|
if not m:
|
|
log.warning(f"Zone '{zone_name}' not found in {named_conf}")
|
|
return False
|
|
|
|
zone_block_inner = m.group(2)
|
|
|
|
if enable:
|
|
# Add dnssec directives if not already present
|
|
if "dnssec-policy" in zone_block_inner:
|
|
return False # already configured
|
|
extra = (
|
|
f'\n dnssec-policy "{dnssec_policy}";'
|
|
f'\n key-directory "{key_dir}";'
|
|
)
|
|
new_inner = zone_block_inner.rstrip() + extra + "\n"
|
|
else:
|
|
# Remove dnssec directives
|
|
new_inner = re.sub(r'\n\s*dnssec-policy[^;]+;', '', zone_block_inner)
|
|
new_inner = re.sub(r'\n\s*inline-signing[^;]+;', '', new_inner)
|
|
new_inner = re.sub(r'\n\s*key-directory[^;]+;', '', new_inner)
|
|
if new_inner == zone_block_inner:
|
|
return False # nothing to remove
|
|
|
|
new_block = m.group(1) + new_inner + m.group(3)
|
|
path.write_text(content[:m.start()] + new_block + content[m.end():])
|
|
log.info(f"{'Enabled' if enable else 'Disabled'} DNSSEC for zone '{zone_name}' in {named_conf}")
|
|
return True
|
|
|
|
|
|
def _get_ds_records(zone_name: str, key_dir: str, rndc_cmd: str = "rndc") -> list:
|
|
"""
|
|
Extract DS records for a zone. Tries multiple strategies:
|
|
1. rndc dnssec -status <zone> — extracts DS from BIND's key state (BIND 9.17+)
|
|
2. dig DS @127.0.0.1 <zone> — query the live signed zone
|
|
3. dnssec-dsfromkey on .key files in key_dir — fallback for older setups
|
|
"""
|
|
ds_records = []
|
|
|
|
# Strategy 1: rndc dnssec -status (BIND 9.17+ with dnssec-policy)
|
|
try:
|
|
r = subprocess.run(
|
|
[rndc_cmd, "dnssec", "-status", zone_name],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode == 0:
|
|
import re
|
|
# Extract lines like "DS: 12345 8 2 ABCDEF..."
|
|
for line in r.stdout.splitlines():
|
|
line = line.strip()
|
|
if re.match(r"DS:", line):
|
|
# Convert to full DS RR format
|
|
ds_val = line[3:].strip()
|
|
ds_records.append(f"{zone_name}. 0 IN DS {ds_val}")
|
|
if ds_records:
|
|
log.info(f"DS records from rndc dnssec -status: {len(ds_records)}")
|
|
return ds_records
|
|
except Exception as e:
|
|
log.debug(f"rndc dnssec -status failed: {e}")
|
|
|
|
# Strategy 2: dig DS @127.0.0.1 (zone must be signed and answering)
|
|
try:
|
|
r = subprocess.run(
|
|
["dig", "+noall", "+answer", f"@127.0.0.1", "DS", zone_name],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode == 0 and "DS" in r.stdout:
|
|
ds_lines = [l.strip() for l in r.stdout.splitlines()
|
|
if "DS" in l and not l.startswith(";")]
|
|
if ds_lines:
|
|
log.info(f"DS records from dig: {len(ds_lines)}")
|
|
return ds_lines
|
|
except FileNotFoundError:
|
|
log.debug("dig not available")
|
|
except Exception as e:
|
|
log.debug(f"dig DS failed: {e}")
|
|
|
|
# Strategy 3: scan key files and run dnssec-dsfromkey
|
|
key_path = Path(key_dir)
|
|
search_dirs = []
|
|
for d in [key_path, Path("/etc/bind/keys"), Path("/var/cache/bind")]:
|
|
if d.exists() and d not in search_dirs:
|
|
search_dirs.append(d)
|
|
|
|
seen_files = set()
|
|
for search_dir in search_dirs:
|
|
if not search_dir.exists():
|
|
continue
|
|
# glob only in the directory itself — never recurse into old/
|
|
for key_file in search_dir.glob(f"K{zone_name}.+*.key"):
|
|
if key_file in seen_files:
|
|
continue
|
|
seen_files.add(key_file)
|
|
try:
|
|
key_content = key_file.read_text()
|
|
# KSK = flags 257 (SEP bit set), ZSK = flags 256
|
|
if " 257 " not in key_content:
|
|
continue
|
|
r = subprocess.run(
|
|
["dnssec-dsfromkey", str(key_file)],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
for line in r.stdout.strip().splitlines():
|
|
if line not in ds_records:
|
|
ds_records.append(line)
|
|
log.info(f"DS from dnssec-dsfromkey: {key_file}")
|
|
except Exception as e:
|
|
log.warning(f"dnssec-dsfromkey error for {key_file}: {e}")
|
|
|
|
return ds_records
|
|
|
|
|
|
def _archive_zone_keys(zone_name: str, key_dir: str):
|
|
"""
|
|
Move existing DNSSEC key files for zone_name into key_dir/old/YYYYMMDD-HHMMSS/.
|
|
Called before enabling DNSSEC (to avoid duplicate keys) and on zone deletion.
|
|
"""
|
|
from datetime import datetime
|
|
key_path = Path(key_dir)
|
|
if not key_path.exists():
|
|
return
|
|
# Find all key files for this zone
|
|
files = list(key_path.glob(f"K{zone_name}.+*"))
|
|
if not files:
|
|
return
|
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
old_dir = key_path / "old" / f"{ts}_{zone_name}"
|
|
old_dir.mkdir(parents=True, exist_ok=True)
|
|
for f in files:
|
|
dest = old_dir / f.name
|
|
f.rename(dest)
|
|
log.info(f"Archived key {f.name} → {dest}")
|
|
log.info(f"Archived {len(files)} key(s) for '{zone_name}' to {old_dir}")
|
|
|
|
|
|
def handle_enable_dnssec(payload: dict, cfg: dict) -> dict:
|
|
"""
|
|
Enable or disable DNSSEC for a zone in named.conf.local.
|
|
|
|
key_dir is read exclusively from the agent config (cfg["key_dir"]).
|
|
The payload only controls: zone, action, dnssec_policy.
|
|
|
|
Actions:
|
|
enable — add dnssec-policy + key-directory to zone block, rndc reconfig
|
|
disable — remove dnssec directives, rndc reconfig
|
|
get_ds — return current DS records without modifying anything
|
|
|
|
Zone block result in named.conf.local:
|
|
zone "example.com" {
|
|
type primary;
|
|
file "/etc/bind/zones/db.example.com";
|
|
dnssec-policy "default";
|
|
key-directory "/etc/bind/keys";
|
|
};
|
|
"""
|
|
zone_name = payload["zone"]
|
|
action = payload.get("action", "enable")
|
|
dnssec_policy = payload.get("dnssec_policy") or cfg.get("dnssec_policy", "default")
|
|
named_conf = cfg.get("named_conf_local", "/etc/bind/named.conf.local")
|
|
key_dir = cfg.get("key_dir", "/etc/bind/keys")
|
|
rndc_cmd = cfg.get("rndc_cmd", "rndc")
|
|
|
|
try:
|
|
# Ensure key directory exists and has correct ownership for BIND
|
|
key_path = Path(key_dir)
|
|
key_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ── get_ds: no modification, just return current DS records ──────
|
|
if action == "get_ds":
|
|
ds = _get_ds_records(zone_name, key_dir, rndc_cmd)
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"action": "get_ds",
|
|
"ds_records": ds,
|
|
"key_dir": key_dir,
|
|
}
|
|
|
|
enable = (action != "disable")
|
|
|
|
# ── Modify named.conf.local zone block ────────────────────────────
|
|
modified = _update_zone_dnssec_in_named_conf(
|
|
zone_name, named_conf, dnssec_policy, enable, key_dir
|
|
)
|
|
|
|
if not modified:
|
|
if enable:
|
|
# Zone already has dnssec-policy — just return DS records
|
|
log.info(f"Zone '{zone_name}' already has DNSSEC configured")
|
|
ds = _get_ds_records(zone_name, key_dir, rndc_cmd)
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"action": action,
|
|
"already_configured": True,
|
|
"ds_records": ds,
|
|
"key_dir": key_dir,
|
|
}
|
|
else:
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"action": "disable",
|
|
"message": f"Zone '{zone_name}' avait déjà DNSSEC désactivé.",
|
|
}
|
|
|
|
log.info(f"named.conf.local updated for zone '{zone_name}' action={action}")
|
|
|
|
if not enable:
|
|
# ── Disable ───────────────────────────────────────────────────
|
|
subprocess.run([rndc_cmd, "reconfig"], capture_output=True, timeout=15)
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"action": "disable",
|
|
"message": (
|
|
f"DNSSEC désactivé pour '{zone_name}'. "
|
|
f"Pensez à supprimer les enregistrements DS chez votre registrar."
|
|
),
|
|
}
|
|
|
|
# ── Enable ────────────────────────────────────────────────────────
|
|
# Archive any existing keys to avoid BIND generating duplicates
|
|
_archive_zone_keys(zone_name, key_dir)
|
|
|
|
# Ensure key_dir exists AND is owned by bind so BIND can write keys.
|
|
# BIND with dnssec-policy generates the keys automatically on reconfig,
|
|
# but ONLY if the key-directory is writable by the bind process.
|
|
try:
|
|
import shutil, pwd
|
|
bind_uid = pwd.getpwnam("bind").pw_uid
|
|
bind_gid = pwd.getpwnam("bind").pw_gid
|
|
key_path.mkdir(parents=True, exist_ok=True)
|
|
os.chown(key_dir, bind_uid, bind_gid)
|
|
import stat
|
|
key_path.chmod(key_path.stat().st_mode | stat.S_IRWXG)
|
|
log.info(f"key_dir {key_dir} owned by bind:bind with group rwx")
|
|
except (KeyError, PermissionError, AttributeError) as e:
|
|
log.warning(
|
|
f"Could not chown {key_dir} to bind: {e}. "
|
|
f"Make sure {key_dir} is writable by the bind user, "
|
|
f"or run the agent as root/with sudo."
|
|
)
|
|
|
|
# rndc reconfig — BIND picks up dnssec-policy and generates keys itself
|
|
rc = subprocess.run(
|
|
[rndc_cmd, "reconfig"],
|
|
capture_output=True, text=True, timeout=15
|
|
)
|
|
if rc.returncode == 0:
|
|
log.info("rndc reconfig OK — BIND will generate keys and sign the zone")
|
|
else:
|
|
log.warning(f"rndc reconfig {rc.returncode}: {rc.stderr.strip()}")
|
|
|
|
# Wait for BIND to generate keys and produce DS records
|
|
import time
|
|
ds_records = []
|
|
for wait_s in [3, 5, 8, 10]:
|
|
time.sleep(wait_s)
|
|
ds_records = _get_ds_records(zone_name, key_dir, rndc_cmd)
|
|
if ds_records:
|
|
log.info(f"DS records available after ~{wait_s}s")
|
|
break
|
|
log.debug(f"Waiting for BIND to generate keys for '{zone_name}'...")
|
|
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"action": "enable",
|
|
"ds_records": ds_records,
|
|
"key_dir": key_dir,
|
|
"message": (
|
|
f"DNSSEC activé pour '{zone_name}'. "
|
|
+ (f"Publiez les {len(ds_records)} enregistrement(s) DS chez votre registrar."
|
|
if ds_records
|
|
else
|
|
"BIND génère les clés — relancez 'Récupérer les DS' dans quelques secondes.")
|
|
),
|
|
}
|
|
|
|
except FileNotFoundError as e:
|
|
return {"success": False,
|
|
"error": f"Commande introuvable : {e}. BIND9 installé et dans le PATH ?"}
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Timeout lors de rndc reconfig"}
|
|
except Exception as e:
|
|
log.error(f"handle_enable_dnssec error for {zone_name}: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def handle_rollback_zone(payload: dict, cfg: dict) -> dict:
|
|
"""
|
|
Restaure un snapshot de zone et recharge BIND.
|
|
Payload: { "zone": "example.fr", "snapshot": "db.example.fr.20260501-143022" (optionnel) }
|
|
Si snapshot absent, restaure le plus récent.
|
|
"""
|
|
try:
|
|
zone_name = payload["zone"]
|
|
zones_dir = cfg["zones_dir"]
|
|
snapshot_name = payload.get("snapshot")
|
|
checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone")
|
|
|
|
ok, info = rollback_zone(zone_name, zones_dir, snapshot_name)
|
|
if not ok:
|
|
return {"success": False, "error": info}
|
|
|
|
path = Path(zones_dir) / f"db.{zone_name}"
|
|
valid, msg = check_zone_file(zone_name, path, checkzone_cmd)
|
|
if not valid:
|
|
return {"success": False, "error": f"Snapshot invalide : {msg}"}
|
|
|
|
rndc("reload", zone_name, cfg["rndc_cmd"])
|
|
log.info(f"Rollback zone '{zone_name}' réussi ← {info}")
|
|
return {"success": True, "restored_snapshot": info}
|
|
except Exception as e:
|
|
log.error(f"rollback_zone error: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def handle_get_snapshot(payload: dict, cfg: dict) -> dict:
|
|
"""Retourne le contenu d'un snapshot de zone."""
|
|
try:
|
|
zone_name = payload["zone"]
|
|
snapshot_name = payload["snapshot"]
|
|
snap = Path(cfg["zones_dir"]) / "history" / snapshot_name
|
|
if not snap.exists():
|
|
return {"success": False, "error": f"Snapshot '{snapshot_name}' introuvable"}
|
|
return {"success": True, "content": snap.read_text()}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
|
|
def handle_list_snapshots(payload: dict, cfg: dict) -> dict:
|
|
"""Liste les snapshots disponibles pour une zone."""
|
|
try:
|
|
zone_name = payload["zone"]
|
|
history_dir = Path(cfg["zones_dir"]) / "history"
|
|
if not history_dir.exists():
|
|
return {"success": True, "zone": zone_name, "snapshots": []}
|
|
snaps = sorted(history_dir.glob(f"db.{zone_name}.*"), reverse=True)
|
|
return {
|
|
"success": True,
|
|
"zone": zone_name,
|
|
"snapshots": [s.name for s in snaps],
|
|
}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
HANDLERS = {
|
|
"apply_zone": handle_apply_zone,
|
|
"rollback_zone": handle_rollback_zone,
|
|
"list_snapshots": handle_list_snapshots,
|
|
"get_snapshot": handle_get_snapshot,
|
|
"reload_zone": handle_reload_zone,
|
|
"delete_zone": handle_delete_zone,
|
|
"get_zone_raw": handle_get_zone_raw,
|
|
"get_state": handle_get_state,
|
|
"save_zone_raw": handle_save_zone_raw,
|
|
"enable_dnssec": handle_enable_dnssec,
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# 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
|
|
# ──────────────────────────────────────────────
|
|
|
|
def run(cfg: dict):
|
|
redis_kwargs = {
|
|
"host": cfg["redis_host"],
|
|
"port": int(cfg["redis_port"]),
|
|
"db": int(cfg["redis_db"]),
|
|
"decode_responses": True,
|
|
"socket_connect_timeout": 10, # timeout connexion initiale
|
|
"socket_timeout": 30, # timeout opérations
|
|
"socket_keepalive": True, # keepalive TCP
|
|
}
|
|
if cfg.get("redis_password"):
|
|
redis_kwargs["password"] = cfg["redis_password"]
|
|
tls_cfg = cfg.get("tls", {})
|
|
if tls_cfg.get("enabled", False):
|
|
redis_kwargs["ssl"] = True
|
|
redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"]
|
|
redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"]
|
|
redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"]
|
|
redis_kwargs["ssl_cert_reqs"] = "required"
|
|
log.info("Redis mTLS enabled")
|
|
else:
|
|
log.warning("Redis TLS disabled — traffic is unencrypted")
|
|
|
|
r = redis.Redis(**redis_kwargs)
|
|
r.ping()
|
|
log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}")
|
|
|
|
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'")
|
|
|
|
for message in pubsub.listen():
|
|
if message["type"] != "message":
|
|
continue
|
|
try:
|
|
msg = json.loads(message["data"])
|
|
except (json.JSONDecodeError, KeyError):
|
|
log.warning("Malformed message received, ignored")
|
|
continue
|
|
|
|
# mTLS: authentication is handled by Redis TLS layer (tls-auth-clients yes).
|
|
# No token check needed — only clients with a valid CA-signed cert can connect.
|
|
|
|
|
|
msg_id = msg.get("id", "unknown")
|
|
action = msg.get("action", "")
|
|
payload = msg.get("payload", {})
|
|
|
|
log.info(f"Received action '{action}' (id={msg_id})")
|
|
|
|
handler = HANDLERS.get(action)
|
|
if not handler:
|
|
ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"}
|
|
else:
|
|
ack = handler(payload, cfg)
|
|
ack["id"] = msg_id
|
|
|
|
r.publish("dns.acks", json.dumps(ack))
|
|
log.info(f"ACK sent for '{action}' (id={msg_id}): success={ack.get('success')}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="NetAdmin DNS Agent")
|
|
parser.add_argument("--config", default="/etc/netadmin/config.yaml",
|
|
help="Path to config.yaml")
|
|
args = parser.parse_args()
|
|
|
|
cfg_path = args.config
|
|
if not Path(cfg_path).exists():
|
|
log.error(f"Config file not found: {cfg_path}")
|
|
sys.exit(1)
|
|
|
|
agent_cfg = {**DEFAULT_CFG, **load_config(cfg_path)}
|
|
log.info(f"DNS agent starting (zones_dir={agent_cfg['zones_dir']})")
|
|
|
|
try:
|
|
run(agent_cfg)
|
|
except KeyboardInterrupt:
|
|
log.info("DNS agent stopped")
|
|
except Exception as e:
|
|
import traceback
|
|
log.error(f"Fatal error: {e}\n{traceback.format_exc()}")
|
|
sys.exit(1) |