Files
kguerineau 5a44e48722 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
2026-05-02 20:57:27 +02:00

698 lines
26 KiB
Python

#!/usr/bin/env python3
"""
mail_agent.py — NetAdmin Mail Agent (PostfixAdmin MySQL schema)
Runs on the Postfix/Dovecot server.
Subscribes to Redis mail.commands, writes to the existing PostfixAdmin
MariaDB database, then publishes ACK.
Schema targeted:
domain (domain, description, aliases, mailboxes, maxquota, quota,
transport, backupmx, created, modified, active)
mailbox (username, password, name, maildir, quota, local_part,
domain, created, modified, active)
alias (address, goto, domain, created, modified, active)
Install on the mail server:
pip install redis pyyaml PyMySQL
python3 mail_agent.py --config /etc/netadmin/config.yaml
Postfix/Dovecot already query MySQL directly — no file reload needed
for account changes. We call 'postfix reload' only when domain config
changes (transport, etc.).
"""
import argparse
import json
import logging
import ssl
import sys
import subprocess
import datetime
from pathlib import Path
import redis
import yaml
try:
import pymysql
import pymysql.cursors
except ImportError:
print("ERROR: PyMySQL not installed. Run: pip install PyMySQL")
sys.exit(1)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [mail-agent] %(levelname)s %(message)s"
)
log = logging.getLogger("mail-agent")
NOW_DEFAULT = datetime.datetime(2000, 1, 1)
# ──────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────
def load_config(path: str) -> dict:
with open(path) as f:
raw = yaml.safe_load(f) or {}
cfg = {}
# Section mail_agent
mail_cfg = raw.get("mail_agent", {})
# Map nested mail_agent.db → flat db_* keys
db = mail_cfg.get("db", {})
if db:
cfg["db_host"] = db.get("host", "localhost")
cfg["db_port"] = int(db.get("port", 3306))
cfg["db_name"] = db.get("name", "netadmin_mail")
cfg["db_user"] = db.get("user", "postfix")
cfg["db_password"] = db.get("password", "")
postfix = mail_cfg.get("postfix", {})
if postfix:
cfg["postfix_reload_cmd"] = postfix.get("reload_cmd", "postfix reload")
dkim = mail_cfg.get("dkim", {})
if dkim:
cfg["dkim_dir"] = dkim.get("dir", "/var/lib/rspamd/dkim")
cfg["dkim_selector"] = dkim.get("selector", "mail")
# Section redis → mapped to redis_* keys
redis_cfg = raw.get("redis", {})
if redis_cfg:
cfg["redis_host"] = redis_cfg.get("host", "localhost")
cfg["redis_port"] = int(redis_cfg.get("port", 6380))
cfg["redis_password"] = redis_cfg.get("password", "")
cfg["redis_db"] = int(redis_cfg.get("db", 0))
if redis_cfg.get("ssl"):
cfg["tls"] = {
"enabled": True,
"ca_cert": redis_cfg.get("ssl_ca_cert", ""),
"client_cert": redis_cfg.get("ssl_certfile", ""),
"client_key": redis_cfg.get("ssl_keyfile", ""),
"check_hostname": redis_cfg.get("check_hostname", False),
}
return cfg
DEFAULT_CFG = {
# MariaDB connection
"db_host": "localhost",
"db_port": 3306,
"db_name": "postfix",
"db_user": "postfix",
"db_password": "",
# Postfix reload (only needed for domain-level changes)
"postfix_reload_cmd": "postfix reload",
# Redis
"redis_host": "localhost",
"redis_port": 6380,
"redis_password": "",
"redis_db": 0,
"tls": {
"enabled": False,
"ca_cert": "/etc/netadmin/certs/ca.crt",
"client_cert": "/etc/netadmin/certs/mail-agent.crt",
"client_key": "/etc/netadmin/certs/mail-agent.key",
"check_hostname": True,
},
}
# ──────────────────────────────────────────────
# mTLS helper
# ──────────────────────────────────────────────
def build_redis_ssl_context(tls_cfg: dict):
if not tls_cfg.get("enabled", False):
return None
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(cafile=tls_cfg["ca_cert"])
ctx.load_cert_chain(
certfile=tls_cfg["client_cert"],
keyfile=tls_cfg["client_key"]
)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
if not tls_cfg.get("check_hostname", True):
ctx.check_hostname = False
return ctx
# ──────────────────────────────────────────────
# DB helper
# ──────────────────────────────────────────────
def get_db(cfg: dict):
return pymysql.connect(
host=cfg["db_host"],
port=int(cfg["db_port"]),
user=cfg["db_user"],
password=cfg["db_password"],
database=cfg["db_name"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
def now() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ──────────────────────────────────────────────
# Command handlers
# ──────────────────────────────────────────────
def _sync_domain_row(cur, domain_name: str, config: dict, now) -> bool:
"""Insert or update the domain row. Returns True if inserted."""
description = config.get("description", "")
max_accounts = int(config.get("max_accounts", 0))
max_quota_mb = int(config.get("max_quota_mb", 0))
mb_quota_mb = int(config.get("mb_quota_mb", 0))
active = int(config.get("active", True))
cur.execute("SELECT domain FROM domain WHERE domain=%s", (domain_name,))
if cur.fetchone():
cur.execute(
"""UPDATE domain SET
description=%s, max_accounts=%s, max_quota_mb=%s,
mb_quota_mb=%s, active=%s, modified=%s
WHERE domain=%s""",
(description, max_accounts, max_quota_mb, mb_quota_mb,
active, now(), domain_name)
)
log.info(f"Updated domain: {domain_name}")
return False
else:
cur.execute(
"""INSERT INTO domain
(domain, description, max_accounts, max_quota_mb,
mb_quota_mb, active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(domain_name, description, max_accounts, max_quota_mb,
mb_quota_mb, active, now(), now())
)
log.info(f"Inserted domain: {domain_name}")
return True
def _sync_mailboxes(cur, domain_name: str, accounts: list, now):
"""Sync mailbox rows for a domain — insert, update, delete."""
cur.execute("SELECT username FROM mailbox WHERE domain=%s", (domain_name,))
existing_users = {r["username"] for r in cur.fetchall()}
incoming_users = {a["username"] for a in accounts}
# Delete removed accounts
for username in existing_users - incoming_users:
cur.execute("DELETE FROM mailbox WHERE username=%s", (username,))
cur.execute("DELETE FROM alias WHERE address=%s AND goto=%s", (username, username))
log.info(f"Deleted mailbox: {username}")
# Insert or update
for acc in accounts:
username = acc["username"]
password = acc.get("password_hash", "")
name = acc.get("name", "")
acc_active = int(acc.get("active", True))
if username in existing_users:
update_fields = {
"name": name,
"quota_mb": int(acc.get("quota", 1024)),
"active": acc_active,
"modified": now(),
}
if password:
update_fields["password"] = password
set_clause = ", ".join(f"{k}=%s" for k in update_fields)
cur.execute(
f"UPDATE mailbox SET {set_clause} WHERE username=%s",
(*update_fields.values(), username)
)
else:
cur.execute(
"""INSERT INTO mailbox
(username, password, name, domain, quota_mb,
active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(username, password, name, domain_name,
int(acc.get("quota", 1024)),
acc_active, now(), now())
)
# Self-alias required for virtual_alias_maps
cur.execute("SELECT address FROM alias WHERE address=%s", (username,))
if not cur.fetchone():
cur.execute(
"""INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%s,%s,%s,1,%s,%s)""",
(username, username, domain_name, now(), now())
)
log.info(f"Created mailbox + self-alias: {username}")
return incoming_users
def _sync_aliases(cur, domain_name: str, aliases: list, incoming_users: set, now):
"""Sync alias rows for a domain — upsert and delete stale entries."""
cur.execute("SELECT address FROM alias WHERE domain=%s", (domain_name,))
existing_aliases = {r["address"] for r in cur.fetchall()}
incoming_alias_addresses = {a["address"] for a in aliases}
# Remove stale aliases (keep self-aliases — managed with mailbox lifecycle)
for addr in existing_aliases - incoming_alias_addresses - incoming_users:
cur.execute("DELETE FROM alias WHERE address=%s AND domain=%s", (addr, domain_name))
log.info(f"Deleted alias: {addr}")
# Upsert
for alias in aliases:
address = alias["address"]
goto = alias["goto"]
alias_active = int(alias.get("active", True))
if address in existing_aliases:
cur.execute(
"UPDATE alias SET goto=%s, active=%s, modified=%s WHERE address=%s",
(goto, alias_active, now(), address)
)
else:
cur.execute(
"""INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%s,%s,%s,%s,%s,%s)""",
(address, goto, domain_name, alias_active, now(), now())
)
log.info(f"Created alias: {address}{goto}")
def handle_apply_domain(payload: dict, cfg: dict) -> dict:
"""
Sync domain row + all mailboxes + aliases for one domain.
Targets the netadmin_mail schema (not PostfixAdmin).
"""
domain_name = payload.get("domain")
config = payload.get("config", {})
accounts = payload.get("accounts", [])
aliases = payload.get("aliases", [])
if not domain_name:
return {"success": False, "error": "Missing 'domain' in payload"}
try:
conn = get_db(cfg)
with conn:
with conn.cursor() as cur:
_sync_domain_row(cur, domain_name, config, now)
incoming_users = _sync_mailboxes(cur, domain_name, accounts, now)
_sync_aliases(cur, domain_name, aliases, incoming_users, now)
conn.commit()
_reload_postfix(cfg)
log.info(f"apply_domain '{domain_name}' complete — "
f"{len(accounts)} mailbox(es), {len(aliases)} alias(es)")
return {"success": True}
except Exception as e:
log.error(f"apply_domain error for '{domain_name}': {e}")
return {"success": False, "error": str(e)}
def handle_delete_domain(payload: dict, cfg: dict) -> dict:
"""Remove all mailboxes and aliases for a domain, then the domain itself."""
domain_name = payload["domain"]
try:
conn = get_db(cfg)
with conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM mailbox WHERE domain=%s", (domain_name,))
cur.execute("DELETE FROM alias WHERE domain=%s", (domain_name,))
cur.execute("DELETE FROM domain WHERE domain=%s", (domain_name,))
conn.commit()
log.info(f"Domain '{domain_name}' deleted from MariaDB")
_reload_postfix(cfg)
return {"success": True}
except Exception as e:
log.error(f"delete_domain error: {e}")
return {"success": False, "error": str(e)}
def handle_get_quota_usage(payload: dict, cfg: dict) -> dict:
"""
Return current quota usage per mailbox for a domain.
Reads from quota2 table (Dovecot quota backend) if available.
"""
domain_name = payload["domain"]
try:
conn = get_db(cfg)
usage = {}
with conn:
with conn.cursor() as cur:
# quota2 is Dovecot's quota backend table — may not exist in netadmin_mail
# If absent, quota usage simply won't be reported (non-fatal)
cur.execute("SHOW TABLES LIKE 'quota2'")
has_quota2 = cur.fetchone() is not None
if has_quota2:
cur.execute(
"""SELECT username,
bytes AS used_bytes,
ROUND(bytes/1048576) AS used_mb
FROM quota2
WHERE username LIKE %s""",
(f"%@{domain_name}",)
)
for row in cur.fetchall():
usage[row["username"]] = {
"used_bytes": row["used_bytes"],
"used_mb": int(row["used_mb"] or 0),
}
else:
# Fallback: quota table (older Dovecot setup)
cur.execute(
"""SELECT username,
current_bytes AS used_bytes,
ROUND(current_bytes/1048576) AS used_mb
FROM quota
WHERE username LIKE %s""",
(f"%@{domain_name}",)
)
for row in cur.fetchall():
usage[row["username"]] = {
"used_bytes": row["used_bytes"],
"used_mb": int(row["used_mb"] or 0),
}
return {"success": True, "domain": domain_name, "usage": usage}
except Exception as e:
log.error(f"get_quota_usage error: {e}")
return {"success": False, "error": str(e)}
def _reload_postfix(cfg: dict):
cmd = cfg.get("postfix_reload_cmd", "postfix reload")
try:
result = subprocess.run(
cmd.split(), capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
log.warning(f"postfix reload: {result.stderr.strip()}")
else:
log.info("Postfix reloaded")
except FileNotFoundError:
log.warning(f"postfix command not found: '{cmd}'")
except Exception as e:
log.warning(f"postfix reload error: {e}")
def handle_get_state(payload: dict, cfg: dict) -> dict:
"""Return the full state of MariaDB for all domains (or a specific one).
This is the canonical source of truth — used by the backend to sync
its local SQLite cache at startup and on demand.
Payload:
{} → all domains
{"domain": "example.com"} → one domain only
Response:
{
"success": true,
"domains": [
{
"name": "example.com",
"active": true,
"max_accounts": 0,
"max_quota_mb": 0,
"accounts": [
{
"username": "user", # local part
"email": "user@example.com",
"quota_mb": 1024, # converted from bytes
"active": true
# password_hash intentionally NOT returned for security
}
],
"aliases": [
{"address": "alias@example.com", "goto": "dest@example.com", "active": true}
]
}
]
}
"""
filter_domain = payload.get("domain")
try:
conn = get_db(cfg)
result = []
with conn:
with conn.cursor() as cur:
# Fetch domains
if filter_domain:
cur.execute("SELECT * FROM domain WHERE domain=%s", (filter_domain,))
else:
cur.execute("SELECT * FROM domain WHERE active=1 ORDER BY domain")
domains = cur.fetchall()
for dom in domains:
domain_name = dom["domain"]
# Mailboxes — no local_part/maildir in netadmin_mail
cur.execute(
"""SELECT username, name, quota_mb, active
FROM mailbox
WHERE domain=%s
ORDER BY username""",
(domain_name,)
)
mailboxes = cur.fetchall()
# Aliases
cur.execute(
"""SELECT address, goto, active
FROM alias
WHERE domain=%s
ORDER BY address""",
(domain_name,)
)
aliases = cur.fetchall()
result.append({
"name": domain_name,
"active": bool(dom["active"]),
"max_accounts": int(dom.get("max_accounts", 0)),
"max_quota_mb": int(dom.get("max_quota_mb", 0)),
"mb_quota_mb": int(dom.get("mb_quota_mb", 0)),
"accounts": [
{
"username": m["username"],
"name": m["name"] or "",
"quota_mb": int(m["quota_mb"] or 0),
"active": bool(m["active"]),
}
for m in mailboxes
],
"aliases": [
{
"address": a["address"],
"goto": a["goto"],
"active": bool(a["active"]),
}
for a in aliases
],
})
return {"success": True, "domains": result}
except Exception as e:
log.error(f"get_state error: {e}")
return {"success": False, "error": str(e)}
def handle_generate_dkim(payload: dict, cfg: dict) -> dict:
"""
Generate a DKIM keypair for a domain using rspamadm dkim_keygen.
Writes private key and map config to the configured DKIM directory.
Payload: { "domain": "example.com", "selector": "mail" }
Returns: { "success": true, "selector": "mail", "txt_record": "v=DKIM1; k=rsa; p=..." }
"""
domain = payload["domain"]
selector = payload.get("selector") or cfg.get("dkim_selector", "mail")
dkim_dir = cfg.get("dkim_dir", "/var/lib/rspamd/dkim")
try:
Path(dkim_dir).mkdir(parents=True, exist_ok=True)
key_file = Path(dkim_dir) / f"{selector}.{domain}.key"
txt_file = Path(dkim_dir) / f"{selector}.{domain}.pub.txt"
# Generate keypair
result = subprocess.run(
["rspamadm", "dkim_keygen",
"-s", selector,
"-d", domain,
"-k", str(key_file)],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
log.error(f"dkim_keygen failed for {domain}: {err}")
return {"success": False, "error": f"rspamadm error: {err}"}
# rspamadm writes the TXT record content to stdout
txt_output = result.stdout.strip()
# Save for reference
txt_file.write_text(txt_output)
# Extract just the p= value for the TXT record
# rspamadm outputs: selector._domainkey.domain. IN TXT "v=DKIM1; k=rsa; p=MIIB..."
# (possibly split across multiple quoted strings)
import re
parts = re.findall(r'"([^"]+)"', txt_output)
txt_value = "".join(parts) # join multi-part TXT
if not txt_value:
# Fallback: try to extract from the raw output
m = re.search(r'(v=DKIM1[^"\n]+)', txt_output)
txt_value = m.group(1).strip() if m else txt_output
# Write Rspamd signing config entry
signing_conf = Path(dkim_dir) / "signing.conf"
entry = f'\n# {domain}\n{domain}\t{{\n\tpath = "{key_file}";\n\tselector = "{selector}";\n}}\n'
existing = signing_conf.read_text() if signing_conf.exists() else ""
if domain not in existing:
with open(signing_conf, "a") as f:
f.write(entry)
log.info(f"Added {domain} to {signing_conf}")
log.info(f"DKIM generated for {domain} selector={selector}")
return {
"success": True,
"domain": domain,
"selector": selector,
"txt_record": txt_value,
"key_file": str(key_file),
}
except FileNotFoundError:
return {"success": False, "error": "rspamadm not found — is Rspamd installed?"}
except subprocess.TimeoutExpired:
return {"success": False, "error": "rspamadm timed out"}
except Exception as e:
log.error(f"generate_dkim error: {e}")
return {"success": False, "error": str(e)}
HANDLERS = {
"apply_domain": handle_apply_domain,
"delete_domain": handle_delete_domain,
"get_quota_usage": handle_get_quota_usage,
"get_state": handle_get_state,
"generate_dkim": handle_generate_dkim,
}
# ──────────────────────────────────────────────
# Main loop
# ──────────────────────────────────────────────
def run(cfg: dict):
redis_kwargs = {
"host": cfg["redis_host"],
"port": int(cfg["redis_port"]),
"db": int(cfg["redis_db"]),
"decode_responses": True,
"socket_connect_timeout": 10, # timeout connexion initiale
"socket_timeout": 30, # timeout opérations
"socket_keepalive": True, # keepalive TCP
}
if cfg.get("redis_password"):
redis_kwargs["password"] = cfg["redis_password"]
tls_cfg = cfg.get("tls", {})
if tls_cfg.get("enabled", False):
redis_kwargs["ssl"] = True
redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"]
redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"]
redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"]
redis_kwargs["ssl_cert_reqs"] = "required"
log.info("Redis mTLS enabled")
else:
log.warning("Redis TLS disabled — traffic is unencrypted")
# Test DB connection on startup
try:
conn = get_db(cfg)
with conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) AS n FROM domain")
n = cur.fetchone()["n"]
log.info(f"MariaDB connected — {n} domain(s) in database")
except Exception as e:
log.error(f"Cannot connect to MariaDB: {e}")
sys.exit(1)
r = redis.Redis(**redis_kwargs)
r.ping()
log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}")
pubsub = r.pubsub()
pubsub.subscribe("mail.commands")
log.info("Subscribed to mail.commands — waiting for messages…")
# Announce reconnection so the backend can flush the pending queue
import socket as _socket
r.publish("agent.ready", json.dumps({"service": "mail", "host": _socket.gethostname()}))
log.info(f"Published agent.ready for service 'mail'")
for message in pubsub.listen():
if message["type"] != "message":
continue
try:
msg = json.loads(message["data"])
except (json.JSONDecodeError, KeyError):
log.warning("Malformed message received, ignored")
continue
# mTLS handles authentication — no token check needed
# Only clients with a valid CA-signed cert can connect to Redis
msg_id = msg.get("id", "unknown")
action = msg.get("action", "")
payload = msg.get("payload", {})
log.info(f"Received action '{action}' (id={msg_id})")
handler = HANDLERS.get(action)
if not handler:
ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"}
else:
ack = handler(payload, cfg)
ack["id"] = msg_id
r.publish("mail.acks", json.dumps(ack))
log.info(f"ACK for '{action}' (id={msg_id}): success={ack.get('success')}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="NetAdmin Mail Agent (PostfixAdmin/MySQL)")
parser.add_argument("--config", default="/etc/netadmin/config.yaml")
args = parser.parse_args()
if not Path(args.config).exists():
log.error(f"Config file not found: {args.config}")
sys.exit(1)
agent_cfg = {**DEFAULT_CFG, **load_config(args.config)}
log.info(
f"Mail agent starting "
f"(db={agent_cfg['db_user']}@{agent_cfg['db_host']}/{agent_cfg['db_name']})"
)
try:
run(agent_cfg)
except KeyboardInterrupt:
log.info("Mail agent stopped")
except Exception as e:
log.error(f"Fatal error: {e}")
sys.exit(1)