feat: NetAdmin DNS & Mail console — version initiale

This commit is contained in:
2026-04-18 18:16:37 +02:00
commit 17664ef038
36 changed files with 11177 additions and 0 deletions
+35
View File
@@ -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/
+368
View File
@@ -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.
+567
View File
@@ -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)
+167
View File
@@ -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)."
+626
View File
@@ -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)
+23
View File
@@ -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
+17
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
redis>=5.0
pyyaml>=6.0
dnspython>=2.6.0 # dns_agent only
PyMySQL>=1.1.0 # mail_agent only
+249
View File
@@ -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
+305
View File
@@ -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
+161
View File
@@ -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()
+127
View File
@@ -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
+98
View File
@@ -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
+250
View File
@@ -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,
)
+180
View File
@@ -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"""\
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:520px;margin:0 auto">
<h2 style="color:#0d7bc4;border-bottom:2px solid #0d7bc4;padding-bottom:8px">
Connexion à votre compte NetAdmin
</h2>
<p>Bonjour <strong>{username}</strong>,</p>
<p>Une connexion a été détectée sur votre compte NetAdmin.</p>
<table style="border-collapse:collapse;margin:16px 0;width:100%">
<tr style="background:#f5f5f5">
<td style="padding:8px 12px;font-weight:bold;width:120px">Date</td>
<td style="padding:8px 12px">{now}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:bold">Adresse IP</td>
<td style="padding:8px 12px;font-family:monospace">{ip or 'inconnue'}</td>
</tr>
<tr style="background:#f5f5f5">
<td style="padding:8px 12px;font-weight:bold">Serveur</td>
<td style="padding:8px 12px">{hostname}</td>
</tr>
</table>
<p style="color:#c00;font-weight:bold">
Si vous n'êtes pas à l'origine de cette connexion,
contactez immédiatement votre administrateur.
</p>
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0">
<p style="color:#999;font-size:11px">NetAdmin — notification automatique</p>
</body>
</html>"""
_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"""\
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;color:#333;max-width:520px;margin:0 auto">
<h2 style="color:#0d7bc4;border-bottom:2px solid #0d7bc4;padding-bottom:8px">
Nouveau rôle NetAdmin
</h2>
<p>Bonjour <strong>{target_username}</strong>,</p>
<p>Un nouveau rôle vous a été assigné.</p>
<table style="border-collapse:collapse;margin:16px 0;width:100%">
<tr style="background:#f5f5f5">
<td style="padding:8px 12px;font-weight:bold;width:140px">Rôle</td>
<td style="padding:8px 12px;font-weight:bold;color:#0d7bc4">{role}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:bold">Périmètre</td>
<td style="padding:8px 12px;font-family:monospace">{scope_str}</td>
</tr>
<tr style="background:#f5f5f5">
<td style="padding:8px 12px;font-weight:bold">Assigné par</td>
<td style="padding:8px 12px">{assigned_by}</td>
</tr>
</table>
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0">
<p style="color:#999;font-size:11px">NetAdmin — notification automatique</p>
</body>
</html>"""
_send(target_email, subject, text, html)
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# routers package
File diff suppressed because it is too large Load Diff
+784
View File
@@ -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: '<weight> <port> <target>'")
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: '<usage> <selector> <matching-type> <cert-hex>'")
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: '<keytag> <algo> <digest-type> <digest-hex>'")
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: '<flags> <tag> <value>'")
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,
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetAdmin — DNS & Mail Console</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+19
View File
@@ -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"
}
}
+481
View File
@@ -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); } }
+175
View File
@@ -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 (
<div style={{minHeight:"100vh", background:"var(--bg-0)",
display:"flex", alignItems:"center", justifyContent:"center", flexDirection:"column", gap:16}}>
<div style={{fontSize:36, color:"var(--accent)"}}></div>
<div className="loading">
<div className="spinner" />
<span style={{color:"var(--text-2)"}}>Vérification de la session</span>
</div>
</div>
);
}
if (!token || !user) {
return <LoginPage apiBase={apiBase} onLogin={onLogin} />;
}
const visibleNav = NAV_ITEMS.filter(n => hasAccess(user, n.roles));
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar-brand">
<span className="brand-icon"></span>
<div>
<div className="brand-name">NetAdmin</div>
<div className="brand-sub">DNS & Mail Console</div>
</div>
</div>
<nav className="sidebar-nav">
{visibleNav.map(item => (
<button key={item.id}
className={`nav-item ${active === item.id ? "active" : ""}`}
onClick={() => setActive(item.id)}>
<span className="nav-icon">{item.icon}</span>
<span>{item.label}</span>
{active === item.id && <span className="nav-indicator" />}
</button>
))}
</nav>
<div className="sidebar-footer">
<div style={{padding:"8px 10px", background:"var(--bg-2)", borderRadius:6,
border:"1px solid var(--border)", marginBottom:10}}>
<div style={{fontSize:12, color:"var(--text-0)", fontWeight:500}}>
{user.full_name || user.username}
</div>
<div style={{fontSize:10, color:"var(--text-2)", marginTop:2}}>
{user.roles?.map(r => r.role === "global_admin" ? "Admin global" : r.role).slice(0,2).join(", ")}
</div>
{user.totp_enabled && (
<div style={{fontSize:10, color:"var(--green)", marginTop:3}}>🔐 2FA activée</div>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => doLogout(true)}
style={{width:"100%", justifyContent:"center", marginBottom:6}}>
Déconnexion
</button>
<div style={{fontSize:10, color:"var(--text-3)"}}>
<label style={{display:"block", marginBottom:3, textTransform:"uppercase", letterSpacing:"0.06em"}}>
Serveur API
</label>
<input
style={{width:"100%", background:"var(--bg-0)", border:"1px solid var(--border)",
borderRadius:4, padding:"3px 6px", fontSize:11, color:"var(--text-2)",
fontFamily:"var(--font-mono)"}}
value={apiBase}
onChange={e => setApiBase(e.target.value)}
placeholder="http://localhost:8000"
/>
</div>
</div>
</aside>
<main className="main">
<div className="main-inner">
{active === "domains" && <DomainsManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
{active === "dns" && <DNSManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
{active === "mail" && <MailManager apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
{active === "lists" && <MailingLists apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
{active === "users" && <UsersManager apiBase={apiBase} token={token} currentUser={user} onUnauthorized={onUnauthorized} />}
{active === "audit" && <AuditLog apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
{active === "sync" && <SyncDashboard apiBase={apiBase} token={token} onUnauthorized={onUnauthorized} />}
{active === "profile" && <UserProfile apiBase={apiBase} token={token} user={user} onUnauthorized={onUnauthorized} />}
</div>
</main>
</div>
);
}
+252
View File
@@ -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 (
<span style={{
display: "inline-block",
padding: "1px 7px", borderRadius: 3,
fontSize: 10, fontWeight: 600,
textTransform: "uppercase", letterSpacing: "0.06em",
background: (SERVICE_COLORS[service] || "var(--text-2)") + "22",
color: SERVICE_COLORS[service] || "var(--text-2)",
border: `1px solid ${SERVICE_COLORS[service] || "var(--text-2)"}44`,
}}>
{service}
</span>
);
}
function StatusDot({ success }) {
return (
<span style={{
display: "inline-block", width: 8, height: 8, borderRadius: "50%",
background: success ? "var(--green)" : "var(--red)",
flexShrink: 0,
}} title={success ? "Succès" : "Échec"} />
);
}
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 (
<>
<div className="page-header">
<div className="page-title"><span className="icon">📋</span> Journal d'audit</div>
<div className="page-sub">Historique des actions qui, quand, quoi</div>
</div>
{error && (
<div className="alert alert-error">
{error}
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}></button>
</div>
)}
{/* Filters */}
<div className="card" style={{marginBottom:12}}>
<div className="card-body" style={{padding:"12px 16px"}}>
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end"}}>
<div className="form-group" style={{marginBottom:0,minWidth:130}}>
<label className="form-label" style={{marginBottom:4}}>Service</label>
<select className="form-select" value={filterService}
onChange={e=>{setFilterService(e.target.value);setPage(0);}}>
<option value="">Tous</option>
<option value="auth">Auth</option>
<option value="dns">DNS</option>
<option value="mail">Mail</option>
<option value="system">Système</option>
</select>
</div>
<div className="form-group" style={{marginBottom:0,flex:1,minWidth:140}}>
<label className="form-label" style={{marginBottom:4}}>Utilisateur</label>
<input className="form-input" placeholder="Rechercher…"
value={filterUsername} onChange={e=>{setFilterUsername(e.target.value);setPage(0);}} />
</div>
<div className="form-group" style={{marginBottom:0,flex:1,minWidth:140}}>
<label className="form-label" style={{marginBottom:4}}>Action</label>
<input className="form-input" placeholder="ex: create_record"
value={filterAction} onChange={e=>{setFilterAction(e.target.value);setPage(0);}} />
</div>
<button className="btn btn-secondary btn-sm" onClick={()=>load(0)}
style={{alignSelf:"flex-end",marginBottom:0}}>
Actualiser
</button>
</div>
</div>
</div>
{/* Table */}
<div className="card">
<div className="card-header">
<div className="card-title">
{total} entrée{total!==1?"s":""}{" "}
{(filterService||filterUsername||filterAction) && <span style={{color:"var(--text-3)",fontWeight:400}}> filtrées</span>}
</div>
{totalPages > 1 && (
<div style={{display:"flex",gap:6,alignItems:"center"}}>
<button className="btn btn-ghost btn-sm" disabled={page===0} onClick={()=>load(page-1)}> Préc.</button>
<span style={{fontSize:12,color:"var(--text-2)"}}>{page+1} / {totalPages}</span>
<button className="btn btn-ghost btn-sm" disabled={page>=totalPages-1} onClick={()=>load(page+1)}>Suiv. </button>
</div>
)}
</div>
<div className="card-body" style={{padding:0}}>
{loading ? (
<div className="loading" style={{padding:24}}><div className="spinner"/> Chargement</div>
) : entries.length === 0 ? (
<div className="empty-state" style={{padding:40}}>
<div className="empty-icon">📋</div>
<div className="empty-text">Aucune entrée</div>
</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th style={{width:20}}></th>
<th>Date</th>
<th>Utilisateur</th>
<th>IP</th>
<th>Service</th>
<th>Action</th>
<th>Cible</th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<>
<tr key={e.id}
onClick={()=>setExpanded(expanded===e.id?null:e.id)}
style={{cursor:e.detail||e.error?"pointer":"default",
background:expanded===e.id?"var(--bg-2)":"transparent"}}>
<td><StatusDot success={!!e.success}/></td>
<td className="cell-dim" style={{whiteSpace:"nowrap",fontSize:11}}>
{fmtDate(e.ts)}
</td>
<td className="cell-mono" style={{fontWeight:e.username==="system"?400:500}}>
{e.username || "—"}
</td>
<td className="cell-dim" style={{fontSize:11,fontFamily:"var(--font-mono)"}}>
{e.ip || "—"}
</td>
<td><Badge service={e.service}/></td>
<td>
<span style={{display:"inline-flex",alignItems:"center",gap:6}}>
<span style={{fontSize:13}}>{ACTION_ICONS[e.action]||"•"}</span>
<span style={{fontSize:12,color:"var(--text-1)"}}>{e.action}</span>
</span>
</td>
<td className="cell-mono" style={{fontSize:11,color:"var(--text-2)",maxWidth:220,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
{e.target || "—"}
</td>
</tr>
{expanded===e.id && (e.detail||e.error) && (
<tr key={e.id+"-detail"} style={{background:"var(--bg-0)"}}>
<td/>
<td colSpan={6} style={{padding:"8px 14px"}}>
{e.error && (
<div style={{color:"var(--red)",fontSize:12,marginBottom:6}}>
{e.error}
</div>
)}
{e.detail && (
<pre style={{
margin:0, fontSize:11, color:"var(--text-2)",
fontFamily:"var(--font-mono)", whiteSpace:"pre-wrap",
wordBreak:"break-all",
}}>
{JSON.stringify(JSON.parse(e.detail), null, 2)}
</pre>
)}
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
);
}
+988
View File
@@ -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 (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> DNS Zones</div>
<div className="page-sub">Manage BIND9 zones and resource records</div>
</div>
{error && <div className="alert alert-error"> {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}></button></div>}
<SyncToast msg={syncMsg} success={syncSuccess} onClose={() => setSyncMsg(null)} />
<div className="card">
<div className="card-header">
<div className="card-title" style={{display:"flex",alignItems:"center",gap:8}}>
Zones
{pendingCount > 0 && (
<span style={{
display:"inline-flex",alignItems:"center",gap:4,
padding:"2px 8px",borderRadius:3,fontSize:10,fontWeight:600,
background:"rgba(255,200,0,0.15)",color:"var(--yellow)",
border:"1px solid rgba(255,200,0,0.3)",
}} title={`${pendingCount} action(s) en attente de synchronisation`}>
{pendingCount} en attente
</span>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={syncFromServer} disabled={syncing}
title="Synchroniser depuis les fichiers de zone BIND">
{syncing
? <><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> Sync</>
: "⟳ Sync BIND"}
</button>
</div>
<div className="card-body">
{loading && !zones.length ? (
<div className="loading"><div className="spinner" /> Chargement des zones</div>
) : (
<div style={{display:"flex",alignItems:"center",gap:8}}>
<select
className="form-select"
style={{flex:1, maxWidth:420}}
value={activeZone || ""}
onChange={(e) => setActiveZone(e.target.value)}
disabled={zones.length === 0}
>
{zones.length === 0
? <option value=""> Aucune zone </option>
: zones.map((z) => (
<option key={z.name} value={z.name}>
{z.last_apply_ok === false ? "⏳ " : ""}{z.name} ({z.record_count} enregistrement{z.record_count !== 1 ? "s" : ""})
</option>
))
}
</select>
</div>
)}
</div>
</div>
{activeZone && (
<div className="card">
<div className="card-header">
<div className="card-title">
<span style={{color:"var(--accent)"}}></span> {activeZone}
<span style={{color:"var(--text-3)",fontSize:11,fontWeight:400}}> {records.length} record{records.length !== 1 ? "s" : ""}</span>
</div>
<div style={{display:"flex",gap:6}}>
<button className="btn btn-ghost btn-sm" onClick={openZoneConfig}> Configuration</button>
<button className="btn btn-ghost btn-sm" onClick={reloadZone} disabled={reloading}
title="Forcer rndc reload sur cette zone">
{reloading
? <><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> </>
: "↺ Recharger"}
</button>
<button className="btn btn-secondary btn-sm" onClick={openRaw}> Raw Zone</button>
<button className="btn btn-primary btn-sm" onClick={openNew}>+ Ajouter</button>
</div>
</div>
<div className="card-body">
<div className="toolbar">
<input className="search-box" placeholder="Filtrer par nom ou valeur…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className="form-select" style={{width:"auto"}} value={filterType} onChange={(e) => setFilterType(e.target.value)}>
<option value="ALL">All types</option>
{RECORD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
{loading ? (
<div className="loading"><div className="spinner" /> Chargement des enregistrements</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">No records found</div>
</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
{[
{ col:"name", label:"Nom" },
{ col:"type", label:"Type" },
{ col:"ttl", label:"TTL" },
{ col:"priority", label:"Priorité" },
{ col:"value", label:"Valeur" },
].map(({ col, label }) => (
<th key={col}
onClick={() => toggleSort(col)}
style={{cursor:"pointer", userSelect:"none", whiteSpace:"nowrap"}}
>
<span style={{display:"inline-flex", alignItems:"center", gap:4}}>
{label}
<span style={{
display:"inline-flex", flexDirection:"column",
lineHeight:1, fontSize:8, opacity: sortCol===col ? 1 : 0.25,
}}>
<span style={{color: sortCol===col && sortDir==="asc" ? "var(--accent)" : "inherit"}}></span>
<span style={{color: sortCol===col && sortDir==="desc" ? "var(--accent)" : "inherit"}}></span>
</span>
</span>
</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{sorted.map((rec) => (
<tr key={rec.id} style={{
opacity: rec.active === false ? 0.5 : 1,
background: rec.pending_sync ? "rgba(255,200,0,0.04)" : "transparent",
}}>
<td className="cell-mono" style={{whiteSpace:"nowrap"}}>
{rec.name || "@"}
{rec.pending_sync && (
<span title="En attente de l'agent BIND — modification non encore appliquée sur le serveur"
style={{marginLeft:5,fontSize:10,color:"var(--yellow)",cursor:"help"}}></span>
)}
{rec.active === false && (
<span title="Enregistrement désactivé — commenté dans le fichier de zone"
style={{marginLeft:4,fontSize:10,color:"var(--text-3)",cursor:"help"}}></span>
)}
</td>
<td><span className={typeBadge(rec.type)}>{rec.type}</span></td>
<td className="cell-dim">{rec.ttl ?? "—"}s</td>
<td className="cell-dim">{rec.priority ?? "—"}</td>
<td className="cell-mono" style={{maxWidth:260,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{rec.value}</td>
<td>
<div className="cell-actions">
<button
className="btn btn-ghost btn-sm"
onClick={() => toggleRecord(rec)}
title={rec.active === false ? "Activer l'enregistrement" : "Désactiver l'enregistrement"}
style={{color: rec.active === false ? "var(--text-3)" : "var(--green)"}}
>
{rec.active === false ? "○" : "●"}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(rec)} title="Modifier"></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={() => deleteRecord(rec)} title="Supprimer"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* Record Modal */}
{showModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal-header">
<div className="modal-title">{editRecord ? "Modifier l'enregistrement" : "Nouvel enregistrement"}</div>
<button className="modal-close" onClick={() => setShowModal(false)}></button>
</div>
<div className="modal-body">
{modalError && (
<div className="alert alert-error" style={{marginBottom:0}}> {modalError}</div>
)}
<div className="form-row">
<div className="form-group">
<label className="form-label">Name</label>
<input className="form-input" placeholder="@ or subdomain" value={form.name} onChange={(e) => setForm({...form, name: e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Type</label>
<select className="form-select" value={form.type} onChange={(e) => setForm({...form, type: e.target.value})}>
{RECORD_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">TTL (seconds)</label>
<input className="form-input" type="number" value={form.ttl} onChange={(e) => setForm({...form, ttl: +e.target.value})} />
</div>
{(form.type === "MX" || form.type === "SRV") && (
<div className="form-group">
<label className="form-label">Priority</label>
<input className="form-input" type="number" value={form.priority} onChange={(e) => setForm({...form, priority: +e.target.value})} />
</div>
)}
</div>
{/* ── Standard value field (all types except DS and TLSA) ── */}
{form.type !== "DS" && form.type !== "TLSA" && (
<div className="form-group">
<label className="form-label">Value</label>
<input className="form-input" placeholder={
form.type === "A" ? "192.168.1.1" :
form.type === "AAAA" ? "2001:db8::1" :
form.type === "MX" ? "mail.example.com." :
form.type === "CNAME"? "target.example.com." :
form.type === "TXT" ? "v=spf1 include:_spf.example.com ~all (ou clé DKIM complète)" :
form.type === "SRV" ? "10 443 sip.example.com. (weight port target)" :
form.type === "CAA" ? "0 issue \"letsencrypt.org\"" :
form.type === "NS" ? "ns1.example.com." :
form.type === "PTR" ? "host.example.com." :
"value"
} value={form.value} onChange={(e) => setForm({...form, value: e.target.value})} />
</div>
)}
{/* ── DS structured fields ── */}
{form.type === "DS" && (
<>
<div style={{
padding:"8px 10px", marginBottom:4,
background:"var(--bg-0)", border:"1px solid var(--border)",
borderRadius:4, fontSize:11, color:"var(--text-2)"
}}>
<strong style={{color:"var(--yellow)"}}>DS</strong>
{" "} Delegation Signer, utilisé pour la chaîne de confiance DNSSEC.
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Key Tag</label>
<input className="form-input" type="number" min="0" max="65535"
placeholder="12345"
value={ds.keyTag} onChange={(e) => setDs({...ds, keyTag: e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Algorithme</label>
<select className="form-select" value={ds.algo}
onChange={(e) => setDs({...ds, algo: e.target.value})}>
<option value="5">5 RSA/SHA-1</option>
<option value="7">7 RSA/SHA-1-NSEC3</option>
<option value="8">8 RSA/SHA-256</option>
<option value="10">10 RSA/SHA-512</option>
<option value="13">13 ECDSA/P-256 (SHA-256)</option>
<option value="14">14 ECDSA/P-384 (SHA-384)</option>
<option value="15">15 Ed25519</option>
<option value="16">16 Ed448</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Type de digest</label>
<select className="form-select" value={ds.digestType}
onChange={(e) => setDs({...ds, digestType: e.target.value})}>
<option value="1">1 SHA-1 (40 caractères hex)</option>
<option value="2">2 SHA-256 (64 caractères hex)</option>
<option value="4">4 SHA-384 (96 caractères hex)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Digest (hex)</label>
<input className="form-input" placeholder={
ds.digestType === "1" ? "40 caractères hex (SHA-1)" :
ds.digestType === "4" ? "96 caractères hex (SHA-384)" :
"64 caractères hex (SHA-256)"
}
value={ds.digest} onChange={(e) => setDs({...ds, digest: e.target.value.replace(/\s/g,"")})}
style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} />
{ds.digest && (
<div style={{fontSize:10, color: (() => {
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
</div>
)}
</div>
</>
)}
{/* ── TLSA structured fields ── */}
{form.type === "TLSA" && (
<>
<div style={{
padding:"8px 10px", marginBottom:4,
background:"var(--bg-0)", border:"1px solid var(--border)",
borderRadius:4, fontSize:11, color:"var(--text-2)"
}}>
<strong style={{color:"var(--accent)"}}>TLSA</strong>
{" "} DANE, associe un certificat TLS à un nom DNS.
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Usage</label>
<select className="form-select" value={tlsa.usage}
onChange={(e) => setTlsa({...tlsa, usage: e.target.value})}>
<option value="0">0 PKIX-TA (CA de confiance)</option>
<option value="1">1 PKIX-EE (cert. end-entity)</option>
<option value="2">2 DANE-TA (ancre de confiance)</option>
<option value="3">3 DANE-EE (cert. end-entity)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Selector</label>
<select className="form-select" value={tlsa.selector}
onChange={(e) => setTlsa({...tlsa, selector: e.target.value})}>
<option value="0">0 Certificat complet</option>
<option value="1">1 SubjectPublicKeyInfo</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Matching Type</label>
<select className="form-select" value={tlsa.matchingType}
onChange={(e) => setTlsa({...tlsa, matchingType: e.target.value})}>
<option value="0">0 Données brutes (pas de hash)</option>
<option value="1">1 SHA-256 (64 caractères hex)</option>
<option value="2">2 SHA-512 (128 caractères hex)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Données du certificat (hex)</label>
<input className="form-input" placeholder={
tlsa.matchingType==="0" ? "DER complet en hex" :
tlsa.matchingType==="2" ? "128 caractères hex (SHA-512)" :
"64 caractères hex (SHA-256)"
}
value={tlsa.certData}
onChange={(e) => setTlsa({...tlsa, certData: e.target.value.replace(/\s/g,"")})}
style={{fontFamily:"var(--font-mono)", letterSpacing:"0.04em"}} />
{tlsa.certData && tlsa.matchingType !== "0" && (
<div style={{fontSize:10, color: (() => {
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
</div>
)}
</div>
</>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveRecord} disabled={saving}>
{saving ? "Sauvegarde…" : editRecord ? "Enregistrer" : "Créer"}
</button>
</div>
</div>
</div>
)}
{/* Raw Zone Modal */}
{showRaw && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowRaw(false)}>
<div className="modal" style={{width:720, maxWidth:"96vw"}}>
<div className="modal-header">
<div className="modal-title">
<span style={{color:"var(--accent)"}}></span> Zone brute {activeZone}
</div>
<button className="modal-close" onClick={() => setShowRaw(false)}></button>
</div>
<div className="modal-body" style={{padding:0}}>
{rawLoading ? (
<div className="loading" style={{padding:32}}><div className="spinner"/> Chargement</div>
) : (
<textarea
value={rawContent}
onChange={(e) => setRawContent(e.target.value)}
spellCheck={false}
style={{
width:"100%",
minHeight:420,
background:"var(--bg-0)",
color:"var(--green)",
fontFamily:"var(--font-mono)",
fontSize:12,
lineHeight:1.7,
border:"none",
outline:"none",
padding:"16px 20px",
resize:"vertical",
borderRadius:"0 0 0 0",
tabSize:8,
}}
/>
)}
</div>
<div className="modal-footer" style={{justifyContent:"space-between"}}>
<div style={{display:"flex",gap:6,alignItems:"center"}}>
<button className="btn btn-secondary btn-sm" onClick={copyRaw}>
{copied ? "✓ Copié !" : "⎘ Copier"}
</button>
<span style={{fontSize:10,color:"var(--text-3)"}}>
{rawContent.split("\n").length} lignes · {rawContent.length} octets
</span>
</div>
<div style={{display:"flex",gap:6}}>
<button className="btn btn-secondary" onClick={() => setShowRaw(false)}>Fermer</button>
<button className="btn btn-primary" onClick={saveRaw} disabled={rawSaving || rawLoading}>
{rawSaving ? "Sauvegarde…" : "💾 Sauvegarder"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Zone Confirmation Modal */}
{confirmZone && (
<div className="modal-overlay" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}>
<div className="modal" style={{maxWidth:440}} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}> Supprimer la zone</div>
<button className="modal-close" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}></button>
</div>
<div className="modal-body">
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
Cette action supprimera <strong style={{color:"var(--text-0)"}}>définitivement</strong> la zone
et tous ses enregistrements. Elle est <strong style={{color:"var(--red)"}}>irréversible</strong>.
</p>
<div style={{
margin:"14px 0 6px",padding:"10px 14px",
background:"var(--bg-0)",border:"1px solid var(--border)",borderRadius:6,
fontFamily:"var(--font-mono)",fontSize:13,color:"var(--accent)",
}}>
{confirmZone}
</div>
<div className="form-group" style={{marginTop:14}}>
<label className="form-label" style={{color:"var(--text-1)"}}>
Pour confirmer, recopiez le nom de la zone ci-dessous&nbsp;:
</label>
<input
className="form-input"
placeholder={confirmZone}
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && confirmInput.trim() === confirmZone && deleteZone(confirmZone)}
autoFocus
style={{
borderColor: confirmInput.length > 0
? confirmInput.trim() === confirmZone ? "var(--red)" : "var(--border)"
: "var(--border)"
}}
/>
{confirmInput.length > 0 && confirmInput.trim() !== confirmZone && (
<div style={{fontSize:11,color:"var(--text-3)",marginTop:4}}>
Le nom ne correspond pas encore
</div>
)}
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => { setConfirmZone(null); setConfirmInput(""); }}>
Annuler
</button>
<button
className="btn btn-danger"
onClick={() => deleteZone(confirmZone)}
disabled={confirmInput.trim() !== confirmZone}
style={{opacity: confirmInput.trim() === confirmZone ? 1 : 0.4, cursor: confirmInput.trim() === confirmZone ? "pointer" : "not-allowed"}}
>
Supprimer définitivement
</button>
</div>
</div>
</div>
)}
{/* Record Toggle Confirmation */}
{confirmToggle && (
<div className="modal-overlay" onClick={() => setConfirmToggle(null)}>
<div className="modal" style={{maxWidth:420}} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color: confirmToggle.active === false ? "var(--green)" : "var(--yellow)"}}>
{confirmToggle.active === false ? "Activer l'enregistrement" : "Désactiver l'enregistrement"}
</div>
<button className="modal-close" onClick={() => setConfirmToggle(null)}></button>
</div>
<div className="modal-body">
<div style={{
background:"var(--bg-0)",border:"1px solid var(--border)",
borderRadius:6,padding:"10px 14px",fontFamily:"var(--font-mono)",
fontSize:12,marginBottom:12,
}}>
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:6}}>
<span className={`badge badge-${(confirmToggle.type||"").toLowerCase()}`}>
{confirmToggle.type}
</span>
<span style={{color:"var(--text-0)",fontWeight:500}}>{confirmToggle.name || "@"}</span>
<span style={{color:"var(--text-2)"}}>TTL {confirmToggle.ttl ?? "—"}s</span>
</div>
<div style={{color:"var(--text-1)",wordBreak:"break-all"}}>{confirmToggle.value}</div>
</div>
{confirmToggle.active === false ? (
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.6}}>
Cet enregistrement sera <strong style={{color:"var(--green)"}}>réactivé</strong> et
appliqué sur le serveur BIND.
</p>
) : (
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.6}}>
Cet enregistrement sera <strong style={{color:"var(--yellow)"}}>désactivé</strong> et
mis en commentaire dans le fichier de zone{" "}
<span style={{fontFamily:"var(--font-mono)",fontSize:11,color:"var(--text-2)"}}>
(; [DISABLED] )
</span>
. Il ne sera plus résolu par BIND.
</p>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setConfirmToggle(null)}>Annuler</button>
<button
className="btn"
style={{
background: confirmToggle.active === false ? "var(--green)" : "var(--yellow)",
color: "var(--bg-0)", fontWeight: 600,
}}
onClick={doToggleRecord}
>
{confirmToggle.active === false ? "● Activer" : "○ Désactiver"}
</button>
</div>
</div>
</div>
)}
{/* Record Delete Confirmation */}
{confirmRec && (
<div className="modal-overlay" onClick={() => setConfirmRec(null)}>
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'enregistrement</div>
<button className="modal-close" onClick={() => setConfirmRec(null)}></button>
</div>
<div className="modal-body">
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7,marginBottom:12}}>
Supprimer définitivement cet enregistrement ?
</p>
<div style={{
background:"var(--bg-0)",border:"1px solid var(--border)",
borderRadius:6,padding:"10px 14px",fontFamily:"var(--font-mono)",fontSize:12,
}}>
<div style={{display:"flex",gap:10,alignItems:"center",flexWrap:"wrap"}}>
<span className={`badge badge-${confirmRec.type.toLowerCase()}`} style={{textTransform:"uppercase"}}>
{confirmRec.type}
</span>
<span style={{color:"var(--text-0)",fontWeight:500}}>{confirmRec.name||"@"}</span>
<span style={{color:"var(--text-2)"}}>TTL {confirmRec.ttl}s</span>
</div>
<div style={{color:"var(--text-1)",marginTop:6,wordBreak:"break-all"}}>
{confirmRec.priority != null && <span style={{color:"var(--yellow)",marginRight:8}}>prio {confirmRec.priority}</span>}
{confirmRec.value}
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setConfirmRec(null)}>Annuler</button>
<button className="btn btn-danger" onClick={doDeleteRecord}>Supprimer</button>
</div>
</div>
</div>
)}
{/* Zone Config Modal */}
{showZoneConfig && (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowZoneConfig(false)}>
<div className="modal" style={{maxWidth:460}}>
<div className="modal-header">
<div className="modal-title">
<span style={{color:"var(--accent)"}}></span> Configuration {activeZone}
</div>
<button className="modal-close" onClick={() => setShowZoneConfig(false)}></button>
</div>
<div className="modal-body">
{zoneConfigError && <div className="alert alert-error" style={{marginBottom:0}}> {zoneConfigError}</div>}
<div className="form-row">
<div className="form-group">
<label className="form-label">TTL par défaut (s)</label>
<input className="form-input" type="number" min="1"
value={zoneConfig.ttl}
onChange={e => setZoneConfig({...zoneConfig, ttl: +e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Email admin SOA</label>
<input className="form-input" placeholder="hostmaster.example.com"
value={zoneConfig.admin}
onChange={e => setZoneConfig({...zoneConfig, admin: e.target.value.replace("@", ".")})} />
<div style={{fontSize:10, color:"var(--text-3)", marginTop:2}}>Sans arobase (@.)</div>
</div>
</div>
<div style={{margin:"10px 0 6px",fontSize:10,color:"var(--text-2)",
letterSpacing:"0.08em",textTransform:"uppercase"}}>Paramètres SOA</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Refresh (s)</label>
<input className="form-input" type="number" min="1"
value={zoneConfig.refresh}
onChange={e => setZoneConfig({...zoneConfig, refresh: +e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Retry (s)</label>
<input className="form-input" type="number" min="1"
value={zoneConfig.retry}
onChange={e => setZoneConfig({...zoneConfig, retry: +e.target.value})} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Expire (s)</label>
<input className="form-input" type="number" min="1"
value={zoneConfig.expire}
onChange={e => setZoneConfig({...zoneConfig, expire: +e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Negative TTL (s)</label>
<input className="form-input" type="number" min="1"
value={zoneConfig.negative_ttl}
onChange={e => setZoneConfig({...zoneConfig, negative_ttl: +e.target.value})} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowZoneConfig(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveZoneConfig} disabled={zoneConfigSaving}>
{zoneConfigSaving ? "Sauvegarde…" : "Enregistrer"}
</button>
</div>
</div>
</div>
)}
</>
);
}
+411
View File
@@ -0,0 +1,411 @@
import { useState, useEffect, useCallback } from "react";
const EMPTY_FORM = {
name: "", has_dns: false, has_mail: false,
dns_ttl: 3600, dns_admin: "hostmaster",
dns_refresh: 43200, dns_retry: 3600, dns_expire: 2419200, dns_negative_ttl: 3600,
max_accounts: 0, max_quota_mb: 0, max_lists: 0,
};
function LimitInput({ label, value, onChange, hint }) {
return (
<div className="form-group">
<label className="form-label">{label}</label>
<input className="form-input" type="number" min="0" value={value}
onChange={e => onChange(+e.target.value)} />
{hint && <div style={{fontSize:10,color:"var(--text-3)",marginTop:3}}>{hint}</div>}
</div>
);
}
function UsageBar({ used, max, label }) {
if (max === 0) return <span style={{fontSize:11,color:"var(--text-3)"}}>Illimité</span>;
const pct = Math.min(100, Math.round((used / max) * 100));
const col = pct > 80 ? "var(--red)" : pct > 60 ? "var(--yellow)" : "var(--green)";
return (
<div style={{minWidth:100}}>
<div style={{height:4,background:"var(--bg-3)",borderRadius:2,overflow:"hidden",marginBottom:3}}>
<div style={{height:"100%",width:`${pct}%`,background:col,borderRadius:2,transition:"width 0.3s"}}/>
</div>
<div style={{fontSize:10,color:"var(--text-2)"}}>{label}: {used}/{max}</div>
</div>
);
}
function ServiceSection({ title, active, onToggle, color, children }) {
return (
<div style={{border:`1px solid ${active ? color+"44":"var(--border)"}`,borderRadius:6,overflow:"hidden",marginBottom:8}}>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
padding:"10px 14px",background:active?color+"11":"var(--bg-2)",cursor:"pointer"}}
onClick={onToggle}>
<div style={{fontSize:12,fontWeight:600,color:active?color:"var(--text-2)"}}>{title}</div>
<div style={{width:36,height:18,borderRadius:9,background:active?color:"var(--bg-3)",
position:"relative",transition:"background 0.2s",border:`1px solid ${active?color:"var(--border)"}`}}>
<div style={{position:"absolute",top:2,left:active?18:2,width:12,height:12,
borderRadius:"50%",background:active?"var(--bg-0)":"var(--text-3)",transition:"left 0.2s"}}/>
</div>
</div>
{active && <div style={{padding:"12px 14px",borderTop:`1px solid ${color}22`}}>{children}</div>}
</div>
);
}
function DnsFields({ v, set }) {
return (
<>
<div className="form-row">
<div className="form-group">
<label className="form-label">TTL par défaut (s)</label>
<input className="form-input" type="number" min="1" value={v.dns_ttl}
onChange={e => set({...v, dns_ttl:+e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Email admin SOA</label>
<input className="form-input" placeholder="hostmaster.example.com"
value={v.dns_admin}
onChange={e => set({...v, dns_admin:e.target.value.replace("@",".")})} />
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>Sans arobase (@.)</div>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Refresh (s)</label>
<input className="form-input" type="number" min="1" value={v.dns_refresh}
onChange={e => set({...v, dns_refresh:+e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Retry (s)</label>
<input className="form-input" type="number" min="1" value={v.dns_retry}
onChange={e => set({...v, dns_retry:+e.target.value})} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Expire (s)</label>
<input className="form-input" type="number" min="1" value={v.dns_expire}
onChange={e => set({...v, dns_expire:+e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Negative TTL (s)</label>
<input className="form-input" type="number" min="1" value={v.dns_negative_ttl}
onChange={e => set({...v, dns_negative_ttl:+e.target.value})} />
</div>
</div>
</>
);
}
function MailFields({ v, set }) {
return (
<>
<div style={{fontSize:11,color:"var(--text-2)",marginBottom:8}}>0 = illimité</div>
<div className="form-row">
<LimitInput label="Max comptes" value={v.max_accounts} onChange={x => set({...v,max_accounts:x})} />
<LimitInput label="Quota global (Mo)" value={v.max_quota_mb} onChange={x => set({...v,max_quota_mb:x})} />
</div>
<LimitInput label="Max listes de diffusion" value={v.max_lists} onChange={x => set({...v,max_lists:x})} />
</>
);
}
export default function DomainsManager({ apiBase, user, token, onUnauthorized }) {
const [domains, setDomains] = useState([]);
const [dnszones, setDnsZones] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [modalError, setModalError] = useState(null);
const [showEdit, setShowEdit] = useState(false);
const [editName, setEditName] = useState(null);
const [editForm, setEditForm] = useState({});
const [confirmDel, setConfirmDel] = useState(null);
const [confirmInput, setConfirmInput] = useState("");
const isAdmin = user?.roles?.some(r => ["global_admin","domain_admin"].includes(r.role));
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."); }
if (!r.ok) {
const e = await r.json().catch(() => ({detail:r.statusText}));
throw new Error(e.detail || r.statusText);
}
if (r.status === 204) return null;
return r.json();
}, [apiBase, token]);
const load = useCallback(() => {
setLoading(true);
Promise.all([api("/mail/domains-config"), api("/dns/zones")])
.then(([mdoms, dzones]) => { setDomains(mdoms); setDnsZones(dzones); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [api]);
useEffect(() => { load(); }, [load]);
const createDomain = async () => {
const name = form.name.trim();
if (!name) { setModalError("Nom de domaine requis"); return; }
if (!form.has_dns && !form.has_mail) { setModalError("Activez au moins un service"); return; }
setSaving(true); setModalError(null);
try {
if (form.has_mail) {
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, {
method:"POST",
body:JSON.stringify({has_mail:true,max_accounts:form.max_accounts,
max_quota_mb:form.max_quota_mb,max_lists:form.max_lists}),
});
}
if (form.has_dns) {
await api("/dns/zones", {
method:"POST",
body:JSON.stringify({name, admin:form.dns_admin, ttl:form.dns_ttl,
refresh:form.dns_refresh, retry:form.dns_retry,
expire:form.dns_expire, negative_ttl:form.dns_negative_ttl}),
});
}
setShowCreate(false); setForm(EMPTY_FORM); load();
} catch(e) { setModalError(e.message); }
finally { setSaving(false); }
};
const openEdit = async (name) => {
setEditName(name); setModalError(null); setSaving(true);
const md = domains.find(d => d.name === name);
const dz = dnszones.find(z => z.name === name);
const ef = {
has_mail: !!md, has_dns: !!dz,
max_accounts: md?.max_accounts ?? 0,
max_quota_mb: md?.max_quota_mb ?? 0,
max_lists: md?.max_lists ?? 0,
dns_ttl: 3600, dns_admin: "hostmaster",
dns_refresh: 43200, dns_retry: 3600, dns_expire: 2419200, dns_negative_ttl: 3600,
};
try {
if (dz) {
const cfg = await api(`/dns/zones/${name}/config`);
ef.dns_ttl = cfg.ttl;
ef.dns_admin = cfg.admin;
}
setEditForm(ef);
setShowEdit(true);
} catch(e) {
setError(e.message);
} finally {
setSaving(false);
}
};
const saveEdit = async () => {
setSaving(true); setModalError(null);
const name = editName;
const md = domains.find(d => d.name === name);
const dz = dnszones.find(z => z.name === name);
try {
// Mail
if (editForm.has_mail && !md) {
await api(`/mail/domains-config?name=${encodeURIComponent(name)}`, {method:"POST",
body:JSON.stringify({has_mail:true,max_accounts:editForm.max_accounts,
max_quota_mb:editForm.max_quota_mb,max_lists:editForm.max_lists})});
} else if (editForm.has_mail && md) {
await api(`/mail/domains-config/${encodeURIComponent(name)}`, {method:"PUT",
body:JSON.stringify({has_mail:true,max_accounts:editForm.max_accounts,
max_quota_mb:editForm.max_quota_mb,max_lists:editForm.max_lists})});
} else if (!editForm.has_mail && md) {
await api(`/mail/domains/${encodeURIComponent(name)}`, {method:"DELETE"});
}
// DNS
if (editForm.has_dns && !dz) {
await api("/dns/zones", {method:"POST",
body:JSON.stringify({name, admin:editForm.dns_admin, ttl:editForm.dns_ttl,
refresh:editForm.dns_refresh, retry:editForm.dns_retry,
expire:editForm.dns_expire, negative_ttl:editForm.dns_negative_ttl})});
} else if (editForm.has_dns && dz) {
await api(`/dns/zones/${name}/config`, {method:"PUT",
body:JSON.stringify({admin:editForm.dns_admin, ttl:editForm.dns_ttl,
refresh:editForm.dns_refresh, retry:editForm.dns_retry,
expire:editForm.dns_expire, negative_ttl:editForm.dns_negative_ttl})});
} else if (!editForm.has_dns && dz) {
await api(`/dns/zones/${name}`, {method:"DELETE"});
}
setShowEdit(false); load();
} catch(e) { setModalError(e.message); }
finally { setSaving(false); }
};
const deleteDomain = async (name) => {
if (confirmDel !== name) { setConfirmDel(name); setConfirmInput(""); return; }
if (confirmInput.trim() !== name) return;
setSaving(true);
try {
const md = domains.find(d => d.name === name);
const dz = dnszones.find(z => z.name === name);
if (md) await api(`/mail/domains/${encodeURIComponent(name)}`, {method:"DELETE"});
if (dz) await api(`/dns/zones/${name}`, {method:"DELETE"});
setConfirmDel(null); setConfirmInput(""); load();
} catch(e) { setError(e.message); }
finally { setSaving(false); }
};
const allNames = [...new Set([...domains.map(d=>d.name),...dnszones.map(z=>z.name)])].sort();
return (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Domaines</div>
<div className="page-sub">Gestion des domaines et de leurs services</div>
</div>
{error && <div className="alert alert-error"> {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}></button></div>}
{isAdmin && <div style={{display:"flex",justifyContent:"flex-end",marginBottom:12}}>
<button className="btn btn-primary" onClick={()=>{setForm(EMPTY_FORM);setModalError(null);setShowCreate(true);}}>+ Nouveau domaine</button>
</div>}
<div className="card">
<div className="card-header"><div className="card-title"> Domaines ({allNames.length})</div></div>
<div className="card-body">
{loading ? <div className="loading"><div className="spinner"/> Chargement</div>
: allNames.length === 0 ? <div className="empty-state"><div className="empty-icon"></div><div className="empty-text">Aucun domaine configuré</div></div>
: (
<div className="table-wrap">
<table>
<thead><tr>
<th>Domaine</th><th>Services</th><th>DNS</th>
<th>Comptes mail</th><th>Quota global</th><th>Listes</th>
{isAdmin && <th></th>}
</tr></thead>
<tbody>
{allNames.map(name => {
const md = domains.find(d => d.name === name);
const dz = dnszones.find(z => z.name === name);
return (
<tr key={name}>
<td className="cell-mono" style={{fontWeight:500,color:"var(--text-0)"}}>{name}</td>
<td><div style={{display:"flex",gap:4}}>
{dz && <span className="badge badge-a">DNS</span>}
{md && <span className="badge badge-mx">MAIL</span>}
{!dz && !md && <span style={{color:"var(--text-3)",fontSize:11}}></span>}
</div></td>
<td className="cell-dim">{dz?`${dz.record_count} enreg.`:"—"}</td>
<td>{md?<UsageBar used={md.account_count} max={md.max_accounts} label="comptes"/>:<span style={{color:"var(--text-3)",fontSize:11}}></span>}</td>
<td>{md?<UsageBar used={md.used_quota_mb} max={md.max_quota_mb} label="Mo"/>:<span style={{color:"var(--text-3)",fontSize:11}}></span>}</td>
<td>{md?<UsageBar used={md.list_count} max={md.max_lists} label="listes"/>:<span style={{color:"var(--text-3)",fontSize:11}}></span>}</td>
{isAdmin && <td><div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={()=>openEdit(name)} title="Configurer"></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={()=>deleteDomain(name)} title="Supprimer"></button>
</div></td>}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Create modal */}
{showCreate && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowCreate(false)}>
<div className="modal" style={{width:540}}>
<div className="modal-header">
<div className="modal-title">Nouveau domaine</div>
<button className="modal-close" onClick={()=>setShowCreate(false)}></button>
</div>
<div className="modal-body">
{modalError && <div className="alert alert-error" style={{marginBottom:0}}> {modalError}</div>}
<div className="form-group">
<label className="form-label">Nom du domaine</label>
<input className="form-input" placeholder="example.com" value={form.name}
onChange={e=>setForm({...form,name:e.target.value})} autoFocus />
</div>
<div style={{margin:"14px 0 6px",fontSize:11,color:"var(--text-2)",letterSpacing:"0.08em",textTransform:"uppercase"}}>Services</div>
<ServiceSection title="◈ Zone DNS" active={form.has_dns} color="var(--accent)"
onToggle={()=>setForm({...form,has_dns:!form.has_dns})}>
<DnsFields v={form} set={setForm}/>
</ServiceSection>
<ServiceSection title="◉ Mail" active={form.has_mail} color="var(--yellow)"
onToggle={()=>setForm({...form,has_mail:!form.has_mail})}>
<MailFields v={form} set={setForm}/>
</ServiceSection>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setShowCreate(false)}>Annuler</button>
<button className="btn btn-primary" onClick={createDomain} disabled={saving}>{saving?"Création…":"Créer"}</button>
</div>
</div>
</div>
)}
{/* Edit modal */}
{showEdit && editName && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowEdit(false)}>
<div className="modal" style={{width:540}}>
<div className="modal-header">
<div className="modal-title"><span style={{color:"var(--accent)"}}></span> {editName}</div>
<button className="modal-close" onClick={()=>setShowEdit(false)}></button>
</div>
<div className="modal-body">
{modalError && <div className="alert alert-error" style={{marginBottom:0}}> {modalError}</div>}
<div style={{marginBottom:6,fontSize:11,color:"var(--text-2)",letterSpacing:"0.08em",textTransform:"uppercase"}}>Services actifs</div>
<ServiceSection title="◈ Zone DNS" active={editForm.has_dns} color="var(--accent)"
onToggle={()=>setEditForm({...editForm,has_dns:!editForm.has_dns})}>
<DnsFields v={editForm} set={setEditForm}/>
</ServiceSection>
<ServiceSection title="◉ Mail" active={editForm.has_mail} color="var(--yellow)"
onToggle={()=>setEditForm({...editForm,has_mail:!editForm.has_mail})}>
<MailFields v={editForm} set={setEditForm}/>
</ServiceSection>
{(!editForm.has_dns||!editForm.has_mail)&&(
<div style={{fontSize:11,color:"var(--yellow)",marginTop:8,
padding:"6px 10px",background:"var(--yellow-dim)",borderRadius:4}}>
Désactiver un service supprimera ses données.
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setShowEdit(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveEdit} disabled={saving}>{saving?"Sauvegarde…":"Enregistrer"}</button>
</div>
</div>
</div>
)}
{/* Delete confirmation */}
{confirmDel && (
<div className="modal-overlay" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}>
<div className="modal" style={{maxWidth:440}} onClick={e=>e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}> Supprimer le domaine</div>
<button className="modal-close" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}></button>
</div>
<div className="modal-body">
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
Supprime <strong>tous les services</strong> (DNS + mail) de{" "}
<strong style={{color:"var(--accent)"}}>{confirmDel}</strong>. Action irréversible.
</p>
<div className="form-group" style={{marginTop:14}}>
<label className="form-label">Recopiez le nom du domaine :</label>
<input className="form-input" placeholder={confirmDel} value={confirmInput}
onChange={e=>setConfirmInput(e.target.value)} autoFocus/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>{setConfirmDel(null);setConfirmInput("");}}>Annuler</button>
<button className="btn btn-danger" onClick={()=>deleteDomain(confirmDel)}
disabled={confirmInput.trim()!==confirmDel}
style={{opacity:confirmInput.trim()===confirmDel?1:0.4}}>
Supprimer définitivement
</button>
</div>
</div>
</div>
)}
</>
);
}
+440
View File
@@ -0,0 +1,440 @@
import { useState, useEffect, useCallback } from "react";
// PKCE helpers
function base64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
async function generatePKCE() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return { verifier, challenge: base64url(digest) };
}
function buildAuthUrl(cfg, challenge, state) {
const params = new URLSearchParams({
response_type: "code",
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
scope: cfg.scopes || "openid email profile",
state,
...(cfg.use_pkce ? { code_challenge: challenge, code_challenge_method: "S256" } : {}),
});
return `${cfg.authorization_endpoint}?${params}`;
}
// TOTP verify step (TOTP already configured)
function TotpVerifyStep({ apiBase, pendingToken, onSuccess, onBack }) {
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const submit = async () => {
if (code.length !== 6) return;
setLoading(true); setError(null);
try {
const r = await fetch(`${apiBase}/auth/totp/validate-login`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${pendingToken}` },
body: JSON.stringify({ code }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || "Code invalide");
// validation succeeded give token + user to parent
onSuccess(data.token, data.user);
} catch(e) { setError(e.message); }
finally { setLoading(false); }
};
return (
<div style={OVERLAY_STYLE}>
<div style={CARD_STYLE}>
<div style={BRAND_STYLE}>
<div style={{fontSize:40}}>🔐</div>
<div style={{fontFamily:"var(--font-display)", fontSize:20, fontWeight:800, marginTop:8}}>
Vérification 2FA
</div>
<div style={{fontSize:12, color:"var(--text-2)", marginTop:4}}>
Saisissez le code affiché par votre application d'authentification
</div>
</div>
{error && <Alert msg={error} />}
<input
className="form-input"
placeholder="123456"
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
onKeyDown={e => e.key === "Enter" && code.length === 6 && submit()}
style={{ textAlign:"center", fontFamily:"var(--font-mono)", fontSize:24,
letterSpacing:"0.25em", marginBottom:16 }}
maxLength={6}
autoFocus
/>
<button className="btn btn-primary"
onClick={submit}
disabled={loading || code.length !== 6}
style={FULL_BTN}>
{loading ? <Spinner /> : "Vérifier"}
</button>
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
Retour à la connexion
</button>
</div>
</div>
);
}
// TOTP setup step (TOTP required but not yet configured)
function TotpSetupStep({ apiBase, pendingToken, onSuccess, onBack }) {
const [step, setStep] = useState("qr"); // qr | verify
const [qrData, setQrData] = useState(null);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`${apiBase}/auth/totp/setup`, {
method: "POST",
headers: { "Authorization": `Bearer ${pendingToken}` },
})
.then(r => r.json())
.then(d => setQrData(d))
.catch(e => setError(e.message));
}, []); // eslint-disable-line
const verify = async () => {
if (code.length !== 6) return;
setLoading(true); setError(null);
try {
// Step 1: activate TOTP with the code
const r1 = await fetch(`${apiBase}/auth/totp/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${pendingToken}` },
body: JSON.stringify({ code }),
});
const d1 = await r1.json();
if (!r1.ok) throw new Error(d1.detail || "Code invalide — vérifiez l'heure de votre appareil");
// Step 2: upgrade session (TOTP is now active, no need to re-enter code)
const r2 = await fetch(`${apiBase}/auth/totp/session-upgrade`, {
method: "POST",
headers: { "Authorization": `Bearer ${pendingToken}` },
});
const d2 = await r2.json();
if (!r2.ok) throw new Error(d2.detail || "Erreur de session");
onSuccess(d2.token, d2.user);
} catch(e) { setError(e.message); }
finally { setLoading(false); }
};
return (
<div style={OVERLAY_STYLE}>
<div style={{...CARD_STYLE, maxWidth:420}}>
<div style={BRAND_STYLE}>
<div style={{fontSize:36}}>🔐</div>
<div style={{fontFamily:"var(--font-display)", fontSize:18, fontWeight:800, marginTop:8}}>
Configuration requise
</div>
<div style={{fontSize:12, color:"var(--yellow)", marginTop:4}}>
Votre administrateur exige la double authentification
</div>
</div>
{error && <Alert msg={error} />}
{!qrData && !error && (
<div style={{textAlign:"center", padding:"20px 0"}}>
<div className="spinner" style={{margin:"0 auto"}} />
</div>
)}
{qrData && (
<>
<p style={{fontSize:12, color:"var(--text-1)", lineHeight:1.7, marginBottom:12}}>
<strong>Étape 1</strong> Scannez ce QR code avec votre application
(Google Authenticator, Aegis, Authy)
</p>
<div style={{textAlign:"center", margin:"0 0 12px"}}>
<img
src={`data:image/png;base64,${qrData.qr_b64}`}
alt="QR TOTP"
style={{width:180, height:180, imageRendering:"pixelated",
border:"4px solid white", borderRadius:4}}
/>
</div>
<details style={{marginBottom:14}}>
<summary style={{fontSize:11, color:"var(--text-3)", cursor:"pointer"}}>
Afficher la clé secrète
</summary>
<div style={{fontFamily:"var(--font-mono)", fontSize:12, letterSpacing:"0.1em",
padding:"6px 10px", background:"var(--bg-0)", borderRadius:4,
marginTop:6, wordBreak:"break-all", color:"var(--accent)"}}>
{qrData.secret}
</div>
</details>
<p style={{fontSize:12, color:"var(--text-1)", lineHeight:1.7, marginBottom:8}}>
<strong>Étape 2</strong> Saisissez le code affiché pour confirmer.
</p>
<input
className="form-input"
placeholder="123456"
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
onKeyDown={e => e.key === "Enter" && code.length === 6 && verify()}
style={{ textAlign:"center", fontFamily:"var(--font-mono)", fontSize:20,
letterSpacing:"0.2em", marginBottom:12 }}
maxLength={6}
autoFocus
/>
<button className="btn btn-primary"
onClick={verify}
disabled={loading || code.length !== 6}
style={FULL_BTN}>
{loading ? <Spinner /> : "Activer et continuer"}
</button>
</>
)}
<button className="btn btn-ghost btn-sm" style={{marginTop:10, width:"100%"}} onClick={onBack}>
Retour à la connexion
</button>
</div>
</div>
);
}
// Shared UI helpers
const OVERLAY_STYLE = {
minHeight:"100vh", background:"var(--bg-0)",
display:"flex", alignItems:"center", justifyContent:"center",
};
const CARD_STYLE = {
width:380, maxWidth:"94vw",
background:"var(--bg-1)", border:"1px solid var(--border)",
borderRadius:12, padding:"36px 32px",
boxShadow:"0 24px 80px rgba(0,0,0,0.5)",
};
const BRAND_STYLE = { textAlign:"center", marginBottom:24 };
const FULL_BTN = { width:"100%", justifyContent:"center", padding:"10px" };
function Alert({ msg }) {
return (
<div className="alert alert-error" style={{marginBottom:14}}> {msg}</div>
);
}
function Spinner() {
return <span className="spinner" style={{width:14, height:14, borderWidth:2}} />;
}
// Main login page
export default function LoginPage({ apiBase, onLogin }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [showPw, setShowPw] = useState(false);
const [oidcCfg, setOidcCfg] = useState(null);
const [oidcLoading, setOidcLoading] = useState(false);
// TOTP pending state set when backend returns totp_pending=true
// The token is stored here temporarily, NOT in localStorage
const [totpState, setTotpState] = useState(null); // null | "verify" | "setup"
const [pendingToken, setPendingToken] = useState(null);
const resetTotp = () => { setTotpState(null); setPendingToken(null); };
// Called by TotpVerifyStep or TotpSetupStep when complete
const onTotpSuccess = useCallback((token, user) => {
onLogin(token, user);
}, [onLogin]);
// Fetch OIDC config on mount
useEffect(() => {
fetch(`${apiBase}/auth/config`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d) setOidcCfg(d);
// If no response, keep null so we don't show/hide incorrectly
})
.catch(() => {
// Network error keep null, OIDC section stays hidden
setOidcCfg({ enabled: false });
});
}, [apiBase]);
// Handle Keycloak redirect-back
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (!code) return;
const savedState = sessionStorage.getItem("oidc_state");
const codeVerifier = sessionStorage.getItem("oidc_verifier");
const savedRedirect = sessionStorage.getItem("oidc_redirect_uri");
sessionStorage.removeItem("oidc_state");
sessionStorage.removeItem("oidc_verifier");
sessionStorage.removeItem("oidc_redirect_uri");
window.history.replaceState({}, "", window.location.pathname);
if (state !== savedState) {
setError("Erreur OIDC : state invalide. Réessayez.");
return;
}
setLoading(true); setError(null);
fetch(`${apiBase}/auth/oidc/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, redirect_uri: savedRedirect, code_verifier: codeVerifier }),
})
.then(r => r.json().then(d => ({ ok: r.ok, data: d })))
.then(({ ok, data }) => {
if (!ok) throw new Error(data.detail || "Erreur SSO");
handleLoginResponse(data);
})
.catch(e => { setError(e.message); setLoading(false); });
}, []); // eslint-disable-line
// Common handler for both local and OIDC login responses
const handleLoginResponse = useCallback((data) => {
if (data.totp_pending) {
setPendingToken(data.token);
// totp_setup_required = must configure TOTP first
// totp_pending alone = must verify existing TOTP
setTotpState(data.totp_setup_required ? "setup" : "verify");
setLoading(false);
return;
}
// No TOTP needed login complete
onLogin(data.token, data.user);
setLoading(false);
}, [onLogin]);
// Local login
const submit = async () => {
if (!username || !password) { setError("Identifiants requis"); return; }
setLoading(true); setError(null);
try {
const r = await fetch(`${apiBase}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || "Identifiant ou mot de passe incorrect");
handleLoginResponse(data);
} catch(e) {
setError(e.message);
setLoading(false);
}
};
// Keycloak SSO
const startOidc = useCallback(async () => {
if (!oidcCfg?.authorization_endpoint) return;
setOidcLoading(true);
try {
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem("oidc_state", state);
sessionStorage.setItem("oidc_verifier", verifier);
sessionStorage.setItem("oidc_redirect_uri", oidcCfg.redirect_uri);
window.location.href = buildAuthUrl(oidcCfg, challenge, state);
} catch(e) {
setError("Erreur SSO : " + e.message);
setOidcLoading(false);
}
}, [oidcCfg]);
// TOTP steps (before login completes)
if (totpState === "verify" && pendingToken) {
return (
<TotpVerifyStep
apiBase={apiBase}
pendingToken={pendingToken}
onSuccess={onTotpSuccess}
onBack={resetTotp}
/>
);
}
if (totpState === "setup" && pendingToken) {
return (
<TotpSetupStep
apiBase={apiBase}
pendingToken={pendingToken}
onSuccess={onTotpSuccess}
onBack={resetTotp}
/>
);
}
// Normal login form
return (
<div style={OVERLAY_STYLE}>
<div style={CARD_STYLE}>
{/* Brand */}
<div style={BRAND_STYLE}>
<div style={{fontSize:40, color:"var(--accent)", filter:"drop-shadow(0 0 12px var(--accent-glow))"}}></div>
<div style={{fontFamily:"var(--font-display)", fontSize:22, fontWeight:800, marginTop:8}}>NetAdmin</div>
<div style={{fontSize:11, color:"var(--text-2)", letterSpacing:"0.1em", textTransform:"uppercase", marginTop:4}}>
DNS & Mail Console
</div>
</div>
{error && <Alert msg={error} />}
{/* Keycloak SSO */}
{oidcCfg?.enabled && (
<>
<button className="btn btn-primary" onClick={startOidc}
disabled={oidcLoading || !oidcCfg.authorization_endpoint}
style={{...FULL_BTN, marginBottom:8}}>
{oidcLoading ? <><Spinner /> Redirection</> : "🔐 Se connecter via Keycloak"}
</button>
<div style={{display:"flex", alignItems:"center", gap:10, margin:"14px 0",
color:"var(--text-3)", fontSize:11}}>
<div style={{flex:1, height:1, background:"var(--border)"}} />
ou connexion locale
<div style={{flex:1, height:1, background:"var(--border)"}} />
</div>
</>
)}
{/* Local form */}
<div className="form-group" style={{marginBottom:12}}>
<label className="form-label">Identifiant</label>
<input className="form-input" placeholder="Identifiant"
value={username} onChange={e => setUsername(e.target.value)}
onKeyDown={e => e.key === "Enter" && submit()}
autoFocus />
</div>
<div className="form-group" style={{marginBottom:22}}>
<label className="form-label">Mot de passe</label>
<div style={{position:"relative"}}>
<input className="form-input"
type={showPw ? "text" : "password"}
placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)}
onKeyDown={e => e.key === "Enter" && submit()}
style={{paddingRight:38}} />
<button onClick={() => setShowPw(!showPw)} style={{
position:"absolute", right:8, top:"50%", transform:"translateY(-50%)",
background:"none", border:"none", color:"var(--text-2)", cursor:"pointer", fontSize:14,
}}>
{showPw ? "🙈" : "👁"}
</button>
</div>
</div>
<button className="btn btn-secondary" onClick={submit} disabled={loading} style={FULL_BTN}>
{loading ? <><Spinner /> Connexion</> : "Se connecter"}
</button>
</div>
</div>
);
}
+726
View File
@@ -0,0 +1,726 @@
import { useState, useEffect, useCallback } from "react";
import SyncToast from "./SyncToast";
const EMPTY_ACC = { username:"", domain:"", password:"", quota:1024, active:true };
const EMPTY_ALIAS = { source:"", destination:"", domain:"" };
function QuotaBar({ used, total }) {
if (!total) return <span style={{fontSize:11,color:"var(--text-3)"}}>Illimité</span>;
const pct = Math.min(100, Math.round((used / total) * 100));
const col = pct > 80 ? "var(--red)" : pct > 60 ? "var(--yellow)" : "var(--green)";
return (
<div style={{minWidth:110}}>
<div style={{height:4,background:"var(--bg-3)",borderRadius:2,overflow:"hidden",marginBottom:3}}>
<div style={{height:"100%",width:`${pct}%`,background:col,borderRadius:2,transition:"width 0.3s"}}/>
</div>
<div style={{fontSize:10,color:"var(--text-2)"}}>{used} / {total} Mo ({pct}%)</div>
</div>
);
}
function PendingBanner({ pending, onRetry, onDismiss, isGlobalAdmin }) {
if (!pending.length) return null;
return (
<div style={{
background:"var(--yellow-dim)", border:"1px solid rgba(255,200,0,0.3)",
borderRadius:6, padding:"10px 14px", marginBottom:12,
}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:6}}>
<span style={{fontSize:13,color:"var(--yellow)",fontWeight:600}}>
{pending.length} action{pending.length>1?"s":""} en attente
</span>
<span style={{fontSize:11,color:"var(--text-2)"}}>
L'agent mail était injoignable au moment de l'opération
</span>
</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{pending.map(p => (
<div key={p.id} style={{
display:"flex",alignItems:"center",gap:8,
background:"var(--bg-1)",borderRadius:4,padding:"6px 10px",
fontSize:12,
}}>
<span style={{
padding:"1px 6px",borderRadius:3,fontSize:10,fontWeight:600,
background: p.status==="failed"?"var(--red-dim)":"var(--yellow-dim)",
color: p.status==="failed"?"var(--red)":"var(--yellow)",
}}>{p.status}</span>
<span style={{color:"var(--text-1)",fontFamily:"var(--font-mono)",flex:1}}>
{p.action}
</span>
<span style={{color:"var(--text-3)",fontSize:10}}>
{new Date(p.created_at).toLocaleString("fr-FR")}
</span>
{isGlobalAdmin && <>
<button className="btn btn-ghost btn-sm" style={{fontSize:11}}
onClick={()=>onRetry(p.id)} title="Relancer"> Réessayer</button>
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
onClick={()=>onDismiss(p.id)} title="Ignorer"></button>
</>}
</div>
))}
</div>
</div>
);
}
export default function MailManager({ apiBase, token, user, onUnauthorized }) {
const [accounts, setAccounts] = useState([]);
const [domains, setDomains] = useState([]);
const [aliases, setAliases] = useState([]);
const [pending, setPending] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [modalErr, setModalErr] = useState(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState(null);
const [syncSuccess, setSyncSuccess] = useState(true);
const [tab, setTab] = useState("accounts");
// Shared domain filter across tabs
const [selDomain, setSelDomain] = useState(null); // null = not yet set
// Accounts
const [accSearch, setAccSearch] = useState("");
const [showAccModal, setShowAccModal] = useState(false);
const [editAcc, setEditAcc] = useState(null);
const [accForm, setAccForm] = useState(EMPTY_ACC);
const [confirmDelAcc, setConfirmDelAcc] = useState(null);
// Aliases
const [aliasSearch, setAliasSearch] = useState("");
const [showAliasModal, setShowAliasModal] = useState(false);
const [editAlias, setEditAlias] = useState(null); // null = new
const [aliasForm, setAliasForm] = useState(EMPTY_ALIAS);
const [confirmDelAlias, setConfirmDelAlias] = useState(null);
const isGlobalAdmin = user?.roles?.some(r => r.role === "global_admin");
const isAdmin = user?.roles?.some(r =>
["global_admin","mail_admin","domain_admin","mail_domain_admin"].includes(r.role)
);
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."); }
if (r.status === 204) return null;
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(() => {
setLoading(true);
Promise.all([
api("/mail/accounts"),
api("/mail/domains"),
api("/mail/aliases"),
api("/mail/pending"),
])
.then(([accs, doms, als, pend]) => {
setAccounts(accs);
setAliases(als);
setPending(pend || []);
setDomains(doms);
// Set default domain to first one if not yet set
setSelDomain(prev => prev && doms.includes(prev) ? prev : (doms[0] || null));
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [api]);
useEffect(() => { load(); }, [load]);
const syncFromServer = async () => {
setSyncing(true); setSyncMsg(null);
try {
const r = await api("/mail/sync", { method:"POST" });
setSyncSuccess(true);
setSyncMsg(r.message);
load();
} catch(e) {
setSyncSuccess(false);
setSyncMsg(e.message);
} finally { setSyncing(false); }
};
const showMutationToast = (result) => {
if (result?.queued) {
setSyncSuccess(false);
setSyncMsg("Enregistré localement. L'agent mail est indisponible — sera appliqué dès sa reconnexion.");
} else if (result !== null && result !== undefined) {
setSyncSuccess(true);
setSyncMsg("Appliqué sur le serveur mail.");
}
};
const retryPending = async (id) => {
try {
await api(`/mail/pending/${id}/retry`, { method:"POST" });
load();
} catch(e) { setError(e.message); }
};
const dismissPending = async (id) => {
try {
await api(`/mail/pending/${id}`, { method:"DELETE" });
load();
} catch(e) { setError(e.message); }
};
const generatePassword = () => {
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*";
const pw = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map(b => chars[b % chars.length]).join("");
setAccForm(prev => ({...prev, password: pw}));
setShowGeneratedPw(true);
};
// Account CRUD
const openNewAcc = () => {
setEditAcc(null);
setAccForm({...EMPTY_ACC, domain: selDomain || ""});
setModalErr(null); setShowGeneratedPw(false); setShowAccModal(true);
};
const openEditAcc = (acc) => {
setEditAcc(acc);
setAccForm({username:acc.username, domain:acc.domain, password:"", quota:acc.quota, active:acc.active});
setModalErr(null); setShowGeneratedPw(false); setShowAccModal(true);
};
const saveAcc = async () => {
if (!accForm.domain) { setModalErr("Sélectionnez un domaine"); return; }
setSaving(true); setModalErr(null);
try {
let result;
if (editAcc) {
result = await api(`/mail/accounts/${editAcc.id}`, {method:"PUT", body:JSON.stringify(accForm)});
} else {
result = await api("/mail/accounts", {method:"POST", body:JSON.stringify(accForm)});
}
setShowAccModal(false);
showMutationToast(result);
load();
} catch(e) { setModalErr(e.message); }
finally { setSaving(false); }
};
const deleteAcc = async () => {
if (!confirmDelAcc) return;
setSaving(true);
try {
const res = await api(`/mail/accounts/${confirmDelAcc.id}`, {method:"DELETE"});
setConfirmDelAcc(null);
showMutationToast(res);
load();
} catch(e) { setError(e.message); }
finally { setSaving(false); }
};
// Alias CRUD
// Get all aliases for same source (to populate edit form)
const getAliasGroup = (source, domain) =>
aliases.filter(a => a.source === source && a.domain === domain);
const openNewAlias = () => {
setEditAlias(null);
setAliasForm({source:"", destination:"", domain: selDomain || ""});
setModalErr(null); setShowAliasModal(true);
};
const openEditAlias = (alias) => {
// Gather all destinations for this source+domain into a textarea (one per line)
const group = getAliasGroup(alias.source, alias.domain);
const destinations = group.map(a => a.destination).join("\n");
setEditAlias(alias);
setAliasForm({source:alias.source, destination:destinations, domain:alias.domain});
setModalErr(null); setShowAliasModal(true);
};
const saveAlias = async () => {
if (!aliasForm.domain) { setModalErr("Sélectionnez un domaine"); return; }
if (!aliasForm.source.trim()) { setModalErr("Source requise"); return; }
if (!aliasForm.destination.trim()) { setModalErr("Destination requise"); return; }
// Convert textarea (one per line) to CSV without spaces
const csvDest = aliasForm.destination
.split(/[\n,]/)
.map(d => d.trim())
.filter(Boolean)
.join(",");
setSaving(true); setModalErr(null);
try {
let aliasResult;
if (editAlias) {
aliasResult = await api(`/mail/aliases/${editAlias.id}`, {
method:"PUT",
body:JSON.stringify({...aliasForm, destination:csvDest}),
});
} else {
aliasResult = await api("/mail/aliases", {
method:"POST",
body:JSON.stringify({...aliasForm, destination:csvDest}),
});
}
setShowAliasModal(false);
showMutationToast(Array.isArray(aliasResult) ? aliasResult[0] : aliasResult);
load();
} catch(e) { setModalErr(e.message); }
finally { setSaving(false); }
};
const deleteAlias = async () => {
if (!confirmDelAlias) return;
setSaving(true);
try {
// Delete all rows sharing same source+domain
const group = getAliasGroup(confirmDelAlias.source, confirmDelAlias.domain);
const delResults = await Promise.all(group.map(a => api(`/mail/aliases/${a.id}`, {method:"DELETE"})));
setConfirmDelAlias(null);
showMutationToast(delResults[0]);
load();
} catch(e) { setError(e.message); }
finally { setSaving(false); }
};
// Filtered data
// Accounts: domain required don't show all
const filteredAccounts = selDomain
? accounts.filter(a =>
a.domain === selDomain &&
(!accSearch || `${a.username}@${a.domain}`.includes(accSearch.toLowerCase()))
)
: [];
// Aliases: group by source+domain for display (show one row per source)
const aliasGroups = (() => {
const groups = {};
for (const a of aliases) {
if (selDomain && a.domain !== selDomain) continue;
const key = `${a.source}@${a.domain}`;
if (!groups[key]) groups[key] = { ...a, destinations: [] };
groups[key].destinations.push(a.destination);
}
return Object.values(groups).filter(g =>
!aliasSearch ||
g.source.includes(aliasSearch.toLowerCase()) ||
g.domain.includes(aliasSearch.toLowerCase()) ||
g.destinations.some(d => d.includes(aliasSearch.toLowerCase()))
);
})();
const DomainSelect = () => (
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:12}}>
<label style={{fontSize:12,color:"var(--text-2)",whiteSpace:"nowrap"}}>Domaine</label>
<select className="form-select" style={{maxWidth:280}}
value={selDomain || ""} onChange={e=>setSelDomain(e.target.value||null)}>
<option value=""> Sélectionner un domaine </option>
{domains.map(d=><option key={d} value={d}>{d}</option>)}
</select>
{selDomain && (
<span style={{fontSize:11,color:"var(--text-3)"}}>
{accounts.filter(a=>a.domain===selDomain).length} compte(s) ·{" "}
{aliases.filter(a=>a.domain===selDomain).length} alias
</span>
)}
</div>
);
return (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Mail</div>
<div className="page-sub">Gestion des comptes et des alias</div>
</div>
{error && <div className="alert alert-error"> {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}></button></div>}
<SyncToast msg={syncMsg} success={syncSuccess} onClose={()=>setSyncMsg(null)} />
<PendingBanner pending={pending} onRetry={retryPending} onDismiss={dismissPending} isGlobalAdmin={isGlobalAdmin} />
{/* Stats */}
<div style={{display:"flex",gap:12,marginBottom:16,flexWrap:"wrap"}}>
{[
{ label:"Comptes", value:accounts.length, color:"var(--accent)" },
{ label:"Actifs", value:accounts.filter(a=>a.active).length, color:"var(--green)" },
{ label:"Alias", value:aliasGroups.length || aliases.length, color:"var(--yellow)" },
{ label:"Domaines", value:domains.length, color:"var(--text-2)" },
].map(s=>(
<div key={s.label} className="card" style={{flex:"1 1 110px",minWidth:110,marginBottom:0}}>
<div className="card-body" style={{padding:"12px 16px"}}>
<div style={{fontSize:22,fontWeight:700,color:s.color,fontFamily:"var(--font-display)"}}>{s.value}</div>
<div style={{fontSize:10,color:"var(--text-2)",textTransform:"uppercase",letterSpacing:"0.08em",marginTop:2}}>{s.label}</div>
</div>
</div>
))}
</div>
{/* Tab bar */}
<div style={{display:"flex",gap:0,marginBottom:12,borderBottom:"1px solid var(--border)"}}>
{[{id:"accounts",label:"◉ Comptes"},{id:"aliases",label:"⇄ Alias"}].map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)}
className="btn btn-ghost"
style={{
borderRadius:"4px 4px 0 0",
borderBottom:tab===t.id?"2px solid var(--accent)":"2px solid transparent",
color:tab===t.id?"var(--accent)":"var(--text-2)",
fontWeight:tab===t.id?600:400,
padding:"8px 18px", marginBottom:-1,
}}>
{t.label}
</button>
))}
<div style={{flex:1}}/>
{isGlobalAdmin && (
<button className="btn btn-secondary btn-sm" onClick={syncFromServer} disabled={syncing}
style={{alignSelf:"center",marginBottom:4}}
title="Synchroniser depuis MariaDB (admin global uniquement)">
{syncing?<><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> Sync</>:"⟳ Sync MariaDB"}
</button>
)}
</div>
{/* Shared domain selector */}
<DomainSelect />
{/* ── Accounts tab ─────────────────────────────────────────────────── */}
{tab === "accounts" && (
<div className="card">
<div className="card-header">
<div className="card-title">
Comptes {selDomain ? `${selDomain}` : ""} ({filteredAccounts.length})
</div>
{isAdmin && selDomain && (
<button className="btn btn-primary btn-sm" onClick={openNewAcc}>+ Nouveau compte</button>
)}
</div>
<div className="card-body">
{!selDomain ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">Sélectionnez un domaine ci-dessus</div>
</div>
) : (
<>
<div className="toolbar" style={{marginBottom:12}}>
<input className="search-box" placeholder="Rechercher…" value={accSearch}
onChange={e=>setAccSearch(e.target.value)} />
</div>
{loading ? <div className="loading"><div className="spinner"/> Chargement</div>
: filteredAccounts.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">Aucun compte pour ce domaine</div>
</div>
) : (
<div className="table-wrap">
<table>
<thead><tr>
<th>Adresse</th><th>Quota</th><th>Statut</th>
{isAdmin && <th></th>}
</tr></thead>
<tbody>
{filteredAccounts.map(acc=>(
<tr key={acc.id}>
<td className="cell-mono" style={{fontWeight:500}}>
{acc.username}@{acc.domain}
</td>
<td><QuotaBar used={acc.used||0} total={acc.quota}/></td>
<td>
{acc.active
? <span className="badge badge-active"> Actif</span>
: <span className="badge badge-inactive"> Inactif</span>}
</td>
{isAdmin && (
<td><div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={()=>openEditAcc(acc)}></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
onClick={()=>setConfirmDelAcc(acc)}></button>
</div></td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)}
{/* ── Aliases tab ──────────────────────────────────────────────────── */}
{tab === "aliases" && (
<div className="card">
<div className="card-header">
<div className="card-title">
Alias {selDomain ? `${selDomain}` : ""} ({aliasGroups.length})
</div>
{isAdmin && selDomain && (
<button className="btn btn-primary btn-sm" onClick={openNewAlias}>+ Nouvel alias</button>
)}
</div>
<div className="card-body">
{!selDomain ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">Sélectionnez un domaine ci-dessus</div>
</div>
) : (
<>
<div className="toolbar" style={{marginBottom:12}}>
<input className="search-box" placeholder="Rechercher…" value={aliasSearch}
onChange={e=>setAliasSearch(e.target.value)} />
</div>
{loading ? <div className="loading"><div className="spinner"/> Chargement</div>
: aliasGroups.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">Aucun alias pour ce domaine</div>
</div>
) : (
<div className="table-wrap">
<table>
<thead><tr>
<th>Source</th><th></th><th>Destination(s)</th>
{isAdmin && <th></th>}
</tr></thead>
<tbody>
{aliasGroups.map(alias=>(
<tr key={`${alias.source}@${alias.domain}`}>
<td className="cell-mono" style={{color:"var(--accent)",fontWeight:500,whiteSpace:"nowrap"}}>
{alias.source}@{alias.domain}
</td>
<td style={{color:"var(--text-3)",textAlign:"center",padding:"0 4px"}}></td>
<td>
<div style={{display:"flex",flexWrap:"wrap",gap:4}}>
{alias.destinations.map(d=>(
<span key={d} style={{
display:"inline-block",fontFamily:"var(--font-mono)",fontSize:11,
padding:"1px 6px",background:"var(--bg-0)",
border:"1px solid var(--border)",borderRadius:3,
}}>{d}</span>
))}
</div>
</td>
{isAdmin && (
<td><div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={()=>openEditAlias(alias)}></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}}
onClick={()=>setConfirmDelAlias(alias)}></button>
</div></td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)}
{/* ── Account modal ─────────────────────────────────────────────────── */}
{showAccModal && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowAccModal(false)}>
<div className="modal" style={{maxWidth:460}}>
<div className="modal-header">
<div className="modal-title">{editAcc?"Modifier le compte":"Nouveau compte"}</div>
<button className="modal-close" onClick={()=>setShowAccModal(false)}></button>
</div>
<div className="modal-body">
{modalErr && <div className="alert alert-error"> {modalErr}</div>}
{!editAcc ? (
<div className="form-row">
<div className="form-group">
<label className="form-label">Identifiant</label>
<input className="form-input" placeholder="utilisateur" value={accForm.username}
onChange={e=>setAccForm({...accForm,username:e.target.value})} autoFocus />
</div>
<div className="form-group">
<label className="form-label">Domaine</label>
<select className="form-select" value={accForm.domain}
onChange={e=>setAccForm({...accForm,domain:e.target.value})}>
<option value=""> Choisir </option>
{domains.map(d=><option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
) : (
<div className="form-group">
<label className="form-label">Adresse</label>
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{editAcc.username}@{editAcc.domain}
</div>
</div>
)}
<div className="form-group">
<label className="form-label">
Mot de passe{editAcc&&<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}>(vide = inchangé)</span>}
</label>
<input className="form-input" type="password"
placeholder={editAcc?"••••••••":"Mot de passe"}
value={accForm.password}
onChange={e=>setAccForm({...accForm,password:e.target.value})} />
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">Quota (Mo)</label>
<input className="form-input" type="number" min="1" value={accForm.quota}
onChange={e=>setAccForm({...accForm,quota:+e.target.value})} />
</div>
<div className="form-group" style={{justifyContent:"flex-end",paddingTop:22}}>
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13}}>
<input type="checkbox" checked={accForm.active}
onChange={e=>setAccForm({...accForm,active:e.target.checked})} />
Compte actif
</label>
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setShowAccModal(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveAcc} disabled={saving}>
{saving?"Sauvegarde…":editAcc?"Enregistrer":"Créer"}
</button>
</div>
</div>
</div>
)}
{/* ── Alias modal ───────────────────────────────────────────────────── */}
{showAliasModal && (
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&setShowAliasModal(false)}>
<div className="modal" style={{maxWidth:460}}>
<div className="modal-header">
<div className="modal-title">{editAlias?"Modifier l'alias":"Nouvel alias"}</div>
<button className="modal-close" onClick={()=>setShowAliasModal(false)}></button>
</div>
<div className="modal-body">
{modalErr && <div className="alert alert-error"> {modalErr}</div>}
<div className="form-row">
<div className="form-group">
<label className="form-label">Source (partie locale)</label>
{editAlias ? (
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{editAlias.source}
</div>
) : (
<input className="form-input" placeholder="contact"
value={aliasForm.source}
onChange={e=>setAliasForm({...aliasForm,source:e.target.value.split("@")[0]})}
autoFocus />
)}
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}>Partie avant le @</div>
</div>
<div className="form-group">
<label className="form-label">Domaine</label>
{editAlias ? (
<div className="form-input" style={{background:"var(--bg-0)",color:"var(--text-2)"}}>
{editAlias.domain}
</div>
) : (
<select className="form-select" value={aliasForm.domain}
onChange={e=>setAliasForm({...aliasForm,domain:e.target.value})}>
<option value=""> Choisir </option>
{domains.map(d=><option key={d} value={d}>{d}</option>)}
</select>
)}
</div>
</div>
<div className="form-group">
<label className="form-label">
Destination(s)
<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}>
une adresse par ligne
</span>
</label>
<textarea className="form-input" rows={4}
placeholder={"user@example.com\nalias2@other.com"}
value={aliasForm.destination}
onChange={e=>setAliasForm({...aliasForm,destination:e.target.value})}
style={{resize:"vertical",fontFamily:"var(--font-mono)",fontSize:12}}
/>
</div>
{(aliasForm.source||editAlias?.source) && (aliasForm.domain||editAlias?.domain) && aliasForm.destination && (
<div style={{padding:"8px 12px",background:"var(--bg-0)",borderRadius:6,fontSize:11,color:"var(--text-2)"}}>
<span style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
{aliasForm.source||editAlias?.source}@{aliasForm.domain||editAlias?.domain}
</span>
{" "}{" "}
<span style={{fontFamily:"var(--font-mono)"}}>
{aliasForm.destination.split(/[\n,]/).map(d=>d.trim()).filter(Boolean).join(", ")}
</span>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setShowAliasModal(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveAlias} disabled={saving}>
{saving?"Sauvegarde…":editAlias?"Enregistrer":"Créer"}
</button>
</div>
</div>
</div>
)}
{/* ── Delete account confirmation ───────────────────────────────────── */}
{confirmDelAcc && (
<div className="modal-overlay" onClick={()=>setConfirmDelAcc(null)}>
<div className="modal" style={{maxWidth:400}} onClick={e=>e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer le compte</div>
<button className="modal-close" onClick={()=>setConfirmDelAcc(null)}></button>
</div>
<div className="modal-body">
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7}}>
Supprimer{" "}
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
{confirmDelAcc.username}@{confirmDelAcc.domain}
</strong> ?
</p>
<div style={{marginTop:8,padding:"6px 10px",background:"var(--red-dim)",
border:"1px solid rgba(255,74,106,0.2)",borderRadius:4,fontSize:11,color:"var(--red)"}}>
Les emails stockés sur le serveur ne seront pas supprimés.
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setConfirmDelAcc(null)}>Annuler</button>
<button className="btn btn-danger" onClick={deleteAcc} disabled={saving}>Supprimer</button>
</div>
</div>
</div>
)}
{/* ── Delete alias confirmation ─────────────────────────────────────── */}
{confirmDelAlias && (
<div className="modal-overlay" onClick={()=>setConfirmDelAlias(null)}>
<div className="modal" style={{maxWidth:400}} onClick={e=>e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'alias</div>
<button className="modal-close" onClick={()=>setConfirmDelAlias(null)}></button>
</div>
<div className="modal-body">
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7}}>
Supprimer l'alias{" "}
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>
{confirmDelAlias.source}@{confirmDelAlias.domain}
</strong>
{confirmDelAlias.destinations?.length > 1
? ` (${confirmDelAlias.destinations.length} destinations)`
: `${confirmDelAlias.destinations?.[0] || confirmDelAlias.destination}`
} ?
</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={()=>setConfirmDelAlias(null)}>Annuler</button>
<button className="btn btn-danger" onClick={deleteAlias} disabled={saving}>Supprimer</button>
</div>
</div>
</div>
)}
</>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useState, useEffect, useCallback } from "react";
const EMPTY_LIST = { name: "", domain: "", description: "", moderator: "" };
export default function MailingLists({ apiBase, token, onUnauthorized }) {
const [lists, setLists] = useState([]);
const [domains, setDomains] = useState([]);
const [selectedList, setSelectedList] = useState(null);
const [members, setMembers] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [loadingMembers, setLoadingMembers] = useState(false);
const [error, setError] = useState(null);
const [showModal, setShowModal] = useState(false);
const [editList, setEditList] = useState(null);
const [form, setForm] = useState(EMPTY_LIST);
const [saving, setSaving] = useState(false);
const [newMember, setNewMember] = useState("");
const [addingMember, setAddingMember] = useState(false);
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);
}
return r.json();
}, [apiBase, token]);
const loadLists = useCallback(() => {
setLoading(true);
Promise.all([api("/mail/lists"), api("/mail/domains")])
.then(([ls, doms]) => { setLists(ls); setDomains(doms); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [api]);
useEffect(() => { loadLists(); }, [loadLists]);
const loadMembers = useCallback((list) => {
setLoadingMembers(true);
api(`/mail/lists/${list.id}/members`)
.then(setMembers)
.catch((e) => setError(e.message))
.finally(() => setLoadingMembers(false));
}, [api]);
useEffect(() => {
if (selectedList) loadMembers(selectedList);
}, [selectedList, loadMembers]);
const filtered = lists.filter((l) =>
!search || l.name.toLowerCase().includes(search.toLowerCase()) ||
l.domain.toLowerCase().includes(search.toLowerCase())
);
const openNew = () => {
setForm({ ...EMPTY_LIST, domain: domains[0] || "" });
setEditList(null);
setShowModal(true);
};
const openEdit = (list) => {
setForm({ name: list.name, domain: list.domain, description: list.description || "", moderator: list.moderator || "" });
setEditList(list);
setShowModal(true);
};
const saveList = async () => {
setSaving(true);
try {
if (editList) {
await api(`/mail/lists/${editList.id}`, { method: "PUT", body: JSON.stringify(form) });
} else {
await api("/mail/lists", { method: "POST", body: JSON.stringify(form) });
}
loadLists();
setShowModal(false);
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
const deleteList = async (list) => {
if (!confirm(`Delete list ${list.name}@${list.domain}?`)) return;
try {
await api(`/mail/lists/${list.id}`, { method: "DELETE" });
if (selectedList?.id === list.id) setSelectedList(null);
loadLists();
} catch (e) {
setError(e.message);
}
};
const addMember = async () => {
if (!newMember || !selectedList) return;
setAddingMember(true);
try {
await api(`/mail/lists/${selectedList.id}/members`, {
method: "POST", body: JSON.stringify({ email: newMember }),
});
setNewMember("");
loadMembers(selectedList);
} catch (e) {
setError(e.message);
} finally {
setAddingMember(false);
}
};
const removeMember = async (email) => {
try {
await api(`/mail/lists/${selectedList.id}/members/${encodeURIComponent(email)}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.email !== email));
} catch (e) {
setError(e.message);
}
};
return (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Mailing Lists</div>
<div className="page-sub">Manage distribution lists and subscribers</div>
</div>
{error && <div className="alert alert-error"> {error}<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}></button></div>}
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
{/* Left: Lists */}
<div className="card" style={{marginBottom:0}}>
<div className="card-header">
<div className="card-title"> Lists ({lists.length})</div>
<button className="btn btn-primary btn-sm" onClick={openNew}>+ New List</button>
</div>
<div className="card-body">
<input className="search-box" placeholder="Search lists…" value={search}
onChange={(e) => setSearch(e.target.value)} style={{marginBottom:12}} />
{loading ? (
<div className="loading"><div className="spinner" /> Loading</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">No lists yet</div>
</div>
) : (
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{filtered.map((list) => (
<div key={list.id}
className={`list-row ${selectedList?.id === list.id ? "list-row--active" : ""}`}
style={{
padding:"10px 12px",
borderRadius:6,
border:`1px solid ${selectedList?.id === list.id ? "rgba(0,212,255,0.25)" : "var(--border)"}`,
background: selectedList?.id === list.id ? "var(--accent-dim)" : "var(--bg-2)",
cursor:"pointer",
transition:"all 0.15s",
}}
onClick={() => setSelectedList(list)}
>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"start"}}>
<div>
<div style={{fontFamily:"var(--font-mono)",fontSize:12,color:"var(--text-0)"}}>
<span style={{color: selectedList?.id === list.id ? "var(--accent)" : "var(--text-0)"}}>{list.name}</span>
<span style={{color:"var(--text-3)"}}>@</span>
<span style={{color:"var(--text-1)"}}>{list.domain}</span>
</div>
{list.description && (
<div style={{fontSize:11,color:"var(--text-2)",marginTop:3}}>{list.description}</div>
)}
<div style={{fontSize:10,color:"var(--text-3)",marginTop:4}}>
{list.member_count || 0} member{list.member_count !== 1 ? "s" : ""}
{list.moderator && ` · mod: ${list.moderator}`}
</div>
</div>
<div style={{display:"flex",gap:3}}>
<button className="btn btn-ghost btn-sm" onClick={(e) => {e.stopPropagation(); openEdit(list);}} title="Edit"></button>
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={(e) => {e.stopPropagation(); deleteList(list);}} title="Delete"></button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Right: Members */}
<div className="card" style={{marginBottom:0}}>
<div className="card-header">
<div className="card-title">
{selectedList
? <><span style={{color:"var(--accent)"}}></span> {selectedList.name}@{selectedList.domain}</>
: "Members"}
</div>
{selectedList && <span style={{fontSize:11,color:"var(--text-2)"}}>{members.length} subscriber{members.length !== 1 ? "s" : ""}</span>}
</div>
<div className="card-body">
{!selectedList ? (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-text">Select a list to manage its members</div>
</div>
) : (
<>
<div style={{display:"flex",gap:8,marginBottom:12}}>
<input className="form-input" placeholder="new@subscriber.com" value={newMember}
onChange={(e) => setNewMember(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addMember()} />
<button className="btn btn-primary btn-sm" onClick={addMember} disabled={addingMember || !newMember}>
{addingMember ? "…" : "+ Add"}
</button>
</div>
{loadingMembers ? (
<div className="loading"><div className="spinner" /> Loading members</div>
) : members.length === 0 ? (
<div className="empty-state" style={{padding:"24px"}}>
<div className="empty-text">No members yet</div>
</div>
) : (
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:380,overflowY:"auto"}}>
{members.map((m) => (
<div key={m.email} style={{
display:"flex",justifyContent:"space-between",alignItems:"center",
padding:"7px 10px",borderRadius:4,background:"var(--bg-2)",
border:"1px solid var(--border)",
}}>
<span style={{fontFamily:"var(--font-mono)",fontSize:12,color:"var(--text-1)"}}>{m.email}</span>
<div style={{display:"flex",alignItems:"center",gap:8}}>
{m.subscribed_at && <span style={{fontSize:10,color:"var(--text-3)"}}>{new Date(m.subscribed_at).toLocaleDateString()}</span>}
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)",padding:"2px 6px"}}
onClick={() => removeMember(m.email)} title="Remove"></button>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal-header">
<div className="modal-title">{editList ? "Edit List" : "New Mailing List"}</div>
<button className="modal-close" onClick={() => setShowModal(false)}></button>
</div>
<div className="modal-body">
<div className="form-row">
<div className="form-group">
<label className="form-label">List Name</label>
<input className="form-input" placeholder="newsletter" value={form.name}
onChange={(e) => setForm({...form, name: e.target.value})} disabled={!!editList} />
</div>
<div className="form-group">
<label className="form-label">Domain</label>
<select className="form-select" value={form.domain}
onChange={(e) => setForm({...form, domain: e.target.value})} disabled={!!editList}>
{domains.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Description</label>
<input className="form-input" placeholder="Company newsletter" value={form.description}
onChange={(e) => setForm({...form, description: e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Moderator email</label>
<input className="form-input" placeholder="admin@example.com" value={form.moderator}
onChange={(e) => setForm({...form, moderator: e.target.value})} />
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancel</button>
<button className="btn btn-primary" onClick={saveList} disabled={saving}>
{saving ? "Saving…" : editList ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>
);
}
+327
View File
@@ -0,0 +1,327 @@
import { useState, useEffect, useCallback } from "react";
const SERVICE_COLORS = { dns:"var(--accent)", mail:"var(--yellow)", "":"var(--text-2)" };
const STATUS_CFG = {
pending: { color:"var(--yellow)", bg:"rgba(255,200,0,0.12)", icon:"⏳", label:"En attente" },
retrying: { color:"var(--accent)", bg:"rgba(0,212,255,0.10)", icon:"↺", label:"En cours" },
failed: { color:"var(--red)", bg:"rgba(255,74,106,0.12)",icon:"✕", label:"Échec" },
done: { color:"var(--green)", bg:"rgba(0,200,100,0.10)", icon:"✓", label:"Appliqué" },
};
const TRIGGER_LABELS = {
startup: "Démarrage",
poll: "Polling auto",
manual: "Manuel",
agent_reconnect: "Reconnexion agent",
};
function StatusBadge({ status }) {
const c = STATUS_CFG[status] || STATUS_CFG.pending;
return <span style={{display:"inline-flex",alignItems:"center",gap:4,padding:"2px 8px",
borderRadius:3,fontSize:10,fontWeight:600,background:c.bg,color:c.color,
border:`1px solid ${c.color}44`}}>{c.icon} {c.label}</span>;
}
function ServiceBadge({ service }) {
const col = SERVICE_COLORS[service] || "var(--text-2)";
return <span style={{display:"inline-block",padding:"1px 7px",borderRadius:3,
fontSize:10,fontWeight:600,textTransform:"uppercase",
background:col+"22",color:col,border:`1px solid ${col}44`}}>{service}</span>;
}
function TriggerBadge({ trigger }) {
return <span style={{fontSize:11,color:"var(--text-2)",
background:"var(--bg-2)",padding:"1px 6px",borderRadius:3}}>
{TRIGGER_LABELS[trigger] || trigger}
</span>;
}
function fmtDate(ts) {
if (!ts) return "—";
return new Date(ts+"Z").toLocaleString("fr-FR",{
day:"2-digit",month:"2-digit",year:"numeric",
hour:"2-digit",minute:"2-digit",second:"2-digit"
});
}
function fmtDuration(ms) {
if (ms == null) return "—";
if (ms < 1000) return `${ms} ms`;
return `${(ms/1000).toFixed(1)} s`;
}
function fmtAction(action) {
const m = { apply_zone:"Appliquer zone", delete_zone:"Supprimer zone",
apply_domain:"Appliquer domaine mail", delete_domain:"Supprimer domaine mail",
get_state:"Lecture état" };
return m[action] || action;
}
function getTarget(payload) {
try { const p=JSON.parse(payload); return p.zone||p.domain||"—"; } catch { return "—"; }
}
// Queue tab
function QueueTab({ apiBase, token, onUnauthorized }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all");
const [expanded,setExpanded]= useState(null);
const api = useCallback(async (path, opts={}) => {
const r = await fetch(`${apiBase}${path}`,{
headers:{"Content-Type":"application/json","Authorization":`Bearer ${token}`},...opts});
if (r.status===401){onUnauthorized?.();throw new Error("Session expirée.");}
if (r.status===204) return null;
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(()=>{
setLoading(true);
api("/mail/pending").then(d=>setItems(d||[])).catch(e=>setError(e.message)).finally(()=>setLoading(false));
},[api]);
useEffect(()=>{load();},[load]);
useEffect(()=>{const t=setInterval(load,10000);return()=>clearInterval(t);},[load]);
const retry = async id=>{try{await api(`/mail/pending/${id}/retry`,{method:"POST"});load();}catch(e){setError(e.message);}};
const dismiss = async id=>{try{await api(`/mail/pending/${id}`,{method:"DELETE"});load();}catch(e){setError(e.message);}};
const filtered = items.filter(i=>{
if(filter==="dns") return i.service==="dns";
if(filter==="mail") return i.service==="mail";
if(filter==="pending") return i.status==="pending"||i.status==="retrying";
if(filter==="failed") return i.status==="failed";
return true;
});
const counts = { all:items.length, dns:items.filter(i=>i.service==="dns").length,
mail:items.filter(i=>i.service==="mail").length,
pending:items.filter(i=>i.status==="pending"||i.status==="retrying").length,
failed:items.filter(i=>i.status==="failed").length,
done:items.filter(i=>i.status==="done").length };
return <>
{error && <div className="alert alert-error" style={{marginBottom:12}}> {error}
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}></button></div>}
{/* Stat cards */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
{[{k:"pending",l:"En attente",c:"var(--yellow)"},{k:"failed",l:"En échec",c:"var(--red)"},
{k:"done",l:"Appliquées",c:"var(--green)"},{k:"dns",l:"DNS",c:"var(--accent)"},
{k:"mail",l:"Mail",c:"var(--yellow)"}].map(s=>(
<div key={s.k} className="card" style={{flex:"1 1 90px",minWidth:90,marginBottom:0,cursor:"pointer",
border:filter===s.k?`1px solid ${s.c}55`:"1px solid var(--border)"}}
onClick={()=>setFilter(filter===s.k?"all":s.k)}>
<div className="card-body" style={{padding:"10px 14px"}}>
<div style={{fontSize:22,fontWeight:700,color:s.c,fontFamily:"var(--font-display)"}}>{counts[s.k]}</div>
<div style={{fontSize:10,color:"var(--text-2)",textTransform:"uppercase",letterSpacing:"0.08em",marginTop:2}}>{s.l}</div>
</div>
</div>
))}
</div>
{/* Actions bar */}
<div style={{display:"flex",gap:8,marginBottom:12,alignItems:"center"}}>
<button className="btn btn-secondary btn-sm" onClick={load} disabled={loading}>
{loading?<><span className="spinner" style={{width:10,height:10,borderWidth:2}}/> </>:"⟳ Actualiser"}
</button>
{counts.pending>0 && <button className="btn btn-primary btn-sm" onClick={async()=>{
for(const i of filtered.filter(x=>x.status!=="done")) await api(`/mail/pending/${i.id}/retry`,{method:"POST"}).catch(()=>{});
load();
}}> Relancer tout ({counts.pending})</button>}
{counts.done>0 && <button className="btn btn-ghost btn-sm" style={{color:"var(--text-3)"}} onClick={async()=>{
for(const i of items.filter(x=>x.status==="done")) await api(`/mail/pending/${i.id}`,{method:"DELETE"}).catch(()=>{});
load();
}}>Vider les appliquées ({counts.done})</button>}
<div style={{flex:1}}/>
<span style={{fontSize:11,color:"var(--text-3)"}}>Actualisation auto · 10s</span>
</div>
{/* Table */}
<div className="card">
<div className="card-header">
<div className="card-title">{filtered.length} action{filtered.length!==1?"s":""}
{filter!=="all"&&<span style={{color:"var(--text-3)",fontWeight:400,marginLeft:6}}> {filter}</span>}
</div>
</div>
<div className="card-body" style={{padding:0}}>
{loading&&!items.length ? <div className="loading" style={{padding:24}}><div className="spinner"/> Chargement</div>
: filtered.length===0 ? <div className="empty-state" style={{padding:40}}>
<div className="empty-icon"></div>
<div className="empty-text">{items.length===0?"Aucune action en attente — agents synchronisés":"Aucune action dans ce filtre"}</div>
</div>
: <div className="table-wrap"><table>
<thead><tr><th>Statut</th><th>Service</th><th>Action</th><th>Cible</th><th>Créée le</th><th>Mise à jour</th><th></th></tr></thead>
<tbody>
{filtered.map(item=>{
const isExp = expanded===item.id;
return <>
<tr key={item.id} onClick={()=>setExpanded(isExp?null:item.id)}
style={{cursor:"pointer",background:isExp?"var(--bg-2)":"transparent"}}>
<td><StatusBadge status={item.status}/></td>
<td><ServiceBadge service={item.service}/></td>
<td style={{fontSize:12,color:"var(--text-1)"}}>{fmtAction(item.action)}</td>
<td className="cell-mono" style={{fontSize:11,color:"var(--accent)"}}>{getTarget(item.payload)}</td>
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(item.created_at)}</td>
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(item.updated_at)}</td>
<td><div className="cell-actions">
{item.status!=="done"&&<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--accent)"}}
onClick={e=>{e.stopPropagation();retry(item.id)}} title="Réessayer"></button>}
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
onClick={e=>{e.stopPropagation();dismiss(item.id)}} title="Supprimer"></button>
</div></td>
</tr>
{isExp&&item.error&&<tr key={item.id+"-e"} style={{background:"var(--bg-0)"}}>
<td colSpan={7} style={{padding:"6px 14px"}}>
<div style={{fontSize:11,color:"var(--red)",fontFamily:"var(--font-mono)"}}> {item.error}</div>
</td>
</tr>}
</>;
})}
</tbody>
</table></div>}
</div>
</div>
</>;
}
// History tab
function HistoryTab({ 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 [filterSvc, setFilterSvc] = useState("");
const [filterTrig, setFilterTrig] = useState("");
const PAGE = 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,offset:p*PAGE});
if(filterSvc) params.set("service",filterSvc);
if(filterTrig) params.set("trigger",filterTrig);
api(`/sync/history?${params}`)
.then(d=>{setEntries(d.entries);setTotal(d.total);setPage(p);})
.catch(e=>setError(e.message))
.finally(()=>setLoading(false));
},[api,filterSvc,filterTrig]);
useEffect(()=>{load(0);},[load]);
const totalPages = Math.ceil(total/PAGE);
return <>
{error && <div className="alert alert-error" style={{marginBottom:12}}> {error}
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={()=>setError(null)}></button></div>}
<div className="card" style={{marginBottom:12}}>
<div className="card-body" style={{padding:"12px 16px"}}>
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"flex-end"}}>
<div className="form-group" style={{marginBottom:0,minWidth:130}}>
<label className="form-label" style={{marginBottom:4}}>Service</label>
<select className="form-select" value={filterSvc} onChange={e=>{setFilterSvc(e.target.value);setPage(0);}}>
<option value="">Tous</option>
<option value="dns">DNS</option>
<option value="mail">Mail</option>
</select>
</div>
<div className="form-group" style={{marginBottom:0,minWidth:160}}>
<label className="form-label" style={{marginBottom:4}}>Déclencheur</label>
<select className="form-select" value={filterTrig} onChange={e=>{setFilterTrig(e.target.value);setPage(0);}}>
<option value="">Tous</option>
<option value="startup">Démarrage</option>
<option value="poll">Polling auto</option>
<option value="manual">Manuel</option>
<option value="agent_reconnect">Reconnexion agent</option>
</select>
</div>
<button className="btn btn-secondary btn-sm" onClick={()=>load(0)} style={{alignSelf:"flex-end",marginBottom:0}}>
Actualiser
</button>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<div className="card-title">{total} synchronisation{total!==1?"s":""}</div>
{totalPages>1&&<div style={{display:"flex",gap:6,alignItems:"center"}}>
<button className="btn btn-ghost btn-sm" disabled={page===0} onClick={()=>load(page-1)}> Préc.</button>
<span style={{fontSize:12,color:"var(--text-2)"}}>{page+1} / {totalPages}</span>
<button className="btn btn-ghost btn-sm" disabled={page>=totalPages-1} onClick={()=>load(page+1)}>Suiv. </button>
</div>}
</div>
<div className="card-body" style={{padding:0}}>
{loading&&!entries.length ? <div className="loading" style={{padding:24}}><div className="spinner"/> Chargement</div>
: entries.length===0 ? <div className="empty-state" style={{padding:40}}>
<div className="empty-icon"></div>
<div className="empty-text">Aucune synchronisation enregistrée</div>
</div>
: <div className="table-wrap"><table>
<thead><tr>
<th style={{width:16}}></th>
<th>Date</th><th>Service</th><th>Déclencheur</th>
<th>Cibles</th><th>Éléments</th><th>Durée</th>
</tr></thead>
<tbody>
{entries.map(e=>(
<tr key={e.id}>
<td>
<span style={{display:"inline-block",width:8,height:8,borderRadius:"50%",
background:e.success?"var(--green)":"var(--red)"}}
title={e.success?"Succès":"Échec : "+(e.error||"")}/>
</td>
<td className="cell-dim" style={{fontSize:11,whiteSpace:"nowrap"}}>{fmtDate(e.ts)}</td>
<td><ServiceBadge service={e.service}/></td>
<td><TriggerBadge trigger={e.trigger}/></td>
<td style={{fontSize:12}}>{e.zones ?? "—"}</td>
<td style={{fontSize:12}}>{e.records ?? "—"}</td>
<td className="cell-dim" style={{fontSize:11}}>{fmtDuration(e.duration_ms)}</td>
</tr>
))}
</tbody>
</table></div>}
</div>
</div>
</>;
}
// Main component
export default function SyncDashboard({ apiBase, token, onUnauthorized }) {
const [tab, setTab] = useState("queue");
return <>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Synchronisations</div>
<div className="page-sub">File d'attente et historique des synchronisations agents</div>
</div>
{/* Tabs */}
<div style={{display:"flex",gap:0,marginBottom:16,borderBottom:"1px solid var(--border)"}}>
{[{id:"queue",label:"⏳ File d'attente"},{id:"history",label:"📋 Historique"}].map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)} className="btn btn-ghost"
style={{borderRadius:"4px 4px 0 0",marginBottom:-1,padding:"8px 18px",
borderBottom:tab===t.id?"2px solid var(--accent)":"2px solid transparent",
color:tab===t.id?"var(--accent)":"var(--text-2)",
fontWeight:tab===t.id?600:400}}>
{t.label}
</button>
))}
</div>
{tab==="queue" && <QueueTab apiBase={apiBase} token={token} onUnauthorized={onUnauthorized}/>}
{tab==="history" && <HistoryTab apiBase={apiBase} token={token} onUnauthorized={onUnauthorized}/>}
</>;
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from "react";
/**
* SyncToast popup notification for sync operations.
*
* Props:
* msg : string | null message to show (null = hidden)
* success : bool green if true, red if false
* onClose : () => void called when closed (auto or manual)
*
* Auto-closes after 5s on success. Stays open on error until manually closed.
*/
export default function SyncToast({ msg, success, onClose }) {
const timer = useRef(null);
useEffect(() => {
if (!msg) return;
if (success) {
timer.current = setTimeout(() => onClose?.(), 5000);
}
return () => clearTimeout(timer.current);
}, [msg, success, onClose]);
if (!msg) return null;
const bg = success ? "var(--green)" : "var(--red)";
const bgDim = success ? "rgba(0,200,100,0.12)" : "rgba(255,74,106,0.12)";
const icon = success ? "✓" : "⚠";
return (
<div style={{
position: "fixed",
bottom: 24,
right: 24,
zIndex: 9999,
minWidth: 280,
maxWidth: 440,
background: bgDim,
border: `1px solid ${bg}44`,
borderLeft: `4px solid ${bg}`,
borderRadius: 8,
padding: "12px 16px",
boxShadow: "0 8px 32px rgba(0,0,0,0.4)",
display: "flex",
alignItems: "flex-start",
gap: 10,
animation: "slideIn 0.2s ease",
}}>
<span style={{
fontSize: 16,
color: bg,
flexShrink: 0,
marginTop: 1,
}}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 12,
color: success ? "var(--green)" : "var(--red)",
fontWeight: 600,
marginBottom: 2,
}}>
{success ? "Synchronisation effectuée" : "En attente de synchronisation"}
</div>
<div style={{
fontSize: 12,
color: "var(--text-1)",
lineHeight: 1.5,
wordBreak: "break-word",
}}>
{msg}
</div>
{success && (
<div style={{ fontSize: 10, color: "var(--text-3)", marginTop: 4 }}>
Fermeture automatique dans 5s
</div>
)}
</div>
<button onClick={onClose} style={{
background: "none",
border: "none",
color: "var(--text-3)",
cursor: "pointer",
fontSize: 14,
flexShrink: 0,
padding: 0,
lineHeight: 1,
}}></button>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
import { useState, useEffect, useCallback } from "react";
export default function UserProfile({ apiBase, token, user, onUnauthorized }) {
const [tab, setTab] = useState("password");
// Password change
const [pwForm, setPwForm] = useState({ current: "", next: "", confirm: "" });
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState(null);
const [pwErr, setPwErr] = useState(null);
// TOTP
const [totpState, setTotpState] = useState("idle"); // idle|setup|verifying|active|disabling
const [totpData, setTotpData] = useState(null); // {secret, qr_b64}
const [totpCode, setTotpCode] = useState("");
const [totpPw, setTotpPw] = useState(""); // for disable
const [totpMsg, setTotpMsg] = useState(null);
const [totpErr, setTotpErr] = useState(null);
const [totpEnabled, setTotpEnabled] = useState(user?.totp_enabled || false);
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."); }
if (!r.ok) {
const e = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(e.detail || r.statusText);
}
return r.json();
}, [apiBase, token]);
// Password handlers
const changePassword = async () => {
setPwErr(null); setPwMsg(null);
if (!pwForm.current) { setPwErr("Mot de passe actuel requis"); return; }
if (pwForm.next.length < 8) { setPwErr("Le nouveau mot de passe doit faire au moins 8 caractères"); return; }
if (pwForm.next !== pwForm.confirm) { setPwErr("Les mots de passe ne correspondent pas"); return; }
setPwSaving(true);
try {
await api("/auth/me/password", {
method: "PUT",
body: JSON.stringify({
current_password: pwForm.current,
new_password: pwForm.next,
}),
});
setPwMsg("Mot de passe modifié avec succès.");
setPwForm({ current: "", next: "", confirm: "" });
} catch(e) { setPwErr(e.message); }
finally { setPwSaving(false); }
};
// TOTP handlers
const startTotpSetup = async () => {
setTotpErr(null); setTotpMsg(null); setTotpCode("");
try {
const data = await api("/auth/totp/setup", { method: "POST" });
if (!data?.qr_b64) throw new Error("Réponse invalide du serveur");
setTotpData(data);
setTotpState("verifying");
} catch(e) { setTotpErr(e.message); }
};
const verifyTotp = async () => {
setTotpErr(null);
try {
const res = await api("/auth/totp/verify", {
method: "POST",
body: JSON.stringify({ code: totpCode }),
});
if (!res?.success) throw new Error(res?.message || "Échec de l'activation");
setTotpEnabled(true);
setTotpState("idle");
setTotpData(null);
setTotpCode("");
setTotpMsg("Double authentification activée avec succès ✓ — elle sera demandée à votre prochaine connexion.");
} catch(e) { setTotpErr(e.message || "Code invalide — vérifiez l'heure de votre appareil"); }
};
const disableTotp = async () => {
setTotpErr(null);
try {
await api("/auth/totp/disable", {
method: "POST",
body: JSON.stringify({ password: totpPw }),
});
setTotpEnabled(false);
setTotpState("idle");
setTotpPw("");
setTotpMsg("Authentification à deux facteurs désactivée.");
} catch(e) { setTotpErr(e.message); }
};
const cancelTotpSetup = () => {
setTotpState("idle"); setTotpData(null); setTotpCode(""); setTotpErr(null);
};
const isOidc = user?.auth_method === "oidc";
return (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Mon compte</div>
<div className="page-sub">{user?.username} {user?.email || "aucun email renseigné"}</div>
</div>
{/* Tab bar */}
<div style={{display:"flex",gap:0,marginBottom:16,borderBottom:"1px solid var(--border)"}}>
{[
{id:"password", label:"🔑 Mot de passe"},
{id:"totp", label:"🔐 Double authentification"},
].map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className="btn btn-ghost"
style={{
borderRadius:"4px 4px 0 0", marginBottom:-1, padding:"8px 18px",
borderBottom: tab===t.id ? "2px solid var(--accent)" : "2px solid transparent",
color: tab===t.id ? "var(--accent)" : "var(--text-2)",
fontWeight: tab===t.id ? 600 : 400,
}}>
{t.label}
</button>
))}
</div>
{/* ── Password tab ────────────────────────────────────────────────── */}
{tab === "password" && (
<div className="card" style={{maxWidth:460}}>
<div className="card-header"><div className="card-title">Changer le mot de passe</div></div>
<div className="card-body">
{isOidc && (
<div className="alert" style={{background:"var(--bg-2)",border:"1px solid var(--border)",
marginBottom:16,color:"var(--text-2)"}}>
Votre compte utilise la connexion SSO (Keycloak) le mot de passe est géré par votre fournisseur d'identité.
</div>
)}
{pwMsg && <div className="alert alert-success" style={{marginBottom:12}}> {pwMsg}</div>}
{pwErr && <div className="alert alert-error" style={{marginBottom:12}}> {pwErr}</div>}
<div className="form-group">
<label className="form-label">Mot de passe actuel</label>
<input className="form-input" type="password" value={pwForm.current}
onChange={e => setPwForm({...pwForm, current:e.target.value})}
disabled={isOidc} autoComplete="current-password" />
</div>
<div className="form-group">
<label className="form-label">Nouveau mot de passe</label>
<input className="form-input" type="password" value={pwForm.next}
onChange={e => setPwForm({...pwForm, next:e.target.value})}
disabled={isOidc} autoComplete="new-password" />
<div style={{fontSize:10,color:"var(--text-3)",marginTop:3}}>Minimum 8 caractères</div>
</div>
<div className="form-group">
<label className="form-label">Confirmer le nouveau mot de passe</label>
<input className="form-input" type="password" value={pwForm.confirm}
onChange={e => setPwForm({...pwForm, confirm:e.target.value})}
disabled={isOidc} autoComplete="new-password" />
</div>
<button className="btn btn-primary" onClick={changePassword}
disabled={pwSaving || isOidc}>
{pwSaving ? "Sauvegarde…" : "Changer le mot de passe"}
</button>
</div>
</div>
)}
{/* ── TOTP tab ─────────────────────────────────────────────────────── */}
{tab === "totp" && (
<div className="card" style={{maxWidth:500}}>
<div className="card-header">
<div className="card-title">Double authentification (TOTP)</div>
{totpEnabled && <span className="badge badge-active"> Activée</span>}
</div>
<div className="card-body">
{isOidc ? (
<div style={{padding:"20px 0",textAlign:"center",color:"var(--text-2)",fontSize:13}}>
<div style={{fontSize:32,marginBottom:12}}>🔐</div>
<div>La double authentification n'est disponible que pour les comptes locaux.</div>
<div style={{marginTop:6,fontSize:11,color:"var(--text-3)"}}>
Les comptes SSO utilisent la 2FA de leur fournisseur d'identité (Keycloak).
</div>
</div>
) : (<>
{totpMsg && <div className="alert alert-success" style={{marginBottom:12}}> {totpMsg}</div>}
{totpErr && <div className="alert alert-error" style={{marginBottom:12}}> {totpErr}</div>}
{/* ── Idle state ── */}
{totpState === "idle" && !totpEnabled && (
<>
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:16}}>
La double authentification ajoute une couche de sécurité supplémentaire.
À chaque connexion, un code à usage unique sera demandé en plus de votre mot de passe.
</p>
{user?.totp_required && (
<div style={{padding:"8px 12px",background:"rgba(255,200,0,0.1)",
border:"1px solid rgba(255,200,0,0.3)",borderRadius:6,fontSize:12,
color:"var(--yellow)",marginBottom:12}}>
Votre administrateur a rendu la double authentification obligatoire pour votre compte.
</div>
)}
<button className="btn btn-primary" onClick={startTotpSetup}>
🔐 Configurer la double authentification
</button>
</>
)}
{totpState === "idle" && totpEnabled && (
<>
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:16}}>
La double authentification est <strong style={{color:"var(--green)"}}>activée</strong> sur votre compte.
Vous utiliserez une application d'authentification (Google Authenticator, Aegis, etc.)
à chaque connexion.
</p>
<button className="btn btn-secondary"
onClick={() => { setTotpState("disabling"); setTotpErr(null); }}>
Désactiver la double authentification
</button>
</>
)}
{/* ── Setup: show QR code ── */}
{totpState === "verifying" && totpData && (
<>
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:12}}>
<strong>Étape 1</strong> Scannez ce QR code avec votre application d'authentification
(Google Authenticator, Aegis, Authy, etc.).
</p>
<div style={{textAlign:"center",margin:"16px 0"}}>
<img
src={`data:image/png;base64,${totpData.qr_b64}`}
alt="QR code TOTP"
style={{width:200,height:200,imageRendering:"pixelated",
border:"4px solid white",borderRadius:4}}
/>
</div>
<details style={{marginBottom:12}}>
<summary style={{fontSize:11,color:"var(--text-3)",cursor:"pointer"}}>
Afficher la clé secrète (saisie manuelle)
</summary>
<div style={{fontFamily:"var(--font-mono)",fontSize:13,letterSpacing:"0.1em",
padding:"8px 12px",background:"var(--bg-0)",borderRadius:4,marginTop:6,
wordBreak:"break-all",color:"var(--accent)"}}>
{totpData.secret}
</div>
</details>
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:8}}>
<strong>Étape 2</strong> Saisissez le code affiché par l'application pour confirmer la configuration.
</p>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<input className="form-input" placeholder="123456"
value={totpCode} onChange={e => setTotpCode(e.target.value.replace(/\D/g,"").slice(0,6))}
onKeyDown={e => e.key === "Enter" && totpCode.length === 6 && verifyTotp()}
style={{width:140,fontFamily:"var(--font-mono)",fontSize:18,
letterSpacing:"0.15em",textAlign:"center"}}
maxLength={6} autoFocus />
<button className="btn btn-primary" onClick={verifyTotp}
disabled={totpCode.length !== 6}>
Vérifier et activer
</button>
<button className="btn btn-ghost btn-sm" onClick={cancelTotpSetup}>
Annuler
</button>
</div>
</>
)}
{/* ── Disable: ask password ── */}
{totpState === "disabling" && (
<>
<p style={{fontSize:13,color:"var(--text-1)",lineHeight:1.7,marginBottom:12}}>
Pour désactiver la double authentification, confirmez votre mot de passe.
</p>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<input className="form-input" type="password" placeholder="Mot de passe"
value={totpPw} onChange={e => setTotpPw(e.target.value)}
onKeyDown={e => e.key === "Enter" && totpPw && disableTotp()}
style={{width:220}} autoFocus />
<button className="btn btn-danger" onClick={disableTotp} disabled={!totpPw}>
Désactiver
</button>
<button className="btn btn-ghost btn-sm"
onClick={() => { setTotpState("idle"); setTotpPw(""); setTotpErr(null); }}>
Annuler
</button>
</div>
</>
)}
</>)}
</div>
</div>
)}
</>
);
}
+531
View File
@@ -0,0 +1,531 @@
import { useState, useEffect, useCallback } from "react";
const ROLE_LABELS = {
global_admin: { label: "Admin global", color: "var(--red)", desc: "Accès complet" },
dns_admin: { label: "Admin DNS", color: "var(--accent)", desc: "Toutes les zones DNS" },
mail_admin: { label: "Admin Mail", color: "var(--yellow)", desc: "Tous les comptes mail" },
domain_admin: { label: "Admin domaine", color: "var(--green)", desc: "DNS + mail" },
dns_zone_admin: { label: "Admin zone DNS", color: "#9d7aff", desc: "Zone DNS spécifique" },
mail_domain_admin: { label: "Admin mail domaine", color: "var(--orange)", desc: "Mail d'un domaine" },
};
const SCOPE_REQUIRED = ["domain_admin", "dns_zone_admin", "mail_domain_admin"];
function RoleBadge({ role, scope }) {
const info = ROLE_LABELS[role] || { label: role, color: "var(--text-2)" };
return (
<span style={{
display:"inline-flex", alignItems:"center", gap:4,
padding:"2px 8px", borderRadius:3, fontSize:10, fontWeight:500,
background: info.color + "22", color: info.color,
border: `1px solid ${info.color}44`,
}}>
{info.label}{scope ? ` (${scope})` : ""}
</span>
);
}
export default function UsersManager({ apiBase, token, currentUser }) {
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [showModal, setShowModal] = useState(false);
const [editUser, setEditUser] = useState(null);
const [showRoleModal, setShowRoleModal] = useState(null); // user object
const [form, setForm] = useState({ username:"", password:"", email:"", full_name:"", auth_method:"local" });
const [roleForm, setRoleForm] = useState({ role:"dns_admin", scopes:[] });
const [allDomains, setAllDomains] = useState([]);
const [domainSearch, setDomainSearch] = useState("");
const [saving, setSaving] = useState(false);
const [confirmUser, setConfirmUser] = useState(null); // user pending deletion
const [confirmRole, setConfirmRole] = useState(null); // {userId, roleId, role, scope}
const [oidcEnabled, setOidcEnabled] = useState(false);
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 e = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(e.detail || r.statusText);
}
if (r.status === 204) return null;
return r.json();
}, [apiBase, token]);
// Fetch all domains for scope selection
const loadDomains = useCallback(() => {
fetch(`${apiBase}/mail/domains-config`, {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.ok ? r.json() : [])
.then(d => setAllDomains(d.map(x => x.name)))
.catch(() => {});
}, [apiBase, token]);
// Fetch server config once to know if OIDC is available
useEffect(() => {
fetch(`${apiBase}/auth/config`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.oidc_enabled) setOidcEnabled(true); })
.catch(() => {});
}, [apiBase]);
const load = useCallback(() => {
setLoading(true);
Promise.all([api("/auth/users"), api("/auth/roles")])
.then(([u, r]) => { setUsers(u); setRoles(r); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [api]);
useEffect(() => { load(); loadDomains(); }, [load, loadDomains]);
const openNew = () => { setForm({ username:"", password:"", email:"", full_name:"", auth_method:"local" }); setEditUser(null); setShowModal(true); };
const openEdit = (u) => { setForm({ username:u.username, password:"", email:u.email||"", full_name:u.full_name||"", auth_method:u.auth_method }); setEditUser(u); setShowModal(true); };
const saveUser = async () => {
setSaving(true);
try {
if (editUser) {
const body = {};
if (form.password) body.password = form.password;
if (form.email !== editUser.email) body.email = form.email;
if (form.full_name !== editUser.full_name) body.full_name = form.full_name;
await api(`/auth/users/${editUser.id}`, { method:"PUT", body:JSON.stringify(body) });
} else {
await api("/auth/users", { method:"POST", body:JSON.stringify(form) });
}
load(); setShowModal(false);
} catch(e) { setError(e.message); }
finally { setSaving(false); }
};
const toggleActive = async (u) => {
try { await api(`/auth/users/${u.id}`, { method:"PUT", body:JSON.stringify({ active: !u.active }) }); load(); }
catch(e) { setError(e.message); }
};
const deleteUser = async (u) => { setConfirmUser(u); };
const doDeleteUser = async () => {
const u = confirmUser; setConfirmUser(null);
try { await api(`/auth/users/${u.id}`, { method:"DELETE" }); load(); }
catch(e) { setError(e.message); }
};
const assignRole = async () => {
if (!showRoleModal) return;
const needsScope = SCOPE_REQUIRED.includes(roleForm.role);
if (needsScope && roleForm.scopes.length === 0) { setError("Sélectionnez au moins un domaine"); return; }
setSaving(true);
try {
if (needsScope) {
// Send all scopes in one request
await api(`/auth/users/${showRoleModal.id}/roles`, {
method:"POST",
body: JSON.stringify({ role: roleForm.role, scopes: roleForm.scopes }),
});
} else {
await api(`/auth/users/${showRoleModal.id}/roles`, {
method:"POST",
body: JSON.stringify({ role: roleForm.role, scope: null }),
});
}
load(); setShowRoleModal(null); setDomainSearch("");
} catch(e) { setError(e.message); }
finally { setSaving(false); }
};
const revokeRole = (userId, roleId, role, scope) => {
setConfirmRole({ userId, roleId, role, scope });
};
const doRevokeRole = async () => {
const { userId, roleId } = confirmRole; setConfirmRole(null);
try { await api(`/auth/users/${userId}/roles/${roleId}`, { method:"DELETE" }); load(); }
catch(e) { setError(e.message); }
};
return (
<>
<div className="page-header">
<div className="page-title"><span className="icon"></span> Utilisateurs & Droits</div>
<div className="page-sub">Gestion des comptes et des permissions</div>
</div>
{error && (
<div className="alert alert-error">
{error}
<button className="btn btn-ghost btn-sm" style={{float:"right"}} onClick={() => setError(null)}></button>
</div>
)}
{/* Roles reference */}
<div className="card" style={{marginBottom:16}}>
<div className="card-header"><div className="card-title">Rôles disponibles</div></div>
<div className="card-body" style={{display:"flex",gap:8,flexWrap:"wrap"}}>
{Object.entries(ROLE_LABELS).map(([role, info]) => (
<div key={role} style={{
padding:"8px 12px", background:"var(--bg-2)", borderRadius:6,
border:"1px solid var(--border)", minWidth:160,
}}>
<RoleBadge role={role} />
<div style={{fontSize:10,color:"var(--text-2)",marginTop:4}}>{info.desc}</div>
{SCOPE_REQUIRED.includes(role) && (
<div style={{fontSize:10,color:"var(--text-3)",marginTop:2}}> Scope requis</div>
)}
</div>
))}
</div>
</div>
<div className="card">
<div className="card-header">
<div className="card-title"> Utilisateurs ({users.length})</div>
<button className="btn btn-primary btn-sm" onClick={openNew}>+ Nouvel utilisateur</button>
</div>
<div className="card-body">
{loading ? (
<div className="loading"><div className="spinner"/> Chargement</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Utilisateur</th>
<th>Email</th>
<th>Méthode</th>
<th>Statut</th>
<th>Rôles</th>
<th>Dernière connexion</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td className="cell-mono">
{u.username}
{u.id === currentUser?.id && (
<span style={{fontSize:10,color:"var(--accent)",marginLeft:6}}>(vous)</span>
)}
</td>
<td className="cell-dim">{u.email || "—"}</td>
<td>
<span className={`badge ${u.auth_method === "oidc" ? "badge-mx" : "badge-a"}`}>
{u.auth_method === "oidc" ? "OpenID" : "Local"}
</span>
</td>
<td style={{whiteSpace:"nowrap"}}>
{u.totp_enabled
? <span className="badge badge-active" title="2FA activée">🔐 Activée</span>
: u.totp_required
? <span style={{fontSize:11,color:"var(--yellow)"}} title="Obligatoire, non configurée"> Requise</span>
: <span style={{fontSize:11,color:"var(--text-3)"}}></span>}
</td>
<td>
<span className={`badge ${u.active ? "badge-active" : "badge-inactive"}`}>
{u.active ? "Actif" : "Désactivé"}
</span>
</td>
<td>
<div style={{display:"flex",gap:3,flexWrap:"wrap",maxWidth:280}}>
{u.roles.length === 0 && <span style={{fontSize:11,color:"var(--text-3)"}}>Aucun rôle</span>}
{u.roles.map((r, i) => (
<div key={i} style={{display:"flex",alignItems:"center",gap:2}}>
<RoleBadge role={r.role} scope={r.scope} />
{u.id !== currentUser?.id && (
<button
className="btn btn-ghost btn-sm"
style={{padding:"1px 4px",fontSize:10,color:"var(--text-3)"}}
onClick={() => revokeRole(u.id, r.id, r.role, r.scope)}
title="Révoquer"
></button>
)}
</div>
))}
<button
className="btn btn-ghost btn-sm"
style={{fontSize:11,color:"var(--accent)",padding:"1px 6px"}}
onClick={() => { setShowRoleModal(u); setRoleForm({ role:"dns_admin", scopes:[] }); setDomainSearch(""); }}
>+ Rôle</button>
</div>
</td>
<td className="cell-dim" style={{fontSize:11}}>
{u.last_login ? new Date(u.last_login).toLocaleString("fr-FR") : "Jamais"}
</td>
<td>
<div className="cell-actions">
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(u)} title="Modifier"></button>
<button className="btn btn-ghost btn-sm" onClick={() => toggleActive(u)} title={u.active ? "Désactiver" : "Activer"}>
{u.active ? "⏸" : "▶"}
</button>
{u.totp_enabled && (
<button className="btn btn-ghost btn-sm" style={{fontSize:11,color:"var(--text-3)"}}
title="Supprimer la 2FA (l'utilisateur devra la reconfigurer)"
onClick={() => handleTotpReset(u.id, u.username)}>🔐</button>
)}
<button className="btn btn-ghost btn-sm"
style={{fontSize:11,color:u.totp_required?"var(--yellow)":"var(--text-3)"}}
title={u.totp_required ? "Retirer l'obligation 2FA" : "Forcer la 2FA pour cet utilisateur"}
onClick={() => handleTotpRequire(u.id, u.username, !u.totp_required)}>
{u.totp_required ? "🔐✓" : "🔐?"}
</button>
{u.id !== currentUser?.id && (
<button className="btn btn-ghost btn-sm" style={{color:"var(--red)"}} onClick={() => deleteUser(u)} title="Supprimer"></button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* User modal */}
{showModal && (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal-header">
<div className="modal-title">{editUser ? "Modifier l'utilisateur" : "Nouvel utilisateur"}</div>
<button className="modal-close" onClick={() => setShowModal(false)}></button>
</div>
<div className="modal-body">
<div className="form-row">
<div className="form-group">
<label className="form-label">Identifiant</label>
<input className="form-input" placeholder="jdupont" value={form.username}
onChange={e => setForm({...form, username:e.target.value})} disabled={!!editUser} />
</div>
<div className="form-group">
<label className="form-label">Méthode d'auth</label>
<select className="form-select" value={form.auth_method}
onChange={e => setForm({...form, auth_method:e.target.value})} disabled={!!editUser}>
<option value="local">Local (mot de passe)</option>
{oidcEnabled && <option value="oidc">OpenID Connect</option>}
</select>
</div>
</div>
{form.auth_method === "local" && (
<div className="form-group">
<label className="form-label">{editUser ? "Nouveau mot de passe (laisser vide = inchangé)" : "Mot de passe"}</label>
<input className="form-input" type="password" placeholder={editUser ? "••••••••" : "Mot de passe"}
value={form.password} onChange={e => setForm({...form, password:e.target.value})} />
</div>
)}
<div className="form-row">
<div className="form-group">
<label className="form-label">Nom complet</label>
<input className="form-input" placeholder="Jean Dupont" value={form.full_name}
onChange={e => setForm({...form, full_name:e.target.value})} />
</div>
<div className="form-group">
<label className="form-label">Email</label>
<input className="form-input" placeholder="jdupont@example.com" value={form.email}
onChange={e => setForm({...form, email:e.target.value})} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Annuler</button>
<button className="btn btn-primary" onClick={saveUser} disabled={saving}>
{saving ? "Sauvegarde…" : editUser ? "Mettre à jour" : "Créer"}
</button>
</div>
</div>
</div>
)}
{/* Role modal */}
{showRoleModal && (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setShowRoleModal(null)}>
<div className="modal" style={{maxWidth:460}}>
<div className="modal-header">
<div className="modal-title">Assigner un rôle {showRoleModal.username}</div>
<button className="modal-close" onClick={() => setShowRoleModal(null)}></button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Rôle</label>
<select className="form-select" value={roleForm.role}
onChange={e => setRoleForm({...roleForm, role:e.target.value, scopes:[]})}>
{roles.map(r => (
<option key={r.role} value={r.role}>
{ROLE_LABELS[r.role]?.label || r.role} {r.description}
</option>
))}
</select>
{roleForm.role && ROLE_LABELS[roleForm.role] && (
<div style={{marginTop:6,padding:"6px 10px",background:"var(--bg-0)",
borderRadius:4,fontSize:11,color:"var(--text-2)"}}>
{ROLE_LABELS[roleForm.role].desc}
</div>
)}
</div>
{SCOPE_REQUIRED.includes(roleForm.role) && (
<div className="form-group">
<label className="form-label">
{roleForm.role === "dns_zone_admin" ? "Zone(s) DNS" : "Domaine(s)"}
{roleForm.scopes.length > 0 && (
<span style={{color:"var(--accent)",marginLeft:6}}>
{roleForm.scopes.length} sélectionné{roleForm.scopes.length > 1 ? "s" : ""}
</span>
)}
</label>
{/* Search input */}
<input className="form-input" placeholder="Rechercher un domaine…"
value={domainSearch}
onChange={e => setDomainSearch(e.target.value)}
style={{marginBottom:6}}
/>
{/* Domain list with checkboxes */}
<div style={{
maxHeight:200, overflowY:"auto",
border:"1px solid var(--border)", borderRadius:6,
background:"var(--bg-1)",
}}>
{allDomains.filter(d => !domainSearch || d.toLowerCase().includes(domainSearch.toLowerCase()))
.map(d => {
const checked = roleForm.scopes.includes(d);
return (
<label key={d} style={{
display:"flex", alignItems:"center", gap:10,
padding:"8px 12px", cursor:"pointer",
background: checked ? "var(--accent-dim)" : "transparent",
borderBottom:"1px solid var(--border)",
transition:"background 0.1s",
}}>
<input type="checkbox" checked={checked}
onChange={() => {
const next = checked
? roleForm.scopes.filter(s => s !== d)
: [...roleForm.scopes, d];
setRoleForm({...roleForm, scopes: next});
}}
style={{accentColor:"var(--accent)",width:14,height:14}}
/>
<span style={{
fontFamily:"var(--font-mono)", fontSize:12,
color: checked ? "var(--accent)" : "var(--text-1)",
}}>{d}</span>
</label>
);
})
}
{allDomains.filter(d => !domainSearch || d.toLowerCase().includes(domainSearch.toLowerCase())).length === 0 && (
<div style={{padding:"12px",color:"var(--text-3)",fontSize:12,textAlign:"center"}}>
Aucun domaine trouvé
</div>
)}
</div>
{/* Selected chips */}
{roleForm.scopes.length > 0 && (
<div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:8}}>
{roleForm.scopes.map(s => (
<span key={s} style={{
display:"inline-flex",alignItems:"center",gap:4,
padding:"2px 8px",borderRadius:3,fontSize:11,
background:"var(--accent-dim)",color:"var(--accent)",
border:"1px solid rgba(0,212,255,0.2)",
}}>
{s}
<button onClick={() => setRoleForm({...roleForm, scopes:roleForm.scopes.filter(x=>x!==s)})}
style={{background:"none",border:"none",color:"var(--accent)",cursor:"pointer",
fontSize:12,lineHeight:1,padding:0}}></button>
</span>
))}
</div>
)}
<div style={{fontSize:11,color:"var(--text-3)",marginTop:6}}>
{roleForm.role === "domain_admin" && "Accès complet (DNS + mail) pour ces domaines."}
{roleForm.role === "dns_zone_admin" && "Gestion des enregistrements DNS de ces zones."}
{roleForm.role === "mail_domain_admin" && "Gestion des comptes et listes mail de ces domaines."}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => { setShowRoleModal(null); setDomainSearch(""); }}>Annuler</button>
<button className="btn btn-primary" onClick={assignRole}
disabled={saving || (SCOPE_REQUIRED.includes(roleForm.role) && roleForm.scopes.length === 0)}>
{saving ? "…" : "Assigner"}
</button>
</div>
</div>
</div>
)}
{/* Delete User Confirmation */}
{confirmUser && (
<div className="modal-overlay" onClick={() => setConfirmUser(null)}>
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--red)"}}>Supprimer l'utilisateur</div>
<button className="modal-close" onClick={() => setConfirmUser(null)}></button>
</div>
<div className="modal-body">
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
Supprimer définitivement le compte{" "}
<strong style={{color:"var(--accent)",fontFamily:"var(--font-mono)"}}>{confirmUser.username}</strong>
{confirmUser.full_name && ` (${confirmUser.full_name})`} ?
</p>
<div style={{marginTop:10,padding:"8px 12px",background:"var(--red-dim)",
border:"1px solid rgba(255,74,106,0.2)",borderRadius:6,fontSize:11,color:"var(--red)"}}>
Ses sessions et rôles seront également supprimés. Action irréversible.
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setConfirmUser(null)}>Annuler</button>
<button className="btn btn-danger" onClick={doDeleteUser}>Supprimer</button>
</div>
</div>
</div>
)}
{/* Revoke Role Confirmation */}
{confirmRole && (
<div className="modal-overlay" onClick={() => setConfirmRole(null)}>
<div className="modal" style={{maxWidth:400}} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title" style={{color:"var(--yellow)"}}>Révoquer un rôle</div>
<button className="modal-close" onClick={() => setConfirmRole(null)}></button>
</div>
<div className="modal-body">
<p style={{color:"var(--text-1)",fontSize:13,lineHeight:1.7}}>
Révoquer le rôle suivant ?
</p>
<div style={{marginTop:10,padding:"10px 14px",background:"var(--bg-0)",
border:"1px solid var(--border)",borderRadius:6}}>
<RoleBadge role={confirmRole.role} scope={confirmRole.scope} />
{confirmRole.scope && (
<div style={{fontSize:11,color:"var(--text-2)",marginTop:6}}>
Domaine / zone : <code style={{color:"var(--accent)"}}>{confirmRole.scope}</code>
</div>
)}
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setConfirmRole(null)}>Annuler</button>
<button className="btn btn-danger" onClick={doRevokeRole}>Révoquer</button>
</div>
</div>
</div>
)}
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/dns': 'http://localhost:8000',
'/mail': 'http://localhost:8000',
}
}
})