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
This commit is contained in:
2026-05-01 22:58:13 +02:00
parent b12ebea0e8
commit 63eb9a6af0
11 changed files with 1042 additions and 31 deletions
+220 -21
View File
@@ -63,6 +63,7 @@ DEFAULT_CFG = {
"zones_dir": "/etc/bind/zones", "zones_dir": "/etc/bind/zones",
"named_conf_local": "/etc/bind/named.conf.local", "named_conf_local": "/etc/bind/named.conf.local",
"rndc_cmd": "rndc", "rndc_cmd": "rndc",
"checkzone_cmd": "named-checkzone",
"key_dir": "/etc/bind/keys", # DNSSEC key directory "key_dir": "/etc/bind/keys", # DNSSEC key directory
"dnssec_policy": "default", # BIND9 dnssec-policy name "dnssec_policy": "default", # BIND9 dnssec-policy name
"redis_host": "localhost", "redis_host": "localhost",
@@ -253,13 +254,128 @@ def rndc(cmd: str, zone: str, rndc_cmd: str):
# Command handlers # 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: def handle_apply_zone(payload: dict, cfg: dict) -> dict:
try: try:
zone_name = payload["zone"] zone_name = payload["zone"]
path = write_zone_file(payload, cfg["zones_dir"]) 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"]) 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"]) rndc("reload", zone_name, cfg["rndc_cmd"])
return {"success": True} return {
"success": True,
"snapshot": snap.name if snap else None,
"serial": payload.get("serial"),
}
except Exception as e: except Exception as e:
log.error(f"apply_zone error: {e}") log.error(f"apply_zone error: {e}")
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -299,19 +415,39 @@ def handle_get_zone_raw(payload: dict, cfg: dict) -> dict:
def handle_save_zone_raw(payload: dict, cfg: dict) -> dict: def handle_save_zone_raw(payload: dict, cfg: dict) -> dict:
try: try:
zone_name = payload["zone"] zone_name = payload["zone"]
content = payload["content"] content = payload["content"]
# Validate with dnspython before writing zones_dir = cfg["zones_dir"]
import dns.zone as dz checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone")
dz.from_text(content, origin=zone_name, check_origin=False) path = Path(zones_dir) / f"db.{zone_name}"
path = Path(cfg["zones_dir"]) / f"db.{zone_name}"
backup = path.with_suffix(".bak") # 1. Snapshot avant modification
if path.exists(): snap = snapshot_zone(zone_name, zones_dir)
backup.write_text(path.read_text())
path.write_text(content) # 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"]) rndc("reload", zone_name, cfg["rndc_cmd"])
return {"success": True, "content": path.read_text()} return {
"success": True,
"content": path.read_text(),
"snapshot": snap.name if snap else None,
}
except Exception as e: except Exception as e:
log.error(f"save_zone_raw error: {e}")
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -783,14 +919,77 @@ def handle_enable_dnssec(payload: dict, cfg: dict) -> dict:
return {"success": False, "error": str(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 = { HANDLERS = {
"apply_zone": handle_apply_zone, "apply_zone": handle_apply_zone,
"reload_zone": handle_reload_zone, "rollback_zone": handle_rollback_zone,
"delete_zone": handle_delete_zone, "list_snapshots": handle_list_snapshots,
"get_zone_raw": handle_get_zone_raw, "get_snapshot": handle_get_snapshot,
"get_state": handle_get_state, "reload_zone": handle_reload_zone,
"save_zone_raw": handle_save_zone_raw, "delete_zone": handle_delete_zone,
"enable_dnssec": handle_enable_dnssec, "get_zone_raw": handle_get_zone_raw,
"get_state": handle_get_state,
"save_zone_raw": handle_save_zone_raw,
"enable_dnssec": handle_enable_dnssec,
} }
+55
View File
@@ -876,6 +876,61 @@ def get_sync_history(
} }
@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") @router.post("/sync")
def sync_endpoint( def sync_endpoint(
zone: str | None = None, zone: str | None = None,
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════════════
# NetAdmin — Script d'installation des AGENTS (serveur DNS ou Mail)
# À exécuter sur le serveur distant (BIND9 ou Postfix)
# Usage : sudo bash install-agent.sh [dns|mail]
# ═══════════════════════════════════════════════════════════════════════════
set -e
AGENT_TYPE="${1:-}"
AGENT_DIR="/opt/netadmin-agent"
LOG_DIR="/var/log/netadmin"
CONFIG_DIR="/etc/netadmin"
if [ "$EUID" -ne 0 ]; then
echo "✗ Ce script doit être exécuté en root (sudo)"
exit 1
fi
if [ "$AGENT_TYPE" != "dns" ] && [ "$AGENT_TYPE" != "mail" ]; then
echo "Usage : sudo bash install-agent.sh [dns|mail]"
exit 1
fi
echo "══════════════════════════════════════════"
echo " NetAdmin — Installation agent $AGENT_TYPE"
echo "══════════════════════════════════════════"
# ── Répertoires ────────────────────────────────────────────────────────────
echo "→ Création des répertoires..."
mkdir -p $AGENT_DIR/venv
mkdir -p $LOG_DIR
mkdir -p $CONFIG_DIR
echo " ✓"
# ── Dépendances système ────────────────────────────────────────────────────
echo "→ Installation des dépendances Python..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip
echo " ✓"
# ── Copie des fichiers agent ───────────────────────────────────────────────
echo "→ Copie des fichiers..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
cp $SOURCE_DIR/agents/${AGENT_TYPE}_agent.py $AGENT_DIR/
cp $SOURCE_DIR/agents/requirements.txt $AGENT_DIR/
echo " ✓"
# ── Environnement Python ───────────────────────────────────────────────────
echo "→ Création du venv Python..."
python3 -m venv $AGENT_DIR/venv
$AGENT_DIR/venv/bin/pip install --quiet --upgrade pip
$AGENT_DIR/venv/bin/pip install --quiet -r $AGENT_DIR/requirements.txt
echo " ✓"
# ── Certificats mTLS ──────────────────────────────────────────────────────
echo "→ Configuration des certificats mTLS..."
mkdir -p $CONFIG_DIR/certs
if [ ! -f $CONFIG_DIR/certs/agent.crt ]; then
echo " ⚠ Certificats mTLS absents dans $CONFIG_DIR/certs/"
echo " Copier depuis le serveur principal :"
echo " - agent.crt (certificat de cet agent)"
echo " - agent.key (clé privée de cet agent)"
echo " - ca.crt (certificat CA NetAdmin)"
echo ""
echo " Puis adapter config.yaml :"
echo " redis:"
echo " ssl_ca_cert: /etc/netadmin/certs/ca.crt"
echo " ssl_certfile: /etc/netadmin/certs/agent.crt"
echo " ssl_keyfile: /etc/netadmin/certs/agent.key"
else
echo " ✓ Certificats trouvés"
fi
# ── Configuration ──────────────────────────────────────────────────────────
if [ ! -f $CONFIG_DIR/config.yaml ]; then
echo "→ Création config.yaml (à compléter)..."
cat > $CONFIG_DIR/config.yaml << YAML
# NetAdmin Agent — $AGENT_TYPE
# Adapter les valeurs ci-dessous
redis:
host: "netadmin.example.fr" # ← IP/FQDN du serveur NetAdmin
port: 6380
password: "changeme" # ← mot de passe Redis
ssl: true
ssl_ca_cert: "/etc/netadmin/certs/ca.crt"
ssl_certfile: "/etc/netadmin/certs/agent.crt"
ssl_keyfile: "/etc/netadmin/certs/agent.key"
YAML
if [ "$AGENT_TYPE" = "dns" ]; then
cat >> $CONFIG_DIR/config.yaml << YAML
dns_agent:
zones_dir: "/etc/bind/zones"
named_conf_local: "/etc/bind/named.conf.local"
rndc_cmd: "rndc"
key_dir: "/etc/bind/keys"
dnssec_policy: "default"
YAML
else
cat >> $CONFIG_DIR/config.yaml << YAML
mail_agent:
db:
host: "localhost"
port: 3306
user: "postfix"
password: "changeme" # ← mot de passe MariaDB netadmin_mail
name: "netadmin_mail"
postfix:
reload_cmd: "postfix reload"
dkim:
dir: "/var/lib/rspamd/dkim"
selector: "mail"
YAML
fi
chmod 640 $CONFIG_DIR/config.yaml
echo " ✓ Éditer $CONFIG_DIR/config.yaml"
else
echo "→ Configuration existante conservée."
fi
# ── Service systemd ────────────────────────────────────────────────────────
echo "→ Installation du service systemd..."
cp $SOURCE_DIR/deploy/netadmin-${AGENT_TYPE}-agent.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable netadmin-${AGENT_TYPE}-agent
echo " ✓"
# ── Logrotate ──────────────────────────────────────────────────────────────
cat > /etc/logrotate.d/netadmin-agent << LOGROTATE
/var/log/netadmin/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 root root
}
LOGROTATE
echo "→ Logrotate ✓"
# ── Résumé ─────────────────────────────────────────────────────────────────
echo ""
echo "══════════════════════════════════════════"
echo " Installation agent $AGENT_TYPE terminée !"
echo "══════════════════════════════════════════"
echo ""
echo " Étapes suivantes :"
echo " 1. Copier les certificats mTLS dans $CONFIG_DIR/certs/"
echo " 2. Éditer $CONFIG_DIR/config.yaml"
echo " - IP du serveur NetAdmin (redis.host)"
echo " - Mot de passe Redis"
if [ "$AGENT_TYPE" = "dns" ]; then
echo " - Chemins BIND9 (zones_dir, named_conf_local)"
else
echo " - Connexion MariaDB (netadmin_mail)"
fi
echo " 3. Démarrer l'agent :"
echo " systemctl start netadmin-${AGENT_TYPE}-agent"
echo " 4. Vérifier les logs :"
echo " journalctl -u netadmin-${AGENT_TYPE}-agent -f"
echo ""
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════════════
# NetAdmin — Script d'installation SERVEUR PRINCIPAL (front + backend)
# Usage : sudo bash install.sh
# ═══════════════════════════════════════════════════════════════════════════
set -e
NETADMIN_DIR="/opt/netadmin"
NETADMIN_USER="netadmin"
LOG_DIR="/var/log/netadmin"
CONFIG_DIR="/etc/netadmin"
FRONTEND_DIR="/var/www/netadmin"
echo "══════════════════════════════════════════"
echo " NetAdmin — Installation serveur principal"
echo "══════════════════════════════════════════"
if [ "$EUID" -ne 0 ]; then
echo "✗ Ce script doit être exécuté en root (sudo)"
exit 1
fi
# ── Utilisateur système ────────────────────────────────────────────────────
echo "→ Création de l'utilisateur netadmin..."
id -u $NETADMIN_USER &>/dev/null || useradd -r -s /bin/false -d $NETADMIN_DIR $NETADMIN_USER
echo " ✓"
# ── Répertoires ────────────────────────────────────────────────────────────
echo "→ Création des répertoires..."
mkdir -p $NETADMIN_DIR/{backend,agents,venv}
mkdir -p $LOG_DIR
mkdir -p $CONFIG_DIR/certs
mkdir -p $FRONTEND_DIR
chown -R $NETADMIN_USER:$NETADMIN_USER $NETADMIN_DIR $LOG_DIR
chmod 750 $CONFIG_DIR
echo " ✓"
# ── Dépendances système ────────────────────────────────────────────────────
echo "→ Installation des dépendances..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip nodejs npm nginx
echo " ✓"
# ── Environnement Python ───────────────────────────────────────────────────
echo "→ Virtualenv Python..."
python3 -m venv $NETADMIN_DIR/venv
$NETADMIN_DIR/venv/bin/pip install --quiet --upgrade pip
$NETADMIN_DIR/venv/bin/pip install --quiet -r $NETADMIN_DIR/backend/requirements.txt
echo " ✓"
# ── Frontend build ─────────────────────────────────────────────────────────
echo "→ Build du frontend..."
cd $NETADMIN_DIR/frontend
npm install --silent
npm run build --silent # utilise .env.production → VITE_API_BASE=/api
cp -r dist/* $FRONTEND_DIR/
chown -R www-data:www-data $FRONTEND_DIR
echo " ✓"
# ── Configuration ──────────────────────────────────────────────────────────
if [ ! -f $CONFIG_DIR/config.yaml ]; then
echo "→ Copie config.yaml..."
cp $NETADMIN_DIR/backend/config.yaml $CONFIG_DIR/config.yaml
chmod 640 $CONFIG_DIR/config.yaml
chown root:$NETADMIN_USER $CONFIG_DIR/config.yaml
echo " ✓ Éditer $CONFIG_DIR/config.yaml avant de démarrer"
else
echo "→ Configuration existante conservée."
fi
# ── Certificats mTLS Redis ─────────────────────────────────────────────────
if [ ! -f $CONFIG_DIR/certs/ca.crt ]; then
echo "→ Génération des certificats mTLS Redis..."
cd $CONFIG_DIR/certs
bash $NETADMIN_DIR/agents/gen_certs.sh
chmod 640 *.key
chown root:$NETADMIN_USER *.crt *.key
echo " ✓ Certificats dans $CONFIG_DIR/certs/"
echo " ⚠ Copier ca.crt + les certificats agents sur les serveurs DNS/Mail"
else
echo "→ Certificats mTLS existants conservés."
fi
# ── Nginx ──────────────────────────────────────────────────────────────────
echo "→ Configuration Nginx..."
cp $NETADMIN_DIR/deploy/nginx-netadmin.conf /etc/nginx/sites-available/netadmin
[ ! -L /etc/nginx/sites-enabled/netadmin ] && \
ln -s /etc/nginx/sites-available/netadmin /etc/nginx/sites-enabled/netadmin
nginx -t && systemctl reload nginx
echo " ✓ Adapter /etc/nginx/sites-available/netadmin (FQDN, IPs autorisées)"
# ── Service systemd backend ────────────────────────────────────────────────
echo "→ Service systemd backend..."
cp $NETADMIN_DIR/deploy/netadmin-backend.service /etc/systemd/system/
cp $NETADMIN_DIR/deploy/log_config.yaml $NETADMIN_DIR/backend/
systemctl daemon-reload
systemctl enable netadmin-backend
echo " ✓"
# ── Logrotate ──────────────────────────────────────────────────────────────
cat > /etc/logrotate.d/netadmin << 'LOGROTATE'
/var/log/netadmin/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 netadmin netadmin
postrotate
systemctl kill -s HUP netadmin-backend 2>/dev/null || true
endscript
}
LOGROTATE
echo "→ Logrotate ✓"
# ── Résumé ─────────────────────────────────────────────────────────────────
echo ""
echo "══════════════════════════════════════════"
echo " Installation terminée !"
echo "══════════════════════════════════════════"
echo ""
echo " Étapes suivantes :"
echo " 1. Éditer $CONFIG_DIR/config.yaml"
echo " - MariaDB, Redis, session_secret, NS, SOA admin"
echo " 2. Adapter Nginx :"
echo " nano /etc/nginx/sites-available/netadmin"
echo " → FQDN, IP autorisées"
echo " 3. Certificat SSL :"
echo " certbot --nginx -d netadmin.example.fr"
echo " 4. Démarrer le backend :"
echo " systemctl start netadmin-backend"
echo " 5. Distribuer les agents sur les serveurs distants :"
echo " scp -r agents/ deploy/ root@serveur-dns:/opt/netadmin-agent/"
echo " scp -r agents/ deploy/ root@serveur-mail:/opt/netadmin-agent/"
echo " scp $CONFIG_DIR/certs/ca.crt root@serveur-dns:/etc/netadmin/certs/"
echo " scp $CONFIG_DIR/certs/ca.crt root@serveur-mail:/etc/netadmin/certs/"
echo " # Puis sur chaque serveur distant :"
echo " sudo bash /opt/netadmin-agent/deploy/install-agent.sh dns"
echo " sudo bash /opt/netadmin-agent/deploy/install-agent.sh mail"
echo ""
+34
View File
@@ -0,0 +1,34 @@
version: 1
disable_existing_loggers: false
formatters:
default:
format: "%(asctime)s [%(name)s] %(levelname)s %(message)s"
datefmt: "%Y-%m-%d %H:%M:%S"
access:
format: "%(asctime)s [access] %(message)s"
datefmt: "%Y-%m-%d %H:%M:%S"
handlers:
default:
class: logging.StreamHandler
formatter: default
stream: ext://sys.stdout
access:
class: logging.StreamHandler
formatter: access
stream: ext://sys.stdout
loggers:
uvicorn:
handlers: [default]
level: INFO
propagate: false
uvicorn.error:
handlers: [default]
level: INFO
propagate: false
uvicorn.access:
handlers: [access]
level: INFO
propagate: false
+33
View File
@@ -0,0 +1,33 @@
[Unit]
Description=NetAdmin Backend (FastAPI)
After=network.target mariadb.service redis.service
Wants=mariadb.service
[Service]
Type=simple
User=netadmin
Group=netadmin
WorkingDirectory=/opt/netadmin/backend
ExecStart=/opt/netadmin/venv/bin/uvicorn main:app \
--host 127.0.0.1 \
--port 8000 \
--workers 2 \
--log-level info \
--access-log \
--log-config /opt/netadmin/backend/log_config.yaml
Restart=on-failure
RestartSec=5
# Logs
StandardOutput=append:/var/log/netadmin/backend.log
StandardError=append:/var/log/netadmin/backend.log
# Sécurité
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/netadmin /var/log/netadmin /etc/netadmin
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=NetAdmin DNS Agent (BIND9)
After=network.target named.service
Wants=named.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/netadmin-agent
ExecStart=/opt/netadmin-agent/venv/bin/python3 dns_agent.py \
--config /etc/netadmin/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/netadmin/dns-agent.log
StandardError=append:/var/log/netadmin/dns-agent.log
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=NetAdmin Mail Agent (Postfix/Dovecot)
After=network.target postfix.service
Wants=postfix.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/netadmin-agent
ExecStart=/opt/netadmin-agent/venv/bin/python3 mail_agent.py \
--config /etc/netadmin/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/netadmin/mail-agent.log
StandardError=append:/var/log/netadmin/mail-agent.log
[Install]
WantedBy=multi-user.target
+100
View File
@@ -0,0 +1,100 @@
# NetAdmin — configuration Nginx
# Fichier : /etc/nginx/sites-available/netadmin
# Activer : ln -s /etc/nginx/sites-available/netadmin /etc/nginx/sites-enabled/
# ── Upstream backend ──────────────────────────────────────────────────────
upstream netadmin_backend {
server 127.0.0.1:8000;
keepalive 32;
}
# ── Redirection HTTP → HTTPS ──────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name netadmin.example.fr; # ← adapter
return 301 https://$host$request_uri;
}
# ── HTTPS ─────────────────────────────────────────────────────────────────
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name netadmin.example.fr; # ← adapter
# ── Certificats SSL ──────────────────────────────────────────────────
ssl_certificate /etc/letsencrypt/live/netadmin.example.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/netadmin.example.fr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS (6 mois)
add_header Strict-Transport-Security "max-age=15768000" always;
# ── Restriction IP ──────────────────────────────────────────────────
# Décommenter et adapter selon votre réseau
# allow 192.168.1.0/24; # réseau local
# allow 10.0.0.0/8; # VPN / WireGuard
# deny all;
# ── Headers sécurité ────────────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── Logs ─────────────────────────────────────────────────────────────
access_log /var/log/nginx/netadmin.access.log;
error_log /var/log/nginx/netadmin.error.log warn;
# ── Frontend — fichiers statiques Vite ──────────────────────────────
root /var/www/netadmin;
index index.html;
location / {
# React Router — toujours servir index.html
try_files $uri $uri/ /index.html;
# Cache assets buildés (hash dans le nom de fichier)
location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
}
# ── Backend API ──────────────────────────────────────────────────────
location /api/ {
proxy_pass http://netadmin_backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 10s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# Désactiver le buffering pour les SSE éventuels
proxy_buffering off;
# Taille des uploads (DKIM keys, etc.)
client_max_body_size 10m;
}
# ── Bloquer l'accès aux fichiers sensibles ──────────────────────────
location ~ /\. {
deny all;
}
location ~ \.(py|yaml|env|log|sql)$ {
deny all;
}
}
+6 -1
View File
@@ -34,7 +34,12 @@ export default function App() {
const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null); const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [active, setActive] = useState("domains"); const [active, setActive] = useState("domains");
const [apiBase, setApiBase] = useState(() => localStorage.getItem("netadmin_api") || "http://localhost:8000"); // En production derrière Nginx, l'API est sur /api
// En dev, pointer sur http://localhost:8000
const [apiBase, setApiBase] = useState(
() => localStorage.getItem("netadmin_api")
|| (import.meta.env.VITE_API_BASE ?? "http://localhost:8000")
);
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [theme, setTheme] = useState(getInitialTheme); const [theme, setTheme] = useState(getInitialTheme);
+244 -9
View File
@@ -42,6 +42,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
const [rawContent, setRawContent] = useState(""); const [rawContent, setRawContent] = useState("");
const [rawSaving, setRawSaving] = useState(false); const [rawSaving, setRawSaving] = useState(false);
const [rawLoading, setRawLoading] = useState(false); const [rawLoading, setRawLoading] = useState(false);
const [rawError, setRawError] = useState(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [confirmZone, setConfirmZone] = useState(null); const [confirmZone, setConfirmZone] = useState(null);
const [confirmRec, setConfirmRec] = useState(null); const [confirmRec, setConfirmRec] = useState(null);
@@ -55,6 +56,11 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
const [dnssecZoneData, setDnssecZoneData] = useState(null); // {ds_records, action, ...} const [dnssecZoneData, setDnssecZoneData] = useState(null); // {ds_records, action, ...}
const [dnssecCopied, setDnssecCopied] = useState(null); const [dnssecCopied, setDnssecCopied] = useState(null);
const [dnssecConfirm, setDnssecConfirm] = useState(null); // "enable" | "disable" const [dnssecConfirm, setDnssecConfirm] = useState(null); // "enable" | "disable"
const [snapshotModal, setSnapshotModal] = useState(false);
const [snapshots, setSnapshots] = useState([]);
const [snapLoading, setSnapLoading] = useState(false);
const [rollbackConf, setRollbackConf] = useState(null);
const [snapDiff, setSnapDiff] = useState(null); // {snapshot, lines: [{type,text}]}
const openDnssec = async () => { const openDnssec = async () => {
setDnssecModal(true); setDnssecModal(true);
@@ -71,6 +77,79 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
} }
}; };
const computeDiff = (oldText, newText) => {
// Simple line-by-line diff
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const result = [];
const maxLen = Math.max(oldLines.length, newLines.length);
// Build a basic diff: find removed and added lines
const oldSet = new Map();
oldLines.forEach((l, i) => oldSet.set(l, i));
const newSet = new Map();
newLines.forEach((l, i) => newSet.set(l, i));
// Use LCS-based approach: mark removed, added, unchanged
let i = 0, j = 0;
while (i < oldLines.length || j < newLines.length) {
const o = oldLines[i], n = newLines[j];
if (i >= oldLines.length) {
result.push({ type: "add", text: n }); j++;
} else if (j >= newLines.length) {
result.push({ type: "del", text: o }); i++;
} else if (o === n) {
result.push({ type: "eq", text: o }); i++; j++;
} else if (!newSet.has(o)) {
result.push({ type: "del", text: o }); i++;
} else if (!oldSet.has(n)) {
result.push({ type: "add", text: n }); j++;
} else {
result.push({ type: "del", text: o }); i++;
result.push({ type: "add", text: n }); j++;
}
}
return result;
};
const loadDiff = async (snap) => {
if (snapDiff?.snapshot === snap) { setSnapDiff(null); return; } // toggle
setSnapDiff({ snapshot: snap, lines: null }); // loading
try {
const [snapData, rawData] = await Promise.all([
api(`/dns/zones/${activeZone}/snapshots/${encodeURIComponent(snap)}`),
api(`/dns/zones/${activeZone}/raw`),
]);
const lines = computeDiff(snapData.content, rawData.content);
setSnapDiff({ snapshot: snap, lines });
} catch(e) {
setSnapDiff({ snapshot: snap, lines: [], error: e.message });
}
};
const loadSnapshots = async (zone) => {
setSnapshotModal(true);
setSnapLoading(true);
setSnapshots([]);
try {
const data = await api(`/dns/zones/${zone}/snapshots`);
setSnapshots(data.snapshots || []);
} catch(e) { setError(e.message); }
finally { setSnapLoading(false); }
};
const doRollback = async (snapshot = null) => {
try {
setSnapLoading(true);
const url = snapshot
? `/dns/zones/${activeZone}/rollback?snapshot=${encodeURIComponent(snapshot)}`
: `/dns/zones/${activeZone}/rollback`;
await api(url, { method: "POST" });
setRollbackConf(null);
setSnapshotModal(false);
load();
} catch(e) { setError(e.message); }
finally { setSnapLoading(false); }
};
const doDnssecAction = async (action, policy = "default") => { const doDnssecAction = async (action, policy = "default") => {
setDnssecLoading(true); setDnssecLoading(true);
try { try {
@@ -170,29 +249,31 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
}; };
const validateRecord = (type, value) => { const validateRecord = (type, value) => {
const fqdn = /^[a-zA-Z0-9._-]+\.$/; // must end with . const isIPv4 = (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v);
const hostname = /^[a-zA-Z0-9._-]+$/; const isIPv6 = (v) => /^[0-9a-fA-F:]+:[0-9a-fA-F:]*$/.test(v);
if (["MX", "NS", "CNAME", "PTR"].includes(type)) { if (["MX", "NS", "CNAME", "PTR"].includes(type)) {
const v = value.trim(); const v = value.trim();
if (!v) return "La valeur ne peut pas être vide"; if (!v) return "La valeur ne peut pas être vide";
// Must end with a dot (FQDN) or be @ or relative // MX/NS/CNAME ne peuvent pas pointer vers une IP
if (!v.endsWith(".") && v !== "@" && !v.includes(" ")) { if (isIPv4(v) || isIPv6(v))
return `Un enregistrement ${type} doit pointer vers un nom d'hôte, pas une adresse IP`;
// Doit se terminer par un point (FQDN)
if (!v.endsWith(".") && v !== "@" && !v.includes(" "))
return `Les enregistrements ${type} doivent se terminer par un point (ex: mail.example.com.)`; return `Les enregistrements ${type} doivent se terminer par un point (ex: mail.example.com.)`;
}
} }
if (type === "MX") { if (type === "MX") {
// MX value is just the hostname (priority is separate)
const v = value.trim(); const v = value.trim();
if (!v) return "Le serveur MX ne peut pas être vide"; if (!v) return "Le serveur MX ne peut pas être vide";
} }
if (type === "A") { if (type === "A") {
const v = value.trim(); const v = value.trim();
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(v)) if (!isIPv4(v))
return "Adresse IPv4 invalide"; return "Adresse IPv4 invalide";
} }
if (type === "AAAA") { if (type === "AAAA") {
const v = value.trim(); const v = value.trim();
if (!/^[0-9a-fA-F:]+$/.test(v)) if (!isIPv6(v))
return "Adresse IPv6 invalide"; return "Adresse IPv6 invalide";
} }
return null; return null;
@@ -250,6 +331,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
const saveRaw = async () => { const saveRaw = async () => {
setRawSaving(true); setRawSaving(true);
setRawError(null);
try { try {
const data = await api(`/dns/zones/${activeZone}/raw`, { const data = await api(`/dns/zones/${activeZone}/raw`, {
method: "PUT", method: "PUT",
@@ -261,7 +343,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
setRecords(updated); setRecords(updated);
setShowRaw(false); setShowRaw(false);
} catch (e) { } catch (e) {
setError(e.message); setRawError(e.message);
} finally { } finally {
setRawSaving(false); setRawSaving(false);
} }
@@ -477,6 +559,11 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
style={{color:"var(--yellow)"}} title="Gérer DNSSEC pour cette zone"> style={{color:"var(--yellow)"}} title="Gérer DNSSEC pour cette zone">
🔒 DNSSEC 🔒 DNSSEC
</button> </button>
<button className="btn btn-ghost btn-sm"
onClick={()=>loadSnapshots(activeZone)}
title="Historique et rollback de la zone">
Historique
</button>
<button className="btn btn-ghost btn-sm" onClick={reloadZone} disabled={reloading} <button className="btn btn-ghost btn-sm" onClick={reloadZone} disabled={reloading}
title="Forcer rndc reload sur cette zone"> title="Forcer rndc reload sur cette zone">
{reloading {reloading
@@ -821,6 +908,23 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
/> />
)} )}
</div> </div>
{rawError && (
<div style={{
padding:"10px 16px",
background:"var(--red-dim)",
borderTop:"1px solid var(--red)",
color:"var(--red)",
fontSize:11,
fontFamily:"var(--font-mono)",
whiteSpace:"pre-wrap",
lineHeight:1.6,
maxHeight:140,
overflowY:"auto",
}}>
<strong> Erreur named-checkzone :</strong>
{rawError}
</div>
)}
<div className="modal-footer" style={{justifyContent:"space-between"}}> <div className="modal-footer" style={{justifyContent:"space-between"}}>
<div style={{display:"flex",gap:6,alignItems:"center"}}> <div style={{display:"flex",gap:6,alignItems:"center"}}>
<button className="btn btn-secondary btn-sm" onClick={copyRaw}> <button className="btn btn-secondary btn-sm" onClick={copyRaw}>
@@ -1066,6 +1170,137 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) {
)} )}
{/* ── Snapshots / Rollback modal ────────────────────────────────────── */}
{snapshotModal && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setSnapshotModal(false)}>
<div className="modal" style={{width:780, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title"> Historique {activeZone}</div>
<button className="modal-close" onClick={()=>setSnapshotModal(false)}></button>
</div>
<div className="modal-body">
{snapLoading && <div className="loading"><div className="spinner"/>Chargement</div>}
{!snapLoading && snapshots.length === 0 && (
<div style={{textAlign:"center",color:"var(--text-3)",padding:24}}>
Aucun snapshot disponible
</div>
)}
{!snapLoading && snapshots.length > 0 && (
<div style={{display:"flex",flexDirection:"column",gap:6}}>
<div style={{fontSize:11,color:"var(--text-3)",marginBottom:4}}>
{snapshots.length} snapshot(s) 20 derniers conservés
</div>
{snapshots.map((snap, i) => {
const isDiffOpen = snapDiff?.snapshot === snap;
// Parse timestamp from filename: db.zone.YYYYMMDD-HHMMSS
const ts = snap.match(/(\d{8}-\d{6})$/)?.[1];
const date = ts
? new Date(
ts.slice(0,4)+"-"+ts.slice(4,6)+"-"+ts.slice(6,8)+"T"+
ts.slice(9,11)+":"+ts.slice(11,13)+":"+ts.slice(13,15)
).toLocaleString("fr-FR")
: snap;
return (
<div key={snap}>
<div style={{
display:"flex",alignItems:"center",gap:8,
padding:"8px 12px",borderRadius: isDiffOpen ? "6px 6px 0 0" : 6,
background: i===0 ? "var(--bg-2)" : "var(--bg-1)",
border:`1px solid ${i===0?"var(--accent)":isDiffOpen?"var(--accent)":"var(--border)"}`
}}>
<span style={{fontSize:11,color:"var(--text-3)"}}>{i===0?"🕐":"○"}</span>
<div style={{flex:1}}>
<div style={{fontSize:12,color:"var(--text-1)",fontFamily:"var(--font-mono)"}}>
{date}
</div>
<div style={{fontSize:10,color:"var(--text-3)"}}>{snap}</div>
</div>
{rollbackConf === snap ? (
<div style={{display:"flex",gap:6}}>
<button className="btn btn-danger btn-sm"
onClick={()=>doRollback(snap)} disabled={snapLoading}>
Confirmer
</button>
<button className="btn btn-secondary btn-sm"
onClick={()=>setRollbackConf(null)}>
Annuler
</button>
</div>
) : (
<div style={{display:"flex",gap:4}}>
<button className="btn btn-ghost btn-sm"
style={{color: isDiffOpen ? "var(--accent)" : "var(--text-2)"}}
onClick={()=>loadDiff(snap)}
title="Voir les différences avec la version actuelle">
{isDiffOpen ? "▲ Diff" : "▼ Diff"}
</button>
<button className="btn btn-secondary btn-sm"
onClick={()=>setRollbackConf(snap)}>
Restaurer
</button>
</div>
)}
</div>
{isDiffOpen && (
<div style={{
borderRadius:"0 0 6px 6px",
border:"1px solid var(--accent)",
borderTop:"none",
maxHeight:260,
overflowY:"auto",
background:"var(--bg-0)",
}}>
{!snapDiff.lines ? (
<div className="loading" style={{padding:12}}>
<div className="spinner"/>Chargement du diff
</div>
) : snapDiff.error ? (
<div style={{color:"var(--red)",padding:12,fontSize:11}}>
{snapDiff.error}
</div>
) : snapDiff.lines.filter(l=>l.type!=="eq").length === 0 ? (
<div style={{color:"var(--text-3)",padding:12,fontSize:11,textAlign:"center"}}>
Aucune différence avec la version actuelle
</div>
) : (
<div style={{fontFamily:"var(--font-mono)",fontSize:11,lineHeight:1.6}}>
{snapDiff.lines.map((line, li) => line.type === "eq" ? null : (
<div key={li} style={{
padding:"1px 12px",
background: line.type==="add" ? "rgba(0,200,80,0.08)"
: "rgba(255,60,60,0.08)",
color: line.type==="add" ? "var(--green)" : "var(--red)",
borderLeft: `3px solid ${line.type==="add"?"var(--green)":"var(--red)"}`,
whiteSpace:"pre",
}}>
{line.type==="add" ? "+" : ""} {line.text}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
<div style={{
marginTop:12, padding:"10px 12px", borderRadius:6,
background:"var(--red-dim)", border:"1px solid var(--red)",
fontSize:11, color:"var(--text-1)"
}}>
La restauration remplace le fichier de zone actuel et recharge BIND.
Le snapshot est validé avec <code>named-checkzone</code> avant application.
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setSnapshotModal(false)}>Fermer</button>
</div>
</div>
</div>
)}
{/* ── DNSSEC result modal ─────────────────────────────────────────── */} {/* ── DNSSEC result modal ─────────────────────────────────────────── */}
<DnssecResultModal <DnssecResultModal
dnssecResult={dnssecResult} dnssecResult={dnssecResult}