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:
+220
-21
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ""
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user