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:
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user