diff --git a/agents/dns_agent.py b/agents/dns_agent.py index ba62f5d..0d3e1a2 100644 --- a/agents/dns_agent.py +++ b/agents/dns_agent.py @@ -63,6 +63,7 @@ DEFAULT_CFG = { "zones_dir": "/etc/bind/zones", "named_conf_local": "/etc/bind/named.conf.local", "rndc_cmd": "rndc", + "checkzone_cmd": "named-checkzone", "key_dir": "/etc/bind/keys", # DNSSEC key directory "dnssec_policy": "default", # BIND9 dnssec-policy name "redis_host": "localhost", @@ -253,13 +254,128 @@ def rndc(cmd: str, zone: str, rndc_cmd: str): # Command handlers # ────────────────────────────────────────────── +def check_zone_file(zone_name: str, path: Path, checkzone_cmd: str) -> tuple[bool, str]: + """ + Valide un fichier de zone avec named-checkzone. + Retourne (ok, message). + Traite les warnings critiques (MX→IP, CNAME apex) comme des erreurs. + """ + FATAL_WARNINGS = [ + "MX is an address", # MX pointant vers une IP + "is a CNAME and is also referred to by a NS/MX record", + "CNAME and other data", # CNAME coexistant avec d'autres records + "has no address records", # hostname sans A/AAAA (si strict) + ] + try: + result = subprocess.run( + [checkzone_cmd, zone_name, str(path)], + capture_output=True, text=True, timeout=15 + ) + output = (result.stdout + result.stderr).strip() + + if result.returncode != 0: + log.error(f"named-checkzone {zone_name}: FAILED\n{output}") + return False, output + + # Vérifier les warnings fatals même si returncode == 0 + fatal = [line for line in output.splitlines() + if any(w in line for w in FATAL_WARNINGS)] + if fatal: + msg = f"Zone invalide (warnings critiques) :\n" + "\n".join(fatal) + log.error(f"named-checkzone {zone_name}: {msg}") + return False, msg + + log.info(f"named-checkzone {zone_name}: OK") + return True, "OK" + + except FileNotFoundError: + log.warning(f"named-checkzone non trouvé à '{checkzone_cmd}' — validation ignorée") + return True, "named-checkzone non disponible" + except Exception as e: + log.error(f"named-checkzone error: {e}") + return False, str(e) + + +def snapshot_zone(zone_name: str, zones_dir: str) -> Path | None: + """ + Sauvegarde le fichier de zone actuel dans zones_dir/history/ + avant chaque modification. Retourne le chemin du snapshot ou None. + """ + from datetime import datetime + src = Path(zones_dir) / f"db.{zone_name}" + if not src.exists(): + return None + history_dir = Path(zones_dir) / "history" + history_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + snapshot = history_dir / f"db.{zone_name}.{ts}" + snapshot.write_text(src.read_text()) + log.info(f"Snapshot zone '{zone_name}' → {snapshot.name}") + # Purge: garder seulement les 20 derniers snapshots par zone + all_snaps = sorted(history_dir.glob(f"db.{zone_name}.*")) + for old in all_snaps[:-20]: + old.unlink() + log.debug(f"Purged old snapshot: {old.name}") + return snapshot + + +def rollback_zone(zone_name: str, zones_dir: str, snapshot_name: str | None = None) -> tuple[bool, str]: + """ + Restaure un snapshot. Si snapshot_name est None, prend le plus récent. + Retourne (ok, message). + """ + history_dir = Path(zones_dir) / "history" + if snapshot_name: + snap = history_dir / snapshot_name + if not snap.exists(): + return False, f"Snapshot '{snapshot_name}' introuvable" + else: + snaps = sorted(history_dir.glob(f"db.{zone_name}.*")) + if not snaps: + return False, f"Aucun snapshot disponible pour '{zone_name}'" + snap = snaps[-1] + + dest = Path(zones_dir) / f"db.{zone_name}" + dest.write_text(snap.read_text()) + log.info(f"Rollback '{zone_name}' ← {snap.name}") + return True, snap.name + + + def handle_apply_zone(payload: dict, cfg: dict) -> dict: try: - zone_name = payload["zone"] - path = write_zone_file(payload, cfg["zones_dir"]) + zone_name = payload["zone"] + zones_dir = cfg["zones_dir"] + checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone") + + # 1. Snapshot de la version actuelle avant toute modification + snap = snapshot_zone(zone_name, zones_dir) + + # 2. Écriture du nouveau fichier de zone + path = write_zone_file(payload, zones_dir) ensure_zone_in_named_conf(zone_name, path, cfg["named_conf_local"]) + + # 3. Validation avec named-checkzone + ok, msg = check_zone_file(zone_name, path, checkzone_cmd) + if not ok: + # Restaurer le snapshot automatiquement + if snap: + path.write_text(snap.read_text()) + log.warning(f"Zone '{zone_name}' restaurée depuis {snap.name} (checkzone KO)") + return { + "success": False, + "error": f"Fichier de zone invalide — rndc reload annulé.\n{msg}", + "checkzone_output": msg, + "snapshot_restored": snap.name if snap else None, + } + + # 4. Reload BIND seulement si le fichier est valide rndc("reload", zone_name, cfg["rndc_cmd"]) - return {"success": True} + return { + "success": True, + "snapshot": snap.name if snap else None, + "serial": payload.get("serial"), + } except Exception as e: log.error(f"apply_zone error: {e}") return {"success": False, "error": str(e)} @@ -299,19 +415,39 @@ def handle_get_zone_raw(payload: dict, cfg: dict) -> dict: def handle_save_zone_raw(payload: dict, cfg: dict) -> dict: try: - zone_name = payload["zone"] - content = payload["content"] - # Validate with dnspython before writing - import dns.zone as dz - dz.from_text(content, origin=zone_name, check_origin=False) - path = Path(cfg["zones_dir"]) / f"db.{zone_name}" - backup = path.with_suffix(".bak") - if path.exists(): - backup.write_text(path.read_text()) - path.write_text(content) + zone_name = payload["zone"] + content = payload["content"] + zones_dir = cfg["zones_dir"] + checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone") + path = Path(zones_dir) / f"db.{zone_name}" + + # 1. Snapshot avant modification + snap = snapshot_zone(zone_name, zones_dir) + + # 2. Écriture du contenu dans un fichier temporaire pour validation + tmp = path.with_suffix(".tmp") + tmp.write_text(content) + + # 3. Validation avec named-checkzone sur le fichier temporaire + ok, msg = check_zone_file(zone_name, tmp, checkzone_cmd) + if not ok: + tmp.unlink(missing_ok=True) + return { + "success": False, + "error": f"Fichier de zone invalide :\n{msg}", + "checkzone_output": msg, + } + + # 4. Remplacement du fichier réel seulement si valide + tmp.rename(path) rndc("reload", zone_name, cfg["rndc_cmd"]) - return {"success": True, "content": path.read_text()} + return { + "success": True, + "content": path.read_text(), + "snapshot": snap.name if snap else None, + } except Exception as e: + log.error(f"save_zone_raw error: {e}") return {"success": False, "error": str(e)} @@ -783,14 +919,77 @@ def handle_enable_dnssec(payload: dict, cfg: dict) -> dict: return {"success": False, "error": str(e)} +def handle_rollback_zone(payload: dict, cfg: dict) -> dict: + """ + Restaure un snapshot de zone et recharge BIND. + Payload: { "zone": "example.fr", "snapshot": "db.example.fr.20260501-143022" (optionnel) } + Si snapshot absent, restaure le plus récent. + """ + try: + zone_name = payload["zone"] + zones_dir = cfg["zones_dir"] + snapshot_name = payload.get("snapshot") + checkzone_cmd = cfg.get("checkzone_cmd", "named-checkzone") + + ok, info = rollback_zone(zone_name, zones_dir, snapshot_name) + if not ok: + return {"success": False, "error": info} + + path = Path(zones_dir) / f"db.{zone_name}" + valid, msg = check_zone_file(zone_name, path, checkzone_cmd) + if not valid: + return {"success": False, "error": f"Snapshot invalide : {msg}"} + + rndc("reload", zone_name, cfg["rndc_cmd"]) + log.info(f"Rollback zone '{zone_name}' réussi ← {info}") + return {"success": True, "restored_snapshot": info} + except Exception as e: + log.error(f"rollback_zone error: {e}") + return {"success": False, "error": str(e)} + + +def handle_get_snapshot(payload: dict, cfg: dict) -> dict: + """Retourne le contenu d'un snapshot de zone.""" + try: + zone_name = payload["zone"] + snapshot_name = payload["snapshot"] + snap = Path(cfg["zones_dir"]) / "history" / snapshot_name + if not snap.exists(): + return {"success": False, "error": f"Snapshot '{snapshot_name}' introuvable"} + return {"success": True, "content": snap.read_text()} + except Exception as e: + return {"success": False, "error": str(e)} + + + +def handle_list_snapshots(payload: dict, cfg: dict) -> dict: + """Liste les snapshots disponibles pour une zone.""" + try: + zone_name = payload["zone"] + history_dir = Path(cfg["zones_dir"]) / "history" + if not history_dir.exists(): + return {"success": True, "zone": zone_name, "snapshots": []} + snaps = sorted(history_dir.glob(f"db.{zone_name}.*"), reverse=True) + return { + "success": True, + "zone": zone_name, + "snapshots": [s.name for s in snaps], + } + except Exception as e: + return {"success": False, "error": str(e)} + + HANDLERS = { - "apply_zone": handle_apply_zone, - "reload_zone": handle_reload_zone, - "delete_zone": handle_delete_zone, - "get_zone_raw": handle_get_zone_raw, - "get_state": handle_get_state, - "save_zone_raw": handle_save_zone_raw, - "enable_dnssec": handle_enable_dnssec, + "apply_zone": handle_apply_zone, + "rollback_zone": handle_rollback_zone, + "list_snapshots": handle_list_snapshots, + "get_snapshot": handle_get_snapshot, + "reload_zone": handle_reload_zone, + "delete_zone": handle_delete_zone, + "get_zone_raw": handle_get_zone_raw, + "get_state": handle_get_state, + "save_zone_raw": handle_save_zone_raw, + "enable_dnssec": handle_enable_dnssec, } diff --git a/backend/routers/dns.py b/backend/routers/dns.py index b01aa45..3c277ab 100644 --- a/backend/routers/dns.py +++ b/backend/routers/dns.py @@ -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") def sync_endpoint( zone: str | None = None, diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh new file mode 100644 index 0000000..d817f5c --- /dev/null +++ b/deploy/install-agent.sh @@ -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 "" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..3dd59a8 --- /dev/null +++ b/deploy/install.sh @@ -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 "" diff --git a/deploy/log_config.yaml b/deploy/log_config.yaml new file mode 100644 index 0000000..fd05ed8 --- /dev/null +++ b/deploy/log_config.yaml @@ -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 diff --git a/deploy/netadmin-backend.service b/deploy/netadmin-backend.service new file mode 100644 index 0000000..6833627 --- /dev/null +++ b/deploy/netadmin-backend.service @@ -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 diff --git a/deploy/netadmin-dns-agent.service b/deploy/netadmin-dns-agent.service new file mode 100644 index 0000000..e9bf04a --- /dev/null +++ b/deploy/netadmin-dns-agent.service @@ -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 diff --git a/deploy/netadmin-mail-agent.service b/deploy/netadmin-mail-agent.service new file mode 100644 index 0000000..33ef9e6 --- /dev/null +++ b/deploy/netadmin-mail-agent.service @@ -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 diff --git a/deploy/nginx-netadmin.conf b/deploy/nginx-netadmin.conf new file mode 100644 index 0000000..33163b0 --- /dev/null +++ b/deploy/nginx-netadmin.conf @@ -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; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 88c350b..da25272 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,7 +34,12 @@ export default function App() { const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null); const [user, setUser] = useState(null); 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 [theme, setTheme] = useState(getInitialTheme); diff --git a/frontend/src/components/DNSManager.jsx b/frontend/src/components/DNSManager.jsx index 79705ef..5edecb1 100644 --- a/frontend/src/components/DNSManager.jsx +++ b/frontend/src/components/DNSManager.jsx @@ -42,6 +42,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { const [rawContent, setRawContent] = useState(""); const [rawSaving, setRawSaving] = useState(false); const [rawLoading, setRawLoading] = useState(false); + const [rawError, setRawError] = useState(null); const [copied, setCopied] = useState(false); const [confirmZone, setConfirmZone] = 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 [dnssecCopied, setDnssecCopied] = useState(null); 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 () => { 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") => { setDnssecLoading(true); try { @@ -170,29 +249,31 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { }; const validateRecord = (type, value) => { - const fqdn = /^[a-zA-Z0-9._-]+\.$/; // must end with . - const hostname = /^[a-zA-Z0-9._-]+$/; + const isIPv4 = (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v); + const isIPv6 = (v) => /^[0-9a-fA-F:]+:[0-9a-fA-F:]*$/.test(v); + if (["MX", "NS", "CNAME", "PTR"].includes(type)) { const v = value.trim(); if (!v) return "La valeur ne peut pas être vide"; - // Must end with a dot (FQDN) or be @ or relative - if (!v.endsWith(".") && v !== "@" && !v.includes(" ")) { + // MX/NS/CNAME ne peuvent pas pointer vers une IP + 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.)`; - } } if (type === "MX") { - // MX value is just the hostname (priority is separate) const v = value.trim(); if (!v) return "Le serveur MX ne peut pas être vide"; } if (type === "A") { const v = value.trim(); - if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(v)) + if (!isIPv4(v)) return "Adresse IPv4 invalide"; } if (type === "AAAA") { const v = value.trim(); - if (!/^[0-9a-fA-F:]+$/.test(v)) + if (!isIPv6(v)) return "Adresse IPv6 invalide"; } return null; @@ -250,6 +331,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { const saveRaw = async () => { setRawSaving(true); + setRawError(null); try { const data = await api(`/dns/zones/${activeZone}/raw`, { method: "PUT", @@ -261,7 +343,7 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { setRecords(updated); setShowRaw(false); } catch (e) { - setError(e.message); + setRawError(e.message); } finally { setRawSaving(false); } @@ -477,6 +559,11 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { style={{color:"var(--yellow)"}} title="Gérer DNSSEC pour cette zone"> 🔒 DNSSEC + + +
+ {snapLoading &&
Chargement…
} + {!snapLoading && snapshots.length === 0 && ( +
+ Aucun snapshot disponible +
+ )} + {!snapLoading && snapshots.length > 0 && ( +
+
+ {snapshots.length} snapshot(s) — 20 derniers conservés +
+ {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 ( +
+
+ {i===0?"🕐":"○"} +
+
+ {date} +
+
{snap}
+
+ {rollbackConf === snap ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ {isDiffOpen && ( +
+ {!snapDiff.lines ? ( +
+
Chargement du diff… +
+ ) : snapDiff.error ? ( +
+ ⚠ {snapDiff.error} +
+ ) : snapDiff.lines.filter(l=>l.type!=="eq").length === 0 ? ( +
+ Aucune différence avec la version actuelle +
+ ) : ( +
+ {snapDiff.lines.map((line, li) => line.type === "eq" ? null : ( +
+ {line.type==="add" ? "+" : "−"} {line.text} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ⚠ La restauration remplace le fichier de zone actuel et recharge BIND. + Le snapshot est validé avec named-checkzone avant application. +
+
+
+ +
+
+
+ )} + {/* ── DNSSEC result modal ─────────────────────────────────────────── */}