From 5a44e48722ed0c79c2e4be6c1b6cf37e92b18b49 Mon Sep 17 00:00:00 2001 From: kguerineau Date: Sat, 2 May 2026 20:57:27 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20corrections=20DNS=20agent=20=E2=80=94=20?= =?UTF-8?q?sync,=20CNAME,=20enregistrements=20d=C3=A9sactiv=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent DNS - load_config() lit maintenant la section `redis` du config.yaml (host, port, password, ssl, ssl_ca_cert, ssl_certfile, ssl_keyfile) → l'agent ne tombait plus sur localhost:6379 par défaut - Suppression de retry_on_timeout (déprécié redis-py 6.0) - Suppression de socket_keepalive_options (clés string invalides) - int() forcé sur port et db dans run() pour éviter TypeError - Traceback complet sur fatal error - get_state: relativize=False → dnspython conserve les FQDN complets - get_state: parse les enregistrements "; [DISABLED]" pour les inclure avec active=False dans le state retourné - _fmt_value: ne ajoute plus le point final sur les noms relatifs (srvweb → reste srvweb, srvweb.infolix.fr → srvweb.infolix.fr.) - save_zone_raw: named-checkzone + snapshot (même logique qu'apply_zone) - gen_certs.sh: REDIS_HOST obligatoire, détection IP vs FQDN pour le SAN Backend DNS - _sync_zone_from_state: normalise les noms FQDN → court avant _stable_id (mail.infolix.fr. → mail pour correspondre à ce qui est en BDD) - _sync_zone_from_state: active=%s au lieu de COALESCE(active,1) → les enregistrements désactivés importés depuis le fichier de zone sont correctement marqués active=0 en BDD Frontend - App.jsx: VITE_API_BASE prioritaire sur localStorage - validateRecord: accepte les noms relatifs sans point pour CNAME/MX/NS --- README.md | 117 +++++++++++++++++++++++++ agents/dns_agent.py | 46 ++++++++-- agents/mail_agent.py | 5 -- backend/routers/dns.py | 20 +++-- deploy/install.sh | 55 +++++++++++- frontend/src/App.jsx | 10 ++- frontend/src/components/DNSManager.jsx | 7 +- 7 files changed, 229 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d5686e2..0f0e5ed 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,102 @@ Interface d'administration DNS et Mail pour serveurs BIND9 + Postfix/Dovecot. - Postfix + Dovecot - Rspamd (optionnel, pour DKIM) +### Bases de données MariaDB + +#### Base NetAdmin (gestion interne) + +```sql +-- Se connecter en root MariaDB +mysql -u root -p + +-- Créer l'utilisateur et la base +CREATE DATABASE netadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'netadmin'@'localhost' IDENTIFIED BY 'mot_de_passe_fort'; +GRANT ALL PRIVILEGES ON netadmin.* TO 'netadmin'@'localhost'; +FLUSH PRIVILEGES; +``` + +Les tables sont créées automatiquement au premier démarrage du backend. + +#### Base netadmin_mail (lue par Postfix/Dovecot) + +À créer sur le **serveur mail** : + +```sql +CREATE DATABASE netadmin_mail CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'postfix'@'localhost' IDENTIFIED BY 'mot_de_passe_fort'; +GRANT ALL PRIVILEGES ON netadmin_mail.* TO 'postfix'@'localhost'; +FLUSH PRIVILEGES; +``` + +Créer les tables manuellement : + +```sql +USE netadmin_mail; + +CREATE TABLE domain ( + domain VARCHAR(253) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + max_accounts INT NOT NULL DEFAULT 0, + max_aliases INT NOT NULL DEFAULT 0, + max_quota_mb INT NOT NULL DEFAULT 0, + mb_quota_mb INT NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + created DATETIME NOT NULL DEFAULT NOW(), + modified DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE mailbox ( + username VARCHAR(128) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + domain VARCHAR(253) NOT NULL, + quota_mb INT NOT NULL DEFAULT 1024, + active TINYINT(1) NOT NULL DEFAULT 1, + created DATETIME NOT NULL DEFAULT NOW(), + modified DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (username), + FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE alias ( + address VARCHAR(253) NOT NULL, + goto TEXT NOT NULL, + domain VARCHAR(253) NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created DATETIME NOT NULL DEFAULT NOW(), + modified DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (address), + FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sender_login_maps ( + sender VARCHAR(255) NOT NULL, + authorized VARCHAR(255) NOT NULL, + domain VARCHAR(253) NOT NULL, + PRIMARY KEY (sender, authorized) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +Si le backend NetAdmin tourne sur un serveur différent du serveur mail, autoriser la connexion distante : + +```sql +CREATE USER 'netadmin_agent'@'IP_SERVEUR_NETADMIN' IDENTIFIED BY 'mot_de_passe_fort'; +GRANT ALL PRIVILEGES ON netadmin_mail.* TO 'netadmin_agent'@'IP_SERVEUR_NETADMIN'; +FLUSH PRIVILEGES; +``` + +#### Permissions du fichier de configuration + +```bash +# Le fichier config.yaml doit être lisible par l'utilisateur netadmin +chown root:netadmin /etc/netadmin/config.yaml +chmod 640 /etc/netadmin/config.yaml +chown root:netadmin /etc/netadmin +chmod 750 /etc/netadmin +``` + ### Backend ```bash @@ -207,6 +303,27 @@ WHERE username LIKE '%@%@%';" ## Sécurité +### Génération du session_secret + +Le `session_secret` dans `config.yaml` est utilisé pour signer les tokens de session. Il doit être une clé aléatoire forte, unique par installation : + +```bash +# Avec Python (recommandé) +python3 -c "import secrets; print(secrets.token_hex(32))" + +# Avec OpenSSL +openssl rand -hex 32 +``` + +Les deux génèrent une clé de 64 caractères hexadécimaux (256 bits). À renseigner dans `config.yaml` : + +```yaml +auth: + session_secret: "coller_la_clé_générée_ici" +``` + +⚠ Ne jamais committer cette valeur dans un dépôt Git. Utiliser une variable d'environnement ou un gestionnaire de secrets en production. + ### Certificats mTLS Redis ```bash diff --git a/agents/dns_agent.py b/agents/dns_agent.py index b49f972..f88e50b 100644 --- a/agents/dns_agent.py +++ b/agents/dns_agent.py @@ -534,7 +534,8 @@ def handle_get_state(payload: dict, cfg: dict) -> dict: continue try: - z = dz.from_file(str(path), origin=zone_name, check_origin=False) + z = dz.from_file(str(path), origin=zone_name, check_origin=False, + relativize=False) except Exception as e: log.warning(f"Cannot parse {path}: {e}") continue @@ -582,12 +583,15 @@ def handle_get_state(payload: dict, cfg: dict) -> dict: expire = soa_nums[3] negative_ttl = soa_nums[4] + origin_suffix = f".{zone_name}." # Parse records (skip SOA) records = [] for name_obj, node in z.nodes.items(): + # Keep full FQDN — normalize apex zone name to @ name_str = str(name_obj) - if name_str == "@": - name_str = "" + if name_str == f"{zone_name}.": + name_str = "@" + # else: keep full FQDN (e.g. wapt.infolix.fr.) for rdataset in node.rdatasets: rdtype = dns.rdatatype.to_text(rdataset.rdtype) if rdtype == "SOA": @@ -609,8 +613,36 @@ def handle_get_state(payload: dict, cfg: dict) -> dict: "ttl": rec_ttl, "value": value, "priority": priority, + "active": True, }) + # Parse disabled records from comments: "; [DISABLED] name ttl IN type value" + disabled_re = re.compile( + r'^;\s*\[DISABLED\]\s+(\S+)\s+(?:(\d+)\s+)?IN\s+(\w+)\s+(.*)', re.IGNORECASE + ) + for line in raw_lines: + m = disabled_re.match(line.strip()) + if not m: + continue + rname, rec_ttl_str, rdtype, rvalue = m.groups() + rname = rname if rname != "@" else "" + rdtype = rdtype.upper() + rec_ttl = int(rec_ttl_str) if rec_ttl_str else None + priority = None + if rdtype == "MX": + parts = rvalue.split(None, 1) + if len(parts) == 2: + try: priority = int(parts[0]); rvalue = parts[1] + except ValueError: pass + records.append({ + "name": rname, + "type": rdtype, + "ttl": rec_ttl, + "value": rvalue.strip(), + "priority": priority, + "active": False, + }) + result.append({ "name": zone_name, "admin": admin, @@ -1034,11 +1066,6 @@ def run(cfg: dict): "socket_connect_timeout": 10, # timeout connexion initiale "socket_timeout": 30, # timeout opérations "socket_keepalive": True, # keepalive TCP - "socket_keepalive_options": { - "TCP_KEEPIDLE": 60, - "TCP_KEEPINTVL": 10, - "TCP_KEEPCNT": 3, - }, } if cfg.get("redis_password"): redis_kwargs["password"] = cfg["redis_password"] @@ -1114,5 +1141,6 @@ if __name__ == "__main__": except KeyboardInterrupt: log.info("DNS agent stopped") except Exception as e: - log.error(f"Fatal error: {e}") + import traceback + log.error(f"Fatal error: {e}\n{traceback.format_exc()}") sys.exit(1) \ No newline at end of file diff --git a/agents/mail_agent.py b/agents/mail_agent.py index 695d702..faa2a7c 100644 --- a/agents/mail_agent.py +++ b/agents/mail_agent.py @@ -606,11 +606,6 @@ def run(cfg: dict): "socket_connect_timeout": 10, # timeout connexion initiale "socket_timeout": 30, # timeout opérations "socket_keepalive": True, # keepalive TCP - "socket_keepalive_options": { - "TCP_KEEPIDLE": 60, - "TCP_KEEPINTVL": 10, - "TCP_KEEPCNT": 3, - }, } if cfg.get("redis_password"): redis_kwargs["password"] = cfg["redis_password"] diff --git a/backend/routers/dns.py b/backend/routers/dns.py index 3c277ab..d53fecb 100644 --- a/backend/routers/dns.py +++ b/backend/routers/dns.py @@ -329,12 +329,12 @@ def _zone_to_payload(zone_name: str) -> dict: "records": [ { "id": r["id"], - "name": r["name"], + "name": r["name"], # kept as-is (short form, e.g. mail) "type": r["type"], "ttl": r["ttl"], "value": r["value"], "priority": r["priority"], - "active": bool(r["active"]), # inactive → commented in zone file + "active": bool(r["active"]), } for r in records ], @@ -718,8 +718,15 @@ def _sync_zone_from_state(conn, zone: dict) -> tuple[bool, int]: incoming_ids = set() new_records = 0 + origin_suffix = f".{name}." for rec in records: rname = rec.get("name", "@") or "@" + # Normalize FQDN to short name to match what's stored in DB + # e.g. "mail.infolix.fr." → "mail", "infolix.fr." → "@" + if rname == f"{name}.": + rname = "@" + elif rname.endswith(origin_suffix): + rname = rname[:-len(origin_suffix)] rtype = rec["type"] rvalue = rec["value"] rec_id = _stable_id(name, rname, rtype, rvalue) @@ -727,15 +734,16 @@ def _sync_zone_from_state(conn, zone: dict) -> tuple[bool, int]: if conn.execute("SELECT id FROM records WHERE id=%s", (rec_id,)).fetchone(): conn.execute( - "UPDATE records SET ttl=%s, priority=%s, active=COALESCE(active,1), pending_sync=0 " + "UPDATE records SET ttl=%s, priority=%s, active=%s, pending_sync=0 " "WHERE id=%s", - (rec.get("ttl"), rec.get("priority"), rec_id) + (rec.get("ttl"), rec.get("priority"), int(rec.get("active", True)), rec_id) ) else: conn.execute( "INSERT INTO records(id,zone,name,type,ttl,value,priority,active,pending_sync) " - "VALUES(%s,%s,%s,%s,%s,%s,%s,1,0)", - (rec_id, name, rname, rtype, rec.get("ttl"), rvalue, rec.get("priority")) + "VALUES(%s,%s,%s,%s,%s,%s,%s,%s,0)", + (rec_id, name, rname, rtype, rec.get("ttl"), rvalue, + rec.get("priority"), int(rec.get("active", True))) ) new_records += 1 diff --git a/deploy/install.sh b/deploy/install.sh index 3dd59a8..d6f3b0a 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -38,9 +38,45 @@ 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 +apt-get install -y -qq python3 python3-venv python3-pip nodejs npm nginx redis-server echo " ✓" +# ── Redis — configuration mTLS ──────────────────────────────────────────── +echo "→ Configuration Redis..." +REDIS_CONF="/etc/redis/redis.conf" + +# Écouter uniquement sur localhost (les agents se connectent via mTLS depuis l'extérieur) +sed -i 's/^bind 127.0.0.1 -::1/bind 0.0.0.0/' $REDIS_CONF + +# Port mTLS +sed -i 's/^port 6379/port 0/' $REDIS_CONF # désactiver port plaintext +grep -q "^tls-port" $REDIS_CONF || cat >> $REDIS_CONF << 'REDISCONF' + +# NetAdmin mTLS +tls-port 6380 +tls-ca-cert-file /etc/netadmin/certs/ca.crt +tls-cert-file /etc/netadmin/certs/redis-server.crt +tls-key-file /etc/netadmin/certs/redis-server.key +tls-auth-clients yes +tls-protocols "TLSv1.2 TLSv1.3" +requirepass changeme_redis +REDISCONF + +# Redis tourne en user redis:redis — lui donner accès aux certificats +# Le dossier /etc/netadmin/certs doit être lisible par redis +chown -R root:redis $CONFIG_DIR/certs +chmod 750 $CONFIG_DIR/certs +# La clé privée Redis uniquement lisible par redis +# (sera appliqué après gen_certs.sh) + +systemctl enable redis-server +echo " ✓ Redis configuré sur :6380 (mTLS)" +echo " ⚠ Adapter 'requirepass' dans /etc/redis/redis.conf" +echo " ⚠ Après génération des certificats (gen_certs.sh) :" +echo " cp certs/redis-server.{crt,key} /etc/netadmin/certs/" +echo " chown redis:redis /etc/netadmin/certs/redis-server.key" +echo " chmod 640 /etc/netadmin/certs/redis-server.key" + # ── Environnement Python ─────────────────────────────────────────────────── echo "→ Virtualenv Python..." python3 -m venv $NETADMIN_DIR/venv @@ -72,11 +108,22 @@ fi 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 + bash $NETADMIN_DIR/agents/gen_certs.sh . + # Permissions par défaut : root:netadmin lisible + chmod 640 *.key *.crt chown root:$NETADMIN_USER *.crt *.key + # La clé Redis doit être lisible par l'user redis (qui démarre redis-server) + chown root:redis redis-server.key + chmod 640 redis-server.key + # Le dossier certs lisible par redis et netadmin + chown root:redis $CONFIG_DIR/certs + chmod 750 $CONFIG_DIR/certs + # L'utilisateur netadmin doit aussi pouvoir lire → ajouter au groupe redis + usermod -aG redis $NETADMIN_USER 2>/dev/null || true echo " ✓ Certificats dans $CONFIG_DIR/certs/" - echo " ⚠ Copier ca.crt + les certificats agents sur les serveurs DNS/Mail" + echo " ⚠ Copier sur les serveurs distants :" + echo " DNS : ca.crt + dns-agent.{crt,key}" + echo " Mail : ca.crt + mail-agent.{crt,key}" else echo "→ Certificats mTLS existants conservés." fi diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index da25272..80ef7f2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,11 +34,13 @@ export default function App() { const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null); const [user, setUser] = useState(null); const [active, setActive] = useState("domains"); - // En production derrière Nginx, l'API est sur /api - // En dev, pointer sur http://localhost:8000 + // En production derrière Nginx, l'API est sur /api (défini dans .env.production) + // En dev, pointer sur http://localhost:8000 (défini dans .env.development) + // Le localStorage permet un override manuel si nécessaire const [apiBase, setApiBase] = useState( - () => localStorage.getItem("netadmin_api") - || (import.meta.env.VITE_API_BASE ?? "http://localhost:8000") + () => import.meta.env.VITE_API_BASE + || localStorage.getItem("netadmin_api") + || "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 5edecb1..58da39a 100644 --- a/frontend/src/components/DNSManager.jsx +++ b/frontend/src/components/DNSManager.jsx @@ -258,9 +258,10 @@ export default function DNSManager({ apiBase, token, onUnauthorized }) { // 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.)`; + // Accepté : FQDN avec point final (mail.example.com.) + // nom relatif sans point (srvweb → BIND ajoute le domaine de zone) + // @ (apex) + // Refusé : chaîne vide } if (type === "MX") { const v = value.trim();