diff --git a/README.md b/README.md index 6b840ca..d5686e2 100644 --- a/README.md +++ b/README.md @@ -1,368 +1,290 @@ # NetAdmin — Console DNS & Mail -Interface d'administration centralisée pour la gestion des zones DNS (BIND9) et des comptes mail (PostfixAdmin/MariaDB), avec authentification locale et SSO (Keycloak/OIDC), double authentification TOTP, audit complet et file d'attente des synchronisations vers les agents distants. +Interface d'administration DNS et Mail pour serveurs BIND9 + Postfix/Dovecot. --- ## Architecture ``` -┌─────────────────────────────────────────────┐ -│ Frontend (React/Vite) :3000 │ -│ LoginPage · DNSManager · MailManager │ -│ UsersManager · AuditLog · SyncDashboard │ -└───────────────┬─────────────────────────────┘ - │ HTTP/JSON -┌───────────────▼─────────────────────────────┐ -│ Backend FastAPI :8000 │ -│ routers/auth · dns · mail │ -│ audit · broker · notify │ -└──────┬──────────────────────┬───────────────┘ - │ PyMySQL │ Redis (mTLS) -┌──────▼──────┐ ┌───────────▼──────────────┐ -│ MariaDB │ │ Redis │ -│ netadmin │ │ dns.commands/acks │ -└─────────────┘ │ mail.commands/acks │ - │ agent.ready │ - └──────┬──────────────┬─────┘ - ┌──────────▼──┐ ┌──────▼──────────┐ - │ dns_agent │ │ mail_agent │ - │ (serveur │ │ (serveur mail) │ - │ BIND9) │ │ │ - └─────────────┘ └──────────────────┘ +┌─────────────────────────────────────────────────────────┐ +│ Frontend React (Vite) :3000 │ +│ Backend FastAPI :8000 │ +│ MariaDB (base: netadmin) — données NetAdmin │ +│ MariaDB (base: netadmin_mail) — données Postfix │ +│ Redis mTLS broker :6380 │ +│ │ │ +│ dns_agent.py ←→ dns.commands/acks → BIND9 │ +│ mail_agent.py ←→ mail.commands/acks → Postfix │ +└─────────────────────────────────────────────────────────┘ ``` -### Composants - -| Composant | Rôle | -|-----------|------| -| **Frontend** | React 18 + Vite — interface utilisateur | -| **Backend** | FastAPI — API REST, authentification, RBAC | -| **MariaDB** | Base unique `netadmin` — toutes les tables | -| **Redis** | Broker de messages mTLS entre backend et agents | -| **dns_agent** | Tourne sur le serveur BIND9 — écrit les fichiers de zone, exécute `rndc` | -| **mail_agent** | Tourne sur le serveur mail — gère PostfixAdmin via MariaDB | - ---- - -## Prérequis - -### Serveur principal (Backend + Frontend) -- Python 3.11+ -- Node.js 18+ -- MariaDB 10.6+ -- Redis 7+ - -### Serveur BIND9 (agent DNS) -- Python 3.11+ -- BIND9 avec `rndc` configuré - -### Serveur Mail (agent mail) -- Python 3.11+ -- PostfixAdmin avec MariaDB -- Accès à la base PostfixAdmin - --- ## Installation -### 1. Base de données MariaDB +### Prérequis -```sql -CREATE DATABASE netadmin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'netadmin'@'localhost' IDENTIFIED BY 'motdepasse'; -GRANT ALL PRIVILEGES ON netadmin.* TO 'netadmin'@'localhost'; -FLUSH PRIVILEGES; -``` +- Python 3.11+ +- Node.js 18+ +- MariaDB 10.6+ +- Redis 7+ +- BIND9 9.20+ +- Postfix + Dovecot +- Rspamd (optionnel, pour DKIM) -Les tables sont créées automatiquement au premier démarrage du backend. - -### 2. Backend +### Backend ```bash cd backend/ -python3 -m venv venv -source venv/bin/activate +python3 -m venv ../venv +source ../venv/bin/activate pip install -r requirements.txt -``` - -Copier et adapter la configuration : - -```bash -cp config.yaml.example config.yaml -# Éditer config.yaml (voir section Configuration) -``` - -Démarrer : - -```bash +cp config.yaml /etc/netadmin/config.yaml # adapter les valeurs uvicorn main:app --host 0.0.0.0 --port 8000 ``` -Un compte `admin` / `admin` est créé automatiquement si la base est vide. **Changer le mot de passe immédiatement.** - -### 3. Frontend +### Frontend ```bash cd frontend/ npm install -npm run build # Production +npm run build # production # ou -npm run dev # Développement +npm run dev # développement :3000 ``` -Le build de production est dans `frontend/dist/` — à servir via nginx ou autre. - -### 4. Certificats mTLS Redis +### Agents ```bash +# Agent DNS (sur le serveur BIND9) cd agents/ -bash gen_certs.sh +pip install -r requirements.txt +cp ../backend/config.yaml /etc/netadmin/config.yaml # section dns_agent +python3 dns_agent.py + +# Agent Mail (sur le serveur Postfix) +python3 mail_agent.py ``` -Cela génère : -- `certs/ca.crt` — Autorité de certification interne -- `certs/backend.crt/key` — Certificat du backend -- `certs/redis-server.crt/key` — Certificat Redis -- `certs/dns-agent.crt/key` — Certificat agent DNS -- `certs/mail-agent.crt/key` — Certificat agent mail - -Distribuer les certificats sur les serveurs concernés. - -### 5. Agent DNS (sur le serveur BIND9) +### Services systemd ```bash -# Copier dns_agent.py et requirements.txt -pip install -r requirements.txt - -# Copier le fichier systemd -cp netadmin-dns-agent.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now netadmin-dns-agent -``` - -Adapter le fichier service pour pointer vers les bons chemins et la config Redis. - -### 6. Agent Mail (sur le serveur mail) - -```bash -# Copier mail_agent.py et requirements.txt -pip install -r requirements.txt - -cp netadmin-mail-agent.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now netadmin-mail-agent +cp agents/netadmin-dns-agent.service /etc/systemd/system/ +cp agents/netadmin-mail-agent.service /etc/systemd/system/ +systemctl enable --now netadmin-dns-agent netadmin-mail-agent ``` --- -## Configuration (`config.yaml`) +## Configuration + +### `backend/config.yaml` ```yaml -# ── Serveur ─────────────────────────────────────────────────────────────── -server: - host: "0.0.0.0" - port: 8000 - cors_origins: - - "http://localhost:3000" - - "https://netadmin.example.com" +dns: + default_ns: + - "ns1.example.fr." + - "ns2.example.fr." + default_soa_admin: "hostmaster.example.fr" + dnssec_enabled: true + key_dir: "/etc/bind/keys" -# ── Base de données MariaDB ─────────────────────────────────────────────── -database: - host: "localhost" - port: 3306 - user: "netadmin" - password: "motdepasse" - name: "netadmin" +mail_default_aliases: + abuse: "postmaster@example.fr" + hostmaster: "postmaster@example.fr" + postmaster: "admin@example.fr" + webmaster: "admin@example.fr" -# ── Redis (broker agents) ───────────────────────────────────────────────── -redis: - host: "localhost" - port: 6380 # Port mTLS - db: 0 - ack_timeout: 15 - tls: - enabled: true - client_cert: "/etc/netadmin/certs/backend.crt" - client_key: "/etc/netadmin/certs/backend.key" - ca_cert: "/etc/netadmin/certs/ca.crt" +dns_agent: + zones_dir: "/etc/bind/zones" + named_conf_local: "/etc/bind/named.conf.local" + rndc_cmd: "rndc" + key_dir: "/etc/bind/keys" + dnssec_policy: "default" -# ── Notifications email ─────────────────────────────────────────────────── -smtp: - enabled: false - host: "smtp.example.com" - port: 587 - username: "" - password: "" - from_addr: "netadmin@example.com" - from_name: "NetAdmin" - use_tls: true # STARTTLS - use_ssl: false # SSL direct (port 465) - notify_login: true # Email à chaque connexion - notify_role_added: true # Email lors de l'ajout d'un rôle +mail_agent: + dkim_dir: "/var/lib/rspamd/dkim" + dkim_selector: "mail" +``` -# ── Authentification ────────────────────────────────────────────────────── -auth: - session_secret: "" # Généré automatiquement si vide - session_ttl_hours: 8 - oidc: - enabled: false - issuer: "https://keycloak.example.com/realms/netadmin" - client_id: "netadmin" - client_secret: "" - redirect_uri: "https://netadmin.example.com" - use_pkce: true - admin_group: "netadmin-admins" +### Postfix `main.cf` -# ── Synchronisation ─────────────────────────────────────────────────────── -sync: - interval: 300 # Polling automatique toutes les 5 minutes (0 = désactivé) +```ini +virtual_mailbox_domains = mysql:/etc/postfix/mysql_virtual_mailbox_domains.cf +virtual_mailbox_maps = mysql:/etc/postfix/mysql_virtual_mailbox_maps.cf +virtual_alias_maps = mysql:/etc/postfix/mysql_virtual_alias_maps.cf +smtpd_sender_login_maps = mysql:/etc/postfix/mysql_sender_login_maps.cf +smtpd_sender_restrictions = reject_sender_login_mismatch, permit +``` + +### Fichiers `.cf` Postfix (pointent sur `netadmin_mail`) + +```ini +# mysql_virtual_mailbox_domains.cf +query = SELECT domain FROM domain WHERE domain='%s' AND active=1 + +# mysql_virtual_mailbox_maps.cf +query = SELECT CONCAT(domain,'/',SUBSTRING_INDEX(username,'@',1),'/') + FROM mailbox WHERE username='%s' AND active=1 + +# mysql_virtual_alias_maps.cf +query = SELECT goto FROM alias WHERE address='%s' AND active=1 + +# mysql_sender_login_maps.cf +query = SELECT authorized FROM sender_login_maps WHERE sender='%s' ``` --- -## Rôles et permissions +## Migration depuis PostfixAdmin -| Rôle | Périmètre | Accès | -|------|-----------|-------| -| `global_admin` | Global | Tout | -| `dns_admin` | Global | Toutes les zones DNS | -| `mail_admin` | Global | Tous les domaines mail | -| `domain_admin` | Par domaine | DNS + mail d'un domaine | -| `dns_zone_admin` | Par zone | Enregistrements d'une zone | -| `mail_domain_admin` | Par domaine | Comptes et alias d'un domaine | +```bash +# Aperçu sans modification +python3 migrate_postfixadmin.py --dry-run \ + --user root --password xxx \ + --source-db postfix --target-db netadmin_mail + +# Migration réelle +python3 migrate_postfixadmin.py \ + --user root --password xxx \ + --source-db postfix --target-db netadmin_mail +``` + +Le script migre : domaines, boîtes aux lettres (SHA512-CRYPT conservé), alias. +Les mots de passe `$1$` (MD5) ou `$2$` (bcrypt) sont marqués `{MIGRATION_REQUIRED}` +et le compte est désactivé — reset admin requis. + +### Scripts SQL de correction + +```bash +# Corriger les alias avec source en partie locale seulement +mysql netadmin -e "UPDATE aliases SET source = CONCAT(source, '@', domain) WHERE source NOT LIKE '%@%';" + +# Corriger les double domaines dans mailbox (si migration partielle) +mysql netadmin_mail -e " +UPDATE mailbox +SET username = CONCAT(SUBSTRING_INDEX(username,'@',1),'@',SUBSTRING_INDEX(username,'@',-1)) +WHERE username LIKE '%@%@%';" +``` --- ## Fonctionnalités ### DNS -- Gestion des zones BIND9 (CRUD) -- Enregistrements A, AAAA, CNAME, MX, TXT, NS, PTR, SRV, CAA, TLSA, DS -- Activation/désactivation d'enregistrements (commentés dans la zone avec `; [DISABLED]`) -- Rechargement de zone via `rndc reload` -- Éditeur de zone brute -- Indicateur ⏳ sur les enregistrements en attente de synchronisation +- Gestion des zones BIND9 (création via Domaines uniquement) +- Enregistrements : A, AAAA, CNAME, MX, TXT, NS, PTR, SRV, CAA, TLSA, DS +- DNSSEC par zone (activation/désactivation, récupération des clés DS) +- NS par défaut configurables, email SOA admin par défaut +- Archivage automatique des clés DNSSEC dans `key_dir/old/` ### Mail -- Gestion des comptes (quota, activation/désactivation) -- Gestion des alias (destinations multiples) -- Générateur de mot de passe aléatoire -- Synchronisation depuis PostfixAdmin (MariaDB) - -### Authentification & Sécurité -- Connexion locale (login/mot de passe) -- SSO Keycloak / OpenID Connect avec PKCE -- Double authentification TOTP (Google Authenticator, Aegis, Authy…) -- TOTP obligatoire configurable par compte (par l'admin) -- Gestion des sessions avec expiration +- Gestion des domaines mail avec quotas (global + par BAL) +- Boîtes aux lettres avec quota individuel et nom affiché +- Alias avec activation/désactivation +- DKIM via Rspamd (`rspamadm dkim_keygen`) +- Alias RFC obligatoires créés automatiquement (abuse, postmaster, hostmaster, webmaster) +- Sender login maps (prochainement) +- Mots de passe hashés en `{SHA512-CRYPT}` via `passlib` ### Administration -- Gestion des utilisateurs et des rôles -- Journal d'audit complet (qui, quand, quoi, depuis quelle IP) -- File d'attente des synchronisations (avec retry automatique à la reconnexion des agents) -- Historique des synchronisations (déclencheur, durée, résultat) -- Notifications email (connexion, ajout de rôle) +- Authentification locale + OpenID Connect (Keycloak) +- TOTP (2FA) par compte, obligatoire ou optionnel +- RBAC : global_admin, dns_admin, mail_admin, domain_admin, dns_zone_admin +- Préférences utilisateur (thème clair/sombre) persistées en BDD +- Audit log complet +- Notifications email à la connexion -### Résilience -- Si un agent est indisponible : l'action est mise en file d'attente SQLite -- À la reconnexion de l'agent, la file est vidée automatiquement -- Badge ⏳ dans l'interface pour les modifications non encore appliquées +### Interface +- Thème sombre / clair +- Création de domaine unifiée (DNS + Mail) +- Validation des enregistrements DNS (FQDN, IPv4, IPv6) +- Affichage des enregistrements DS détaillés (Key Tag, Algorithme, Digest) +- Interface entièrement en français --- -## Structure du projet +## Sécurité + +### Certificats mTLS Redis + +```bash +cd agents/ +bash gen_certs.sh # génère CA + certificats agents +``` + +Copier les certificats dans `/etc/netadmin/certs/`. + +### Premier compte admin + +Au premier démarrage, NetAdmin crée automatiquement : +- Utilisateur : `admin` +- Mot de passe : `admin` +- **Changer immédiatement après connexion.** + +--- + +## Structure des fichiers ``` netadmin/ ├── backend/ -│ ├── main.py # Point d'entrée FastAPI +│ ├── main.py # FastAPI app │ ├── config.py # Dataclasses de configuration -│ ├── config.yaml # Configuration (à adapter) -│ ├── db.py # Connexion MariaDB partagée -│ ├── audit.py # Journal d'audit + historique sync -│ ├── broker.py # Broker Redis + file d'attente -│ ├── notify.py # Notifications email (SMTP) +│ ├── config.yaml # Configuration principale +│ ├── db.py # Connexion MariaDB (netadmin) +│ ├── broker.py # File Redis + pending_actions +│ ├── audit.py # Audit log +│ ├── notify.py # Notifications email │ ├── requirements.txt │ └── routers/ -│ ├── auth.py # Authentification, RBAC, TOTP -│ ├── dns.py # Zones et enregistrements DNS -│ └── mail.py # Comptes, alias, domaines mail +│ ├── auth.py # Auth locale + OIDC + TOTP + RBAC +│ ├── dns.py # Zones + enregistrements + DNSSEC +│ └── mail.py # Domaines + comptes + alias + DKIM +├── agents/ +│ ├── dns_agent.py # Agent BIND9 +│ ├── mail_agent.py # Agent Postfix/Dovecot/Rspamd +│ ├── requirements.txt +│ ├── gen_certs.sh # Génération certificats mTLS +│ ├── netadmin-dns-agent.service +│ └── netadmin-mail-agent.service ├── frontend/ -│ ├── index.html +│ ├── src/ +│ │ ├── App.jsx # Routing + sidebar + auth +│ │ ├── App.css # Thème sombre/clair +│ │ ├── main.jsx # Point d'entrée + polices +│ │ └── components/ +│ │ ├── LoginPage.jsx +│ │ ├── DomainsManager.jsx # DNS + Mail par domaine +│ │ ├── DNSManager.jsx # Enregistrements + DNSSEC +│ │ ├── MailManager.jsx # Comptes + alias +│ │ ├── UsersManager.jsx # Utilisateurs + rôles +│ │ ├── UserProfile.jsx # Mon compte + 2FA + thème +│ │ ├── AuditLog.jsx +│ │ ├── SyncDashboard.jsx +│ │ └── SyncToast.jsx │ ├── package.json -│ ├── vite.config.js -│ └── src/ -│ ├── App.jsx -│ ├── App.css -│ ├── main.jsx -│ └── components/ -│ ├── LoginPage.jsx -│ ├── DNSManager.jsx -│ ├── MailManager.jsx -│ ├── DomainsManager.jsx -│ ├── UsersManager.jsx -│ ├── UserProfile.jsx -│ ├── AuditLog.jsx -│ ├── SyncDashboard.jsx -│ ├── SyncToast.jsx -│ └── MailingLists.jsx -└── agents/ - ├── dns_agent.py # Agent BIND9 - ├── mail_agent.py # Agent PostfixAdmin - ├── gen_certs.sh # Génération certificats mTLS - ├── requirements.txt - ├── netadmin-dns-agent.service - └── netadmin-mail-agent.service +│ └── vite.config.js +├── migrate_postfixadmin.py # Migration depuis PostfixAdmin +├── fix_alias_source.sql # Correction alias partiels +├── fix_double_domain.sql # Correction double domaine +└── README.md ``` --- -## Déploiement nginx (exemple) +## Bases de données -```nginx -# Frontend -server { - listen 443 ssl; - server_name netadmin.example.com; +### `netadmin` (gestion NetAdmin) +- `users`, `user_roles`, `sessions`, `user_preferences` +- `zones`, `records` (DNS) +- `domains`, `accounts`, `aliases` (Mail NetAdmin) +- `pending_actions`, `audit_log`, `sync_history` - root /opt/netadmin/frontend/dist; - index index.html; +### `netadmin_mail` (lue par Postfix/Dovecot) +- `domain` — domaines hébergés +- `mailbox` — boîtes aux lettres (username = email complet) +- `alias` — alias et redirections (inclut self-aliases pour virtual_alias_maps) +- `sender_login_maps` — autorisations d'envoi - location / { - try_files $uri $uri/ /index.html; - } - - location /api/ { - proxy_pass http://127.0.0.1:8000/; - proxy_set_header X-Forwarded-For $remote_addr; - } -} -``` - ---- - -## Systemd (backend) - -```ini -[Unit] -Description=NetAdmin Backend -After=network.target mariadb.service redis.service - -[Service] -Type=simple -User=netadmin -WorkingDirectory=/opt/netadmin/backend -ExecStart=/opt/netadmin/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - ---- - -## Licence - -Usage interne — tous droits réservés. diff --git a/agents/dns_agent.py b/agents/dns_agent.py index 5f4831f..ba62f5d 100644 --- a/agents/dns_agent.py +++ b/agents/dns_agent.py @@ -63,6 +63,8 @@ DEFAULT_CFG = { "zones_dir": "/etc/bind/zones", "named_conf_local": "/etc/bind/named.conf.local", "rndc_cmd": "rndc", + "key_dir": "/etc/bind/keys", # DNSSEC key directory + "dnssec_policy": "default", # BIND9 dnssec-policy name "redis_host": "localhost", "redis_port": 6379, "redis_password": "", @@ -270,6 +272,9 @@ def handle_delete_zone(payload: dict, cfg: dict) -> dict: path = Path(cfg["zones_dir"]) / f"db.{zone_name}" if path.exists(): path.unlink() + # Archive DNSSEC keys for this zone + key_dir = cfg.get("key_dir", "/etc/bind/keys") + _archive_zone_keys(zone_name, key_dir) try: subprocess.run([cfg["rndc_cmd"], "reconfig"], capture_output=True, timeout=10) except Exception: @@ -468,6 +473,316 @@ def handle_reload_zone(payload: dict, cfg: dict) -> dict: return {"success": False, "error": str(e)} + +def _update_zone_dnssec_in_named_conf(zone_name: str, named_conf: str, + dnssec_policy: str, enable: bool, + key_dir: str = "/etc/bind/keys") -> bool: + """ + Add or remove dnssec-policy + inline-signing in the zone block. + Returns True if the file was modified. + """ + path = Path(named_conf) + if not path.exists(): + return False + + content = path.read_text() + # Find the zone block + import re + pattern = re.compile( + rf'(zone\s+"{re.escape(zone_name)}"\s*\{{)([^}}]+)(\}};)', + re.DOTALL + ) + m = pattern.search(content) + if not m: + log.warning(f"Zone '{zone_name}' not found in {named_conf}") + return False + + zone_block_inner = m.group(2) + + if enable: + # Add dnssec directives if not already present + if "dnssec-policy" in zone_block_inner: + return False # already configured + extra = ( + f'\n dnssec-policy "{dnssec_policy}";' + f'\n key-directory "{key_dir}";' + ) + new_inner = zone_block_inner.rstrip() + extra + "\n" + else: + # Remove dnssec directives + new_inner = re.sub(r'\n\s*dnssec-policy[^;]+;', '', zone_block_inner) + new_inner = re.sub(r'\n\s*inline-signing[^;]+;', '', new_inner) + new_inner = re.sub(r'\n\s*key-directory[^;]+;', '', new_inner) + if new_inner == zone_block_inner: + return False # nothing to remove + + new_block = m.group(1) + new_inner + m.group(3) + path.write_text(content[:m.start()] + new_block + content[m.end():]) + log.info(f"{'Enabled' if enable else 'Disabled'} DNSSEC for zone '{zone_name}' in {named_conf}") + return True + + +def _get_ds_records(zone_name: str, key_dir: str, rndc_cmd: str = "rndc") -> list: + """ + Extract DS records for a zone. Tries multiple strategies: + 1. rndc dnssec -status — extracts DS from BIND's key state (BIND 9.17+) + 2. dig DS @127.0.0.1 — query the live signed zone + 3. dnssec-dsfromkey on .key files in key_dir — fallback for older setups + """ + ds_records = [] + + # Strategy 1: rndc dnssec -status (BIND 9.17+ with dnssec-policy) + try: + r = subprocess.run( + [rndc_cmd, "dnssec", "-status", zone_name], + capture_output=True, text=True, timeout=10 + ) + if r.returncode == 0: + import re + # Extract lines like "DS: 12345 8 2 ABCDEF..." + for line in r.stdout.splitlines(): + line = line.strip() + if re.match(r"DS:", line): + # Convert to full DS RR format + ds_val = line[3:].strip() + ds_records.append(f"{zone_name}. 0 IN DS {ds_val}") + if ds_records: + log.info(f"DS records from rndc dnssec -status: {len(ds_records)}") + return ds_records + except Exception as e: + log.debug(f"rndc dnssec -status failed: {e}") + + # Strategy 2: dig DS @127.0.0.1 (zone must be signed and answering) + try: + r = subprocess.run( + ["dig", "+noall", "+answer", f"@127.0.0.1", "DS", zone_name], + capture_output=True, text=True, timeout=10 + ) + if r.returncode == 0 and "DS" in r.stdout: + ds_lines = [l.strip() for l in r.stdout.splitlines() + if "DS" in l and not l.startswith(";")] + if ds_lines: + log.info(f"DS records from dig: {len(ds_lines)}") + return ds_lines + except FileNotFoundError: + log.debug("dig not available") + except Exception as e: + log.debug(f"dig DS failed: {e}") + + # Strategy 3: scan key files and run dnssec-dsfromkey + key_path = Path(key_dir) + search_dirs = [] + for d in [key_path, Path("/etc/bind/keys"), Path("/var/cache/bind")]: + if d.exists() and d not in search_dirs: + search_dirs.append(d) + + seen_files = set() + for search_dir in search_dirs: + if not search_dir.exists(): + continue + # glob only in the directory itself — never recurse into old/ + for key_file in search_dir.glob(f"K{zone_name}.+*.key"): + if key_file in seen_files: + continue + seen_files.add(key_file) + try: + key_content = key_file.read_text() + # KSK = flags 257 (SEP bit set), ZSK = flags 256 + if " 257 " not in key_content: + continue + r = subprocess.run( + ["dnssec-dsfromkey", str(key_file)], + capture_output=True, text=True, timeout=10 + ) + if r.returncode == 0 and r.stdout.strip(): + for line in r.stdout.strip().splitlines(): + if line not in ds_records: + ds_records.append(line) + log.info(f"DS from dnssec-dsfromkey: {key_file}") + except Exception as e: + log.warning(f"dnssec-dsfromkey error for {key_file}: {e}") + + return ds_records + + +def _archive_zone_keys(zone_name: str, key_dir: str): + """ + Move existing DNSSEC key files for zone_name into key_dir/old/YYYYMMDD-HHMMSS/. + Called before enabling DNSSEC (to avoid duplicate keys) and on zone deletion. + """ + from datetime import datetime + key_path = Path(key_dir) + if not key_path.exists(): + return + # Find all key files for this zone + files = list(key_path.glob(f"K{zone_name}.+*")) + if not files: + return + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + old_dir = key_path / "old" / f"{ts}_{zone_name}" + old_dir.mkdir(parents=True, exist_ok=True) + for f in files: + dest = old_dir / f.name + f.rename(dest) + log.info(f"Archived key {f.name} → {dest}") + log.info(f"Archived {len(files)} key(s) for '{zone_name}' to {old_dir}") + + +def handle_enable_dnssec(payload: dict, cfg: dict) -> dict: + """ + Enable or disable DNSSEC for a zone in named.conf.local. + + key_dir is read exclusively from the agent config (cfg["key_dir"]). + The payload only controls: zone, action, dnssec_policy. + + Actions: + enable — add dnssec-policy + key-directory to zone block, rndc reconfig + disable — remove dnssec directives, rndc reconfig + get_ds — return current DS records without modifying anything + + Zone block result in named.conf.local: + zone "example.com" { + type primary; + file "/etc/bind/zones/db.example.com"; + dnssec-policy "default"; + key-directory "/etc/bind/keys"; + }; + """ + zone_name = payload["zone"] + action = payload.get("action", "enable") + dnssec_policy = payload.get("dnssec_policy") or cfg.get("dnssec_policy", "default") + named_conf = cfg.get("named_conf_local", "/etc/bind/named.conf.local") + key_dir = cfg.get("key_dir", "/etc/bind/keys") + rndc_cmd = cfg.get("rndc_cmd", "rndc") + + try: + # Ensure key directory exists and has correct ownership for BIND + key_path = Path(key_dir) + key_path.mkdir(parents=True, exist_ok=True) + + # ── get_ds: no modification, just return current DS records ────── + if action == "get_ds": + ds = _get_ds_records(zone_name, key_dir, rndc_cmd) + return { + "success": True, + "zone": zone_name, + "action": "get_ds", + "ds_records": ds, + "key_dir": key_dir, + } + + enable = (action != "disable") + + # ── Modify named.conf.local zone block ──────────────────────────── + modified = _update_zone_dnssec_in_named_conf( + zone_name, named_conf, dnssec_policy, enable, key_dir + ) + + if not modified: + if enable: + # Zone already has dnssec-policy — just return DS records + log.info(f"Zone '{zone_name}' already has DNSSEC configured") + ds = _get_ds_records(zone_name, key_dir, rndc_cmd) + return { + "success": True, + "zone": zone_name, + "action": action, + "already_configured": True, + "ds_records": ds, + "key_dir": key_dir, + } + else: + return { + "success": True, + "zone": zone_name, + "action": "disable", + "message": f"Zone '{zone_name}' avait déjà DNSSEC désactivé.", + } + + log.info(f"named.conf.local updated for zone '{zone_name}' action={action}") + + if not enable: + # ── Disable ─────────────────────────────────────────────────── + subprocess.run([rndc_cmd, "reconfig"], capture_output=True, timeout=15) + return { + "success": True, + "zone": zone_name, + "action": "disable", + "message": ( + f"DNSSEC désactivé pour '{zone_name}'. " + f"Pensez à supprimer les enregistrements DS chez votre registrar." + ), + } + + # ── Enable ──────────────────────────────────────────────────────── + # Archive any existing keys to avoid BIND generating duplicates + _archive_zone_keys(zone_name, key_dir) + + # Ensure key_dir exists AND is owned by bind so BIND can write keys. + # BIND with dnssec-policy generates the keys automatically on reconfig, + # but ONLY if the key-directory is writable by the bind process. + try: + import shutil, pwd + bind_uid = pwd.getpwnam("bind").pw_uid + bind_gid = pwd.getpwnam("bind").pw_gid + key_path.mkdir(parents=True, exist_ok=True) + os.chown(key_dir, bind_uid, bind_gid) + import stat + key_path.chmod(key_path.stat().st_mode | stat.S_IRWXG) + log.info(f"key_dir {key_dir} owned by bind:bind with group rwx") + except (KeyError, PermissionError, AttributeError) as e: + log.warning( + f"Could not chown {key_dir} to bind: {e}. " + f"Make sure {key_dir} is writable by the bind user, " + f"or run the agent as root/with sudo." + ) + + # rndc reconfig — BIND picks up dnssec-policy and generates keys itself + rc = subprocess.run( + [rndc_cmd, "reconfig"], + capture_output=True, text=True, timeout=15 + ) + if rc.returncode == 0: + log.info("rndc reconfig OK — BIND will generate keys and sign the zone") + else: + log.warning(f"rndc reconfig {rc.returncode}: {rc.stderr.strip()}") + + # Wait for BIND to generate keys and produce DS records + import time + ds_records = [] + for wait_s in [3, 5, 8, 10]: + time.sleep(wait_s) + ds_records = _get_ds_records(zone_name, key_dir, rndc_cmd) + if ds_records: + log.info(f"DS records available after ~{wait_s}s") + break + log.debug(f"Waiting for BIND to generate keys for '{zone_name}'...") + + return { + "success": True, + "zone": zone_name, + "action": "enable", + "ds_records": ds_records, + "key_dir": key_dir, + "message": ( + f"DNSSEC activé pour '{zone_name}'. " + + (f"Publiez les {len(ds_records)} enregistrement(s) DS chez votre registrar." + if ds_records + else + "BIND génère les clés — relancez 'Récupérer les DS' dans quelques secondes.") + ), + } + + except FileNotFoundError as e: + return {"success": False, + "error": f"Commande introuvable : {e}. BIND9 installé et dans le PATH ?"} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Timeout lors de rndc reconfig"} + except Exception as e: + log.error(f"handle_enable_dnssec error for {zone_name}: {e}") + return {"success": False, "error": str(e)} + + HANDLERS = { "apply_zone": handle_apply_zone, "reload_zone": handle_reload_zone, @@ -475,6 +790,7 @@ HANDLERS = { "get_zone_raw": handle_get_zone_raw, "get_state": handle_get_state, "save_zone_raw": handle_save_zone_raw, + "enable_dnssec": handle_enable_dnssec, } @@ -564,4 +880,4 @@ if __name__ == "__main__": log.info("DNS agent stopped") except Exception as e: log.error(f"Fatal error: {e}") - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/agents/mail_agent.py b/agents/mail_agent.py index 4d8c8ce..ddc743c 100644 --- a/agents/mail_agent.py +++ b/agents/mail_agent.py @@ -128,184 +128,157 @@ def now() -> str: # 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. - - Payload (from mail.py _apply_domain): - { - "domain": "example.com", - "config": { - "description": "", - "max_accounts": 0, # domain.mailboxes (0=unlimited) - "max_quota_mb": 0, # domain.quota (MB, 0=unlimited) - "max_mailbox_quota_mb": 1024, # domain.maxquota (MB per mailbox) - "active": true - }, - "accounts": [ - { - "username": "user@example.com", - "local_part": "user", - "domain": "example.com", - "password_hash": "{SHA512}...", - "name": "", - "quota": 1024, # MB - "active": true - } - ], - "aliases": [ - {"address": "alias@example.com", "goto": "dest@example.com"} - ] - } + Targets the netadmin_mail schema (not PostfixAdmin). """ - domain_name = payload["domain"] - dcfg = payload.get("config", {}) + 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: - - # ── domain row ───────────────────────────────────── - active = int(dcfg.get("active", True)) - mailboxes = int(dcfg.get("max_accounts", 0)) - quota_mb = int(dcfg.get("max_quota_mb", 0)) - maxquota_mb = int(dcfg.get("max_mailbox_quota_mb", 0)) - description = dcfg.get("description", "") - transport = dcfg.get("transport", "virtual") - - cur.execute("SELECT domain FROM domain WHERE domain=%s", (domain_name,)) - if cur.fetchone(): - cur.execute( - """UPDATE domain SET - description=%s, mailboxes=%s, maxquota=%s, quota=%s, - transport=%s, active=%s, modified=%s - WHERE domain=%s""", - (description, mailboxes, maxquota_mb, quota_mb, - transport, active, now(), domain_name) - ) - log.info(f"Updated domain: {domain_name}") - else: - cur.execute( - """INSERT INTO domain - (domain, description, aliases, mailboxes, maxquota, quota, - transport, backupmx, created, modified, active) - VALUES (%s,%s,0,%s,%s,%s,%s,0,%s,%s,%s)""", - (domain_name, description, mailboxes, maxquota_mb, quota_mb, - transport, now(), now(), active) - ) - log.info(f"Inserted domain: {domain_name}") - - # ── mailboxes ────────────────────────────────────── - # Get current usernames for this domain - 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,)) - # Also remove the auto-alias created by PostfixAdmin - cur.execute( - "DELETE FROM alias WHERE address=%s AND domain=%s", - (username, domain_name) - ) - log.info(f"Deleted mailbox: {username}") - - # Upsert active accounts - for acc in accounts: - username = acc["username"] - local_part = acc.get("local_part") or username.split("@")[0] - password = acc.get("password_hash", "") - name = acc.get("name", "") - # quota: our backend stores MB, PostfixAdmin stores bytes - quota_bytes = int(acc.get("quota", 1024)) * 1024 * 1024 - acc_active = int(acc.get("active", True)) - maildir = f"{domain_name}/{local_part}/" - - if username in existing_users: - update_fields = { - "name": name, - "quota": quota_bytes, - "active": acc_active, - "modified": now(), - } - # Only update password if provided and non-empty - 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, maildir, quota, local_part, - domain, created, modified, active) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - (username, password, name, maildir, quota_bytes, - local_part, domain_name, now(), now(), acc_active) - ) - # PostfixAdmin convention: create a self-alias for each mailbox - cur.execute("SELECT address FROM alias WHERE address=%s", (username,)) - if not cur.fetchone(): - cur.execute( - """INSERT INTO alias - (address, goto, domain, created, modified, active) - VALUES (%s,%s,%s,%s,%s,1)""", - (username, username, domain_name, now(), now()) - ) - log.info(f"Created mailbox: {username}") - - # ── aliases (non-mailbox) ────────────────────────── - # Get current non-mailbox aliases for this domain - cur.execute( - "SELECT address FROM alias WHERE domain=%s", (domain_name,) - ) - existing_aliases = {r["address"] for r in cur.fetchall()} - # Mailbox self-aliases are managed above — don't touch them here - incoming_alias_addresses = {a["address"] for a in aliases} - - # Remove aliases that no longer exist - # (only remove if not a mailbox self-alias) - 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 aliases - for alias in aliases: - address = alias["address"] - goto = alias["goto"] # comma-separated if multiple destinations - if address in existing_aliases: - cur.execute( - "UPDATE alias SET goto=%s, modified=%s WHERE address=%s", - (goto, now(), address) - ) - else: - cur.execute( - """INSERT INTO alias - (address, goto, domain, created, modified, active) - VALUES (%s,%s,%s,%s,%s,1)""", - (address, goto, domain_name, now(), now()) - ) - log.info(f"Created alias: {address} → {goto}") - + _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() - # Postfix reads MySQL directly — reload only needed for domain-level changes _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: {e}") + log.error(f"apply_domain error for '{domain_name}': {e}") return {"success": False, "error": str(e)} @@ -339,10 +312,9 @@ def handle_get_quota_usage(payload: dict, cfg: dict) -> dict: usage = {} with conn: with conn.cursor() as cur: - # quota2 is populated by Dovecot dict quota backend - cur.execute( - "SHOW TABLES LIKE 'quota2'" - ) + # 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: @@ -449,56 +421,38 @@ def handle_get_state(payload: dict, cfg: dict) -> dict: for dom in domains: domain_name = dom["domain"] - # Mailboxes — exclude password for security + # Mailboxes — no local_part/maildir in netadmin_mail cur.execute( - """SELECT username, local_part, name, - ROUND(quota / 1048576) AS quota_mb, - active + """SELECT username, name, quota_mb, active FROM mailbox WHERE domain=%s - ORDER BY local_part""", + ORDER BY username""", (domain_name,) ) mailboxes = cur.fetchall() - # Aliases — exclude self-aliases (address == goto, used for mailboxes) + # Aliases cur.execute( """SELECT address, goto, active FROM alias WHERE domain=%s - AND address != goto ORDER BY address""", (domain_name,) ) aliases = cur.fetchall() - # Quota usage from quota2 (if available) - quota_usage = {} - cur.execute("SHOW TABLES LIKE 'quota2'") - if cur.fetchone(): - cur.execute( - """SELECT username, - ROUND(bytes/1048576) AS used_mb - FROM quota2 - WHERE username LIKE %s""", - (f"%@{domain_name}",) - ) - for row in cur.fetchall(): - quota_usage[row["username"]] = int(row["used_mb"] or 0) - result.append({ - "name": domain_name, - "active": bool(dom["active"]), - "max_accounts": int(dom["mailboxes"]), # 0 = unlimited - "max_quota_mb": int(dom["quota"]), # total domain quota MB + "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["local_part"], - "email": m["username"], # full user@domain - "name": m["name"] or "", - "quota_mb": int(m["quota_mb"] or 0), - "used_mb": quota_usage.get(m["username"], 0), - "active": bool(m["active"]), + "username": m["username"], + "name": m["name"] or "", + "quota_mb": int(m["quota_mb"] or 0), + "active": bool(m["active"]), } for m in mailboxes ], @@ -517,11 +471,88 @@ def handle_get_state(payload: dict, cfg: dict) -> dict: 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, } diff --git a/backend/config.py b/backend/config.py index ca2bd02..1713b29 100644 --- a/backend/config.py +++ b/backend/config.py @@ -63,6 +63,15 @@ class SyncConfig: interval: int = 300 # seconds between polls, 0 = disabled +@dataclass +class DnsConfig: + default_ns: list = field(default_factory=lambda: ["ns1.infolix.fr", "ns2.infolix.fr"]) + default_soa_admin: str = "hostmaster" + dnssec_enabled: bool = False # whether DNSSEC signing is available on the agent + keygen_cmd: str = "dnssec-keygen" + key_dir: str = "/etc/bind/keys" + + @dataclass class DatabaseConfig: host: str = "localhost" @@ -104,6 +113,13 @@ class AppConfig: redis: RedisConfig = field(default_factory=RedisConfig) sync: SyncConfig = field(default_factory=SyncConfig) smtp: SmtpConfig = field(default_factory=SmtpConfig) + dns: DnsConfig = field(default_factory=DnsConfig) + mail_default_aliases: dict = field(default_factory=lambda: { + "abuse": "postmaster@localhost", + "hostmaster": "postmaster@localhost", + "postmaster": "admin@localhost", + "webmaster": "admin@localhost", + }) database: DatabaseConfig = field(default_factory=DatabaseConfig) mail: MailConfig = field(default_factory=MailConfig) auth: AuthConfig = field(default_factory=AuthConfig) diff --git a/backend/config.yaml b/backend/config.yaml index b34cf82..2cb6a01 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -1,7 +1,6 @@ # ═══════════════════════════════════════════════════════════════════ # NetAdmin — fichier de configuration # Emplacement par défaut : /etc/netadmin/config.yaml -# Surcharge via : NETADMIN_CONFIG=/chemin/config.yaml # ═══════════════════════════════════════════════════════════════════ # ── Serveur ───────────────────────────────────────────────────────── @@ -14,44 +13,55 @@ server: # ── Redis (broker de messages) ────────────────────────────────────── redis: - host: "localhost" - port: 6380 # port TLS Redis (6380 = convention TLS) - password: "" - db: 0 - ack_timeout: 10 # secondes d'attente de l'ACK agent - - # mTLS — certificats pour le backend (client Redis) + host: "localhost" + port: 6380 + password: "" + db: 0 + ack_timeout: 10 tls: - enabled: true - ca_cert: "/etc/netadmin/certs/ca.crt" - client_cert: "/etc/netadmin/certs/backend.crt" - client_key: "/etc/netadmin/certs/backend.key" - # Vérification du hostname du serveur Redis + enabled: true + ca_cert: "/etc/netadmin/certs/ca.crt" + client_cert: "/etc/netadmin/certs/backend.crt" + client_key: "/etc/netadmin/certs/backend.key" check_hostname: true # ── Notifications email ────────────────────────────────────────────── smtp: - enabled: false - host: "smtp.example.com" - port: 587 - username: "" - password: "" - from_addr: "netadmin@example.com" - from_name: "NetAdmin" - use_tls: true # STARTTLS (port 587) - use_ssl: false # SSL direct (port 465) — use_tls doit être false - # Déclencheurs de notification - notify_login: true # email à chaque connexion réussie - notify_role_added: true # email lors de l'ajout d'un rôle + enabled: false + host: "smtp.example.com" + port: 587 + username: "" + password: "" + from_addr: "netadmin@example.com" + from_name: "NetAdmin" + use_tls: true + use_ssl: false + notify_login: true + notify_role_added: true -# ── Synchronisation agent ─────────────────────────────────────────── +# ── Synchronisation ───────────────────────────────────────────────── sync: - # Intervalle de polling MariaDB → SQLite (secondes, 0 = désactivé) - interval: 300 # 5 minutes + interval: 300 + +# ── DNS (backend) ──────────────────────────────────────────────────── +dns: + default_ns: + - "ns1.infolix.fr." + - "ns2.infolix.fr." + # Email admin SOA par défaut (sans arobase, @ → .) + default_soa_admin: "hostmaster.infolix.fr" + dnssec_enabled: true + keygen_cmd: "dnssec-keygen" + key_dir: "/etc/bind/keys" + +# ── Alias mail créés automatiquement à la création d'un domaine mail ─── +mail_default_aliases: + abuse: "postmaster@infolix.fr" # RFC 2142 obligatoire + hostmaster: "postmaster@infolix.fr" # gestionnaire DNS + postmaster: "admin@infolix.fr" # RFC 5321 obligatoire + webmaster: "admin@infolix.fr" # gestionnaire web -# ── Base de données (metadata) ────────────────────────────────────── # ── Base de données MariaDB ─────────────────────────────────────────── -# Toutes les tables (auth, DNS, mail, audit, sync) dans la même base database: host: "localhost" port: 3306 @@ -59,40 +69,44 @@ database: password: "changeme" name: "netadmin" -# ── Mail ───────────────────────────────────────────────────────────── - # ── Authentification ───────────────────────────────────────────────── - session_secret: "" +auth: + session_secret: "" session_ttl_hours: 8 - oidc: - enabled: false - issuer: "" - client_id: "" - client_secret: "" - redirect_uri: "http://localhost:3000/auth/callback" - use_pkce: true + enabled: false + issuer: "" + client_id: "" + client_secret: "" + redirect_uri: "http://localhost:3000/auth/callback" + use_pkce: true auto_provision: true - admin_group: "" + admin_group: "" scopes: - "openid" - "email" - "profile" # ═══════════════════════════════════════════════════════════════════ -# Configuration des AGENTS (lue par dns_agent.py / mail_agent.py) -# Ces sections ne sont PAS lues par le backend +# Configuration des AGENTS +# Ces sections sont lues par dns_agent.py / mail_agent.py uniquement # ═══════════════════════════════════════════════════════════════════ -# ── Agent DNS (serveur BIND) ──────────────────────────────────────── +# ── Agent DNS (serveur BIND9) ──────────────────────────────────────── dns_agent: zones_dir: "/etc/bind/zones" named_conf_local: "/etc/bind/named.conf.local" rndc_cmd: "rndc" - redis_host: "netadmin.example.com" - redis_port: 6380 - redis_password: "" - redis_db: 0 + + # ── DNSSEC ────────────────────────────────────────────────────── + key_dir: "/etc/bind/keys" # répertoire des clés DNSSEC (key-directory BIND9) + dnssec_policy: "default" # nom de la dnssec-policy dans named.conf + + # ── Redis ──────────────────────────────────────────────────────── + redis_host: "netadmin.example.com" + redis_port: 6380 + redis_password: "" + redis_db: 0 tls: enabled: true ca_cert: "/etc/netadmin/certs/ca.crt" @@ -100,25 +114,27 @@ dns_agent: client_key: "/etc/netadmin/certs/dns-agent.key" check_hostname: true -# ── Agent Mail (serveur Postfix/Dovecot) ──────────────────────────── +# ── Agent Mail (serveur Postfix/Dovecot) ───────────────────────────── mail_agent: - # ── MariaDB (base PostfixAdmin existante) ────────────────────── + # ── MariaDB PostfixAdmin ───────────────────────────────────────── db_host: "localhost" db_port: 3306 db_name: "postfix" db_user: "postfix" db_password: "mot_de_passe_db" - # ── Postfix ───────────────────────────────────────────────────── + # ── Postfix ────────────────────────────────────────────────────── postfix_reload_cmd: "postfix reload" - # ── Redis ─────────────────────────────────────────────────────── + # ── DKIM (Rspamd sur ce même serveur) ─────────────────────────── + dkim_dir: "/var/lib/rspamd/dkim" + dkim_selector: "mail" + + # ── Redis ──────────────────────────────────────────────────────── redis_host: "netadmin.example.com" redis_port: 6380 redis_password: "" redis_db: 0 - - # ── mTLS ──────────────────────────────────────────────────────── tls: enabled: true ca_cert: "/etc/netadmin/certs/ca.crt" diff --git a/backend/db.py b/backend/db.py index 1e5ad08..30e479d 100644 --- a/backend/db.py +++ b/backend/db.py @@ -96,3 +96,12 @@ def _dt(v) -> str | None: if isinstance(v, (_datetime.datetime, _datetime.date)): return v.isoformat() return str(v) if v else None + + +def get_or_404(conn, query: str, params: tuple, detail: str): + """Execute query and raise HTTPException(404) if no row found.""" + from fastapi import HTTPException + row = conn.execute(query, params).fetchone() + if not row: + raise HTTPException(404, detail) + return row diff --git a/backend/requirements.txt b/backend/requirements.txt index 923837a..c1500cb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ qrcode>=7.4.2 pillow>=10.0.0 PyMySQL>=1.1.0 +passlib[bcrypt]>=1.7.4 diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 661deea..4441529 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -17,6 +17,21 @@ import hashlib from config import cfg import datetime + +# ── Constantes de rôles ─────────────────────────────────────────────────── +ROLE_GLOBAL_ADMIN = "global_admin" +ROLE_DNS_ADMIN = "dns_admin" +ROLE_MAIL_ADMIN = "mail_admin" +ROLE_DOMAIN_ADMIN = "domain_admin" +ROLE_DNS_ZONE_ADMIN = "dns_zone_admin" +ROLE_MAIL_DOMAIN_ADMIN = "mail_domain_admin" + +ALL_ROLES = { + ROLE_GLOBAL_ADMIN, ROLE_DNS_ADMIN, ROLE_MAIL_ADMIN, + ROLE_DOMAIN_ADMIN, ROLE_DNS_ZONE_ADMIN, ROLE_MAIL_DOMAIN_ADMIN, +} +# ───────────────────────────────────────────────────────────────────────── + from contextlib import contextmanager from pathlib import Path from typing import List, Optional, Dict @@ -86,7 +101,7 @@ def _create_session(user_id: str, ip: str = None) -> str: expires = now + datetime.timedelta(seconds=SESSION_TTL) with get_db() as conn: # Clean expired sessions - conn.execute("DELETE FROM sessions WHERE expires_at < %s", (now.isoformat(),)) + conn.execute("DELETE FROM sessions WHERE expires_at < NOW()") conn.execute( "INSERT INTO sessions(token, user_id, created_at, expires_at, ip) VALUES (%s,%s,%s,%s,%s)", (token, user_id, now.isoformat(), expires.isoformat(), ip) @@ -96,13 +111,12 @@ def _create_session(user_id: str, ip: str = None) -> str: def _get_session_user(token: str) -> Optional[dict]: - now = datetime.datetime.utcnow().isoformat() with get_db() as conn: row = conn.execute( "SELECT s.user_id, s.expires_at, u.username, u.email, u.full_name, u.active, u.auth_method " "FROM sessions s JOIN users u ON s.user_id = u.id " - "WHERE s.token = %s AND s.expires_at > %s", - (token, now) + "WHERE s.token = %s AND s.expires_at > NOW()", + (token,) ).fetchone() if not row or not row["active"]: return None @@ -121,7 +135,7 @@ def _get_session_user(token: str) -> Optional[dict]: def _has_role(user: dict, role: str, scope: str = None) -> bool: for r in user["roles"]: - if r["role"] == "global_admin": + if r["role"] == ROLE_GLOBAL_ADMIN: return True if r["role"] == role and (scope is None or r["scope"] == scope or r["scope"] is None): return True @@ -170,6 +184,13 @@ def init_db(): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id VARCHAR(36) PRIMARY KEY, + prefs TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) # Migration: add TOTP columns if upgrading # Note: SQLite ALTER TABLE only accepts nullable columns or columns with # a literal default. NOT NULL constraints are NOT allowed in ALTER TABLE. @@ -224,16 +245,16 @@ def get_current_user( # Also accept token from cookie token = request.cookies.get("netadmin_session") if not token: - raise HTTPException(status_code=401, detail="Not authenticated") + raise HTTPException(status_code=401, detail="Non authentifié") user = _get_session_user(token) if not user: - raise HTTPException(status_code=401, detail="Session expired or invalid") + raise HTTPException(status_code=401, detail="Session expirée ou invalide") return user def require_global_admin(user: dict = Depends(get_current_user)): if not _has_role(user, "global_admin"): - raise HTTPException(status_code=403, detail="Global admin required") + raise HTTPException(status_code=403, detail="Droits administrateur global requis") return user @@ -245,9 +266,9 @@ def get_allowed_dns_domains(user: dict) -> list | None: dns_zone_admin(scope). """ for r in user["roles"]: - if r["role"] == "global_admin" and r["scope"] is None: + if r["role"] == ROLE_GLOBAL_ADMIN and r["scope"] is None: return None - if r["role"] == "dns_admin" and r["scope"] is None: + if r["role"] == ROLE_DNS_ADMIN and r["scope"] is None: return None domains = set() @@ -265,9 +286,9 @@ def get_allowed_mail_domains(user: dict) -> list | None: mail_domain_admin(scope). """ for r in user["roles"]: - if r["role"] == "global_admin" and r["scope"] is None: + if r["role"] == ROLE_GLOBAL_ADMIN and r["scope"] is None: return None - if r["role"] == "mail_admin" and r["scope"] is None: + if r["role"] == ROLE_MAIL_ADMIN and r["scope"] is None: return None domains = set() @@ -301,14 +322,14 @@ def require_dns_access(domain: str, user: dict): """Raise 403 if user has no DNS access to this domain.""" if not can_access_dns_domain(user, domain): raise HTTPException(status_code=403, - detail=f"DNS access denied for domain '{domain}'") + detail=f"Accès DNS refusé pour le domaine '{domain}'") def require_mail_access(domain: str, user: dict): """Raise 403 if user has no mail access to this domain.""" if not can_access_mail_domain(user, domain): raise HTTPException(status_code=403, - detail=f"Mail access denied for domain '{domain}'") + detail=f"Accès mail refusé pour le domaine '{domain}'") # Legacy alias @@ -401,7 +422,7 @@ def _get_oidc_discovery() -> dict: with urllib.request.urlopen(url, timeout=10) as r: _oidc_discovery_cache = _json.loads(r.read()) except Exception as e: - raise HTTPException(status_code=503, detail=f"Cannot reach Keycloak: {e}") + raise HTTPException(status_code=503, detail=f"Impossible de joindre Keycloak : {e}") return _oidc_discovery_cache # ────────────────────────────────────────────── @@ -442,11 +463,11 @@ def login(body: LoginRequest, request: Request, response: Response): (body.username,) ).fetchone() if not row: - raise HTTPException(status_code=401, detail="Invalid username or password") + raise HTTPException(status_code=401, detail="Identifiant ou mot de passe incorrect") if row["auth_method"] == "oidc": - raise HTTPException(status_code=400, detail="This account uses OpenID Connect — use /auth/oidc/login") + raise HTTPException(status_code=400, detail="Ce compte utilise OpenID Connect — utilisez /auth/oidc/login") if not _verify_password(body.password, row["password_hash"]): - raise HTTPException(status_code=401, detail="Invalid username or password") + raise HTTPException(status_code=401, detail="Identifiant ou mot de passe incorrect") ip = request.client.host if request.client else None token = _create_session(row["id"], ip) @@ -462,8 +483,6 @@ def login(body: LoginRequest, request: Request, response: Response): audit.record("auth", "login", target=f"user:{row['username']}", detail={"method": "local"}, success=True, user={"id": row["id"], "username": row["username"]}, ip=ip) - notify.notify_login(row["username"], row["email"], ip) - # Signal frontend if TOTP verification is required after login if row["totp_enabled"]: # TOTP is configured — verify the code before granting access @@ -484,6 +503,8 @@ def login(body: LoginRequest, request: Request, response: Response): totp_pending=True, totp_setup_required=True, ) + # No TOTP — notify login immediately + notify.notify_login(row["username"], row.get("email", ""), ip) return SessionInfo( token=token, expires_at=expires, @@ -546,168 +567,190 @@ def oidc_config(): } + +def _exchange_oidc_token(discovery: dict, code: str, redirect_uri: str, + code_verifier: str | None) -> dict: + """Exchange an authorization code for tokens via Keycloak token endpoint.""" + import urllib.request, urllib.parse, json as _json + + token_data: dict = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": OIDC_CLIENT_ID, + } + if OIDC_USE_PKCE and code_verifier: + token_data["code_verifier"] = code_verifier + if OIDC_CLIENT_SECRET: + token_data["client_secret"] = OIDC_CLIENT_SECRET + + req = urllib.request.Request( + discovery["token_endpoint"], + data=urllib.parse.urlencode(token_data).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as r: + tokens = _json.loads(r.read()) + + if "error" in tokens: + raise HTTPException( + status_code=400, + detail=f"Erreur token Keycloak : {tokens['error']} — {tokens.get('error_description', '')}" + ) + access_token = tokens.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Pas d'access_token dans la réponse Keycloak") + + # Fetch userinfo + import urllib.request as _ur + req2 = _ur.Request( + discovery["userinfo_endpoint"], + headers={"Authorization": f"Bearer {access_token}"}, + ) + with _ur.urlopen(req2, timeout=10) as r: + import json as _json2 + return _json2.loads(r.read()) + + +def _provision_oidc_user(conn, userinfo: dict) -> str: + """Insert or update user from Keycloak userinfo claims. Returns user_id.""" + sub = userinfo.get("sub") + username = userinfo.get("preferred_username") or userinfo.get("email") or sub + email = userinfo.get("email") + full_name = (userinfo.get("name") or + f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip() + or username) + groups = userinfo.get("groups", []) + + row = conn.execute("SELECT * FROM users WHERE oidc_sub = %s", (sub,)).fetchone() + if not row: + if not cfg.auth.oidc.auto_provision: + raise HTTPException( + status_code=403, + detail="Auto-provisionnement désactivé. Contactez un administrateur." + ) + uid = str(uuid.uuid4()) + conn.execute( + "INSERT INTO users(id, username, email, full_name, auth_method, oidc_sub) " + "VALUES (%s,%s,%s,%s,%s,%s)", + (uid, username, email, full_name, "oidc", sub) + ) + if OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups: + conn.execute( + "INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)", + (str(uuid.uuid4()), uid, ROLE_GLOBAL_ADMIN, None) + ) + return uid + else: + user_id = row["id"] + conn.execute( + "UPDATE users SET email=%s, full_name=%s WHERE id=%s", + (email, full_name, user_id) + ) + if OIDC_ADMIN_GROUP: + has_role = conn.execute( + "SELECT id FROM user_roles WHERE user_id=%s AND role=%s AND scope IS NULL", + (user_id, ROLE_GLOBAL_ADMIN) + ).fetchone() + if OIDC_ADMIN_GROUP in groups and not has_role: + conn.execute( + "INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)", + (str(uuid.uuid4()), user_id, ROLE_GLOBAL_ADMIN, None) + ) + elif OIDC_ADMIN_GROUP not in groups and has_role: + conn.execute( + "DELETE FROM user_roles WHERE user_id=%s AND role=%s AND scope IS NULL", + (user_id, ROLE_GLOBAL_ADMIN) + ) + return user_id + + @router.post("/oidc/callback") def oidc_callback(body: dict, request: Request, response: Response): - """Exchange authorization code for a NetAdmin session. - - Expected body: - { - "code": "", - "redirect_uri": "", - "code_verifier": "" - } - """ + """Exchange authorization code for a NetAdmin session.""" if not OIDC_ENABLED: - raise HTTPException(status_code=400, detail="OpenID Connect is not enabled") + raise HTTPException(status_code=400, detail="OpenID Connect non activé") code = body.get("code") redirect_uri = body.get("redirect_uri", OIDC_REDIRECT_URI) code_verifier = body.get("code_verifier") if not code: - raise HTTPException(status_code=422, detail="Missing 'code' in request body") + raise HTTPException(status_code=422, detail="Paramètre 'code' manquant") if OIDC_USE_PKCE and not code_verifier: - raise HTTPException(status_code=422, detail="PKCE is enabled — 'code_verifier' is required") - - import urllib.request, urllib.parse, json as _json, base64 as _b64 + raise HTTPException(status_code=422, detail="PKCE activé — 'code_verifier' requis") try: discovery = _get_oidc_discovery() - - # ── Token exchange ────────────────────────────────────────── - token_data: dict = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": OIDC_CLIENT_ID, - } - if OIDC_USE_PKCE and code_verifier: - token_data["code_verifier"] = code_verifier - - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - # Keycloak accepts client_secret in POST body (confidential clients) - # or without it (public clients with PKCE) - if OIDC_CLIENT_SECRET: - token_data["client_secret"] = OIDC_CLIENT_SECRET - - req = urllib.request.Request( - discovery["token_endpoint"], - data=urllib.parse.urlencode(token_data).encode(), - headers=headers, - method="POST", - ) - with urllib.request.urlopen(req, timeout=10) as r: - tokens = _json.loads(r.read()) - - if "error" in tokens: - raise HTTPException( - status_code=400, - detail=f"Keycloak token error: {tokens['error']} — {tokens.get('error_description', '')}" - ) - - access_token = tokens.get("access_token") - if not access_token: - raise HTTPException(status_code=400, detail="No access_token in Keycloak response") - - # ── Userinfo ──────────────────────────────────────────────── - req2 = urllib.request.Request( - discovery["userinfo_endpoint"], - headers={"Authorization": f"Bearer {access_token}"}, - ) - with urllib.request.urlopen(req2, timeout=10) as r: - userinfo = _json.loads(r.read()) - + userinfo = _exchange_oidc_token(discovery, code, redirect_uri, code_verifier) except HTTPException: raise except Exception as e: - raise HTTPException(status_code=400, detail=f"Keycloak OIDC error: {e}") + raise HTTPException(status_code=400, detail=f"Erreur OIDC Keycloak : {e}") - # ── Extract user info from Keycloak claims ─────────────────────── sub = userinfo.get("sub") + username = userinfo.get("preferred_username") or userinfo.get("email") or sub + email = userinfo.get("email") if not sub: - raise HTTPException(status_code=400, detail="No 'sub' claim in userinfo") + raise HTTPException(status_code=400, detail="Pas de claim 'sub' dans userinfo") - # Keycloak uses preferred_username; fallback to email then sub - username = userinfo.get("preferred_username") or userinfo.get("email") or sub - email = userinfo.get("email") - # Keycloak may provide name, or given_name + family_name - full_name = userinfo.get("name") or ( - f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip() - ) or username - # Keycloak groups claim (requires "groups" mapper in client scope) - groups = userinfo.get("groups", []) - - # ── Provision / update user in DB ─────────────────────────────── with get_db() as conn: - row = conn.execute("SELECT * FROM users WHERE oidc_sub = %s", (sub,)).fetchone() + user_id = _provision_oidc_user(conn, userinfo) - if not row: - if not cfg.auth.oidc.auto_provision: - raise HTTPException( - status_code=403, - detail="Auto-provisioning is disabled. Ask an admin to create your account." - ) - uid = str(uuid.uuid4()) - conn.execute( - "INSERT INTO users(id, username, email, full_name, auth_method, oidc_sub) " - "VALUES (%s,%s,%s,%s,%s,%s)", - (uid, username, email, full_name, "oidc", sub) - ) - user_id = uid - # Auto-grant global_admin if user is in the configured admin group - if OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups: - conn.execute( - "INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)", - (str(uuid.uuid4()), uid, "global_admin", None) - ) - else: - user_id = row["id"] - # Keep email/name in sync with Keycloak on every login - conn.execute( - "UPDATE users SET email=%s, full_name=%s WHERE id=%s", - (email, full_name, user_id) - ) - # Sync admin group membership - if OIDC_ADMIN_GROUP: - has_role = conn.execute( - "SELECT id FROM user_roles WHERE user_id=%s AND role='global_admin' AND scope IS NULL", - (user_id,) - ).fetchone() - if OIDC_ADMIN_GROUP in groups and not has_role: - conn.execute( - "INSERT IGNORE INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)", - (str(uuid.uuid4()), user_id, "global_admin", None) - ) - elif OIDC_ADMIN_GROUP not in groups and has_role: - conn.execute( - "DELETE FROM user_roles WHERE user_id=%s AND role='global_admin' AND scope IS NULL", - (user_id,) - ) - - ip = request.client.host if request.client else None - token = _create_session(user_id, ip) + ip = request.client.host if request.client else "" + token = _create_session(user_id, ip) expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat() response.set_cookie("netadmin_session", token, httponly=True, samesite="lax", max_age=SESSION_TTL) - ip2 = request.client.host if request.client else "" audit.record("auth", "login", target=f"user:{username}", detail={"method": "oidc", "sub": sub}, success=True, - user={"id": user_id, "username": username}, ip=ip2) - notify.notify_login(username, email, ip2) + user={"id": user_id, "username": username}, ip=ip) + notify.notify_login(username, email or "", ip) - # Return full session like the local login endpoint with get_db() as conn: - row2 = conn.execute("SELECT * FROM users WHERE id=%s", (user_id,)).fetchone() - roles = conn.execute("SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_id,)).fetchall() + row = conn.execute("SELECT * FROM users WHERE id=%s", (user_id,)).fetchone() + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_id,) + ).fetchall() return SessionInfo( - token=token, - expires_at=expires, - user=_user_response(row2, + token=token, expires_at=expires, + user=_user_response(row, roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], ), ) +@router.get("/me/preferences") +def get_preferences(user: dict = Depends(get_current_user)): + """Return the current user's saved preferences (theme, etc.).""" + with get_db() as conn: + row = conn.execute( + "SELECT prefs FROM user_preferences WHERE user_id = %s", (user["id"],) + ).fetchone() + if not row: + return {} + import json as _json + try: + return _json.loads(row["prefs"]) + except Exception: + return {} + + +@router.put("/me/preferences", status_code=204) +def save_preferences(body: dict, user: dict = Depends(get_current_user)): + """Save arbitrary user preferences (theme, etc.) to the database.""" + import json as _json + # Whitelist allowed keys to avoid storing arbitrary data + allowed = {"theme"} + filtered = {k: v for k, v in body.items() if k in allowed} + prefs_json = _json.dumps(filtered) + with get_db() as conn: + conn.execute( + "INSERT INTO user_preferences(user_id, prefs) VALUES(%s, %s) " + "ON DUPLICATE KEY UPDATE prefs=%s", + (user["id"], prefs_json, prefs_json) + ) + # ────────────────────────────────────────────── # User management (global_admin only) @@ -731,7 +774,7 @@ def list_users(admin=Depends(require_global_admin)): @router.post("/users", response_model=UserResponse, status_code=201) def create_user(body: UserCreate, admin=Depends(require_global_admin)): if body.auth_method == "local" and not body.password: - raise HTTPException(status_code=422, detail="Password required for local accounts") + raise HTTPException(status_code=422, detail="Mot de passe requis pour les comptes locaux") uid = str(uuid.uuid4()) pw_hash = _hash_password(body.password) if body.password else None with get_db() as conn: @@ -742,7 +785,7 @@ def create_user(body: UserCreate, admin=Depends(require_global_admin)): (uid, body.username, pw_hash, body.email, body.full_name, body.auth_method, body.oidc_sub) ) except sqlite3.IntegrityError: - raise HTTPException(status_code=409, detail=f"Username '{body.username}' already exists") + raise HTTPException(status_code=409, detail=f"L'utilisateur '{body.username}' existe déjà") row = conn.execute("SELECT * FROM users WHERE id = %s", (uid,)).fetchone() audit.record("auth", "create_user", target=f"user:{body.username}", detail={"auth_method": body.auth_method, "email": body.email}, @@ -996,8 +1039,11 @@ def totp_validate_login(body: dict, request: Request, response: Response): user=user_data, ip=request.client.host if request.client else "") raise HTTPException(400, "Code TOTP invalide") + ip = request.client.host if request.client else "" audit.record("auth", "totp_verified", target=f"user:{row['username']}", - user=user_data, ip=request.client.host if request.client else "") + user=user_data, ip=ip) + # Notify login only now that TOTP is validated + notify.notify_login(row["username"], row.get("email", ""), ip) expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat() return SessionInfo( token=token, expires_at=expires, diff --git a/backend/routers/dns.py b/backend/routers/dns.py index 11a8d90..b01aa45 100644 --- a/backend/routers/dns.py +++ b/backend/routers/dns.py @@ -31,7 +31,7 @@ from main import get_request_ip router = APIRouter(dependencies=[Depends(get_current_user)]) -from db import get_db, _dt +from db import get_db, _dt, get_or_404 VALID_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"} @@ -113,20 +113,21 @@ except Exception as e: # ────────────────────────────────────────────── class ZoneCreate(BaseModel): - name: str = Field(..., example="example.com") - admin: str = Field("hostmaster", example="hostmaster.example.com") - ttl: int = Field(3600, ge=1, le=2147483647) - refresh: int = Field(43200, ge=1, le=2147483647) - retry: int = Field(3600, ge=1, le=2147483647) - expire: int = Field(2419200, ge=1, le=2147483647) - negative_ttl: int = Field(3600, ge=1, le=2147483647) + name: str = Field(..., example="example.com") + admin: str = Field("hostmaster", example="hostmaster.example.com") + ttl: int = Field(3600, ge=1, le=2147483647) + refresh: int = Field(43200, ge=1, le=2147483647) + retry: int = Field(3600, ge=1, le=2147483647) + expire: int = Field(2419200, ge=1, le=2147483647) + negative_ttl: int = Field(3600, ge=1, le=2147483647) + enable_dnssec: bool = Field(False, description="Sign zone with DNSSEC after creation") @field_validator("admin") @classmethod def no_at(cls, v: str) -> str: v = v.strip() if "@" in v: - raise ValueError("Admin email must not contain '@' — use a dot instead") + raise ValueError("L'email admin ne doit pas contenir '@' — utilisez un point") return v @field_validator("name") @@ -134,19 +135,19 @@ class ZoneCreate(BaseModel): def validate_zone_name(cls, v: str) -> str: v = v.strip().rstrip(".") if not v: - raise ValueError("Zone name cannot be empty") + raise ValueError("Le nom de zone ne peut pas être vide") if len(v) > 253: - raise ValueError("Zone name too long (max 253 characters)") + raise ValueError("Nom de zone trop long (max 253 caractères)") labels = v.split(".") if len(labels) < 2: - raise ValueError("Zone name must have at least two labels (e.g. example.com)") + raise ValueError("Le nom de zone doit avoir au moins deux labels (ex: example.com)") for label in labels: if not label: - raise ValueError(f"Empty label in zone name '{v}'") + raise ValueError(f"Label vide dans le nom de zone '{v}'") if len(label) > 63: raise ValueError(f"Label '{label}' too long (max 63 characters)") if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$', label): - raise ValueError(f"Invalid label '{label}'") + raise ValueError(f"Label invalide '{label}'") return v @@ -162,7 +163,7 @@ class ZoneConfig(BaseModel): @classmethod def no_at(cls, v: str) -> str: if "@" in v: - raise ValueError("Admin email must not contain '@'") + raise ValueError("L'email admin ne doit pas contenir '@'") return v.strip() @@ -179,6 +180,7 @@ class ZoneInfo(BaseModel): last_applied_at: Optional[str] = None queued: bool = False queue_id: Optional[str] = None + dnssec_enabled: Optional[bool] = None # None = unknown @field_validator("last_applied_at", mode="before") @classmethod @@ -199,7 +201,7 @@ class RecordCreate(BaseModel): def validate_type(cls, v: str) -> str: v = v.strip().upper() if v not in VALID_RECORD_TYPES: - raise ValueError(f"Unsupported type '{v}'") + raise ValueError(f"Type non supporté '{v}'") return v @field_validator("name") @@ -209,14 +211,14 @@ class RecordCreate(BaseModel): if not v: return "@" if not RE_DNS_NAME.match(v): - raise ValueError(f"Invalid DNS name '{v}'") + raise ValueError(f"Nom DNS invalide '{v}'") return v @field_validator("value") @classmethod def validate_value(cls, v: str) -> str: if not v or not v.strip(): - raise ValueError("Record value cannot be empty") + raise ValueError("La valeur ne peut pas être vide") return v.strip() @model_validator(mode="after") @@ -224,49 +226,49 @@ class RecordCreate(BaseModel): t, v = self.type, self.value if t == "A": try: ipaddress.IPv4Address(v) - except ValueError: raise ValueError(f"Invalid IPv4: '{v}'") + except ValueError: raise ValueError(f"IPv4 invalide : '{v}'") elif t == "AAAA": try: ipaddress.IPv6Address(v) - except ValueError: raise ValueError(f"Invalid IPv6: '{v}'") + except ValueError: raise ValueError(f"IPv6 invalide : '{v}'") elif t in ("CNAME", "NS", "PTR"): if not RE_DNS_NAME.match(v.rstrip(".")): - raise ValueError(f"Invalid hostname for {t}: '{v}'") + raise ValueError(f"Nom d'hôte invalide pour {t}: '{v}'") elif t == "MX": if self.priority is None: - raise ValueError("MX requires a priority") + raise ValueError("MX : une priorité est requise") if not RE_DNS_NAME.match(v.rstrip(".")): - raise ValueError(f"Invalid MX hostname: '{v}'") + raise ValueError(f"Nom d'hôte MX invalide : '{v}'") elif t == "SRV": if self.priority is None: - raise ValueError("SRV requires a priority") + raise ValueError("SRV : une priorité est requise") parts = v.split() if len(parts) != 3: - raise ValueError("SRV: ' '") + raise ValueError("SRV : format attendu ' '") try: w, p = int(parts[0]), int(parts[1]) assert 0 <= w <= 65535 and 0 <= p <= 65535 except (ValueError, AssertionError): - raise ValueError("SRV weight/port must be 0-65535") + raise ValueError("SRV : poids/port doit être entre 0 et 65535") elif t == "TXT": if "\n" in v or "\r" in v: - raise ValueError("TXT must not contain newlines") + raise ValueError("TXT ne doit pas contenir de retours à la ligne") elif t == "TLSA": parts = v.split(None, 3) if len(parts) != 4: - raise ValueError("TLSA: ' '") + raise ValueError("TLSA : format ' '") try: usage, sel, mt = int(parts[0]), int(parts[1]), int(parts[2]) except ValueError: - raise ValueError("TLSA usage/selector/matching-type must be integers") - if usage not in range(4): raise ValueError("TLSA usage 0-3") - if sel not in (0, 1): raise ValueError("TLSA selector 0 or 1") - if mt not in (0, 1, 2): raise ValueError("TLSA matching-type 0-2") + raise ValueError("TLSA : usage/sélecteur/type doivent être des entiers") + if usage not in range(4): raise ValueError("TLSA : usage doit être entre 0 et 3") + if sel not in (0, 1): raise ValueError("TLSA : sélecteur doit être 0 ou 1") + if mt not in (0, 1, 2): raise ValueError("TLSA : type de correspondance doit être entre 0 et 2") if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")): - raise ValueError("TLSA cert data must be hex") + raise ValueError("TLSA : données certificat en hexadécimal") elif t == "DS": parts = v.split(None, 3) if len(parts) != 4: - raise ValueError("DS: ' '") + raise ValueError("DS : format ' '") try: kt, algo, dt = int(parts[0]), int(parts[1]), int(parts[2]) except ValueError: @@ -278,14 +280,14 @@ class RecordCreate(BaseModel): raise ValueError("DS digest must be hex") elif t == "CAA": parts = v.split(None, 2) - if len(parts) != 3: raise ValueError("CAA: ' '") + if len(parts) != 3: raise ValueError("CAA : format ' '") try: flags = int(parts[0]) assert 0 <= flags <= 255 except (ValueError, AssertionError): - raise ValueError("CAA flags 0-255") + raise ValueError("CAA : flags doit être entre 0 et 255") if parts[1] not in ("issue", "issuewild", "iodef"): - raise ValueError("CAA tag: issue, issuewild or iodef") + raise ValueError("CAA : tag doit être issue, issuewild ou iodef") return self @@ -384,6 +386,18 @@ def _row_to_zone(row, record_count: int = 0) -> ZoneInfo: +@router.get("/config") +def get_dns_config(): + """Return public DNS configuration needed by the frontend. + Currently exposes the default NS records added to new zones. + """ + return { + "default_ns": cfg.dns.default_ns or [], + "dnssec_enabled": cfg.dns.dnssec_enabled, + "default_soa_admin": getattr(cfg.dns, "default_soa_admin", "hostmaster"), + } + + @router.get("/zones", response_model=List[ZoneInfo]) def list_zones(user: dict = Depends(get_current_user)): allowed = get_allowed_dns_domains(user) @@ -403,6 +417,7 @@ def list_zones(user: dict = Depends(get_current_user)): @router.post("/zones", response_model=ZoneInfo, status_code=201) def create_zone(body: ZoneCreate, user: dict = Depends(get_current_user)): require_dns_access(body.name, user) + with get_db() as conn: existing = conn.execute("SELECT name FROM zones WHERE name=%s", (body.name,)).fetchone() if existing: @@ -411,15 +426,53 @@ def create_zone(body: ZoneCreate, user: dict = Depends(get_current_user)): "INSERT INTO zones(name,admin,ttl,refresh,retry,expire,negative_ttl) VALUES(%s,%s,%s,%s,%s,%s,%s)", (body.name, body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl) ) + # Insert default NS records from config + ns_list = cfg.dns.default_ns or [] + for ns in ns_list: + ns_val = ns.rstrip(".") + rec_id = __import__("hashlib").sha1( + f"{body.name}:@:NS:{ns_val}".encode() + ).hexdigest()[:36] + conn.execute( + "INSERT IGNORE INTO records(id,zone,name,type,ttl,value,active,pending_sync) " + "VALUES(%s,%s,'@','NS',%s,%s,1,1)", + (rec_id, body.name, body.ttl, ns_val) + ) + + # Apply zone (outside get_db to avoid lock contention) q = _apply_zone(body.name) + + # DNSSEC signing if requested and enabled in config + dnssec_result = None + if body.enable_dnssec and cfg.dns.dnssec_enabled: + try: + dnssec_ack = publish_dns("enable_dnssec", { + "zone": body.name, + "keygen_cmd": cfg.dns.keygen_cmd, + "key_dir": cfg.dns.key_dir, + }) + dnssec_result = dnssec_ack + except Exception as e: + dnssec_result = {"success": False, "error": str(e)} + elif body.enable_dnssec and not cfg.dns.dnssec_enabled: + dnssec_result = {"success": False, "error": "DNSSEC non activé dans la configuration (dns.dnssec_enabled: false)"} + with get_db() as conn: - row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone() + row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone() + count = conn.execute("SELECT COUNT(*) AS n FROM records WHERE zone=%s", (body.name,)).fetchone()["n"] + audit.record("dns", "create_zone", target=f"zone:{body.name}", - detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]}, + detail={"admin": body.admin, "ttl": body.ttl, + "ns_added": len(ns_list), "dnssec": body.enable_dnssec, + "queued": q["queued"]}, user=user, ip=get_request_ip()) - z = _row_to_zone(row, 0) + + z = _row_to_zone(row, count) z.queued = q["queued"] z.queue_id = q.get("queue_id") + # Attach DNSSEC result as extra field if applicable + if dnssec_result is not None: + z.__dict__["dnssec"] = dnssec_result return z @@ -521,7 +574,7 @@ def update_record(zone_name: str, record_id: str, body: RecordCreate, require_dns_access(zone_name, user) with get_db() as conn: if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone(): - raise HTTPException(404, "Record not found") + raise HTTPException(404, "Enregistrement introuvable") conn.execute( "UPDATE records SET name=%s,type=%s,ttl=%s,value=%s,priority=%s,active=%s,pending_sync=1 WHERE id=%s", (body.name or "@", body.type, body.ttl, body.value, body.priority, @@ -546,7 +599,7 @@ def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_curre require_dns_access(zone_name, user) with get_db() as conn: if not conn.execute("SELECT id FROM records WHERE id=%s AND zone=%s", (record_id, zone_name)).fetchone(): - raise HTTPException(404, "Record not found") + raise HTTPException(404, "Enregistrement introuvable") conn.execute("DELETE FROM records WHERE id=%s", (record_id,)) q = _apply_zone(zone_name) audit.record("dns", "delete_record", target=f"zone:{zone_name}/record:{record_id}", @@ -556,16 +609,35 @@ def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_curre @router.post("/zones/{zone_name}/reload") def reload_zone(zone_name: str, user: dict = Depends(get_current_user)): - """Force rndc reload on the zone — does not modify the zone file.""" + """Force rndc reload on the zone. + Tries reload_zone action first; falls back to apply_zone (also triggers rndc reload) + for compatibility with older agent versions that don't support reload_zone. + """ require_dns_access(zone_name, user) with get_db() as conn: if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone(): raise HTTPException(404, f"Zone '{zone_name}' not found") - result = publish_or_queue("dns", "reload_zone", {"zone": zone_name}) + + # Try reload_zone first (lightweight — no file rewrite) + # Falls back to apply_zone for old agents that don't have reload_zone handler + try: + from broker import publish_and_wait + ack = publish_and_wait("dns.commands", "dns.acks", "reload_zone", {"zone": zone_name}) + if ack.get("success"): + audit.record("dns", "reload_zone", target=f"zone:{zone_name}", + detail={"method": "reload_zone"}, + user=user, ip=get_request_ip()) + return {"zone": zone_name, "queued": False, "queue_id": None} + # Agent returned success=False (unknown action etc.) — fall through + except (RuntimeError, HTTPException): + pass # Agent unreachable or returned error — fall through to apply_zone + + # Fallback: apply_zone rewrites the file AND calls rndc reload + result = _apply_zone(zone_name) audit.record("dns", "reload_zone", target=f"zone:{zone_name}", - detail={"queued": result.get("queued")}, + detail={"method": "apply_zone_fallback", "queued": result.get("queued")}, user=user, ip=get_request_ip()) - return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("id")} + return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("queue_id")} # ── Raw zone (read-only — agent generates it) ────────────────────── @@ -577,7 +649,7 @@ def get_zone_raw(zone_name: str, user: dict = Depends(get_current_user)): try: ack = publish_dns("get_zone_raw", {"zone": zone_name}) if not ack.get("success"): - raise HTTPException(500, f"Agent error: {ack.get('error','')}") + raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}") return {"zone": zone_name, "content": ack.get("content", ""), "path": ack.get("path", "")} except RuntimeError as e: raise HTTPException(503, str(e)) @@ -593,7 +665,7 @@ def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_u try: ack = publish_dns("save_zone_raw", {"zone": zone_name, "content": content}) if not ack.get("success"): - raise HTTPException(500, f"Agent error: {ack.get('error','')}") + raise HTTPException(500, f"Erreur de l'agent DNS : {ack.get('error','')}") return {"zone": zone_name, "content": ack.get("content", content)} except RuntimeError as e: raise HTTPException(503, str(e)) @@ -603,6 +675,80 @@ def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_u # Sync from BIND agent (zone files are source of truth) # ────────────────────────────────────────────── + +def _stable_id(zone_name: str, rname: str, rtype: str, rvalue: str) -> str: + """Deterministic record ID = sha1(zone:name:type:value) formatted as UUID.""" + key = f"{zone_name}:{rname}:{rtype}:{rvalue}".encode() + h = hashlib.sha1(key).hexdigest() + return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}" + + +def _sync_zone_from_state(conn, zone: dict) -> tuple[bool, int]: + """Upsert one zone + its records from agent get_state. + Returns (is_new_zone, new_record_count). + """ + name = zone["name"] + admin = zone.get("admin", "hostmaster") + ttl = zone.get("ttl", 3600) + refresh = zone.get("refresh", 43200) + retry = zone.get("retry", 3600) + expire = zone.get("expire", 2419200) + negative_ttl = zone.get("negative_ttl", 3600) + records = zone.get("records", []) + + existing = conn.execute("SELECT name FROM zones WHERE name=%s", (name,)).fetchone() + if existing: + conn.execute( + """UPDATE zones + SET admin=%s, ttl=%s, refresh=%s, retry=%s, expire=%s, negative_ttl=%s, + last_applied_at=NOW(), last_apply_ok=1 + WHERE name=%s""", + (admin, ttl, refresh, retry, expire, negative_ttl, name) + ) + is_new = False + else: + conn.execute( + """INSERT INTO zones + (name, admin, ttl, refresh, retry, expire, negative_ttl, + last_applied_at, last_apply_ok) + VALUES (%s,%s,%s,%s,%s,%s,%s,NOW(),1)""", + (name, admin, ttl, refresh, retry, expire, negative_ttl) + ) + is_new = True + + incoming_ids = set() + new_records = 0 + for rec in records: + rname = rec.get("name", "@") or "@" + rtype = rec["type"] + rvalue = rec["value"] + rec_id = _stable_id(name, rname, rtype, rvalue) + incoming_ids.add(rec_id) + + 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 " + "WHERE id=%s", + (rec.get("ttl"), rec.get("priority"), 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")) + ) + new_records += 1 + + # Remove stale records + existing_ids = {r["id"] for r in + conn.execute("SELECT id FROM records WHERE zone=%s", (name,)).fetchall()} + for stale_id in existing_ids - incoming_ids: + conn.execute("DELETE FROM records WHERE id=%s", (stale_id,)) + + return is_new, new_records + + + def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") -> dict: """Pull zone state from BIND via the DNS agent and update local SQLite. @@ -643,89 +789,10 @@ def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") -> conn.execute("DELETE FROM zones WHERE name=%s", (stale,)) for zone in ack.get("zones", []): - name = zone["name"] - admin = zone.get("admin", "hostmaster") - ttl = zone.get("ttl", 3600) - refresh = zone.get("refresh", 43200) - retry = zone.get("retry", 3600) - expire = zone.get("expire", 2419200) - negative_ttl = zone.get("negative_ttl", 3600) - records = zone.get("records", []) - - existing = conn.execute( - "SELECT name FROM zones WHERE name=%s", (name,) - ).fetchone() - - if existing: - conn.execute( - """UPDATE zones - SET admin=%s, ttl=%s, refresh=%s, retry=%s, expire=%s, negative_ttl=%s, - last_applied_at=NOW(), last_apply_ok=1 - WHERE name=%s""", - (admin, ttl, refresh, retry, expire, negative_ttl, name) - ) - updated_zones += 1 - else: - conn.execute( - """INSERT INTO zones - (name, admin, ttl, refresh, retry, expire, negative_ttl, - last_applied_at, last_apply_ok) - VALUES (%s,%s,%s,%s,%s,%s,%s,NOW(),1)""", - (name, admin, ttl, refresh, retry, expire, negative_ttl) - ) - imported_zones += 1 - - # Sync records using stable deterministic IDs based on content. - # This prevents IDs from changing on every sync poll, which would - # break in-flight frontend requests (PUT/DELETE on stale IDs). - # - # Stable ID = sha1(zone:name:type:value) — same record always gets - # the same UUID-like hex string regardless of sync timing. - - def _stable_id(zone_name: str, rname: str, rtype: str, rvalue: str) -> str: - key = f"{zone_name}:{rname}:{rtype}:{rvalue}".encode() - h = hashlib.sha1(key).hexdigest() - # Format as UUID (8-4-4-4-12) - return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}" - - incoming_ids = set() - for rec in records: - rname = rec.get("name", "@") or "@" - rtype = rec["type"] - rvalue = rec["value"] - rec_id = _stable_id(name, rname, rtype, rvalue) - incoming_ids.add(rec_id) - - existing_rec = conn.execute( - "SELECT id FROM records WHERE id=%s", (rec_id,) - ).fetchone() - - if existing_rec: - # Update mutable fields — keep same ID, clear pending_sync - conn.execute( - """UPDATE records - SET ttl=%s, priority=%s, active=COALESCE(active,1), pending_sync=0 - WHERE id=%s""", - (rec.get("ttl"), rec.get("priority"), 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")) - ) - imported_records += 1 - - # Remove records that no longer exist in the zone file - existing_ids = { - r["id"] for r in conn.execute( - "SELECT id FROM records WHERE zone=%s", (name,) - ).fetchall() - } - for stale_id in existing_ids - incoming_ids: - conn.execute("DELETE FROM records WHERE id=%s", (stale_id,)) + is_new, n_rec = _sync_zone_from_state(conn, zone) + imported_zones += 1 if is_new else 0 + updated_zones += 0 if is_new else 1 + imported_records += n_rec result = { "imported_zones": imported_zones, @@ -744,6 +811,55 @@ def sync_from_agent(zone_filter: str | None = None, trigger: str = "manual") -> return result +@router.post("/zones/{zone_name}/dnssec") +def zone_dnssec( + zone_name: str, + action: str = "enable", + dnssec_policy: str = "default", + user: dict = Depends(get_current_user), +): + """ + Enable or disable DNSSEC for a zone. + Actions: enable | disable | get_ds + + Modifies the zone block in named.conf.local to add/remove + dnssec-policy + inline-signing, then calls rndc reconfig. + Returns DS records for publication at the registrar. + """ + require_dns_access(zone_name, user) + with get_db() as conn: + if not conn.execute("SELECT name FROM zones WHERE name=%s", (zone_name,)).fetchone(): + raise HTTPException(404, f"Zone '{zone_name}' introuvable") + + payload = { + "zone": zone_name, + "action": action, + "dnssec_policy": dnssec_policy, + # key_dir intentionally omitted — read from agent's own config + } + + try: + ack = publish_dns("enable_dnssec", payload) + except Exception as e: + raise HTTPException(502, f"Agent inaccessible : {e}") + + if not ack.get("success"): + raise HTTPException(500, ack.get("error", "Erreur agent DNSSEC")) + + audit.record("dns", f"dnssec_{action}", target=f"zone:{zone_name}", + detail={"ds_count": len(ack.get("ds_records", []))}, + user=user, ip=get_request_ip()) + + return { + "zone": zone_name, + "action": action, + "ds_records": ack.get("ds_records", []), + "key_dir": ack.get("key_dir", cfg.dns.key_dir), + "message": ack.get("message", ""), + "already_configured": ack.get("already_configured", False), + } + + @router.get("/sync/history") def get_sync_history( limit: int = 50, diff --git a/backend/routers/mail.py b/backend/routers/mail.py index 1b0ae83..1cbc0de 100644 --- a/backend/routers/mail.py +++ b/backend/routers/mail.py @@ -1,3 +1,4 @@ +import sqlite3 """ Mail Router — stores accounts/lists in SQLite, applies via mail agent through Redis. Domain quota: max_quota_mb = total MB allocatable across all mailboxes of the domain. @@ -6,14 +7,13 @@ Domain quota: max_quota_mb = total MB allocatable across all mailboxes of the do import hashlib import time import uuid -import sqlite3 import datetime from contextlib import contextmanager from pathlib import Path from typing import List, Optional from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from config import cfg from broker import publish_mail, publish_or_queue, list_pending, retry_pending, mark_done @@ -26,7 +26,7 @@ from routers.auth import ( router = APIRouter(dependencies=[Depends(get_current_user)]) -from db import get_db, _dt +from db import get_db, _dt, get_or_404 # ────────────────────────────────────────────── @@ -41,7 +41,8 @@ def init_db(): has_mail TINYINT NOT NULL DEFAULT 1, max_accounts INT NOT NULL DEFAULT 0, max_quota_mb INT NOT NULL DEFAULT 0, - max_lists INT NOT NULL DEFAULT 0, + max_aliases INT NOT NULL DEFAULT 0, + mb_quota_mb INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) @@ -49,6 +50,7 @@ def init_db(): CREATE TABLE IF NOT EXISTS accounts ( id VARCHAR(36) PRIMARY KEY, username VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', domain VARCHAR(253) NOT NULL, password_hash VARCHAR(255) NOT NULL, quota INT NOT NULL DEFAULT 1024, @@ -65,43 +67,32 @@ def init_db(): source VARCHAR(128) NOT NULL, destination VARCHAR(255) NOT NULL, domain VARCHAR(253) NOT NULL, + active TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uq_alias (source, destination, domain), FOREIGN KEY (domain) REFERENCES domains(name) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS mailing_lists ( - id VARCHAR(36) PRIMARY KEY, - name VARCHAR(128) NOT NULL, - domain VARCHAR(253) NOT NULL, - description TEXT, - moderator VARCHAR(255), - created_at DATETIME NOT NULL DEFAULT NOW(), - UNIQUE KEY uq_list (name, domain), - FOREIGN KEY (domain) REFERENCES domains(name) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS list_members ( - id VARCHAR(36) PRIMARY KEY, - list_id VARCHAR(36) NOT NULL, - email VARCHAR(255) NOT NULL, - subscribed_at DATETIME NOT NULL DEFAULT NOW(), - UNIQUE KEY uq_member (list_id, email), - FOREIGN KEY (list_id) REFERENCES mailing_lists(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) + # Migration: add columns to existing DBs for col, defn in [ ("has_mail", "INT NOT NULL DEFAULT 1"), ("max_accounts", "INT NOT NULL DEFAULT 0"), ("max_quota_mb", "INT NOT NULL DEFAULT 0"), - ("max_lists", "INT NOT NULL DEFAULT 0"), + ("max_aliases", "INT NOT NULL DEFAULT 0"), + ("mb_quota_mb", "INT NOT NULL DEFAULT 0"), ]: try: conn.execute(f"ALTER TABLE domains ADD COLUMN {col} {defn}") except Exception: pass + for tbl, col, defn in [ + ("accounts", "name", "VARCHAR(255) NOT NULL DEFAULT ''"), + ("aliases", "active", "TINYINT NOT NULL DEFAULT 1"), + ]: + try: + conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} {defn}") + except Exception: + pass # get_db imported from db.py @@ -117,11 +108,43 @@ except Exception as e: # Helpers # ────────────────────────────────────────────── +import re as _re +_EMAIL_RE = _re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + +def _validate_email(addr: str, field: str = "Adresse") -> None: + """Raise 422 if addr is not a valid email address.""" + if not _EMAIL_RE.match(addr): + raise HTTPException(422, f"{field} invalide : '{addr}' n'est pas une adresse email valide") + + def hash_password(password: str) -> str: - """SHA-512 hash for Dovecot compatibility ({SHA512}).""" - import base64 - h = hashlib.sha512(password.encode()).digest() - return "{SHA512}" + base64.b64encode(h).decode() + """SHA512-CRYPT hash compatible with Postfix/Dovecot ({SHA512-CRYPT}$6$...).""" + try: + from passlib.hash import sha512_crypt + hashed = sha512_crypt.using(rounds=5000).hash(password) + return "{SHA512-CRYPT}" + hashed + except ImportError: + # Fallback: crypt module (deprecated in Python 3.13) + import crypt + hashed = crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)) + return "{SHA512-CRYPT}" + hashed + + + +def _check_mailbox_quota(domain: str, quota_mb: int): + """Raise 422 if quota_mb exceeds the per-mailbox limit set on the domain.""" + with get_db() as conn: + row = conn.execute( + "SELECT mb_quota_mb FROM domains WHERE name=%s", (domain,) + ).fetchone() + if not row or row["mb_quota_mb"] == 0: + return # no per-mailbox limit + if quota_mb > row["mb_quota_mb"]: + raise HTTPException( + 422, + f"Le quota de la BAL ({quota_mb} Mo) dépasse la limite par BAL " + f"du domaine ({row['mb_quota_mb']} Mo)." + ) def _check_domain_quota(domain: str, new_quota: int, exclude_account_id: str | None = None): @@ -138,13 +161,17 @@ def _check_domain_quota(domain: str, new_quota: int, exclude_account_id: str | N params.append(exclude_account_id) used = conn.execute(q, params).fetchone()["n"] + remaining = row["max_quota_mb"] - int(used) if used + new_quota > row["max_quota_mb"]: raise HTTPException( status_code=422, detail=( - f"Domain quota exceeded: {used + new_quota} MB requested but " - f"domain limit is {row['max_quota_mb']} MB " - f"({row['max_quota_mb'] - used} MB remaining)." + f"Quota global du domaine insuffisant. " + f"Quota global : {row['max_quota_mb']} Mo — " + f"Déjà alloué aux comptes existants : {int(used)} Mo — " + f"Disponible : {max(0, remaining)} Mo — " + f"Quota demandé pour ce compte : {new_quota} Mo. " + f"Réduisez le quota de ce compte ou augmentez le quota global du domaine." ) ) @@ -164,20 +191,25 @@ def _check_account_limit(domain: str, exclude_account_id: str | None = None): if count >= row["max_accounts"]: raise HTTPException( 422, - f"Domain account limit reached ({row['max_accounts']} max)." + f"Nombre maximum de comptes atteint pour ce domaine ({row['max_accounts']} comptes autorisés)." ) -def _check_list_limit(domain: str): - with get_db() as conn: - row = conn.execute("SELECT max_lists FROM domains WHERE name=%s", (domain,)).fetchone() - if not row or row["max_lists"] == 0: - return - count = conn.execute( - "SELECT COUNT(*) AS n FROM mailing_lists WHERE domain=%s", (domain,) - ).fetchone()["n"] - if count >= row["max_lists"]: - raise HTTPException(422, f"Domain mailing list limit reached ({row['max_lists']} max).") + + +def _group_aliases(aliases, domain: str) -> list: + """Group alias rows by source, returning one entry per source with CSV goto.""" + groups: dict = {} + actives: dict = {} + for a in aliases: + src = a["source"] if "@" in a["source"] else f"{a['source']}@{domain}" + dest = a["destination"] + if src not in groups: + groups[src] = [] + actives[src] = bool(a.get("active", 1)) + groups[src].append(dest) + return [{"address": src, "goto": ",".join(dests), "active": actives[src]} + for src, dests in groups.items()] def _apply_domain(domain: str): @@ -195,12 +227,11 @@ def _apply_domain(domain: str): dom_config = {} if dom: dom_config = { - "description": "", - "max_accounts": dom["max_accounts"], # → domain.mailboxes - "max_quota_mb": dom["max_quota_mb"], # → domain.quota (MB) - "max_mailbox_quota_mb": dom["max_quota_mb"], # → domain.maxquota (MB per BAL) - "active": bool(dom["has_mail"]), - "transport": "virtual", + "description": "", + "max_accounts": dom["max_accounts"], + "max_quota_mb": dom["max_quota_mb"], + "mb_quota_mb": dom.get("mb_quota_mb", 0), + "active": bool(dom["has_mail"]), } payload = { @@ -208,24 +239,18 @@ def _apply_domain(domain: str): "config": dom_config, "accounts": [ { - "username": f"{a['username']}@{a['domain']}", - "local_part": a["username"], + "username": a["username"], # full email: user@domain.fr "domain": a["domain"], "password_hash": a["password_hash"], "name": "", - "quota": a["quota"], # MB — agent converts to bytes + "quota": a["quota"], # MB "active": bool(a["active"]), } for a in accounts ], # PostfixAdmin alias format: address → goto (comma-separated) - "aliases": [ - { - "address": a["source"], - "goto": a["destination"], - } - for a in aliases - ], + # Group destinations by source → CSV goto for netadmin_mail + "aliases": _group_aliases(aliases, domain), } result = publish_or_queue("mail", "apply_domain", payload) return result # {"success": True, "queued": bool, "id"%s: str} @@ -236,36 +261,52 @@ def _apply_domain(domain: str): # ────────────────────────────────────────────── class DomainConfig(BaseModel): - has_mail: bool = True - max_accounts: int = Field(0, ge=0) - max_quota_mb: int = Field(0, ge=0) - max_lists: int = Field(0, ge=0) + has_mail: bool = True + max_accounts: int = Field(0, ge=0) + max_aliases: int = Field(0, ge=0) + max_quota_mb: int = Field(0, ge=0) + mb_quota_mb: int = Field(0, ge=0) + create_default_aliases: bool = True + + @model_validator(mode="after") + def check_quotas(self): + if self.mb_quota_mb > 0 and self.max_quota_mb > 0: + if self.mb_quota_mb > self.max_quota_mb: + raise ValueError( + f"Le quota par boîte aux lettres ({self.mb_quota_mb} Mo) est supérieur " + f"au quota global du domaine ({self.max_quota_mb} Mo). " + f"Chaque BAL ne peut pas avoir plus d'espace que le total alloué au domaine." + ) + return self class DomainResponse(BaseModel): name: str has_mail: bool max_accounts: int + max_aliases: int = 0 max_quota_mb: int - max_lists: int + mb_quota_mb: int = 0 created_at: str account_count: int = 0 - list_count: int = 0 used_quota_mb: int = 0 free_quota_mb: int = 0 # 0 = unlimited class AccountCreate(BaseModel): username: str + name: str = "" domain: str password: str = "" quota: int = Field(1024, ge=1) active: bool = True + class AccountResponse(BaseModel): id: str username: str + name: str = "" domain: str quota: int used: int @@ -280,26 +321,6 @@ class AccountResponse(BaseModel): queue_id: Optional[str] = None -class MailListCreate(BaseModel): - name: str - domain: str - description: Optional[str] = None - moderator: Optional[str] = None - - -class MailListResponse(BaseModel): - id: str - name: str - domain: str - description: Optional[str] - moderator: Optional[str] - member_count: int = 0 - created_at: str - - @field_validator("created_at", mode="before") - @classmethod - def _coerce_created_at(cls, v): return _dt(v) or "" - class MemberAdd(BaseModel): email: str @@ -319,6 +340,77 @@ class MemberResponse(BaseModel): # Sync from MariaDB agent (MariaDB is source of truth) # ────────────────────────────────────────────── +def _sync_domain_from_state(conn, dom: dict) -> tuple[int, int]: + """Upsert one domain + its accounts + aliases from agent state. Returns (new_accounts, new_aliases).""" + import uuid as _uuid + domain_name = dom["name"] + max_accounts = dom["max_accounts"] + max_quota_mb = dom["max_quota_mb"] + active = dom["active"] + new_accounts = 0 + new_aliases = 0 + + # Upsert domain row + if conn.execute("SELECT name FROM domains WHERE name=%s", (domain_name,)).fetchone(): + conn.execute( + "UPDATE domains SET has_mail=%s, max_accounts=%s, max_quota_mb=%s, " + "max_aliases=%s, mb_quota_mb=%s WHERE name=%s", + (int(active), max_accounts, max_quota_mb, + dom.get("max_aliases", 0), dom.get("mb_quota_mb", 0), domain_name) + ) + else: + conn.execute( + "INSERT INTO domains(name,has_mail,max_accounts,max_quota_mb,max_aliases,mb_quota_mb) " + "VALUES(%s,%s,%s,%s,%s,%s)", + (domain_name, int(active), max_accounts, max_quota_mb, + dom.get("max_aliases", 0), dom.get("mb_quota_mb", 0)) + ) + + # Sync accounts + local_accounts = {r["username"]: r for r in + conn.execute("SELECT * FROM accounts WHERE domain=%s", (domain_name,)).fetchall()} + remote_usernames = {a["username"] for a in dom.get("accounts", [])} + + for username in set(local_accounts) - remote_usernames: + conn.execute("DELETE FROM accounts WHERE username=%s AND domain=%s", (username, domain_name)) + + for acc in dom.get("accounts", []): + username = acc["username"] + quota_mb = acc["quota_mb"] + used_mb = acc.get("used_mb", 0) + acc_active = int(acc["active"]) + acc_name = acc.get("name", "") + if username in local_accounts: + conn.execute( + "UPDATE accounts SET name=%s, quota=%s, used=%s, active=%s " + "WHERE username=%s AND domain=%s", + (acc_name, quota_mb, used_mb, acc_active, username, domain_name) + ) + else: + conn.execute( + "INSERT INTO accounts(id,username,name,domain,password_hash,quota,used,active) " + "VALUES(%s,%s,%s,%s,%s,%s,%s,%s)", + (_uuid.uuid4().hex, username, acc_name, domain_name, "", quota_mb, used_mb, acc_active) + ) + new_accounts += 1 + + # Sync aliases — delete all then re-insert (MariaDB is authoritative) + conn.execute("DELETE FROM aliases WHERE domain=%s", (domain_name,)) + for alias in dom.get("aliases", []): + source = alias["address"] if "@" in alias["address"] else f"{alias['address']}@{domain_name}" + for dest in [d.strip() for d in alias["goto"].split(",") if d.strip()]: + if "@" not in dest: + dest = f"{dest}@{domain_name}" + conn.execute( + "INSERT IGNORE INTO aliases(id,source,destination,domain) VALUES(%s,%s,%s,%s)", + (_uuid.uuid4().hex, source, dest, domain_name) + ) + new_aliases += 1 + + return new_accounts, new_aliases + + + def sync_from_agent(domain_filter: str | None = None, trigger: str = "manual") -> dict: """Pull state from MariaDB via the mail agent and update local SQLite. @@ -338,7 +430,7 @@ def sync_from_agent(domain_filter: str | None = None, trigger: str = "manual") - err = ack.get("error", "") audit.record_sync("mail", trigger, duration_ms=int((time.monotonic()-t0)*1000), success=False, error=err) - raise HTTPException(500, f"Mail agent error: {err}") + raise HTTPException(500, f"Erreur de l'agent mail : {err}") imported_domains = 0 imported_accounts = 0 @@ -346,82 +438,11 @@ def sync_from_agent(domain_filter: str | None = None, trigger: str = "manual") - with get_db() as conn: for dom in ack.get("domains", []): - domain_name = dom["name"] - max_accounts = dom["max_accounts"] - max_quota_mb = dom["max_quota_mb"] - active = dom["active"] - - # Upsert domain - existing = conn.execute( - "SELECT name FROM domains WHERE name=%s", (domain_name,) - ).fetchone() - if existing: - conn.execute( - "UPDATE domains SET has_mail=%s, max_accounts=%s, max_quota_mb=%s WHERE name=%s", - (int(active), max_accounts, max_quota_mb, domain_name) - ) - else: - conn.execute( - "INSERT INTO domains(name, has_mail, max_accounts, max_quota_mb) VALUES(%s,%s,%s,%s)", - (domain_name, int(active), max_accounts, max_quota_mb) - ) + n_acc, n_ali = _sync_domain_from_state(conn, dom) + if not conn.execute("SELECT name FROM domains WHERE name=%s", (dom["name"],)).fetchone(): imported_domains += 1 - - # Sync accounts — MariaDB is authoritative - # Get current local accounts for this domain - local_accounts = { - r["username"]: r - for r in conn.execute( - "SELECT * FROM accounts WHERE domain=%s", (domain_name,) - ).fetchall() - } - remote_usernames = {a["username"] for a in dom.get("accounts", [])} - - # Remove accounts that no longer exist in MariaDB - for username in set(local_accounts) - remote_usernames: - conn.execute( - "DELETE FROM accounts WHERE username=%s AND domain=%s", - (username, domain_name) - ) - - # Upsert accounts from MariaDB - for acc in dom.get("accounts", []): - username = acc["username"] - quota_mb = acc["quota_mb"] - used_mb = acc.get("used_mb", 0) - acc_active = int(acc["active"]) - - if username in local_accounts: - conn.execute( - "UPDATE accounts SET quota=%s, used=%s, active=%s WHERE username=%s AND domain=%s", - (quota_mb, used_mb, acc_active, username, domain_name) - ) - else: - import uuid as _uuid - conn.execute( - "INSERT INTO accounts(id,username,domain,password_hash,quota,used,active) " - "VALUES(%s,%s,%s,%s,%s,%s,%s)", - (_uuid.uuid4().hex, username, domain_name, "", quota_mb, used_mb, acc_active) - ) - imported_accounts += 1 - - # Sync aliases — delete all then re-insert (MariaDB is authoritative). - # goto may be CSV (multiple destinations) — we store one row per - # destination in SQLite to keep UNIQUE(source, destination) intact. - conn.execute("DELETE FROM aliases WHERE domain=%s", (domain_name,)) - import uuid as _uuid - for alias in dom.get("aliases", []): - address_full = alias["address"] - local_part = address_full.split("@")[0] - # PostfixAdmin stores multiple destinations as CSV in goto - destinations = [d.strip() for d in alias["goto"].split(",") if d.strip()] - for dest in destinations: - conn.execute( - "INSERT IGNORE INTO aliases(id,source,destination,domain) " - "VALUES(%s,%s,%s,%s)", - (_uuid.uuid4().hex, local_part, dest, domain_name) - ) - imported_aliases += 1 + imported_accounts += n_acc + imported_aliases += n_ali result = { "domains": imported_domains, @@ -492,15 +513,15 @@ def sync_endpoint( def _build_domain_response(row, conn) -> DomainResponse: name = row["name"] acc = conn.execute("SELECT COUNT(*) AS n FROM accounts WHERE domain=%s", (name,)).fetchone()["n"] - lists = conn.execute("SELECT COUNT(*) AS n FROM mailing_lists WHERE domain=%s", (name,)).fetchone()["n"] used = conn.execute("SELECT COALESCE(SUM(quota), 0) AS n FROM accounts WHERE domain=%s", (name,)).fetchone()["n"] max_q = row["max_quota_mb"] free = max(0, max_q - used) if max_q > 0 else 0 return DomainResponse( name=name, has_mail=bool(row["has_mail"]), - max_accounts=row["max_accounts"], max_quota_mb=max_q, - max_lists=row["max_lists"], created_at=_dt(row["created_at"]) or "", - account_count=acc, list_count=lists, + max_accounts=row["max_accounts"], max_aliases=row.get("max_aliases",0), + max_quota_mb=max_q, mb_quota_mb=row.get("mb_quota_mb",0), + created_at=_dt(row["created_at"]) or "", + account_count=acc, used_quota_mb=used, free_quota_mb=free, ) @@ -509,7 +530,7 @@ def _build_domain_response(row, conn) -> DomainResponse: def list_domains(user: dict = Depends(get_current_user)): allowed = get_allowed_mail_domains(user) with get_db() as conn: - rows = conn.execute("SELECT name FROM domains ORDER BY name").fetchall() + rows = conn.execute("SELECT name FROM domains WHERE name != 'ALL' ORDER BY name").fetchall() names = [r["name"] for r in rows] if allowed is not None: names = [n for n in names if n in allowed] @@ -535,7 +556,7 @@ def get_domain_config(name: str, user: dict = Depends(get_current_user)): with get_db() as conn: row = conn.execute("SELECT * FROM domains WHERE name=%s", (name,)).fetchone() if not row: - raise HTTPException(404, f"Domain '{name}' not found") + raise HTTPException(404, f"Domaine '{name}' introuvable") return _build_domain_response(row, conn) @@ -545,11 +566,27 @@ def create_domain_config(name: str, body: DomainConfig, user: dict = Depends(get with get_db() as conn: try: conn.execute( - "INSERT INTO domains(name,has_mail,max_accounts,max_quota_mb,max_lists) VALUES(%s,%s,%s,%s,%s)", - (name, int(body.has_mail), body.max_accounts, body.max_quota_mb, body.max_lists) + "INSERT INTO domains(name,has_mail,max_accounts,max_aliases,max_quota_mb,mb_quota_mb) " + "VALUES(%s,%s,%s,%s,%s,%s)", + (name, int(body.has_mail), body.max_accounts, body.max_aliases, + body.max_quota_mb, body.mb_quota_mb) ) - except sqlite3.IntegrityError: - raise HTTPException(409, f"Domain '{name}' already exists") + # Créer les alias RFC obligatoires si demandé + if body.create_default_aliases: + default_aliases = getattr(cfg, "mail_default_aliases", {}) or {} + for local, goto in default_aliases.items(): + address = f"{local}@{name}" + alias_id = str(uuid.uuid4()) + try: + conn.execute( + "INSERT IGNORE INTO aliases(id,source,destination,domain) VALUES(%s,%s,%s,%s)", + (alias_id, address, goto, name) + ) + except Exception: + pass + except Exception as _ie: + if "Duplicate" not in str(_ie): raise + raise HTTPException(409, f"Le domaine '{name}' existe déjà") row = conn.execute("SELECT * FROM domains WHERE name=%s", (name,)).fetchone() return _build_domain_response(row, conn) @@ -559,10 +596,10 @@ def update_domain_config(name: str, body: DomainConfig, user: dict = Depends(get require_mail_access(name, user) with get_db() as conn: if not conn.execute("SELECT name FROM domains WHERE name=%s", (name,)).fetchone(): - raise HTTPException(404, f"Domain '{name}' not found") + raise HTTPException(404, f"Domaine '{name}' introuvable") conn.execute( - "UPDATE domains SET has_mail=%s,max_accounts=%s,max_quota_mb=%s,max_lists=%s WHERE name=%s", - (int(body.has_mail), body.max_accounts, body.max_quota_mb, body.max_lists, name) + "UPDATE domains SET has_mail=%s,max_accounts=%s,max_aliases=%s,max_quota_mb=%s,mb_quota_mb=%s WHERE name=%s", + (int(body.has_mail), body.max_accounts, body.max_aliases, body.max_quota_mb, body.mb_quota_mb, name) ) row = conn.execute("SELECT * FROM domains WHERE name=%s", (name,)).fetchone() return _build_domain_response(row, conn) @@ -574,16 +611,111 @@ def create_domain(name: str, user: dict = Depends(get_current_user)): with get_db() as conn: try: conn.execute("INSERT IGNORE INTO domains(name) VALUES(%s)", (name,)) - except sqlite3.IntegrityError: - raise HTTPException(409, f"Domain '{name}' already exists") + except Exception as _ie: + if "Duplicate" not in str(_ie): raise + raise HTTPException(409, f"Le domaine '{name}' existe déjà") return {"name": name} + +@router.post("/domains/{name}/dkim") +def generate_dkim( + name: str, + selector: str = "mail", + user: dict = Depends(get_current_user), +): + """ + Generate a DKIM keypair via the mail agent (rspamadm). + If the DNS zone exists in NetAdmin, automatically create/update + the TXT record mail._domainkey.. + """ + require_mail_access(name, user) + + # 1. Ask the mail agent to generate the keypair + from broker import publish_mail + try: + ack = publish_mail("generate_dkim", {"domain": name, "selector": selector}) + except Exception as e: + raise HTTPException(502, f"Agent mail inaccessible : {e}") + + if not ack.get("success"): + raise HTTPException(500, ack.get("error", "Erreur agent DKIM")) + + txt_value = ack["txt_record"] + sel = ack.get("selector", selector) + + # 2. Try to create/update the DNS TXT record if zone exists + dns_result = None + record_name = f"{sel}._domainkey" + zone_found = False + + # Check if zone exists and write the record — committed before _apply_zone opens its own connection + with get_db() as conn: + zone = conn.execute( + "SELECT name FROM zones WHERE name = %s", (name,) + ).fetchone() + + if zone: + zone_found = True + import hashlib + record_id = hashlib.sha1( + f"{name}:{record_name}:TXT:{txt_value}".encode() + ).hexdigest()[:36] + + conn.execute( + "DELETE FROM records WHERE zone=%s AND name=%s AND type='TXT'", + (name, record_name) + ) + conn.execute( + "INSERT INTO records(id, zone, name, type, ttl, value, active, pending_sync) " + "VALUES(%s,%s,%s,'TXT',%s,%s,1,1)", + (record_id, name, record_name, 3600, txt_value) + ) + # conn is committed and closed here — safe to call _apply_zone + + if zone_found: + _apply_zone = _get_apply_zone_fn() + apply_result = _apply_zone(name) + dns_result = { + "zone": name, + "record": f"{record_name}.{name}", + "applied": not apply_result.get("queued", False), + "queued": apply_result.get("queued", False), + } + + audit.record("mail", "generate_dkim", + target=f"domain:{name}", + detail={"selector": sel, "dns_updated": dns_result is not None}, + user=user) + + return { + "domain": name, + "selector": sel, + "txt_record": txt_value, + "txt_name": f"{record_name}.{name}", + "dns": dns_result, + } + + +def _get_apply_zone_fn(): + """Import _apply_zone from dns router lazily to avoid circular import.""" + from routers import dns as dns_router + return dns_router._apply_zone + @router.delete("/domains/{name}", status_code=204) def delete_domain(name: str, user: dict = Depends(get_current_user)): require_mail_access(name, user) with get_db() as conn: + if not conn.execute("SELECT name FROM domains WHERE name=%s", (name,)).fetchone(): + raise HTTPException(404, f"Domaine '{name}' introuvable") conn.execute("DELETE FROM domains WHERE name=%s", (name,)) + # Notifier l'agent mail pour supprimer dans netadmin_mail + try: + publish_or_queue("mail", "delete_domain", {"domain": name}) + except Exception as e: + log.warning(f"delete_domain agent notification failed: {e}") + audit.record("mail", "delete_domain", target=f"domain:{name}", + user=user, ip=get_request_ip()) # ────────────────────────────────────────────── @@ -592,7 +724,8 @@ def delete_domain(name: str, user: dict = Depends(get_current_user)): def _row_to_account(row, aliases=None) -> AccountResponse: return AccountResponse( - id=row["id"], username=row["username"], domain=row["domain"], + id=row["id"], username=row["username"], name=row.get("name",""), + domain=row["domain"], quota=row["quota"], used=row["used"], active=bool(row["active"]), created_at=_dt(row["created_at"]) or "", aliases=aliases or [], ) @@ -620,17 +753,21 @@ def create_account(body: AccountCreate, user: dict = Depends(get_current_user)): require_mail_access(body.domain, user) _check_account_limit(body.domain) _check_domain_quota(body.domain, body.quota) + _check_mailbox_quota(body.domain, body.quota) + # username = full email address + username = body.username if "@" in body.username else f"{body.username}@{body.domain}" with get_db() as conn: conn.execute("INSERT IGNORE INTO domains(name) VALUES(%s)", (body.domain,)) acc_id = str(uuid.uuid4()) pw = hash_password(body.password) if body.password else "" try: conn.execute( - "INSERT INTO accounts(id,username,domain,password_hash,quota,active) VALUES(%s,%s,%s,%s,%s,%s)", - (acc_id, body.username, body.domain, pw, body.quota, int(body.active)) + "INSERT INTO accounts(id,username,name,domain,password_hash,quota,active) VALUES(%s,%s,%s,%s,%s,%s,%s)", + (acc_id, username, body.name, body.domain, pw, body.quota, int(body.active)) ) - except sqlite3.IntegrityError: - raise HTTPException(409, f"Account {body.username}@{body.domain} already exists") + except Exception as _ie: + if "Duplicate" not in str(_ie): raise + raise HTTPException(409, f"Le compte {username} existe déjà") row = conn.execute("SELECT * FROM accounts WHERE id=%s", (acc_id,)).fetchone() q = _apply_domain(body.domain) audit.record("mail", "create_account", @@ -646,12 +783,11 @@ def create_account(body: AccountCreate, user: dict = Depends(get_current_user)): @router.put("/accounts/{account_id}", response_model=AccountResponse) def update_account(account_id: str, body: AccountCreate, user: dict = Depends(get_current_user)): with get_db() as conn: - row = conn.execute("SELECT * FROM accounts WHERE id=%s", (account_id,)).fetchone() - if not row: - raise HTTPException(404, "Account not found") + row = get_or_404(conn, "SELECT * FROM accounts WHERE id=%s", (account_id,), "Compte introuvable") require_mail_access(row["domain"], user) _check_domain_quota(row["domain"], body.quota, exclude_account_id=account_id) - updates = {} + _check_mailbox_quota(row["domain"], body.quota) + updates = {"name": body.name} if body.password: updates["password_hash"] = hash_password(body.password) updates["quota"] = body.quota @@ -677,9 +813,7 @@ def update_account(account_id: str, body: AccountCreate, user: dict = Depends(ge @router.delete("/accounts/{account_id}") def delete_account(account_id: str, user: dict = Depends(get_current_user)): with get_db() as conn: - row = conn.execute("SELECT * FROM accounts WHERE id=%s", (account_id,)).fetchone() - if not row: - raise HTTPException(404, "Account not found") + row = get_or_404(conn, "SELECT * FROM accounts WHERE id=%s", (account_id,), "Compte introuvable") require_mail_access(row["domain"], user) domain = row["domain"] with get_db() as conn: @@ -703,7 +837,7 @@ def get_domain_quota_usage(name: str, user: dict = Depends(get_current_user)): try: ack = publish_mail("get_quota_usage", {"domain": name}) if not ack.get("success"): - raise HTTPException(500, f"Mail agent error: {ack.get('error','')}") + raise HTTPException(500, f"Erreur de l'agent mail : {ack.get('error','')}") # Update used quota in local DB for display usage = ack.get("usage", {}) with get_db() as conn: @@ -724,16 +858,26 @@ def get_domain_quota_usage(name: str, user: dict = Depends(get_current_user)): # ────────────────────────────────────────────── class AliasCreate(BaseModel): - source: str # local part only (e.g. "contact") or full address + source: str # full email address (e.g. "contact@domain.fr") destination: str # full address (e.g. "user@example.com"), CSV for multiple domain: str + @field_validator("source", mode="before") + @classmethod + def ensure_full_email(cls, v: str, info) -> str: + v = v.strip() + if "@" not in v: + # Will be completed with domain in create_alias if needed + pass + return v + class AliasResponse(BaseModel): id: str source: str destination: str domain: str + active: bool = True queued: bool = False queue_id: Optional[str] = None @@ -743,13 +887,14 @@ def list_aliases(user: dict = Depends(get_current_user)): allowed = get_allowed_mail_domains(user) with get_db() as conn: rows = conn.execute( - "SELECT * FROM aliases ORDER BY domain, source" + "SELECT * FROM aliases WHERE source != destination ORDER BY domain, source" ).fetchall() if allowed is not None: rows = [r for r in rows if r["domain"] in allowed] return [AliasResponse( id=r["id"], source=r["source"], - destination=r["destination"], domain=r["domain"] + destination=r["destination"], domain=r["domain"], + active=bool(r.get("active", 1)) ) for r in rows] @@ -758,12 +903,13 @@ def list_aliases_for_domain(domain: str, user: dict = Depends(get_current_user)) require_mail_access(domain, user) with get_db() as conn: rows = conn.execute( - "SELECT * FROM aliases WHERE domain=%s ORDER BY source", + "SELECT * FROM aliases WHERE domain=%s AND source != destination ORDER BY source", (domain,) ).fetchall() return [AliasResponse( id=r["id"], source=r["source"], - destination=r["destination"], domain=r["domain"] + destination=r["destination"], domain=r["domain"], + active=bool(r.get("active", 1)) ) for r in rows] @@ -771,17 +917,37 @@ def list_aliases_for_domain(domain: str, user: dict = Depends(get_current_user)) def create_alias(body: AliasCreate, user: dict = Depends(get_current_user)): """Create one alias. If destination contains commas, creates one row per dest.""" require_mail_access(body.domain, user) - # Normalize source: strip @domain if present - source = body.source.split("@")[0].strip() - if not source: - raise HTTPException(422, "Source cannot be empty") + # source = full email address + source = body.source.strip() + if "@" not in source: + source = f"{source}@{body.domain}" + if not source or source == f"@{body.domain}": + raise HTTPException(422, "Source ne peut pas être vide") # Split CSV destinations - destinations = [d.strip() for d in body.destination.split(",") if d.strip()] + # destinations = full email addresses + destinations = [] + for d in body.destination.replace("\n", ",").split(","): + d = d.strip() + if not d: continue + if "@" not in d: + d = f"{d}@{body.domain}" + destinations.append(d) if not destinations: - raise HTTPException(422, "Destination cannot be empty") + raise HTTPException(422, "La destination ne peut pas être vide") + # Validate each destination is a real email address + for dest in destinations: + _validate_email(dest, "Destination") created = [] with get_db() as conn: conn.execute("INSERT IGNORE INTO domains(name) VALUES(%s)", (body.domain,)) + # Vérifier que source ne correspond pas à un compte mail existant + existing_account = conn.execute( + "SELECT id FROM accounts WHERE username=%s", (source,) + ).fetchone() + if existing_account: + raise HTTPException(409, + f"L'adresse {source} est déjà utilisée par un compte mail. " + f"Impossible de créer un alias avec la même adresse.") for dest in destinations: alias_id = str(uuid.uuid4()) try: @@ -793,11 +959,13 @@ def create_alias(body: AliasCreate, user: dict = Depends(get_current_user)): id=alias_id, source=source, destination=dest, domain=body.domain )) - except sqlite3.IntegrityError: - pass # Already exists — skip silently + except Exception as _ie: + if "Duplicate" not in str(_ie): + raise + # Already exists — skip silently — skip silently q = _apply_domain(body.domain) audit.record("mail", "create_alias", - target=f"alias:{source}@{body.domain}", + target=f"alias:{source}", detail={"destinations": destinations, "queued": q["queued"]}, user=user, ip=get_request_ip()) for a in created: @@ -813,13 +981,17 @@ def update_alias(alias_id: str, body: AliasCreate, user: dict = Depends(get_curr body.destination may be CSV (one per line converted to comma-separated). """ with get_db() as conn: - row = conn.execute("SELECT * FROM aliases WHERE id=%s", (alias_id,)).fetchone() - if not row: - raise HTTPException(404, "Alias not found") + row = get_or_404(conn, "SELECT * FROM aliases WHERE id=%s", (alias_id,), "Alias introuvable") require_mail_access(row["domain"], user) source = row["source"] domain = row["domain"] - destinations = [d.strip() for d in body.destination.replace("\n", ",").split(",") if d.strip()] + destinations = [] + for d in body.destination.replace("\n", ",").split(","): + d = d.strip() + if not d: continue + if "@" not in d: + d = f"{d}@{domain}" + destinations.append(d) if not destinations: raise HTTPException(422, "Au moins une destination est requise") updated = [] @@ -844,6 +1016,23 @@ def update_alias(alias_id: str, body: AliasCreate, user: dict = Depends(get_curr return updated +@router.patch("/aliases/{alias_id}/toggle", response_model=AliasResponse) +def toggle_alias(alias_id: str, user: dict = Depends(get_current_user)): + """Toggle alias active/inactive.""" + with get_db() as conn: + row = get_or_404(conn, "SELECT * FROM aliases WHERE id=%s", (alias_id,), "Alias introuvable") + require_mail_access(row["domain"], user) + new_active = 0 if row["active"] else 1 + conn.execute("UPDATE aliases SET active=%s WHERE id=%s", (new_active, alias_id)) + row = conn.execute("SELECT * FROM aliases WHERE id=%s", (alias_id,)).fetchone() + q = _apply_domain(row["domain"]) + audit.record("mail", "toggle_alias", target=f"alias:{row['source']}", + detail={"active": bool(new_active)}, user=user, ip=get_request_ip()) + return AliasResponse(id=row["id"], source=row["source"], + destination=row["destination"], domain=row["domain"], + active=bool(new_active), queued=q["queued"]) + + @router.delete("/aliases/{alias_id}") def delete_alias(alias_id: str, user: dict = Depends(get_current_user)): with get_db() as conn: @@ -851,7 +1040,7 @@ def delete_alias(alias_id: str, user: dict = Depends(get_current_user)): "SELECT * FROM aliases WHERE id=%s", (alias_id,) ).fetchone() if not row: - raise HTTPException(404, "Alias not found") + raise HTTPException(404, "Alias introuvable") require_mail_access(row["domain"], user) domain = row["domain"] with get_db() as conn: @@ -866,149 +1055,33 @@ def delete_alias(alias_id: str, user: dict = Depends(get_current_user)): # Mailing list routes # ────────────────────────────────────────────── -@router.get("/lists", response_model=List[MailListResponse]) -def list_mailing_lists(user: dict = Depends(get_current_user)): - allowed = get_allowed_mail_domains(user) - with get_db() as conn: - rows = conn.execute("SELECT * FROM mailing_lists ORDER BY domain, name").fetchall() - if allowed is not None: - rows = [r for r in rows if r["domain"] in allowed] - result = [] - for row in rows: - cnt = conn.execute( - "SELECT COUNT(*) AS n FROM list_members WHERE list_id=%s", (row["id"],) - ).fetchone()["n"] - result.append(MailListResponse( - id=row["id"], name=row["name"], domain=row["domain"], - description=row["description"], moderator=row["moderator"], - member_count=cnt, created_at=_dt(row["created_at"]) or "" - )) - return result - - -@router.post("/lists", response_model=MailListResponse, status_code=201) -def create_mailing_list(body: MailListCreate, user: dict = Depends(get_current_user)): - require_mail_access(body.domain, user) - _check_list_limit(body.domain) - with get_db() as conn: - conn.execute("INSERT IGNORE INTO domains(name) VALUES(%s)", (body.domain,)) - lid = str(uuid.uuid4()) - try: - conn.execute( - "INSERT INTO mailing_lists(id,name,domain,description,moderator) VALUES(%s,%s,%s,%s,%s)", - (lid, body.name, body.domain, body.description, body.moderator) - ) - except sqlite3.IntegrityError: - raise HTTPException(409, f"List {body.name}@{body.domain} already exists") - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (lid,)).fetchone() - return MailListResponse( - id=row["id"], name=row["name"], domain=row["domain"], - description=row["description"], moderator=row["moderator"], - member_count=0, created_at=_dt(row["created_at"]) or "" - ) - - -@router.put("/lists/{list_id}", response_model=MailListResponse) -def update_mailing_list(list_id: str, body: MailListCreate, user: dict = Depends(get_current_user)): - with get_db() as conn: - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - if not row: - raise HTTPException(404, "List not found") - require_mail_access(row["domain"], user) - with get_db() as conn: - conn.execute( - "UPDATE mailing_lists SET description=%s,moderator=%s WHERE id=%s", - (body.description, body.moderator, list_id) - ) - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - cnt = conn.execute("SELECT COUNT(*) AS n FROM list_members WHERE list_id=%s", (list_id,)).fetchone()["n"] - return MailListResponse( - id=row["id"], name=row["name"], domain=row["domain"], - description=row["description"], moderator=row["moderator"], - member_count=cnt, created_at=_dt(row["created_at"]) or "" - ) - - -@router.delete("/lists/{list_id}", status_code=204) -def delete_mailing_list(list_id: str, user: dict = Depends(get_current_user)): - with get_db() as conn: - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - if not row: - raise HTTPException(404, "List not found") - require_mail_access(row["domain"], user) - with get_db() as conn: - conn.execute("DELETE FROM list_members WHERE list_id=%s", (list_id,)) - conn.execute("DELETE FROM mailing_lists WHERE id=%s", (list_id,)) - - -@router.get("/lists/{list_id}/members", response_model=List[MemberResponse]) -def list_members(list_id: str, user: dict = Depends(get_current_user)): - with get_db() as conn: - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - if not row: - raise HTTPException(404, "List not found") - require_mail_access(row["domain"], user) - rows = conn.execute( - "SELECT email, subscribed_at FROM list_members WHERE list_id=%s ORDER BY subscribed_at", - (list_id,) - ).fetchall() - return [MemberResponse(email=r["email"], subscribed_at=_dt(r["subscribed_at"]) or "") for r in rows] - - -@router.post("/lists/{list_id}/members", response_model=MemberResponse, status_code=201) -def add_member(list_id: str, body: MemberAdd, user: dict = Depends(get_current_user)): - with get_db() as conn: - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - if not row: - raise HTTPException(404, "List not found") - require_mail_access(row["domain"], user) - now = datetime.datetime.utcnow().isoformat() - try: - conn.execute( - "INSERT INTO list_members(id,list_id,email,subscribed_at) VALUES(%s,%s,%s,%s)", - (str(uuid.uuid4()), list_id, body.email, now) - ) - except sqlite3.IntegrityError: - raise HTTPException(409, f"{body.email} is already a member") - return MemberResponse(email=body.email, subscribed_at=_dt(now) or "") - - -@router.delete("/lists/{list_id}/members/{email}", status_code=204) -def remove_member(list_id: str, email: str, user: dict = Depends(get_current_user)): - with get_db() as conn: - row = conn.execute("SELECT * FROM mailing_lists WHERE id=%s", (list_id,)).fetchone() - if not row: - raise HTTPException(404, "List not found") - require_mail_access(row["domain"], user) - conn.execute("DELETE FROM list_members WHERE list_id=%s AND email=%s", (list_id, email)) - - # ────────────────────────────────────────────── -# Pending action queue +# Pending actions queue # ────────────────────────────────────────────── @router.get("/pending") -def get_pending_actions(user: dict = Depends(get_current_user)): - """Return all pending (queued) agent actions.""" - return list_pending() +def get_pending(user: dict = Depends(get_current_user)): + """Return all pending/failed mail agent actions.""" + rows = list_pending() + return [r for r in rows if r.get("service") == "mail"] @router.post("/pending/{entry_id}/retry") def retry_pending_action(entry_id: str, user: dict = Depends(get_current_user)): - """Retry a queued action.""" - from routers.auth import _has_role - if not _has_role(user, "global_admin"): - raise HTTPException(403, "Seul un admin global peut relancer une action en attente") + """Retry a queued mail action.""" result = retry_pending(entry_id) if not result.get("success"): - raise HTTPException(500, result.get("error", "Retry failed")) + raise HTTPException(500, result.get("error", "Échec de la relance")) return result @router.delete("/pending/{entry_id}", status_code=204) def delete_pending_action(entry_id: str, user: dict = Depends(get_current_user)): - """Dismiss (delete) a queued action without retrying.""" - from routers.auth import _has_role - if not _has_role(user, "global_admin"): - raise HTTPException(403, "Seul un admin global peut supprimer une action en attente") - mark_done(entry_id) + """Remove a queued mail action.""" + with get_db() as conn: + row = conn.execute( + "SELECT id FROM pending_actions WHERE id=%s AND service='mail'", (entry_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Action en attente introuvable") + conn.execute("DELETE FROM pending_actions WHERE id=%s", (entry_id,)) diff --git a/fix_alias_source.sql b/fix_alias_source.sql new file mode 100644 index 0000000..b6f0935 --- /dev/null +++ b/fix_alias_source.sql @@ -0,0 +1,13 @@ +-- Correction des alias dans la base netadmin dont source est une partie locale +-- Exemple: "kg-lbc" → "kg-lbc@infolix.fr" + +-- Vérifier d'abord +SELECT source, domain FROM aliases WHERE source NOT LIKE '%@%' LIMIT 20; + +-- Corriger +UPDATE aliases +SET source = CONCAT(source, '@', domain) +WHERE source NOT LIKE '%@%'; + +-- Vérifier +SELECT source, domain FROM aliases WHERE source NOT LIKE '%@%'; diff --git a/fix_double_domain.sql b/fix_double_domain.sql new file mode 100644 index 0000000..4ab0d91 --- /dev/null +++ b/fix_double_domain.sql @@ -0,0 +1,18 @@ +-- Correction des comptes avec double domaine dans netadmin_mail +-- Exemple: "asterisk@infolix.fr@infolix.fr" → "asterisk@infolix.fr" +-- Exécuter sur la base netadmin_mail + +-- Vérifier d'abord +SELECT username FROM mailbox WHERE username LIKE '%@%@%'; + +-- Corriger +UPDATE mailbox +SET username = CONCAT( + SUBSTRING_INDEX(username, '@', 1), -- partie locale (avant premier @) + '@', + SUBSTRING_INDEX(username, '@', -1) -- domaine (après dernier @) +) +WHERE username LIKE '%@%@%'; + +-- Vérifier le résultat +SELECT username FROM mailbox WHERE username LIKE '%@%@%'; diff --git a/frontend/package.json b/frontend/package.json index bc544c3..82795e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@fontsource/jetbrains-mono": "^5.0.0", + "@fontsource-variable/syne": "^5.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.1", "vite": "^5.0.0" } -} +} \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 659f31c..b1b29ae 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,7 +1,10 @@ -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Syne:wght@400;500;600;700;800&display=swap'); +/* Fonts imported locally via @fontsource in main.jsx */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +/* ══════════════════════════════════════════════════════════ + VARIABLES — thème sombre (identique à l'original) +══════════════════════════════════════════════════════════ */ :root { --bg-0: #050708; --bg-1: #0b0e11; @@ -24,25 +27,58 @@ --yellow: #ffb700; --yellow-dim: rgba(255, 183, 0, 0.12); --orange: #ff7d40; + --modal-bg: rgba(0, 0, 0, 0.7); + --scrollbar-thumb: #1e252c; + --shadow-modal: 0 24px 80px rgba(0, 0, 0, 0.6); --sidebar-w: 240px; --radius: 6px; --font-mono: 'JetBrains Mono', monospace; - --font-display: 'Syne', sans-serif; + --font-display: 'Syne Variable', 'Syne', sans-serif; } -html, body, #root { height: 100%; } +/* ══════════════════════════════════════════════════════════ + VARIABLES — thème clair (seulement les overrides) +══════════════════════════════════════════════════════════ */ +[data-theme="light"] { + --bg-0: #f0f2f5; + --bg-1: #ffffff; + --bg-2: #f7f8fa; + --bg-3: #eef0f3; + --bg-4: #e4e7ec; + --border: #d8dde5; + --border-hi: #b8c0cc; + --text-0: #0f1923; + --text-1: #2d3f50; + --text-2: #637280; + --text-3: #9aaab8; + --accent: #0080cc; + --accent-dim: rgba(0, 128, 204, 0.1); + --accent-glow: rgba(0, 128, 204, 0.2); + --green: #00965a; + --green-dim: rgba(0, 150, 90, 0.1); + --red: #d42050; + --red-dim: rgba(212, 32, 80, 0.1); + --yellow: #c07800; + --yellow-dim: rgba(192, 120, 0, 0.1); + --orange: #c05020; + --modal-bg: rgba(0, 0, 0, 0.45); + --scrollbar-thumb: #d0d5de; + --shadow-modal: 0 16px 60px rgba(0, 0, 0, 0.18); +} + +html, body, #root { height: 100%; width: 100%; } body { background: var(--bg-0); color: var(--text-0); font-family: var(--font-mono); - font-size: 13px; - line-height: 1.6; + font-size: 14px; + line-height: 1.65; -webkit-font-smoothing: antialiased; } -/* ── APP LAYOUT ── */ -.app { display: flex; height: 100vh; overflow: hidden; } +/* ── APP LAYOUT — plein écran ── */ +.app { display: flex; height: 100vh; width: 100vw; overflow: hidden; } /* ── SIDEBAR ── */ .sidebar { @@ -55,6 +91,7 @@ body { padding: 24px 0; position: relative; z-index: 10; + flex-shrink: 0; } .sidebar::after { @@ -97,7 +134,7 @@ body { text-transform: uppercase; } -.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; } +.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } .nav-item { width: 100%; @@ -110,7 +147,7 @@ body { border-radius: var(--radius); color: var(--text-2); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; cursor: pointer; position: relative; transition: all 0.15s ease; @@ -126,7 +163,7 @@ body { border: 1px solid rgba(0,212,255,0.15); } -.nav-icon { font-size: 14px; width: 18px; text-align: center; } +.nav-icon { font-size: 16px; width: 20px; text-align: center; } .nav-indicator { position: absolute; @@ -164,7 +201,7 @@ body { .api-input:focus { border-color: var(--accent); } .status-dot { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-2); } -.dot { width: 7px; height: 7px; border-radius: 50%; } +.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .dot--green { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2s infinite; } .dot--red { background: var(--red); } @@ -173,9 +210,9 @@ body { 50% { opacity: 0.5; } } -/* ── MAIN AREA ── */ -.main { flex: 1; overflow-y: auto; background: var(--bg-0); } -.main-inner { padding: 32px; max-width: 1200px; } +/* ── MAIN AREA — plein écran ── */ +.main { flex: 1; min-width: 0; overflow-y: auto; background: var(--bg-0); } +.main-inner { padding: 32px; width: 100%; max-width: 100%; } /* ── PAGE HEADER ── */ .page-header { margin-bottom: 28px; } @@ -189,7 +226,7 @@ body { gap: 10px; } .page-title .icon { color: var(--accent); } -.page-sub { color: var(--text-2); font-size: 12px; margin-top: 4px; } +.page-sub { color: var(--text-2); font-size: 13px; margin-top: 4px; } /* ── CARDS ── */ .card { @@ -209,7 +246,7 @@ body { .card-title { font-family: var(--font-display); - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--text-0); display: flex; @@ -231,7 +268,7 @@ body { padding: 7px 12px; color: var(--text-0); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; outline: none; transition: border-color 0.15s; } @@ -243,10 +280,10 @@ body { display: inline-flex; align-items: center; gap: 6px; - padding: 7px 14px; + padding: 8px 16px; border-radius: var(--radius); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; font-weight: 500; border: none; cursor: pointer; @@ -254,50 +291,64 @@ body { white-space: nowrap; letter-spacing: 0.02em; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn-primary { background: var(--accent); color: var(--bg-0); } -.btn-primary:hover { background: #33ddff; box-shadow: 0 0 12px var(--accent-glow); } +.btn-primary:hover:not(:disabled) { background: #33ddff; box-shadow: 0 0 12px var(--accent-glow); } .btn-secondary { background: var(--bg-3); color: var(--text-1); border: 1px solid var(--border); } -.btn-secondary:hover { background: var(--bg-4); border-color: var(--border-hi); color: var(--text-0); } +.btn-secondary:hover:not(:disabled) { background: var(--bg-4); border-color: var(--border-hi); color: var(--text-0); } .btn-danger { background: var(--red-dim); color: var(--red); border: 1px solid rgba(255,74,106,0.2); } -.btn-danger:hover { background: rgba(255,74,106,0.2); } +.btn-danger:hover:not(:disabled) { background: rgba(255,74,106,0.2); } .btn-ghost { background: transparent; color: var(--text-2); - padding: 4px 8px; + padding: 5px 5px; + font-size: 25px; + line-height: 1; } -.btn-ghost:hover { color: var(--text-0); } +.btn-ghost:hover:not(:disabled) { color: var(--text-0); } -.btn-sm { padding: 4px 10px; font-size: 11px; } +.btn-icon { + background: transparent; + border: none; + padding: 5px 5px; + font-size: 25px; + line-height: 1; + color: var(--text-2); + cursor: pointer; + border-radius: var(--radius); + transition: color 0.15s; +} +.btn-icon:hover:not(:disabled) { color: var(--text-0); } +.btn-icon:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-sm { padding: 5px 5px; font-size: 16px; } /* ── TABLE ── */ .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } -thead tr { - border-bottom: 1px solid var(--border); -} +thead tr { border-bottom: 1px solid var(--border); } th { - padding: 8px 12px; + padding: 10px 14px; text-align: left; - font-size: 10px; - letter-spacing: 0.1em; + font-size: 11px; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); font-weight: 500; @@ -305,20 +356,19 @@ th { } td { - padding: 10px 12px; + padding: 12px 14px; border-bottom: 1px solid rgba(31,42,51,0.6); color: var(--text-1); - font-size: 12px; + font-size: 13px; } tr:last-child td { border-bottom: none; } - tbody tr { transition: background 0.1s; } tbody tr:hover { background: var(--bg-2); } .cell-mono { font-family: var(--font-mono); color: var(--text-0); } .cell-dim { color: var(--text-2); } -.cell-actions { display: flex; gap: 4px; justify-content: flex-end; } +.cell-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; } /* ── BADGES ── */ .badge { @@ -326,9 +376,9 @@ tbody tr:hover { background: var(--bg-2); } align-items: center; padding: 2px 8px; border-radius: 3px; - font-size: 10px; + font-size: 11px; font-weight: 500; - letter-spacing: 0.08em; + letter-spacing: 0.06em; text-transform: uppercase; } @@ -348,7 +398,7 @@ tbody tr:hover { background: var(--bg-2); } .modal-overlay { position: fixed; inset: 0; - background: rgba(0,0,0,0.7); + background: var(--modal-bg); backdrop-filter: blur(4px); display: flex; align-items: center; @@ -368,7 +418,7 @@ tbody tr:hover { background: var(--bg-2); } max-height: 90vh; overflow-y: auto; animation: slideUp 0.2s ease; - box-shadow: 0 24px 80px rgba(0,0,0,0.6); + box-shadow: var(--shadow-modal); } @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @@ -383,7 +433,7 @@ tbody tr:hover { background: var(--bg-2); } .modal-title { font-family: var(--font-display); - font-size: 15px; + font-size: 16px; font-weight: 700; color: var(--text-0); } @@ -392,9 +442,9 @@ tbody tr:hover { background: var(--bg-2); } background: none; border: none; color: var(--text-2); - font-size: 18px; + font-size: 20px; cursor: pointer; - padding: 2px 6px; + padding: 4px 8px; border-radius: 4px; transition: all 0.1s; } @@ -406,16 +456,16 @@ tbody tr:hover { background: var(--bg-2); } /* ── FORM ── */ .form-group { display: flex; flex-direction: column; gap: 5px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.form-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-2); } +.form-label { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-2); } .form-input, .form-select, .form-textarea { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); - padding: 8px 12px; + padding: 9px 13px; color: var(--text-0); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; outline: none; transition: border-color 0.15s; width: 100%; @@ -441,11 +491,7 @@ tbody tr:hover { background: var(--bg-2); } .zone-tab.active { background: var(--accent-dim); border-color: rgba(0,212,255,0.3); color: var(--accent); } /* ── EMPTY STATE ── */ -.empty-state { - text-align: center; - padding: 48px 24px; - color: var(--text-3); -} +.empty-state { text-align: center; padding: 48px 24px; color: var(--text-3); } .empty-icon { font-size: 32px; margin-bottom: 10px; } .empty-text { font-size: 13px; color: var(--text-2); } @@ -453,7 +499,7 @@ tbody tr:hover { background: var(--bg-2); } .alert { padding: 10px 14px; border-radius: var(--radius); - font-size: 12px; + font-size: 13px; border-left: 3px solid; margin-bottom: 12px; } @@ -467,15 +513,52 @@ tbody tr:hover { background: var(--bg-2); } .quota-fill--low { background: var(--green); } .quota-fill--mid { background: var(--yellow); } .quota-fill--high { background: var(--red); } -.quota-text { font-size: 10px; color: var(--text-2); } +.quota-text { font-size: 11px; color: var(--text-2); } /* ── SCROLLBAR ── */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; } +::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--border-hi); } /* ── LOADING ── */ -.loading { display: flex; align-items: center; gap: 10px; padding: 20px; color: var(--text-2); font-size: 12px; } -.spinner { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } +.loading { display: flex; align-items: center; gap: 10px; padding: 20px; color: var(--text-2); font-size: 13px; } +.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + +/* ── THEME TOGGLE ── */ +.theme-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-2); +} +.theme-switch { position: relative; width: 38px; height: 20px; flex-shrink: 0; } +.theme-switch input { opacity: 0; width: 0; height: 0; position: absolute; } +.theme-switch-track { + position: absolute; + inset: 0; + background: var(--bg-4); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.theme-switch input:checked + .theme-switch-track { background: var(--accent-dim); border-color: var(--accent); } +.theme-switch-thumb { + position: absolute; + top: 3px; left: 3px; + width: 12px; height: 12px; + background: var(--text-3); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; + pointer-events: none; +} +.theme-switch input:checked ~ .theme-switch-thumb { transform: translateX(18px); background: var(--accent); } + +/* ── THEME TRANSITIONS ── */ +body, .sidebar, .main, .card, .modal, .nav-item, +.form-input, .form-select, .form-textarea, .btn-secondary { + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 416d342..88c350b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,7 +6,6 @@ import MailManager from "./components/MailManager"; import AuditLog from "./components/AuditLog"; import SyncDashboard from "./components/SyncDashboard"; import UserProfile from "./components/UserProfile"; -import MailingLists from "./components/MailingLists"; import UsersManager from "./components/UsersManager"; import "./App.css"; @@ -14,24 +13,35 @@ const NAV_ITEMS = [ { id:"domains", label:"Domaines", icon:"◇", roles:["global_admin","domain_admin","dns_admin","mail_admin","mail_domain_admin","dns_zone_admin"] }, { id:"dns", label:"DNS", icon:"◈", roles:["global_admin","dns_admin","domain_admin","dns_zone_admin"] }, { id:"mail", label:"Comptes mail", icon:"◉", roles:["global_admin","mail_admin","domain_admin","mail_domain_admin"] }, - { id:"lists", label:"Listes diffusion", icon:"◎", roles:["global_admin","mail_admin","domain_admin","mail_domain_admin"] }, { id:"users", label:"Utilisateurs", icon:"⊙", roles:["global_admin"] }, { id:"audit", label:"Journal d'audit", icon:"📋", roles:["global_admin"] }, { id:"sync", label:"Synchronisations", icon:"⟳", roles:["global_admin"] }, - { id:"profile", label:"Mon compte", icon:"◎", roles:["global_admin","dns_admin","mail_admin","domain_admin","dns_zone_admin","mail_domain_admin"] }, + { id:"profile", label:"Mon compte", icon:"◎", roles:[] }, // always visible ]; function hasAccess(user, roles) { if (!user) return false; + if (roles.length === 0) return true; // toujours visible (ex: Mon compte) return user.roles?.some(r => r.role === "global_admin" || roles.includes(r.role)); } +// ── Theme persistence ───────────────────────────────────────────────────── +const getInitialTheme = () => { + try { return localStorage.getItem("netadmin_theme") || "dark"; } catch { return "dark"; } +}; + export default function App() { const [token, setToken] = useState(() => localStorage.getItem("netadmin_token") || null); const [user, setUser] = useState(null); const [active, setActive] = useState("domains"); const [apiBase, setApiBase] = useState(() => localStorage.getItem("netadmin_api") || "http://localhost:8000"); const [checking, setChecking] = useState(true); + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + try { localStorage.setItem("netadmin_theme", theme); } catch {} + }, [theme]); // Persist token useEffect(() => { @@ -67,19 +77,32 @@ export default function App() { if (!r.ok) throw new Error("Erreur serveur"); return r.json(); }) - .then(u => { if (u) setUser(u); }) + .then(u => { if (u) { setUser(u); loadPreferences(token); } }) .catch(() => doLogout(false)) .finally(() => setChecking(false)); }, [token, apiBase]); // eslint-disable-line const onUnauthorized = useCallback(() => doLogout(false), [doLogout]); + const loadPreferences = useCallback(async (tok) => { + try { + const r = await fetch(`${apiBase}/auth/me/preferences`, { + headers: { Authorization: `Bearer ${tok}` } + }); + if (r.ok) { + const prefs = await r.json(); + if (prefs.theme) setTheme(prefs.theme); + } + } catch {} + }, [apiBase]); + // Called by LoginPage once login + TOTP (if needed) are fully complete - const onLogin = useCallback((token, user) => { + const onLogin = useCallback((token, tok) => { setToken(token); - setUser(user); + setUser(tok); setActive("domains"); - }, []); + loadPreferences(token); + }, [loadPreferences]); if (checking) { return ( @@ -142,19 +165,18 @@ export default function App() { ⏻ Déconnexion -
-
@@ -163,11 +185,10 @@ export default function App() { {active === "domains" && } {active === "dns" && } {active === "mail" && } - {active === "lists" && } {active === "users" && } {active === "audit" && } {active === "sync" && } - {active === "profile" && } + {active === "profile" && } diff --git a/frontend/src/components/AccountModal.jsx b/frontend/src/components/AccountModal.jsx new file mode 100644 index 0000000..13783e2 --- /dev/null +++ b/frontend/src/components/AccountModal.jsx @@ -0,0 +1,140 @@ +import { useState } from "react"; + +/** + * AccountModal — Modale de création / modification d'un compte mail. + * Props: + * editAcc, accForm, setAccForm — état du formulaire + * saving, modalErr + * showGeneratedPw, setShowGeneratedPw, generatePassword + * domains, mailDomains, accounts + * onSave, onClose + */ +export default function AccountModal({ + editAcc, accForm, setAccForm, + saving, modalErr, + showGeneratedPw, setShowGeneratedPw, generatePassword, + domains, mailDomains, accounts, + onSave, onClose, +}) { + return ( +
e.target===e.currentTarget&&onClose()}> +
+
+
{editAcc ? "Modifier le compte" : "Nouveau compte"}
+ +
+
+ {modalErr &&
⚠ {modalErr}
} + {!editAcc ? ( +
+
+ + setAccForm({...accForm,username:e.target.value})} + autoFocus /> +
+
+ + +
+
+ ) : ( +
+ +
+ {editAcc.username} +
+
+ )} + +
+ + setAccForm({...accForm,name:e.target.value})} /> +
+ +
+ +
+ {setAccForm({...accForm,password:e.target.value});setShowGeneratedPw(false);}} + style={{flex:1,fontFamily:showGeneratedPw?"var(--font-mono)":"inherit"}} + /> + + +
+ {showGeneratedPw && accForm.password && ( +
+ ⚠ Notez ce mot de passe, il ne sera plus affiché. +
+ )} +
+ +
+
+ + setAccForm({...accForm,quota:+e.target.value})} /> + {(() => { + const dom = mailDomains.find(d=>d.name===(accForm.domain||editAcc?.domain)); + if (!dom || !dom.max_quota_mb) return null; + const used = accounts + .filter(a=>a.domain===dom.name && a.id!==(editAcc?.id)) + .reduce((s,a)=>s+(a.quota||0), 0); + const remaining = dom.max_quota_mb - used; + const ok = accForm.quota <= remaining; + return ( +
+ {ok + ? `Quota global : ${dom.max_quota_mb} Mo — Alloué : ${used} Mo — Disponible : ${remaining} Mo` + : `⚠ Quota insuffisant : ${remaining} Mo disponibles sur ${dom.max_quota_mb} Mo`} + {dom.mb_quota_mb > 0 && ` — Max par BAL : ${dom.mb_quota_mb} Mo`} +
+ ); + })()} +
+
+ +
+
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/AliasModal.jsx b/frontend/src/components/AliasModal.jsx new file mode 100644 index 0000000..88d687d --- /dev/null +++ b/frontend/src/components/AliasModal.jsx @@ -0,0 +1,94 @@ +/** + * AliasModal — Modale de création / modification d'un alias mail. + */ +export default function AliasModal({ + editAlias, aliasForm, setAliasForm, + saving, modalErr, + selDomain, + onSave, onClose, +}) { + return ( +
e.target===e.currentTarget&&onClose()}> +
+
+
{editAlias ? "Modifier l'alias" : "Nouvel alias"}
+ +
+
+ {modalErr &&
⚠ {modalErr}
} +
+
+ + {editAlias ? ( +
+ {editAlias.source} +
+ ) : ( + { + const val = e.target.value; + const updates = {source: val}; + if (val.includes("@")) { + const parts = val.split("@"); + updates.domain = parts[parts.length-1]; + } + setAliasForm({...aliasForm,...updates}); + }} + autoFocus /> + )} +
+ Email complet, ex: contact@infolix.fr +
+
+
+ +
+ {editAlias ? editAlias.domain : (aliasForm.domain || selDomain || "—")} +
+
+
+ +
+ +