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
955 lines
39 KiB
Python
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,
|
|
} |