Files

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)