Files
netadmin/backend/routers/dns.py
T
kguerineau 63eb9a6af0 feat: historisation DNS avec snapshots, diff et rollback
Snapshots
- Snapshot automatique avant chaque modification de zone (apply_zone + save_zone_raw)
- Stockage dans zones_dir/history/ avec timestamp, purge auto à 20 entrées par zone
- named-checkzone exécuté sur fichier temporaire avant remplacement du fichier réel
- Rollback automatique du snapshot précédent si named-checkzone échoue
- Détection des warnings critiques (MX→IP, CNAME apex) même si returncode=0

Nouvelles routes backend
- GET  /dns/zones/{zone}/snapshots            — liste des snapshots
- GET  /dns/zones/{zone}/snapshots/{name}     — contenu d'un snapshot
- POST /dns/zones/{zone}/rollback?snapshot=   — restauration + named-checkzone + rndc reload

Nouveaux handlers agent
- list_snapshots, get_snapshot, rollback_zone

Interface DNSManager
- Bouton ⏱ Historique dans la toolbar de zone
- Modale avec liste des snapshots (date formatée)
- Diff inline ▼/▲ par snapshot : lignes ajoutées (vert) / supprimées (rouge)
- Bouton ↩ Restaurer avec confirmation
- Erreur named-checkzone affichée dans la modale Raw au lieu de l'interface principale
- Validation MX/NS/CNAME/PTR : bloque les valeurs pointant vers une IP

Déploiement
- install.sh (serveur principal) et install-agent.sh [dns|mail] (serveurs distants)
- Config Nginx avec restriction IP, SSL, reverse proxy /api/
- VITE_API_BASE via .env.development / .env.production
- Services systemd pour backend et agents
2026-05-01 22:58:13 +02:00

955 lines
39 KiB
Python

"""
DNS Router — stores zone/record metadata in SQLite, applies via DNS agent through Redis.
The backend never touches zone files or calls rndc directly.
All mutations are:
1. Validated here (Pydantic + semantic checks)
2. Persisted in SQLite
3. Sent to the DNS agent via Redis broker
4. The agent applies changes and sends ACK
"""
import hashlib
import re
import time
import uuid
import ipaddress
import sqlite3
import datetime
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field, field_validator, model_validator
from config import cfg
from broker import publish_dns, publish_or_queue
from routers.auth import get_current_user, get_allowed_dns_domains, require_dns_access
import audit
from main import get_request_ip
router = APIRouter(dependencies=[Depends(get_current_user)])
from db import get_db, _dt, get_or_404
VALID_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"}
RE_DNS_NAME = re.compile(
r'^(@|\*|(\*\.)?([a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.)*'
r'[a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.?)$'
)
# ──────────────────────────────────────────────
# Database
# ──────────────────────────────────────────────
def init_db():
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS zones (
name VARCHAR(253) PRIMARY KEY,
admin VARCHAR(255) NOT NULL DEFAULT 'hostmaster',
ttl INT NOT NULL DEFAULT 3600,
refresh INT NOT NULL DEFAULT 43200,
retry INT NOT NULL DEFAULT 3600,
expire INT NOT NULL DEFAULT 2419200,
negative_ttl INT NOT NULL DEFAULT 3600,
created_at DATETIME NOT NULL DEFAULT NOW(),
last_applied_at DATETIME,
last_apply_ok TINYINT DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS records (
id VARCHAR(36) PRIMARY KEY,
zone VARCHAR(253) NOT NULL,
name VARCHAR(253) NOT NULL,
type VARCHAR(10) NOT NULL,
ttl INT,
value TEXT NOT NULL,
priority INT,
active TINYINT NOT NULL DEFAULT 1,
pending_sync TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT NOW(),
FOREIGN KEY (zone) REFERENCES zones(name) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# MySQL ALTER TABLE — add columns only if missing
for col, col_type in [
("active", "TINYINT NOT NULL DEFAULT 1"),
("pending_sync", "TINYINT NOT NULL DEFAULT 0"),
]:
try:
conn.execute(
"SELECT COUNT(*) AS n as cnt FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='records' AND COLUMN_NAME=%s",
(col,)
)
row = conn.fetchone()
if not row or row.get("cnt", 0) == 0:
conn.execute(f"ALTER TABLE records ADD COLUMN {col} {col_type}")
except Exception:
pass
try:
init_db()
# Migration: add active column if upgrading
with get_db() as _mc:
try:
_mc.execute("ALTER TABLE records ADD COLUMN active INT NOT NULL DEFAULT 1")
except Exception:
pass
try:
_mc.execute("ALTER TABLE records ADD COLUMN pending_sync INT NOT NULL DEFAULT 0")
except Exception:
pass
except Exception as e:
print(f"[WARN] DNS DB init failed: {e}")
# ──────────────────────────────────────────────
# Pydantic models
# ──────────────────────────────────────────────
class ZoneCreate(BaseModel):
name: str = Field(..., example="example.com")
admin: str = Field("hostmaster", example="hostmaster.example.com")
ttl: int = Field(3600, ge=1, le=2147483647)
refresh: int = Field(43200, ge=1, le=2147483647)
retry: int = Field(3600, ge=1, le=2147483647)
expire: int = Field(2419200, ge=1, le=2147483647)
negative_ttl: int = Field(3600, ge=1, le=2147483647)
enable_dnssec: bool = Field(False, description="Sign zone with DNSSEC after creation")
@field_validator("admin")
@classmethod
def no_at(cls, v: str) -> str:
v = v.strip()
if "@" in v:
raise ValueError("L'email admin ne doit pas contenir '@' — utilisez un point")
return v
@field_validator("name")
@classmethod
def validate_zone_name(cls, v: str) -> str:
v = v.strip().rstrip(".")
if not v:
raise ValueError("Le nom de zone ne peut pas être vide")
if len(v) > 253:
raise ValueError("Nom de zone trop long (max 253 caractères)")
labels = v.split(".")
if len(labels) < 2:
raise ValueError("Le nom de zone doit avoir au moins deux labels (ex: example.com)")
for label in labels:
if not label:
raise ValueError(f"Label vide dans le nom de zone '{v}'")
if len(label) > 63:
raise ValueError(f"Label '{label}' too long (max 63 characters)")
if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$', label):
raise ValueError(f"Label invalide '{label}'")
return v
class ZoneConfig(BaseModel):
admin: str = Field("hostmaster")
ttl: int = Field(3600, ge=1, le=2147483647)
refresh: int = Field(43200, ge=1, le=2147483647)
retry: int = Field(3600, ge=1, le=2147483647)
expire: int = Field(2419200, ge=1, le=2147483647)
negative_ttl: int = Field(3600, ge=1, le=2147483647)
@field_validator("admin")
@classmethod
def no_at(cls, v: str) -> str:
if "@" in v:
raise ValueError("L'email admin ne doit pas contenir '@'")
return v.strip()
class ZoneInfo(BaseModel):
name: str
admin: str
ttl: int
refresh: int
retry: int
expire: int
negative_ttl: int
record_count: int = 0
last_apply_ok: Optional[bool] = None
last_applied_at: Optional[str] = None
queued: bool = False
queue_id: Optional[str] = None
dnssec_enabled: Optional[bool] = None # None = unknown
@field_validator("last_applied_at", mode="before")
@classmethod
def _coerce_dt(cls, v):
return _dt(v)
class RecordCreate(BaseModel):
name: str = Field(..., example="@")
type: str = Field(..., example="A")
ttl: Optional[int] = Field(None, ge=0, le=2147483647)
value: str = Field(..., example="192.168.1.1")
priority: Optional[int] = Field(None, ge=0, le=65535)
active: bool = Field(True)
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
v = v.strip().upper()
if v not in VALID_RECORD_TYPES:
raise ValueError(f"Type non supporté '{v}'")
return v
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
return "@"
if not RE_DNS_NAME.match(v):
raise ValueError(f"Nom DNS invalide '{v}'")
return v
@field_validator("value")
@classmethod
def validate_value(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("La valeur ne peut pas être vide")
return v.strip()
@model_validator(mode="after")
def validate_by_type(self) -> "RecordCreate":
t, v = self.type, self.value
if t == "A":
try: ipaddress.IPv4Address(v)
except ValueError: raise ValueError(f"IPv4 invalide : '{v}'")
elif t == "AAAA":
try: ipaddress.IPv6Address(v)
except ValueError: raise ValueError(f"IPv6 invalide : '{v}'")
elif t in ("CNAME", "NS", "PTR"):
if not RE_DNS_NAME.match(v.rstrip(".")):
raise ValueError(f"Nom d'hôte invalide pour {t}: '{v}'")
elif t == "MX":
if self.priority is None:
raise ValueError("MX : une priorité est requise")
if not RE_DNS_NAME.match(v.rstrip(".")):
raise ValueError(f"Nom d'hôte MX invalide : '{v}'")
elif t == "SRV":
if self.priority is None:
raise ValueError("SRV : une priorité est requise")
parts = v.split()
if len(parts) != 3:
raise ValueError("SRV : format attendu '<poids> <port> <cible>'")
try:
w, p = int(parts[0]), int(parts[1])
assert 0 <= w <= 65535 and 0 <= p <= 65535
except (ValueError, AssertionError):
raise ValueError("SRV : poids/port doit être entre 0 et 65535")
elif t == "TXT":
if "\n" in v or "\r" in v:
raise ValueError("TXT ne doit pas contenir de retours à la ligne")
elif t == "TLSA":
parts = v.split(None, 3)
if len(parts) != 4:
raise ValueError("TLSA : format '<usage> <sélecteur> <type> <hex>'")
try:
usage, sel, mt = int(parts[0]), int(parts[1]), int(parts[2])
except ValueError:
raise ValueError("TLSA : usage/sélecteur/type doivent être des entiers")
if usage not in range(4): raise ValueError("TLSA : usage doit être entre 0 et 3")
if sel not in (0, 1): raise ValueError("TLSA : sélecteur doit être 0 ou 1")
if mt not in (0, 1, 2): raise ValueError("TLSA : type de correspondance doit être entre 0 et 2")
if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")):
raise ValueError("TLSA : données certificat en hexadécimal")
elif t == "DS":
parts = v.split(None, 3)
if len(parts) != 4:
raise ValueError("DS : format '<keytag> <algo> <type-digest> <hex>'")
try:
kt, algo, dt = int(parts[0]), int(parts[1]), int(parts[2])
except ValueError:
raise ValueError("DS keytag/algo/digest-type must be integers")
if not (0 <= kt <= 65535): raise ValueError("DS keytag 0-65535")
if algo not in {5,7,8,10,13,14,15,16}: raise ValueError(f"DS unknown algorithm {algo}")
if dt not in (1, 2, 4): raise ValueError("DS digest-type: 1, 2 or 4")
if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")):
raise ValueError("DS digest must be hex")
elif t == "CAA":
parts = v.split(None, 2)
if len(parts) != 3: raise ValueError("CAA : format '<flags> <tag> <valeur>'")
try:
flags = int(parts[0])
assert 0 <= flags <= 255
except (ValueError, AssertionError):
raise ValueError("CAA : flags doit être entre 0 et 255")
if parts[1] not in ("issue", "issuewild", "iodef"):
raise ValueError("CAA : tag doit être issue, issuewild ou iodef")
return self
class RecordResponse(BaseModel):
id: str
zone: str
name: str
type: str
ttl: Optional[int]
value: str
priority: Optional[int] = None
active: bool = True
queued: bool = False
queue_id: Optional[str] = None
pending_sync: bool = False
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
def _zone_to_payload(zone_name: str) -> dict:
"""Build the full zone payload to send to the DNS agent."""
with get_db() as conn:
z = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone()
if not z:
raise HTTPException(404, f"Zone '{zone_name}' not found")
records = conn.execute(
"SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,)
).fetchall()
return {
"zone": z["name"],
"admin": z["admin"],
"ttl": z["ttl"],
"refresh": z["refresh"],
"retry": z["retry"],
"expire": z["expire"],
"negative_ttl": z["negative_ttl"],
"records": [
{
"id": r["id"],
"name": r["name"],
"type": r["type"],
"ttl": r["ttl"],
"value": r["value"],
"priority": r["priority"],
"active": bool(r["active"]), # inactive → commented in zone file
}
for r in records
],
}
def _apply_zone(zone_name: str) -> dict:
"""Send zone to DNS agent. If unreachable, queue for later.
Returns {"queued": bool, "queue_id": str|None}.
Never raises — save always succeeds in SQLite.
"""
payload = _zone_to_payload(zone_name)
result = publish_or_queue("dns", "apply_zone", payload)
queued = result.get("queued", False)
now = datetime.datetime.utcnow().isoformat()
with get_db() as conn:
conn.execute(
"UPDATE zones SET last_applied_at=%s, last_apply_ok=%s WHERE name=%s",
(now, 0 if queued else 1, zone_name)
)
if not queued:
# Agent confirmed — clear pending_sync on all records of this zone
conn.execute(
"UPDATE records SET pending_sync=0 WHERE zone=%s", (zone_name,)
)
return {"queued": queued, "queue_id": result.get("id")}
def _delete_zone_on_agent(zone_name: str) -> dict:
"""Delete zone on agent. If unreachable, queue for later."""
result = publish_or_queue("dns", "delete_zone", {"zone": zone_name})
return {"queued": result.get("queued", False), "queue_id": result.get("id")}
def _row_to_zone(row, record_count: int = 0) -> ZoneInfo:
return ZoneInfo(
name=row["name"], admin=row["admin"], ttl=row["ttl"],
refresh=row["refresh"], retry=row["retry"],
expire=row["expire"], negative_ttl=row["negative_ttl"],
record_count=record_count,
last_apply_ok=bool(row["last_apply_ok"]) if row["last_apply_ok"] is not None else None,
last_applied_at=_dt(row["last_applied_at"]),
)
# ──────────────────────────────────────────────
# Routes — Zones
# ──────────────────────────────────────────────
@router.get("/config")
def get_dns_config():
"""Return public DNS configuration needed by the frontend.
Currently exposes the default NS records added to new zones.
"""
return {
"default_ns": cfg.dns.default_ns or [],
"dnssec_enabled": cfg.dns.dnssec_enabled,
"default_soa_admin": getattr(cfg.dns, "default_soa_admin", "hostmaster"),
}
@router.get("/zones", response_model=List[ZoneInfo])
def list_zones(user: dict = Depends(get_current_user)):
allowed = get_allowed_dns_domains(user)
with get_db() as conn:
rows = conn.execute("SELECT * FROM zones ORDER BY name").fetchall()
result = []
for row in rows:
if allowed is not None and row["name"] not in allowed:
continue
count = conn.execute(
"SELECT COUNT(*) AS n FROM records WHERE zone=%s", (row["name"],)
).fetchone()["n"]
result.append(_row_to_zone(row, count))
return result
@router.post("/zones", response_model=ZoneInfo, status_code=201)
def create_zone(body: ZoneCreate, user: dict = Depends(get_current_user)):
require_dns_access(body.name, user)
with get_db() as conn:
existing = conn.execute("SELECT name FROM zones WHERE name=%s", (body.name,)).fetchone()
if existing:
raise HTTPException(409, f"Zone '{body.name}' already exists")
conn.execute(
"INSERT INTO zones(name,admin,ttl,refresh,retry,expire,negative_ttl) VALUES(%s,%s,%s,%s,%s,%s,%s)",
(body.name, body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl)
)
# Insert default NS records from config
ns_list = cfg.dns.default_ns or []
for ns in ns_list:
ns_val = ns.rstrip(".")
rec_id = __import__("hashlib").sha1(
f"{body.name}:@:NS:{ns_val}".encode()
).hexdigest()[:36]
conn.execute(
"INSERT IGNORE INTO records(id,zone,name,type,ttl,value,active,pending_sync) "
"VALUES(%s,%s,'@','NS',%s,%s,1,1)",
(rec_id, body.name, body.ttl, ns_val)
)
# Apply zone (outside get_db to avoid lock contention)
q = _apply_zone(body.name)
# DNSSEC signing if requested and enabled in config
dnssec_result = None
if body.enable_dnssec and cfg.dns.dnssec_enabled:
try:
dnssec_ack = publish_dns("enable_dnssec", {
"zone": body.name,
"keygen_cmd": cfg.dns.keygen_cmd,
"key_dir": cfg.dns.key_dir,
})
dnssec_result = dnssec_ack
except Exception as e:
dnssec_result = {"success": False, "error": str(e)}
elif body.enable_dnssec and not cfg.dns.dnssec_enabled:
dnssec_result = {"success": False, "error": "DNSSEC non activé dans la configuration (dns.dnssec_enabled: false)"}
with get_db() as conn:
row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone()
count = conn.execute("SELECT COUNT(*) AS n FROM records WHERE zone=%s", (body.name,)).fetchone()["n"]
audit.record("dns", "create_zone", target=f"zone:{body.name}",
detail={"admin": body.admin, "ttl": body.ttl,
"ns_added": len(ns_list), "dnssec": body.enable_dnssec,
"queued": q["queued"]},
user=user, ip=get_request_ip())
z = _row_to_zone(row, count)
z.queued = q["queued"]
z.queue_id = q.get("queue_id")
# Attach DNSSEC result as extra field if applicable
if dnssec_result is not None:
z.__dict__["dnssec"] = dnssec_result
return z
@router.delete("/zones/{zone_name}", status_code=204)
def delete_zone(zone_name: str, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' not found")
conn.execute("DELETE FROM zones WHERE name=%s", (zone_name,))
q = _delete_zone_on_agent(zone_name)
audit.record("dns", "delete_zone", target=f"zone:{zone_name}",
detail={"queued": q["queued"]}, user=user, ip=get_request_ip())
@router.get("/zones/{zone_name}/config")
def get_zone_config(zone_name: str, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
row = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone()
if not row:
raise HTTPException(404, f"Zone '{zone_name}' not found")
return {
"zone": zone_name, "admin": row["admin"], "ttl": row["ttl"],
"refresh": row["refresh"], "retry": row["retry"],
"expire": row["expire"], "negative_ttl": row["negative_ttl"],
}
@router.put("/zones/{zone_name}/config")
def update_zone_config(zone_name: str, body: ZoneConfig, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' not found")
conn.execute(
"UPDATE zones SET admin=%s,ttl=%s,refresh=%s,retry=%s,expire=%s,negative_ttl=%s WHERE name=%s",
(body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl, zone_name)
)
q = _apply_zone(zone_name)
audit.record("dns", "update_zone_config", target=f"zone:{zone_name}",
detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]},
user=user, ip=get_request_ip())
cfg_data = get_zone_config(zone_name, user)
cfg_data["queued"] = q["queued"]
cfg_data["queue_id"] = q.get("queue_id")
return cfg_data
# ──────────────────────────────────────────────
# Routes — Records
# ──────────────────────────────────────────────
@router.get("/zones/{zone_name}/records", response_model=List[RecordResponse])
def list_records(zone_name: str, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' not found")
rows = conn.execute(
"SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,)
).fetchall()
return [RecordResponse(
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
pending_sync=bool(r["pending_sync"]) if r["pending_sync"] is not None else False
) for r in rows]
@router.post("/zones/{zone_name}/records", response_model=RecordResponse, status_code=201)
def create_record(zone_name: str, body: RecordCreate, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' not found")
rec_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO records(id,zone,name,type,ttl,value,priority,active,pending_sync) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,1)",
(rec_id, zone_name, body.name or "@", body.type, body.ttl,
body.value, body.priority, int(body.active))
)
q = _apply_zone(zone_name)
with get_db() as conn:
r = conn.execute("SELECT * FROM records WHERE id=%s", (rec_id,)).fetchone()
audit.record("dns", "create_record", target=f"zone:{zone_name}",
detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]},
user=user, ip=get_request_ip())
return RecordResponse(
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
queued=q["queued"], queue_id=q.get("queue_id"),
pending_sync=q["queued"] # still pending if queued
)
@router.put("/zones/{zone_name}/records/{record_id}", response_model=RecordResponse)
def update_record(zone_name: str, record_id: str, body: RecordCreate,
user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone():
raise HTTPException(404, "Enregistrement introuvable")
conn.execute(
"UPDATE records SET name=%s,type=%s,ttl=%s,value=%s,priority=%s,active=%s,pending_sync=1 WHERE id=%s",
(body.name or "@", body.type, body.ttl, body.value, body.priority,
int(body.active), record_id)
)
q = _apply_zone(zone_name)
with get_db() as conn:
r = conn.execute("SELECT * FROM records WHERE id=%s", (record_id,)).fetchone()
audit.record("dns", "update_record", target=f"zone:{zone_name}/record:{record_id}",
detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]},
user=user, ip=get_request_ip())
return RecordResponse(
id=r["id"], zone=r["zone"], name=r["name"], type=r["type"],
ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]),
queued=q["queued"], queue_id=q.get("queue_id"),
pending_sync=q["queued"]
)
@router.delete("/zones/{zone_name}/records/{record_id}")
def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_current_user)):
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone():
raise HTTPException(404, "Enregistrement introuvable")
conn.execute("DELETE FROM records WHERE id=%s", (record_id,))
q = _apply_zone(zone_name)
audit.record("dns", "delete_record", target=f"zone:{zone_name}/record:{record_id}",
detail={"queued": q["queued"]}, user=user, ip=get_request_ip())
return {"queued": q["queued"], "queue_id": q.get("queue_id")}
@router.post("/zones/{zone_name}/reload")
def reload_zone(zone_name: str, user: dict = Depends(get_current_user)):
"""Force rndc reload on the zone.
Tries reload_zone action first; falls back to apply_zone (also triggers rndc reload)
for compatibility with older agent versions that don't support reload_zone.
"""
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' not found")
# Try reload_zone first (lightweight — no file rewrite)
# Falls back to apply_zone for old agents that don't have reload_zone handler
try:
from broker import publish_and_wait
ack = publish_and_wait("dns.commands", "dns.acks", "reload_zone", {"zone": zone_name})
if ack.get("success"):
audit.record("dns", "reload_zone", target=f"zone:{zone_name}",
detail={"method": "reload_zone"},
user=user, ip=get_request_ip())
return {"zone": zone_name, "queued": False, "queue_id": None}
# Agent returned success=False (unknown action etc.) — fall through
except (RuntimeError, HTTPException):
pass # Agent unreachable or returned error — fall through to apply_zone
# Fallback: apply_zone rewrites the file AND calls rndc reload
result = _apply_zone(zone_name)
audit.record("dns", "reload_zone", target=f"zone:{zone_name}",
detail={"method": "apply_zone_fallback", "queued": result.get("queued")},
user=user, ip=get_request_ip())
return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("queue_id")}
# ── Raw zone (read-only — agent generates it) ──────────────────────
@router.get("/zones/{zone_name}/raw")
def get_zone_raw(zone_name: str, user: dict = Depends(get_current_user)):
"""Ask the DNS agent to return the current raw zone file content."""
require_dns_access(zone_name, user)
try:
ack = publish_dns("get_zone_raw", {"zone": zone_name})
if not ack.get("success"):
raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}")
return {"zone": zone_name, "content": ack.get("content", ""), "path": ack.get("path", "")}
except RuntimeError as e:
raise HTTPException(503, str(e))
@router.put("/zones/{zone_name}/raw")
def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_user)):
"""Send raw zone content to the agent for direct write (advanced use)."""
require_dns_access(zone_name, user)
content = body.get("content", "")
if not content.strip():
raise HTTPException(422, "Zone content cannot be empty")
try:
ack = publish_dns("save_zone_raw", {"zone": zone_name, "content": content})
if not ack.get("success"):
raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}")
return {"zone": zone_name, "content": ack.get("content", content)}
except RuntimeError as e:
raise HTTPException(503, str(e))
# ──────────────────────────────────────────────
# Sync from BIND agent (zone files are source of truth)
# ──────────────────────────────────────────────
def _stable_id(zone_name: str, rname: str, rtype: str, rvalue: str) -> str:
"""Deterministic record ID = sha1(zone:name:type:value) formatted as UUID."""
key = f"{zone_name}:{rname}:{rtype}:{rvalue}".encode()
h = hashlib.sha1(key).hexdigest()
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
def _sync_zone_from_state(conn, zone: dict) -> tuple[bool, int]:
"""Upsert one zone + its records from agent get_state.
Returns (is_new_zone, new_record_count).
"""
name = zone["name"]
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)
records = zone.get("records", [])
existing = conn.execute("SELECT name FROM zones WHERE name=%s", (name,)).fetchone()
if existing:
conn.execute(
"""UPDATE zones
SET admin=%s, ttl=%s, refresh=%s, retry=%s, expire=%s, negative_ttl=%s,
last_applied_at=NOW(), last_apply_ok=1
WHERE name=%s""",
(admin, ttl, refresh, retry, expire, negative_ttl, name)
)
is_new = False
else:
conn.execute(
"""INSERT INTO zones
(name, admin, ttl, refresh, retry, expire, negative_ttl,
last_applied_at, last_apply_ok)
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW(),1)""",
(name, admin, ttl, refresh, retry, expire, negative_ttl)
)
is_new = True
incoming_ids = set()
new_records = 0
for rec in records:
rname = rec.get("name", "@") or "@"
rtype = rec["type"]
rvalue = rec["value"]
rec_id = _stable_id(name, rname, rtype, rvalue)
incoming_ids.add(rec_id)
if conn.execute("SELECT id FROM records WHERE id=%s", (rec_id,)).fetchone():
conn.execute(
"UPDATE records SET ttl=%s, priority=%s, active=COALESCE(active,1), pending_sync=0 "
"WHERE id=%s",
(rec.get("ttl"), rec.get("priority"), rec_id)
)
else:
conn.execute(
"INSERT INTO records(id,zone,name,type,ttl,value,priority,active,pending_sync) "
"VALUES(%s,%s,%s,%s,%s,%s,%s,1,0)",
(rec_id, name, rname, rtype, rec.get("ttl"), rvalue, rec.get("priority"))
)
new_records += 1
# Remove stale records
existing_ids = {r["id"] for r in
conn.execute("SELECT id FROM records WHERE zone=%s", (name,)).fetchall()}
for stale_id in existing_ids - incoming_ids:
conn.execute("DELETE FROM records WHERE id=%s", (stale_id,))
return is_new, new_records
def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") -> dict:
"""Pull zone state from BIND via the DNS agent and update local SQLite.
Zone files on the BIND server have priority over SQLite.
- Zone exists on BIND but not in SQLite → imported
- Zone exists in SQLite but not on BIND → removed from SQLite
- Records are fully replaced per zone (zone file is authoritative)
"""
t0 = time.monotonic()
try:
payload = {"zone": zone_filter} if zone_filter else {}
ack = publish_dns("get_state", payload)
except RuntimeError as e:
audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000),
success=False, error=str(e))
raise HTTPException(503, f"DNS agent unreachable: {e}")
if not ack.get("success"):
err = ack.get("error", "")
audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000),
success=False, error=err)
raise HTTPException(500, f"DNS agent error: {err}")
imported_zones = 0
updated_zones = 0
imported_records = 0
remote_zone_names = {z["name"] for z in ack.get("zones", [])}
with get_db() as conn:
# If full sync (no filter): remove zones that no longer exist on BIND
if not zone_filter:
local_zones = {
r["name"] for r in conn.execute("SELECT name FROM zones").fetchall()
}
for stale in local_zones - remote_zone_names:
conn.execute("DELETE FROM records WHERE zone=%s", (stale,))
conn.execute("DELETE FROM zones WHERE name=%s", (stale,))
for zone in ack.get("zones", []):
is_new, n_rec = _sync_zone_from_state(conn, zone)
imported_zones += 1 if is_new else 0
updated_zones += 0 if is_new else 1
imported_records += n_rec
result = {
"imported_zones": imported_zones,
"updated_zones": updated_zones,
"imported_records": imported_records,
"total_zones": len(ack.get("zones", [])),
}
audit.record_sync(
"dns", trigger,
duration_ms=int((time.monotonic()-t0)*1000),
zones=result["total_zones"],
records=result["imported_records"],
success=True,
detail=result,
)
return result
@router.post("/zones/{zone_name}/dnssec")
def zone_dnssec(
zone_name: str,
action: str = "enable",
dnssec_policy: str = "default",
user: dict = Depends(get_current_user),
):
"""
Enable or disable DNSSEC for a zone.
Actions: enable | disable | get_ds
Modifies the zone block in named.conf.local to add/remove
dnssec-policy + inline-signing, then calls rndc reconfig.
Returns DS records for publication at the registrar.
"""
require_dns_access(zone_name, user)
with get_db() as conn:
if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone():
raise HTTPException(404, f"Zone '{zone_name}' introuvable")
payload = {
"zone": zone_name,
"action": action,
"dnssec_policy": dnssec_policy,
# key_dir intentionally omitted — read from agent's own config
}
try:
ack = publish_dns("enable_dnssec", payload)
except Exception as e:
raise HTTPException(502, f"Agent inaccessible : {e}")
if not ack.get("success"):
raise HTTPException(500, ack.get("error", "Erreur agent DNSSEC"))
audit.record("dns", f"dnssec_{action}", target=f"zone:{zone_name}",
detail={"ds_count": len(ack.get("ds_records", []))},
user=user, ip=get_request_ip())
return {
"zone": zone_name,
"action": action,
"ds_records": ack.get("ds_records", []),
"key_dir": ack.get("key_dir", cfg.dns.key_dir),
"message": ack.get("message", ""),
"already_configured": ack.get("already_configured", False),
}
@router.get("/sync/history")
def get_sync_history(
limit: int = 50,
offset: int = 0,
user: dict = Depends(get_current_user),
):
"""Return DNS sync history (global admin only)."""
from routers.auth import _has_role
if not _has_role(user, "global_admin"):
raise HTTPException(403, "Accès réservé à l'admin global")
return {
"total": audit.count_sync_history("dns"),
"entries": audit.query_sync_history("dns", limit=limit, offset=offset),
}
@router.get("/zones/{zone_name}/snapshots")
def list_zone_snapshots(zone_name: str, user: dict = Depends(get_current_user)):
"""Liste les snapshots disponibles pour une zone."""
try:
ack = publish_dns("list_snapshots", {"zone": zone_name})
except Exception:
return {"zone": zone_name, "snapshots": []}
# Agent peut retourner success:False si le dossier history/ n'existe pas encore
return {
"zone": zone_name,
"snapshots": ack.get("snapshots", []),
}
@router.get("/zones/{zone_name}/snapshots/{snapshot_name}")
def get_snapshot_content(
zone_name: str,
snapshot_name: str,
user: dict = Depends(get_current_user),
):
"""Retourne le contenu d'un snapshot de zone."""
try:
ack = publish_dns("get_snapshot", {"zone": zone_name, "snapshot": snapshot_name})
except Exception:
raise HTTPException(503, "Agent DNS inaccessible")
if not ack.get("success"):
raise HTTPException(404, ack.get("error", "Snapshot introuvable"))
return {"zone": zone_name, "snapshot": snapshot_name, "content": ack["content"]}
@router.post("/zones/{zone_name}/rollback")
def rollback_zone(
zone_name: str,
snapshot: str | None = None,
user: dict = Depends(get_current_user),
):
"""Restaure un snapshot de zone et recharge BIND."""
require_dns_access(zone_name, user)
payload = {"zone": zone_name}
if snapshot:
payload["snapshot"] = snapshot
ack = publish_dns("rollback_zone", payload)
if not ack.get("success"):
raise HTTPException(500, ack.get("error", "Rollback échoué"))
audit.record("dns", "rollback_zone", target=f"zone:{zone_name}",
detail={"snapshot": ack.get("restored_snapshot")},
user=user, ip=get_request_ip())
return {
"success": True,
"zone": zone_name,
"restored_snapshot": ack.get("restored_snapshot"),
}
@router.post("/sync")
def sync_endpoint(
zone: str | None = None,
user: dict = Depends(get_current_user)
):
"""Pull current zone state from the BIND agent and update local SQLite.
BIND zone files are the source of truth.
Optional query param: %szone=example.com to sync a single zone.
"""
if zone:
require_dns_access(zone, user)
result = sync_from_agent(zone, trigger="manual")
return {
"status": "ok",
"message": (
f"Sync DNS terminé : {result['total_zones']} zone(s) traitée(s), "
f"{result['imported_zones']} importée(s), "
f"{result['updated_zones']} mise(s) à jour, "
f"{result['imported_records']} enregistrement(s)."
),
"details": result,
}