fix: corrections DNS agent — sync, CNAME, enregistrements désactivés

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
This commit is contained in:
2026-05-02 20:57:27 +02:00
parent 36147fb1e0
commit 5a44e48722
7 changed files with 229 additions and 31 deletions
+117
View File
@@ -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
+37 -9
View File
@@ -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)
-5
View File
@@ -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"]
+14 -6
View File
@@ -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
+51 -4
View File
@@ -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
+6 -4
View File
@@ -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);
+4 -3
View File
@@ -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();