commit 17664ef03846700be9ad9b4724459e2cf0b100c3 Author: kguerineau Date: Sat Apr 18 18:16:37 2026 +0200 feat: NetAdmin DNS & Mail console — version initiale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fb3bce --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +venv/ +.venv/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +frontend/dist/ + +# Secrets & certs +*.key +*.pem +certs/ + +# DB +*.db +*.sqlite3 + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b840ca --- /dev/null +++ b/README.md @@ -0,0 +1,368 @@ +# 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. + +--- + +## 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) │ │ │ + └─────────────┘ └──────────────────┘ +``` + +### 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 + +```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; +``` + +Les tables sont créées automatiquement au premier démarrage du backend. + +### 2. Backend + +```bash +cd backend/ +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 +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 + +```bash +cd frontend/ +npm install +npm run build # Production +# ou +npm run dev # Développement +``` + +Le build de production est dans `frontend/dist/` — à servir via nginx ou autre. + +### 4. Certificats mTLS Redis + +```bash +cd agents/ +bash gen_certs.sh +``` + +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) + +```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 +``` + +--- + +## Configuration (`config.yaml`) + +```yaml +# ── Serveur ─────────────────────────────────────────────────────────────── +server: + host: "0.0.0.0" + port: 8000 + cors_origins: + - "http://localhost:3000" + - "https://netadmin.example.com" + +# ── Base de données MariaDB ─────────────────────────────────────────────── +database: + host: "localhost" + port: 3306 + user: "netadmin" + password: "motdepasse" + name: "netadmin" + +# ── 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" + +# ── 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 + +# ── 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" + +# ── Synchronisation ─────────────────────────────────────────────────────── +sync: + interval: 300 # Polling automatique toutes les 5 minutes (0 = désactivé) +``` + +--- + +## Rôles et permissions + +| 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 | + +--- + +## 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 + +### 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 + +### 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) + +### 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 + +--- + +## Structure du projet + +``` +netadmin/ +├── backend/ +│ ├── main.py # Point d'entrée FastAPI +│ ├── 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) +│ ├── requirements.txt +│ └── routers/ +│ ├── auth.py # Authentification, RBAC, TOTP +│ ├── dns.py # Zones et enregistrements DNS +│ └── mail.py # Comptes, alias, domaines mail +├── frontend/ +│ ├── index.html +│ ├── 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 +``` + +--- + +## Déploiement nginx (exemple) + +```nginx +# Frontend +server { + listen 443 ssl; + server_name netadmin.example.com; + + root /opt/netadmin/frontend/dist; + index index.html; + + 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 new file mode 100644 index 0000000..5f4831f --- /dev/null +++ b/agents/dns_agent.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +""" +dns_agent.py — NetAdmin DNS Agent +Runs on the BIND server. Subscribes to Redis dns.commands, +writes zone files and calls rndc, then publishes ACK to dns.acks. + +Install on the BIND server: + pip install redis pyyaml dnspython + python3 dns_agent.py --config /etc/netadmin/config.yaml + +Systemd unit: see docs/dns-agent.service +""" + +import argparse +import json +import logging +import os +import re +import sys +import subprocess +import textwrap +from pathlib import Path + +import ssl +import redis +import yaml + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [dns-agent] %(levelname)s %(message)s" +) +log = logging.getLogger("dns-agent") + +# ────────────────────────────────────────────── +# Config +# ────────────────────────────────────────────── + +def load_config(path: str) -> dict: + with open(path) as f: + cfg = yaml.safe_load(f) or {} + return cfg.get("dns_agent", {}) + + + +def build_redis_ssl_context(tls_cfg: dict) -> "ssl.SSLContext | None": + """Build an mTLS SSLContext from the agent tls config dict.""" + import ssl + if not tls_cfg.get("enabled", False): + return None + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.load_verify_locations(cafile=tls_cfg["ca_cert"]) + ctx.load_cert_chain( + certfile=tls_cfg["client_cert"], + keyfile=tls_cfg["client_key"] + ) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + if not tls_cfg.get("check_hostname", True): + ctx.check_hostname = False + return ctx + +DEFAULT_CFG = { + "zones_dir": "/etc/bind/zones", + "named_conf_local": "/etc/bind/named.conf.local", + "rndc_cmd": "rndc", + "redis_host": "localhost", + "redis_port": 6379, + "redis_password": "", + "redis_db": 0, +} + + +# ────────────────────────────────────────────── +# Zone file generation +# ────────────────────────────────────────────── + +def _fmt_ttl(ttl: int | None, zone_ttl: int) -> str: + """Return tab-separated TTL field or empty string if equals zone default.""" + if ttl is None or ttl == zone_ttl: + return "" + return f"{ttl}\t" + + +def _fmt_value(rtype: str, value: str) -> str: + """Normalize record value for zone file.""" + if rtype == "TXT": + # Strip existing outer quotes, re-split into 255-byte chunks + raw = value + if raw.startswith('"') and raw.endswith('"') and len(raw) >= 2: + raw = raw[1:-1] + raw = re.sub(r'"\s+"', "", raw) + raw = raw.replace('\\"', '"') + encoded = raw.encode("utf-8") + chunks = [] + for i in range(0, len(encoded), 255): + chunk = encoded[i:i+255].decode("utf-8", errors="replace") + chunk = chunk.replace('"', '\\"') + chunks.append(f'"{chunk}"') + return " ".join(chunks) if chunks else '""' + if rtype in ("CNAME", "MX", "NS", "PTR") and not value.endswith("."): + return value + "." + return value + + +def build_zone_content(zone: dict) -> str: + """Generate a complete zone file from the zone payload.""" + name = zone["zone"] + 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) + admin_fqdn = admin if admin.endswith(".") else admin + "." + + serial = _next_serial(name) + + lines = [ + f"; Zone file for {name} — managed by NetAdmin", + f"; DO NOT EDIT MANUALLY", + f"$ORIGIN {name}.", + f"$TTL {ttl}", + f"@\tIN\tSOA\tns1.{name}. {admin_fqdn} (", + f"\t\t\t{serial:<12}; Serial", + f"\t\t\t{refresh:<12}; Refresh", + f"\t\t\t{retry:<12}; Retry", + f"\t\t\t{expire:<12}; Expire", + f"\t\t\t{negative_ttl} )\t; Negative TTL", + "", + ] + + for rec in zone.get("records", []): + rtype = rec["type"] + rname = rec["name"] or "@" + rvalue = _fmt_value(rtype, rec["value"]) + ttl_f = _fmt_ttl(rec.get("ttl"), ttl) + priority = rec.get("priority") + active = rec.get("active", True) + + if rtype in ("MX", "SRV") and priority is not None: + line = f"{rname}\t{ttl_f}IN\t{rtype}\t{priority}\t{rvalue}" + else: + line = f"{rname}\t{ttl_f}IN\t{rtype}\t{rvalue}" + + if not active: + lines.append(f"; [DISABLED] {line}") + else: + lines.append(line) + + lines.append("") + return "\n".join(lines) + + +def _next_serial(zone_name: str) -> str: + """Generate YYYYMMDDnn serial, incrementing from existing if same day.""" + import datetime + today = datetime.date.today().strftime("%Y%m%d") + # Try to read existing serial + # (we don't have the path here, handled at write time) + return f"{today}01" + + +def _read_existing_serial(path: Path) -> str | None: + if not path.exists(): + return None + for line in path.read_text().splitlines(): + nc = line.split(";")[0].strip() + try: + val = int(nc) + if len(str(val)) == 10: + return str(val) + except ValueError: + pass + return None + + +def _compute_serial(path: Path) -> str: + import datetime + today = datetime.date.today().strftime("%Y%m%d") + existing = _read_existing_serial(path) + if existing and existing.startswith(today): + counter = int(existing[8:]) + 1 + return f"{today}{counter:02d}" + return f"{today}01" + + +def write_zone_file(zone: dict, zones_dir: str) -> Path: + """Write zone file, preserving/incrementing serial.""" + name = zone["zone"] + path = Path(zones_dir) / f"db.{name}" + + # Compute correct serial + serial = _compute_serial(path) + + # Build content with correct serial + content = build_zone_content(zone) + # Replace the placeholder serial + content = re.sub(r'(\d{10})\s*;\s*Serial', f"{serial}\t\t\t; Serial", content) + + Path(zones_dir).mkdir(parents=True, exist_ok=True) + path.write_text(content) + log.info(f"Zone file written: {path} (serial {serial})") + return path + + +# ────────────────────────────────────────────── +# named.conf.local management +# ────────────────────────────────────────────── + +def ensure_zone_in_named_conf(zone_name: str, zone_file: Path, named_conf: str): + path = Path(named_conf) + if not path.exists(): + path.write_text("") + content = path.read_text() + if f'zone "{zone_name}"' in content: + return # already present + entry = f'\nzone "{zone_name}" {{\n type master;\n file "{zone_file}";\n}};\n' + path.write_text(content + entry) + log.info(f"Added zone '{zone_name}' to {named_conf}") + + +def remove_zone_from_named_conf(zone_name: str, named_conf: str): + path = Path(named_conf) + if not path.exists(): + return + content = path.read_text() + pattern = rf'\nzone\s+"{re.escape(zone_name)}"\s*\{{[^}}]+\}};\n' + new = re.sub(pattern, "", content, flags=re.DOTALL) + if new != content: + path.write_text(new) + log.info(f"Removed zone '{zone_name}' from {named_conf}") + + +def rndc(cmd: str, zone: str, rndc_cmd: str): + try: + result = subprocess.run( + [rndc_cmd, cmd, zone], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + log.warning(f"rndc {cmd} {zone}: {result.stderr.strip()}") + else: + log.info(f"rndc {cmd} {zone}: OK") + except FileNotFoundError: + log.warning(f"rndc not found at '{rndc_cmd}' — zone changes written but not reloaded") + except Exception as e: + log.warning(f"rndc error: {e}") + + +# ────────────────────────────────────────────── +# Command handlers +# ────────────────────────────────────────────── + +def handle_apply_zone(payload: dict, cfg: dict) -> dict: + try: + zone_name = payload["zone"] + path = write_zone_file(payload, cfg["zones_dir"]) + ensure_zone_in_named_conf(zone_name, path, cfg["named_conf_local"]) + rndc("reload", zone_name, cfg["rndc_cmd"]) + return {"success": True} + except Exception as e: + log.error(f"apply_zone error: {e}") + return {"success": False, "error": str(e)} + + +def handle_delete_zone(payload: dict, cfg: dict) -> dict: + try: + zone_name = payload["zone"] + remove_zone_from_named_conf(zone_name, cfg["named_conf_local"]) + path = Path(cfg["zones_dir"]) / f"db.{zone_name}" + if path.exists(): + path.unlink() + try: + subprocess.run([cfg["rndc_cmd"], "reconfig"], capture_output=True, timeout=10) + except Exception: + pass + log.info(f"Zone '{zone_name}' deleted") + return {"success": True} + except Exception as e: + log.error(f"delete_zone error: {e}") + return {"success": False, "error": str(e)} + + +def handle_get_zone_raw(payload: dict, cfg: dict) -> dict: + try: + zone_name = payload["zone"] + path = Path(cfg["zones_dir"]) / f"db.{zone_name}" + if not path.exists(): + return {"success": False, "error": f"Zone file not found: {path}"} + return {"success": True, "content": path.read_text(), "path": str(path)} + except Exception as e: + return {"success": False, "error": str(e)} + + +def handle_save_zone_raw(payload: dict, cfg: dict) -> dict: + try: + zone_name = payload["zone"] + content = payload["content"] + # Validate with dnspython before writing + import dns.zone as dz + dz.from_text(content, origin=zone_name, check_origin=False) + path = Path(cfg["zones_dir"]) / f"db.{zone_name}" + backup = path.with_suffix(".bak") + if path.exists(): + backup.write_text(path.read_text()) + path.write_text(content) + rndc("reload", zone_name, cfg["rndc_cmd"]) + return {"success": True, "content": path.read_text()} + except Exception as e: + return {"success": False, "error": str(e)} + + + +def handle_get_state(payload: dict, cfg: dict) -> dict: + """Read all zones from named.conf.local + their zone files via dnspython. + + Returns the full authoritative state so the backend can sync its SQLite. + Payload: + {} → all zones + {"zone": "example.com"} → one zone only + + Response: + { + "success": true, + "zones": [ + { + "name": "example.com", + "admin": "hostmaster.example.com", + "ttl": 3600, + "refresh": 43200, + "retry": 3600, + "expire": 2419200, + "negative_ttl": 3600, + "records": [ + {"name":"www","type":"A","ttl":null,"value":"1.2.3.4","priority":null}, + ... + ] + } + ] + } + """ + import dns.zone as dz + import dns.rdatatype + + filter_zone = payload.get("zone") + zones_dir = cfg["zones_dir"] + named_conf = cfg["named_conf_local"] + + # Read zone names from named.conf.local + all_zone_names = [] + try: + nc_text = Path(named_conf).read_text() + all_zone_names = re.findall(r'zone\s+"([^"]+)"', nc_text) + except Exception as e: + return {"success": False, "error": f"Cannot read {named_conf}: {e}"} + + if filter_zone: + all_zone_names = [z for z in all_zone_names if z == filter_zone] + + result = [] + for zone_name in all_zone_names: + path = Path(zones_dir) / f"db.{zone_name}" + if not path.exists(): + log.warning(f"Zone file missing for {zone_name}: {path}") + continue + + try: + z = dz.from_file(str(path), origin=zone_name, check_origin=False) + except Exception as e: + log.warning(f"Cannot parse {path}: {e}") + continue + + # Read $TTL and SOA fields + ttl = 3600 + admin = "hostmaster" + refresh = 43200 + retry = 3600 + expire = 2419200 + negative_ttl = 3600 + + raw_lines = path.read_text().splitlines() + in_soa = False + soa_nums = [] + for line in raw_lines: + stripped = line.strip().split(";")[0].strip() + upper = stripped.upper() + if re.match(r'^\$TTL\s+', stripped, re.IGNORECASE): + try: + ttl = int(stripped.split()[1]) + except (IndexError, ValueError): + pass + if "SOA" in upper and not in_soa: + in_soa = True + parts = stripped.split() + idx = next((i for i,p in enumerate(parts) if p.upper()=="SOA"), None) + if idx is not None and idx + 2 < len(parts): + admin = parts[idx+2].rstrip(".") + for token in (parts[idx+3:] if idx else []): + if token in ("(",")"): continue + try: soa_nums.append(int(token)) + except ValueError: pass + elif in_soa: + for token in stripped.split(): + if token == ")": in_soa = False; break + try: soa_nums.append(int(token)) + except ValueError: pass + if not in_soa and soa_nums: + break + + if len(soa_nums) >= 5: + refresh = soa_nums[1] + retry = soa_nums[2] + expire = soa_nums[3] + negative_ttl = soa_nums[4] + + # Parse records (skip SOA) + records = [] + for name_obj, node in z.nodes.items(): + name_str = str(name_obj) + if name_str == "@": + name_str = "" + for rdataset in node.rdatasets: + rdtype = dns.rdatatype.to_text(rdataset.rdtype) + if rdtype == "SOA": + continue + rec_ttl = rdataset.ttl if rdataset.ttl != ttl else None + for rdata in rdataset: + value = rdata.to_text() + priority = None + if rdtype == "MX": + parts = value.split(None, 1) + priority = int(parts[0]) + value = parts[1] if len(parts) > 1 else "" + elif rdtype == "SRV": + parts = value.split(None, 3) + priority = int(parts[0]) if parts else None + records.append({ + "name": name_str, + "type": rdtype, + "ttl": rec_ttl, + "value": value, + "priority": priority, + }) + + result.append({ + "name": zone_name, + "admin": admin, + "ttl": ttl, + "refresh": refresh, + "retry": retry, + "expire": expire, + "negative_ttl": negative_ttl, + "records": records, + }) + log.info(f"get_state: parsed {zone_name} ({len(records)} records)") + + return {"success": True, "zones": result} + +def handle_reload_zone(payload: dict, cfg: dict) -> dict: + """Force rndc reload on a zone without rewriting the file.""" + try: + zone_name = payload["zone"] + rndc("reload", zone_name, cfg["rndc_cmd"]) + log.info(f"Zone '{zone_name}' reloaded via rndc") + return {"success": True} + except Exception as e: + log.error(f"reload_zone error: {e}") + return {"success": False, "error": str(e)} + + +HANDLERS = { + "apply_zone": handle_apply_zone, + "reload_zone": handle_reload_zone, + "delete_zone": handle_delete_zone, + "get_zone_raw": handle_get_zone_raw, + "get_state": handle_get_state, + "save_zone_raw": handle_save_zone_raw, +} + + +# ────────────────────────────────────────────── +# Main loop +# ────────────────────────────────────────────── + +def run(cfg: dict): + redis_kwargs = { + "host": cfg["redis_host"], + "port": cfg["redis_port"], + "db": cfg["redis_db"], + "decode_responses": True, + } + if cfg.get("redis_password"): + redis_kwargs["password"] = cfg["redis_password"] + tls_cfg = cfg.get("tls", {}) + if tls_cfg.get("enabled", False): + redis_kwargs["ssl"] = True + redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"] + redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"] + redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"] + redis_kwargs["ssl_cert_reqs"] = "required" + log.info("Redis mTLS enabled") + else: + log.warning("Redis TLS disabled — traffic is unencrypted") + + r = redis.Redis(**redis_kwargs) + r.ping() + log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}") + + pubsub = r.pubsub() + pubsub.subscribe("dns.commands") + log.info("Subscribed to dns.commands — waiting for messages…") + # Announce reconnection so the backend can flush the pending queue + import socket as _socket + r.publish("agent.ready", json.dumps({"service": "dns", "host": _socket.gethostname()})) + log.info(f"Published agent.ready for service 'dns'") + + for message in pubsub.listen(): + if message["type"] != "message": + continue + try: + msg = json.loads(message["data"]) + except (json.JSONDecodeError, KeyError): + log.warning("Malformed message received, ignored") + continue + + # mTLS: authentication is handled by Redis TLS layer (tls-auth-clients yes). + # No token check needed — only clients with a valid CA-signed cert can connect. + + + msg_id = msg.get("id", "unknown") + action = msg.get("action", "") + payload = msg.get("payload", {}) + + log.info(f"Received action '{action}' (id={msg_id})") + + handler = HANDLERS.get(action) + if not handler: + ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"} + else: + ack = handler(payload, cfg) + ack["id"] = msg_id + + r.publish("dns.acks", json.dumps(ack)) + log.info(f"ACK sent for '{action}' (id={msg_id}): success={ack.get('success')}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="NetAdmin DNS Agent") + parser.add_argument("--config", default="/etc/netadmin/config.yaml", + help="Path to config.yaml") + args = parser.parse_args() + + cfg_path = args.config + if not Path(cfg_path).exists(): + log.error(f"Config file not found: {cfg_path}") + sys.exit(1) + + agent_cfg = {**DEFAULT_CFG, **load_config(cfg_path)} + log.info(f"DNS agent starting (zones_dir={agent_cfg['zones_dir']})") + + try: + run(agent_cfg) + except KeyboardInterrupt: + log.info("DNS agent stopped") + except Exception as e: + log.error(f"Fatal error: {e}") + sys.exit(1) diff --git a/agents/gen_certs.sh b/agents/gen_certs.sh new file mode 100644 index 0000000..d837db3 --- /dev/null +++ b/agents/gen_certs.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# gen_certs.sh — Generate mTLS certificates for NetAdmin +# +# Usage: +# chmod +x gen_certs.sh +# ./gen_certs.sh [output_dir] +# +# Output: +# certs/ +# ca.crt — CA certificate (distribute to ALL nodes) +# ca.key — CA private key (keep on CA machine only) +# backend.crt — Backend client cert (for broker.py) +# backend.key — Backend private key +# dns-agent.crt — DNS agent cert +# dns-agent.key — DNS agent private key +# mail-agent.crt — Mail agent cert +# mail-agent.key — Mail agent private key +# redis-server.crt — Redis server cert +# redis-server.key — Redis server private key +# +# After generation: +# - Install ca.crt + redis-server.{crt,key} on the Redis/NetAdmin server +# - Install ca.crt + backend.{crt,key} on the NetAdmin backend server +# - Install ca.crt + dns-agent.{crt,key} on the BIND server +# - Install ca.crt + mail-agent.{crt,key} on the mail server +# - Configure Redis with tls-cert-file, tls-key-file, tls-ca-cert-file + +set -euo pipefail + +OUT="${1:-certs}" +mkdir -p "$OUT" + +DAYS=3650 # 10 years for CA +CERT_DAYS=825 # ~2 years for node certs (Apple/browser limit, doesn't apply here but good practice) +KEYSIZE=4096 + +echo "=== NetAdmin mTLS Certificate Generator ===" +echo "Output directory: $OUT" +echo "" + +# ── CA ───────────────────────────────────────────────────────────────────── +echo "[1/5] Generating CA..." +openssl genrsa -out "$OUT/ca.key" $KEYSIZE 2>/dev/null + +openssl req -new -x509 \ + -key "$OUT/ca.key" \ + -out "$OUT/ca.crt" \ + -days $DAYS \ + -subj "/C=FR/O=NetAdmin/CN=NetAdmin Internal CA" \ + -extensions v3_ca \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + +echo " CA created: $OUT/ca.crt" + +# ── Helper function ───────────────────────────────────────────────────────── +gen_cert() { + local NAME="$1" + local CN="$2" + local USAGE="$3" # "server" or "client" or "both" + local SANS="${4:-}" + + echo "[?] Generating $NAME certificate (CN=$CN)..." + + openssl genrsa -out "$OUT/$NAME.key" $KEYSIZE 2>/dev/null + + # Build SAN extension + local SAN_EXT="" + if [ -n "$SANS" ]; then + SAN_EXT="subjectAltName=$SANS" + fi + + # Build EKU + local EKU="" + case "$USAGE" in + server) EKU="extendedKeyUsage=serverAuth" ;; + client) EKU="extendedKeyUsage=clientAuth" ;; + both) EKU="extendedKeyUsage=serverAuth,clientAuth" ;; + esac + + # Create CSR + openssl req -new \ + -key "$OUT/$NAME.key" \ + -out "$OUT/$NAME.csr" \ + -subj "/C=FR/O=NetAdmin/CN=$CN" 2>/dev/null + + # Sign with CA + local EXTFILE + EXTFILE=$(mktemp) + echo "[ ext ]" >> "$EXTFILE" + echo "basicConstraints=CA:FALSE" >> "$EXTFILE" + echo "keyUsage=critical,digitalSignature,keyEncipherment" >> "$EXTFILE" + [ -n "$EKU" ] && echo "$EKU" >> "$EXTFILE" + [ -n "$SAN_EXT" ] && echo "$SAN_EXT" >> "$EXTFILE" + + openssl x509 -req \ + -in "$OUT/$NAME.csr" \ + -CA "$OUT/ca.crt" \ + -CAkey "$OUT/ca.key" \ + -CAcreateserial \ + -out "$OUT/$NAME.crt" \ + -days $CERT_DAYS \ + -extfile "$EXTFILE" \ + -extensions ext \ + 2>/dev/null + + rm -f "$OUT/$NAME.csr" "$EXTFILE" + echo " Created: $OUT/$NAME.crt" +} + +# ── Redis server cert ─────────────────────────────────────────────────────── +# Update the IP/hostname below to match your Redis server +REDIS_HOST="${REDIS_HOST:-localhost}" +echo "" +echo "[2/5] Redis server cert (hostname: $REDIS_HOST)" +echo " Set REDIS_HOST env var to override (e.g. REDIS_HOST=redis.example.com ./gen_certs.sh)" +gen_cert "redis-server" "redis-server" "server" "DNS:$REDIS_HOST,DNS:localhost,IP:127.0.0.1" + +# ── Backend client cert ───────────────────────────────────────────────────── +echo "" +echo "[3/5] Backend client cert" +gen_cert "backend" "netadmin-backend" "client" + +# ── DNS agent cert ────────────────────────────────────────────────────────── +echo "" +echo "[4/5] DNS agent cert" +gen_cert "dns-agent" "netadmin-dns-agent" "client" + +# ── Mail agent cert ───────────────────────────────────────────────────────── +echo "" +echo "[5/5] Mail agent cert" +gen_cert "mail-agent" "netadmin-mail-agent" "client" + +# ── Set permissions ───────────────────────────────────────────────────────── +chmod 644 "$OUT"/*.crt +chmod 600 "$OUT"/*.key +echo "" +echo "=== Done! ===" +echo "" +echo "Files generated in $OUT/:" +ls -lh "$OUT/" +echo "" +echo "=== Deployment checklist ===" +echo "" +echo "On the NetAdmin/Redis server:" +echo " mkdir -p /etc/netadmin/certs" +echo " cp $OUT/ca.crt $OUT/redis-server.{crt,key} $OUT/backend.{crt,key} /etc/netadmin/certs/" +echo " chown redis:redis /etc/netadmin/certs/redis-server.key" +echo "" +echo " Add to /etc/redis/redis.conf:" +echo " tls-port 6380" +echo " port 0 # disable plain" +echo " tls-cert-file /etc/netadmin/certs/redis-server.crt" +echo " tls-key-file /etc/netadmin/certs/redis-server.key" +echo " tls-ca-cert-file /etc/netadmin/certs/ca.crt" +echo " tls-auth-clients yes # require client cert (mTLS)" +echo " tls-protocols 'TLSv1.2 TLSv1.3'" +echo "" +echo "On the BIND server:" +echo " mkdir -p /etc/netadmin/certs" +echo " cp $OUT/ca.crt $OUT/dns-agent.{crt,key} /etc/netadmin/certs/" +echo "" +echo "On the mail server:" +echo " mkdir -p /etc/netadmin/certs" +echo " cp $OUT/ca.crt $OUT/mail-agent.{crt,key} /etc/netadmin/certs/" +echo "" +echo "Then update /etc/netadmin/config.yaml on each server (see tls section)." diff --git a/agents/mail_agent.py b/agents/mail_agent.py new file mode 100644 index 0000000..4d8c8ce --- /dev/null +++ b/agents/mail_agent.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +""" +mail_agent.py — NetAdmin Mail Agent (PostfixAdmin MySQL schema) +Runs on the Postfix/Dovecot server. +Subscribes to Redis mail.commands, writes to the existing PostfixAdmin +MariaDB database, then publishes ACK. + +Schema targeted: + domain (domain, description, aliases, mailboxes, maxquota, quota, + transport, backupmx, created, modified, active) + mailbox (username, password, name, maildir, quota, local_part, + domain, created, modified, active) + alias (address, goto, domain, created, modified, active) + +Install on the mail server: + pip install redis pyyaml PyMySQL + python3 mail_agent.py --config /etc/netadmin/config.yaml + +Postfix/Dovecot already query MySQL directly — no file reload needed +for account changes. We call 'postfix reload' only when domain config +changes (transport, etc.). +""" + +import argparse +import json +import logging +import ssl +import sys +import subprocess +import datetime +from pathlib import Path + +import redis +import yaml + +try: + import pymysql + import pymysql.cursors +except ImportError: + print("ERROR: PyMySQL not installed. Run: pip install PyMySQL") + sys.exit(1) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [mail-agent] %(levelname)s %(message)s" +) +log = logging.getLogger("mail-agent") + +NOW_DEFAULT = datetime.datetime(2000, 1, 1) + + +# ────────────────────────────────────────────── +# Config +# ────────────────────────────────────────────── + +def load_config(path: str) -> dict: + with open(path) as f: + cfg = yaml.safe_load(f) or {} + return cfg.get("mail_agent", {}) + + +DEFAULT_CFG = { + # MariaDB connection + "db_host": "localhost", + "db_port": 3306, + "db_name": "postfix", + "db_user": "postfix", + "db_password": "", + # Postfix reload (only needed for domain-level changes) + "postfix_reload_cmd": "postfix reload", + # Redis + "redis_host": "localhost", + "redis_port": 6380, + "redis_password": "", + "redis_db": 0, + "tls": { + "enabled": False, + "ca_cert": "/etc/netadmin/certs/ca.crt", + "client_cert": "/etc/netadmin/certs/mail-agent.crt", + "client_key": "/etc/netadmin/certs/mail-agent.key", + "check_hostname": True, + }, +} + + +# ────────────────────────────────────────────── +# mTLS helper +# ────────────────────────────────────────────── + +def build_redis_ssl_context(tls_cfg: dict): + if not tls_cfg.get("enabled", False): + return None + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.load_verify_locations(cafile=tls_cfg["ca_cert"]) + ctx.load_cert_chain( + certfile=tls_cfg["client_cert"], + keyfile=tls_cfg["client_key"] + ) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + if not tls_cfg.get("check_hostname", True): + ctx.check_hostname = False + return ctx + + +# ────────────────────────────────────────────── +# DB helper +# ────────────────────────────────────────────── + +def get_db(cfg: dict): + return pymysql.connect( + host=cfg["db_host"], + port=int(cfg["db_port"]), + user=cfg["db_user"], + password=cfg["db_password"], + database=cfg["db_name"], + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + +def now() -> str: + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +# ────────────────────────────────────────────── +# Command handlers +# ────────────────────────────────────────────── + +def 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"} + ] + } + """ + domain_name = payload["domain"] + dcfg = payload.get("config", {}) + accounts = payload.get("accounts", []) + aliases = payload.get("aliases", []) + + 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}") + + conn.commit() + + # Postfix reads MySQL directly — reload only needed for domain-level changes + _reload_postfix(cfg) + return {"success": True} + + except Exception as e: + log.error(f"apply_domain error: {e}") + return {"success": False, "error": str(e)} + + +def handle_delete_domain(payload: dict, cfg: dict) -> dict: + """Remove all mailboxes and aliases for a domain, then the domain itself.""" + domain_name = payload["domain"] + try: + conn = get_db(cfg) + with conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM mailbox WHERE domain=%s", (domain_name,)) + cur.execute("DELETE FROM alias WHERE domain=%s", (domain_name,)) + cur.execute("DELETE FROM domain WHERE domain=%s", (domain_name,)) + conn.commit() + log.info(f"Domain '{domain_name}' deleted from MariaDB") + _reload_postfix(cfg) + return {"success": True} + except Exception as e: + log.error(f"delete_domain error: {e}") + return {"success": False, "error": str(e)} + + +def handle_get_quota_usage(payload: dict, cfg: dict) -> dict: + """ + Return current quota usage per mailbox for a domain. + Reads from quota2 table (Dovecot quota backend) if available. + """ + domain_name = payload["domain"] + try: + conn = get_db(cfg) + usage = {} + with conn: + with conn.cursor() as cur: + # quota2 is populated by Dovecot dict quota backend + cur.execute( + "SHOW TABLES LIKE 'quota2'" + ) + has_quota2 = cur.fetchone() is not None + + if has_quota2: + cur.execute( + """SELECT username, + bytes AS used_bytes, + ROUND(bytes/1048576) AS used_mb + FROM quota2 + WHERE username LIKE %s""", + (f"%@{domain_name}",) + ) + for row in cur.fetchall(): + usage[row["username"]] = { + "used_bytes": row["used_bytes"], + "used_mb": int(row["used_mb"] or 0), + } + else: + # Fallback: quota table (older Dovecot setup) + cur.execute( + """SELECT username, + current_bytes AS used_bytes, + ROUND(current_bytes/1048576) AS used_mb + FROM quota + WHERE username LIKE %s""", + (f"%@{domain_name}",) + ) + for row in cur.fetchall(): + usage[row["username"]] = { + "used_bytes": row["used_bytes"], + "used_mb": int(row["used_mb"] or 0), + } + + return {"success": True, "domain": domain_name, "usage": usage} + except Exception as e: + log.error(f"get_quota_usage error: {e}") + return {"success": False, "error": str(e)} + + +def _reload_postfix(cfg: dict): + cmd = cfg.get("postfix_reload_cmd", "postfix reload") + try: + result = subprocess.run( + cmd.split(), capture_output=True, text=True, timeout=15 + ) + if result.returncode != 0: + log.warning(f"postfix reload: {result.stderr.strip()}") + else: + log.info("Postfix reloaded") + except FileNotFoundError: + log.warning(f"postfix command not found: '{cmd}'") + except Exception as e: + log.warning(f"postfix reload error: {e}") + + + +def handle_get_state(payload: dict, cfg: dict) -> dict: + """Return the full state of MariaDB for all domains (or a specific one). + + This is the canonical source of truth — used by the backend to sync + its local SQLite cache at startup and on demand. + + Payload: + {} → all domains + {"domain": "example.com"} → one domain only + + Response: + { + "success": true, + "domains": [ + { + "name": "example.com", + "active": true, + "max_accounts": 0, + "max_quota_mb": 0, + "accounts": [ + { + "username": "user", # local part + "email": "user@example.com", + "quota_mb": 1024, # converted from bytes + "active": true + # password_hash intentionally NOT returned for security + } + ], + "aliases": [ + {"address": "alias@example.com", "goto": "dest@example.com", "active": true} + ] + } + ] + } + """ + filter_domain = payload.get("domain") + try: + conn = get_db(cfg) + result = [] + with conn: + with conn.cursor() as cur: + # Fetch domains + if filter_domain: + cur.execute("SELECT * FROM domain WHERE domain=%s", (filter_domain,)) + else: + cur.execute("SELECT * FROM domain WHERE active=1 ORDER BY domain") + domains = cur.fetchall() + + for dom in domains: + domain_name = dom["domain"] + + # Mailboxes — exclude password for security + cur.execute( + """SELECT username, local_part, name, + ROUND(quota / 1048576) AS quota_mb, + active + FROM mailbox + WHERE domain=%s + ORDER BY local_part""", + (domain_name,) + ) + mailboxes = cur.fetchall() + + # Aliases — exclude self-aliases (address == goto, used for mailboxes) + 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 + "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"]), + } + for m in mailboxes + ], + "aliases": [ + { + "address": a["address"], + "goto": a["goto"], + "active": bool(a["active"]), + } + for a in aliases + ], + }) + + return {"success": True, "domains": result} + except Exception as e: + log.error(f"get_state error: {e}") + return {"success": False, "error": str(e)} + +HANDLERS = { + "apply_domain": handle_apply_domain, + "delete_domain": handle_delete_domain, + "get_quota_usage": handle_get_quota_usage, + "get_state": handle_get_state, +} + + +# ────────────────────────────────────────────── +# Main loop +# ────────────────────────────────────────────── + +def run(cfg: dict): + redis_kwargs = { + "host": cfg["redis_host"], + "port": cfg["redis_port"], + "db": cfg["redis_db"], + "decode_responses": True, + } + if cfg.get("redis_password"): + redis_kwargs["password"] = cfg["redis_password"] + tls_cfg = cfg.get("tls", {}) + if tls_cfg.get("enabled", False): + redis_kwargs["ssl"] = True + redis_kwargs["ssl_certfile"] = tls_cfg["client_cert"] + redis_kwargs["ssl_keyfile"] = tls_cfg["client_key"] + redis_kwargs["ssl_ca_certs"] = tls_cfg["ca_cert"] + redis_kwargs["ssl_cert_reqs"] = "required" + log.info("Redis mTLS enabled") + else: + log.warning("Redis TLS disabled — traffic is unencrypted") + + # Test DB connection on startup + try: + conn = get_db(cfg) + with conn: + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS n FROM domain") + n = cur.fetchone()["n"] + log.info(f"MariaDB connected — {n} domain(s) in database") + except Exception as e: + log.error(f"Cannot connect to MariaDB: {e}") + sys.exit(1) + + r = redis.Redis(**redis_kwargs) + r.ping() + log.info(f"Connected to Redis at {cfg['redis_host']}:{cfg['redis_port']}") + + pubsub = r.pubsub() + pubsub.subscribe("mail.commands") + log.info("Subscribed to mail.commands — waiting for messages…") + # Announce reconnection so the backend can flush the pending queue + import socket as _socket + r.publish("agent.ready", json.dumps({"service": "mail", "host": _socket.gethostname()})) + log.info(f"Published agent.ready for service 'mail'") + + for message in pubsub.listen(): + if message["type"] != "message": + continue + try: + msg = json.loads(message["data"]) + except (json.JSONDecodeError, KeyError): + log.warning("Malformed message received, ignored") + continue + + # mTLS handles authentication — no token check needed + # Only clients with a valid CA-signed cert can connect to Redis + + msg_id = msg.get("id", "unknown") + action = msg.get("action", "") + payload = msg.get("payload", {}) + + log.info(f"Received action '{action}' (id={msg_id})") + + handler = HANDLERS.get(action) + if not handler: + ack = {"id": msg_id, "success": False, "error": f"Unknown action '{action}'"} + else: + ack = handler(payload, cfg) + ack["id"] = msg_id + + r.publish("mail.acks", json.dumps(ack)) + log.info(f"ACK for '{action}' (id={msg_id}): success={ack.get('success')}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="NetAdmin Mail Agent (PostfixAdmin/MySQL)") + parser.add_argument("--config", default="/etc/netadmin/config.yaml") + args = parser.parse_args() + + if not Path(args.config).exists(): + log.error(f"Config file not found: {args.config}") + sys.exit(1) + + agent_cfg = {**DEFAULT_CFG, **load_config(args.config)} + log.info( + f"Mail agent starting " + f"(db={agent_cfg['db_user']}@{agent_cfg['db_host']}/{agent_cfg['db_name']})" + ) + + try: + run(agent_cfg) + except KeyboardInterrupt: + log.info("Mail agent stopped") + except Exception as e: + log.error(f"Fatal error: {e}") + sys.exit(1) diff --git a/agents/netadmin-dns-agent.service b/agents/netadmin-dns-agent.service new file mode 100644 index 0000000..a8f4d59 --- /dev/null +++ b/agents/netadmin-dns-agent.service @@ -0,0 +1,23 @@ +[Unit] +Description=NetAdmin DNS Agent +After=network.target named.service + +[Service] +Type=simple +User=bind +Group=bind +ExecStart=/usr/bin/python3 /opt/netadmin-agent/dns_agent.py --config /etc/netadmin/config.yaml +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=netadmin-dns-agent + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/etc/bind/zones /etc/bind/named.conf.local +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/agents/netadmin-mail-agent.service b/agents/netadmin-mail-agent.service new file mode 100644 index 0000000..62817f2 --- /dev/null +++ b/agents/netadmin-mail-agent.service @@ -0,0 +1,17 @@ +[Unit] +Description=NetAdmin Mail Agent +After=network.target postfix.service dovecot.service + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/bin/python3 /opt/netadmin-agent/mail_agent.py --config /etc/netadmin/config.yaml +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=netadmin-mail-agent + +[Install] +WantedBy=multi-user.target diff --git a/agents/requirements.txt b/agents/requirements.txt new file mode 100644 index 0000000..f6d315b --- /dev/null +++ b/agents/requirements.txt @@ -0,0 +1,4 @@ +redis>=5.0 +pyyaml>=6.0 +dnspython>=2.6.0 # dns_agent only +PyMySQL>=1.1.0 # mail_agent only diff --git a/backend/audit.py b/backend/audit.py new file mode 100644 index 0000000..e1ee537 --- /dev/null +++ b/backend/audit.py @@ -0,0 +1,249 @@ +""" +audit.py — Centralized audit log for NetAdmin + +All tables live in the shared MariaDB database (cfg.database.name). +""" + +import datetime +import json +import logging +import uuid + +from db import get_db as _conn + +log = logging.getLogger(__name__) + + +def _init_tables(): + """Create audit and sync_history tables if they don't exist.""" + try: + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS audit_log ( + id VARCHAR(36) PRIMARY KEY, + ts DATETIME NOT NULL DEFAULT NOW(), + user_id VARCHAR(36), + username VARCHAR(64), + ip VARCHAR(45), + service VARCHAR(16) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(255), + detail TEXT, + success TINYINT NOT NULL DEFAULT 1, + error TEXT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + for idx_name, idx_col in [ + ("idx_audit_ts", "ts"), + ("idx_audit_user", "user_id"), + ("idx_audit_service", "service"), + ]: + try: + conn.execute(f"CREATE INDEX {idx_name} ON audit_log({idx_col})") + except Exception: + pass # index already exists + except Exception as e: + log.warning(f"[audit] init_tables: {e}") + + +_init_tables() + + +def record( + service: str, + action: str, + target: str | None = None, + detail: dict | None = None, + success: bool = True, + error: str | None = None, + user: dict | None = None, # FastAPI user dict from get_current_user + ip: str | None = None, +) -> str: + """Write one audit entry. Returns the entry id.""" + entry_id = str(uuid.uuid4()) + user_id = user.get("id") if user else None + username = user.get("username") if user else "system" + try: + with _conn() as conn: + conn.execute( + """INSERT INTO audit_log + (id, user_id, username, ip, service, action, target, detail, success, error) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + entry_id, + user_id, + username, + ip, + service, + action, + target, + json.dumps(detail) if detail else None, + int(success), + error, + ) + ) + except Exception as e: + # Audit must never break the main flow + log.warning(f"[audit] Failed to write log entry: {e}") + return entry_id + + +def query( + service: str | None = None, + username: str | None = None, + action: str | None = None, + limit: int = 200, + offset: int = 0, +) -> list[dict]: + """Query audit log entries, newest first.""" + conditions = [] + params = [] + if service: + conditions.append("service = %s"); params.append(service) + if username: + conditions.append("username LIKE %s"); params.append(f"%{username}%") + if action: + conditions.append("action = %s"); params.append(action) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params += [limit, offset] + + try: + with _conn() as conn: + rows = conn.execute( + f"SELECT * FROM audit_log {where} ORDER BY ts DESC LIMIT %s OFFSET %s", + params + ).fetchall() + return [dict(r) for r in rows] + except Exception as e: + log.warning(f"[audit] Query failed: {e}") + return [] + + +def count( + service: str | None = None, + username: str | None = None, + action: str | None = None, +) -> int: + conditions = [] + params = [] + if service: + conditions.append("service = %s"); params.append(service) + if username: + conditions.append("username LIKE %s"); params.append(f"%{username}%") + if action: + conditions.append("action = %s"); params.append(action) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + try: + with _conn() as conn: + return conn.execute( + f"SELECT COUNT(*) AS n FROM audit_log {where}", params + ).fetchone()["n"] + except Exception as e: + log.warning(f"[audit] Count failed: {e}") + return 0 + + +# ────────────────────────────────────────────────────────────────────────── +# Sync history +# ────────────────────────────────────────────────────────────────────────── + +def _ensure_sync_history(): + """Create sync_history table if not exists.""" + try: + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS sync_history ( + id VARCHAR(36) PRIMARY KEY, + ts DATETIME NOT NULL DEFAULT NOW(), + service VARCHAR(16) NOT NULL, + `trigger` VARCHAR(32) NOT NULL, + duration_ms INT, + zones INT DEFAULT 0, + records INT DEFAULT 0, + success TINYINT NOT NULL DEFAULT 1, + error TEXT, + detail TEXT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + try: + conn.execute("CREATE INDEX idx_synchist_ts ON sync_history(ts)") + except Exception: + pass # index already exists + except Exception as e: + log.warning(f"[audit] sync_history init failed: {e}") + + +_ensure_sync_history() + + +def record_sync( + service: str, + trigger: str, + duration_ms: int | None = None, + zones: int = 0, + records: int = 0, + success: bool = True, + error: str | None = None, + detail: dict | None = None, +) -> str: + """Record one sync operation in sync_history.""" + entry_id = str(uuid.uuid4()) + try: + with _conn() as conn: + conn.execute( + """INSERT INTO sync_history + (id, service, `trigger`, duration_ms, zones, records, success, error, detail) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + entry_id, service, trigger, + duration_ms, zones, records, + int(success), error, + json.dumps(detail) if detail else None, + ) + ) + except Exception as e: + log.warning(f"[audit] Failed to write sync_history: {e}") + return entry_id + + +def query_sync_history( + service: str | None = None, + trigger: str | None = None, + limit: int = 100, + offset: int = 0, +) -> list[dict]: + """Return sync history entries, newest first.""" + conditions, params = [], [] + if service: + conditions.append("service=%s"); params.append(service) + if trigger: + conditions.append("`trigger`=%s"); params.append(trigger) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params += [limit, offset] + try: + with _conn() as conn: + rows = conn.execute( + f"SELECT * FROM sync_history {where} ORDER BY ts DESC LIMIT %s OFFSET %s", + params + ).fetchall() + return [dict(r) for r in rows] + except Exception as e: + log.warning(f"[audit] sync_history query failed: {e}") + return [] + + +def count_sync_history(service: str | None = None) -> int: + conditions, params = [], [] + if service: + conditions.append("service=%s"); params.append(service) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + try: + with _conn() as conn: + return conn.execute( + f"SELECT COUNT(*) AS n FROM sync_history {where}", params + ).fetchone()["n"] + except Exception as e: + log.warning(f"[audit] sync_history count failed: {e}") + return 0 diff --git a/backend/broker.py b/backend/broker.py new file mode 100644 index 0000000..aa173e9 --- /dev/null +++ b/backend/broker.py @@ -0,0 +1,305 @@ +""" +Pending queue: if an agent is unreachable, actions are stored in SQLite +(table: pending_actions) and can be retried later via /pending endpoints. + +broker.py — Redis mTLS message broker for NetAdmin + +All connections use mutual TLS (mTLS): + - Backend presents backend.crt signed by the internal CA + - Redis server presents redis-server.crt signed by the same CA + - Both sides verify each other's certificate + +Channels: + dns.commands / dns.acks + mail.commands / mail.acks +""" + +import datetime +import json +import logging +import ssl +import time +import uuid + +import redis +import redis.connection + +from config import cfg + +log = logging.getLogger(__name__) + +_redis_client: redis.Redis | None = None + + +# ────────────────────────────────────────────── +# Pending action queue (SQLite — shared netadmin.db) +# ────────────────────────────────────────────── + +from db import get_db as _queue_conn + + +def _init_queue_table(): + try: + with _queue_conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS pending_actions ( + id VARCHAR(36) PRIMARY KEY, + service TEXT NOT NULL, + action TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NOT NULL DEFAULT NOW() + ) + """) + except Exception as e: + log.warning(f"[broker] queue table init: {e}") + + +_init_queue_table() + + + +def queue_action(service: str, action: str, payload: dict) -> str: + """Store a failed action for later retry. Returns the queue entry id.""" + entry_id = str(uuid.uuid4()) + with _queue_conn() as conn: + conn.execute( + "INSERT INTO pending_actions(id,service,action,payload,error) VALUES(%s,%s,%s,%s,%s)", + (entry_id, service, action, json.dumps(payload), + "Agent unreachable at " + datetime.datetime.utcnow().isoformat()) + ) + log.warning(f"[queue] Action '{action}' ({service}) queued (id={entry_id})") + return entry_id + + +def list_pending() -> list: + """Return all non-done pending actions ordered by creation date.""" + with _queue_conn() as conn: + rows = conn.execute( + "SELECT * FROM pending_actions WHERE status != 'done' ORDER BY created_at" + ).fetchall() + return [dict(r) for r in rows] + + +def mark_done(entry_id: str): + with _queue_conn() as conn: + conn.execute( + "UPDATE pending_actions SET status='done', updated_at=NOW() WHERE id=%s", + (entry_id,) + ) + + +def mark_failed(entry_id: str, error: str): + with _queue_conn() as conn: + conn.execute( + "UPDATE pending_actions SET status='failed', error=%s, updated_at=NOW() WHERE id=%s", + (error, entry_id) + ) + + +def get_redis() -> redis.Redis: + global _redis_client + if _redis_client is not None: + return _redis_client + + kwargs: dict = { + "host": cfg.redis.host, + "port": cfg.redis.port, + "db": cfg.redis.db, + "decode_responses": True, + "socket_timeout": cfg.redis.ack_timeout + 2, + "socket_connect_timeout": 5, + } + + if cfg.redis.password: + kwargs["password"] = cfg.redis.password + + tls = cfg.redis.tls + if tls.enabled: + kwargs["ssl"] = True + kwargs["ssl_certfile"] = tls.client_cert + kwargs["ssl_keyfile"] = tls.client_key + kwargs["ssl_ca_certs"] = tls.ca_cert + kwargs["ssl_cert_reqs"] = "required" + log.info( + f"[broker] Redis mTLS enabled " + f"(cert={tls.client_cert}, ca={tls.ca_cert})" + ) + else: + log.warning("[broker] Redis TLS disabled — traffic is unencrypted") + + _redis_client = redis.Redis(**kwargs) + return _redis_client + + +def publish_and_wait(channel: str, ack_channel: str, + action: str, payload: dict, + timeout: int | None = None) -> dict: + """Publish a command and block until the agent ACKs or timeout. + + Both the publish connection and the subscribe connection use the same + mTLS configuration. + + Returns ACK dict: {"id": ..., "success": True/False, "error": "..."} + Raises RuntimeError on timeout or connection error. + """ + r = get_redis() + msg_id = str(uuid.uuid4()) + timeout = timeout or cfg.redis.ack_timeout + + message = json.dumps({ + "id": msg_id, + "action": action, + "payload": payload, + # No token — authentication is handled by the mTLS certificate CN + }) + + # Build a separate subscribe connection with mTLS + sub_kwargs: dict = { + "host": cfg.redis.host, + "port": cfg.redis.port, + "db": cfg.redis.db, + "decode_responses": True, + } + if cfg.redis.password: + sub_kwargs["password"] = cfg.redis.password + tls = cfg.redis.tls + if tls.enabled: + sub_kwargs["ssl"] = True + sub_kwargs["ssl_certfile"] = tls.client_cert + sub_kwargs["ssl_keyfile"] = tls.client_key + sub_kwargs["ssl_ca_certs"] = tls.ca_cert + sub_kwargs["ssl_cert_reqs"] = "required" + + sub_r = redis.Redis(**sub_kwargs) + pubsub = sub_r.pubsub() + pubsub.subscribe(ack_channel) + + try: + # Drain any stale subscribe confirmation message + pubsub.get_message(timeout=0.1) + + r.publish(channel, message) + log.debug(f"[broker] published '{action}' → {channel} (id={msg_id})") + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + msg = pubsub.get_message(timeout=min(remaining, 0.5)) + if msg is None or msg["type"] != "message": + continue + try: + ack = json.loads(msg["data"]) + except (json.JSONDecodeError, TypeError): + continue + if ack.get("id") == msg_id: + log.debug(f"[broker] ACK for {msg_id}: success={ack.get('success')}") + return ack + + finally: + try: + pubsub.unsubscribe() + pubsub.close() + sub_r.close() + except Exception: + pass + + raise RuntimeError( + f"Agent timeout ({timeout}s) for action '{action}'. " + "Check that the agent is running and connected to Redis." + ) + + +def publish_or_queue(service: str, action: str, payload: dict) -> dict: + """Try to publish and wait for ACK. If agent is unreachable, queue the action. + + Returns: + {"success": True, "queued": False} — agent responded OK + {"success": True, "queued": True, "id": "..."} — queued for later + Raises HTTPException on agent error (agent reachable but returned failure). + """ + from fastapi import HTTPException + channel = f"{service}.commands" + ack_channel = f"{service}.acks" + try: + ack = publish_and_wait(channel, ack_channel, action, payload) + if not ack.get("success"): + raise HTTPException(500, f"Agent error: {ack.get('error', '')}") + return {"success": True, "queued": False} + except RuntimeError: + # Agent unreachable — queue for later + entry_id = queue_action(service, action, payload) + return {"success": True, "queued": True, "id": entry_id} + + +def retry_pending(entry_id: str) -> dict: + """Retry a queued action. Returns result dict.""" + with _queue_conn() as conn: + row = conn.execute( + "SELECT * FROM pending_actions WHERE id=%s", (entry_id,) + ).fetchone() + if not row: + return {"success": False, "error": "Not found"} + + conn2_ctx = _queue_conn() + with conn2_ctx as conn: + conn.execute( + "UPDATE pending_actions SET status='retrying', updated_at=NOW() WHERE id=%s", + (entry_id,) + ) + + service = row["service"] + action = row["action"] + payload = json.loads(row["payload"]) + channel = f"{service}.commands" + ack_channel = f"{service}.acks" + + try: + ack = publish_and_wait(channel, ack_channel, action, payload) + if ack.get("success"): + mark_done(entry_id) + return {"success": True, "queued": False} + else: + err = ack.get("error", "Unknown error") + mark_failed(entry_id, err) + return {"success": False, "error": err} + except RuntimeError as e: + mark_failed(entry_id, str(e)) + return {"success": False, "error": str(e)} + + +def flush_pending_for_service(service: str): + """Retry all pending actions for a given service (called when agent reconnects).""" + with _queue_conn() as conn: + rows = conn.execute( + "SELECT * FROM pending_actions WHERE service=%s AND status != 'done' ORDER BY created_at", + (service,) + ).fetchall() + if not rows: + log.info(f"[queue] No pending actions for service '{service}'") + return + log.info(f"[queue] Flushing {len(rows)} pending action(s) for '{service}'") + for row in rows: + result = retry_pending(row["id"]) + if result.get("success"): + log.info(f"[queue] Flushed action {row['id']} ({row['action']})") + else: + log.warning(f"[queue] Failed to flush {row['id']}: {result.get('error')}") + + +def publish_dns(action: str, payload: dict) -> dict: + return publish_and_wait("dns.commands", "dns.acks", action, payload) + + +def publish_mail(action: str, payload: dict) -> dict: + return publish_and_wait("mail.commands", "mail.acks", action, payload) + + +def check_connection() -> bool: + try: + get_redis().ping() + return True + except Exception: + return False diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ca2bd02 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,161 @@ +""" +config.py — Chargement centralisé de la configuration NetAdmin +""" +import os +import secrets +from pathlib import Path +from dataclasses import dataclass, field +from typing import List + +try: + import yaml + _HAS_YAML = True +except ImportError: + _HAS_YAML = False + print("[WARN] PyYAML not installed — using defaults. Run: pip install pyyaml") + + +@dataclass +class ServerConfig: + host: str = "0.0.0.0" + port: int = 8000 + cors_origins: List[str] = field(default_factory=lambda: ["*"]) + behind_proxy: bool = False + + +@dataclass +class RedisTLSConfig: + enabled: bool = True + ca_cert: str = "/etc/netadmin/certs/ca.crt" + client_cert: str = "/etc/netadmin/certs/backend.crt" + client_key: str = "/etc/netadmin/certs/backend.key" + check_hostname: bool = True + + +@dataclass +class RedisConfig: + host: str = "localhost" + port: int = 6380 + password: str = "" + db: int = 0 + ack_timeout: int = 10 + tls: RedisTLSConfig = field(default_factory=RedisTLSConfig) + + +@dataclass +class SmtpConfig: + enabled: bool = False + host: str = "localhost" + port: int = 587 + username: str = "" + password: str = "" + from_addr: str = "netadmin@localhost" + from_name: str = "NetAdmin" + use_tls: bool = True # STARTTLS + use_ssl: bool = False # SSL/TLS direct (port 465) + # Notification triggers + notify_login: bool = True # send email on successful login + notify_role_added: bool = True # send email when a role is assigned + + +@dataclass +class SyncConfig: + interval: int = 300 # seconds between polls, 0 = disabled + + +@dataclass +class DatabaseConfig: + host: str = "localhost" + port: int = 3306 + user: str = "netadmin" + password: str = "" + name: str = "netadmin" + + +@dataclass +class MailConfig: + pass # mail data stored in shared netadmin.db + + +@dataclass +class OidcConfig: + enabled: bool = False + issuer: str = "" + client_id: str = "" + client_secret: str = "" + scopes: List[str] = field(default_factory=lambda: ["openid", "email", "profile"]) + auto_provision: bool = True + redirect_uri: str = "http://localhost:3000/auth/callback" + use_pkce: bool = True + admin_group: str = "" + + +@dataclass +class AuthConfig: + session_secret: str = "" + session_ttl_hours: int = 8 + oidc: OidcConfig = field(default_factory=OidcConfig) + # db_path removed — auth tables live in the shared netadmin.db + + +@dataclass +class AppConfig: + server: ServerConfig = field(default_factory=ServerConfig) + redis: RedisConfig = field(default_factory=RedisConfig) + sync: SyncConfig = field(default_factory=SyncConfig) + smtp: SmtpConfig = field(default_factory=SmtpConfig) + database: DatabaseConfig = field(default_factory=DatabaseConfig) + mail: MailConfig = field(default_factory=MailConfig) + auth: AuthConfig = field(default_factory=AuthConfig) + + +def _merge(obj, data: dict): + if not isinstance(data, dict): + return + for key, value in data.items(): + if not hasattr(obj, key): + continue + current = getattr(obj, key) + if hasattr(current, '__dataclass_fields__'): + _merge(current, value) + else: + setattr(obj, key, value) + + +def _find_config_file(): + for path in [ + os.environ.get("NETADMIN_CONFIG", ""), + "/etc/netadmin/config.yaml", + str(Path(__file__).parent / "config.yaml"), + ]: + if path and Path(path).exists(): + return Path(path) + return None + + +def _load() -> AppConfig: + config = AppConfig() + config_file = _find_config_file() + if config_file is None: + print("[INFO] No config.yaml found — using defaults") + return _finalise(config) + print(f"[INFO] Loading configuration from: {config_file}") + if not _HAS_YAML: + return _finalise(config) + try: + with open(config_file) as f: + raw = yaml.safe_load(f) or {} + _merge(config, raw) + except Exception as e: + print(f"[ERROR] Failed to parse {config_file}: {e}") + return _finalise(config) + + +def _finalise(config: AppConfig) -> AppConfig: + if not config.auth.session_secret: + config.auth.session_secret = secrets.token_hex(32) + print("[INFO] session_secret not set — generated a random one (sessions won't survive restarts)") + return config + + +cfg: AppConfig = _load() diff --git a/backend/config.yaml b/backend/config.yaml new file mode 100644 index 0000000..b34cf82 --- /dev/null +++ b/backend/config.yaml @@ -0,0 +1,127 @@ +# ═══════════════════════════════════════════════════════════════════ +# NetAdmin — fichier de configuration +# Emplacement par défaut : /etc/netadmin/config.yaml +# Surcharge via : NETADMIN_CONFIG=/chemin/config.yaml +# ═══════════════════════════════════════════════════════════════════ + +# ── Serveur ───────────────────────────────────────────────────────── +server: + host: "0.0.0.0" + port: 8000 + cors_origins: + - "http://localhost:3000" + behind_proxy: false + +# ── 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) + 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 + 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 + +# ── Synchronisation agent ─────────────────────────────────────────── +sync: + # Intervalle de polling MariaDB → SQLite (secondes, 0 = désactivé) + interval: 300 # 5 minutes + +# ── 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 + user: "netadmin" + password: "changeme" + name: "netadmin" + +# ── Mail ───────────────────────────────────────────────────────────── + +# ── Authentification ───────────────────────────────────────────────── + session_secret: "" + session_ttl_hours: 8 + + oidc: + enabled: false + issuer: "" + client_id: "" + client_secret: "" + redirect_uri: "http://localhost:3000/auth/callback" + use_pkce: true + auto_provision: true + 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 +# ═══════════════════════════════════════════════════════════════════ + +# ── Agent DNS (serveur BIND) ──────────────────────────────────────── +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 + tls: + enabled: true + ca_cert: "/etc/netadmin/certs/ca.crt" + client_cert: "/etc/netadmin/certs/dns-agent.crt" + client_key: "/etc/netadmin/certs/dns-agent.key" + check_hostname: true + +# ── Agent Mail (serveur Postfix/Dovecot) ──────────────────────────── +mail_agent: + # ── MariaDB (base PostfixAdmin existante) ────────────────────── + db_host: "localhost" + db_port: 3306 + db_name: "postfix" + db_user: "postfix" + db_password: "mot_de_passe_db" + + # ── Postfix ───────────────────────────────────────────────────── + postfix_reload_cmd: "postfix reload" + + # ── 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" + client_cert: "/etc/netadmin/certs/mail-agent.crt" + client_key: "/etc/netadmin/certs/mail-agent.key" + check_hostname: true diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..1e5ad08 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,98 @@ +""" +db.py — MySQL/MariaDB connection for NetAdmin (PyMySQL). +All tables live in the shared 'netadmin' database. +Rows returned as dicts. Interface compatible with sqlite3 usage patterns. +""" + +import logging +from contextlib import contextmanager +from typing import Any + +import pymysql +import pymysql.cursors + +from config import cfg + +log = logging.getLogger(__name__) + + +class _ConnWrapper: + """ + Thin wrapper around a PyMySQL connection that adds .execute() / .fetchone() + / .fetchall() methods directly on the connection object — matching the sqlite3 + interface used throughout the codebase. + """ + + def __init__(self, conn): + self._conn = conn + self._cur = conn.cursor() + + # ── Cursor proxy ───────────────────────────────────────────────────── + def execute(self, sql: str, params=None): + self._cur.execute(sql, params or ()) + return self._cur + + def fetchone(self): + return self._cur.fetchone() + + def fetchall(self): + return self._cur.fetchall() + + # ── Transaction proxy ───────────────────────────────────────────────── + def commit(self): + self._conn.commit() + + def rollback(self): + self._conn.rollback() + + def close(self): + try: + self._cur.close() + except Exception: + pass + self._conn.close() + + +def _connect() -> _ConnWrapper: + db = cfg.database + raw = pymysql.connect( + host = db.host, + port = db.port, + user = db.user, + password = db.password, + database = db.name, + charset = "utf8mb4", + cursorclass = pymysql.cursors.DictCursor, + autocommit = False, + ) + return _ConnWrapper(raw) + + +@contextmanager +def get_db(): + """Yield a connection wrapper. Commits on success, rolls back on exception.""" + conn = _connect() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +# ── Datetime helper ──────────────────────────────────────────────────────── + +def _dt(v) -> str | None: + """Convert datetime/date → ISO string. Pass str/None through unchanged. + + MariaDB returns DATETIME columns as datetime.datetime objects via PyMySQL. + Pydantic models expect str for these fields. + """ + import datetime as _datetime + if v is None: + return None + if isinstance(v, (_datetime.datetime, _datetime.date)): + return v.isoformat() + return str(v) if v else None diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..9d29fdd --- /dev/null +++ b/backend/main.py @@ -0,0 +1,250 @@ +""" +NetAdmin — DNS & Mail Manager Backend +""" +import asyncio +import logging +import uvicorn +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request, Depends +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware + +from config import cfg +from broker import check_connection +import audit +from contextvars import ContextVar + +# ── Request context (IP) ───────────────────────────────────────────────── +_request_ip: ContextVar[str] = ContextVar("request_ip", default="") + + +def get_request_ip() -> str: + return _request_ip.get() + + +class IPMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + if not ip: + ip = request.client.host if request.client else "" + token = _request_ip.set(ip) + try: + return await call_next(request) + finally: + _request_ip.reset(token) + +log = logging.getLogger(__name__) + + +# ────────────────────────────────────────────── +# Sync helpers (run in thread pool — sync_from_agent is blocking) +# ────────────────────────────────────────────── + +def _do_sync(label: str): + """Run a full sync from both agents. Called from executor to avoid blocking.""" + # ── DNS sync ───────────────────────────────────────────────────── + try: + from routers.dns import sync_from_agent as dns_sync + r = dns_sync(trigger=label) + log.info( + f"[sync:{label}] DNS OK — " + f"{r['total_zones']} zone(s), " + f"{r['imported_zones']} importée(s), " + f"{r['imported_records']} enreg." + ) + except Exception as e: + log.warning(f"[sync:{label}] DNS failed (agent may be offline): {e}") + + # ── Mail sync ───────────────────────────────────────────────────── + try: + from routers.mail import sync_from_agent as mail_sync + r = mail_sync(trigger=label) + log.info( + f"[sync:{label}] Mail OK — " + f"{r['total_domains']} domaine(s), " + f"{r['accounts']} nouveau(x) compte(s)" + ) + except Exception as e: + log.warning(f"[sync:{label}] Mail failed (agent may be offline): {e}") + + +async def _agent_ready_listener(): + """Listen for agent.ready messages and flush the pending queue.""" + import json as _json + from broker import get_redis, flush_pending_for_service + try: + r = get_redis() + sub_kwargs = { + "host": cfg.redis.host, "port": cfg.redis.port, + "db": cfg.redis.db, "decode_responses": True, + } + if cfg.redis.password: + sub_kwargs["password"] = cfg.redis.password + tls = cfg.redis.tls + if tls.enabled: + sub_kwargs.update({ + "ssl": True, + "ssl_certfile": tls.client_cert, + "ssl_keyfile": tls.client_key, + "ssl_ca_certs": tls.ca_cert, + "ssl_cert_reqs": "required", + }) + import redis as _redis + sub_r = _redis.Redis(**sub_kwargs) + pubsub = sub_r.pubsub() + pubsub.subscribe("agent.ready") + log.info("[agent-listener] Subscribed to agent.ready") + loop = asyncio.get_running_loop() + while True: + msg = await loop.run_in_executor(None, lambda: pubsub.get_message(timeout=1.0)) + if msg and msg["type"] == "message": + try: + data = _json.loads(msg["data"]) + service = data.get("service") + if service in ("dns", "mail"): + log.info(f"[agent-listener] Agent '{service}' reconnected — flushing queue") + audit.record("system", "agent_reconnect", + target=f"agent:{service}", detail=data) + await loop.run_in_executor(None, flush_pending_for_service, service) + except Exception as e: + log.warning(f"[agent-listener] Error processing agent.ready: {e}") + await asyncio.sleep(0) + except Exception as e: + log.warning(f"[agent-listener] Listener error: {e}") + + +async def _sync_once(label: str): + """Async wrapper: run blocking sync in thread pool.""" + if not check_connection(): + log.warning(f"[sync:{label}] Redis not reachable — skipped") + return + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _do_sync, label) + + +async def _polling_loop(): + """Background task: sync from MariaDB every cfg.sync.interval seconds.""" + interval = cfg.sync.interval + if interval <= 0: + log.info("[polling] Disabled (sync.interval=0)") + return + + log.info(f"[polling] Started — interval={interval}s") + while True: + await asyncio.sleep(interval) + await _sync_once("poll") + + +# ────────────────────────────────────────────── +# Lifespan: startup sync + launch polling task +# ────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 1. Initial sync on startup + await _sync_once("startup") # trigger="startup" passed to sync functions + + # 2. Launch background polling task + poll_task = None + listener_task = None + if cfg.sync.interval > 0: + poll_task = asyncio.create_task(_polling_loop()) + log.info(f"[startup] Background polling task started (every {cfg.sync.interval}s)") + else: + log.info("[startup] Background polling disabled (sync.interval=0)") + + try: + listener_task = asyncio.create_task(_agent_ready_listener()) + log.info("[startup] Agent-ready listener started") + except Exception as e: + log.warning(f"[startup] Could not start agent-ready listener: {e}") + + yield # ← application runs here + + # 3. Shutdown: cancel tasks cleanly + for task in filter(None, [poll_task, listener_task]): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + log.info("[shutdown] Background tasks stopped") + + +# ────────────────────────────────────────────── +# App +# ────────────────────────────────────────────── + +app = FastAPI( + title="NetAdmin API", + version="2.0.0", + lifespan=lifespan, + docs_url=None, # Disable Swagger UI — not exposed to end users + redoc_url=None, # Disable ReDoc + openapi_url=None, # Disable OpenAPI schema endpoint +) + +app.add_middleware(IPMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=cfg.server.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +from routers import auth, dns, mail # noqa: E402 +from routers.auth import get_current_user, require_global_admin # noqa: E402 +from db import get_db # noqa: E402 +app.include_router(auth.router, prefix="/auth", tags=["Auth"]) +app.include_router(dns.router, prefix="/dns", tags=["DNS"]) +app.include_router(mail.router, prefix="/mail", tags=["Mail"]) + + +@app.get("/health") +def health(): + return { + "status": "ok", + "redis": check_connection(), + "sync_interval": cfg.sync.interval, + } + + +@app.get("/sync/history") +def get_global_sync_history( + service: str | None = None, + trigger: str | None = None, + limit: int = 100, + offset: int = 0, + user: dict = Depends(require_global_admin), +): + """Combined sync history for all services — global admin only.""" + return { + "total": audit.count_sync_history(service), + "entries": audit.query_sync_history(service, trigger, limit, offset), + } + + +@app.get("/audit") +def get_audit_log( + service: str | None = None, + username: str | None = None, + action: str | None = None, + limit: int = 100, + offset: int = 0, + user: dict = Depends(require_global_admin), +): + """Audit log — global admin only.""" + return { + "total": audit.count(service, username, action), + "entries": audit.query(service, username, action, limit, offset), + } + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=cfg.server.host, + port=cfg.server.port, + reload=False, + ) diff --git a/backend/notify.py b/backend/notify.py new file mode 100644 index 0000000..5d815d2 --- /dev/null +++ b/backend/notify.py @@ -0,0 +1,180 @@ +""" +notify.py — Email notifications for NetAdmin +""" + +import datetime +import email.utils +import logging +import quopri +import socket +from email.header import Header +from email.message import Message +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + +from config import cfg + +log = logging.getLogger(__name__) + + +def _make_qp_part(content: str, subtype: str) -> Message: + """Build a text/plain or text/html MIME part using quoted-printable encoding. + + MIMEText with charset='utf-8' defaults to base64. + We encode manually as QP so mail clients display it as plain text. + """ + encoded = quopri.encodestring(content.encode("utf-8"), quotetabs=False) + part = Message() + part["Content-Type"] = f"text/{subtype}; charset=utf-8" + part["Content-Transfer-Encoding"] = "quoted-printable" + part.set_payload(encoded.decode("ascii")) + return part + + +def _send(to_addr: str, subject: str, body_text: str, body_html: Optional[str] = None): + """Send an email with correct headers and QP encoding.""" + s = cfg.smtp + if not s.enabled or not to_addr: + return + + try: + msg = MIMEMultipart("alternative") + + # RFC-required headers + msg["Message-ID"] = email.utils.make_msgid(domain=s.from_addr.split("@")[-1]) + msg["Date"] = email.utils.formatdate(localtime=True) + msg["Subject"] = Header(subject, "utf-8").encode() + msg["From"] = email.utils.formataddr((s.from_name, s.from_addr)) + msg["To"] = to_addr + msg["X-Mailer"] = "NetAdmin" + + # Attach parts with quoted-printable (readable in all clients) + msg.attach(_make_qp_part(body_text, "plain")) + if body_html: + msg.attach(_make_qp_part(body_html, "html")) + + if s.use_ssl: + server = __import__("smtplib").SMTP_SSL(s.host, s.port, timeout=10) + else: + server = __import__("smtplib").SMTP(s.host, s.port, timeout=10) + if s.use_tls: + server.starttls() + + if s.username and s.password: + server.login(s.username, s.password) + + server.sendmail(s.from_addr, [to_addr], msg.as_string()) + server.quit() + log.info(f"[notify] Email sent to {to_addr}: {subject}") + + except Exception as e: + log.warning(f"[notify] Failed to send email to {to_addr}: {e}") + + +def notify_login(username: str, email_addr: Optional[str], ip: str): + if not cfg.smtp.notify_login or not email_addr: + return + + hostname = socket.gethostname() + now = datetime.datetime.now().strftime("%d/%m/%Y à %H:%M") + + subject = "Connexion à votre compte NetAdmin" + text = ( + f"Bonjour {username},\n\n" + f"Une connexion a été détectée sur votre compte NetAdmin.\n\n" + f" Date : {now}\n" + f" Adresse IP : {ip or 'inconnue'}\n" + f" Serveur : {hostname}\n\n" + f"Si vous n'êtes pas à l'origine de cette connexion, " + f"contactez immédiatement votre administrateur.\n\n" + f"-- NetAdmin" + ) + html = f"""\ + + + + +

+ Connexion à votre compte NetAdmin +

+

Bonjour {username},

+

Une connexion a été détectée sur votre compte NetAdmin.

+ + + + + + + + + + + + + +
Date{now}
Adresse IP{ip or 'inconnue'}
Serveur{hostname}
+

+ Si vous n'êtes pas à l'origine de cette connexion, + contactez immédiatement votre administrateur. +

+
+

NetAdmin — notification automatique

+ +""" + + _send(email_addr, subject, text, html) + + +def notify_role_assigned( + target_username: str, + target_email: Optional[str], + role: str, + scopes: list, + assigned_by: str, +): + if not cfg.smtp.notify_role_added or not target_email: + return + + scope_str = ", ".join(str(s) for s in scopes if s) if scopes and scopes != [None] else "global" + + subject = f"NetAdmin — Nouveau rôle assigné : {role}" + text = ( + f"Bonjour {target_username},\n\n" + f"Un nouveau rôle vous a été assigné sur NetAdmin.\n\n" + f" Rôle : {role}\n" + f" Périmètre : {scope_str}\n" + f" Assigné par : {assigned_by}\n\n" + f"Connectez-vous à NetAdmin pour consulter vos permissions.\n\n" + f"-- NetAdmin" + ) + html = f"""\ + + + + +

+ Nouveau rôle NetAdmin +

+

Bonjour {target_username},

+

Un nouveau rôle vous a été assigné.

+ + + + + + + + + + + + + +
Rôle{role}
Périmètre{scope_str}
Assigné par{assigned_by}
+
+

NetAdmin — notification automatique

+ +""" + + _send(target_email, subject, text, html) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..923837a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +dnspython>=2.6.0 +pydantic[email]>=2.0.0 +pyyaml>=6.0 +redis>=5.0 +pyotp>=2.9.0 +qrcode>=7.4.2 +pillow>=10.0.0 + +PyMySQL>=1.1.0 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..9c8ddfa --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# routers package diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..661deea --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,1121 @@ +""" +Auth Router — User authentication (SQLite local + OpenID Connect) and RBAC +Roles: + - global_admin : full access everywhere + - dns_admin : manage all DNS zones + - mail_admin : manage all mail accounts/lists + - domain_admin : DNS + mail for a specific domain + - dns_zone_admin : DNS for a specific zone only +""" + +import os +import re +import uuid +import sqlite3 +import secrets +import hashlib + +from config import cfg +import datetime +from contextlib import contextmanager +from pathlib import Path +from typing import List, Optional, Dict + +from fastapi import APIRouter, HTTPException, Depends, Request, Response +import audit +import notify +from main import get_request_ip +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel, Field + +router = APIRouter() + +from db import get_db, _dt +SECRET = cfg.auth.session_secret +SESSION_TTL = cfg.auth.session_ttl_hours * 3600 + +# OpenID Connect / Keycloak settings +OIDC_ENABLED = cfg.auth.oidc.enabled +OIDC_ISSUER = cfg.auth.oidc.issuer +OIDC_CLIENT_ID = cfg.auth.oidc.client_id +OIDC_CLIENT_SECRET = cfg.auth.oidc.client_secret +OIDC_REDIRECT_URI = cfg.auth.oidc.redirect_uri +OIDC_USE_PKCE = cfg.auth.oidc.use_pkce +OIDC_SCOPES = " ".join(cfg.auth.oidc.scopes) +OIDC_ADMIN_GROUP = cfg.auth.oidc.admin_group +_oidc_discovery_cache: dict = {} + +VALID_ROLES = { + "global_admin", # Full access everywhere + "dns_admin", # All DNS zones + "mail_admin", # All mail accounts and lists + "domain_admin", # DNS + mail for specific domain(s) + "dns_zone_admin", # DNS records for specific zone(s) + "mail_domain_admin", # Mail accounts and lists for specific domain(s) +} + +# ────────────────────────────────────────────── +# Database +# ────────────────────────────────────────────── + + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── +def _hash_password(password: str) -> str: + import base64 + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 260000) + return "pbkdf2:" + base64.b64encode(salt + dk).decode() + + +def _verify_password(password: str, stored: str) -> bool: + import base64 + if not stored or not stored.startswith("pbkdf2:"): + return False + raw = base64.b64decode(stored[7:]) + salt, dk_stored = raw[:16], raw[16:] + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 260000) + return secrets.compare_digest(dk, dk_stored) + + +def _create_session(user_id: str, ip: str = None) -> str: + token = secrets.token_urlsafe(48) + now = datetime.datetime.utcnow() + 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( + "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) + ) + conn.execute("UPDATE users SET last_login = %s WHERE id = %s", (str(now), user_id)) + return token + + +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) + ).fetchone() + if not row or not row["active"]: + return None + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id = %s", (row["user_id"],) + ).fetchall() + return { + "id": row["user_id"], + "username": row["username"], + "email": row["email"], + "full_name": row["full_name"], + "auth_method": row["auth_method"], + "roles": [{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + } + + +def _has_role(user: dict, role: str, scope: str = None) -> bool: + for r in user["roles"]: + if r["role"] == "global_admin": + return True + if r["role"] == role and (scope is None or r["scope"] == scope or r["scope"] is None): + return True + return False + + +# get_db imported from db.py — single shared netadmin.db + + +def init_db(): + with get_db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(128), + email VARCHAR(255), + full_name VARCHAR(255), + active TINYINT NOT NULL DEFAULT 1, + auth_method VARCHAR(16) NOT NULL DEFAULT 'local', + oidc_sub VARCHAR(255), + totp_secret VARCHAR(64) DEFAULT NULL, + totp_enabled TINYINT NOT NULL DEFAULT 0, + totp_required TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT NOW(), + last_login DATETIME + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_roles ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + role VARCHAR(32) NOT NULL, + scope VARCHAR(255), + UNIQUE KEY uq_user_role_scope (user_id, role, scope(100)), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + ip VARCHAR(45), + 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. + for col, defn in [ + ("totp_secret", "TEXT DEFAULT NULL"), + ("totp_enabled", "INT DEFAULT 0"), + ("totp_required", "INT DEFAULT 0"), + ]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} {defn}") + conn.commit() + except Exception: + pass # column already exists — safe to ignore + + # Backfill NULL values that may exist from old rows + conn.execute("UPDATE users SET totp_enabled=0 WHERE totp_enabled IS NULL") + conn.execute("UPDATE users SET totp_required=0 WHERE totp_required IS NULL") + conn.commit() + + # Create default admin if no users exist + count = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + if count == 0: + admin_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO users(id, username, password_hash, email, full_name, auth_method) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (admin_id, "admin", _hash_password("admin"), "admin@localhost", "Administrator", "local") + ) + conn.execute( + "INSERT INTO user_roles(id, user_id, role, scope) VALUES (%s, %s, %s, %s)", + (str(uuid.uuid4()), admin_id, "global_admin", None) + ) + print("[INFO] Created default admin user (username: admin, password: admin) — CHANGE THIS!") + + +try: + init_db() +except Exception as e: + print(f"[WARN] Auth DB init failed: {e}") + +# FastAPI dependency +security = HTTPBearer(auto_error=False) + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + request: Request = None, +) -> dict: + token = None + if credentials: + token = credentials.credentials + elif request: + # Also accept token from cookie + token = request.cookies.get("netadmin_session") + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + user = _get_session_user(token) + if not user: + raise HTTPException(status_code=401, detail="Session expired or invalid") + 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") + return user + + +def get_allowed_dns_domains(user: dict) -> list | None: + """Domains this user can manage via DNS. + + Returns None = unrestricted, [] = no access, [x, y] = specific domains. + DNS access granted by: global_admin, dns_admin (no scope), domain_admin(scope), + dns_zone_admin(scope). + """ + for r in user["roles"]: + if r["role"] == "global_admin" and r["scope"] is None: + return None + if r["role"] == "dns_admin" and r["scope"] is None: + return None + + domains = set() + for r in user["roles"]: + if r["role"] in ("domain_admin", "dns_zone_admin") and r["scope"]: + domains.add(r["scope"]) + return list(domains) + + +def get_allowed_mail_domains(user: dict) -> list | None: + """Domains this user can manage via mail. + + Returns None = unrestricted, [] = no access, [x, y] = specific domains. + Mail access granted by: global_admin, mail_admin (no scope), domain_admin(scope), + mail_domain_admin(scope). + """ + for r in user["roles"]: + if r["role"] == "global_admin" and r["scope"] is None: + return None + if r["role"] == "mail_admin" and r["scope"] is None: + return None + + domains = set() + for r in user["roles"]: + if r["role"] in ("domain_admin", "mail_domain_admin") and r["scope"]: + domains.add(r["scope"]) + return list(domains) + + +# Keep get_allowed_domains as alias for DNS (used in dns.py) +def get_allowed_domains(user: dict) -> list | None: + """Alias for get_allowed_dns_domains — used by DNS routes.""" + return get_allowed_dns_domains(user) + + +def can_access_dns_domain(user: dict, domain: str) -> bool: + allowed = get_allowed_dns_domains(user) + if allowed is None: + return True + return domain in allowed + + +def can_access_mail_domain(user: dict, domain: str) -> bool: + allowed = get_allowed_mail_domains(user) + if allowed is None: + return True + return domain in allowed + + +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}'") + + +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}'") + + +# Legacy alias +def can_access_domain(user: dict, domain: str) -> bool: + return can_access_dns_domain(user, domain) + + +def require_domain_access(domain: str, user: dict): + require_dns_access(domain, user) + + +# ────────────────────────────────────────────── +# Pydantic models +# ────────────────────────────────────────────── + +class LoginRequest(BaseModel): + username: str + password: str + + +class UserCreate(BaseModel): + username: str = Field(..., min_length=2, max_length=64) + password: Optional[str] = None + email: Optional[str] = None + full_name: Optional[str] = None + auth_method: str = Field("local", pattern="^(local|oidc)$") + oidc_sub: Optional[str] = None + + +class UserUpdate(BaseModel): + password: Optional[str] = None + email: Optional[str] = None + full_name: Optional[str] = None + active: Optional[bool] = None + + +class RoleAssign(BaseModel): + role: str + scope: Optional[str] = None # single scope (legacy) + scopes: Optional[List[str]] = None # multiple scopes at once + + +class UserResponse(BaseModel): + id: str + username: str + email: Optional[str] + full_name: Optional[str] + active: bool + auth_method: str + created_at: str + last_login: Optional[str] + roles: List[dict] = [] + totp_enabled: bool = False + totp_required: bool = False + + +class SessionInfo(BaseModel): + token: str + user: UserResponse + expires_at: str + totp_pending: bool = False + totp_setup_required: bool = False + + +def _user_response(row: dict, roles: list = []) -> UserResponse: + """Build UserResponse from a DB row, safely converting datetime fields.""" + return UserResponse( + id = row["id"], + username = row["username"], + email = row.get("email"), + full_name = row.get("full_name"), + active = bool(row.get("active", 1)), + auth_method = row.get("auth_method", "local"), + created_at = _dt(row.get("created_at")) or "", + last_login = _dt(row.get("last_login")), + roles = roles, + totp_enabled = bool(row.get("totp_enabled") or 0), + totp_required = bool(row.get("totp_required") or 0), + ) + + +def _get_oidc_discovery() -> dict: + """Fetch and cache the Keycloak OIDC discovery document.""" + global _oidc_discovery_cache + if _oidc_discovery_cache: + return _oidc_discovery_cache + import urllib.request, json as _json + url = f"{OIDC_ISSUER}/.well-known/openid-configuration" + try: + 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}") + return _oidc_discovery_cache + +# ────────────────────────────────────────────── +# Auth routes +# ────────────────────────────────────────────── + +@router.get("/config") +def get_config(): + """Public endpoint — returns all config flags the frontend needs.""" + result = { + "oidc_enabled": OIDC_ENABLED, + "enabled": OIDC_ENABLED, # alias used by LoginPage + "oidc_issuer": OIDC_ISSUER, + "oidc_client_id": OIDC_CLIENT_ID, + } + if OIDC_ENABLED: + try: + discovery = _get_oidc_discovery() + result.update({ + "client_id": OIDC_CLIENT_ID, + "redirect_uri": OIDC_REDIRECT_URI, + "scopes": OIDC_SCOPES, + "use_pkce": OIDC_USE_PKCE, + "authorization_endpoint": discovery.get("authorization_endpoint", ""), + }) + except Exception: + result["authorization_endpoint"] = "" + return result + + +@router.post("/login", response_model=SessionInfo) +def login(body: LoginRequest, request: Request, response: Response): + with get_db() as conn: + row = conn.execute( + "SELECT *, COALESCE(totp_enabled, 0) AS totp_enabled, " + "COALESCE(totp_required, 0) AS totp_required " + "FROM users WHERE username = %s AND active = 1", + (body.username,) + ).fetchone() + if not row: + raise HTTPException(status_code=401, detail="Invalid username or password") + if row["auth_method"] == "oidc": + raise HTTPException(status_code=400, detail="This account uses OpenID Connect — use /auth/oidc/login") + if not _verify_password(body.password, row["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid username or password") + + ip = request.client.host if request.client else None + token = _create_session(row["id"], ip) + + with get_db() as conn: + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id = %s", (row["id"],) + ).fetchall() + expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat() + + response.set_cookie("netadmin_session", token, httponly=True, samesite="lax", max_age=SESSION_TTL) + ip = request.client.host if request.client else "" + 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 + return SessionInfo( + token=token, expires_at=expires, + user=_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ), + totp_pending=True, + ) + if row["totp_required"] and not row["totp_enabled"]: + # TOTP is required by admin but not yet configured — force setup + return SessionInfo( + token=token, expires_at=expires, + user=_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ), + totp_pending=True, + totp_setup_required=True, + ) + return SessionInfo( + token=token, + expires_at=expires, + user=_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ) + ) + + +@router.post("/logout", status_code=204) +def logout(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), response: Response = None): + token = credentials.credentials if credentials else None + if token: + with get_db() as conn: + row = conn.execute( + "SELECT u.id, u.username FROM sessions s JOIN users u ON s.user_id=u.id WHERE s.token=%s", + (token,) + ).fetchone() + conn.execute("DELETE FROM sessions WHERE token = %s", (token,)) + if row: + audit.record("auth", "logout", target=f"user:{row['username']}", + user={"id": row["id"], "username": row["username"]}, ip="") + if response: + response.delete_cookie("netadmin_session") + + +@router.get("/me", response_model=UserResponse) +def get_me(user: dict = Depends(get_current_user)): + with get_db() as conn: + 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 _user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ) + + +# ────────────────────────────────────────────── +# OpenID Connect (optional) +# ────────────────────────────────────────────── + + +@router.get("/oidc/config") +def oidc_config(): + """Return public OIDC config for the frontend to initiate the auth flow.""" + if not OIDC_ENABLED: + return {"enabled": False} + try: + discovery = _get_oidc_discovery() + auth_endpoint = discovery.get("authorization_endpoint", "") + except Exception: + auth_endpoint = "" + return { + "enabled": OIDC_ENABLED, + "issuer": OIDC_ISSUER, + "client_id": OIDC_CLIENT_ID, + "redirect_uri": OIDC_REDIRECT_URI, + "scopes": OIDC_SCOPES, + "use_pkce": OIDC_USE_PKCE, + "authorization_endpoint": auth_endpoint, + } + + +@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": "" + } + """ + if not OIDC_ENABLED: + raise HTTPException(status_code=400, detail="OpenID Connect is not enabled") + + 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") + 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 + + 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()) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"Keycloak OIDC error: {e}") + + # ── Extract user info from Keycloak claims ─────────────────────── + sub = userinfo.get("sub") + if not sub: + raise HTTPException(status_code=400, detail="No 'sub' claim in 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() + + 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) + 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) + + # 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() + + return SessionInfo( + token=token, + expires_at=expires, + user=_user_response(row2, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ), + ) + + +# ────────────────────────────────────────────── +# User management (global_admin only) +# ────────────────────────────────────────────── + +@router.get("/users", response_model=List[UserResponse]) +def list_users(admin=Depends(require_global_admin)): + with get_db() as conn: + rows = conn.execute("SELECT * FROM users ORDER BY username").fetchall() + result = [] + for row in rows: + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id = %s", (row["id"],) + ).fetchall() + result.append(_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + )) + return result + + +@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") + uid = str(uuid.uuid4()) + pw_hash = _hash_password(body.password) if body.password else None + with get_db() as conn: + try: + conn.execute( + "INSERT INTO users(id, username, password_hash, email, full_name, auth_method, oidc_sub) " + "VALUES (%s,%s,%s,%s,%s,%s,%s)", + (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") + 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}, + user=admin, ip=get_request_ip()) + return _user_response(row, roles=[]) + + +@router.put("/users/{user_id}", response_model=UserResponse) +def update_user(user_id: str, body: UserUpdate, admin=Depends(require_global_admin)): + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="User not found") + updates = {} + if body.password is not None: + updates["password_hash"] = _hash_password(body.password) + if body.email is not None: + updates["email"] = body.email + if body.full_name is not None: + updates["full_name"] = body.full_name + if body.active is not None: + updates["active"] = int(body.active) + if updates: + clause = ", ".join(f"{k} = %s" for k in updates) + conn.execute(f"UPDATE users SET {clause} WHERE id = %s", (*updates.values(), user_id)) + 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() + changed = {k: v for k, v in { + "email": body.email, "full_name": body.full_name, + "active": body.active, "password": "***" if body.password else None, + }.items() if v is not None} + audit.record("auth", "update_user", target=f"user:{row['username']}", + detail={"changes": changed}, user=admin, ip=get_request_ip()) + return _user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ) + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user(user_id: str, admin=Depends(require_global_admin)): + with get_db() as conn: + # Prevent deleting the last global_admin + ga_count = conn.execute( + "SELECT COUNT(*) AS n FROM user_roles ur JOIN users u ON ur.user_id = u.id " + "WHERE ur.role = 'global_admin' AND u.active = 1" + ).fetchone()["n"] + if ga_count <= 1: + target_role = conn.execute( + "SELECT role FROM user_roles WHERE user_id = %s AND role = 'global_admin'", (user_id,) + ).fetchone() + if target_role: + raise HTTPException(status_code=409, detail="Cannot delete the last global admin") + username_deleted = conn.execute( + "SELECT username FROM users WHERE id=%s", (user_id,) + ).fetchone() + username_deleted = username_deleted["username"] if username_deleted else user_id + conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,)) + conn.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,)) + conn.execute("DELETE FROM users WHERE id = %s", (user_id,)) + audit.record("auth", "delete_user", target=f"user:{username_deleted}", + user=admin, ip=get_request_ip()) + + +# ────────────────────────────────────────────── +# Role management +# ────────────────────────────────────────────── + +@router.get("/users/{user_id}/roles") +def get_user_roles(user_id: str, admin=Depends(require_global_admin)): + with get_db() as conn: + rows = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id = %s", (user_id,) + ).fetchall() + return [{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in rows] + + +@router.post("/users/{user_id}/roles", status_code=201) +def assign_role(user_id: str, body: RoleAssign, admin=Depends(require_global_admin)): + if body.role not in VALID_ROLES: + raise HTTPException(status_code=422, + detail=f"Invalid role '{body.role}'. Valid: {', '.join(sorted(VALID_ROLES))}") + # Build list of scopes to assign + scopes_to_assign: list = [] + if body.scopes: + scopes_to_assign = [s for s in body.scopes if s] + elif body.scope is not None: + scopes_to_assign = [body.scope] + else: + scopes_to_assign = [None] # global (no scope) + + created = [] + with get_db() as conn: + if not conn.execute("SELECT id FROM users WHERE id = %s", (user_id,)).fetchone(): + raise HTTPException(status_code=404, detail="User not found") + for scope in scopes_to_assign: + role_id = str(uuid.uuid4()) + try: + conn.execute( + "INSERT INTO user_roles(id, user_id, role, scope) VALUES (%s,%s,%s,%s)", + (role_id, user_id, body.role, scope) + ) + created.append({"id": role_id, "user_id": user_id, "role": body.role, "scope": scope}) + except sqlite3.IntegrityError: + pass # Already assigned — skip silently + if created: + with get_db() as conn: + urow = conn.execute("SELECT username, email FROM users WHERE id=%s", (user_id,)).fetchone() + uname = urow["username"] if urow else user_id + uemail = urow["email"] if urow else None + audit.record("auth", "assign_role", target=f"user:{uname}", + detail={"role": body.role, "scopes": scopes_to_assign}, + user=admin, ip=get_request_ip()) + notify.notify_role_assigned( + target_username=uname, + target_email=uemail, + role=body.role, + scopes=scopes_to_assign, + assigned_by=admin.get("username", "admin"), + ) + return created + + +@router.delete("/users/{user_id}/roles/{role_id}", status_code=204) +def revoke_role(user_id: str, role_id: str, admin=Depends(require_global_admin)): + with get_db() as conn: + role_row = conn.execute( + "SELECT ur.role, ur.scope, u.username FROM user_roles ur " + "JOIN users u ON ur.user_id=u.id " + "WHERE ur.id=%s AND ur.user_id=%s", (role_id, user_id) + ).fetchone() + conn.execute( + "DELETE FROM user_roles WHERE id = %s AND user_id = %s", (role_id, user_id) + ) + if role_row: + audit.record("auth", "revoke_role", target=f"user:{role_row['username']}", + detail={"role": role_row["role"], "scope": role_row["scope"]}, + user=admin, ip=get_request_ip()) + + +# ────────────────────────────────────────────── +# TOTP / 2FA routes +# ────────────────────────────────────────────── + +def _totp_uri(secret: str, username: str) -> str: + import pyotp + return pyotp.TOTP(secret).provisioning_uri( + name=username, issuer_name="NetAdmin" + ) + + +def _generate_qr_b64(uri: str) -> str: + """Return a base64-encoded PNG of the TOTP QR code.""" + import qrcode, base64, io + qr = qrcode.make(uri) + buf = io.BytesIO() + qr.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + +@router.put("/me/password") +def change_own_password(body: dict, user: dict = Depends(get_current_user)): + """Allow a user to change their own password (requires current password).""" + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + if not current or not new_pw: + raise HTTPException(422, "Mot de passe actuel et nouveau mot de passe requis") + if len(new_pw) < 8: + raise HTTPException(422, "Le nouveau mot de passe doit faire au moins 8 caractères") + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id=%s", (user["id"],)).fetchone() + if not row: + raise HTTPException(404, "Utilisateur introuvable") + if row["auth_method"] == "oidc": + raise HTTPException(400, "Les comptes SSO ne peuvent pas modifier leur mot de passe ici") + if not _verify_password(current, row["password_hash"] or ""): + raise HTTPException(401, "Mot de passe actuel incorrect") + with get_db() as conn: + conn.execute( + "UPDATE users SET password_hash=%s WHERE id=%s", + (_hash_password(new_pw), user["id"]) + ) + audit.record("auth", "change_password", target=f"user:{user['username']}", + user=user, ip=get_request_ip()) + return {"success": True, "message": "Mot de passe modifié avec succès"} + + +@router.post("/totp/session-upgrade") +def totp_session_upgrade(request: Request): + """Called after totp/verify succeeds during forced setup flow. + The user already proved they have the correct code via /totp/verify. + This just returns a valid SessionInfo with totp_pending=false. + """ + token = None + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if not token: + raise HTTPException(401, "Token manquant") + user_data = _get_session_user(token) + if not user_data: + raise HTTPException(401, "Session expirée") + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id=%s", (user_data["id"],)).fetchone() + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_data["id"],) + ).fetchall() + if not row: + raise HTTPException(404, "Utilisateur introuvable") + # Verify TOTP is actually enabled (setup was completed) + if not row["totp_enabled"]: + raise HTTPException(400, "Le TOTP n'est pas encore activé") + expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat() + return SessionInfo( + token=token, expires_at=expires, + user=_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ), + totp_pending=False, + ) + + +@router.post("/totp/validate-login") +def totp_validate_login(body: dict, request: Request, response: Response): + """Validate TOTP code after password login (when totp_pending=true). + Requires the session token in Authorization header. + Returns the same SessionInfo but with totp_pending=false. + """ + import pyotp + from fastapi.security import HTTPBearer as _Bearer + token = None + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if not token: + raise HTTPException(401, "Token de session manquant") + user_data = _get_session_user(token) + if not user_data: + raise HTTPException(401, "Session expirée ou invalide") + + code = str(body.get("code", "")).strip() + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id=%s", (user_data["id"],)).fetchone() + roles = conn.execute( + "SELECT id, role, scope FROM user_roles WHERE user_id=%s", (user_data["id"],) + ).fetchall() + if not row or not row["totp_secret"]: + raise HTTPException(400, "TOTP non configuré") + totp = pyotp.TOTP(row["totp_secret"]) + if not totp.verify(code, valid_window=1): + audit.record("auth", "totp_failed", target=f"user:{row['username']}", + user=user_data, ip=request.client.host if request.client else "") + raise HTTPException(400, "Code TOTP invalide") + + audit.record("auth", "totp_verified", target=f"user:{row['username']}", + user=user_data, ip=request.client.host if request.client else "") + expires = (datetime.datetime.utcnow() + datetime.timedelta(seconds=SESSION_TTL)).isoformat() + return SessionInfo( + token=token, expires_at=expires, + user=_user_response(row, + roles=[{"id": r["id"], "role": r["role"], "scope": r["scope"]} for r in roles], + ), + totp_pending=False, + ) + + +@router.post("/totp/setup") +def totp_setup(user: dict = Depends(get_current_user)): + """Generate a new TOTP secret and return the QR code. + Only available for local accounts — OIDC accounts use their provider.""" + import pyotp + with get_db() as conn: + row = conn.execute("SELECT auth_method FROM users WHERE id=%s", (user["id"],)).fetchone() + if row and row["auth_method"] == "oidc": + raise HTTPException(400, "La double authentification n'est disponible que pour les comptes locaux. Les comptes SSO utilisent leur fournisseur d'identité.") + secret = pyotp.random_base32() + with get_db() as conn: + conn.execute("UPDATE users SET totp_secret=%s WHERE id=%s", (secret, user["id"])) + uri = _totp_uri(secret, user["username"]) + return { + "secret": secret, + "uri": uri, + "qr_b64": _generate_qr_b64(uri), + } + + +@router.post("/totp/verify") +def totp_verify(body: dict, user: dict = Depends(get_current_user)): + """Verify the TOTP code and activate 2FA if correct.""" + import pyotp + code = str(body.get("code", "")).strip() + with get_db() as conn: + row = conn.execute("SELECT totp_secret FROM users WHERE id=%s", (user["id"],)).fetchone() + if not row or not row["totp_secret"]: + raise HTTPException(400, "Aucun secret TOTP en attente — lancez /totp/setup d'abord") + totp = pyotp.TOTP(row["totp_secret"]) + if not totp.verify(code, valid_window=1): + raise HTTPException(400, "Code TOTP invalide") + with get_db() as conn: + conn.execute("UPDATE users SET totp_enabled=1 WHERE id=%s", (user["id"],)) + audit.record("auth", "totp_enabled", target=f"user:{user['username']}", + user=user, ip=get_request_ip()) + return {"success": True, "message": "Authentification à deux facteurs activée"} + + +@router.post("/totp/disable") +def totp_disable(body: dict, user: dict = Depends(get_current_user)): + """Disable TOTP for the current user (requires password confirmation).""" + password = body.get("password", "") + if not password: + raise HTTPException(422, "Mot de passe requis pour désactiver le TOTP") + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id=%s", (user["id"],)).fetchone() + if not row: + raise HTTPException(404, "Utilisateur introuvable") + if row["auth_method"] == "local" and not _verify_password(password, row["password_hash"] or ""): + raise HTTPException(401, "Mot de passe incorrect") + with get_db() as conn: + conn.execute( + "UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=%s", (user["id"],) + ) + audit.record("auth", "totp_disabled", target=f"user:{user['username']}", + user=user, ip=get_request_ip()) + return {"success": True, "message": "Authentification à deux facteurs désactivée"} + + +@router.post("/users/{user_id}/totp/reset", status_code=200) +def admin_totp_reset(user_id: str, admin=Depends(require_global_admin)): + """Admin: remove TOTP for a user (forces re-setup on next login).""" + with get_db() as conn: + row = conn.execute("SELECT username FROM users WHERE id=%s", (user_id,)).fetchone() + if not row: + raise HTTPException(404, "Utilisateur introuvable") + conn.execute( + "UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=%s", (user_id,) + ) + audit.record("auth", "admin_totp_reset", target=f"user:{row['username']}", + user=admin, ip=get_request_ip()) + return {"success": True} + + +@router.post("/users/{user_id}/totp/require", status_code=200) +def admin_totp_require(user_id: str, body: dict, admin=Depends(require_global_admin)): + """Admin: force TOTP activation requirement for a user.""" + required = bool(body.get("required", True)) + with get_db() as conn: + row = conn.execute("SELECT username FROM users WHERE id=%s", (user_id,)).fetchone() + if not row: + raise HTTPException(404, "Utilisateur introuvable") + conn.execute( + "UPDATE users SET totp_required=%s WHERE id=%s", (int(required), user_id) + ) + audit.record("auth", "admin_totp_require", + target=f"user:{row['username']}", + detail={"required": required}, + user=admin, ip=get_request_ip()) + return {"success": True, "required": required} + + + +@router.get("/roles") +def list_roles(): + """Return available roles with descriptions.""" + return [ + {"role": "global_admin", "scope_required": False, "multi_scope": False, + "description": "Accès complet à tout"}, + {"role": "dns_admin", "scope_required": False, "multi_scope": False, + "description": "Gestion de toutes les zones DNS"}, + {"role": "mail_admin", "scope_required": False, "multi_scope": False, + "description": "Gestion de tous les comptes et listes mail"}, + {"role": "domain_admin", "scope_required": True, "multi_scope": True, + "description": "DNS + mail pour des domaines spécifiques"}, + {"role": "dns_zone_admin", "scope_required": True, "multi_scope": True, + "description": "Enregistrements DNS pour des zones spécifiques"}, + {"role": "mail_domain_admin", "scope_required": True, "multi_scope": True, + "description": "Comptes et listes mail pour des domaines spécifiques"}, + ] diff --git a/backend/routers/dns.py b/backend/routers/dns.py new file mode 100644 index 0000000..11a8d90 --- /dev/null +++ b/backend/routers/dns.py @@ -0,0 +1,784 @@ +""" +DNS Router — stores zone/record metadata in SQLite, applies via DNS agent through Redis. + +The backend never touches zone files or calls rndc directly. +All mutations are: + 1. Validated here (Pydantic + semantic checks) + 2. Persisted in SQLite + 3. Sent to the DNS agent via Redis broker + 4. The agent applies changes and sends ACK +""" + +import hashlib +import re +import time +import uuid +import ipaddress +import sqlite3 +import datetime +from contextlib import contextmanager +from pathlib import Path +from typing import Optional, List + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field, field_validator, model_validator + +from config import cfg +from broker import publish_dns, publish_or_queue +from routers.auth import get_current_user, get_allowed_dns_domains, require_dns_access +import audit +from main import get_request_ip + +router = APIRouter(dependencies=[Depends(get_current_user)]) + +from db import get_db, _dt + +VALID_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"} + +RE_DNS_NAME = re.compile( + r'^(@|\*|(\*\.)?([a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.)*' + r'[a-zA-Z0-9_]([a-zA-Z0-9\-_]{0,61}[a-zA-Z0-9_])?\.?)$' +) + + +# ────────────────────────────────────────────── +# Database +# ────────────────────────────────────────────── + +def init_db(): + with get_db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS zones ( + name VARCHAR(253) PRIMARY KEY, + admin VARCHAR(255) NOT NULL DEFAULT 'hostmaster', + ttl INT NOT NULL DEFAULT 3600, + refresh INT NOT NULL DEFAULT 43200, + retry INT NOT NULL DEFAULT 3600, + expire INT NOT NULL DEFAULT 2419200, + negative_ttl INT NOT NULL DEFAULT 3600, + created_at DATETIME NOT NULL DEFAULT NOW(), + last_applied_at DATETIME, + last_apply_ok TINYINT DEFAULT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS records ( + id VARCHAR(36) PRIMARY KEY, + zone VARCHAR(253) NOT NULL, + name VARCHAR(253) NOT NULL, + type VARCHAR(10) NOT NULL, + ttl INT, + value TEXT NOT NULL, + priority INT, + active TINYINT NOT NULL DEFAULT 1, + pending_sync TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT NOW(), + FOREIGN KEY (zone) REFERENCES zones(name) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + # MySQL ALTER TABLE — add columns only if missing + for col, col_type in [ + ("active", "TINYINT NOT NULL DEFAULT 1"), + ("pending_sync", "TINYINT NOT NULL DEFAULT 0"), + ]: + try: + conn.execute( + "SELECT COUNT(*) AS n as cnt FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='records' AND COLUMN_NAME=%s", + (col,) + ) + row = conn.fetchone() + if not row or row.get("cnt", 0) == 0: + conn.execute(f"ALTER TABLE records ADD COLUMN {col} {col_type}") + except Exception: + pass +try: + init_db() + # Migration: add active column if upgrading + with get_db() as _mc: + try: + _mc.execute("ALTER TABLE records ADD COLUMN active INT NOT NULL DEFAULT 1") + except Exception: + pass + try: + _mc.execute("ALTER TABLE records ADD COLUMN pending_sync INT NOT NULL DEFAULT 0") + except Exception: + pass +except Exception as e: + print(f"[WARN] DNS DB init failed: {e}") + + +# ────────────────────────────────────────────── +# Pydantic models +# ────────────────────────────────────────────── + +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) + + @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") + return v + + @field_validator("name") + @classmethod + def validate_zone_name(cls, v: str) -> str: + v = v.strip().rstrip(".") + if not v: + raise ValueError("Zone name cannot be empty") + if len(v) > 253: + raise ValueError("Zone name too long (max 253 characters)") + labels = v.split(".") + if len(labels) < 2: + raise ValueError("Zone name must have at least two labels (e.g. example.com)") + for label in labels: + if not label: + raise ValueError(f"Empty label in zone name '{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}'") + return v + + +class ZoneConfig(BaseModel): + admin: str = Field("hostmaster") + 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) + + @field_validator("admin") + @classmethod + def no_at(cls, v: str) -> str: + if "@" in v: + raise ValueError("Admin email must not contain '@'") + return v.strip() + + +class ZoneInfo(BaseModel): + name: str + admin: str + ttl: int + refresh: int + retry: int + expire: int + negative_ttl: int + record_count: int = 0 + last_apply_ok: Optional[bool] = None + last_applied_at: Optional[str] = None + queued: bool = False + queue_id: Optional[str] = None + + @field_validator("last_applied_at", mode="before") + @classmethod + def _coerce_dt(cls, v): + return _dt(v) + + +class RecordCreate(BaseModel): + name: str = Field(..., example="@") + type: str = Field(..., example="A") + ttl: Optional[int] = Field(None, ge=0, le=2147483647) + value: str = Field(..., example="192.168.1.1") + priority: Optional[int] = Field(None, ge=0, le=65535) + active: bool = Field(True) + + @field_validator("type") + @classmethod + def validate_type(cls, v: str) -> str: + v = v.strip().upper() + if v not in VALID_RECORD_TYPES: + raise ValueError(f"Unsupported type '{v}'") + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if not v: + return "@" + if not RE_DNS_NAME.match(v): + raise ValueError(f"Invalid DNS name '{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") + return v.strip() + + @model_validator(mode="after") + def validate_by_type(self) -> "RecordCreate": + t, v = self.type, self.value + if t == "A": + try: ipaddress.IPv4Address(v) + except ValueError: raise ValueError(f"Invalid IPv4: '{v}'") + elif t == "AAAA": + try: ipaddress.IPv6Address(v) + except ValueError: raise ValueError(f"Invalid IPv6: '{v}'") + elif t in ("CNAME", "NS", "PTR"): + if not RE_DNS_NAME.match(v.rstrip(".")): + raise ValueError(f"Invalid hostname for {t}: '{v}'") + elif t == "MX": + if self.priority is None: + raise ValueError("MX requires a priority") + if not RE_DNS_NAME.match(v.rstrip(".")): + raise ValueError(f"Invalid MX hostname: '{v}'") + elif t == "SRV": + if self.priority is None: + raise ValueError("SRV requires a priority") + parts = v.split() + if len(parts) != 3: + raise ValueError("SRV: ' '") + 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") + elif t == "TXT": + if "\n" in v or "\r" in v: + raise ValueError("TXT must not contain newlines") + elif t == "TLSA": + parts = v.split(None, 3) + if len(parts) != 4: + raise ValueError("TLSA: ' '") + 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") + if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")): + raise ValueError("TLSA cert data must be hex") + elif t == "DS": + parts = v.split(None, 3) + if len(parts) != 4: + raise ValueError("DS: ' '") + try: + kt, algo, dt = int(parts[0]), int(parts[1]), int(parts[2]) + except ValueError: + raise ValueError("DS keytag/algo/digest-type must be integers") + if not (0 <= kt <= 65535): raise ValueError("DS keytag 0-65535") + if algo not in {5,7,8,10,13,14,15,16}: raise ValueError(f"DS unknown algorithm {algo}") + if dt not in (1, 2, 4): raise ValueError("DS digest-type: 1, 2 or 4") + if not re.match(r'^[0-9a-fA-F]+$', parts[3].replace(" ", "")): + raise ValueError("DS digest must be hex") + elif t == "CAA": + parts = v.split(None, 2) + if len(parts) != 3: raise ValueError("CAA: ' '") + try: + flags = int(parts[0]) + assert 0 <= flags <= 255 + except (ValueError, AssertionError): + raise ValueError("CAA flags 0-255") + if parts[1] not in ("issue", "issuewild", "iodef"): + raise ValueError("CAA tag: issue, issuewild or iodef") + return self + + +class RecordResponse(BaseModel): + id: str + zone: str + name: str + type: str + ttl: Optional[int] + value: str + priority: Optional[int] = None + active: bool = True + queued: bool = False + queue_id: Optional[str] = None + pending_sync: bool = False + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + +def _zone_to_payload(zone_name: str) -> dict: + """Build the full zone payload to send to the DNS agent.""" + with get_db() as conn: + z = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone() + if not z: + raise HTTPException(404, f"Zone '{zone_name}' not found") + records = conn.execute( + "SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,) + ).fetchall() + return { + "zone": z["name"], + "admin": z["admin"], + "ttl": z["ttl"], + "refresh": z["refresh"], + "retry": z["retry"], + "expire": z["expire"], + "negative_ttl": z["negative_ttl"], + "records": [ + { + "id": r["id"], + "name": r["name"], + "type": r["type"], + "ttl": r["ttl"], + "value": r["value"], + "priority": r["priority"], + "active": bool(r["active"]), # inactive → commented in zone file + } + for r in records + ], + } + + +def _apply_zone(zone_name: str) -> dict: + """Send zone to DNS agent. If unreachable, queue for later. + Returns {"queued": bool, "queue_id": str|None}. + Never raises — save always succeeds in SQLite. + """ + payload = _zone_to_payload(zone_name) + result = publish_or_queue("dns", "apply_zone", payload) + queued = result.get("queued", False) + now = datetime.datetime.utcnow().isoformat() + with get_db() as conn: + conn.execute( + "UPDATE zones SET last_applied_at=%s, last_apply_ok=%s WHERE name=%s", + (now, 0 if queued else 1, zone_name) + ) + if not queued: + # Agent confirmed — clear pending_sync on all records of this zone + conn.execute( + "UPDATE records SET pending_sync=0 WHERE zone=%s", (zone_name,) + ) + return {"queued": queued, "queue_id": result.get("id")} + + +def _delete_zone_on_agent(zone_name: str) -> dict: + """Delete zone on agent. If unreachable, queue for later.""" + result = publish_or_queue("dns", "delete_zone", {"zone": zone_name}) + return {"queued": result.get("queued", False), "queue_id": result.get("id")} + + +def _row_to_zone(row, record_count: int = 0) -> ZoneInfo: + return ZoneInfo( + name=row["name"], admin=row["admin"], ttl=row["ttl"], + refresh=row["refresh"], retry=row["retry"], + expire=row["expire"], negative_ttl=row["negative_ttl"], + record_count=record_count, + last_apply_ok=bool(row["last_apply_ok"]) if row["last_apply_ok"] is not None else None, + last_applied_at=_dt(row["last_applied_at"]), + ) + + +# ────────────────────────────────────────────── +# Routes — Zones +# ────────────────────────────────────────────── + + + +@router.get("/zones", response_model=List[ZoneInfo]) +def list_zones(user: dict = Depends(get_current_user)): + allowed = get_allowed_dns_domains(user) + with get_db() as conn: + rows = conn.execute("SELECT * FROM zones ORDER BY name").fetchall() + result = [] + for row in rows: + if allowed is not None and row["name"] not in allowed: + continue + count = conn.execute( + "SELECT COUNT(*) AS n FROM records WHERE zone=%s", (row["name"],) + ).fetchone()["n"] + result.append(_row_to_zone(row, count)) + return result + + +@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: + raise HTTPException(409, f"Zone '{body.name}' already exists") + conn.execute( + "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) + ) + q = _apply_zone(body.name) + with get_db() as conn: + row = conn.execute("SELECT * FROM zones WHERE name=%s", (body.name,)).fetchone() + audit.record("dns", "create_zone", target=f"zone:{body.name}", + detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + z = _row_to_zone(row, 0) + z.queued = q["queued"] + z.queue_id = q.get("queue_id") + return z + + +@router.delete("/zones/{zone_name}", status_code=204) +def delete_zone(zone_name: str, user: dict = Depends(get_current_user)): + 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") + conn.execute("DELETE FROM zones WHERE name=%s", (zone_name,)) + q = _delete_zone_on_agent(zone_name) + audit.record("dns", "delete_zone", target=f"zone:{zone_name}", + detail={"queued": q["queued"]}, user=user, ip=get_request_ip()) + + +@router.get("/zones/{zone_name}/config") +def get_zone_config(zone_name: str, user: dict = Depends(get_current_user)): + require_dns_access(zone_name, user) + with get_db() as conn: + row = conn.execute("SELECT * FROM zones WHERE name=%s", (zone_name,)).fetchone() + if not row: + raise HTTPException(404, f"Zone '{zone_name}' not found") + return { + "zone": zone_name, "admin": row["admin"], "ttl": row["ttl"], + "refresh": row["refresh"], "retry": row["retry"], + "expire": row["expire"], "negative_ttl": row["negative_ttl"], + } + + +@router.put("/zones/{zone_name}/config") +def update_zone_config(zone_name: str, body: ZoneConfig, user: dict = Depends(get_current_user)): + 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") + conn.execute( + "UPDATE zones SET admin=%s,ttl=%s,refresh=%s,retry=%s,expire=%s,negative_ttl=%s WHERE name=%s", + (body.admin, body.ttl, body.refresh, body.retry, body.expire, body.negative_ttl, zone_name) + ) + q = _apply_zone(zone_name) + audit.record("dns", "update_zone_config", target=f"zone:{zone_name}", + detail={"admin": body.admin, "ttl": body.ttl, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + cfg_data = get_zone_config(zone_name, user) + cfg_data["queued"] = q["queued"] + cfg_data["queue_id"] = q.get("queue_id") + return cfg_data + + +# ────────────────────────────────────────────── +# Routes — Records +# ────────────────────────────────────────────── + +@router.get("/zones/{zone_name}/records", response_model=List[RecordResponse]) +def list_records(zone_name: str, user: dict = Depends(get_current_user)): + 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") + rows = conn.execute( + "SELECT * FROM records WHERE zone=%s ORDER BY type, name", (zone_name,) + ).fetchall() + return [RecordResponse( + id=r["id"], zone=r["zone"], name=r["name"], type=r["type"], + ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]), + pending_sync=bool(r["pending_sync"]) if r["pending_sync"] is not None else False + ) for r in rows] + + +@router.post("/zones/{zone_name}/records", response_model=RecordResponse, status_code=201) +def create_record(zone_name: str, body: RecordCreate, user: dict = Depends(get_current_user)): + 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") + rec_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO records(id,zone,name,type,ttl,value,priority,active,pending_sync) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,1)", + (rec_id, zone_name, body.name or "@", body.type, body.ttl, + body.value, body.priority, int(body.active)) + ) + q = _apply_zone(zone_name) + with get_db() as conn: + r = conn.execute("SELECT * FROM records WHERE id=%s", (rec_id,)).fetchone() + audit.record("dns", "create_record", target=f"zone:{zone_name}", + detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + return RecordResponse( + id=r["id"], zone=r["zone"], name=r["name"], type=r["type"], + ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]), + queued=q["queued"], queue_id=q.get("queue_id"), + pending_sync=q["queued"] # still pending if queued + ) + + +@router.put("/zones/{zone_name}/records/{record_id}", response_model=RecordResponse) +def update_record(zone_name: str, record_id: str, body: RecordCreate, + user: dict = Depends(get_current_user)): + 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") + 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, + int(body.active), record_id) + ) + q = _apply_zone(zone_name) + with get_db() as conn: + r = conn.execute("SELECT * FROM records WHERE id=%s", (record_id,)).fetchone() + audit.record("dns", "update_record", target=f"zone:{zone_name}/record:{record_id}", + detail={"name": body.name, "type": body.type, "value": body.value, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + return RecordResponse( + id=r["id"], zone=r["zone"], name=r["name"], type=r["type"], + ttl=r["ttl"], value=r["value"], priority=r["priority"], active=bool(r["active"]), + queued=q["queued"], queue_id=q.get("queue_id"), + pending_sync=q["queued"] + ) + + +@router.delete("/zones/{zone_name}/records/{record_id}") +def delete_record(zone_name: str, record_id: str, user: dict = Depends(get_current_user)): + 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") + 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}", + detail={"queued": q["queued"]}, user=user, ip=get_request_ip()) + return {"queued": q["queued"], "queue_id": q.get("queue_id")} + + +@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.""" + 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}) + audit.record("dns", "reload_zone", target=f"zone:{zone_name}", + detail={"queued": result.get("queued")}, + user=user, ip=get_request_ip()) + return {"zone": zone_name, "queued": result.get("queued", False), "queue_id": result.get("id")} + + +# ── Raw zone (read-only — agent generates it) ────────────────────── + +@router.get("/zones/{zone_name}/raw") +def get_zone_raw(zone_name: str, user: dict = Depends(get_current_user)): + """Ask the DNS agent to return the current raw zone file content.""" + require_dns_access(zone_name, 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','')}") + return {"zone": zone_name, "content": ack.get("content", ""), "path": ack.get("path", "")} + except RuntimeError as e: + raise HTTPException(503, str(e)) + + +@router.put("/zones/{zone_name}/raw") +def save_zone_raw(zone_name: str, body: dict, user: dict = Depends(get_current_user)): + """Send raw zone content to the agent for direct write (advanced use).""" + require_dns_access(zone_name, user) + content = body.get("content", "") + if not content.strip(): + raise HTTPException(422, "Zone content cannot be empty") + 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','')}") + return {"zone": zone_name, "content": ack.get("content", content)} + except RuntimeError as e: + raise HTTPException(503, str(e)) + + +# ────────────────────────────────────────────── +# Sync from BIND agent (zone files are source of truth) +# ────────────────────────────────────────────── + +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. + + Zone files on the BIND server have priority over SQLite. + - Zone exists on BIND but not in SQLite → imported + - Zone exists in SQLite but not on BIND → removed from SQLite + - Records are fully replaced per zone (zone file is authoritative) + """ + t0 = time.monotonic() + try: + payload = {"zone": zone_filter} if zone_filter else {} + ack = publish_dns("get_state", payload) + except RuntimeError as e: + audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000), + success=False, error=str(e)) + raise HTTPException(503, f"DNS agent unreachable: {e}") + + if not ack.get("success"): + err = ack.get("error", "") + audit.record_sync("dns", trigger, duration_ms=int((time.monotonic()-t0)*1000), + success=False, error=err) + raise HTTPException(500, f"DNS agent error: {err}") + + imported_zones = 0 + updated_zones = 0 + imported_records = 0 + + remote_zone_names = {z["name"] for z in ack.get("zones", [])} + + with get_db() as conn: + # If full sync (no filter): remove zones that no longer exist on BIND + if not zone_filter: + local_zones = { + r["name"] for r in conn.execute("SELECT name FROM zones").fetchall() + } + for stale in local_zones - remote_zone_names: + conn.execute("DELETE FROM records WHERE zone=%s", (stale,)) + 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,)) + + result = { + "imported_zones": imported_zones, + "updated_zones": updated_zones, + "imported_records": imported_records, + "total_zones": len(ack.get("zones", [])), + } + audit.record_sync( + "dns", trigger, + duration_ms=int((time.monotonic()-t0)*1000), + zones=result["total_zones"], + records=result["imported_records"], + success=True, + detail=result, + ) + return result + + +@router.get("/sync/history") +def get_sync_history( + limit: int = 50, + offset: int = 0, + user: dict = Depends(get_current_user), +): + """Return DNS sync history (global admin only).""" + from routers.auth import _has_role + if not _has_role(user, "global_admin"): + raise HTTPException(403, "Accès réservé à l'admin global") + return { + "total": audit.count_sync_history("dns"), + "entries": audit.query_sync_history("dns", limit=limit, offset=offset), + } + + +@router.post("/sync") +def sync_endpoint( + zone: str | None = None, + user: dict = Depends(get_current_user) +): + """Pull current zone state from the BIND agent and update local SQLite. + BIND zone files are the source of truth. + Optional query param: %szone=example.com to sync a single zone. + """ + if zone: + require_dns_access(zone, user) + result = sync_from_agent(zone, trigger="manual") + return { + "status": "ok", + "message": ( + f"Sync DNS terminé : {result['total_zones']} zone(s) traitée(s), " + f"{result['imported_zones']} importée(s), " + f"{result['updated_zones']} mise(s) à jour, " + f"{result['imported_records']} enregistrement(s)." + ), + "details": result, + } \ No newline at end of file diff --git a/backend/routers/mail.py b/backend/routers/mail.py new file mode 100644 index 0000000..1b0ae83 --- /dev/null +++ b/backend/routers/mail.py @@ -0,0 +1,1014 @@ +""" +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. +""" + +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 config import cfg +from broker import publish_mail, publish_or_queue, list_pending, retry_pending, mark_done +import audit +from main import get_request_ip +from routers.auth import ( + get_current_user, get_allowed_mail_domains, + require_mail_access, +) + +router = APIRouter(dependencies=[Depends(get_current_user)]) + +from db import get_db, _dt + + +# ────────────────────────────────────────────── +# Database +# ────────────────────────────────────────────── + +def init_db(): + with get_db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS domains ( + name VARCHAR(253) PRIMARY KEY, + 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, + created_at DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS accounts ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(128) NOT NULL, + domain VARCHAR(253) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + quota INT NOT NULL DEFAULT 1024, + used INT NOT NULL DEFAULT 0, + active TINYINT NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT NOW(), + UNIQUE KEY uq_account (username, domain), + FOREIGN KEY (domain) REFERENCES domains(name) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS aliases ( + id VARCHAR(36) PRIMARY KEY, + source VARCHAR(128) NOT NULL, + destination VARCHAR(255) NOT NULL, + domain VARCHAR(253) NOT NULL, + 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"), + ]: + try: + conn.execute(f"ALTER TABLE domains ADD COLUMN {col} {defn}") + except Exception: + pass + + +# get_db imported from db.py + + +try: + init_db() +except Exception as e: + print(f"[WARN] Mail DB init failed: {e}") + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + +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() + + +def _check_domain_quota(domain: str, new_quota: int, exclude_account_id: str | None = None): + """Raise 422 if adding new_quota would exceed the domain's max_quota_mb.""" + with get_db() as conn: + row = conn.execute("SELECT max_quota_mb FROM domains WHERE name=%s", (domain,)).fetchone() + if not row or row["max_quota_mb"] == 0: + return # unlimited + + q = "SELECT COALESCE(SUM(quota), 0) AS n FROM accounts WHERE domain=%s" + params = [domain] + if exclude_account_id: + q += " AND id != %s" + params.append(exclude_account_id) + used = conn.execute(q, params).fetchone()["n"] + + 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)." + ) + ) + + +def _check_account_limit(domain: str, exclude_account_id: str | None = None): + """Raise 422 if the domain has reached its max_accounts limit.""" + with get_db() as conn: + row = conn.execute("SELECT max_accounts FROM domains WHERE name=%s", (domain,)).fetchone() + if not row or row["max_accounts"] == 0: + return # unlimited + q = "SELECT COUNT(*) AS n FROM accounts WHERE domain=%s" + params = [domain] + if exclude_account_id: + q += " AND id != %s" + params.append(exclude_account_id) + count = conn.execute(q, params).fetchone()["n"] + if count >= row["max_accounts"]: + raise HTTPException( + 422, + f"Domain account limit reached ({row['max_accounts']} max)." + ) + + +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 _apply_domain(domain: str): + """Build full PostfixAdmin-compatible payload and send to mail agent.""" + with get_db() as conn: + dom = conn.execute("SELECT * FROM domains WHERE name=%s", (domain,)).fetchone() + accounts = conn.execute( + "SELECT * FROM accounts WHERE domain=%s", (domain,) + ).fetchall() + aliases = conn.execute( + "SELECT source, destination FROM aliases WHERE domain=%s", (domain,) + ).fetchall() + + # domain config for the agent + 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", + } + + payload = { + "domain": domain, + "config": dom_config, + "accounts": [ + { + "username": f"{a['username']}@{a['domain']}", + "local_part": a["username"], + "domain": a["domain"], + "password_hash": a["password_hash"], + "name": "", + "quota": a["quota"], # MB — agent converts to bytes + "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 + ], + } + result = publish_or_queue("mail", "apply_domain", payload) + return result # {"success": True, "queued": bool, "id"%s: str} + + +# ────────────────────────────────────────────── +# Pydantic models +# ────────────────────────────────────────────── + +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) + + +class DomainResponse(BaseModel): + name: str + has_mail: bool + max_accounts: int + max_quota_mb: int + max_lists: int + 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 + domain: str + password: str = "" + quota: int = Field(1024, ge=1) + active: bool = True + + +class AccountResponse(BaseModel): + id: str + username: str + domain: str + quota: int + used: int + active: bool + created_at: str + + @field_validator("created_at", mode="before") + @classmethod + def _coerce_created_at(cls, v): return _dt(v) or "" + aliases: List[str] = [] + queued: bool = False + 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 + + +class MemberResponse(BaseModel): + email: str + subscribed_at: str + + @field_validator("subscribed_at", mode="before") + @classmethod + def _coerce_subscribed_at(cls, v): return _dt(v) or "" + + + +# ────────────────────────────────────────────── +# Sync from MariaDB agent (MariaDB is source of truth) +# ────────────────────────────────────────────── + +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. + + MariaDB has priority in case of conflict. + Returns a summary of what was imported/updated. + """ + t0 = time.monotonic() + try: + payload = {"domain": domain_filter} if domain_filter else {} + ack = publish_mail("get_state", payload) + except RuntimeError as e: + audit.record_sync("mail", trigger, duration_ms=int((time.monotonic()-t0)*1000), + success=False, error=str(e)) + raise HTTPException(503, f"Mail agent unreachable: {e}") + + if not ack.get("success"): + 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}") + + imported_domains = 0 + imported_accounts = 0 + imported_aliases = 0 + + 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) + ) + 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 + + result = { + "domains": imported_domains, + "accounts": imported_accounts, + "aliases": imported_aliases, + "total_domains": len(ack.get("domains", [])), + } + audit.record_sync( + "mail", trigger, + duration_ms=int((time.monotonic()-t0)*1000), + zones=result["total_domains"], + records=result["accounts"], + success=True, + detail=result, + ) + return result + + + + +@router.get("/sync/history") +def get_mail_sync_history( + limit: int = 50, + offset: int = 0, + user: dict = Depends(get_current_user), +): + """Return mail sync history (global admin only).""" + from routers.auth import _has_role + if not _has_role(user, "global_admin"): + raise HTTPException(403, "Accès réservé à l'admin global") + return { + "total": audit.count_sync_history("mail"), + "entries": audit.query_sync_history("mail", limit=limit, offset=offset), + } + + +@router.post("/sync") +def sync_endpoint( + domain: str | None = None, + user: dict = Depends(get_current_user) +): + """Pull the current state from MariaDB via the mail agent and update local SQLite. + MariaDB is the source of truth — any discrepancy is resolved in its favor. + Optional query param: %sdomain=example.com to sync a single domain. + """ + from routers.auth import require_mail_access + if domain: + require_mail_access(domain, user) + + result = sync_from_agent(domain, trigger="manual") + audit.record("mail", "sync", target=domain or "all", + detail=result, user=user, ip=get_request_ip()) + return { + "status": "ok", + "message": ( + f"Sync complete: {result['total_domains']} domain(s) processed, " + f"{result['domains']} new domain(s), " + f"{result['accounts']} new account(s), " + f"{result['aliases']} new alias(es)." + ), + "details": result, + } + +# ────────────────────────────────────────────── +# Domain routes +# ────────────────────────────────────────────── + +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, + used_quota_mb=used, free_quota_mb=free, + ) + + +@router.get("/domains", response_model=List[str]) +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() + names = [r["name"] for r in rows] + if allowed is not None: + names = [n for n in names if n in allowed] + return names + + +@router.get("/domains-config", response_model=List[DomainResponse]) +def list_domains_config(user: dict = Depends(get_current_user)): + allowed = get_allowed_mail_domains(user) + with get_db() as conn: + rows = conn.execute("SELECT * FROM domains ORDER BY name").fetchall() + result = [] + for row in rows: + if allowed is not None and row["name"] not in allowed: + continue + result.append(_build_domain_response(row, conn)) + return result + + +@router.get("/domains-config/{name}", response_model=DomainResponse) +def get_domain_config(name: str, user: dict = Depends(get_current_user)): + require_mail_access(name, 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") + return _build_domain_response(row, conn) + + +@router.post("/domains-config", response_model=DomainResponse, status_code=201) +def create_domain_config(name: str, body: DomainConfig, user: dict = Depends(get_current_user)): + require_mail_access(name, user) + 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) + ) + except sqlite3.IntegrityError: + raise HTTPException(409, f"Domain '{name}' already exists") + row = conn.execute("SELECT * FROM domains WHERE name=%s", (name,)).fetchone() + return _build_domain_response(row, conn) + + +@router.put("/domains-config/{name}", response_model=DomainResponse) +def update_domain_config(name: str, body: DomainConfig, 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"Domain '{name}' not found") + 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) + ) + row = conn.execute("SELECT * FROM domains WHERE name=%s", (name,)).fetchone() + return _build_domain_response(row, conn) + + +@router.post("/domains", status_code=201) +def create_domain(name: str, user: dict = Depends(get_current_user)): + require_mail_access(name, 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") + return {"name": name} + + +@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: + conn.execute("DELETE FROM domains WHERE name=%s", (name,)) + + +# ────────────────────────────────────────────── +# Account routes +# ────────────────────────────────────────────── + +def _row_to_account(row, aliases=None) -> AccountResponse: + return AccountResponse( + id=row["id"], username=row["username"], domain=row["domain"], + quota=row["quota"], used=row["used"], active=bool(row["active"]), + created_at=_dt(row["created_at"]) or "", aliases=aliases or [], + ) + + +@router.get("/accounts", response_model=List[AccountResponse]) +def list_accounts(user: dict = Depends(get_current_user)): + allowed = get_allowed_mail_domains(user) + with get_db() as conn: + rows = conn.execute("SELECT * FROM accounts ORDER BY domain, username").fetchall() + if allowed is not None: + rows = [r for r in rows if r["domain"] in allowed] + result = [] + for row in rows: + als = conn.execute( + "SELECT source FROM aliases WHERE destination=%s AND domain=%s", + (f"{row['username']}@{row['domain']}", row["domain"]) + ).fetchall() + result.append(_row_to_account(row, [a["source"] for a in als])) + return result + + +@router.post("/accounts", response_model=AccountResponse, status_code=201) +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) + 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)) + ) + except sqlite3.IntegrityError: + raise HTTPException(409, f"Account {body.username}@{body.domain} already exists") + row = conn.execute("SELECT * FROM accounts WHERE id=%s", (acc_id,)).fetchone() + q = _apply_domain(body.domain) + audit.record("mail", "create_account", + target=f"account:{body.username}@{body.domain}", + detail={"quota": body.quota, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + r = _row_to_account(row) + r.queued = q["queued"] + r.queue_id = q.get("id") + return r + + +@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") + require_mail_access(row["domain"], user) + _check_domain_quota(row["domain"], body.quota, exclude_account_id=account_id) + updates = {} + if body.password: + updates["password_hash"] = hash_password(body.password) + updates["quota"] = body.quota + updates["active"] = int(body.active) + with get_db() as conn: + clause = ", ".join(f"{k}=%s" for k in updates) + conn.execute(f"UPDATE accounts SET {clause} WHERE id=%s", (*updates.values(), account_id)) + row = conn.execute("SELECT * FROM accounts WHERE id=%s", (account_id,)).fetchone() + als = conn.execute( + "SELECT source FROM aliases WHERE destination=%s AND domain=%s", + (f"{row['username']}@{row['domain']}", row["domain"]) + ).fetchall() + q = _apply_domain(row["domain"]) + audit.record("mail", "update_account", + target=f"account:{row['username']}@{row['domain']}", + detail={"queued": q["queued"]}, user=user, ip=get_request_ip()) + r = _row_to_account(row, [a["source"] for a in als]) + r.queued = q["queued"] + r.queue_id = q.get("id") + return r + + +@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") + require_mail_access(row["domain"], user) + domain = row["domain"] + with get_db() as conn: + conn.execute("DELETE FROM accounts WHERE id=%s", (account_id,)) + q = _apply_domain(domain) + audit.record("mail", "delete_account", + target=f"account:{account_id}", + detail={"queued": q["queued"]}, user=user, ip=get_request_ip()) + # Return 204 but include queued info as header-compatible dict + return {"queued": q["queued"], "queue_id": q.get("id")} + + +# ────────────────────────────────────────────── +# Quota usage (reads from Dovecot quota2 via agent) +# ────────────────────────────────────────────── + +@router.get("/domains-config/{name}/quota-usage") +def get_domain_quota_usage(name: str, user: dict = Depends(get_current_user)): + """Ask the mail agent to return current Dovecot quota usage for a domain.""" + require_mail_access(name, 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','')}") + # Update used quota in local DB for display + usage = ack.get("usage", {}) + with get_db() as conn: + for username_full, u in usage.items(): + local_part = username_full.split("@")[0] + conn.execute( + "UPDATE accounts SET used=%s WHERE username=%s AND domain=%s", + (u.get("used_mb", 0), local_part, name) + ) + return ack + except RuntimeError as e: + raise HTTPException(503, str(e)) + + + +# ────────────────────────────────────────────── +# Alias routes +# ────────────────────────────────────────────── + +class AliasCreate(BaseModel): + source: str # local part only (e.g. "contact") or full address + destination: str # full address (e.g. "user@example.com"), CSV for multiple + domain: str + + +class AliasResponse(BaseModel): + id: str + source: str + destination: str + domain: str + queued: bool = False + queue_id: Optional[str] = None + + +@router.get("/aliases", response_model=List[AliasResponse]) +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" + ).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"] + ) for r in rows] + + +@router.get("/aliases/{domain}", response_model=List[AliasResponse]) +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", + (domain,) + ).fetchall() + return [AliasResponse( + id=r["id"], source=r["source"], + destination=r["destination"], domain=r["domain"] + ) for r in rows] + + +@router.post("/aliases", response_model=List[AliasResponse], status_code=201) +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") + # Split CSV destinations + destinations = [d.strip() for d in body.destination.split(",") if d.strip()] + if not destinations: + raise HTTPException(422, "Destination cannot be empty") + created = [] + with get_db() as conn: + conn.execute("INSERT IGNORE INTO domains(name) VALUES(%s)", (body.domain,)) + for dest in destinations: + alias_id = str(uuid.uuid4()) + try: + conn.execute( + "INSERT INTO aliases(id,source,destination,domain) VALUES(%s,%s,%s,%s)", + (alias_id, source, dest, body.domain) + ) + created.append(AliasResponse( + id=alias_id, source=source, + destination=dest, domain=body.domain + )) + except sqlite3.IntegrityError: + pass # Already exists — skip silently + q = _apply_domain(body.domain) + audit.record("mail", "create_alias", + target=f"alias:{source}@{body.domain}", + detail={"destinations": destinations, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + for a in created: + a.queued = q["queued"] + a.queue_id = q.get("id") + return created + + +@router.put("/aliases/{alias_id}", response_model=List[AliasResponse]) +def update_alias(alias_id: str, body: AliasCreate, user: dict = Depends(get_current_user)): + """Replace all destinations for a source alias. + Deletes existing rows for that source+domain and re-creates them. + 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") + 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()] + if not destinations: + raise HTTPException(422, "Au moins une destination est requise") + updated = [] + with get_db() as conn: + # Remove all existing rows for this source in this domain + conn.execute("DELETE FROM aliases WHERE source=%s AND domain=%s", (source, domain)) + for dest in destinations: + new_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO aliases(id,source,destination,domain) VALUES(%s,%s,%s,%s)", + (new_id, source, dest, domain) + ) + updated.append(AliasResponse(id=new_id, source=source, destination=dest, domain=domain)) + q = _apply_domain(domain) + audit.record("mail", "update_alias", + target=f"alias:{source}@{domain}", + detail={"destinations": destinations, "queued": q["queued"]}, + user=user, ip=get_request_ip()) + for a in updated: + a.queued = q["queued"] + a.queue_id = q.get("id") + return updated + + +@router.delete("/aliases/{alias_id}") +def delete_alias(alias_id: str, user: dict = Depends(get_current_user)): + 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") + require_mail_access(row["domain"], user) + domain = row["domain"] + with get_db() as conn: + conn.execute("DELETE FROM aliases WHERE id=%s", (alias_id,)) + q = _apply_domain(domain) + audit.record("mail", "delete_alias", + target=f"alias:{alias_id}", + detail={"queued": q["queued"]}, user=user, ip=get_request_ip()) + return {"queued": q["queued"], "queue_id": q.get("id")} + +# ────────────────────────────────────────────── +# 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 +# ────────────────────────────────────────────── + +@router.get("/pending") +def get_pending_actions(user: dict = Depends(get_current_user)): + """Return all pending (queued) agent actions.""" + return list_pending() + + +@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") + result = retry_pending(entry_id) + if not result.get("success"): + raise HTTPException(500, result.get("error", "Retry failed")) + 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) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..eda0741 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + NetAdmin — DNS & Mail Console + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bc544c3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "dns-mail-manager", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..659f31c --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,481 @@ +@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'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-0: #050708; + --bg-1: #0b0e11; + --bg-2: #111519; + --bg-3: #181d22; + --bg-4: #1e252c; + --border: #1f2a33; + --border-hi: #2a3a47; + --text-0: #e8edf2; + --text-1: #a8b8c8; + --text-2: #5a7080; + --text-3: #344550; + --accent: #00d4ff; + --accent-dim: rgba(0, 212, 255, 0.12); + --accent-glow: rgba(0, 212, 255, 0.25); + --green: #00e5a0; + --green-dim: rgba(0, 229, 160, 0.12); + --red: #ff4a6a; + --red-dim: rgba(255, 74, 106, 0.12); + --yellow: #ffb700; + --yellow-dim: rgba(255, 183, 0, 0.12); + --orange: #ff7d40; + --sidebar-w: 240px; + --radius: 6px; + --font-mono: 'JetBrains Mono', monospace; + --font-display: 'Syne', sans-serif; +} + +html, body, #root { height: 100%; } + +body { + background: var(--bg-0); + color: var(--text-0); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* ── APP LAYOUT ── */ +.app { display: flex; height: 100vh; overflow: hidden; } + +/* ── SIDEBAR ── */ +.sidebar { + width: var(--sidebar-w); + min-width: var(--sidebar-w); + background: var(--bg-1); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 24px 0; + position: relative; + z-index: 10; +} + +.sidebar::after { + content: ''; + position: absolute; + top: 0; right: -1px; bottom: 0; + width: 1px; + background: linear-gradient(to bottom, transparent, var(--accent), transparent); + opacity: 0.3; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px 28px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.brand-icon { + font-size: 24px; + color: var(--accent); + filter: drop-shadow(0 0 8px var(--accent-glow)); + line-height: 1; +} + +.brand-name { + font-family: var(--font-display); + font-size: 16px; + font-weight: 800; + color: var(--text-0); + letter-spacing: 0.05em; +} + +.brand-sub { + font-size: 10px; + color: var(--text-2); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.sidebar-nav { flex: 1; padding: 0 10px; display: flex; flex-direction: column; gap: 2px; } + +.nav-item { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: transparent; + border: none; + border-radius: var(--radius); + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + position: relative; + transition: all 0.15s ease; + text-align: left; + letter-spacing: 0.03em; +} + +.nav-item:hover { background: var(--bg-3); color: var(--text-1); } + +.nav-item.active { + background: var(--accent-dim); + color: var(--accent); + border: 1px solid rgba(0,212,255,0.15); +} + +.nav-icon { font-size: 14px; width: 18px; text-align: center; } + +.nav-indicator { + position: absolute; + right: -10px; + width: 3px; + height: 20px; + background: var(--accent); + border-radius: 3px 0 0 3px; + box-shadow: 0 0 8px var(--accent); +} + +.sidebar-footer { + padding: 20px; + border-top: 1px solid var(--border); + margin-top: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.api-label { font-size: 9px; letter-spacing: 0.15em; color: var(--text-3); text-transform: uppercase; display: block; margin-bottom: 4px; } + +.api-input { + width: 100%; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 8px; + color: var(--text-1); + font-family: var(--font-mono); + font-size: 11px; + outline: none; + transition: border-color 0.15s; +} +.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--green { background: var(--green); box-shadow: 0 0 6px var(--green); animation: pulse 2s infinite; } +.dot--red { background: var(--red); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── MAIN AREA ── */ +.main { flex: 1; overflow-y: auto; background: var(--bg-0); } +.main-inner { padding: 32px; max-width: 1200px; } + +/* ── PAGE HEADER ── */ +.page-header { margin-bottom: 28px; } +.page-title { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + color: var(--text-0); + display: flex; + align-items: center; + gap: 10px; +} +.page-title .icon { color: var(--accent); } +.page-sub { color: var(--text-2); font-size: 12px; margin-top: 4px; } + +/* ── CARDS ── */ +.card { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.card-title { + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + color: var(--text-0); + display: flex; + align-items: center; + gap: 8px; +} + +.card-body { padding: 18px; } + +/* ── TOOLBAR ── */ +.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; } + +.search-box { + flex: 1; + min-width: 200px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 12px; + color: var(--text-0); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color 0.15s; +} +.search-box:focus { border-color: var(--accent); } +.search-box::placeholder { color: var(--text-3); } + +/* ── BUTTONS ── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.btn-primary { + background: var(--accent); + color: var(--bg-0); +} +.btn-primary:hover { 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-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-ghost { + background: transparent; + color: var(--text-2); + padding: 4px 8px; +} +.btn-ghost:hover { color: var(--text-0); } + +.btn-sm { padding: 4px 10px; font-size: 11px; } + +/* ── TABLE ── */ +.table-wrap { overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; } + +thead tr { + border-bottom: 1px solid var(--border); +} + +th { + padding: 8px 12px; + text-align: left; + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + font-weight: 500; + white-space: nowrap; +} + +td { + padding: 10px 12px; + border-bottom: 1px solid rgba(31,42,51,0.6); + color: var(--text-1); + font-size: 12px; +} + +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; } + +/* ── BADGES ── */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.badge-a { background: rgba(0,212,255,0.12); color: var(--accent); } +.badge-aaaa { background: rgba(0,229,160,0.12); color: var(--green); } +.badge-mx { background: rgba(255,183,0,0.12); color: var(--yellow); } +.badge-cname { background: rgba(255,125,64,0.12); color: var(--orange); } +.badge-txt { background: rgba(130,100,255,0.12); color: #9d7aff; } +.badge-ns { background: rgba(255,74,106,0.12); color: var(--red); } +.badge-ptr { background: rgba(100,180,255,0.12); color: #64b4ff; } +.badge-srv { background: rgba(0,229,160,0.1); color: #00c88a; } +.badge-default { background: var(--bg-3); color: var(--text-2); } +.badge-active { background: var(--green-dim); color: var(--green); } +.badge-inactive { background: var(--red-dim); color: var(--red); } + +/* ── MODAL ── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.modal { + background: var(--bg-2); + border: 1px solid var(--border-hi); + border-radius: 10px; + width: 520px; + max-width: 94vw; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.2s ease; + box-shadow: 0 24px 80px rgba(0,0,0,0.6); +} + +@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--border); +} + +.modal-title { + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + color: var(--text-0); +} + +.modal-close { + background: none; + border: none; + color: var(--text-2); + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.1s; +} +.modal-close:hover { background: var(--bg-3); color: var(--text-0); } + +.modal-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; } +.modal-footer { padding: 16px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; } + +/* ── 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-input, .form-select, .form-textarea { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text-0); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color 0.15s; + width: 100%; +} +.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: var(--accent); } +.form-select { cursor: pointer; } +.form-textarea { resize: vertical; min-height: 80px; } + +/* ── SELECT ZONE TABS ── */ +.zone-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; } +.zone-tab { + padding: 5px 14px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 11px; + background: var(--bg-2); + border: 1px solid var(--border); + color: var(--text-2); + cursor: pointer; + transition: all 0.15s; +} +.zone-tab:hover { border-color: var(--border-hi); color: var(--text-1); } +.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-icon { font-size: 32px; margin-bottom: 10px; } +.empty-text { font-size: 13px; color: var(--text-2); } + +/* ── ALERT ── */ +.alert { + padding: 10px 14px; + border-radius: var(--radius); + font-size: 12px; + border-left: 3px solid; + margin-bottom: 12px; +} +.alert-error { background: var(--red-dim); border-color: var(--red); color: var(--red); } +.alert-success { background: var(--green-dim); border-color: var(--green); color: var(--green); } + +/* ── QUOTA BAR ── */ +.quota-bar { display: flex; flex-direction: column; gap: 4px; min-width: 100px; } +.quota-track { height: 4px; background: var(--bg-3); border-radius: 2px; overflow: hidden; } +.quota-fill { height: 100%; border-radius: 2px; transition: width 0.3s; } +.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); } + +/* ── 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: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; } +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..416d342 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,175 @@ +import { useState, useEffect, useCallback } from "react"; +import LoginPage from "./components/LoginPage"; +import DomainsManager from "./components/DomainsManager"; +import DNSManager from "./components/DNSManager"; +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"; + +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"] }, +]; + +function hasAccess(user, roles) { + if (!user) return false; + return user.roles?.some(r => r.role === "global_admin" || roles.includes(r.role)); +} + +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); + + // Persist token + useEffect(() => { + if (token) localStorage.setItem("netadmin_token", token); + else localStorage.removeItem("netadmin_token"); + }, [token]); + + // Persist apiBase + useEffect(() => { + localStorage.setItem("netadmin_api", apiBase); + }, [apiBase]); + + const doLogout = useCallback(async (callServer = true) => { + if (callServer && token) { + try { + await fetch(`${apiBase}/auth/logout`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + } catch {} + } + setToken(null); + setUser(null); + }, [token, apiBase]); + + // Validate token on mount + useEffect(() => { + if (!token) { setChecking(false); setUser(null); return; } + setChecking(true); + fetch(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }) + .then(r => { + if (r.status === 401) { doLogout(false); return null; } + if (!r.ok) throw new Error("Erreur serveur"); + return r.json(); + }) + .then(u => { if (u) setUser(u); }) + .catch(() => doLogout(false)) + .finally(() => setChecking(false)); + }, [token, apiBase]); // eslint-disable-line + + const onUnauthorized = useCallback(() => doLogout(false), [doLogout]); + + // Called by LoginPage once login + TOTP (if needed) are fully complete + const onLogin = useCallback((token, user) => { + setToken(token); + setUser(user); + setActive("domains"); + }, []); + + if (checking) { + return ( +
+
+
+
+ Vérification de la session… +
+
+ ); + } + + if (!token || !user) { + return ; + } + + const visibleNav = NAV_ITEMS.filter(n => hasAccess(user, n.roles)); + + return ( +
+ + +
+
+ {active === "domains" && } + {active === "dns" && } + {active === "mail" && } + {active === "lists" && } + {active === "users" && } + {active === "audit" && } + {active === "sync" && } + {active === "profile" && } +
+
+
+ ); +} diff --git a/frontend/src/components/AuditLog.jsx b/frontend/src/components/AuditLog.jsx new file mode 100644 index 0000000..c0de71a --- /dev/null +++ b/frontend/src/components/AuditLog.jsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useCallback } from "react"; + +const SERVICE_COLORS = { + auth: "var(--accent)", + dns: "#9d7aff", + mail: "var(--yellow)", + system: "var(--text-2)", +}; + +const ACTION_ICONS = { + login: "🔑", + logout: "🚪", + create_user: "👤", + assign_role: "🏷", + create_zone: "◈", + delete_zone: "✕", + update_zone_config: "⚙", + create_record: "+", + update_record: "✎", + delete_record: "✕", + sync: "⟳", + create_account: "◉", + update_account: "✎", + delete_account: "✕", + create_alias: "⇄", + update_alias: "✎", + delete_alias: "✕", + agent_reconnect: "🔌", +}; + +function Badge({ service }) { + return ( + + {service} + + ); +} + +function StatusDot({ success }) { + return ( + + ); +} + +export default function AuditLog({ apiBase, token, onUnauthorized }) { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(0); + const [expanded, setExpanded] = useState(null); + + // Filters + const [filterService, setFilterService] = useState(""); + const [filterUsername, setFilterUsername] = useState(""); + const [filterAction, setFilterAction] = useState(""); + + const PAGE_SIZE = 50; + + const api = useCallback(async (path) => { + const r = await fetch(`${apiBase}${path}`, { + headers: { "Authorization": `Bearer ${token}` }, + }); + if (r.status === 401) { onUnauthorized?.(); throw new Error("Session expirée."); } + if (!r.ok) { + const e = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(e.detail || r.statusText); + } + return r.json(); + }, [apiBase, token]); + + const load = useCallback((p = 0) => { + setLoading(true); + const params = new URLSearchParams({ limit: PAGE_SIZE, offset: p * PAGE_SIZE }); + if (filterService) params.set("service", filterService); + if (filterUsername) params.set("username", filterUsername); + if (filterAction) params.set("action", filterAction); + api(`/audit?${params}`) + .then(data => { setEntries(data.entries); setTotal(data.total); setPage(p); }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, [api, filterService, filterUsername, filterAction]); + + useEffect(() => { load(0); }, [load]); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + const fmtDate = (ts) => { + if (!ts) return "—"; + const d = new Date(ts + "Z"); + return d.toLocaleString("fr-FR", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + }; + + return ( + <> +
+
📋 Journal d'audit
+
Historique des actions — qui, quand, quoi
+
+ + {error && ( +
+ ⚠ {error} + +
+ )} + + {/* Filters */} +
+
+
+
+ + +
+
+ + {setFilterUsername(e.target.value);setPage(0);}} /> +
+
+ + {setFilterAction(e.target.value);setPage(0);}} /> +
+ +
+
+
+ + {/* Table */} +
+
+
+ {total} entrée{total!==1?"s":""}{" "} + {(filterService||filterUsername||filterAction) && — filtrées} +
+ {totalPages > 1 && ( +
+ + {page+1} / {totalPages} + +
+ )} +
+
+ {loading ? ( +
Chargement…
+ ) : entries.length === 0 ? ( +
+
📋
+
Aucune entrée
+
+ ) : ( +
+ + + + + + + + + + + + + + {entries.map(e => ( + <> + setExpanded(expanded===e.id?null:e.id)} + style={{cursor:e.detail||e.error?"pointer":"default", + background:expanded===e.id?"var(--bg-2)":"transparent"}}> + + + + + + + + + {expanded===e.id && (e.detail||e.error) && ( + + + + )} + + ))} + +
DateUtilisateurIPServiceActionCible
+ {fmtDate(e.ts)} + + {e.username || "—"} + + {e.ip || "—"} + + + {ACTION_ICONS[e.action]||"•"} + {e.action} + + + {e.target || "—"} +
+ + {e.error && ( +
+ ⚠ {e.error} +
+ )} + {e.detail && ( +
+                                {JSON.stringify(JSON.parse(e.detail), null, 2)}
+                              
+ )} +
+
+ )} +
+
+ + ); +} diff --git a/frontend/src/components/DNSManager.jsx b/frontend/src/components/DNSManager.jsx new file mode 100644 index 0000000..9c0621c --- /dev/null +++ b/frontend/src/components/DNSManager.jsx @@ -0,0 +1,988 @@ +import { useState, useEffect, useCallback } from "react"; +import SyncToast from "./SyncToast"; + +const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "TLSA", "DS"]; + +const typeBadge = (t) => { + const map = { A:"badge-a", AAAA:"badge-aaaa", MX:"badge-mx", CNAME:"badge-cname", TXT:"badge-txt", NS:"badge-ns", PTR:"badge-ptr", SRV:"badge-srv" }; + return `badge ${map[t] || "badge-default"}`; +}; + +const EMPTY_RECORD = { name: "", type: "A", ttl: 3600, value: "", priority: "" }; +const EMPTY_DS = { keyTag: "", algo: "13", digestType: "2", digest: "" }; +const EMPTY_TLSA = { usage: "3", selector: "1", matchingType: "1", certData: "" }; + +export default function DNSManager({ apiBase, token, onUnauthorized }) { + const [zones, setZones] = useState([]); + const [activeZone, setActiveZone] = useState(null); + const [records, setRecords] = useState([]); + const [search, setSearch] = useState(""); + const [filterType, setFilterType] = useState("ALL"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showModal, setShowModal] = useState(false); + const [editRecord, setEditRecord] = useState(null); + const [form, setForm] = useState(EMPTY_RECORD); + const [ds, setDs] = useState(EMPTY_DS); + const [tlsa, setTlsa] = useState(EMPTY_TLSA); + const [saving, setSaving] = useState(false); + const [showZoneConfig, setShowZoneConfig] = useState(false); + const [zoneConfig, setZoneConfig] = useState({ ttl:3600, admin:"hostmaster", refresh:43200, retry:3600, expire:2419200, negative_ttl:3600 }); + const [zoneConfigSaving, setZoneConfigSaving] = useState(false); + const [zoneConfigError, setZoneConfigError] = useState(null); + const [syncing, setSyncing] = useState(false); + const [reloading, setReloading] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncSuccess, setSyncSuccess] = useState(true); + const [modalError, setModalError] = useState(null); + const [showRaw, setShowRaw] = useState(false); + const [rawContent, setRawContent] = useState(""); + const [rawSaving, setRawSaving] = useState(false); + const [rawLoading, setRawLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [confirmZone, setConfirmZone] = useState(null); + const [confirmRec, setConfirmRec] = useState(null); + const [confirmToggle, setConfirmToggle] = useState(null); // record pending toggle + const [confirmInput, setConfirmInput] = useState(""); + const [pendingCount, setPendingCount] = useState(0); + + const api = useCallback(async (path, options = {}) => { + const r = await fetch(`${apiBase}${path}`, { + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, + ...options, + }); + if (r.status === 401) { + onUnauthorized?.(); + throw new Error("Session expirée — veuillez vous reconnecter."); + } + if (!r.ok) { + const err = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(err.detail || r.statusText); + } + // 204 No Content — no body to parse + if (r.status === 204) return null; + return r.json(); + }, [apiBase, token]); + + useEffect(() => { + setLoading(true); + api("/dns/zones") + .then((data) => { + setZones(data); + if (data.length > 0) setActiveZone(data[0].name); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [api]); + + useEffect(() => { + if (!activeZone) return; + setLoading(true); + setError(null); + api(`/dns/zones/${activeZone}/records`) + .then(setRecords) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [activeZone, api]); + + const [sortCol, setSortCol] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + + const toggleSort = (col) => { + if (sortCol === col) setSortDir((d) => d === "asc" ? "desc" : "asc"); + else { setSortCol(col); setSortDir("asc"); } + }; + + const filtered = records.filter((r) => { + const matchType = filterType === "ALL" || r.type === filterType; + const matchSearch = !search || + r.name.toLowerCase().includes(search.toLowerCase()) || + r.value.toLowerCase().includes(search.toLowerCase()); + return matchType && matchSearch; + }); + + const sorted = [...filtered].sort((a, b) => { + let av = a[sortCol] ?? ""; + let bv = b[sortCol] ?? ""; + // numeric sort for ttl and priority + if (sortCol === "ttl" || sortCol === "priority") { + av = Number(av) || 0; + bv = Number(bv) || 0; + return sortDir === "asc" ? av - bv : bv - av; + } + av = String(av).toLowerCase(); + bv = String(bv).toLowerCase(); + if (av < bv) return sortDir === "asc" ? -1 : 1; + if (av > bv) return sortDir === "asc" ? 1 : -1; + return 0; + }); + + const openNew = () => { + setForm(EMPTY_RECORD); setDs(EMPTY_DS); setTlsa(EMPTY_TLSA); + setModalError(null); setEditRecord(null); setShowModal(true); + }; + const openEdit = (rec) => { + setForm({ ...rec }); + // Pre-fill structured fields when editing DS / TLSA + if (rec.type === "DS") { + const p = rec.value.split(/\s+/); + setDs({ keyTag: p[0]||"", algo: p[1]||"13", digestType: p[2]||"2", digest: p[3]||"" }); + } else if (rec.type === "TLSA") { + const p = rec.value.split(/\s+/); + setTlsa({ usage: p[0]||"3", selector: p[1]||"1", matchingType: p[2]||"1", certData: p[3]||"" }); + } + setModalError(null); setEditRecord(rec); setShowModal(true); + }; + + const showMutationToast = (result) => { + if (result?.queued) { + setSyncSuccess(false); + setSyncMsg("Modification enregistrée. L'agent BIND est injoignable — elle sera appliquée automatiquement dès sa reconnexion."); + } else { + setSyncSuccess(true); + setSyncMsg("Zone mise à jour et rechargée sur le serveur BIND."); + } + }; + + const saveRecord = async () => { + setSaving(true); + // Build composite value for DS and TLSA from structured sub-fields + let compositeValue = form.value; + if (form.type === "DS") + compositeValue = `${ds.keyTag} ${ds.algo} ${ds.digestType} ${ds.digest}`.trim(); + if (form.type === "TLSA") + compositeValue = `${tlsa.usage} ${tlsa.selector} ${tlsa.matchingType} ${tlsa.certData}`.trim(); + const payload = { + ...form, + value: compositeValue, + priority: (form.priority === "" || form.priority === undefined) ? null : Number(form.priority), + }; + try { + if (editRecord) { + await api(`/dns/zones/${activeZone}/records/${editRecord.id}`, { + method: "PUT", body: JSON.stringify(payload), + }); + } else { + await api(`/dns/zones/${activeZone}/records`, { + method: "POST", body: JSON.stringify(payload), + }); + } + const updated = await api(`/dns/zones/${activeZone}/records`); + setRecords(updated); + setShowModal(false); + } catch (e) { + setModalError(e.message); + } finally { + setSaving(false); + } + }; + + const openRaw = async () => { + setRawLoading(true); + setShowRaw(true); + try { + const data = await api(`/dns/zones/${activeZone}/raw`); + setRawContent(data.content); + } catch (e) { + setError(e.message); + setShowRaw(false); + } finally { + setRawLoading(false); + } + }; + + const saveRaw = async () => { + setRawSaving(true); + try { + const data = await api(`/dns/zones/${activeZone}/raw`, { + method: "PUT", + body: JSON.stringify({ content: rawContent }), + }); + setRawContent(data.content); + // Refresh record list + const updated = await api(`/dns/zones/${activeZone}/records`); + setRecords(updated); + setShowRaw(false); + } catch (e) { + setError(e.message); + } finally { + setRawSaving(false); + } + }; + + const copyRaw = () => { + navigator.clipboard.writeText(rawContent).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const deleteRecord = async (rec) => { + setConfirmRec(rec); + }; + + const doDeleteRecord = async () => { + const rec = confirmRec; + setConfirmRec(null); + try { + const res = await api(`/dns/zones/${activeZone}/records/${rec.id}`, { method: "DELETE" }); + setRecords((prev) => prev.filter((r) => r.id !== rec.id)); + showMutationToast(res || {queued: false}); + } catch (e) { + setError(e.message); + } + }; + + const toggleRecord = (rec) => { + setConfirmToggle(rec); + }; + + const doToggleRecord = async () => { + const rec = confirmToggle; + setConfirmToggle(null); + try { + const res = await api(`/dns/zones/${activeZone}/records/${rec.id}`, { + method: "PUT", + body: JSON.stringify({ + name: rec.name, type: rec.type, ttl: rec.ttl, + value: rec.value, priority: rec.priority, active: !rec.active, + }), + }); + setRecords(prev => prev.map(r => r.id === rec.id ? {...r, ...res} : r)); + showMutationToast(res); + } catch(e) { setError(e.message); } + }; + + const reloadZone = async () => { + setReloading(true); + try { + const res = await api(`/dns/zones/${activeZone}/reload`, { method: "POST" }); + if (res.queued) { + setSyncSuccess(false); + setSyncMsg("Rechargement mis en file d'attente — l'agent BIND est injoignable."); + } else { + setSyncSuccess(true); + setSyncMsg(`Zone ${activeZone} rechargée sur le serveur BIND (rndc reload).`); + } + } catch(e) { setError(e.message); } + finally { setReloading(false); } + }; + + const openZoneConfig = async () => { + setZoneConfigError(null); + try { + const cfg = await api(`/dns/zones/${activeZone}/config`); + setZoneConfig({ + ttl: cfg.ttl, + admin: cfg.admin, + refresh: cfg.refresh ?? 43200, + retry: cfg.retry ?? 3600, + expire: cfg.expire ?? 2419200, + negative_ttl: cfg.negative_ttl ?? 3600, + }); + setShowZoneConfig(true); + } catch(e) { setError(e.message); } + }; + + const saveZoneConfig = async () => { + setZoneConfigSaving(true); setZoneConfigError(null); + try { + await api(`/dns/zones/${activeZone}/config`, { + method: "PUT", + body: JSON.stringify({ + admin: zoneConfig.admin, + ttl: zoneConfig.ttl, + refresh: zoneConfig.refresh, + retry: zoneConfig.retry, + expire: zoneConfig.expire, + negative_ttl: zoneConfig.negative_ttl, + }), + }); + setShowZoneConfig(false); + } catch(e) { setZoneConfigError(e.message); } + finally { setZoneConfigSaving(false); } + }; + + const syncFromServer = async () => { + setSyncing(true); setSyncMsg(null); + try { + const r = await api("/dns/sync", { method: "POST" }); + setSyncSuccess(true); + setSyncMsg(r.message); + const updated = await api("/dns/zones"); + setZones(updated); + if (updated.length > 0 && !updated.find(z => z.name === activeZone)) { + setActiveZone(updated[0].name); + } + } catch(e) { + setSyncSuccess(false); + setSyncMsg(e.message); + } finally { + setSyncing(false); + } + }; + + const deleteZone = async (zone) => { + // Step 1 — open confirmation modal + if (confirmZone !== zone) { + setConfirmZone(zone); + setConfirmInput(""); + return; + } + // Step 2 — user must have typed the zone name exactly + if (confirmInput.trim() !== zone) return; + setConfirmZone(null); + setConfirmInput(""); + try { + await api(`/dns/zones/${zone}`, { method: "DELETE" }); + const updated = await api("/dns/zones"); + setZones(updated); + setActiveZone(updated[0]?.name || null); + } catch (e) { + setError(e.message); + } + }; + + return ( + <> +
+
DNS Zones
+
Manage BIND9 zones and resource records
+
+ + {error &&
⚠ {error}
} + setSyncMsg(null)} /> + +
+
+
+ Zones + {pendingCount > 0 && ( + + ⏳ {pendingCount} en attente + + )} +
+ +
+
+ {loading && !zones.length ? ( +
Chargement des zones…
+ ) : ( +
+ + +
+ )} +
+
+ + {activeZone && ( +
+
+
+ {activeZone} + — {records.length} record{records.length !== 1 ? "s" : ""} +
+
+ + + + +
+
+
+
+ setSearch(e.target.value)} /> + +
+ + {loading ? ( +
Chargement des enregistrements…
+ ) : filtered.length === 0 ? ( +
+
+
No records found
+
+ ) : ( +
+ + + + {[ + { col:"name", label:"Nom" }, + { col:"type", label:"Type" }, + { col:"ttl", label:"TTL" }, + { col:"priority", label:"Priorité" }, + { col:"value", label:"Valeur" }, + ].map(({ col, label }) => ( + + ))} + + + + + {sorted.map((rec) => ( + + + + + + + + + ))} + +
toggleSort(col)} + style={{cursor:"pointer", userSelect:"none", whiteSpace:"nowrap"}} + > + + {label} + + + + + +
+ {rec.name || "@"} + {rec.pending_sync && ( + + )} + {rec.active === false && ( + + )} + {rec.type}{rec.ttl ?? "—"}s{rec.priority ?? "—"}{rec.value} +
+ + + +
+
+
+ )} +
+
+ )} + + {/* Record Modal */} + {showModal && ( +
e.target === e.currentTarget && setShowModal(false)}> +
+
+
{editRecord ? "Modifier l'enregistrement" : "Nouvel enregistrement"}
+ +
+
+ {modalError && ( +
⚠ {modalError}
+ )} +
+
+ + setForm({...form, name: e.target.value})} /> +
+
+ + +
+
+
+
+ + setForm({...form, ttl: +e.target.value})} /> +
+ {(form.type === "MX" || form.type === "SRV") && ( +
+ + setForm({...form, priority: +e.target.value})} /> +
+ )} +
+ {/* ── Standard value field (all types except DS and TLSA) ── */} + {form.type !== "DS" && form.type !== "TLSA" && ( +
+ + setForm({...form, value: e.target.value})} /> +
+ )} + + {/* ── DS structured fields ── */} + {form.type === "DS" && ( + <> +
+ DS + {" "}— Delegation Signer, utilisé pour la chaîne de confiance DNSSEC. +
+
+
+ + setDs({...ds, keyTag: e.target.value})} /> +
+
+ + +
+
+
+ + +
+
+ + setDs({...ds, digest: e.target.value.replace(/\s/g,"")})} + style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} /> + {ds.digest && ( +
{ + const expected = ds.digestType==="1"?40:ds.digestType==="4"?96:64; + return ds.digest.length===expected?"var(--green)":"var(--yellow)"; + })(), marginTop:3}}> + {ds.digest.length} / {ds.digestType==="1"?40:ds.digestType==="4"?96:64} caractères +
+ )} +
+ + )} + + {/* ── TLSA structured fields ── */} + {form.type === "TLSA" && ( + <> +
+ TLSA + {" "}— DANE, associe un certificat TLS à un nom DNS. +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + setTlsa({...tlsa, certData: e.target.value.replace(/\s/g,"")})} + style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} /> + {tlsa.certData && tlsa.matchingType !== "0" && ( +
{ + const expected = tlsa.matchingType==="2"?128:64; + return tlsa.certData.length===expected?"var(--green)":"var(--yellow)"; + })(), marginTop:3}}> + {tlsa.certData.length} / {tlsa.matchingType==="2"?128:64} caractères +
+ )} +
+ + )} +
+
+ + +
+
+
+ )} + + {/* Raw Zone Modal */} + {showRaw && ( +
e.target === e.currentTarget && setShowRaw(false)}> +
+
+
+ Zone brute — {activeZone} +
+ +
+
+ {rawLoading ? ( +
Chargement…
+ ) : ( +