Files
netadmin/migrate_postfixadmin.py
kguerineau b12ebea0e8 refactor: refactorisation, validation et corrections mail/DNS
Refactorisation
- Hook useApi centralisé (hooks/useApi.js) — supprime 200 lignes dupliquées
- Helpers backend : get_or_404, _sync_domain_from_state, _sync_zone_from_state, _stable_id
- Constantes de rôles RBAC dans auth.py (ROLE_GLOBAL_ADMIN, etc.)
- handle_apply_domain découpé en _sync_domain_row / _sync_mailboxes / _sync_aliases (169→32 lignes)
- oidc_callback découpé en _exchange_oidc_token / _provision_oidc_user (159→54 lignes)
- Extraction composants : AccountModal, AliasModal, DnssecResultModal, DnssecManageModal
- Suppression MailingLists.jsx (inutilisé)

Corrections
- Notification de connexion différée après validation TOTP
- Création domaine : validation complète avant tout appel API (quota bal > quota global bloqué)
- Création domaine : service Mail non tenté si création DNS échoue
- Alias goto : validation email bloquante (frontend + backend)
- Alias : vérification conflit avec compte mail existant (409)
- delete_domain : notifie l'agent mail pour suppression dans netadmin_mail
- Clés DNSSEC archivées dans key_dir/old/ avant activation et à la suppression de zone
- _get_ds_records : exclut le dossier old/ (évitait la lecture de doublons)
- Modale TOTP : remplace window.confirm par une vraie modale
- "Mon compte" accessible sans rôle
- Suppression champ "Serveur API" de la sidebar
2026-04-24 15:36:29 +02:00

378 lines
14 KiB
Python

#!/usr/bin/env python3
"""
migrate_postfixadmin.py — Migration PostfixAdmin → netadmin_mail
================================================================
Lit depuis la base PostfixAdmin existante (base: postfix)
Crée et peuple la nouvelle base netadmin_mail
Usage:
python3 migrate_postfixadmin.py --dry-run # aperçu sans écriture
python3 migrate_postfixadmin.py # migration réelle
Configuration via variables d'environnement ou arguments :
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD
SOURCE_DB (défaut: postfix)
TARGET_DB (défaut: netadmin_mail)
"""
import argparse
import logging
import sys
from datetime import datetime
try:
import pymysql
import pymysql.cursors
except ImportError:
print("ERROR: pip install PyMySQL")
sys.exit(1)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [migrate] %(levelname)s %(message)s"
)
log = logging.getLogger("migrate")
# ── Config ────────────────────────────────────────────────────────────────
def get_args():
p = argparse.ArgumentParser(description="Migrate PostfixAdmin → netadmin_mail")
p.add_argument("--host", default="localhost")
p.add_argument("--port", default=3306, type=int)
p.add_argument("--user", default="root")
p.add_argument("--password", default="")
p.add_argument("--source-db", default="postfix")
p.add_argument("--target-db", default="netadmin_mail")
p.add_argument("--dry-run", action="store_true",
help="Affiche ce qui sera fait sans écrire en base")
return p.parse_args()
def connect(args, database=None):
return pymysql.connect(
host=args.host, port=args.port,
user=args.user, password=args.password,
database=database,
cursorclass=pymysql.cursors.DictCursor,
charset="utf8mb4",
)
# ── Schéma cible ──────────────────────────────────────────────────────────
SCHEMA = """
CREATE TABLE IF NOT EXISTS domain (
domain VARCHAR(255) NOT NULL PRIMARY KEY,
description VARCHAR(255) 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() ON UPDATE NOW()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mailbox (
username VARCHAR(255) NOT NULL PRIMARY KEY,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
domain VARCHAR(255) 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() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alias (
address VARCHAR(255) NOT NULL PRIMARY KEY,
goto TEXT NOT NULL,
domain VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sender_login_maps (
sender VARCHAR(255) NOT NULL PRIMARY KEY,
authorized TEXT NOT NULL,
domain VARCHAR(255) NOT NULL,
created DATETIME NOT NULL DEFAULT NOW(),
modified DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (domain) REFERENCES domain(domain) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
def create_schema(conn):
with conn.cursor() as cur:
for stmt in SCHEMA.strip().split(";"):
stmt = stmt.strip()
if stmt:
cur.execute(stmt)
conn.commit()
log.info("Schéma netadmin_mail créé ✓")
# ── Helpers mot de passe ──────────────────────────────────────────────────
def normalize_password(raw: str) -> str | None:
"""
Normalise le mot de passe vers le format {SHA512-CRYPT}.
- Déjà {SHA512-CRYPT}xxx → conservé tel quel
- Commence par $6$ → préfixe {SHA512-CRYPT} ajouté
- $1$ (MD5) ou $2 (bcrypt) → non migrables → None
- Vide → None
"""
if not raw:
return None
if raw.startswith("{SHA512-CRYPT}"):
return raw
if raw.startswith("$6$"):
return "{SHA512-CRYPT}" + raw
# MD5-CRYPT ($1$), bcrypt ($2$/$2a$/$2y$), DES — non migrables
return None
# ── Migration ─────────────────────────────────────────────────────────────
def migrate_domains(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM domain WHERE domain != 'ALL'")
rows = cur.fetchall()
log.info(f"Domaines trouvés : {len(rows)}")
ok = skip = 0
for row in rows:
domain = row["domain"]
# PostfixAdmin quota est en Mo (quota global du domaine)
max_quota = int(row.get("quota", 0))
# maxquota = quota par BAL en Mo
mb_quota = int(row.get("maxquota", 0))
# aliases = limite d'alias par domaine
max_aliases = int(row.get("aliases", 0))
data = {
"domain": domain,
"description": row.get("description", ""),
"max_accounts": row.get("mailboxes", 0),
"max_aliases": max_aliases,
"max_quota_mb": max_quota,
"mb_quota_mb": mb_quota,
"active": int(row.get("active", 1)),
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
log.info(f" [DRY] domain: {domain} (max_accounts={data['max_accounts']})")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO domain
(domain, description, max_accounts, max_aliases, max_quota_mb, mb_quota_mb,
active, created, modified)
VALUES (%(domain)s, %(description)s, %(max_accounts)s, %(max_aliases)s,
%(max_quota_mb)s, %(mb_quota_mb)s, %(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
max_accounts=VALUES(max_accounts),
max_aliases=VALUES(max_aliases),
max_quota_mb=VALUES(max_quota_mb),
mb_quota_mb=VALUES(mb_quota_mb),
modified=NOW()
""", data)
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" domaine {domain}: {e}")
skip += 1
log.info(f"Domaines : {ok} migrés, {skip} ignorés")
def migrate_mailboxes(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM mailbox")
rows = cur.fetchall()
log.info(f"Boîtes trouvées : {len(rows)}")
ok = skip = no_pwd = 0
for row in rows:
username = row["username"]
newpassword = row.get("newpassword", "")
raw_pwd = row.get("password", "")
# Priorité : newpassword (déjà SHA512) s'il est rempli
if newpassword and newpassword.strip():
password = normalize_password(newpassword.strip())
if password is None:
password = normalize_password(raw_pwd)
else:
password = normalize_password(raw_pwd)
if password is None:
log.warning(f" {username}: mot de passe non migrable ({raw_pwd[:10]}...) — compte marqué MIGRATION_REQUIRED")
password = "{MIGRATION_REQUIRED}"
no_pwd += 1
# PostfixAdmin mailbox quota est en octets (ex: 10485760000 ≈ 10 Go)
# NetAdmin utilise les Mo → diviser par 1024*1024
quota_raw = int(row.get("quota", 0))
quota_mb = quota_raw // (1024 * 1024) if quota_raw > 0 else 10000
if quota_mb == 0:
quota_mb = 10000 # défaut 10 Go (= 10000 Mo comme PostfixAdmin)
# Désactiver les comptes dont le mot de passe n'a pas pu être migré
active = 0 if password == "{MIGRATION_REQUIRED}" else int(row.get("active", 1))
data = {
"username": username,
"password": password,
"name": row.get("name", ""),
"domain": row["domain"],
"quota_mb": quota_mb,
"active": active,
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
pwd_status = "OK" if not password.startswith("{MIGRATION_REQUIRED}") else "RESET REQUIS"
log.info(f" [DRY] mailbox: {username} ({pwd_status}, quota={quota_mb}Mo)")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO mailbox
(username, password, name, domain, quota_mb, active, created, modified)
VALUES (%(username)s, %(password)s, %(name)s, %(domain)s,
%(quota_mb)s, %(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
password=VALUES(password), name=VALUES(name),
quota_mb=VALUES(quota_mb), modified=NOW()
""", data)
# Self-alias requis pour virtual_alias_maps
cur.execute("""
INSERT INTO alias (address, goto, domain, active, created, modified)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE goto=VALUES(goto), modified=NOW()
""", (username, username, data["domain"],
data["active"], data["created"], data["modified"]))
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" mailbox {username}: {e}")
skip += 1
log.info(f"Boîtes : {ok} migrées ({no_pwd} avec reset requis), {skip} ignorées")
def migrate_aliases(src, tgt, dry_run):
with src.cursor() as cur:
cur.execute("SELECT * FROM alias")
rows = cur.fetchall()
log.info(f"Alias trouvés : {len(rows)}")
ok = skip = ignored = 0
for row in rows:
address = row["address"]
goto = row.get("goto", "")
# Ignorer les alias auto (address == goto) — déjà créés avec la BAL
if address == goto:
ignored += 1
continue
# Ignorer les catch-all vides
if not goto.strip():
ignored += 1
continue
# Ignorer le domaine ALL (PostfixAdmin)
if row.get("domain") == "ALL":
ignored += 1
continue
data = {
"address": address,
"goto": goto,
"domain": row["domain"],
"active": int(row.get("active", 1)),
"created": row.get("created", datetime.now()),
"modified": row.get("modified", datetime.now()),
}
if dry_run:
log.info(f" [DRY] alias: {address}{goto[:60]}")
ok += 1
continue
try:
with tgt.cursor() as cur:
cur.execute("""
INSERT INTO alias
(address, goto, domain, active, created, modified)
VALUES (%(address)s, %(goto)s, %(domain)s,
%(active)s, %(created)s, %(modified)s)
ON DUPLICATE KEY UPDATE
goto=VALUES(goto), modified=NOW()
""", data)
tgt.commit()
ok += 1
except Exception as e:
log.warning(f" alias {address}: {e}")
skip += 1
log.info(f"Alias : {ok} migrés, {ignored} ignorés (auto/vides), {skip} erreurs")
# ── Main ──────────────────────────────────────────────────────────────────
def main():
args = get_args()
if args.dry_run:
log.info("=== MODE DRY-RUN — aucune écriture ===")
# Connexion source
log.info(f"Connexion source: {args.source_db}@{args.host}")
src = connect(args, args.source_db)
if not args.dry_run:
# Créer la base cible si nécessaire
admin = connect(args)
with admin.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{args.target_db}` "
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
admin.commit()
admin.close()
log.info(f"Base {args.target_db} créée ✓")
tgt = connect(args, args.target_db)
create_schema(tgt)
else:
tgt = None
# Migration
migrate_domains(src, tgt, args.dry_run)
migrate_mailboxes(src, tgt, args.dry_run)
migrate_aliases(src, tgt, args.dry_run)
src.close()
if tgt:
tgt.close()
log.info("=== Migration terminée ===")
if args.dry_run:
log.info("Relancez sans --dry-run pour appliquer.")
if __name__ == "__main__":
main()