Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2c69f51a0 | ||
|
|
218a1f14ac |
No files matched your search
@@ -31,6 +31,53 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.19.0] "Lanner" — 2026-10-20
|
||||
|
||||
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende
|
||||
(sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“.
|
||||
|
||||
- **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine
|
||||
Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit.
|
||||
Tokens eines Operators gehen bis zum Bereich „Ausrollen“.
|
||||
- **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens
|
||||
einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`).
|
||||
- **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM,
|
||||
Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen
|
||||
zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte
|
||||
`notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen
|
||||
AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus.
|
||||
- **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer
|
||||
verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen).
|
||||
- **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und
|
||||
Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle
|
||||
`rule_hits` (Migration `0012`).
|
||||
- Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
|
||||
|
||||
## [1.18.0] "Kestrel" — 2026-10-19
|
||||
|
||||
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
|
||||
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.
|
||||
|
||||
- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
|
||||
Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
|
||||
Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
|
||||
Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
|
||||
Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
|
||||
`api_tokens` (Migration `0008`).
|
||||
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
|
||||
offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
|
||||
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
|
||||
Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
|
||||
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
|
||||
erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
|
||||
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
|
||||
0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
|
||||
Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
|
||||
auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
|
||||
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
|
||||
Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
|
||||
Anfrage neu geladen.
|
||||
|
||||
## [1.17.0] "Imperial" — 2026-10-19
|
||||
|
||||
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
|
||||
|
||||
+6
-3
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.17.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.19.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -22,7 +22,10 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
|
||||
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
|
||||
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
- **Automatisierung** — persönliche API-Tokens (Lesen / Ausrollen / Admin), Prometheus-Metriken, ein nur lesender MCP-Endpunkt für KI-Assistenten und ein OpenAPI-Schema.
|
||||
- **Sicherheitslage** — ein Wert von 0 bis 100 je Firewall aus Härtungs- und Regel-Hygiene-Prüfungen (offene WAN-Regeln, schwaches SSH/SNMP, verdeckte Regeln, alte Backups …), nur lesend.
|
||||
- **Backups & Wiederherstellung** — geplante Konfigurationssicherungen mit Aufbewahrung, verschlüsselte Offsite-Kopie auf S3-kompatiblen Speicher, Änderungsansicht zwischen Sicherungen und eine vollständige verschlüsselte Sicherung von OPNexus selbst (`opnexus backup --full`).
|
||||
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Operator / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
|
||||
|
||||
Alle Screenshots zeigen fiktive Beispieldaten.
|
||||
|
||||
@@ -58,7 +61,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.17.0
|
||||
OPNEXUS_VERSION=1.19.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.17.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.19.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -22,7 +22,10 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
|
||||
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
|
||||
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
|
||||
- **Automation** — personal API tokens (read / deploy / admin), Prometheus metrics, a read-only MCP endpoint for AI assistants and an OpenAPI schema.
|
||||
- **Security posture** — a 0–100 score per firewall from hardening and rule-hygiene checks (open WAN rules, weak SSH/SNMP, shadowed rules, stale backups …), read-only.
|
||||
- **Backups & recovery** — scheduled config backups with retention, an encrypted offsite copy to S3-compatible storage, a redacted change view between backups, and a full encrypted backup/restore of OPNexus itself (`opnexus backup --full`).
|
||||
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / operator / read-only), keyboard operation, dark mode, German and English UI.
|
||||
|
||||

|
||||
|
||||
@@ -60,7 +63,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.17.0
|
||||
OPNEXUS_VERSION=1.19.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,7 +8,8 @@ from starlette.responses import JSONResponse
|
||||
from app.database import SessionLocal
|
||||
from app.errors import ErrorCode
|
||||
from app.routers.auth import COOKIE_NAME
|
||||
from app.services import auth_service
|
||||
from app.routers.auth import client_ip
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
_PUBLIC_PATHS = {
|
||||
"/api/auth/login",
|
||||
@@ -35,6 +36,8 @@ _PUBLIC_PATHS = {
|
||||
_READ_ONLY_ALLOWED_WRITES = {
|
||||
"/api/auth/change-password",
|
||||
"/api/settings",
|
||||
# Read-only JSON-RPC tools (see routers/mcp.py).
|
||||
"/api/mcp",
|
||||
# Self-scoped: a user managing their own second factor.
|
||||
"/api/auth/2fa/setup",
|
||||
"/api/auth/2fa/enable",
|
||||
@@ -48,6 +51,7 @@ _READ_ONLY_ALLOWED_WRITES = {
|
||||
_SECURITY_KINDS = {
|
||||
"ipsec": "ipsec_tunnel", "openvpn": "openvpn_instance", "wireguard": "wireguard_tunnel",
|
||||
"certificates": "certificate", "acme": "acme", "notifications": "notification", "auth": "auth",
|
||||
"tokens": "api_token",
|
||||
}
|
||||
_COLLECTIONS = {"tunnels", "instances", "managed", "accounts", "certificates", "settings"}
|
||||
|
||||
@@ -86,19 +90,37 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
if request.url.path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
api_token = None
|
||||
bearer = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
if token:
|
||||
if bearer is not None:
|
||||
# An API token wins over a cookie; a bad one never falls back to it.
|
||||
async with SessionLocal() as db:
|
||||
resolved = await api_token_service.resolve(db, bearer, client_ip(request))
|
||||
user, api_token = resolved if resolved is not None else (None, None)
|
||||
token = bearer
|
||||
elif token:
|
||||
async with SessionLocal() as db:
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if token:
|
||||
if user is not None and api_token is not None and api_token_service.denies(
|
||||
api_token.scope, request.method, request.url.path
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.API_TOKEN_SCOPE_FORBIDDEN.value, "params": {"scope": api_token.scope}}},
|
||||
)
|
||||
if user is not None:
|
||||
parts = request.url.path.strip("/").split("/")
|
||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system"}
|
||||
kinds = {"rules": "rule", "aliases": "alias", "port-forward": "port_forward", "one-to-one": "one_to_one", "outbound-nat": "outbound_nat", "hosts": "host", "users": "user", "system": "system", "backups": "backup"}
|
||||
writing = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
||||
if len(parts) > 1 and parts[1] in _SECURITY_KINDS:
|
||||
event = {**_security_event(parts, request.method), "details": {"method": request.method, "path": request.url.path}}
|
||||
kind = event["kind"]
|
||||
else:
|
||||
kind = kinds.get(parts[1]) if len(parts) > 1 else None
|
||||
if len(parts) > 2 and parts[1] == "settings":
|
||||
kind = "settings" # where OPNexus stores/sends things (own prefs at /api/settings stay unaudited)
|
||||
action = ("delete" if request.method == "DELETE" else parts[-1] if len(parts) > 3 else "edit" if request.method in ("PUT", "PATCH") else "create")
|
||||
event = {"kind": kind, "action": action, "entity_id": parts[2] if len(parts) > 2 else None,
|
||||
"host_id": parts[2] if kind == "host" and len(parts) > 2 else None,
|
||||
@@ -109,10 +131,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
user.role == "read_only"
|
||||
and request.method not in ("GET", "HEAD", "OPTIONS")
|
||||
and request.url.path not in _READ_ONLY_ALLOWED_WRITES
|
||||
# Own API tokens (the router limits a read_only user to read scope).
|
||||
and not request.url.path.startswith("/api/tokens")
|
||||
):
|
||||
if tracked:
|
||||
# Refused attempts at security-relevant writes stay attributable.
|
||||
ctx = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
await audit_service.persist(**event, status="denied", message="Schreibzugriff für Nur-Lese-Konto verweigert")
|
||||
finally:
|
||||
@@ -121,8 +145,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
status_code=403,
|
||||
content={"detail": {"code": ErrorCode.READ_ONLY_ACCESS.value, "params": {}}},
|
||||
)
|
||||
if user.role == "operator" and api_token_service.operator_denies(request.method, request.url.path):
|
||||
if tracked:
|
||||
ctx = audit_service.context.set({"actor": user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150], "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
await audit_service.persist(**event, status="denied", message="Nur für Administratoren")
|
||||
finally:
|
||||
audit_service.context.reset(ctx)
|
||||
return JSONResponse(status_code=403, content={"detail": {"code": ErrorCode.ADMIN_REQUIRED.value, "params": {}}})
|
||||
request.state.user = user
|
||||
token = audit_service.context.set({"actor": user.username, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
request.state.api_token = api_token
|
||||
actor = user.username if api_token is None else f"{user.username} (API: {api_token.name})"[:150]
|
||||
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": event["action"]})
|
||||
try:
|
||||
if tracked:
|
||||
await audit_service.persist(**event, status="requested")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Decrypt an offsite backup (``.opxbak``) back into a config.xml.
|
||||
|
||||
docker compose exec -T backend python -m app.backup_decrypt < file.opxbak > config.xml
|
||||
|
||||
The passphrase comes from the BACKUP_PASSPHRASE environment variable or, if that is
|
||||
not set, from the passphrase stored in OPNexus. For a disaster-recovery machine
|
||||
without OPNexus run it with the passphrase you kept elsewhere:
|
||||
|
||||
docker run --rm -i -e BACKUP_PASSPHRASE=... <backend image> python -m app.backup_decrypt < file.opxbak > config.xml
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from app.services import backup_crypto, notification_secret_store
|
||||
|
||||
|
||||
def main() -> int:
|
||||
passphrase = os.environ.get("BACKUP_PASSPHRASE") or notification_secret_store.read_secret("backup_passphrase")
|
||||
if not passphrase:
|
||||
print("Keine Passphrase: BACKUP_PASSPHRASE setzen.", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
sys.stdout.buffer.write(backup_crypto.decrypt(sys.stdin.buffer.read(), passphrase))
|
||||
except backup_crypto.DecryptError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -53,6 +53,8 @@ class Settings(BaseSettings):
|
||||
# reachable both by IP and by name: "https://10.0.0.5,https://opnexus.lan".
|
||||
frontend_origin: str = ""
|
||||
opnsense_secret_dir: str = "/data/opnsense-secrets"
|
||||
# Lab only: allow an http:// endpoint for offsite backups (credentials in the clear).
|
||||
allow_insecure_offsite: bool = False
|
||||
opnsense_backup_dir: str = "/data/opnsense-backups"
|
||||
opnsense_timeout: float = 10.0
|
||||
# Firewall API credentials are sent as HTTP Basic auth. Plain http://
|
||||
|
||||
@@ -154,6 +154,12 @@ class ErrorCode(str, Enum):
|
||||
SERVICE_NOT_FOUND = "SERVICE_NOT_FOUND"
|
||||
SERVICE_LOCKED = "SERVICE_LOCKED"
|
||||
READ_ONLY_ACCESS = "READ_ONLY_ACCESS"
|
||||
API_TOKEN_LIMIT_REACHED = "API_TOKEN_LIMIT_REACHED"
|
||||
OFFSITE_TEST_FAILED = "OFFSITE_TEST_FAILED"
|
||||
BACKUP_UNLESBAR = "BACKUP_UNLESBAR"
|
||||
API_TOKEN_NOT_FOUND = "API_TOKEN_NOT_FOUND"
|
||||
API_TOKEN_SCOPE_FORBIDDEN = "API_TOKEN_SCOPE_FORBIDDEN"
|
||||
API_TOKEN_SCOPE_TOO_HIGH = "API_TOKEN_SCOPE_TOO_HIGH"
|
||||
|
||||
|
||||
def api_error(status_code: int, code: ErrorCode, **params: Any) -> HTTPException:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Full OPNexus backup: the database and every secret store in one encrypted file.
|
||||
|
||||
The nightly database dump is not enough to rebuild an OPNexus server: the firewalls' API secrets, TOTP
|
||||
secrets, VPN/certificate keys and the stored config snapshots live in separate volumes. This packs all of it
|
||||
(``database.dump`` + the data directories + ``manifest.json``) into a tar.gz and encrypts it with a passphrase
|
||||
(AES-256-GCM, services/backup_crypto.py). Without the passphrase the file is useless, so keep it elsewhere.
|
||||
|
||||
Normally driven by the host CLI:
|
||||
|
||||
./opnexus backup --full [FILE] # writes an encrypted .opxfull file
|
||||
./opnexus restore FILE # onto a fresh or existing installation
|
||||
|
||||
or directly (passphrase in FULL_BACKUP_PASSPHRASE):
|
||||
|
||||
python -m app.full_backup create > file.opxfull
|
||||
python -m app.full_backup info < file.opxfull # manifest only
|
||||
python -m app.full_backup restore < file.opxfull
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.config import settings
|
||||
from app.services import backup_crypto
|
||||
from app.version import APP_VERSION
|
||||
|
||||
FORMAT = 1
|
||||
MIN_PASSPHRASE = 12
|
||||
# manifest key -> settings attribute holding the directory
|
||||
_DATA_DIRS = {
|
||||
"opnsense_secrets": "opnsense_secret_dir",
|
||||
"opnsense_backups": "opnsense_backup_dir",
|
||||
"ipsec_psk_secrets": "ipsec_psk_secret_dir",
|
||||
"openvpn_key_secrets": "openvpn_key_secret_dir",
|
||||
"wireguard_key_secrets": "wireguard_key_secret_dir",
|
||||
"cert_key_secrets": "cert_key_secret_dir",
|
||||
"acme_secrets": "acme_secret_dir",
|
||||
"totp_secrets": "totp_secret_dir",
|
||||
"notification_secrets": "notification_secret_dir",
|
||||
}
|
||||
|
||||
|
||||
class FullBackupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _dir(name: str) -> Path:
|
||||
return Path(getattr(settings, _DATA_DIRS[name]))
|
||||
|
||||
|
||||
def _db_args() -> tuple[list[str], dict]:
|
||||
url = make_url(settings.database_url)
|
||||
return (
|
||||
["-h", url.host or "localhost", "-p", str(url.port or 5432), "-U", url.username or "", "-d", url.database or ""],
|
||||
{**os.environ, "PGPASSWORD": url.password or ""},
|
||||
)
|
||||
|
||||
|
||||
def _dump_database(target: Path) -> None:
|
||||
args, env = _db_args()
|
||||
skip = [f"--exclude-table-data={t}" for t in ("firewall_log_entries", "host_resource_samples")]
|
||||
subprocess.run(["pg_dump", *args, "-F", "c", *skip, "-f", str(target)], env=env, check=True, capture_output=True, timeout=1800)
|
||||
|
||||
|
||||
def _restore_database(source: Path) -> None:
|
||||
args, env = _db_args()
|
||||
result = subprocess.run(
|
||||
["pg_restore", *args, "--clean", "--if-exists", "--no-owner", "--exit-on-error", str(source)],
|
||||
env=env, capture_output=True, text=True, timeout=1800,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise FullBackupError(f"pg_restore fehlgeschlagen: {result.stderr.strip()[:400]}")
|
||||
|
||||
|
||||
def _head_revision() -> str:
|
||||
from alembic.script import ScriptDirectory
|
||||
from app import migrate
|
||||
return ScriptDirectory.from_config(migrate._config()).get_current_head()
|
||||
|
||||
|
||||
def create(passphrase: str, *, dump=_dump_database, revision=None) -> bytes:
|
||||
if len(passphrase) < MIN_PASSPHRASE:
|
||||
raise FullBackupError(f"Die Passphrase braucht mindestens {MIN_PASSPHRASE} Zeichen")
|
||||
manifest = {
|
||||
"format": FORMAT, "app_version": APP_VERSION, "db_revision": revision or _head_revision(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(), "data": sorted(_DATA_DIRS),
|
||||
}
|
||||
plain = io.BytesIO()
|
||||
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=plain, mode="w:gz") as tar:
|
||||
raw = json.dumps(manifest, indent=1).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(raw)
|
||||
tar.addfile(info, io.BytesIO(raw))
|
||||
dump_path = Path(tmp) / "database.dump"
|
||||
dump(dump_path)
|
||||
tar.add(dump_path, arcname="database.dump")
|
||||
for name in _DATA_DIRS:
|
||||
path = _dir(name)
|
||||
if path.is_dir():
|
||||
tar.add(path, arcname=f"data/{name}")
|
||||
return backup_crypto.encrypt(plain.getvalue(), passphrase)
|
||||
|
||||
|
||||
def _open(blob: bytes, passphrase: str) -> tarfile.TarFile:
|
||||
try:
|
||||
plain = backup_crypto.decrypt(blob, passphrase)
|
||||
except backup_crypto.DecryptError as exc:
|
||||
raise FullBackupError(str(exc)) from None
|
||||
try:
|
||||
return tarfile.open(fileobj=io.BytesIO(plain), mode="r:gz")
|
||||
except tarfile.TarError:
|
||||
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Archiv unlesbar)") from None
|
||||
|
||||
|
||||
def _manifest(tar: tarfile.TarFile) -> dict:
|
||||
try:
|
||||
member = tar.extractfile("manifest.json")
|
||||
manifest = json.loads(member.read()) if member else None
|
||||
except (KeyError, ValueError):
|
||||
manifest = None
|
||||
if not isinstance(manifest, dict) or manifest.get("format") != FORMAT:
|
||||
raise FullBackupError("Das ist keine vollständige OPNexus-Sicherung (Manifest fehlt oder ist neuer)")
|
||||
return manifest
|
||||
|
||||
|
||||
def info(blob: bytes, passphrase: str) -> dict:
|
||||
with _open(blob, passphrase) as tar:
|
||||
return _manifest(tar)
|
||||
|
||||
|
||||
def restore(blob: bytes, passphrase: str, *, restore_db=_restore_database, head=None) -> dict:
|
||||
"""Put database and data directories back. The caller stops the backend first."""
|
||||
with _open(blob, passphrase) as tar:
|
||||
manifest = _manifest(tar)
|
||||
if not _older_or_equal(manifest.get("db_revision"), head):
|
||||
raise FullBackupError(
|
||||
f"Die Sicherung stammt aus einer neueren OPNexus-Version ({manifest.get('app_version')}). "
|
||||
"Erst auf diese Version aktualisieren, dann zurückspielen."
|
||||
)
|
||||
members = tar.getmembers()
|
||||
for member in members: # reject anything that could escape: absolute paths, .., links, devices
|
||||
if member.name.startswith(("/", "\\")) or ".." in Path(member.name).parts or not (member.isfile() or member.isdir()):
|
||||
raise FullBackupError(f"Unzulässiger Eintrag im Archiv: {member.name!r}")
|
||||
known = {f"data/{name}" for name in _DATA_DIRS}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dump = tar.extractfile("database.dump")
|
||||
if dump is None:
|
||||
raise FullBackupError("Die Sicherung enthält keine Datenbank")
|
||||
dump_path = Path(tmp) / "database.dump"
|
||||
dump_path.write_bytes(dump.read())
|
||||
restore_db(dump_path)
|
||||
restored = []
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if len(parts) < 2 or parts[0] != "data" or f"data/{parts[1]}" not in known:
|
||||
continue
|
||||
target_root = _dir(parts[1])
|
||||
target = target_root.joinpath(*parts[2:])
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
source = tar.extractfile(member)
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
out.write(source.read())
|
||||
os.utime(target, (member.mtime, member.mtime)) # snapshot ages come from file times
|
||||
restored.append(parts[1])
|
||||
return {**manifest, "files_restored": len(restored)}
|
||||
|
||||
|
||||
def _older_or_equal(revision: str | None, head: str | None) -> bool:
|
||||
"""Revisions are zero-padded numbers ('0009'): a dump from an older schema is fine, the app migrates it."""
|
||||
try:
|
||||
return int(revision) <= int(head or _head_revision())
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _main(argv: list[str]) -> int:
|
||||
passphrase = os.environ.get("FULL_BACKUP_PASSPHRASE", "")
|
||||
if len(argv) != 2 or argv[1] not in ("create", "info", "restore"):
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
if argv[1] == "create":
|
||||
sys.stdout.buffer.write(create(passphrase))
|
||||
elif argv[1] == "info":
|
||||
print(json.dumps(info(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||
else:
|
||||
print(json.dumps(restore(sys.stdin.buffer.read(), passphrase), indent=1))
|
||||
except (FullBackupError, subprocess.SubprocessError, OSError) as exc:
|
||||
print(f"Fehler: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv))
|
||||
+99
-3
@@ -5,6 +5,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
@@ -17,7 +18,7 @@ from app.security_middleware import OriginCheckMiddleware, SecurityHeadersMiddle
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app import migrate
|
||||
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router
|
||||
from app.routers import auth, hosts, logs, rules, groups, aliases, port_forward, search as search_router, users as users_router, tokens as tokens_router, metrics as metrics_router, mcp as mcp_router, compliance as compliance_router, audit_export as audit_export_router, backups as backups_router
|
||||
from app.routers import one_to_one
|
||||
from app.services.one_to_one_deploy_service import sweep_expired_one_to_one_deployments
|
||||
from app.routers import outbound_nat
|
||||
@@ -49,6 +50,9 @@ from app.routers import resource_samples as resource_samples_router
|
||||
from app.services import resource_monitor_service
|
||||
from app.routers import scheduled_maintenance as scheduled_maintenance_router
|
||||
from app.services import scheduled_maintenance_service
|
||||
from app.services import compliance_service
|
||||
from app.services import audit_export_service
|
||||
from app.services import backup_schedule_service
|
||||
from app.services import carp_event_service, carp_maintenance_window_service
|
||||
|
||||
# Without this, Python's root logger defaults to WARNING with no handler,
|
||||
@@ -254,6 +258,57 @@ async def _scheduled_maintenance_poll_loop():
|
||||
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
|
||||
|
||||
|
||||
_COMPLIANCE_FIRST_RUN_SECONDS = 120
|
||||
_COMPLIANCE_INTERVAL_SECONDS = 6 * 3600
|
||||
|
||||
|
||||
async def _compliance_poll_loop():
|
||||
"""Sixth background loop: refreshes every enabled host's security-posture result.
|
||||
Each check is one read-only config download per firewall, so every six hours
|
||||
is plenty; the first run waits a little so a restart is not a thundering herd."""
|
||||
await asyncio.sleep(_COMPLIANCE_FIRST_RUN_SECONDS)
|
||||
while True:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
await compliance_service.check_all(db)
|
||||
except Exception:
|
||||
logger.exception("Sicherheitslage-Prüfung fehlgeschlagen")
|
||||
await asyncio.sleep(_COMPLIANCE_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
_AUDIT_EXPORT_POLL_SECONDS = 10
|
||||
|
||||
|
||||
async def _audit_export_poll_loop():
|
||||
"""Seventh background loop: forwards new audit events (syslog / webhook) when configured.
|
||||
Cheap when off (one settings read); keeps draining while batches come back full."""
|
||||
while True:
|
||||
shipped = 0
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
shipped = await audit_export_service.run_once(db)
|
||||
except Exception:
|
||||
logger.exception("Audit-Export fehlgeschlagen")
|
||||
await asyncio.sleep(0 if shipped >= audit_export_service.BATCH else _AUDIT_EXPORT_POLL_SECONDS)
|
||||
|
||||
|
||||
_BACKUP_POLL_SECONDS = 15 * 60
|
||||
|
||||
|
||||
async def _backup_poll_loop():
|
||||
"""Eighth background loop: takes due scheduled config backups, prunes, copies offsite."""
|
||||
await asyncio.sleep(90)
|
||||
while True:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
result = await backup_schedule_service.run_due(db)
|
||||
if result["taken"] or result["failed"] or result["uploaded"]:
|
||||
logger.info("Geplante Backups: %s", result)
|
||||
except Exception:
|
||||
logger.exception("Geplante Backups fehlgeschlagen")
|
||||
await asyncio.sleep(_BACKUP_POLL_SECONDS)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await migrate.run()
|
||||
@@ -272,6 +327,9 @@ async def lifespan(app: FastAPI):
|
||||
log_ingest_task = asyncio.create_task(_log_ingest_poll_loop())
|
||||
resource_task = asyncio.create_task(_resource_poll_loop())
|
||||
scheduled_maintenance_task = asyncio.create_task(_scheduled_maintenance_poll_loop())
|
||||
compliance_task = asyncio.create_task(_compliance_poll_loop())
|
||||
audit_export_task = asyncio.create_task(_audit_export_poll_loop())
|
||||
backup_task = asyncio.create_task(_backup_poll_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -280,14 +338,46 @@ async def lifespan(app: FastAPI):
|
||||
log_ingest_task.cancel()
|
||||
resource_task.cancel()
|
||||
scheduled_maintenance_task.cancel()
|
||||
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task):
|
||||
compliance_task.cancel()
|
||||
audit_export_task.cancel()
|
||||
backup_task.cancel()
|
||||
for task in (sweep_task, notification_task, log_ingest_task, resource_task, scheduled_maintenance_task, compliance_task, audit_export_task, backup_task):
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="OPNexus", version=APP_VERSION, lifespan=lifespan)
|
||||
API_DESCRIPTION = (
|
||||
"REST API of OPNexus, the central management for OPNsense firewall fleets.\n\n"
|
||||
"**Authentication:** send a personal API token as `Authorization: Bearer opx_...` "
|
||||
"(create one under *System > API tokens*). Scopes: `read` (GET only), `deploy` "
|
||||
"(+ roll out rules, NAT, VPN, maintenance), `admin` (+ users, settings, hosts). "
|
||||
"A token never exceeds its owner's role. Token and account management is session-only.\n\n"
|
||||
"Errors are `{\"detail\": {\"code\": \"...\", \"params\": {...}}}` with a stable `code`."
|
||||
)
|
||||
|
||||
# The interactive Swagger/ReDoc pages load scripts from a CDN, which the proxy's
|
||||
# Content-Security-Policy forbids; the schema itself is served (behind auth) for
|
||||
# any client that renders it (Postman, openapi-generator, an AI assistant).
|
||||
app = FastAPI(
|
||||
title="OPNexus", version=APP_VERSION, lifespan=lifespan, description=API_DESCRIPTION,
|
||||
docs_url=None, redoc_url=None, openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
def _custom_openapi():
|
||||
if app.openapi_schema is None:
|
||||
schema = get_openapi(title=app.title, version=app.version, description=app.description, routes=app.routes)
|
||||
schema.setdefault("components", {}).setdefault("securitySchemes", {})["bearerAuth"] = {
|
||||
"type": "http", "scheme": "bearer", "description": "Personal API token (opx_...)",
|
||||
}
|
||||
schema["security"] = [{"bearerAuth": []}]
|
||||
app.openapi_schema = schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = _custom_openapi
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _safe_validation_error(request: Request, exc: RequestValidationError):
|
||||
@@ -347,6 +437,12 @@ app.include_router(hosts.router, prefix="/api")
|
||||
app.include_router(rules.router, prefix="/api")
|
||||
app.include_router(search_router.router, prefix="/api")
|
||||
app.include_router(users_router.router, prefix="/api")
|
||||
app.include_router(tokens_router.router, prefix="/api")
|
||||
app.include_router(metrics_router.router, prefix="/api")
|
||||
app.include_router(mcp_router.router, prefix="/api")
|
||||
app.include_router(compliance_router.router, prefix="/api")
|
||||
app.include_router(audit_export_router.router, prefix="/api")
|
||||
app.include_router(backups_router.router, prefix="/api")
|
||||
app.include_router(logs.router, prefix="/api")
|
||||
app.include_router(groups.router, prefix="/api")
|
||||
app.include_router(aliases.router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Personal API tokens for scripts, CI jobs, Prometheus and MCP clients.
|
||||
|
||||
Only the SHA-256 of the token is stored (the token is 256 random bits, so a
|
||||
fast hash is enough and lets a request be resolved with one indexed lookup);
|
||||
the raw value is shown exactly once, at creation. A token belongs to a user and
|
||||
never exceeds that user's role: see services/api_token_service.py for the
|
||||
scopes and AuthMiddleware for where they are enforced.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
# "read" | "deploy" | "admin"
|
||||
scope: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
# First characters of the token, so a list can tell tokens apart. Not secret.
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
|
||||
user: Mapped["AdminUser"] = relationship("AdminUser")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Forwarding of the audit trail to a syslog server and/or a webhook (singleton row).
|
||||
|
||||
``cursor_*`` is the position of the last shipped event, so delivery resumes where
|
||||
it stopped (at-least-once). Enabling a channel starts at "now": the existing history
|
||||
is not replayed. The webhook signing secret lives on disk, like every other secret
|
||||
(services/notification_secret_store.py).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditExportSettings(Base):
|
||||
__tablename__ = "audit_export_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
syslog_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
syslog_host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
syslog_port: Mapped[int] = mapped_column(Integer, nullable=False, default=514)
|
||||
# "udp" | "tcp" | "tls"
|
||||
syslog_protocol: Mapped[str] = mapped_column(String(3), nullable=False, default="udp")
|
||||
webhook_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
webhook_signing_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
cursor_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cursor_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
last_error_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Scheduled config backups and their offsite copy (singleton row + upload ledger).
|
||||
|
||||
The S3 secret key and the encryption passphrase live on disk (notification_secret_store,
|
||||
names ``backup_s3_secret`` / ``backup_passphrase``), never here.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BackupSettings(Base):
|
||||
__tablename__ = "backup_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scheduled_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
interval_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24)
|
||||
keep_count: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||
offsite_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
s3_endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_region: Mapped[str] = mapped_column(String(64), nullable=False, default="us-east-1")
|
||||
s3_bucket: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_prefix: Mapped[str] = mapped_column(String(255), nullable=False, default="opnexus")
|
||||
s3_access_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
s3_path_style: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
s3_secret_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
passphrase_set: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_offsite_ok_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_offsite_error: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
|
||||
|
||||
class BackupUpload(Base):
|
||||
"""Which local snapshot already exists offsite (so nothing is uploaded twice)."""
|
||||
|
||||
__tablename__ = "backup_uploads"
|
||||
__table_args__ = (UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||
snapshot_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
remote_key: Mapped[str] = mapped_column(String(400), nullable=False)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Latest security-posture result per host (see services/compliance_service.py).
|
||||
|
||||
One row per host, replaced on every check. The findings never contain
|
||||
configuration values beyond rule descriptions and the protocol names they
|
||||
report on; secrets from config.xml are never read into them.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ComplianceResult(Base):
|
||||
__tablename__ = "compliance_results"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
|
||||
)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# None when the check could not run (see error).
|
||||
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
findings: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# None | "unreachable" | "invalid_config"
|
||||
error: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
error_detail: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
@@ -51,6 +51,7 @@ class NotificationSettings(Base):
|
||||
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""When did a firewall rule last match something? (per host and rule UUID)
|
||||
|
||||
pf's own counters restart whenever the ruleset is reloaded (every apply, every reboot), so
|
||||
"packets = 0" alone proves nothing. OPNexus samples them periodically and keeps the time of the
|
||||
last observed hit, which survives those resets. See services/rule_hit_service.py.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RuleHit(Base):
|
||||
__tablename__ = "rule_hits"
|
||||
__table_args__ = (UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
host_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey("opnsense_hosts.id", ondelete="CASCADE"), index=True)
|
||||
rule_uuid: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# None = never seen a hit since OPNexus started watching.
|
||||
last_hit_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_packets: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
last_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
@@ -3,7 +3,7 @@
|
||||
For a fresh install whose generated first-start password scrolled out of the
|
||||
log, and for an admin who forgot theirs. Needs shell access to the server,
|
||||
which is the trust boundary for this tool anyway (same as app.reset_2fa).
|
||||
All sessions of the account are ended. Normally called through the host
|
||||
All sessions and API tokens of the account are ended. Normally called through the host
|
||||
CLI:
|
||||
|
||||
./opnexus password [username]
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy import select
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.user import AdminUser
|
||||
from app.services import auth_service
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
|
||||
async def main(username: str | None) -> int:
|
||||
@@ -48,6 +48,7 @@ async def main(username: str | None) -> int:
|
||||
password = secrets.token_urlsafe(18)
|
||||
user.password_hash = await auth_service.hash_password_async(password)
|
||||
await auth_service.revoke_sessions(db, user.id, commit=False)
|
||||
await api_token_service.revoke_all(db, user.id, commit=False)
|
||||
await db.commit()
|
||||
name = user.username
|
||||
has_totp = user.totp_enabled
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Audit export settings (admin only): where the audit trail is forwarded to."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.audit_export import AuditExportRead, AuditExportUpdate
|
||||
from app.services import audit_export_service as svc
|
||||
from app.services import notification_secret_store
|
||||
|
||||
router = APIRouter(prefix="/settings/audit-export", tags=["audit-export"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
@router.get("", response_model=AuditExportRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.put("", response_model=AuditExportRead)
|
||||
async def update(payload: AuditExportUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
row = await svc.get_settings(db)
|
||||
was_active = svc.active(row)
|
||||
for field in ("syslog_enabled", "syslog_host", "syslog_port", "syslog_protocol", "webhook_enabled", "webhook_url"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value.strip() if isinstance(value, str) else value)
|
||||
if payload.clear_webhook_signing_secret:
|
||||
notification_secret_store.delete_secret(svc.SECRET_NAME)
|
||||
row.webhook_signing_secret_set = False
|
||||
elif payload.webhook_signing_secret is not None:
|
||||
notification_secret_store.save_secret(svc.SECRET_NAME, payload.webhook_signing_secret)
|
||||
row.webhook_signing_secret_set = True
|
||||
if not svc.active(row):
|
||||
row.cursor_created_at = row.cursor_id = None # next enabling starts from "now" again
|
||||
elif not was_active:
|
||||
row.cursor_created_at, row.cursor_id = datetime.now(timezone.utc), None
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
try:
|
||||
await svc.send_test(await svc.get_settings(db))
|
||||
except svc.ExportError as exc:
|
||||
raise api_error(502, ErrorCode.UPSTREAM_REQUEST_FAILED) from exc
|
||||
return {"success": True}
|
||||
@@ -9,7 +9,7 @@ from app.errors import ErrorCode, api_error
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.services import auth_service, totp_service
|
||||
from app.services import api_token_service, auth_service, totp_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,6 +63,14 @@ def _account_success(user: AdminUser, action: str) -> None:
|
||||
|
||||
|
||||
async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
|
||||
# AuthMiddleware already resolved the caller (session cookie or API token), but
|
||||
# in its own, closed DB session: re-load into this request's session so that
|
||||
# changes made to the user (2FA, password...) are actually persisted.
|
||||
resolved = getattr(request.state, "user", None)
|
||||
if resolved is not None:
|
||||
user = await db.get(AdminUser, resolved.id)
|
||||
if user is not None:
|
||||
return user
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
@@ -262,6 +270,14 @@ async def logout(request: Request, response: Response, db: AsyncSession = Depend
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
||||
if raw is not None:
|
||||
resolved = await api_token_service.resolve(db, raw, client_ip(request))
|
||||
if resolved is None:
|
||||
return {"authenticated": False}
|
||||
user, api_token = resolved
|
||||
return {"authenticated": True, "username": user.username, "role": user.role,
|
||||
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
user = await auth_service.resolve_session(db, token)
|
||||
if user is None:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Scheduled backups: status for everybody, settings and offsite test for admins, "run now" for operators."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.backup_settings import BackupSettingsRead, BackupSettingsUpdate, BackupStatusRead
|
||||
from app.services import backup_diff_service, backup_store
|
||||
from app.services import backup_schedule_service as svc
|
||||
from app.services import notification_secret_store
|
||||
from app.services.s3_client import S3Error
|
||||
|
||||
router = APIRouter(tags=["backups"])
|
||||
|
||||
|
||||
def _require_admin(user: AdminUser) -> None:
|
||||
if user.role != "admin":
|
||||
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
||||
|
||||
|
||||
@router.get("/backups/status", response_model=BackupStatusRead)
|
||||
async def status(db: AsyncSession = Depends(get_db)):
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.post("/backups/run")
|
||||
async def run_now(db: AsyncSession = Depends(get_db)):
|
||||
"""Back up every enabled host now (and copy offsite if configured)."""
|
||||
return await svc.run_due(db, force=True)
|
||||
|
||||
|
||||
@router.get("/settings/backups", response_model=BackupSettingsRead)
|
||||
async def read(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
@router.put("/settings/backups", response_model=BackupSettingsRead)
|
||||
async def update(payload: BackupSettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
_require_admin(user)
|
||||
row = await svc.get_settings(db)
|
||||
for field in ("scheduled_enabled", "interval_hours", "keep_count", "offsite_enabled", "s3_region", "s3_path_style"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value)
|
||||
for field in ("s3_endpoint", "s3_bucket", "s3_access_key", "s3_prefix"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(row, field, value.strip().rstrip("/") if field == "s3_endpoint" else value.strip())
|
||||
if payload.clear_s3_secret:
|
||||
notification_secret_store.delete_secret(svc.S3_SECRET)
|
||||
row.s3_secret_set = False
|
||||
elif payload.s3_secret_key is not None:
|
||||
notification_secret_store.save_secret(svc.S3_SECRET, payload.s3_secret_key)
|
||||
row.s3_secret_set = True
|
||||
if payload.passphrase is not None:
|
||||
notification_secret_store.save_secret(svc.PASSPHRASE, payload.passphrase)
|
||||
row.passphrase_set = True
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/settings/backups/test-offsite")
|
||||
async def test_offsite(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Write, read back and delete a probe object in the bucket."""
|
||||
_require_admin(user)
|
||||
cfg = await svc.get_settings(db)
|
||||
try:
|
||||
key = "/".join(p for p in (cfg.s3_prefix.strip("/"), f".opnexus-test-{int(datetime.now(timezone.utc).timestamp())}") if p)
|
||||
await svc.s3_for(cfg).test(key)
|
||||
except S3Error as exc:
|
||||
raise api_error(502, ErrorCode.OFFSITE_TEST_FAILED, reason=str(exc)) from exc
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/backup-diff")
|
||||
async def backup_diff(
|
||||
host_id: uuid.UUID,
|
||||
a: str | None = Query(default=None, description="Older backup id (default: the one before b)"),
|
||||
b: str | None = Query(default=None, description="Newer backup id (default: the newest)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""What changed between two stored backups of this host. Secrets are replaced by hashes."""
|
||||
if await db.get(OpnsenseHost, host_id) is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
snapshots = [s["id"] for s in backup_store.list_snapshots(host_id)] # newest first
|
||||
new_id = b or (snapshots[0] if snapshots else None)
|
||||
old_id = a or (snapshots[snapshots.index(new_id) + 1] if new_id in snapshots and snapshots.index(new_id) + 1 < len(snapshots) else None)
|
||||
if not new_id or not old_id:
|
||||
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||
old, new = backup_store.read_snapshot(host_id, old_id), backup_store.read_snapshot(host_id, new_id)
|
||||
if old is None or new is None:
|
||||
raise api_error(404, ErrorCode.BACKUP_NICHT_GEFUNDEN)
|
||||
try:
|
||||
result = backup_diff_service.diff(old, new)
|
||||
except backup_diff_service.InvalidConfig as exc:
|
||||
raise api_error(422, ErrorCode.BACKUP_UNLESBAR, reason=str(exc)) from exc
|
||||
return {"a": old_id, "b": new_id, **result}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Security posture ("Sicherheitslage"): stored results and on-demand checks.
|
||||
|
||||
Reads (GET) only return what the last check stored, so they are cheap and safe
|
||||
for dashboards, Prometheus and MCP. A check (POST) downloads the host's config
|
||||
read-only, evaluates it and stores the result; see services/compliance_service.py.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.rule_hit import RuleHit
|
||||
from app.services import compliance_service, rule_hit_service
|
||||
|
||||
router = APIRouter(tags=["compliance"])
|
||||
|
||||
|
||||
def _read(host: OpnsenseHost, row: ComplianceResult | None, *, detail: bool) -> dict:
|
||||
result = {
|
||||
"host_id": host.id, "host_name": host.name, "cluster_name": host.cluster_name, "enabled": host.enabled,
|
||||
"checked_at": row.checked_at if row else None,
|
||||
"score": row.score if row else None,
|
||||
"grade": compliance_service.grade(row.score) if row and row.score is not None else None,
|
||||
"error": row.error if row else None,
|
||||
"counts": compliance_service.counts(row.findings) if row else None,
|
||||
}
|
||||
if detail:
|
||||
result["findings"] = row.findings if row else []
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/compliance")
|
||||
async def fleet_compliance(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
rows = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
|
||||
return [_read(host, rows.get(host.id), detail=False) for host in hosts]
|
||||
|
||||
|
||||
@router.post("/compliance/check")
|
||||
async def check_fleet(db: AsyncSession = Depends(get_db)):
|
||||
await compliance_service.check_all(db)
|
||||
return await fleet_compliance(db)
|
||||
|
||||
|
||||
async def _host(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
|
||||
host = await db.get(OpnsenseHost, host_id)
|
||||
if host is None:
|
||||
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
||||
return host
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/compliance")
|
||||
async def host_compliance(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host_id))
|
||||
return _read(host, row, detail=True)
|
||||
|
||||
|
||||
@router.post("/hosts/{host_id}/compliance/check")
|
||||
async def check_host(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
host = await _host(db, host_id)
|
||||
row = await compliance_service.check_host(db, host)
|
||||
return _read(host, row, detail=True)
|
||||
|
||||
|
||||
@router.get("/hosts/{host_id}/rule-hits")
|
||||
async def rule_hits(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""When each rule last matched something, as observed by OPNexus (see services/rule_hit_service.py)."""
|
||||
await _host(db, host_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
|
||||
return sorted(
|
||||
(
|
||||
{
|
||||
"rule_uuid": r.rule_uuid, "description": r.description, "enabled": r.enabled,
|
||||
"state": rule_hit_service.classify(r, now), "packets": r.last_packets, "bytes": r.last_bytes,
|
||||
"last_hit_at": r.last_hit_at, "first_seen_at": r.first_seen_at, "last_sampled_at": r.last_sampled_at,
|
||||
}
|
||||
for r in rows
|
||||
),
|
||||
key=lambda item: (item["state"] != "unused", item["last_hit_at"] or datetime.min.replace(tzinfo=timezone.utc), item["description"]),
|
||||
)
|
||||
@@ -184,18 +184,8 @@ async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]:
|
||||
return actions
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardSummary)
|
||||
async def summary(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
statuses = await get_statuses(hosts)
|
||||
host_summaries = [
|
||||
DashboardHost(
|
||||
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
|
||||
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
|
||||
)
|
||||
for host in hosts
|
||||
]
|
||||
|
||||
async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
|
||||
"""Everything that needs a human, newest first (shared with the metrics endpoint)."""
|
||||
pending_actions: list[PendingAction] = []
|
||||
pending_actions += await _rule_actions(db)
|
||||
pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward")
|
||||
@@ -209,6 +199,22 @@ async def summary(db: AsyncSession = Depends(get_db)):
|
||||
pending_actions += await _stuck_host_operations(db)
|
||||
pending_actions += await _carp_auto_revert_actions(db)
|
||||
pending_actions.sort(key=lambda item: item.created_at, reverse=True)
|
||||
return pending_actions
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardSummary)
|
||||
async def summary(db: AsyncSession = Depends(get_db)):
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
statuses = await get_statuses(hosts)
|
||||
host_summaries = [
|
||||
DashboardHost(
|
||||
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
|
||||
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
|
||||
)
|
||||
for host in hosts
|
||||
]
|
||||
|
||||
pending_actions = await collect_pending_actions(db)
|
||||
|
||||
activity_rows = (
|
||||
await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15))
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Read-only MCP server (Model Context Protocol, streamable HTTP transport).
|
||||
|
||||
Lets an AI assistant (Claude Code, Claude Desktop, ...) look at the fleet:
|
||||
|
||||
claude mcp add --transport http opnexus https://<opnexus>/api/mcp \
|
||||
--header "Authorization: Bearer opx_..."
|
||||
|
||||
Stateless JSON-RPC over POST (the spec allows plain ``application/json``
|
||||
replies; there is no server-initiated stream, GET answers 405). Every tool is a
|
||||
GET against OPNexus' own API, sent through the app itself with the caller's own
|
||||
credentials, so exactly the caller's permissions apply: a ``read`` token sees
|
||||
what a ``read`` token sees, secret-bearing endpoints stay closed. Nothing here
|
||||
can change state.
|
||||
"""
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.version import APP_VERSION
|
||||
|
||||
router = APIRouter(tags=["mcp"])
|
||||
|
||||
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
||||
MAX_RESULT_CHARS = 60_000
|
||||
# Not reachable through the generic reader (credentials management, the MCP endpoint itself).
|
||||
_BLOCKED_PREFIXES = ("/api/tokens", "/api/auth", "/api/mcp")
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "fleet_overview",
|
||||
"description": "Fleet status at a glance: every firewall with online state and CARP role, items that need "
|
||||
"attention (failed or unconfirmed deployments, stuck maintenance) and the latest activity.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/dashboard",
|
||||
},
|
||||
{
|
||||
"name": "list_hosts",
|
||||
"description": "All managed firewalls with name, URL, cluster and whether monitoring is enabled.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/hosts",
|
||||
},
|
||||
{
|
||||
"name": "host_resources",
|
||||
"description": "Recent CPU load, memory and disk usage samples of one firewall (a sample per minute).",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["host_id"],
|
||||
"properties": {
|
||||
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
|
||||
"since_hours": {"type": "integer", "minimum": 1, "maximum": 720, "default": 1},
|
||||
},
|
||||
},
|
||||
"path": "/api/hosts/{host_id}/resource-samples",
|
||||
"query": ("since_hours",),
|
||||
# The raw per-sample payload (every interface counter) would drown the useful numbers.
|
||||
"drop": ("raw",),
|
||||
},
|
||||
{
|
||||
"name": "carp_events",
|
||||
"description": "History of CARP role changes (MASTER/BACKUP) across the clusters.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"path": "/api/cluster/carp-events",
|
||||
},
|
||||
{
|
||||
"name": "recent_audit",
|
||||
"description": "The audit trail: who changed or deployed what, newest first.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
|
||||
"kind": {"type": "string", "description": "e.g. rule, alias, host, user"},
|
||||
"status": {"type": "string", "description": "e.g. accepted, rejected, denied"},
|
||||
"q": {"type": "string", "description": "free-text filter"},
|
||||
},
|
||||
},
|
||||
"path": "/api/audit",
|
||||
"query": ("limit", "kind", "status", "q"),
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search rules, aliases, NAT, VPN, certificates, groups and hosts by text.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["q"],
|
||||
"properties": {"q": {"type": "string", "description": "at least 2 characters"}},
|
||||
},
|
||||
"path": "/api/search",
|
||||
"query": ("q",),
|
||||
},
|
||||
{
|
||||
"name": "api_get",
|
||||
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
|
||||
"The schema of all endpoints is at /api/openapi.json.",
|
||||
"inputSchema": {
|
||||
"type": "object", "required": ["path"],
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Starts with /api/"},
|
||||
"query": {"type": "object", "description": "Query parameters", "additionalProperties": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _tool_list() -> list[dict]:
|
||||
return [{k: v for k, v in tool.items() if k in ("name", "description", "inputSchema")} for tool in TOOLS]
|
||||
|
||||
|
||||
def _text(text: str, is_error: bool = False) -> dict:
|
||||
if len(text) > MAX_RESULT_CHARS:
|
||||
text = text[:MAX_RESULT_CHARS] + f"\n… truncated ({len(text)} characters in total); narrow the request."
|
||||
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
||||
|
||||
|
||||
async def _get(request: Request, path: str, query: dict, drop: tuple = ()) -> dict:
|
||||
if not path.startswith("/api/") or ".." in path or "?" in path or "#" in path or path.startswith(_BLOCKED_PREFIXES):
|
||||
return _text("This path cannot be read through MCP.", True)
|
||||
headers = {k: v for k in ("authorization", "cookie") if (v := request.headers.get(k))}
|
||||
url = path + (("?" + urlencode(query)) if query else "")
|
||||
transport = httpx.ASGITransport(app=request.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://opnexus", headers=headers, timeout=30) as client:
|
||||
response = await client.get(url)
|
||||
body = response.text
|
||||
try:
|
||||
data = response.json()
|
||||
if drop and isinstance(data, list):
|
||||
data = [{k: v for k, v in item.items() if k not in drop} if isinstance(item, dict) else item for item in data]
|
||||
body = json.dumps(data, ensure_ascii=False, indent=1)
|
||||
except ValueError:
|
||||
pass
|
||||
return _text(body, response.status_code >= 400)
|
||||
|
||||
|
||||
async def _call_tool(request: Request, name: str, arguments: dict) -> dict:
|
||||
tool = next((t for t in TOOLS if t["name"] == name), None)
|
||||
if tool is None:
|
||||
return _text(f"Unknown tool: {name}", True)
|
||||
if name == "api_get":
|
||||
query = arguments.get("query") or {}
|
||||
if not isinstance(arguments.get("path"), str) or not isinstance(query, dict):
|
||||
return _text("path (string) is required.", True)
|
||||
return await _get(request, arguments["path"], {str(k): str(v) for k, v in query.items()})
|
||||
path = tool["path"]
|
||||
if "{host_id}" in path:
|
||||
host_id = str(arguments.get("host_id", ""))
|
||||
if not host_id.replace("-", "").isalnum():
|
||||
return _text("host_id is required (see list_hosts).", True)
|
||||
path = path.format(host_id=host_id)
|
||||
query = {key: arguments[key] for key in tool.get("query", ()) if arguments.get(key) not in (None, "")}
|
||||
return await _get(request, path, query, tool.get("drop", ()))
|
||||
|
||||
|
||||
def _error(request_id, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
async def _handle(request: Request, message: dict) -> dict | None:
|
||||
"""The JSON-RPC response for one message, None for a notification."""
|
||||
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or "method" not in message:
|
||||
return _error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request")
|
||||
method, request_id = message["method"], message.get("id")
|
||||
params = message.get("params") or {}
|
||||
if "id" not in message: # notification (e.g. notifications/initialized): nothing to answer
|
||||
return None
|
||||
if method == "initialize":
|
||||
requested = params.get("protocolVersion")
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {
|
||||
"protocolVersion": requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0],
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": "opnexus", "version": APP_VERSION},
|
||||
"instructions": "Read-only access to an OPNsense fleet managed by OPNexus.",
|
||||
}}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}}
|
||||
if method == "tools/call":
|
||||
arguments = params.get("arguments") or {}
|
||||
if not isinstance(params.get("name"), str) or not isinstance(arguments, dict):
|
||||
return _error(request_id, -32602, "name (string) and arguments (object) are required")
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": await _call_tool(request, params["name"], arguments)}
|
||||
return _error(request_id, -32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
@router.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return JSONResponse(_error(None, -32700, "Parse error"), status_code=400)
|
||||
if isinstance(payload, list):
|
||||
replies = [reply for message in payload if (reply := await _handle(request, message)) is not None]
|
||||
return JSONResponse(replies) if replies else Response(status_code=202)
|
||||
reply = await _handle(request, payload)
|
||||
return JSONResponse(reply) if reply is not None else Response(status_code=202)
|
||||
|
||||
|
||||
@router.get("/mcp")
|
||||
async def mcp_no_stream():
|
||||
# No server-initiated messages: tell clients not to expect an SSE stream.
|
||||
return Response(status_code=405, headers={"Allow": "POST"})
|
||||
@@ -0,0 +1,18 @@
|
||||
"""GET /api/metrics — Prometheus text exposition (see services/metrics_service.py).
|
||||
|
||||
Authenticated like everything else; the intended client is a Prometheus job
|
||||
with ``authorization: {credentials: <read token>}``.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services import metrics_service
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=PlainTextResponse)
|
||||
async def metrics(db: AsyncSession = Depends(get_db)):
|
||||
return PlainTextResponse(await metrics_service.render(db), media_type=metrics_service.CONTENT_TYPE)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Manage personal API tokens (see services/api_token_service.py for scopes).
|
||||
|
||||
Session-only: an API token cannot reach these endpoints (AuthMiddleware), so a
|
||||
leaked token can never mint a replacement or list/revoke its siblings. A
|
||||
read_only user may manage their own tokens, but only with read scope (an operator up to deploy).
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import _account_failure, _account_success, _check_account_budget, current_user
|
||||
from app.schemas.api_token import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, TokenScope
|
||||
from app.services import api_token_service, auth_service
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
def _read(token: ApiToken, owner: str) -> dict:
|
||||
return {
|
||||
"id": token.id, "name": token.name, "scope": token.scope, "prefix": token.prefix, "owner": owner,
|
||||
"created_at": token.created_at, "expires_at": token.expires_at,
|
||||
"last_used_at": token.last_used_at, "last_used_ip": token.last_used_ip,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApiTokenRead])
|
||||
async def list_tokens(db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
||||
"""Own tokens; an admin sees everybody's (to be able to revoke them)."""
|
||||
query = select(ApiToken, AdminUser.username).join(AdminUser, ApiToken.user_id == AdminUser.id)
|
||||
if user.role != "admin":
|
||||
query = query.where(ApiToken.user_id == user.id)
|
||||
rows = (await db.execute(query.order_by(ApiToken.created_at.desc()))).all()
|
||||
return [_read(token, owner) for token, owner in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
payload: ApiTokenCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
||||
):
|
||||
allowed = {"admin": (TokenScope.read, TokenScope.deploy, TokenScope.admin), "operator": (TokenScope.read, TokenScope.deploy)}
|
||||
if payload.scope not in allowed.get(user.role, (TokenScope.read,)):
|
||||
raise api_error(403, ErrorCode.API_TOKEN_SCOPE_TOO_HIGH)
|
||||
_check_account_budget(user, "create-token")
|
||||
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
||||
_account_failure(user, "create-token")
|
||||
raise api_error(401, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
||||
_account_success(user, "create-token")
|
||||
token, raw = await api_token_service.create(
|
||||
db, user, name=payload.name.strip(), scope=payload.scope.value, expires_in_days=payload.expires_in_days
|
||||
)
|
||||
return {**_read(token, user.username), "token": raw}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)
|
||||
):
|
||||
token = await db.get(ApiToken, token_id)
|
||||
if token is None or (token.user_id != user.id and user.role != "admin"):
|
||||
raise api_error(404, ErrorCode.API_TOKEN_NOT_FOUND)
|
||||
await db.delete(token)
|
||||
await db.commit()
|
||||
@@ -9,7 +9,7 @@ from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.routers.auth import current_user
|
||||
from app.schemas.user import UserCreate, UserPasswordReset, UserRead, UserRoleUpdate
|
||||
from app.services import auth_service, totp_service
|
||||
from app.services import api_token_service, auth_service, totp_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -79,6 +79,7 @@ async def reset_password(user_id: uuid.UUID, payload: UserPasswordReset, db: Asy
|
||||
# A reset is the answer to a suspected compromise: every session of the
|
||||
# account ends with the old password, atomically with the new hash.
|
||||
await auth_service.revoke_sessions(db, target.id, commit=False)
|
||||
await api_token_service.revoke_all(db, target.id, commit=False)
|
||||
await db.commit()
|
||||
return target
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TokenScope(str, Enum):
|
||||
read = "read"
|
||||
deploy = "deploy"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
class ApiTokenCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
# Least privilege by default.
|
||||
scope: TokenScope = TokenScope.read
|
||||
# None = never expires. The UI defaults to 90 days.
|
||||
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
# Creating a long-lived credential needs the account password again.
|
||||
current_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class ApiTokenRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
scope: TokenScope
|
||||
prefix: str
|
||||
owner: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None
|
||||
last_used_at: datetime | None
|
||||
last_used_ip: str | None
|
||||
|
||||
|
||||
class ApiTokenCreated(ApiTokenRead):
|
||||
# The only time the raw token is ever returned.
|
||||
token: str
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AuditExportRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
syslog_enabled: bool
|
||||
syslog_host: str | None
|
||||
syslog_port: int
|
||||
syslog_protocol: Literal["udp", "tcp", "tls"]
|
||||
webhook_enabled: bool
|
||||
webhook_url: str | None
|
||||
webhook_signing_secret_set: bool
|
||||
last_success_at: datetime | None
|
||||
last_error: str | None
|
||||
last_error_at: datetime | None
|
||||
|
||||
|
||||
class AuditExportUpdate(BaseModel):
|
||||
"""Partial update; the signing secret is write-only (set it or clear it)."""
|
||||
|
||||
syslog_enabled: bool | None = None
|
||||
syslog_host: str | None = Field(default=None, max_length=255)
|
||||
syslog_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
syslog_protocol: Literal["udp", "tcp", "tls"] | None = None
|
||||
webhook_enabled: bool | None = None
|
||||
webhook_url: str | None = Field(default=None, max_length=500)
|
||||
webhook_signing_secret: str | None = Field(default=None, min_length=1)
|
||||
clear_webhook_signing_secret: bool = False
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BackupStatusRead(BaseModel):
|
||||
"""What every signed-in user may see: no endpoints, no keys."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
scheduled_enabled: bool
|
||||
interval_hours: int
|
||||
keep_count: int
|
||||
offsite_enabled: bool
|
||||
last_run_at: datetime | None
|
||||
last_offsite_ok_at: datetime | None
|
||||
last_offsite_error: str | None
|
||||
|
||||
|
||||
class BackupSettingsRead(BackupStatusRead):
|
||||
s3_endpoint: str | None
|
||||
s3_region: str
|
||||
s3_bucket: str | None
|
||||
s3_prefix: str
|
||||
s3_access_key: str | None
|
||||
s3_path_style: bool
|
||||
s3_secret_set: bool
|
||||
passphrase_set: bool
|
||||
|
||||
|
||||
class BackupSettingsUpdate(BaseModel):
|
||||
"""Partial update; the S3 secret key and the passphrase are write-only."""
|
||||
|
||||
scheduled_enabled: bool | None = None
|
||||
interval_hours: int | None = Field(default=None, ge=1, le=24 * 30)
|
||||
keep_count: int | None = Field(default=None, ge=3, le=1000)
|
||||
offsite_enabled: bool | None = None
|
||||
s3_endpoint: str | None = Field(default=None, max_length=255)
|
||||
s3_region: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
s3_bucket: str | None = Field(default=None, max_length=255)
|
||||
s3_prefix: str | None = Field(default=None, max_length=255)
|
||||
s3_access_key: str | None = Field(default=None, max_length=255)
|
||||
s3_path_style: bool | None = None
|
||||
s3_secret_key: str | None = Field(default=None, min_length=1, max_length=512)
|
||||
clear_s3_secret: bool = False
|
||||
passphrase: str | None = Field(default=None, min_length=12, max_length=512)
|
||||
@@ -11,6 +11,8 @@ ProblemType = Literal[
|
||||
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
||||
"scheduled_maintenance_failed",
|
||||
"backup_stale",
|
||||
"backup_offsite_failed",
|
||||
]
|
||||
|
||||
|
||||
@@ -40,6 +42,7 @@ class NotificationSettingsRead(BaseModel):
|
||||
network_drift_enabled: bool = True
|
||||
carp_auto_revert_failed_enabled: bool
|
||||
scheduled_maintenance_failed_enabled: bool = True
|
||||
backup_failed_enabled: bool = True
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
@@ -77,6 +80,7 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
network_drift_enabled: bool | None = None
|
||||
carp_auto_revert_failed_enabled: bool | None = None
|
||||
scheduled_maintenance_failed_enabled: bool | None = None
|
||||
backup_failed_enabled: bool | None = None
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
|
||||
@@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
class UserRole(str, Enum):
|
||||
admin = "admin"
|
||||
# Rolls out and operates (rules, NAT, VPN, maintenance, tokens of its own) but cannot
|
||||
# change who may access OPNexus or where it sends data (users, settings, hosts).
|
||||
operator = "operator"
|
||||
read_only = "read_only"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Personal API tokens: creation, lookup and scope rules.
|
||||
|
||||
Scopes (a token can never do more than its owner's role allows; a read_only
|
||||
user's token is read-only whatever its scope says):
|
||||
|
||||
* ``read`` — GET/HEAD/OPTIONS only, except endpoints that return secrets (raw
|
||||
config backups, notification settings): those need ``admin``. For Prometheus,
|
||||
dashboards, MCP clients.
|
||||
* ``deploy`` — additionally every write that rolls out or operates on the fleet
|
||||
(rules, aliases, NAT, VPN, maintenance, backups...). NOT the writes that
|
||||
change who can access OPNexus or where it sends data: users, settings,
|
||||
notifications, host registration/removal, log settings, OPNexus self-update.
|
||||
* ``admin`` — everything the owner may do, except what is session-only.
|
||||
|
||||
Session-only for every scope: managing tokens themselves and the account
|
||||
endpoints (/api/auth/*), so a leaked token can neither mint more tokens nor
|
||||
change a password or second factor.
|
||||
|
||||
The enforcement lives in AuthMiddleware (one place, like the read_only role).
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.errors import ErrorCode, api_error
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.user import AdminUser
|
||||
|
||||
TOKEN_PREFIX = "opx_"
|
||||
SCOPES = ("read", "deploy", "admin")
|
||||
MAX_TOKENS_PER_USER = 50
|
||||
# last_used_at is only rewritten when older than this, so a scraper hitting
|
||||
# /api/metrics every few seconds does not turn into a write per request.
|
||||
_LAST_USED_GRANULARITY = timedelta(minutes=1)
|
||||
|
||||
_SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
|
||||
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)")
|
||||
# Reads that expose secrets (config.xml holds password hashes and VPN keys, the
|
||||
# notification settings the webhook URL/SMTP data): admin scope only.
|
||||
_ADMIN_SCOPE_READS = re.compile(r"^/api/hosts/[^/]+/backups/[^/]+$|^/api/notifications(/|$)|^/api/settings/")
|
||||
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read.
|
||||
_MCP_PATH = "/api/mcp"
|
||||
_ADMIN_ONLY_WRITES = re.compile(
|
||||
r"^/api/(users|settings|system|notifications)(/|$)"
|
||||
r"|^/api/logs/settings$"
|
||||
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def denies(scope: str, method: str, path: str) -> bool:
|
||||
"""True if a token with this scope must not make this request."""
|
||||
if _SESSION_ONLY.match(path):
|
||||
return True
|
||||
if path == _MCP_PATH:
|
||||
return False
|
||||
if method in _SAFE_METHODS:
|
||||
return scope != "admin" and bool(_ADMIN_SCOPE_READS.match(path))
|
||||
if scope == "read":
|
||||
return True
|
||||
if scope == "deploy" and _ADMIN_ONLY_WRITES.match(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def operator_denies(method: str, path: str) -> bool:
|
||||
"""True if an `operator` account must not make this request: the writes that
|
||||
change who may access OPNexus or where it sends data. (Their own theme at
|
||||
PUT /api/settings stays open; account endpoints are not matched here.)"""
|
||||
return method not in _SAFE_METHODS and path != "/api/settings" and bool(_ADMIN_ONLY_WRITES.match(path))
|
||||
|
||||
|
||||
def bearer_from_header(header: str) -> str | None:
|
||||
"""The token of an ``Authorization: Bearer opx_...`` header, else None."""
|
||||
scheme, _, value = header.partition(" ")
|
||||
value = value.strip()
|
||||
if scheme.lower() == "bearer" and value.startswith(TOKEN_PREFIX):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession, user: AdminUser, *, name: str, scope: str, expires_in_days: int | None
|
||||
) -> tuple[ApiToken, str]:
|
||||
count = len((await db.scalars(select(ApiToken.id).where(ApiToken.user_id == user.id))).all())
|
||||
if count >= MAX_TOKENS_PER_USER:
|
||||
raise api_error(409, ErrorCode.API_TOKEN_LIMIT_REACHED, limit=MAX_TOKENS_PER_USER)
|
||||
raw = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
token = ApiToken(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
scope=scope,
|
||||
token_hash=hash_token(raw),
|
||||
prefix=raw[: len(TOKEN_PREFIX) + 6],
|
||||
expires_at=_now() + timedelta(days=expires_in_days) if expires_in_days else None,
|
||||
)
|
||||
db.add(token)
|
||||
await db.commit()
|
||||
await db.refresh(token)
|
||||
return token, raw
|
||||
|
||||
|
||||
async def resolve(db: AsyncSession, raw: str, ip: str | None) -> tuple[AdminUser, ApiToken] | None:
|
||||
"""The owner and token for a raw bearer token, or None if unknown/expired."""
|
||||
token = await db.scalar(select(ApiToken).where(ApiToken.token_hash == hash_token(raw)))
|
||||
if token is None or (token.expires_at is not None and token.expires_at <= _now()):
|
||||
return None
|
||||
user = await db.get(AdminUser, token.user_id)
|
||||
if user is None:
|
||||
return None
|
||||
if token.last_used_at is None or _now() - token.last_used_at > _LAST_USED_GRANULARITY:
|
||||
token.last_used_at = _now()
|
||||
token.last_used_ip = ip
|
||||
await db.commit()
|
||||
return user, token
|
||||
|
||||
|
||||
async def revoke_all(db: AsyncSession, user_id, *, commit: bool = True) -> None:
|
||||
"""Delete every token of an account (password reset: answer to a suspected compromise)."""
|
||||
await db.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
|
||||
if commit:
|
||||
await db.commit()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Ships new audit events to syslog (RFC 5424, UDP / TCP / TLS) and/or a webhook.
|
||||
|
||||
A poll loop (main.py) calls ``run_once``: events after the stored cursor, oldest
|
||||
first, in batches. A channel failing leaves the cursor where it is, so the same
|
||||
events are tried again next round (at-least-once; every event carries its id so a
|
||||
receiver can de-duplicate). If both channels are on, both must succeed to advance.
|
||||
|
||||
What is sent: who, what kind/action/status, which entity and host, the message and
|
||||
a correlation id. NOT ``details`` — those hold before/after payloads of firewall
|
||||
objects, which have no business in a log aggregator.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.audit_export import AuditExportSettings
|
||||
from app.services import notification_secret_store
|
||||
from app.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
BATCH = 200
|
||||
SECRET_NAME = "audit_webhook_signing_secret"
|
||||
_FACILITY_LOCAL0 = 16
|
||||
_BAD_STATUSES = {"denied", "rejected", "error", "revert_failed", "failed"}
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> AuditExportSettings:
|
||||
row = await db.get(AuditExportSettings, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = AuditExportSettings(
|
||||
id=SINGLETON_ID, syslog_enabled=False, syslog_port=514, syslog_protocol="udp",
|
||||
webhook_enabled=False, webhook_signing_secret_set=False,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def event_dict(event: AuditEvent) -> dict:
|
||||
return {
|
||||
"id": str(event.id), "time": event.created_at.isoformat(), "correlation_id": event.correlation_id,
|
||||
"actor": event.actor, "kind": event.kind, "action": event.action, "status": event.status,
|
||||
"entity_id": event.entity_id, "entity_name": event.entity_name,
|
||||
"host_id": event.host_id, "host_name": event.host_name, "message": event.message,
|
||||
}
|
||||
|
||||
|
||||
def syslog_line(event: dict, hostname: str = "opnexus") -> bytes:
|
||||
"""RFC 5424: <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID - MSG (JSON)."""
|
||||
severity = 4 if event["status"] in _BAD_STATUSES else 6
|
||||
pri = _FACILITY_LOCAL0 * 8 + severity
|
||||
return f"<{pri}>1 {event['time']} {hostname} opnexus - AUDIT - {json.dumps(event, ensure_ascii=False)}".encode()
|
||||
|
||||
|
||||
def _frame_tcp(lines: list[bytes]) -> bytes:
|
||||
"""RFC 6587 octet counting: '<len> <message>' per message."""
|
||||
return b"".join(str(len(line)).encode() + b" " + line for line in lines)
|
||||
|
||||
|
||||
def _send_syslog_sync(row: AuditExportSettings, lines: list[bytes]) -> None:
|
||||
host, port = row.syslog_host, row.syslog_port
|
||||
if row.syslog_protocol == "udp":
|
||||
with socket.socket(socket.AF_INET6 if ":" in (host or "") else socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
for line in lines:
|
||||
sock.sendto(line[:8000], (host, port))
|
||||
return
|
||||
with socket.create_connection((host, port), timeout=settings.opnsense_timeout) as raw:
|
||||
if row.syslog_protocol == "tls":
|
||||
context = ssl.create_default_context(cafile=settings.smtp_ca_file or None)
|
||||
with context.wrap_socket(raw, server_hostname=host) as tls:
|
||||
tls.sendall(_frame_tcp(lines))
|
||||
else:
|
||||
raw.sendall(_frame_tcp(lines))
|
||||
|
||||
|
||||
async def send_syslog(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if not row.syslog_host:
|
||||
raise ExportError("Kein Syslog-Ziel konfiguriert")
|
||||
try:
|
||||
await asyncio.to_thread(_send_syslog_sync, row, [syslog_line(e) for e in events])
|
||||
except (OSError, ssl.SSLError) as exc:
|
||||
raise ExportError(f"Syslog-Zustellung fehlgeschlagen ({type(exc).__name__})") from None
|
||||
|
||||
|
||||
async def send_webhook(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if not row.webhook_url:
|
||||
raise ExportError("Keine Webhook-URL konfiguriert")
|
||||
raw = json.dumps({"source": "opnexus", "version": APP_VERSION, "events": events}, ensure_ascii=False).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if row.webhook_signing_secret_set:
|
||||
secret = notification_secret_store.read_secret(SECRET_NAME)
|
||||
if not secret:
|
||||
raise ExportError("Signaturschlüssel ist konfiguriert, aber nicht lesbar – bitte neu setzen")
|
||||
headers["X-OPNexus-Signature"] = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.opnsense_timeout) as client:
|
||||
(await client.post(row.webhook_url, content=raw, headers=headers)).raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
# The URL often is the credential: never put it in a message.
|
||||
reason = f"HTTP {exc.response.status_code}" if isinstance(exc, httpx.HTTPStatusError) else type(exc).__name__
|
||||
raise ExportError(f"Webhook-Zustellung fehlgeschlagen ({reason})") from None
|
||||
|
||||
|
||||
def active(row: AuditExportSettings) -> bool:
|
||||
return bool((row.syslog_enabled and row.syslog_host) or (row.webhook_enabled and row.webhook_url))
|
||||
|
||||
|
||||
async def _ship(row: AuditExportSettings, events: list[dict]) -> None:
|
||||
if row.syslog_enabled and row.syslog_host:
|
||||
await send_syslog(row, events)
|
||||
if row.webhook_enabled and row.webhook_url:
|
||||
await send_webhook(row, events)
|
||||
|
||||
|
||||
async def run_once(db: AsyncSession, lag_seconds: float = 3.0) -> int:
|
||||
"""Ship one batch; returns how many events went out (0 when idle or failing).
|
||||
Only events at least ``lag_seconds`` old are shipped: an event is stamped when it
|
||||
is created, a moment before its transaction commits, and the cursor must not
|
||||
jump past one that becomes visible a little later."""
|
||||
row = await get_settings(db)
|
||||
if not active(row):
|
||||
return 0
|
||||
if row.cursor_created_at is None:
|
||||
# First round after enabling: start from now, do not replay history.
|
||||
row.cursor_created_at = datetime.now(timezone.utc)
|
||||
row.cursor_id = None
|
||||
await db.commit()
|
||||
return 0
|
||||
after = AuditEvent.created_at > row.cursor_created_at
|
||||
if row.cursor_id:
|
||||
after = or_(after, and_(AuditEvent.created_at == row.cursor_created_at, AuditEvent.id > uuid.UUID(row.cursor_id)))
|
||||
ripe = AuditEvent.created_at <= datetime.now(timezone.utc) - timedelta(seconds=lag_seconds)
|
||||
# Ordering by (created_at, id) must match the cursor comparison above.
|
||||
events = (await db.scalars(select(AuditEvent).where(after, ripe).order_by(AuditEvent.created_at, AuditEvent.id).limit(BATCH))).all()
|
||||
if not events:
|
||||
return 0
|
||||
try:
|
||||
await _ship(row, [event_dict(e) for e in events])
|
||||
except ExportError as exc:
|
||||
row.last_error, row.last_error_at = str(exc)[:300], datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.warning("Audit-Export: %s", exc)
|
||||
return 0
|
||||
row.cursor_created_at, row.cursor_id = events[-1].created_at, str(events[-1].id)
|
||||
row.last_success_at, row.last_error = datetime.now(timezone.utc), None
|
||||
await db.commit()
|
||||
return len(events)
|
||||
|
||||
|
||||
async def send_test(row: AuditExportSettings) -> None:
|
||||
event = {
|
||||
"id": "00000000-0000-0000-0000-000000000000", "time": datetime.now(timezone.utc).isoformat(),
|
||||
"correlation_id": "test", "actor": "opnexus", "kind": "test", "action": "test", "status": "accepted",
|
||||
"entity_id": None, "entity_name": None, "host_id": None, "host_name": None,
|
||||
"message": "Dies ist ein Test des Audit-Exports von OPNexus.",
|
||||
}
|
||||
if not (row.syslog_enabled or row.webhook_enabled):
|
||||
raise ExportError("Kein Kanal eingeschaltet")
|
||||
await _ship(row, [event])
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication core: password hashing, server-side sessions, brute-force
|
||||
protection and the initial admin bootstrap.
|
||||
|
||||
Two roles ("admin", "read_only"), no orgs, no API keys — see
|
||||
Two roles ("admin", "read_only"), no orgs; API tokens live in api_token_service — see
|
||||
AdminUser/AuthMiddleware for how a role is enforced. Originally mirrored
|
||||
InfraPanel's own app/services/auth_service.py (single-admin-only); the role
|
||||
column and its enforcement are OPNexus-specific additions on top of that.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Client-side encryption of config backups before they leave the server.
|
||||
|
||||
File format (``.opxbak``): ``OPXBAK1`` | salt (16) | nonce (12) | AES-256-GCM ciphertext+tag.
|
||||
The key is scrypt(passphrase, salt). The header is authenticated as associated data, so a
|
||||
file cannot be re-labelled. The passphrase never leaves OPNexus' secret store; without it
|
||||
an offsite copy is unreadable, which is the point — and also why it must be kept somewhere
|
||||
else too (see the handbook). Decrypt with ``python -m app.backup_decrypt``.
|
||||
"""
|
||||
import os
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||
|
||||
MAGIC = b"OPXBAK1"
|
||||
_SALT, _NONCE = 16, 12
|
||||
# scrypt cost: ~64 MiB, well above interactive defaults; a backup is encrypted rarely.
|
||||
_N, _R, _P = 2**16, 8, 1
|
||||
|
||||
|
||||
class DecryptError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||
return Scrypt(salt=salt, length=32, n=_N, r=_R, p=_P).derive(passphrase.encode())
|
||||
|
||||
|
||||
def encrypt(plaintext: bytes, passphrase: str) -> bytes:
|
||||
salt, nonce = os.urandom(_SALT), os.urandom(_NONCE)
|
||||
header = MAGIC + salt + nonce
|
||||
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, plaintext, header)
|
||||
|
||||
|
||||
def decrypt(blob: bytes, passphrase: str) -> bytes:
|
||||
header_len = len(MAGIC) + _SALT + _NONCE
|
||||
if len(blob) < header_len + 16 or not blob.startswith(MAGIC):
|
||||
raise DecryptError("Keine OPNexus-Backup-Datei (Kopfzeile fehlt).")
|
||||
header, body = blob[:header_len], blob[header_len:]
|
||||
salt = header[len(MAGIC): len(MAGIC) + _SALT]
|
||||
nonce = header[len(MAGIC) + _SALT:]
|
||||
try:
|
||||
return AESGCM(_key(passphrase, salt)).decrypt(nonce, body, header)
|
||||
except InvalidTag:
|
||||
raise DecryptError("Falsche Passphrase oder beschädigte Datei.") from None
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Readable, redacted diff between two stored config backups of the same host.
|
||||
|
||||
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
|
||||
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
|
||||
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
|
||||
but its value never leaves the server. Output is a unified diff, capped in size.
|
||||
"""
|
||||
import difflib
|
||||
import hashlib
|
||||
import re
|
||||
from xml.etree.ElementTree import Element, indent, tostring
|
||||
|
||||
import defusedxml.ElementTree as safe_ET
|
||||
|
||||
MAX_LINES = 3000
|
||||
MAX_BYTES = 32 * 1024 * 1024
|
||||
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
|
||||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||||
_BLOB = 120
|
||||
|
||||
|
||||
class InvalidConfig(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _redact(root: Element) -> None:
|
||||
for el in root.iter():
|
||||
if len(el) == 0 and el.text and el.text.strip():
|
||||
text = el.text.strip()
|
||||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||||
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
|
||||
|
||||
|
||||
def normalise(xml_bytes: bytes) -> list[str]:
|
||||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||||
raise InvalidConfig("leer oder zu groß")
|
||||
try:
|
||||
root = safe_ET.fromstring(xml_bytes)
|
||||
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
|
||||
raise InvalidConfig(type(exc).__name__) from None
|
||||
if root.tag != "opnsense":
|
||||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||||
for volatile in root.findall("revision"):
|
||||
root.remove(volatile)
|
||||
_redact(root)
|
||||
indent(root, space=" ")
|
||||
return tostring(root, encoding="unicode").splitlines()
|
||||
|
||||
|
||||
def diff(old: bytes, new: bytes) -> dict:
|
||||
a, b = normalise(old), normalise(new)
|
||||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||||
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Scheduled config backups: take, keep the newest N, copy encrypted offsite.
|
||||
|
||||
Called by a poll loop (main.py). Per enabled host a new snapshot is taken when the newest one
|
||||
is older than the interval; the oldest beyond ``keep_count`` are deleted. When an offsite target
|
||||
is set, every local snapshot that is not offsite yet is encrypted (backup_crypto) and uploaded
|
||||
(s3_client); the ledger ``backup_uploads`` remembers what is already there. Nothing is ever
|
||||
deleted offsite: use the bucket's lifecycle rules for that.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.backup_settings import BackupSettings, BackupUpload
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import backup_crypto, backup_store, notification_secret_store
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
from app.services.s3_client import S3Client, S3Error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
S3_SECRET, PASSPHRASE = "backup_s3_secret", "backup_passphrase"
|
||||
MAX_UPLOADS_PER_RUN = 20
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession) -> BackupSettings:
|
||||
row = await db.get(BackupSettings, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = BackupSettings(
|
||||
id=SINGLETON_ID, scheduled_enabled=True, interval_hours=24, keep_count=30, offsite_enabled=False,
|
||||
s3_region="us-east-1", s3_prefix="opnexus", s3_path_style=True, s3_secret_set=False, passphrase_set=False,
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def offsite_ready(cfg: BackupSettings) -> bool:
|
||||
return bool(cfg.offsite_enabled and cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and cfg.s3_secret_set and cfg.passphrase_set)
|
||||
|
||||
|
||||
def s3_for(cfg: BackupSettings) -> S3Client:
|
||||
secret = notification_secret_store.read_secret(S3_SECRET)
|
||||
if not (cfg.s3_endpoint and cfg.s3_bucket and cfg.s3_access_key and secret):
|
||||
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten)")
|
||||
return S3Client(endpoint=cfg.s3_endpoint, region=cfg.s3_region, bucket=cfg.s3_bucket, access_key=cfg.s3_access_key,
|
||||
secret_key=secret, path_style=cfg.s3_path_style)
|
||||
|
||||
|
||||
def remote_key(cfg: BackupSettings, host: OpnsenseHost, snapshot_id: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", host.name).strip("-") or "host"
|
||||
prefix = cfg.s3_prefix.strip("/")
|
||||
return "/".join(p for p in (prefix, f"{safe}-{str(host.id)[:8]}", snapshot_id.removesuffix(".xml") + ".opxbak") if p)
|
||||
|
||||
|
||||
async def _take(host: OpnsenseHost) -> str:
|
||||
content = await client.download_backup(host)
|
||||
if b"<opnsense" not in content[:4096]:
|
||||
raise OpnsenseAPIError("Antwort ist keine OPNsense-Konfiguration")
|
||||
return backup_store.save_snapshot(host.id, content)
|
||||
|
||||
|
||||
async def _prune(db: AsyncSession, host: OpnsenseHost, keep: int) -> int:
|
||||
old = backup_store.list_snapshots(host.id)[keep:]
|
||||
for snapshot in old:
|
||||
backup_store.delete_snapshot(host.id, snapshot["id"])
|
||||
if old:
|
||||
rows = (await db.scalars(select(BackupUpload).where(BackupUpload.host_id == host.id, BackupUpload.snapshot_id.in_([s["id"] for s in old])))).all()
|
||||
for row in rows:
|
||||
await db.delete(row)
|
||||
return len(old)
|
||||
|
||||
|
||||
async def upload_pending(db: AsyncSession, cfg: BackupSettings, hosts: list[OpnsenseHost]) -> int:
|
||||
"""Encrypt and upload local snapshots that are not offsite yet (newest first, bounded per run)."""
|
||||
passphrase = notification_secret_store.read_secret(PASSPHRASE)
|
||||
if not passphrase:
|
||||
raise S3Error("Passphrase fehlt oder ist nicht lesbar – bitte neu setzen")
|
||||
s3 = s3_for(cfg)
|
||||
done, budget = 0, MAX_UPLOADS_PER_RUN
|
||||
for host in hosts:
|
||||
have = set((await db.scalars(select(BackupUpload.snapshot_id).where(BackupUpload.host_id == host.id))).all())
|
||||
for snapshot in backup_store.list_snapshots(host.id):
|
||||
if snapshot["id"] in have:
|
||||
continue
|
||||
if budget == 0:
|
||||
return done
|
||||
content = backup_store.read_snapshot(host.id, snapshot["id"])
|
||||
if content is None:
|
||||
continue
|
||||
key = remote_key(cfg, host, snapshot["id"])
|
||||
await s3.put(key, backup_crypto.encrypt(content, passphrase))
|
||||
db.add(BackupUpload(host_id=host.id, snapshot_id=snapshot["id"], remote_key=key, uploaded_at=datetime.now(timezone.utc)))
|
||||
await db.commit()
|
||||
budget -= 1
|
||||
done += 1
|
||||
return done
|
||||
|
||||
|
||||
async def run_due(db: AsyncSession, *, force: bool = False) -> dict:
|
||||
cfg = await get_settings(db)
|
||||
hosts = list((await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all())
|
||||
taken = failed = pruned = 0
|
||||
if cfg.scheduled_enabled or force:
|
||||
now = datetime.now(timezone.utc)
|
||||
for host in hosts:
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
if not force and snapshots and now - snapshots[0]["created_at"] < timedelta(hours=cfg.interval_hours):
|
||||
pruned += await _prune(db, host, cfg.keep_count)
|
||||
continue
|
||||
try:
|
||||
await _take(host)
|
||||
taken += 1
|
||||
except OpnsenseAPIError as exc:
|
||||
failed += 1
|
||||
logger.warning("Geplantes Backup von %s fehlgeschlagen: %s", host.name, exc)
|
||||
pruned += await _prune(db, host, cfg.keep_count)
|
||||
cfg.last_run_at = now
|
||||
uploaded = 0
|
||||
if cfg.offsite_enabled:
|
||||
try:
|
||||
if not offsite_ready(cfg):
|
||||
raise S3Error("Offsite-Ziel unvollständig (Endpunkt, Bucket, Zugangsdaten, Passphrase)")
|
||||
uploaded = await upload_pending(db, cfg, hosts)
|
||||
cfg.last_offsite_error = None
|
||||
if uploaded or cfg.last_offsite_ok_at is None:
|
||||
cfg.last_offsite_ok_at = datetime.now(timezone.utc)
|
||||
except S3Error as exc:
|
||||
cfg.last_offsite_error = str(exc)[:300]
|
||||
logger.warning("Offsite-Backup: %s", exc)
|
||||
await db.commit()
|
||||
return {"hosts": len(hosts), "taken": taken, "failed": failed, "pruned": pruned, "uploaded": uploaded}
|
||||
@@ -42,7 +42,8 @@ def list_snapshots(host_id: uuid.UUID) -> list[dict]:
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
"size": stat.st_size,
|
||||
})
|
||||
return sorted(items, key=lambda i: i["created_at"], reverse=True)
|
||||
# Names are sortable timestamps: they break ties between files written within one mtime tick.
|
||||
return sorted(items, key=lambda i: (i["created_at"], i["id"]), reverse=True)
|
||||
|
||||
|
||||
def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
||||
@@ -53,3 +54,11 @@ def read_snapshot(host_id: uuid.UUID, backup_id: str) -> bytes | None:
|
||||
if backup_id != path.name or not path.is_file():
|
||||
return None
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def delete_snapshot(host_id: uuid.UUID, backup_id: str) -> bool:
|
||||
path = _host_dir(host_id) / backup_id
|
||||
if backup_id != path.name or not path.is_file():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Security posture of a firewall: hardening checks and rule hygiene.
|
||||
|
||||
The checks read the firewall's own config.xml (one read-only download, parsed
|
||||
in memory with defusedxml, never stored) plus two facts OPNexus already knows:
|
||||
whether firmware updates are pending and how old the newest local backup is.
|
||||
The XML layout was verified against real OPNsense 26.7 configs: rules live in
|
||||
``OPNsense/Firewall/Filter/rules/rule`` (MVC) or, on installs not yet migrated,
|
||||
in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
||||
|
||||
``evaluate`` is pure (bytes in, findings out). A finding is
|
||||
``{id, severity, passed, params}``; ``passed`` is None when a check could not be
|
||||
decided. The score starts at 100 and loses a penalty per failed check, so one
|
||||
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import defusedxml.ElementTree as safe_ET
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.services import backup_store, rule_hit_service
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
MAX_CONFIG_BYTES = 32 * 1024 * 1024
|
||||
BACKUP_MAX_AGE_DAYS = 7
|
||||
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
||||
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
||||
MAX_EXAMPLES = 5
|
||||
|
||||
|
||||
def grade(score: int) -> str:
|
||||
return "good" if score >= 90 else "fair" if score >= 70 else "poor"
|
||||
|
||||
|
||||
def _text(el: Element | None, path: str) -> str | None:
|
||||
node = el.find(path) if el is not None else None
|
||||
if node is None or node.text is None or not node.text.strip():
|
||||
return None
|
||||
return node.text.strip()
|
||||
|
||||
|
||||
# ── Rule normalisation ───────────────────────────────────────────────────
|
||||
|
||||
def _endpoint(node: Element | None) -> tuple[str, bool, str | None]:
|
||||
"""(network, negated, port) of a legacy <source>/<destination> element."""
|
||||
if node is None:
|
||||
return "any", False, None
|
||||
negated = node.find("not") is not None
|
||||
port = _text(node, "port")
|
||||
if node.find("any") is not None:
|
||||
return "any", negated, port
|
||||
for tag in ("network", "address"):
|
||||
value = _text(node, tag)
|
||||
if value:
|
||||
return value, negated, port
|
||||
return "any", negated, port
|
||||
|
||||
|
||||
def _mvc_rule(rule: Element) -> dict:
|
||||
return {
|
||||
"enabled": _text(rule, "enabled") != "0",
|
||||
"action": (_text(rule, "action") or "pass").lower(),
|
||||
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
||||
"direction": _text(rule, "direction") or "in",
|
||||
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
||||
"protocol": (_text(rule, "protocol") or "any").lower(),
|
||||
"src": _text(rule, "source_net") or "any", "src_not": _text(rule, "source_not") == "1",
|
||||
"sport": _text(rule, "source_port"),
|
||||
"dst": _text(rule, "destination_net") or "any", "dst_not": _text(rule, "destination_not") == "1",
|
||||
"dport": _text(rule, "destination_port"),
|
||||
"floating": False,
|
||||
"quick": _text(rule, "quick") != "0",
|
||||
"descr": _text(rule, "description") or "",
|
||||
"order": int(_text(rule, "sequence") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_rule(rule: Element, index: int) -> dict:
|
||||
src, src_not, sport = _endpoint(rule.find("source"))
|
||||
dst, dst_not, dport = _endpoint(rule.find("destination"))
|
||||
return {
|
||||
"enabled": rule.find("disabled") is None,
|
||||
"action": (_text(rule, "type") or "pass").lower(),
|
||||
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
||||
"direction": _text(rule, "direction") or "in",
|
||||
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
||||
"protocol": (_text(rule, "protocol") or "any").lower(),
|
||||
"src": src, "src_not": src_not, "sport": sport, "dst": dst, "dst_not": dst_not, "dport": dport,
|
||||
"floating": _text(rule, "floating") == "yes",
|
||||
"quick": _text(rule, "quick") != "0",
|
||||
"descr": _text(rule, "descr") or "",
|
||||
"order": index,
|
||||
}
|
||||
|
||||
|
||||
def extract_rules(root: Element) -> list[list[dict]]:
|
||||
"""Rule lists in evaluation order, one per source (MVC, legacy)."""
|
||||
mvc = sorted((_mvc_rule(r) for r in root.findall("OPNsense/Firewall/Filter/rules/rule")), key=lambda r: r["order"])
|
||||
legacy = [_legacy_rule(r, i) for i, r in enumerate(root.findall("filter/rule"))]
|
||||
return [group for group in (mvc, legacy) if group]
|
||||
|
||||
|
||||
def wan_interfaces(root: Element) -> set[str]:
|
||||
"""Interfaces facing the outside: the one named wan*, or any with an upstream gateway."""
|
||||
result = set()
|
||||
for node in root.findall("interfaces/*"):
|
||||
if node.tag.lower().startswith("wan") or _text(node, "gateway"):
|
||||
result.add(node.tag)
|
||||
return result
|
||||
|
||||
|
||||
def _is_any_any(rule: dict) -> bool:
|
||||
return (
|
||||
rule["enabled"] and rule["action"] == "pass" and rule["src"] == "any" and not rule["src_not"]
|
||||
and rule["dst"] == "any" and not rule["dst_not"] and rule["protocol"] == "any"
|
||||
and not rule["sport"] and not rule["dport"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _covers(a: dict, b: dict) -> bool:
|
||||
"""Every packet matched by rule b is already matched by the earlier rule a.
|
||||
Only exact-or-any comparisons, so it never claims coverage it cannot prove."""
|
||||
if not (a["enabled"] and b["enabled"]) or a["floating"] or b["floating"] or not a["quick"]:
|
||||
return False
|
||||
if not a["interfaces"] or not set(b["interfaces"]) <= set(a["interfaces"]):
|
||||
return False
|
||||
if a["direction"] != b["direction"] or a["ipprotocol"] != b["ipprotocol"]:
|
||||
return False
|
||||
if a["protocol"] != "any" and a["protocol"] != b["protocol"]:
|
||||
return False
|
||||
for side in ("src", "dst"):
|
||||
if a[side + "_not"] or b[side + "_not"]:
|
||||
if (a[side], a[side + "_not"]) != (b[side], b[side + "_not"]):
|
||||
return False
|
||||
elif a[side] != "any" and a[side] != b[side]:
|
||||
return False
|
||||
for port in ("sport", "dport"):
|
||||
if a[port] and a[port] != b[port]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _label(rule: dict) -> str:
|
||||
return rule["descr"] or f"{rule['action']} {rule['src']} → {rule['dst']}"
|
||||
|
||||
|
||||
def analyse_rules(groups: list[list[dict]]) -> dict:
|
||||
shadowed, duplicates = [], []
|
||||
for rules in groups:
|
||||
for j, later in enumerate(rules):
|
||||
for earlier in rules[:j]:
|
||||
if _covers(earlier, later):
|
||||
(duplicates if earlier["action"] == later["action"] and _covers(later, earlier) else shadowed).append(_label(later))
|
||||
break
|
||||
return {"shadowed": shadowed, "duplicates": duplicates}
|
||||
|
||||
|
||||
# ── Checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _finding(check_id: str, severity: str, passed: bool | None, **params) -> dict:
|
||||
return {"id": check_id, "severity": severity, "passed": passed, "params": params}
|
||||
|
||||
|
||||
def _listing(items: list[str]) -> dict:
|
||||
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
|
||||
|
||||
|
||||
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None,
|
||||
unused_rules: dict | None = None) -> list[dict]:
|
||||
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
|
||||
raise ValueError("config.xml missing or too large")
|
||||
root = safe_ET.fromstring(xml_bytes)
|
||||
if root.tag != "opnsense":
|
||||
raise ValueError("not an OPNsense config.xml")
|
||||
findings: list[dict] = []
|
||||
|
||||
# Management access
|
||||
protocol = _text(root, "system/webgui/protocol")
|
||||
findings.append(_finding("webgui_https", "high", protocol == "https", protocol=protocol or "http"))
|
||||
|
||||
ssh = root.find("system/ssh")
|
||||
ssh_enabled = ssh is not None and _text(ssh, "enabled") in ("enabled", "1")
|
||||
root_login = ssh_enabled and _text(ssh, "permitrootlogin") == "1"
|
||||
password_login = ssh_enabled and _text(ssh, "passwordauth") == "1"
|
||||
if root_login and password_login:
|
||||
findings.append(_finding("ssh_hardened", "high", False, reason="root_password"))
|
||||
elif password_login:
|
||||
findings.append(_finding("ssh_hardened", "medium", False, reason="password"))
|
||||
else:
|
||||
findings.append(_finding("ssh_hardened", "high", True))
|
||||
|
||||
community = _text(root, "snmpd/rocommunity")
|
||||
if root.find("snmpd") is not None:
|
||||
findings.append(_finding("snmp_community", "high", (community or "").lower() not in ("", "public")))
|
||||
|
||||
# Rules
|
||||
wans = wan_interfaces(root)
|
||||
groups = extract_rules(root)
|
||||
all_rules = [rule for group in groups for rule in group]
|
||||
wan_open = [_label(r) for r in all_rules if _is_any_any(r) and set(r["interfaces"]) & wans]
|
||||
findings.append(_finding("wan_any_any", "critical", not wan_open, **_listing(wan_open)))
|
||||
inner_open = [_label(r) for r in all_rules if _is_any_any(r) and r["interfaces"] and not set(r["interfaces"]) & wans]
|
||||
findings.append(_finding("internal_any_any", "medium", not inner_open, **_listing(inner_open)))
|
||||
analysis = analyse_rules(groups)
|
||||
findings.append(_finding("rules_shadowed", "medium", not analysis["shadowed"], **_listing(analysis["shadowed"])))
|
||||
findings.append(_finding("rules_duplicate", "low", not analysis["duplicates"], **_listing(analysis["duplicates"])))
|
||||
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
|
||||
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
|
||||
|
||||
# Usage (from the pf counters OPNexus has been sampling; None = no data yet)
|
||||
if unused_rules is not None:
|
||||
names = unused_rules["unused"]
|
||||
findings.append(_finding("rules_unused", "low", None if (not names and unused_rules["collecting"]) else not names, **_listing(names)))
|
||||
|
||||
# Operations
|
||||
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
|
||||
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
|
||||
findings.append(_finding("remote_logging", "low", bool(syslog_targets) or bool(_text(root, "syslog/remoteserver"))))
|
||||
findings.append(_finding("ids_enabled", "info", _text(root, "OPNsense/IDS/general/enabled") == "1"))
|
||||
|
||||
# From what OPNexus already knows
|
||||
findings.append(_finding("firmware_current", "medium", None if firmware_status is None else firmware_status != "update"))
|
||||
if newest_backup_age_s is None:
|
||||
findings.append(_finding("backup_recent", "medium", False, age_days=None))
|
||||
else:
|
||||
age_days = int(newest_backup_age_s // 86400)
|
||||
findings.append(_finding("backup_recent", "medium", age_days < BACKUP_MAX_AGE_DAYS, age_days=age_days))
|
||||
return findings
|
||||
|
||||
|
||||
def score(findings: list[dict]) -> int:
|
||||
return max(0, 100 - sum(PENALTY[f["severity"]] for f in findings if f["passed"] is False))
|
||||
|
||||
|
||||
def counts(findings: list[dict]) -> dict:
|
||||
result = {severity: 0 for severity in SEVERITY_ORDER}
|
||||
for finding in findings:
|
||||
if finding["passed"] is False:
|
||||
result[finding["severity"]] += 1
|
||||
return result
|
||||
|
||||
|
||||
# ── Running it for a host ────────────────────────────────────────────────
|
||||
|
||||
async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
||||
"""Download the config, evaluate, store (one row per host, replaced each time)."""
|
||||
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host.id))
|
||||
if row is None:
|
||||
row = ComplianceResult(host_id=host.id)
|
||||
db.add(row)
|
||||
row.checked_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
xml = await client.download_backup(host)
|
||||
try:
|
||||
firmware_status = (await client.get_firmware_status(host)).get("status")
|
||||
except OpnsenseAPIError:
|
||||
firmware_status = None
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
|
||||
try:
|
||||
await rule_hit_service.sample_host(db, host)
|
||||
except OpnsenseAPIError:
|
||||
pass # usage data is a bonus; the check does not depend on it
|
||||
findings = evaluate(xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
||||
unused_rules=await rule_hit_service.unused_summary(db, host.id))
|
||||
row.findings, row.score, row.error = findings, score(findings), None
|
||||
except OpnsenseAPIError as exc:
|
||||
row.findings, row.score, row.error = [], None, "unreachable"
|
||||
row.error_detail = str(exc)[:300]
|
||||
except ValueError as exc:
|
||||
row.findings, row.score, row.error = [], None, "invalid_config"
|
||||
row.error_detail = str(exc)[:300]
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def check_all(db: AsyncSession) -> int:
|
||||
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
||||
for host in hosts:
|
||||
await check_host(db, host)
|
||||
return len(hosts)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Prometheus exposition of the fleet state (GET /api/metrics).
|
||||
|
||||
Deliberately database-only, like the dashboard summary: a scrape must never
|
||||
fan out live calls to every firewall (an offline host would stall it past the
|
||||
scraper's timeout and a 15 s scrape interval would hammer the fleet). The
|
||||
inputs are what OPNexus' own pollers already store:
|
||||
|
||||
* host reachability and resources — the 60 s resource sampler (a host that
|
||||
produced no sample for ``STALE_AFTER`` is reported down),
|
||||
* CARP role — the latest observed transition (carp_events),
|
||||
* open problems — the notification engine's problem table,
|
||||
* pending actions — the same list the dashboard shows.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.carp_event import CarpEvent
|
||||
from app.models.compliance_result import ComplianceResult
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.notification import NotificationProblem
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.routers.dashboard import collect_pending_actions
|
||||
from app.services import compliance_service
|
||||
from app.version import APP_VERSION
|
||||
|
||||
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
||||
# The sampler runs every 60 s; three missed rounds mean the host is not reachable.
|
||||
STALE_AFTER = timedelta(seconds=180)
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
class _Exposition:
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
|
||||
def family(self, name: str, help_text: str, kind: str = "gauge") -> None:
|
||||
self.lines.append(f"# HELP {name} {help_text}")
|
||||
self.lines.append(f"# TYPE {name} {kind}")
|
||||
|
||||
def sample(self, name: str, value: float, **labels: str) -> None:
|
||||
rendered = ",".join(f'{key}="{_escape(str(val))}"' for key, val in labels.items() if val is not None)
|
||||
number = repr(float(value)) if not float(value).is_integer() else str(int(value))
|
||||
self.lines.append(f"{name}{{{rendered}}} {number}" if rendered else f"{name} {number}")
|
||||
|
||||
def text(self) -> str:
|
||||
return "\n".join(self.lines) + "\n"
|
||||
|
||||
|
||||
async def render(db: AsyncSession) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
out = _Exposition()
|
||||
|
||||
out.family("opnexus_info", "OPNexus version (always 1).")
|
||||
out.sample("opnexus_info", 1, version=APP_VERSION)
|
||||
|
||||
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
|
||||
latest_sample = (
|
||||
select(HostResourceSample.host_id, func.max(HostResourceSample.sampled_at).label("at"))
|
||||
.group_by(HostResourceSample.host_id)
|
||||
.subquery()
|
||||
)
|
||||
samples = {
|
||||
sample.host_id: sample
|
||||
for sample in (
|
||||
await db.scalars(
|
||||
select(HostResourceSample).join(
|
||||
latest_sample,
|
||||
(HostResourceSample.host_id == latest_sample.c.host_id)
|
||||
& (HostResourceSample.sampled_at == latest_sample.c.at),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
latest_event = (
|
||||
select(CarpEvent.host_id, func.max(CarpEvent.detected_at).label("at")).group_by(CarpEvent.host_id).subquery()
|
||||
)
|
||||
carp_state = {
|
||||
event.host_id: event.to_status
|
||||
for event in (
|
||||
await db.scalars(
|
||||
select(CarpEvent).join(
|
||||
latest_event, (CarpEvent.host_id == latest_event.c.host_id) & (CarpEvent.detected_at == latest_event.c.at)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
out.family("opnexus_hosts", "Number of managed firewalls.")
|
||||
out.sample("opnexus_hosts", len(hosts))
|
||||
out.family("opnexus_host_enabled", "1 if monitoring of the host is enabled.")
|
||||
out.family("opnexus_host_up", "1 if the host answered the resource sampler within the last 3 minutes.")
|
||||
out.family("opnexus_host_last_sample_timestamp_seconds", "Unix time of the last successful resource sample.")
|
||||
out.family("opnexus_host_memory_used_percent", "Memory in use, percent (last sample).")
|
||||
out.family("opnexus_host_load1", "1-minute load average (last sample).")
|
||||
out.family("opnexus_host_disk_used_percent", "Fullest disk, percent (last sample).")
|
||||
out.family("opnexus_host_carp_master", "1 if the host's last observed CARP role is MASTER, 0 if BACKUP; absent if unknown.")
|
||||
for host in hosts:
|
||||
labels = {"host": host.name, "cluster": host.cluster_name or ""}
|
||||
sample = samples.get(host.id)
|
||||
up = bool(host.enabled and sample is not None and now - sample.sampled_at <= STALE_AFTER)
|
||||
out.sample("opnexus_host_enabled", 1 if host.enabled else 0, **labels)
|
||||
out.sample("opnexus_host_up", 1 if up else 0, **labels)
|
||||
if sample is not None:
|
||||
out.sample("opnexus_host_last_sample_timestamp_seconds", sample.sampled_at.timestamp(), **labels)
|
||||
for name, value in (
|
||||
("opnexus_host_memory_used_percent", sample.mem_used_pct),
|
||||
("opnexus_host_load1", sample.load_avg_1m),
|
||||
("opnexus_host_disk_used_percent", sample.disk_used_pct),
|
||||
):
|
||||
if value is not None:
|
||||
out.sample(name, value, **labels)
|
||||
if carp_state.get(host.id) in ("MASTER", "BACKUP"):
|
||||
out.sample("opnexus_host_carp_master", 1 if carp_state[host.id] == "MASTER" else 0, **labels)
|
||||
|
||||
out.family("opnexus_host_compliance_score", "Security posture score 0-100 of the last check (absent if never checked).")
|
||||
out.family("opnexus_host_compliance_findings", "Failed security checks of the last check, by severity.")
|
||||
results = {r.host_id: r for r in (await db.scalars(select(ComplianceResult))).all()}
|
||||
for host in hosts:
|
||||
row = results.get(host.id)
|
||||
if row is None or row.score is None:
|
||||
continue
|
||||
labels = {"host": host.name, "cluster": host.cluster_name or ""}
|
||||
out.sample("opnexus_host_compliance_score", row.score, **labels)
|
||||
for severity, count in compliance_service.counts(row.findings).items():
|
||||
out.sample("opnexus_host_compliance_findings", count, severity=severity, **labels)
|
||||
|
||||
out.family("opnexus_pending_actions", "Deployments and operations that need attention, by kind and status.")
|
||||
counts: dict[tuple[str, str], int] = {}
|
||||
for action in await collect_pending_actions(db):
|
||||
counts[(action.kind, action.status)] = counts.get((action.kind, action.status), 0) + 1
|
||||
for (kind, status), count in sorted(counts.items()):
|
||||
out.sample("opnexus_pending_actions", count, kind=kind, status=status)
|
||||
|
||||
out.family("opnexus_problems_open", "Open problems known to the notification engine, by type.")
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(NotificationProblem.problem_type, func.count())
|
||||
.where(NotificationProblem.status == "open")
|
||||
.group_by(NotificationProblem.problem_type)
|
||||
)
|
||||
).all()
|
||||
for problem_type, count in sorted(rows):
|
||||
out.sample("opnexus_problems_open", count, type=problem_type)
|
||||
|
||||
out.family("opnexus_scheduled_maintenance_pending", "Scheduled maintenance jobs that have not run yet.")
|
||||
pending = await db.scalar(select(func.count()).select_from(ScheduledMaintenance).where(ScheduledMaintenance.status == "pending"))
|
||||
out.sample("opnexus_scheduled_maintenance_pending", pending or 0)
|
||||
|
||||
return out.text()
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alias_deployment import AliasDeployment
|
||||
from app.models.backup_settings import BackupSettings
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.host_operation import HostOperation
|
||||
from app.models.one_to_one_deployment import OneToOneDeployment
|
||||
@@ -16,7 +17,7 @@ from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||
from app.models.port_forward_deployment import PortForwardDeployment
|
||||
from app.models.rule_deployment import RuleDeployment
|
||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||
from app.services import nat_drift_service, network_drift_service, rule_drift_service
|
||||
from app.services import backup_store, nat_drift_service, network_drift_service, rule_drift_service
|
||||
from app.services.notification_dedup import Problems
|
||||
from app.services.host_status_service import get_statuses
|
||||
from app.services.live_certificates_service import get_live_certificates
|
||||
@@ -290,3 +291,31 @@ async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict
|
||||
"message": (sched.result or {}).get("error"),
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
async def check_backup_stale(db: AsyncSession) -> dict[str, dict]:
|
||||
"""Scheduled backups are on but a host has no recent one: three missed intervals
|
||||
(at least 6 hours) cover a reboot or a short outage without crying wolf."""
|
||||
cfg = await db.get(BackupSettings, 1)
|
||||
if cfg is not None and not cfg.scheduled_enabled:
|
||||
return {}
|
||||
interval = cfg.interval_hours if cfg is not None else 24
|
||||
limit = timedelta(hours=max(3 * interval, 6))
|
||||
now = datetime.now(timezone.utc)
|
||||
problems: dict[str, dict] = {}
|
||||
for host in await _enabled_hosts(db):
|
||||
snapshots = backup_store.list_snapshots(host.id)
|
||||
newest = snapshots[0]["created_at"] if snapshots else None
|
||||
if (newest is None and now - host.created_at > limit) or (newest is not None and now - newest > limit):
|
||||
problems[str(host.id)] = {
|
||||
"host_id": str(host.id), "host_name": host.name,
|
||||
"last_backup": newest.isoformat() if newest else "noch nie",
|
||||
}
|
||||
return problems
|
||||
|
||||
|
||||
async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
|
||||
cfg = await db.get(BackupSettings, 1)
|
||||
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
|
||||
return {}
|
||||
return {"offsite": {"message": cfg.last_offsite_error}}
|
||||
@@ -34,6 +34,8 @@ _LABELS = {
|
||||
"network_drift": "Netzwerk-Abweichung im Cluster",
|
||||
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
||||
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||
"backup_stale": "Kein aktuelles Backup",
|
||||
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ _CHECKS = [
|
||||
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
|
||||
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
|
||||
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||
]
|
||||
|
||||
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||
|
||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
||||
"smtp_from_address", "smtp_to_addresses",
|
||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
@@ -369,6 +369,15 @@ async def get_source_nat_settings(host: OpnsenseHost) -> dict:
|
||||
# Core controllers, checked against OPNsense core source:
|
||||
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php
|
||||
# https://github.com/opnsense/core/blob/master/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php
|
||||
async def get_pf_rule_statistics(host: OpnsenseHost) -> dict:
|
||||
"""`diagnostics/firewall/pfStatistics/rules` (Diagnostics Api FirewallController):
|
||||
{"rules": {"filter rules": {"@N <pf rule text> label \"<uuid>\"": {evaluations, packets,
|
||||
bytes, states, ...}}}}. Verified live on OPNsense 26.7: the pf label of a rule created in
|
||||
the rule editor/API is its UUID, so counters can be tied to rules; automatic rules carry
|
||||
an md5 label instead."""
|
||||
return await _request(host, "GET", "/diagnostics/firewall/pfStatistics/rules")
|
||||
|
||||
|
||||
async def get_firmware_status(host: OpnsenseHost) -> dict:
|
||||
# GET reads cached results. POST would trigger a synchronous probe.
|
||||
return await _request(host, "GET", "/core/firmware/status")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Rule usage from pf's counters: which rules never match anything?
|
||||
|
||||
``sample_host`` reads the per-rule packet counters (``diagnostics/firewall/pfStatistics/rules``) and the
|
||||
configured rules (``searchRule``) and updates one row per rule: the time of the last observed hit.
|
||||
|
||||
* A counter that grew since the last sample is a hit "now" (at most one sampling interval late).
|
||||
* A counter that is *lower* than before means pf reloaded its ruleset (an apply or a reboot), which
|
||||
zeroes everything. Whatever it shows above zero was hit since the reload, so that also counts as a hit now;
|
||||
zero tells nothing, the old ``last_hit_at`` stays.
|
||||
* A rule is called unused only after OPNexus has watched it for ``UNUSED_AFTER_DAYS`` without a hit.
|
||||
A new rule, or a brand-new installation, is "still collecting data" instead of a false alarm.
|
||||
|
||||
Only rules with a UUID label are tied to a configured rule (rules created in the rule editor or API; OPNsense's
|
||||
built-in automatic rules carry hash labels and are not user rules).
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.host import OpnsenseHost
|
||||
from app.models.rule_hit import RuleHit
|
||||
from app.services import opnsense_client as client
|
||||
from app.services.opnsense_client import OpnsenseAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNUSED_AFTER_DAYS = 30
|
||||
_LABEL = re.compile(r'label "([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"')
|
||||
|
||||
|
||||
def parse_counters(stats: dict) -> dict[str, dict]:
|
||||
"""{rule uuid: {packets, bytes, evaluations}} summed over every pf line a rule expands to."""
|
||||
lines = ((stats or {}).get("rules") or {}).get("filter rules") or {}
|
||||
result: dict[str, dict] = {}
|
||||
for text, values in lines.items():
|
||||
match = _LABEL.search(text)
|
||||
if not match or not isinstance(values, dict):
|
||||
continue
|
||||
entry = result.setdefault(match.group(1).lower(), {"packets": 0, "bytes": 0, "evaluations": 0})
|
||||
for key in entry:
|
||||
entry[key] += int(values.get(key) or 0)
|
||||
return result
|
||||
|
||||
|
||||
def configured_rules(search: dict) -> dict[str, dict]:
|
||||
rows = (search or {}).get("rows") or []
|
||||
return {
|
||||
str(r["uuid"]).lower(): {"description": r.get("description") or "", "enabled": r.get("enabled") in ("1", 1, True)}
|
||||
for r in rows if r.get("uuid") and not r.get("is_automatic")
|
||||
}
|
||||
|
||||
|
||||
async def sample_host(db: AsyncSession, host: OpnsenseHost, now: datetime | None = None) -> int:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
counters = parse_counters(await client.get_pf_rule_statistics(host))
|
||||
rules = configured_rules(await client.search_rules(host, all_rows=True))
|
||||
rows = {r.rule_uuid: r for r in (await db.scalars(select(RuleHit).where(RuleHit.host_id == host.id))).all()}
|
||||
for uuid_, meta in rules.items():
|
||||
counter = counters.get(uuid_, {"packets": 0, "bytes": 0})
|
||||
row = rows.get(uuid_)
|
||||
if row is None:
|
||||
row = RuleHit(host_id=host.id, rule_uuid=uuid_, first_seen_at=now, last_packets=0, last_bytes=0, last_sampled_at=now)
|
||||
db.add(row)
|
||||
if counter["packets"] > 0:
|
||||
row.last_hit_at = now # already matching when first seen
|
||||
elif counter["packets"] > row.last_packets or (counter["packets"] < row.last_packets and counter["packets"] > 0):
|
||||
row.last_hit_at = now
|
||||
row.description = meta["description"][:255]
|
||||
row.enabled = meta["enabled"]
|
||||
row.last_packets, row.last_bytes, row.last_sampled_at = counter["packets"], counter["bytes"], now
|
||||
for uuid_, row in rows.items(): # rule deleted on the firewall
|
||||
if uuid_ not in rules:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return len(rules)
|
||||
|
||||
|
||||
async def sample_all(db: AsyncSession) -> int:
|
||||
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))).all()
|
||||
sampled = 0
|
||||
for host in hosts:
|
||||
try:
|
||||
await sample_host(db, host)
|
||||
sampled += 1
|
||||
except OpnsenseAPIError as exc:
|
||||
logger.warning("Regelnutzung von %s nicht lesbar: %s", host.name, exc)
|
||||
except Exception:
|
||||
logger.exception("Regelnutzung von %s fehlgeschlagen", host.name)
|
||||
return sampled
|
||||
|
||||
|
||||
def classify(row: RuleHit, now: datetime) -> str:
|
||||
"""'used' | 'unused' | 'collecting' | 'disabled'"""
|
||||
if not row.enabled:
|
||||
return "disabled"
|
||||
if row.last_hit_at is not None and now - row.last_hit_at < timedelta(days=UNUSED_AFTER_DAYS):
|
||||
return "used"
|
||||
watched = now - row.first_seen_at
|
||||
if watched < timedelta(days=UNUSED_AFTER_DAYS):
|
||||
return "used" if row.last_hit_at is not None else "collecting"
|
||||
return "unused"
|
||||
|
||||
|
||||
async def unused_summary(db: AsyncSession, host_id) -> dict | None:
|
||||
"""For the security check: None while nothing is known yet, else {"ready": bool, "rules": [names]}."""
|
||||
rows = (await db.scalars(select(RuleHit).where(RuleHit.host_id == host_id))).all()
|
||||
if not rows:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
states = [(r, classify(r, now)) for r in rows]
|
||||
unused = [r.description or r.rule_uuid for r, s in states if s == "unused"]
|
||||
watching = [s for _, s in states if s == "collecting"]
|
||||
return {"unused": unused, "collecting": len(watching)}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Minimal S3-compatible object client (AWS Signature V4) for offsite backups.
|
||||
|
||||
Just what is needed — PUT, GET, DELETE and a connection test — so no AWS SDK is pulled
|
||||
into the image. Works with AWS S3, MinIO, Backblaze B2, Wasabi, Cloudflare R2, Hetzner
|
||||
Object Storage... Path-style addressing (``https://endpoint/bucket/key``) is the default
|
||||
because it works everywhere; virtual-hosted style is available for providers that need it.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class S3Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _hmac(key: bytes, message: str) -> bytes:
|
||||
return hmac.new(key, message.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _quote(path: str) -> str:
|
||||
return quote(path, safe="/-_.~")
|
||||
|
||||
|
||||
def sign(*, method: str, host: str, path: str, query: str, payload_hash: str, access_key: str, secret_key: str,
|
||||
region: str, now: datetime, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""SigV4 headers for one request (the algorithm of the AWS docs, service ``s3``)."""
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
day = now.strftime("%Y%m%d")
|
||||
headers = {"host": host, "x-amz-content-sha256": payload_hash, "x-amz-date": amz_date}
|
||||
headers.update({k.lower(): v.strip() for k, v in (extra_headers or {}).items()})
|
||||
names = sorted(headers)
|
||||
canonical_headers = "".join(f"{n}:{headers[n]}\n" for n in names)
|
||||
signed = ";".join(names)
|
||||
canonical_request = "\n".join([method, _quote(path), query, canonical_headers, signed, payload_hash])
|
||||
scope = f"{day}/{region}/s3/aws4_request"
|
||||
to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, scope, _sha256(canonical_request.encode())])
|
||||
key = _hmac(_hmac(_hmac(_hmac(("AWS4" + secret_key).encode(), day), region), "s3"), "aws4_request")
|
||||
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
headers["authorization"] = f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed}, Signature={signature}"
|
||||
return headers
|
||||
|
||||
|
||||
class S3Client:
|
||||
def __init__(self, *, endpoint: str, region: str, bucket: str, access_key: str, secret_key: str, path_style: bool = True):
|
||||
parts = urlsplit(endpoint)
|
||||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||||
raise S3Error("Ungültiger Endpunkt")
|
||||
if parts.scheme == "http" and not settings.allow_insecure_offsite:
|
||||
# Credentials and (encrypted, but still) backups must not travel in the clear.
|
||||
raise S3Error("Der Endpunkt muss https:// sein")
|
||||
self.scheme, self.netloc = parts.scheme, parts.netloc
|
||||
self.region, self.bucket, self.path_style = region or "us-east-1", bucket, path_style
|
||||
self.access_key, self.secret_key = access_key, secret_key
|
||||
|
||||
def _target(self, key: str) -> tuple[str, str, str]:
|
||||
"""(host header, URL path, URL)"""
|
||||
if self.path_style:
|
||||
host, path = self.netloc, f"/{self.bucket}/{key}"
|
||||
else:
|
||||
host, path = f"{self.bucket}.{self.netloc}", f"/{key}"
|
||||
return host, path, f"{self.scheme}://{host}{_quote(path)}"
|
||||
|
||||
async def _request(self, method: str, key: str, body: bytes = b"") -> httpx.Response:
|
||||
host, path, url = self._target(key)
|
||||
headers = sign(
|
||||
method=method, host=host, path=path, query="", payload_hash=_sha256(body), access_key=self.access_key,
|
||||
secret_key=self.secret_key, region=self.region, now=datetime.now(timezone.utc),
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
return await client.request(method, url, content=body if method == "PUT" else None, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
raise S3Error(f"Verbindung fehlgeschlagen ({type(exc).__name__})") from None
|
||||
|
||||
@staticmethod
|
||||
def _check(response: httpx.Response, what: str) -> None:
|
||||
if response.status_code >= 300:
|
||||
raise S3Error(f"{what}: HTTP {response.status_code}")
|
||||
|
||||
async def put(self, key: str, data: bytes) -> None:
|
||||
self._check(await self._request("PUT", key, data), "Hochladen")
|
||||
|
||||
async def get(self, key: str) -> bytes:
|
||||
response = await self._request("GET", key)
|
||||
self._check(response, "Herunterladen")
|
||||
return response.content
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
response = await self._request("DELETE", key)
|
||||
if response.status_code not in (200, 204, 404):
|
||||
self._check(response, "Löschen")
|
||||
|
||||
async def test(self, key: str) -> None:
|
||||
"""Write, read back and delete a tiny object: proves credentials, bucket and permissions."""
|
||||
probe = b"opnexus connection test"
|
||||
await self.put(key, probe)
|
||||
if await self.get(key) != probe:
|
||||
raise S3Error("Der Speicher lieferte etwas anderes zurück")
|
||||
await self.delete(key)
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.17.0"
|
||||
APP_CODENAME = "Imperial"
|
||||
APP_VERSION = "1.19.0"
|
||||
APP_CODENAME = "Lanner"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""API-Tokens für Skripte, CI, Prometheus und MCP.
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-10-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Eine Altinstallation (vor Alembic) wird mit create_all() nach den aktuellen
|
||||
# Modellen gebaut und hat die Tabelle dann schon.
|
||||
if sa.inspect(op.get_bind()).has_table("api_tokens"):
|
||||
return
|
||||
op.create_table(
|
||||
"api_tokens",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=80), nullable=False),
|
||||
sa.Column("scope", sa.String(length=10), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("prefix", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_ip", sa.String(length=45), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["admin_users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
|
||||
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS api_tokens")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Sicherheitslage: letztes Prüfergebnis je Host.
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-10-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Eine Altinstallation wird mit create_all() nach den aktuellen Modellen gebaut.
|
||||
if sa.inspect(op.get_bind()).has_table("compliance_results"):
|
||||
return
|
||||
op.create_table(
|
||||
"compliance_results",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("score", sa.Integer(), nullable=True),
|
||||
sa.Column("findings", sa.JSON(), nullable=False),
|
||||
sa.Column("error", sa.String(length=20), nullable=True),
|
||||
sa.Column("error_detail", sa.String(length=300), nullable=True),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_compliance_results_host_id", "compliance_results", ["host_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS compliance_results")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Audit-Export: Syslog und Webhook.
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("audit_export_settings"):
|
||||
return
|
||||
op.create_table(
|
||||
"audit_export_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("syslog_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("syslog_host", sa.String(length=255), nullable=True),
|
||||
sa.Column("syslog_port", sa.Integer(), nullable=False),
|
||||
sa.Column("syslog_protocol", sa.String(length=3), nullable=False),
|
||||
sa.Column("webhook_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("webhook_url", sa.String(length=500), nullable=True),
|
||||
sa.Column("webhook_signing_secret_set", sa.Boolean(), nullable=False),
|
||||
sa.Column("cursor_created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cursor_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error", sa.String(length=300), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS audit_export_settings")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Geplante Backups mit Aufbewahrung und verschlüsseltem Offsite-Ziel; Benachrichtigung bei Backup-Problemen.
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS backup_failed_enabled boolean NOT NULL DEFAULT true")
|
||||
if not inspector.has_table("backup_settings"):
|
||||
op.create_table(
|
||||
"backup_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("scheduled_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("interval_hours", sa.Integer(), nullable=False),
|
||||
sa.Column("keep_count", sa.Integer(), nullable=False),
|
||||
sa.Column("offsite_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("s3_endpoint", sa.String(length=255), nullable=True),
|
||||
sa.Column("s3_region", sa.String(length=64), nullable=False),
|
||||
sa.Column("s3_bucket", sa.String(length=255), nullable=True),
|
||||
sa.Column("s3_prefix", sa.String(length=255), nullable=False),
|
||||
sa.Column("s3_access_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("s3_path_style", sa.Boolean(), nullable=False),
|
||||
sa.Column("s3_secret_set", sa.Boolean(), nullable=False),
|
||||
sa.Column("passphrase_set", sa.Boolean(), nullable=False),
|
||||
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_offsite_ok_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_offsite_error", sa.String(length=300), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
if not inspector.has_table("backup_uploads"):
|
||||
op.create_table(
|
||||
"backup_uploads",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("snapshot_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("remote_key", sa.String(length=400), nullable=False),
|
||||
sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("host_id", "snapshot_id", name="uq_backup_upload"),
|
||||
)
|
||||
op.create_index("ix_backup_uploads_host_id", "backup_uploads", ["host_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS backup_uploads")
|
||||
op.execute("DROP TABLE IF EXISTS backup_settings")
|
||||
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS backup_failed_enabled")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Regelnutzung: wann hat eine Regel zuletzt etwas getroffen.
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table("rule_hits"):
|
||||
return
|
||||
op.create_table(
|
||||
"rule_hits",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("host_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("rule_uuid", sa.String(length=36), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_sampled_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_hit_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_packets", sa.BigInteger(), nullable=False),
|
||||
sa.Column("last_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["opnsense_hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("host_id", "rule_uuid", name="uq_rule_hit"),
|
||||
)
|
||||
op.create_index("ix_rule_hits_host_id", "rule_hits", ["host_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS rule_hits")
|
||||
@@ -0,0 +1,281 @@
|
||||
"""API tokens: creation, scopes, session-only boundaries, revocation, audit."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.api_token import ApiToken
|
||||
from app.services import api_token_service
|
||||
|
||||
PASSWORD = settings.admin_password
|
||||
|
||||
|
||||
async def _make(client, scope="read", name="ci", **extra):
|
||||
resp = await client.post("/api/tokens", json={"name": name, "scope": scope, "current_password": PASSWORD, **extra})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _bearer(token: str) -> AsyncClient:
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
|
||||
async def _login(username: str, password: str) -> AsyncClient:
|
||||
ac = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||
resp = await ac.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return ac
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scope,method,path,denied",
|
||||
[
|
||||
("read", "GET", "/api/hosts", False),
|
||||
("read", "POST", "/api/rules", True),
|
||||
# Secret-bearing reads need the admin scope; MCP (read-only tools) is open to every scope.
|
||||
("read", "GET", "/api/hosts/abc/backups/config-1.xml", True),
|
||||
("deploy", "GET", "/api/hosts/abc/backups/config-1.xml", True),
|
||||
("admin", "GET", "/api/hosts/abc/backups/config-1.xml", False),
|
||||
("read", "GET", "/api/hosts/abc/backups", False),
|
||||
("read", "GET", "/api/notifications/settings", True),
|
||||
("admin", "GET", "/api/notifications/settings", False),
|
||||
("read", "POST", "/api/mcp", False),
|
||||
("deploy", "POST", "/api/rules", False),
|
||||
("deploy", "DELETE", "/api/aliases/x", False),
|
||||
("deploy", "POST", "/api/hosts", True),
|
||||
("deploy", "POST", "/api/hosts/tls-probe", True),
|
||||
("deploy", "PATCH", "/api/hosts/abc", True),
|
||||
("deploy", "DELETE", "/api/hosts/abc", True),
|
||||
("deploy", "POST", "/api/hosts/abc/maintenance/execute", False),
|
||||
("deploy", "POST", "/api/users", True),
|
||||
("deploy", "PUT", "/api/settings", True),
|
||||
("deploy", "PUT", "/api/notifications/settings", True),
|
||||
("deploy", "PUT", "/api/logs/settings", True),
|
||||
("deploy", "POST", "/api/system/updater/update", True),
|
||||
("admin", "POST", "/api/users", False),
|
||||
("admin", "POST", "/api/hosts", False),
|
||||
# Session-only for every scope, reads included.
|
||||
("admin", "GET", "/api/tokens", True),
|
||||
("admin", "POST", "/api/tokens", True),
|
||||
("read", "GET", "/api/tokens", True),
|
||||
("admin", "POST", "/api/auth/change-password", True),
|
||||
("admin", "POST", "/api/auth/2fa/disable", True),
|
||||
],
|
||||
)
|
||||
def test_scope_matrix(scope, method, path, denied):
|
||||
assert api_token_service.denies(scope, method, path) is denied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_needs_password_and_shows_token_once(client):
|
||||
bad = await client.post("/api/tokens", json={"name": "x", "current_password": "wrong"})
|
||||
assert bad.status_code == 401
|
||||
assert bad.json()["detail"]["code"] == "AKTUELLES_PASSWORT_IST_FALSCH"
|
||||
|
||||
created = await _make(client, expires_in_days=30)
|
||||
assert created["token"].startswith("opx_")
|
||||
assert created["scope"] == "read"
|
||||
assert created["prefix"] == created["token"][:10]
|
||||
assert created["expires_at"] is not None
|
||||
|
||||
listed = (await client.get("/api/tokens")).json()
|
||||
assert [t["id"] for t in listed] == [created["id"]]
|
||||
assert "token" not in listed[0] and created["token"] not in str(listed)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_the_hash_is_stored(client):
|
||||
created = await _make(client)
|
||||
async with SessionLocal() as db:
|
||||
row = await db.scalar(select(ApiToken))
|
||||
assert row.token_hash == api_token_service.hash_token(created["token"])
|
||||
assert created["token"] not in (row.token_hash, row.prefix)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_authenticates_without_a_session(client, anon_client):
|
||||
created = await _make(client)
|
||||
async with _bearer(created["token"]) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
me = (await api.get("/api/auth/me")).json()
|
||||
assert me["authenticated"] and me["via"] == "token" and me["scope"] == "read"
|
||||
assert (await anon_client.get("/api/hosts")).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_scope_cannot_write(client):
|
||||
created = await _make(client, "read")
|
||||
async with _bearer(created["token"]) as api:
|
||||
resp = await api.post("/api/groups", json={"name": "g"})
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deploy_scope_writes_fleet_objects_but_not_access_control(client):
|
||||
created = await _make(client, "deploy")
|
||||
async with _bearer(created["token"]) as api:
|
||||
assert (await api.post("/api/groups", json={"name": "from-ci"})).status_code == 201
|
||||
for method, path, body in [
|
||||
("POST", "/api/users", {"username": "evil", "password": "evil-password-123", "role": "admin"}),
|
||||
("POST", "/api/hosts", {}),
|
||||
]:
|
||||
resp = await api.request(method, path, json=body)
|
||||
assert resp.status_code == 403, (path, resp.text)
|
||||
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tokens_cannot_manage_tokens_or_accounts(client):
|
||||
created = await _make(client, "admin")
|
||||
async with _bearer(created["token"]) as api:
|
||||
for method, path in [("GET", "/api/tokens"), ("POST", "/api/tokens"), ("DELETE", f"/api/tokens/{created['id']}"),
|
||||
("POST", "/api/auth/change-password")]:
|
||||
resp = await api.request(method, path, json={"name": "x", "current_password": PASSWORD})
|
||||
assert resp.status_code == 403, (path, resp.text)
|
||||
assert resp.json()["detail"]["code"] == "API_TOKEN_SCOPE_FORBIDDEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_scope_acts_as_admin_and_is_audited_with_token_name(client):
|
||||
created = await _make(client, "admin", name="terraform")
|
||||
async with _bearer(created["token"]) as api:
|
||||
resp = await api.post("/api/users", json={"username": "viaapi", "password": "via-api-password-1", "role": "read_only"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
items = (await client.get("/api/audit", params={"kind": "user"})).json()["items"]
|
||||
assert any(e["actor"] == "admin (API: terraform)" for e in items), items
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_or_expired_token_is_401_and_never_falls_back_to_the_cookie(client):
|
||||
# `client` carries a valid session cookie; a broken bearer header must still fail.
|
||||
resp = await client.get("/api/hosts", headers={"Authorization": "Bearer opx_doesnotexist"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
created = await _make(client)
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(update(ApiToken).values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
|
||||
await db.commit()
|
||||
async with _bearer(created["token"]) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_token_bearer_values_are_ignored(client):
|
||||
# Only opx_ values are API tokens; anything else leaves normal cookie auth alone.
|
||||
resp = await client.get("/api/hosts", headers={"Authorization": "Bearer something-else"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoked_token_stops_working(client):
|
||||
created = await _make(client)
|
||||
async with _bearer(created["token"]) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
assert (await client.delete(f"/api/tokens/{created['id']}")).status_code == 204
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
assert (await client.delete(f"/api/tokens/{created['id']}")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_used_is_recorded(client):
|
||||
created = await _make(client)
|
||||
async with _bearer(created["token"]) as api:
|
||||
await api.get("/api/hosts")
|
||||
listed = (await client.get("/api/tokens")).json()[0]
|
||||
assert listed["last_used_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_user_gets_only_read_tokens_and_sees_only_their_own(client):
|
||||
resp = await client.post("/api/users", json={"username": "tokviewer", "password": "viewer-password-123", "role": "read_only"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
admin_token = await _make(client, "admin", name="admins")
|
||||
viewer = await _login("tokviewer", "viewer-password-123")
|
||||
try:
|
||||
denied = await viewer.post("/api/tokens", json={"name": "x", "scope": "deploy", "current_password": "viewer-password-123"})
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["detail"]["code"] == "API_TOKEN_SCOPE_TOO_HIGH"
|
||||
ok = await viewer.post("/api/tokens", json={"name": "grafana", "scope": "read", "current_password": "viewer-password-123"})
|
||||
assert ok.status_code == 201, ok.text
|
||||
mine = (await viewer.get("/api/tokens")).json()
|
||||
assert [t["name"] for t in mine] == ["grafana"]
|
||||
# Not someone else's token.
|
||||
assert (await viewer.delete(f"/api/tokens/{admin_token['id']}")).status_code == 404
|
||||
finally:
|
||||
await viewer.aclose()
|
||||
# The admin sees both and may revoke the viewer's.
|
||||
all_tokens = {t["name"]: t for t in (await client.get("/api/tokens")).json()}
|
||||
assert set(all_tokens) == {"admins", "grafana"} and all_tokens["grafana"]["owner"] == "tokviewer"
|
||||
assert (await client.delete(f"/api/tokens/{all_tokens['grafana']['id']}")).status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demoted_owner_makes_the_token_read_only(client):
|
||||
users = await client.post("/api/users", json={"username": "demotee", "password": "demotee-password-1", "role": "admin"})
|
||||
uid = users.json()["id"]
|
||||
owner = await _login("demotee", "demotee-password-1")
|
||||
try:
|
||||
created = (await owner.post("/api/tokens", json={"name": "t", "scope": "admin", "current_password": "demotee-password-1"})).json()
|
||||
finally:
|
||||
await owner.aclose()
|
||||
assert (await client.patch(f"/api/users/{uid}/role", json={"role": "read_only"})).status_code == 200
|
||||
async with _bearer(created["token"]) as api:
|
||||
resp = await api.post("/api/groups", json={"name": "g"})
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"]["code"] == "READ_ONLY_ACCESS"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_reset_and_user_delete_revoke_tokens(client):
|
||||
resp = await client.post("/api/users", json={"username": "resetme", "password": "resetme-password-1", "role": "admin"})
|
||||
uid = resp.json()["id"]
|
||||
owner = await _login("resetme", "resetme-password-1")
|
||||
try:
|
||||
t1 = (await owner.post("/api/tokens", json={"name": "a", "current_password": "resetme-password-1"})).json()
|
||||
finally:
|
||||
await owner.aclose()
|
||||
async with _bearer(t1["token"]) as api:
|
||||
assert (await api.get("/api/hosts")).status_code == 200
|
||||
assert (await client.post(f"/api/users/{uid}/reset-password", json={"new_password": "brand-new-password-1"})).status_code == 200
|
||||
assert (await api.get("/api/hosts")).status_code == 401
|
||||
assert (await client.delete(f"/api/users/{uid}")).status_code == 204
|
||||
async with SessionLocal() as db:
|
||||
assert (await db.scalars(select(ApiToken))).all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_count_is_limited(client, monkeypatch):
|
||||
monkeypatch.setattr(api_token_service, "MAX_TOKENS_PER_USER", 2)
|
||||
await _make(client, name="1")
|
||||
await _make(client, name="2")
|
||||
resp = await client.post("/api/tokens", json={"name": "3", "current_password": PASSWORD})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "API_TOKEN_LIMIT_REACHED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_password_attempts_are_throttled(client):
|
||||
codes = []
|
||||
for _ in range(15):
|
||||
codes.append((await client.post("/api/tokens", json={"name": "x", "current_password": "nope"})).status_code)
|
||||
assert 429 in codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_schema_is_served_behind_auth_with_bearer_scheme(client, anon_client):
|
||||
assert (await anon_client.get("/api/openapi.json")).status_code == 401
|
||||
schema = (await client.get("/api/openapi.json")).json()
|
||||
assert schema["components"]["securitySchemes"]["bearerAuth"]["scheme"] == "bearer"
|
||||
assert "/api/tokens" in schema["paths"] and "/api/hosts" in schema["paths"]
|
||||
created = await _make(client)
|
||||
async with _bearer(created["token"]) as api:
|
||||
assert (await api.get("/api/openapi.json")).status_code == 200
|
||||
# No interactive docs page (its CDN scripts would be blocked by the CSP anyway).
|
||||
assert (await client.get("/docs")).status_code != 200
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Audit export: cursor semantics, wire formats (syslog UDP/TCP, signed webhook), admin-only settings."""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.audit_export import AuditExportSettings
|
||||
from app.services import audit_export_service as svc
|
||||
from app.services import notification_secret_store
|
||||
from tests.test_api_tokens import _login
|
||||
|
||||
|
||||
async def _event(n: int, *, age_s: float = 60, status="accepted", details=None) -> AuditEvent:
|
||||
async with SessionLocal() as db:
|
||||
event = AuditEvent(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(seconds=age_s + (10 - n) * 0.001), correlation_id="c", actor="admin", kind="rule",
|
||||
action="deploy", status=status, entity_name=f"rule-{n}", message=f"m{n}", details=details or {},
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
return event
|
||||
|
||||
|
||||
async def _configure(**fields):
|
||||
async with SessionLocal() as db:
|
||||
row = await svc.get_settings(db)
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _run(lag=0):
|
||||
async with SessionLocal() as db:
|
||||
return await svc.run_once(db, lag_seconds=lag)
|
||||
|
||||
|
||||
async def _state():
|
||||
async with SessionLocal() as db:
|
||||
return await svc.get_settings(db)
|
||||
|
||||
|
||||
class _UdpReceiver:
|
||||
def __init__(self):
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.settimeout(2)
|
||||
self.port = self.sock.getsockname()[1]
|
||||
|
||||
def receive(self, n):
|
||||
return [self.sock.recvfrom(65535)[0].decode() for _ in range(n)]
|
||||
|
||||
|
||||
class _TcpReceiver:
|
||||
def __init__(self):
|
||||
self.server = socket.socket()
|
||||
self.server.bind(("127.0.0.1", 0))
|
||||
self.server.listen(1)
|
||||
self.port = self.server.getsockname()[1]
|
||||
self.data = b""
|
||||
self.thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _serve(self):
|
||||
conn, _ = self.server.accept()
|
||||
while chunk := conn.recv(65535):
|
||||
self.data += chunk
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _reset(tmp_path):
|
||||
async with SessionLocal() as db:
|
||||
await db.execute(AuditExportSettings.__table__.delete())
|
||||
await db.commit()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_when_nothing_is_enabled():
|
||||
await _event(1)
|
||||
assert await _run() == 0
|
||||
assert (await _state()).cursor_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_round_starts_from_now_without_replaying_history():
|
||||
await _event(1, age_s=3600)
|
||||
udp = _UdpReceiver()
|
||||
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port)
|
||||
assert await _run() == 0 # sets the cursor
|
||||
assert (await _state()).cursor_created_at is not None
|
||||
await asyncio.sleep(0.05)
|
||||
await _event(2, age_s=0)
|
||||
assert await _run() == 1
|
||||
(line,) = udp.receive(1)
|
||||
assert '"entity_name": "rule-2"' in line and "rule-1" not in line
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_syslog_udp_line_is_rfc5424_with_severity_by_status_and_no_details():
|
||||
udp = _UdpReceiver()
|
||||
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1, status="accepted", details={"after_payload": {"password": "hunter2"}})
|
||||
await _event(2, status="denied")
|
||||
assert await _run() == 2
|
||||
ok, bad = udp.receive(2)
|
||||
assert ok.startswith("<134>1 ") and bad.startswith("<132>1 ") # local0.info / local0.warning
|
||||
assert " opnexus - AUDIT - {" in ok
|
||||
body = json.loads(ok.split(" - AUDIT - ", 1)[1])
|
||||
assert body["actor"] == "admin" and body["kind"] == "rule" and "details" not in body
|
||||
assert "hunter2" not in ok
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_syslog_tcp_uses_octet_counting():
|
||||
tcp = _TcpReceiver()
|
||||
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=tcp.port, syslog_protocol="tcp",
|
||||
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1)
|
||||
assert await _run() == 1
|
||||
tcp.thread.join(timeout=3)
|
||||
length, _, rest = tcp.data.partition(b" ")
|
||||
assert int(length) == len(rest) and rest.startswith(b"<134>1 ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_keeps_the_cursor_and_the_next_round_retries(respx_mock):
|
||||
await _configure(webhook_enabled=True, webhook_url="https://hook.test/secret-token",
|
||||
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1)
|
||||
route = respx_mock.post("https://hook.test/secret-token").mock(return_value=httpx.Response(500))
|
||||
assert await _run() == 0
|
||||
state = await _state()
|
||||
assert state.cursor_id is None and "HTTP 500" in state.last_error and "secret-token" not in state.last_error
|
||||
route.mock(return_value=httpx.Response(200))
|
||||
assert await _run() == 1
|
||||
state = await _state()
|
||||
assert state.last_error is None and state.last_success_at is not None and state.cursor_id is not None
|
||||
assert await _run() == 0 # nothing new
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_batch_is_signed(respx_mock):
|
||||
notification_secret_store.save_secret(svc.SECRET_NAME, "s3cret")
|
||||
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
|
||||
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1)
|
||||
await _event(2)
|
||||
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||
assert await _run() == 2
|
||||
request = route.calls.last.request
|
||||
assert request.headers["X-OPNexus-Signature"] == "sha256=" + hmac.new(b"s3cret", request.content, hashlib.sha256).hexdigest()
|
||||
payload = json.loads(request.content)
|
||||
assert [e["entity_name"] for e in payload["events"]] == ["rule-1", "rule-2"] and payload["source"] == "opnexus"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_signing_secret_fails_closed(respx_mock):
|
||||
notification_secret_store.delete_secret(svc.SECRET_NAME)
|
||||
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
|
||||
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1)
|
||||
assert await _run() == 0 # respx would fail the test on any request: nothing was sent
|
||||
assert "nicht lesbar" in (await _state()).last_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batches_are_ordered_and_resume_without_gaps_or_duplicates(respx_mock, monkeypatch):
|
||||
monkeypatch.setattr(svc, "BATCH", 2)
|
||||
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
for n in range(1, 6):
|
||||
await _event(n)
|
||||
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||
assert [await _run(), await _run(), await _run(), await _run()] == [2, 2, 1, 0]
|
||||
names = [e["entity_name"] for call in route.calls for e in json.loads(call.request.content)["events"]]
|
||||
assert names == [f"rule-{n}" for n in range(1, 6)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_very_fresh_events_wait_for_the_lag():
|
||||
udp = _UdpReceiver()
|
||||
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
await _event(1, age_s=0.2)
|
||||
assert await _run(lag=3) == 0
|
||||
assert await _run(lag=0) == 1
|
||||
|
||||
|
||||
# ── Settings API ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_roundtrip_hides_the_secret_and_enabling_starts_from_now(client):
|
||||
got = (await client.get("/api/settings/audit-export")).json()
|
||||
assert got["syslog_enabled"] is False and got["webhook_signing_secret_set"] is False
|
||||
put = await client.put("/api/settings/audit-export", json={
|
||||
"syslog_enabled": True, "syslog_host": "127.0.0.1", "syslog_port": 5514, "syslog_protocol": "tcp",
|
||||
"webhook_signing_secret": "shh"})
|
||||
assert put.status_code == 200, put.text
|
||||
body = put.json()
|
||||
assert body["syslog_protocol"] == "tcp" and body["webhook_signing_secret_set"] is True and "shh" not in put.text
|
||||
assert (await _state()).cursor_created_at is not None
|
||||
cleared = (await client.put("/api/settings/audit-export", json={"clear_webhook_signing_secret": True, "syslog_enabled": False})).json()
|
||||
assert cleared["webhook_signing_secret_set"] is False
|
||||
assert (await _state()).cursor_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_validation(client):
|
||||
for body in ({"syslog_protocol": "carrier-pigeon"}, {"syslog_port": 0}, {"webhook_url": "x" * 501}):
|
||||
assert (await client.put("/api/settings/audit-export", json=body)).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_endpoint_needs_a_channel_and_reports_delivery_failures(client, respx_mock):
|
||||
assert (await client.post("/api/settings/audit-export/test")).status_code == 502
|
||||
await client.put("/api/settings/audit-export", json={"webhook_enabled": True, "webhook_url": "https://hook.test/in"})
|
||||
respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
|
||||
assert (await client.post("/api/settings/audit-export/test")).json() == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_admins_may_read_or_change_it(client):
|
||||
for role in ("operator", "read_only"):
|
||||
name = f"exp-{role}"
|
||||
assert (await client.post("/api/users", json={"username": name, "password": "export-password-1", "role": role})).status_code == 201
|
||||
user = await _login(name, "export-password-1")
|
||||
try:
|
||||
assert (await user.get("/api/settings/audit-export")).status_code == 403
|
||||
assert (await user.put("/api/settings/audit-export", json={"syslog_enabled": True})).status_code == 403
|
||||
finally:
|
||||
await user.aclose()
|
||||
@@ -92,13 +92,15 @@ async def test_health_stays_public_but_docs_require_auth(anon_client):
|
||||
# /docs/redoc/openapi.json used to be public — now require a session
|
||||
# like any other endpoint, since fully documenting a firewall-fleet
|
||||
# API surface to an anonymous caller is free recon for an attacker.
|
||||
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||
for path in ("/docs", "/redoc", "/openapi.json", "/api/openapi.json"):
|
||||
resp = await anon_client.get(path)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docs_reachable_once_logged_in(client):
|
||||
async def test_openapi_schema_reachable_once_logged_in(client):
|
||||
# The schema lives under /api (the proxy only routes /api to the backend); the
|
||||
# interactive Swagger/ReDoc pages are gone (their CDN scripts violate the CSP).
|
||||
assert (await client.get("/api/openapi.json")).status_code == 200
|
||||
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||
resp = await client.get(path)
|
||||
assert resp.status_code == 200
|
||||
assert (await client.get(path)).status_code == 404
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Scheduled backups, retention, encrypted offsite copy: crypto, S3 signing, schedule logic, API."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.backup_settings import BackupSettings, BackupUpload
|
||||
from app.services import backup_crypto, backup_schedule_service as svc, backup_store, notification_checks, notification_secret_store
|
||||
from app.services import s3_client
|
||||
from tests.test_api_tokens import _login
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
CONFIG = b"<?xml version='1.0'?><opnsense><system><hostname>fw</hostname></system></opnsense>"
|
||||
|
||||
|
||||
# ── encryption ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_encryption_roundtrip_and_every_failure_mode():
|
||||
blob = backup_crypto.encrypt(CONFIG, "correct horse battery")
|
||||
assert blob.startswith(backup_crypto.MAGIC) and CONFIG not in blob
|
||||
assert backup_crypto.decrypt(blob, "correct horse battery") == CONFIG
|
||||
assert backup_crypto.encrypt(CONFIG, "correct horse battery") != blob # fresh salt and nonce
|
||||
with pytest.raises(backup_crypto.DecryptError):
|
||||
backup_crypto.decrypt(blob, "wrong passphrase!")
|
||||
tampered = bytearray(blob)
|
||||
tampered[-1] ^= 1
|
||||
with pytest.raises(backup_crypto.DecryptError):
|
||||
backup_crypto.decrypt(bytes(tampered), "correct horse battery")
|
||||
relabelled = bytearray(blob)
|
||||
relabelled[len(backup_crypto.MAGIC)] ^= 1 # salt is authenticated header data
|
||||
with pytest.raises(backup_crypto.DecryptError):
|
||||
backup_crypto.decrypt(bytes(relabelled), "correct horse battery")
|
||||
for junk in (b"", b"OPXBAK1", b"not a backup at all, really not" * 3):
|
||||
with pytest.raises(backup_crypto.DecryptError):
|
||||
backup_crypto.decrypt(junk, "x" * 12)
|
||||
|
||||
|
||||
def test_decrypt_cli_reads_stdin_and_the_environment():
|
||||
blob = backup_crypto.encrypt(CONFIG, "cli passphrase 123")
|
||||
ok = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
|
||||
env={**os.environ, "BACKUP_PASSPHRASE": "cli passphrase 123"})
|
||||
assert ok.returncode == 0 and ok.stdout == CONFIG
|
||||
bad = subprocess.run([sys.executable, "-m", "app.backup_decrypt"], input=blob, capture_output=True,
|
||||
env={**os.environ, "BACKUP_PASSPHRASE": "nope nope nope!"})
|
||||
assert bad.returncode == 1 and bad.stdout == b""
|
||||
|
||||
|
||||
# ── SigV4 (examples from the AWS S3 documentation) ───────────────────────
|
||||
|
||||
_AK, _SK = "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
_EMPTY = s3_client._sha256(b"")
|
||||
|
||||
|
||||
def test_sigv4_matches_the_aws_get_object_example():
|
||||
headers = s3_client.sign(
|
||||
method="GET", host="examplebucket.s3.amazonaws.com", path="/test.txt", query="", payload_hash=_EMPTY,
|
||||
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
|
||||
extra_headers={"Range": "bytes=0-9"},
|
||||
)
|
||||
assert headers["authorization"].endswith("Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41")
|
||||
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date" in headers["authorization"]
|
||||
|
||||
|
||||
def test_sigv4_matches_the_aws_put_object_example_with_an_escaped_key():
|
||||
body = b"Welcome to Amazon S3."
|
||||
headers = s3_client.sign(
|
||||
method="PUT", host="examplebucket.s3.amazonaws.com", path="/test$file.text", query="", payload_hash=s3_client._sha256(body),
|
||||
access_key=_AK, secret_key=_SK, region="us-east-1", now=datetime(2013, 5, 24, tzinfo=timezone.utc),
|
||||
extra_headers={"Date": "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY"},
|
||||
)
|
||||
assert headers["authorization"].endswith("Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd")
|
||||
|
||||
|
||||
def _s3(**kw):
|
||||
return s3_client.S3Client(endpoint="https://s3.test", region="eu-1", bucket="bk", access_key="AK", secret_key="SK", **kw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_client_requests_and_errors(respx_mock):
|
||||
put = respx_mock.put("https://s3.test/bk/a/b%20c.opxbak").mock(return_value=httpx.Response(200))
|
||||
await _s3().put("a/b c.opxbak", b"data")
|
||||
request = put.calls.last.request
|
||||
assert request.headers["authorization"].startswith("AWS4-HMAC-SHA256 Credential=AK/") and "/eu-1/s3/aws4_request" in request.headers["authorization"]
|
||||
assert request.headers["x-amz-content-sha256"] == s3_client._sha256(b"data") and request.content == b"data"
|
||||
respx_mock.put("https://bk.s3.test/x").mock(return_value=httpx.Response(403))
|
||||
with pytest.raises(s3_client.S3Error, match="HTTP 403"):
|
||||
await _s3(path_style=False).put("x", b"1")
|
||||
respx_mock.delete("https://s3.test/bk/gone").mock(return_value=httpx.Response(404))
|
||||
await _s3().delete("gone") # deleting what is not there is fine
|
||||
respx_mock.put("https://s3.test/bk/net").mock(side_effect=httpx.ConnectError("boom"))
|
||||
with pytest.raises(s3_client.S3Error, match="Verbindung"):
|
||||
await _s3().put("net", b"1")
|
||||
|
||||
|
||||
def test_s3_endpoint_must_be_https_unless_the_lab_switch_is_on(monkeypatch):
|
||||
with pytest.raises(s3_client.S3Error):
|
||||
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
for bad in ("ftp://x", "minio", ""):
|
||||
with pytest.raises(s3_client.S3Error):
|
||||
s3_client.S3Client(endpoint=bad, region="r", bucket="b", access_key="a", secret_key="s")
|
||||
monkeypatch.setattr(s3_client.settings, "allow_insecure_offsite", True)
|
||||
s3_client.S3Client(endpoint="http://minio.lan:9000", region="r", bucket="b", access_key="a", secret_key="s")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_connection_test_proves_write_read_delete(respx_mock):
|
||||
store = {}
|
||||
respx_mock.put(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
|
||||
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
|
||||
gone = respx_mock.delete(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(204))
|
||||
await _s3().test("probe")
|
||||
assert gone.called
|
||||
respx_mock.get(url__regex=r"https://s3.test/bk/probe").mock(return_value=httpx.Response(200, content=b"other"))
|
||||
with pytest.raises(s3_client.S3Error):
|
||||
await _s3().test("probe")
|
||||
|
||||
|
||||
# ── schedule, retention, offsite ─────────────────────────────────────────
|
||||
|
||||
def _mock_fw(respx_mock, content=CONFIG):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
return respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=content))
|
||||
|
||||
|
||||
async def _cfg(**fields):
|
||||
async with SessionLocal() as db:
|
||||
row = await svc.get_settings(db)
|
||||
for k, v in fields.items():
|
||||
setattr(row, k, v)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _run(force=False):
|
||||
async with SessionLocal() as db:
|
||||
return await svc.run_due(db, force=force)
|
||||
|
||||
|
||||
def _age(host_id, snapshot_id, hours):
|
||||
import app.services.backup_store as bs
|
||||
path = bs._host_dir(host_id) / snapshot_id
|
||||
t = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp()
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_backup_is_taken_when_due_and_not_before(client, respx_mock):
|
||||
download = _mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
assert (await _run())["taken"] == 1 and len(backup_store.list_snapshots(host["id"])) == 1
|
||||
assert (await _run())["taken"] == 0 # fresh, nothing due
|
||||
first = backup_store.list_snapshots(host["id"])[0]["id"]
|
||||
_age(host["id"], first, 25)
|
||||
assert (await _run())["taken"] == 1 and download.call_count == 2
|
||||
assert (await _run(force=True))["taken"] == 1 # "run now" ignores the interval
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_schedule_takes_nothing_and_disabled_hosts_are_skipped(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _cfg(scheduled_enabled=False)
|
||||
assert (await _run())["taken"] == 0
|
||||
await _cfg(scheduled_enabled=True)
|
||||
await client.patch(f"/api/hosts/{host['id']}", json={"enabled": False})
|
||||
assert (await _run())["taken"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_keeps_only_the_newest_n(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _cfg(keep_count=3)
|
||||
for _ in range(6):
|
||||
backup_store.save_snapshot(host["id"], CONFIG)
|
||||
result = await _run()
|
||||
kept = backup_store.list_snapshots(host["id"])
|
||||
assert len(kept) == 3 and result["pruned"] >= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_is_counted_a_garbage_answer_is_not_stored_and_others_continue(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
ok = await _create_host(client, "fw-ok", "https://ok.test")
|
||||
bad = await _create_host(client, "fw-bad", "https://bad.test")
|
||||
junk = await _create_host(client, "fw-junk", "https://junk.test")
|
||||
respx_mock.get("https://ok.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=CONFIG))
|
||||
respx_mock.get("https://bad.test/api/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||
respx_mock.get("https://junk.test/api/core/backup/download/this").mock(return_value=httpx.Response(200, content=b"<html>login</html>"))
|
||||
result = await _run()
|
||||
assert (result["taken"], result["failed"]) == (1, 2)
|
||||
assert backup_store.list_snapshots(ok["id"]) and not backup_store.list_snapshots(bad["id"]) and not backup_store.list_snapshots(junk["id"])
|
||||
|
||||
|
||||
async def _offsite_on(passphrase="offsite passphrase 1"):
|
||||
notification_secret_store.save_secret(svc.S3_SECRET, "SK")
|
||||
notification_secret_store.save_secret(svc.PASSPHRASE, passphrase)
|
||||
await _cfg(offsite_enabled=True, s3_endpoint="https://s3.test", s3_bucket="bk", s3_access_key="AK", s3_secret_set=True, passphrase_set=True, s3_prefix="/opnexus/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offsite_upload_is_encrypted_once_only_and_named_by_host(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw 1/main", "https://fw1.test")
|
||||
await _offsite_on()
|
||||
puts = respx_mock.put(url__regex=r"https://s3\.test/bk/opnexus/.*").mock(return_value=httpx.Response(200))
|
||||
result = await _run()
|
||||
assert result["uploaded"] == 1 and puts.call_count == 1
|
||||
request = puts.calls.last.request
|
||||
assert re.fullmatch(rf"/bk/opnexus/fw-1-main-{host['id'][:8]}/\d{{8}}T\d+\.opxbak", request.url.path)
|
||||
assert CONFIG not in request.content and backup_crypto.decrypt(request.content, "offsite passphrase 1") == CONFIG
|
||||
assert (await _run())["uploaded"] == 0 and puts.call_count == 1 # ledger: nothing twice
|
||||
async with SessionLocal() as db:
|
||||
assert len((await db.scalars(select(BackupUpload))).all()) == 1
|
||||
cfg = await svc.get_settings(db)
|
||||
assert cfg.last_offsite_error is None and cfg.last_offsite_ok_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offsite_failure_is_recorded_and_retried_next_round(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _offsite_on()
|
||||
route = respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(503))
|
||||
await _run()
|
||||
async with SessionLocal() as db:
|
||||
assert "HTTP 503" in (await svc.get_settings(db)).last_offsite_error
|
||||
assert (await db.scalars(select(BackupUpload))).all() == []
|
||||
route.mock(return_value=httpx.Response(200))
|
||||
assert (await _run())["uploaded"] == 1
|
||||
async with SessionLocal() as db:
|
||||
assert (await svc.get_settings(db)).last_offsite_error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_offsite_config_and_lost_passphrase_fail_closed(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _cfg(offsite_enabled=True)
|
||||
await _run()
|
||||
async with SessionLocal() as db:
|
||||
assert "unvollständig" in (await svc.get_settings(db)).last_offsite_error
|
||||
await _offsite_on()
|
||||
notification_secret_store.delete_secret(svc.PASSPHRASE) # respx would fail on any upload: nothing leaves unencrypted
|
||||
await _run()
|
||||
async with SessionLocal() as db:
|
||||
assert "Passphrase" in (await svc.get_settings(db)).last_offsite_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pruned_snapshots_leave_the_ledger(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _offsite_on()
|
||||
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(200))
|
||||
await _cfg(keep_count=3)
|
||||
for _ in range(4):
|
||||
backup_store.save_snapshot(host["id"], CONFIG)
|
||||
await _run()
|
||||
async with SessionLocal() as db:
|
||||
names = {r.snapshot_id for r in (await db.scalars(select(BackupUpload))).all()}
|
||||
assert names <= {s["id"] for s in backup_store.list_snapshots(host["id"])}
|
||||
|
||||
|
||||
# ── notifications ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_backup_problem_after_three_missed_intervals(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_backup_stale(db) == {} # brand-new host: not yet
|
||||
from app.models.host import OpnsenseHost
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(OpnsenseHost, __import__("uuid").UUID(host["id"]))
|
||||
row.created_at = datetime.now(timezone.utc) - timedelta(days=5)
|
||||
await db.commit()
|
||||
async with SessionLocal() as db:
|
||||
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
|
||||
name = backup_store.save_snapshot(host["id"], CONFIG)
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_backup_stale(db) == {}
|
||||
_age(host["id"], name, 80)
|
||||
async with SessionLocal() as db:
|
||||
assert list(await notification_checks.check_backup_stale(db)) == [host["id"]]
|
||||
await _cfg(scheduled_enabled=False)
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_backup_stale(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offsite_problem_follows_the_last_error():
|
||||
await _cfg(offsite_enabled=True, last_offsite_error="HTTP 503")
|
||||
async with SessionLocal() as db:
|
||||
assert (await notification_checks.check_backup_offsite(db)) == {"offsite": {"message": "HTTP 503"}}
|
||||
await _cfg(last_offsite_error=None)
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_backup_offsite(db) == {}
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_roundtrip_never_returns_secrets(client):
|
||||
put = await client.put("/api/settings/backups", json={
|
||||
"interval_hours": 12, "keep_count": 10, "offsite_enabled": True, "s3_endpoint": "https://s3.test/", "s3_bucket": "bk",
|
||||
"s3_access_key": "AK", "s3_secret_key": "TOPSECRET", "passphrase": "a long passphrase here"})
|
||||
assert put.status_code == 200, put.text
|
||||
body = put.json()
|
||||
assert body["s3_endpoint"] == "https://s3.test" and body["s3_secret_set"] and body["passphrase_set"]
|
||||
assert "TOPSECRET" not in put.text and "passphrase here" not in put.text
|
||||
assert notification_secret_store.read_secret(svc.S3_SECRET) == "TOPSECRET"
|
||||
cleared = (await client.put("/api/settings/backups", json={"clear_s3_secret": True})).json()
|
||||
assert cleared["s3_secret_set"] is False and notification_secret_store.read_secret(svc.S3_SECRET) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_validation(client):
|
||||
for body in ({"interval_hours": 0}, {"keep_count": 2}, {"passphrase": "short"}, {"s3_region": ""}):
|
||||
assert (await client.put("/api/settings/backups", json=body)).status_code == 422, body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_is_visible_to_everyone_settings_only_to_admins_run_now_to_operators(client, respx_mock):
|
||||
_mock_fw(respx_mock)
|
||||
await _create_host(client, "fw1", "https://fw1.test")
|
||||
for role in ("read_only", "operator"):
|
||||
name = f"bk-{role}"
|
||||
assert (await client.post("/api/users", json={"username": name, "password": "backup-password-1", "role": role})).status_code == 201
|
||||
user = await _login(name, "backup-password-1")
|
||||
try:
|
||||
status = (await user.get("/api/backups/status")).json()
|
||||
assert "s3_endpoint" not in status and status["interval_hours"] == 24
|
||||
assert (await user.get("/api/settings/backups")).status_code == 403
|
||||
assert (await user.put("/api/settings/backups", json={"keep_count": 5})).status_code == 403
|
||||
assert (await user.post("/api/settings/backups/test-offsite")).status_code == 403
|
||||
expected = 403 if role == "read_only" else 200
|
||||
assert (await user.post("/api/backups/run")).status_code == expected
|
||||
finally:
|
||||
await user.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_offsite_reports_the_reason(client, respx_mock):
|
||||
resp = await client.post("/api/settings/backups/test-offsite")
|
||||
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "OFFSITE_TEST_FAILED"
|
||||
await _offsite_on()
|
||||
respx_mock.route(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(403))
|
||||
resp = await client.post("/api/settings/backups/test-offsite")
|
||||
assert resp.status_code == 502 and "403" in resp.json()["detail"]["params"]["reason"]
|
||||
respx_mock.clear()
|
||||
store = {}
|
||||
respx_mock.put(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: store.update(v=r.content) or httpx.Response(200))
|
||||
respx_mock.get(url__regex=r"https://s3\.test/bk/.*").mock(side_effect=lambda r: httpx.Response(200, content=store["v"]))
|
||||
respx_mock.delete(url__regex=r"https://s3\.test/bk/.*").mock(return_value=httpx.Response(204))
|
||||
assert (await client.post("/api/settings/backups/test-offsite")).json() == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_changes_are_audited_without_secrets(client):
|
||||
from app.models.audit import AuditEvent
|
||||
await client.put("/api/settings/backups", json={"s3_secret_key": "LEAKME-1234"})
|
||||
async with SessionLocal() as db:
|
||||
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "settings"))).all()
|
||||
assert rows and "LEAKME" not in json.dumps([r.details for r in rows]) + "".join(r.message or "" for r in rows)
|
||||
|
||||
|
||||
# ── config diff ──────────────────────────────────────────────────────────
|
||||
|
||||
def _xml(*, hostname="fw", password="p1", dns="1.1.1.1", cert="A" * 200, revision="r1", keylength="4096"):
|
||||
return (f"<opnsense><revision><time>{revision}</time></revision><system><hostname>{hostname}</hostname>"
|
||||
f"<user><name>root</name><password>{password}</password><apikey>KEY-{password}</apikey></user>"
|
||||
f"<dnsserver>{dns}</dnsserver></system><cert><crt>{cert}</crt><keylength>{keylength}</keylength></cert></opnsense>").encode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_shows_changes_and_hides_every_secret(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
backup_store.save_snapshot(host["id"], _xml())
|
||||
backup_store.save_snapshot(host["id"], _xml(hostname="fw-new", password="SUPERSECRET2", dns="9.9.9.9", cert="B" * 200, revision="r2", keylength="2048"))
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
text = "\n".join(body["lines"])
|
||||
assert not body["identical"] and body["added"] >= 4 and body["removed"] >= 4
|
||||
assert "- <hostname>fw</hostname>" in text and "+ <hostname>fw-new</hostname>" in text
|
||||
assert "9.9.9.9" in text and "<keylength>2048</keylength>" in text # ordinary values stay readable
|
||||
for secret in ("SUPERSECRET2", "KEY-SUPERSECRET2", "p1", "A" * 50, "B" * 50):
|
||||
assert secret not in text, secret
|
||||
assert "‹sha256:" in text # a changed secret is still visible as a change
|
||||
assert "r1" not in text and "r2" not in text # the volatile revision block is ignored
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identical_backups_and_explicit_selection(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
first = backup_store.save_snapshot(host["id"], _xml(revision="r1"))
|
||||
second = backup_store.save_snapshot(host["id"], _xml(revision="r2"))
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["identical"] is True
|
||||
third = backup_store.save_snapshot(host["id"], _xml(dns="8.8.8.8"))
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": first, "b": third})).json()
|
||||
assert (body["a"], body["b"]) == (first, third) and not body["identical"]
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()["a"] == second # default: previous vs newest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_errors(client):
|
||||
import uuid
|
||||
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/backup-diff")).status_code == 404
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # nothing to compare
|
||||
one = backup_store.save_snapshot(host["id"], _xml())
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff")).status_code == 404 # only one
|
||||
bad = backup_store.save_snapshot(host["id"], b"<html>not a config</html>")
|
||||
resp = await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": bad})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "BACKUP_UNLESBAR"
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": "../../etc/passwd", "b": one})).status_code == 404
|
||||
xxe = b'<?xml version="1.0"?><!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><opnsense>&e;</opnsense>'
|
||||
evil = backup_store.save_snapshot(host["id"], xxe)
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/backup-diff", params={"a": one, "b": evil})).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_is_capped(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
many = "".join(f"<dns{i}>1.1.1.{i % 250}</dns{i}>" for i in range(5000))
|
||||
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many}</system></opnsense>".encode())
|
||||
backup_store.save_snapshot(host["id"], f"<opnsense><system>{many.replace('1.1.1', '2.2.2')}</system></opnsense>".encode())
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/backup-diff")).json()
|
||||
assert body["truncated"] is True and len(body["lines"]) == 3000
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Security posture: check logic on config.xml shapes taken from real OPNsense 26.7 installs,
|
||||
the stored-result API, and the metrics that follow from it."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.services import compliance_service as cs
|
||||
from tests.test_api_tokens import _bearer, _make
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
|
||||
def _mvc_rule(*, interface="lan", action="pass", src="any", dst="any", protocol="any", descr="r", seq=1,
|
||||
enabled="1", dport="", direction="in", ipprotocol="inet", quick="1", src_not="0"):
|
||||
return (f"<rule><enabled>{enabled}</enabled><action>{action}</action><quick>{quick}</quick><interface>{interface}</interface>"
|
||||
f"<direction>{direction}</direction><ipprotocol>{ipprotocol}</ipprotocol><protocol>{protocol}</protocol>"
|
||||
f"<source_net>{src}</source_net><source_not>{src_not}</source_not><destination_net>{dst}</destination_net>"
|
||||
f"<destination_not>0</destination_not><destination_port>{dport}</destination_port>"
|
||||
f"<description>{descr}</description><sequence>{seq}</sequence></rule>")
|
||||
|
||||
|
||||
def _config(*, rules="", legacy="", protocol="https", ssh="", snmpd="", timeservers="0.pool.ntp.org", syslog="",
|
||||
interfaces='<wan><if>vtnet0</if></wan><lan><if>vtnet1</if></lan>', ids="0"):
|
||||
return (f"<opnsense><system><webgui><protocol>{protocol}</protocol></webgui>"
|
||||
f"<timeservers>{timeservers}</timeservers>{ssh}</system><interfaces>{interfaces}</interfaces>"
|
||||
f"<filter>{legacy}</filter>{snmpd}<OPNsense><Firewall><Filter><rules>{rules}</rules></Filter></Firewall>"
|
||||
f"<IDS><general><enabled>{ids}</enabled></general></IDS><Syslog><destinations>{syslog}</destinations></Syslog>"
|
||||
f"</OPNsense></opnsense>").encode()
|
||||
|
||||
|
||||
def _run(xml, firmware="none", backup_age=3600.0):
|
||||
return {f["id"]: f for f in cs.evaluate(xml, firmware_status=firmware, newest_backup_age_s=backup_age)}
|
||||
|
||||
|
||||
def test_clean_config_passes_everything_and_scores_100():
|
||||
findings = cs.evaluate(
|
||||
_config(rules=_mvc_rule(descr="lan out", src="lan"), syslog="<destination><enabled>1</enabled></destination>", ids="1"),
|
||||
firmware_status="none", newest_backup_age_s=3600,
|
||||
)
|
||||
assert [f["id"] for f in findings if f["passed"] is False] == []
|
||||
assert cs.score(findings) == 100 and cs.grade(100) == "good"
|
||||
|
||||
|
||||
def test_wan_any_any_is_critical_and_names_the_rule():
|
||||
# What the OPNexus test firewalls have: a pass-everything rule on the WAN.
|
||||
by_id = _run(_config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
|
||||
assert by_id["wan_any_any"]["passed"] is False and by_id["wan_any_any"]["severity"] == "critical"
|
||||
assert by_id["wan_any_any"]["params"] == {"count": 1, "examples": ["Allow ALL"]}
|
||||
assert cs.score(list(by_id.values())) <= 70
|
||||
|
||||
|
||||
def test_default_lan_rule_is_not_a_finding_but_a_lan_any_source_is():
|
||||
assert _run(_config(rules=_mvc_rule(src="lan", descr="Default allow LAN to any rule")))["internal_any_any"]["passed"] is True
|
||||
assert _run(_config(rules=_mvc_rule(src="any", descr="open")))["internal_any_any"]["passed"] is False
|
||||
|
||||
|
||||
def test_a_wan_rule_with_a_port_or_protocol_is_not_any_any():
|
||||
for extra in ({"dport": "443"}, {"protocol": "TCP"}):
|
||||
assert _run(_config(rules=_mvc_rule(interface="wan", **extra)))["wan_any_any"]["passed"] is True
|
||||
|
||||
|
||||
def test_disabled_rules_do_not_count():
|
||||
assert _run(_config(rules=_mvc_rule(interface="wan", enabled="0")))["wan_any_any"]["passed"] is True
|
||||
|
||||
|
||||
def test_legacy_rule_format_is_understood():
|
||||
legacy = ("<rule><type>pass</type><interface>wan</interface><ipprotocol>inet</ipprotocol><descr>ALLOW ANY</descr>"
|
||||
"<source><any>1</any></source><destination><any/></destination></rule>")
|
||||
by_id = _run(_config(legacy=legacy))
|
||||
assert by_id["wan_any_any"]["passed"] is False and by_id["wan_any_any"]["params"]["examples"] == ["ALLOW ANY"]
|
||||
|
||||
|
||||
def test_interface_with_a_gateway_counts_as_wan():
|
||||
interfaces = "<opt1><if>vtnet2</if><gateway>GW1</gateway></opt1><lan><if>vtnet1</if></lan>"
|
||||
assert _run(_config(interfaces=interfaces, rules=_mvc_rule(interface="opt1")))["wan_any_any"]["passed"] is False
|
||||
|
||||
|
||||
def test_shadowed_and_duplicate_rules():
|
||||
rules = (
|
||||
_mvc_rule(interface="lan", src="any", dst="any", action="block", descr="block all", seq=1)
|
||||
+ _mvc_rule(interface="lan", src="10.0.0.0/8", dst="any", action="pass", descr="never reached", seq=2)
|
||||
+ _mvc_rule(interface="dmz", src="dmz", dst="any", descr="a", seq=3)
|
||||
+ _mvc_rule(interface="dmz", src="dmz", dst="any", descr="a again", seq=4)
|
||||
)
|
||||
by_id = _run(_config(rules=rules))
|
||||
assert by_id["rules_shadowed"]["passed"] is False and by_id["rules_shadowed"]["params"]["examples"] == ["never reached"]
|
||||
assert by_id["rules_duplicate"]["passed"] is False and by_id["rules_duplicate"]["params"]["examples"] == ["a again"]
|
||||
|
||||
|
||||
def test_coverage_is_never_claimed_without_proof():
|
||||
rules = (
|
||||
_mvc_rule(interface="lan", src="lan", dst="any", dport="443", seq=1, descr="https only")
|
||||
+ _mvc_rule(interface="lan", src="lan", dst="any", dport="80", seq=2, descr="http")
|
||||
+ _mvc_rule(interface="lan", src="lan", dst="any", seq=3, descr="everything")
|
||||
+ _mvc_rule(interface="lan", src="lan", dst="any", ipprotocol="inet6", seq=4, descr="v6")
|
||||
+ _mvc_rule(interface="lan", src="lan", dst="any", src_not="1", seq=5, descr="negated")
|
||||
)
|
||||
by_id = _run(_config(rules=rules))
|
||||
assert by_id["rules_shadowed"]["passed"] is True and by_id["rules_duplicate"]["passed"] is True
|
||||
|
||||
|
||||
def test_rules_are_ordered_by_sequence_not_file_order():
|
||||
rules = _mvc_rule(src="10.0.0.0/8", descr="narrow", seq=9) + _mvc_rule(src="any", descr="broad", seq=1, action="block")
|
||||
assert _run(_config(rules=rules))["rules_shadowed"]["params"]["examples"] == ["narrow"]
|
||||
|
||||
|
||||
def test_ssh_levels():
|
||||
on = "<ssh><enabled>enabled</enabled><permitrootlogin>{}</permitrootlogin><passwordauth>{}</passwordauth></ssh>"
|
||||
worst = _run(_config(ssh=on.format(1, 1)))["ssh_hardened"]
|
||||
assert (worst["passed"], worst["severity"]) == (False, "high")
|
||||
mid = _run(_config(ssh=on.format(0, 1)))["ssh_hardened"]
|
||||
assert (mid["passed"], mid["severity"]) == (False, "medium")
|
||||
assert _run(_config(ssh=on.format(0, 0)))["ssh_hardened"]["passed"] is True
|
||||
assert _run(_config(ssh="<ssh><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))["ssh_hardened"]["passed"] is True
|
||||
assert _run(_config())["ssh_hardened"]["passed"] is True
|
||||
|
||||
|
||||
def test_webgui_snmp_ntp_logging_ids():
|
||||
assert _run(_config(protocol="http"))["webgui_https"]["passed"] is False
|
||||
assert "snmp_community" not in _run(_config())
|
||||
assert _run(_config(snmpd="<snmpd><rocommunity>public</rocommunity></snmpd>"))["snmp_community"]["passed"] is False
|
||||
assert _run(_config(snmpd="<snmpd><rocommunity>s3cret-ro</rocommunity></snmpd>"))["snmp_community"]["passed"] is True
|
||||
assert _run(_config(timeservers=""))["ntp_configured"]["passed"] is False
|
||||
assert _run(_config())["remote_logging"]["passed"] is False
|
||||
assert _run(_config(ids="0"))["ids_enabled"]["severity"] == "info"
|
||||
|
||||
|
||||
def test_firmware_and_backup_inputs():
|
||||
assert _run(_config(), firmware="update")["firmware_current"]["passed"] is False
|
||||
assert _run(_config(), firmware=None)["firmware_current"]["passed"] is None
|
||||
assert _run(_config(), backup_age=None)["backup_recent"]["passed"] is False
|
||||
assert _run(_config(), backup_age=8 * 86400)["backup_recent"]["passed"] is False
|
||||
assert _run(_config(), backup_age=2 * 86400)["backup_recent"]["passed"] is True
|
||||
|
||||
|
||||
def test_unknown_results_do_not_cost_points_and_info_is_free():
|
||||
findings = cs.evaluate(_config(), firmware_status=None, newest_backup_age_s=0)
|
||||
failed = [f["id"] for f in findings if f["passed"] is False]
|
||||
assert cs.score(findings) == 100 - sum(cs.PENALTY[f["severity"]] for f in findings if f["passed"] is False)
|
||||
assert "firmware_current" not in failed and "ids_enabled" in failed and cs.PENALTY["info"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [b"", b" ", b"<html/>", b"<opnsense>", b'<!DOCTYPE x [<!ENTITY a "b">]><opnsense>&a;</opnsense>'])
|
||||
def test_invalid_or_hostile_input_is_rejected(bad):
|
||||
with pytest.raises(Exception):
|
||||
cs.evaluate(bad, firmware_status=None, newest_backup_age_s=None)
|
||||
|
||||
|
||||
def test_findings_carry_no_config_secrets():
|
||||
xml = _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")).replace(
|
||||
b"<opnsense>", b"<opnsense><system2><user><password>$2y$hash</password><apikey>KEY123</apikey></user></system2>")
|
||||
assert b"KEY123" not in repr(cs.evaluate(xml, firmware_status=None, newest_backup_age_s=None)).encode()
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _mock_host(respx_mock, xml, firmware_status="none"):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(200, content=xml))
|
||||
respx_mock.get(url__regex=r".*/core/firmware/status").mock(return_value=httpx.Response(200, json={"status": firmware_status}))
|
||||
respx_mock.get(url__regex=r".*/diagnostics/firewall/pfStatistics/rules").mock(return_value=httpx.Response(200, json={"rules": {"filter rules": {}}}))
|
||||
respx_mock.get(url__regex=r".*/firewall/filter/searchRule").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_stores_and_lists_the_result(client, respx_mock):
|
||||
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
assert (await client.get(f"/api/hosts/{host['id']}/compliance")).json()["checked_at"] is None
|
||||
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
|
||||
assert result["error"] is None and result["score"] < 100 and result["grade"] in ("fair", "poor")
|
||||
assert result["counts"]["critical"] == 1
|
||||
assert next(f for f in result["findings"] if f["id"] == "wan_any_any")["passed"] is False
|
||||
stored = (await client.get(f"/api/hosts/{host['id']}/compliance")).json()
|
||||
assert stored["score"] == result["score"] and stored["findings"] == result["findings"]
|
||||
fleet = (await client.get("/api/compliance")).json()
|
||||
assert [(h["host_name"], h["score"]) for h in fleet] == [("fw1", result["score"])] and "findings" not in fleet[0]
|
||||
# Second check replaces the row instead of adding one.
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
assert len((await client.get("/api/compliance")).json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_host_is_reported_not_scored(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
|
||||
assert result["error"] == "unreachable" and result["score"] is None and result["grade"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garbage_config_is_reported_as_invalid(client, respx_mock):
|
||||
_mock_host(respx_mock, b"<html>not a config</html>")
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
assert (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()["error"] == "invalid_config"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_host_is_404_and_checks_need_write_rights(client):
|
||||
import uuid
|
||||
assert (await client.get(f"/api/hosts/{uuid.uuid4()}/compliance")).status_code == 404
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
assert (await api.get("/api/compliance")).status_code == 200 # reading is fine
|
||||
assert (await api.post("/api/compliance/check")).status_code == 403 # running a check is not
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_expose_the_score(client, respx_mock):
|
||||
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL")))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
result = (await client.post(f"/api/hosts/{host['id']}/compliance/check")).json()
|
||||
text = (await client.get("/api/metrics")).text
|
||||
assert f'opnexus_host_compliance_score{{host="fw1",cluster=""}} {result["score"]}' in text
|
||||
assert 'opnexus_host_compliance_findings{severity="critical",host="fw1",cluster=""} 1' in text
|
||||
|
||||
|
||||
# ── rule usage (pf counters) ─────────────────────────────────────────────
|
||||
|
||||
from app.database import SessionLocal # noqa: E402
|
||||
from app.models.host import OpnsenseHost # noqa: E402
|
||||
from app.models.rule_hit import RuleHit # noqa: E402
|
||||
from app.services import rule_hit_service as rh # noqa: E402
|
||||
|
||||
U1, U2, U3 = "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"
|
||||
|
||||
|
||||
def _pf(**packets):
|
||||
"""pfStatistics/rules shaped like the real answer: one entry per pf line, label = rule uuid."""
|
||||
lines = {f'@{i} pass in quick on vtnet0 inet all flags S/SA keep state label "{u}"': {"evaluations": 10, "packets": p, "bytes": p * 100}
|
||||
for i, (u, p) in enumerate(packets.items())}
|
||||
lines['@99 pass out log quick on vtnet0 inet6 proto udp from fe80::/10 to any keep state label "2f2c37025cd02b5cd29fd330f8e48954"'] = {"evaluations": 5, "packets": 9, "bytes": 9}
|
||||
return {"rules": {"filter rules": lines}}
|
||||
|
||||
|
||||
def _search(*uuids, disabled=()):
|
||||
rows = [{"uuid": u, "description": f"rule {u[0]}", "enabled": "0" if u in disabled else "1"} for u in uuids]
|
||||
rows.append({"uuid": "autoautoauto", "description": "Default deny", "enabled": "1", "is_automatic": True})
|
||||
return {"rows": rows}
|
||||
|
||||
|
||||
def test_pf_counters_are_tied_to_rule_uuids_and_summed_over_expanded_lines():
|
||||
stats = _pf(**{U1: 5})
|
||||
stats["rules"]["filter rules"][f'@7 pass in quick on vtnet0 inet6 all label "{U1}"'] = {"evaluations": 2, "packets": 3, "bytes": 30}
|
||||
got = rh.parse_counters(stats)
|
||||
assert got[U1] == {"packets": 8, "bytes": 530, "evaluations": 12}
|
||||
assert "2f2c37025cd02b5cd29fd330f8e48954" not in got # md5 labels are automatic rules, not user rules
|
||||
assert rh.parse_counters({}) == {} and rh.parse_counters({"rules": None}) == {}
|
||||
|
||||
|
||||
def test_configured_rules_skip_automatic_ones():
|
||||
assert set(rh.configured_rules(_search(U1, U2, disabled=(U2,)))) == {U1, U2}
|
||||
assert rh.configured_rules(_search(U1, U2, disabled=(U2,)))[U2]["enabled"] is False
|
||||
|
||||
|
||||
async def _sample(host_id, stats, search, at):
|
||||
async with SessionLocal() as db:
|
||||
host = await db.get(OpnsenseHost, __import__("uuid").UUID(host_id))
|
||||
from unittest.mock import AsyncMock, patch
|
||||
with patch.object(rh.client, "get_pf_rule_statistics", AsyncMock(return_value=stats)), \
|
||||
patch.object(rh.client, "search_rules", AsyncMock(return_value=search)):
|
||||
return await rh.sample_host(db, host, now=at)
|
||||
|
||||
|
||||
async def _rows(host_id):
|
||||
async with SessionLocal() as db:
|
||||
return {r.rule_uuid: r for r in (await db.scalars(select(RuleHit))).all()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_hit_tracking_survives_counter_resets(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
day0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
day = lambda n: day0 + timedelta(days=n) # noqa: E731
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 0}), _search(U1, U2, U3), day(0))
|
||||
rows = await _rows(host["id"])
|
||||
assert rows[U1].last_hit_at is None and rows[U2].last_hit_at == day(0) # already matching when first seen
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 7, U3: 4}), _search(U1, U2, U3), day(1))
|
||||
rows = await _rows(host["id"])
|
||||
assert rows[U2].last_hit_at == day(0) and rows[U3].last_hit_at == day(1) # U2 flat, U3 grew
|
||||
# pf reloaded (counters restart): U2 is lower but above zero -> hit since the reload; U1 at zero tells nothing
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 2, U3: 0}), _search(U1, U2, U3), day(2))
|
||||
rows = await _rows(host["id"])
|
||||
assert rows[U2].last_hit_at == day(2) and rows[U3].last_hit_at == day(1) and rows[U1].last_hit_at is None
|
||||
# a rule that disappeared from the firewall is forgotten
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 2}), _search(U1, U2), day(3))
|
||||
assert set(await _rows(host["id"])) == {U1, U2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unused_only_after_the_observation_window(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
now = datetime.now(timezone.utc)
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=10))
|
||||
async with SessionLocal() as db:
|
||||
states = {r.rule_uuid: rh.classify(r, now) for r in (await db.scalars(select(RuleHit))).all()}
|
||||
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
|
||||
assert states == {U1: "collecting", U2: "used", U3: "disabled"}
|
||||
assert summary == {"unused": [], "collecting": 1}
|
||||
await _sample(host["id"], _pf(**{U1: 0, U2: 5}), _search(U1, U2, U3, disabled=(U3,)), now - timedelta(days=1))
|
||||
async with SessionLocal() as db:
|
||||
row = (await db.scalars(select(RuleHit).where(RuleHit.rule_uuid == U1))).one()
|
||||
row.first_seen_at = now - timedelta(days=45) # watched long enough
|
||||
await db.commit()
|
||||
summary = await rh.unused_summary(db, __import__("uuid").UUID(host["id"]))
|
||||
assert summary["unused"] == ["rule 1"]
|
||||
body = (await client.get(f"/api/hosts/{host['id']}/rule-hits")).json()
|
||||
assert body[0]["state"] == "unused" and body[0]["rule_uuid"] == U1 # unused rules first
|
||||
|
||||
|
||||
def test_unused_finding_is_unknown_while_collecting_and_a_low_finding_when_found():
|
||||
xml = _config()
|
||||
ids = lambda f: {x["id"]: x for x in f} # noqa: E731
|
||||
none_yet = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1))
|
||||
assert "rules_unused" not in none_yet
|
||||
collecting = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 3}))
|
||||
assert collecting["rules_unused"]["passed"] is None
|
||||
clean = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": [], "collecting": 0}))
|
||||
assert clean["rules_unused"]["passed"] is True
|
||||
found = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": ["old vpn rule"], "collecting": 0}))
|
||||
assert found["rules_unused"]["passed"] is False and found["rules_unused"]["severity"] == "low"
|
||||
assert found["rules_unused"]["params"] == {"count": 1, "examples": ["old vpn rule"]}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Full OPNexus backup: round trip, integrity, refusal of hostile or newer archives."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import full_backup as fb
|
||||
from app.config import settings
|
||||
from app.services import backup_crypto
|
||||
|
||||
PASS = "full backup passphrase 1"
|
||||
old_mtime = 1_700_000_000 # Nov 2023
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(tmp_path, monkeypatch):
|
||||
"""Point every data directory at a scratch tree with recognisable content."""
|
||||
for name, attr in fb._DATA_DIRS.items():
|
||||
path = tmp_path / name
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(settings, attr, str(path))
|
||||
(tmp_path / "opnsense_secrets" / "host-1.secret").write_text("API-SECRET-1")
|
||||
(tmp_path / "totp_secrets").joinpath("u.secret").write_text("TOTP-SECRET")
|
||||
sub = tmp_path / "opnsense_backups" / "host-1"
|
||||
sub.mkdir()
|
||||
(sub / "20260101T000000.xml").write_text("<opnsense/>")
|
||||
os.utime(sub / "20260101T000000.xml", (old_mtime, old_mtime))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _dump(path: Path) -> None:
|
||||
path.write_bytes(b"PGDMP-fake-database")
|
||||
|
||||
|
||||
def _create(**kw):
|
||||
return fb.create(PASS, dump=_dump, revision="0009", **kw)
|
||||
|
||||
|
||||
def test_roundtrip_restores_every_secret_and_the_database(data):
|
||||
blob = _create()
|
||||
assert b"API-SECRET-1" not in blob and b"TOTP-SECRET" not in blob and b"PGDMP" not in blob
|
||||
# Wipe the data, then restore.
|
||||
for f in data.rglob("*"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
seen = {}
|
||||
result = fb.restore(blob, PASS, restore_db=lambda p: seen.update(dump=p.read_bytes()), head="0012")
|
||||
assert seen["dump"] == b"PGDMP-fake-database"
|
||||
assert (data / "opnsense_secrets" / "host-1.secret").read_text() == "API-SECRET-1"
|
||||
assert (data / "totp_secrets" / "u.secret").read_text() == "TOTP-SECRET"
|
||||
assert (data / "opnsense_backups" / "host-1" / "20260101T000000.xml").read_text() == "<opnsense/>"
|
||||
assert result["files_restored"] == 3 and result["db_revision"] == "0009"
|
||||
mode = (data / "opnsense_secrets" / "host-1.secret").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
# file times survive: stored config backups are aged by their mtime
|
||||
assert abs((data / "opnsense_backups" / "host-1" / "20260101T000000.xml").stat().st_mtime - old_mtime) < 2
|
||||
|
||||
|
||||
def test_info_shows_only_the_manifest_and_needs_the_passphrase(data):
|
||||
blob = _create()
|
||||
manifest = fb.info(blob, PASS)
|
||||
assert manifest["format"] == 1 and manifest["db_revision"] == "0009" and "opnsense_secrets" in manifest["data"]
|
||||
with pytest.raises(fb.FullBackupError, match="Passphrase"):
|
||||
fb.info(blob, "wrong passphrase 123")
|
||||
|
||||
|
||||
def test_short_passphrase_is_refused(data):
|
||||
with pytest.raises(fb.FullBackupError, match="mindestens"):
|
||||
fb.create("short", dump=_dump, revision="0009")
|
||||
|
||||
|
||||
def test_tampered_or_foreign_files_are_refused(data):
|
||||
blob = bytearray(_create())
|
||||
blob[-5] ^= 0xFF
|
||||
with pytest.raises(fb.FullBackupError):
|
||||
fb.restore(bytes(blob), PASS, restore_db=lambda p: None, head="0012")
|
||||
for junk in (b"", b"not a backup"):
|
||||
with pytest.raises(fb.FullBackupError):
|
||||
fb.restore(junk, PASS, restore_db=lambda p: None, head="0012")
|
||||
# A valid encrypted file that is not a full backup (a single config backup, say).
|
||||
with pytest.raises(fb.FullBackupError, match="Archiv unlesbar"):
|
||||
fb.restore(backup_crypto.encrypt(b"<opnsense/>", PASS), PASS, restore_db=lambda p: None, head="0012")
|
||||
|
||||
|
||||
def test_a_backup_from_a_newer_version_is_refused_before_anything_is_touched(data):
|
||||
blob = fb.create(PASS, dump=_dump, revision="0020")
|
||||
touched = []
|
||||
with pytest.raises(fb.FullBackupError, match="neueren"):
|
||||
fb.restore(blob, PASS, restore_db=lambda p: touched.append(p), head="0012")
|
||||
assert touched == []
|
||||
(data / "totp_secrets" / "u.secret").write_text("UNCHANGED")
|
||||
assert (data / "totp_secrets" / "u.secret").read_text() == "UNCHANGED"
|
||||
|
||||
|
||||
def _hostile(member_name: str, kind=tarfile.REGTYPE, link=""):
|
||||
plain = io.BytesIO()
|
||||
with tarfile.open(fileobj=plain, mode="w:gz") as tar:
|
||||
manifest = json.dumps({"format": 1, "db_revision": "0001"}).encode()
|
||||
info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
|
||||
tar.addfile(info, io.BytesIO(manifest))
|
||||
dump = tarfile.TarInfo("database.dump"); dump.size = 1
|
||||
tar.addfile(dump, io.BytesIO(b"x"))
|
||||
evil = tarfile.TarInfo(member_name); evil.type = kind; evil.linkname = link
|
||||
evil.size = 4 if kind == tarfile.REGTYPE else 0
|
||||
tar.addfile(evil, io.BytesIO(b"evil") if kind == tarfile.REGTYPE else None)
|
||||
return backup_crypto.encrypt(plain.getvalue(), PASS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,kind,link", [
|
||||
("../../etc/cron.d/x", tarfile.REGTYPE, ""),
|
||||
("/etc/passwd", tarfile.REGTYPE, ""),
|
||||
("data/opnsense_secrets/../../escape", tarfile.REGTYPE, ""),
|
||||
("data/opnsense_secrets/link", tarfile.SYMTYPE, "/etc/shadow"),
|
||||
("data/opnsense_secrets/dev", tarfile.CHRTYPE, ""),
|
||||
])
|
||||
def test_hostile_archives_are_rejected_before_the_database_is_touched(data, name, kind, link):
|
||||
touched = []
|
||||
with pytest.raises(fb.FullBackupError, match="Unzulässig"):
|
||||
fb.restore(_hostile(name, kind, link), PASS, restore_db=lambda p: touched.append(p), head="0012")
|
||||
assert touched == [] and not (data.parent / "escape").exists()
|
||||
|
||||
|
||||
def test_unknown_directories_in_the_archive_are_ignored(data, tmp_path):
|
||||
blob = _hostile("data/not_a_known_volume/file")
|
||||
fb.restore(blob, PASS, restore_db=lambda p: None, head="0012")
|
||||
assert not (tmp_path / "not_a_known_volume").exists()
|
||||
|
||||
|
||||
def test_command_line_reads_the_passphrase_from_the_environment(data, monkeypatch, capsys):
|
||||
blob = _create()
|
||||
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", PASS)
|
||||
class _In: # sys.stdin stand-in with a binary buffer
|
||||
buffer = io.BytesIO(blob)
|
||||
monkeypatch.setattr("sys.stdin", _In)
|
||||
assert fb._main(["full_backup", "info"]) == 0
|
||||
assert '"format": 1' in capsys.readouterr().out
|
||||
monkeypatch.setenv("FULL_BACKUP_PASSPHRASE", "")
|
||||
assert fb._main(["full_backup", "create"]) == 1
|
||||
assert fb._main(["full_backup", "bogus"]) == 2
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Read-only MCP endpoint: protocol basics, tools, and that it cannot be used to escalate."""
|
||||
import pytest
|
||||
|
||||
from tests.test_api_tokens import _bearer, _make
|
||||
|
||||
|
||||
def _rpc(method, params=None, id_=1):
|
||||
message = {"jsonrpc": "2.0", "method": method}
|
||||
if id_ is not None:
|
||||
message["id"] = id_
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
return message
|
||||
|
||||
|
||||
async def _call(api, name, **arguments):
|
||||
resp = await api.post("/api/mcp", json=_rpc("tools/call", {"name": name, "arguments": arguments}))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["result"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_authentication(anon_client):
|
||||
assert (await anon_client.post("/api/mcp", json=_rpc("ping"))).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handshake_and_tool_list_with_a_read_token(client):
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
init = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2025-06-18"}))).json()["result"]
|
||||
assert init["protocolVersion"] == "2025-06-18"
|
||||
assert init["serverInfo"]["name"] == "opnexus" and "tools" in init["capabilities"]
|
||||
# Unknown future version: answer with one we speak.
|
||||
other = (await api.post("/api/mcp", json=_rpc("initialize", {"protocolVersion": "2099-01-01"}))).json()["result"]
|
||||
assert other["protocolVersion"] == "2025-06-18"
|
||||
# A notification gets no body.
|
||||
note = await api.post("/api/mcp", json=_rpc("notifications/initialized", id_=None))
|
||||
assert note.status_code == 202 and note.content == b""
|
||||
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
|
||||
assert {"fleet_overview", "list_hosts", "recent_audit", "search", "api_get"} <= {t["name"] for t in tools}
|
||||
assert all("inputSchema" in t and "path" not in t for t in tools)
|
||||
assert (await api.post("/api/mcp", json=_rpc("ping"))).json()["result"] == {}
|
||||
unknown = (await api.post("/api/mcp", json=_rpc("resources/list"))).json()
|
||||
assert unknown["error"]["code"] == -32601
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_is_405_and_bad_json_is_a_parse_error(client):
|
||||
assert (await client.get("/api/mcp")).status_code == 405
|
||||
bad = await client.post("/api/mcp", content=b"{nope", headers={"Content-Type": "application/json"})
|
||||
assert bad.status_code == 400 and bad.json()["error"]["code"] == -32700
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_read_through_the_callers_own_permissions(client):
|
||||
await client.post("/api/groups", json={"name": "findable-group"})
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
hosts = await _call(api, "list_hosts")
|
||||
assert hosts["isError"] is False and hosts["content"][0]["text"] == "[]"
|
||||
found = await _call(api, "search", q="findable")
|
||||
assert "findable-group" in found["content"][0]["text"]
|
||||
audit = await _call(api, "recent_audit", limit=5)
|
||||
assert audit["isError"] is False
|
||||
overview = await _call(api, "fleet_overview")
|
||||
assert "pending_actions" in overview["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_reader_cannot_reach_credentials_or_secrets_and_never_writes(client):
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
for path in ("/api/tokens", "/api/auth/me", "/api/mcp", "/etc/passwd", "/api/../x", "/api/hosts?x=1", "relative"):
|
||||
result = await _call(api, "api_get", path=path)
|
||||
assert result["isError"] is True, path
|
||||
# Secret-bearing endpoint: refused by the caller's own (read) scope, not by the tool.
|
||||
denied = await _call(api, "api_get", path="/api/notifications/settings")
|
||||
assert denied["isError"] is True and "API_TOKEN_SCOPE_FORBIDDEN" in denied["content"][0]["text"]
|
||||
# api_get is GET-only by construction: there is no way to name another method.
|
||||
tools = (await api.post("/api/mcp", json=_rpc("tools/list"))).json()["result"]["tools"]
|
||||
schema = next(t for t in tools if t["name"] == "api_get")["inputSchema"]
|
||||
assert set(schema["properties"]) == {"path", "query"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_and_bad_arguments_are_tool_errors(client):
|
||||
token = await _make(client, "read")
|
||||
async with _bearer(token["token"]) as api:
|
||||
assert (await _call(api, "delete_everything"))["isError"] is True
|
||||
assert (await _call(api, "host_resources"))["isError"] is True
|
||||
bad = await api.post("/api/mcp", json=_rpc("tools/call", {"arguments": {}}))
|
||||
assert bad.json()["error"]["code"] == -32602
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_users_session_can_use_it_too(client):
|
||||
resp = await client.post("/api/users", json={"username": "mcpviewer", "password": "viewer-password-123", "role": "read_only"})
|
||||
assert resp.status_code == 201
|
||||
from tests.test_api_tokens import _login
|
||||
viewer = await _login("mcpviewer", "viewer-password-123")
|
||||
try:
|
||||
assert (await viewer.post("/api/mcp", json=_rpc("ping"))).status_code == 200
|
||||
finally:
|
||||
await viewer.aclose()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Prometheus endpoint: format, content and that it never calls a firewall."""
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.carp_event import CarpEvent
|
||||
from app.models.notification import NotificationProblem
|
||||
from app.models.resource_sample import HostResourceSample
|
||||
from tests.test_api_tokens import _bearer, _make
|
||||
from tests.test_rules import _create_host
|
||||
|
||||
_SAMPLE_LINE = re.compile(r'^[a-zA-Z_:][a-zA-Z0-9_:]*(\{([a-zA-Z_][a-zA-Z0-9_]*="([^"\\]|\\.)*"(,|(?=\})))*\})? -?\d+(\.\d+)?(e[+-]?\d+)?$')
|
||||
|
||||
|
||||
def _parse(text: str) -> dict[str, float]:
|
||||
"""name{labels} -> value, asserting the exposition grammar line by line."""
|
||||
values = {}
|
||||
for line in text.splitlines():
|
||||
if not line or line.startswith("# HELP ") or line.startswith("# TYPE "):
|
||||
continue
|
||||
assert _SAMPLE_LINE.match(line), f"not valid exposition: {line!r}"
|
||||
key, _, value = line.rpartition(" ")
|
||||
values[key] = float(value)
|
||||
return values
|
||||
|
||||
|
||||
async def _seed(host_id: str, *, age_s: int, carp: str | None):
|
||||
async with SessionLocal() as db:
|
||||
db.add(HostResourceSample(
|
||||
host_id=host_id, sampled_at=datetime.now(timezone.utc) - timedelta(seconds=age_s),
|
||||
mem_used_pct=25.0, load_avg_1m=0.42, disk_used_pct=12.0, raw={},
|
||||
))
|
||||
if carp:
|
||||
db.add(CarpEvent(host_id=host_id, host_name="fw1", cluster_name="ha", from_status=None, to_status=carp))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_requires_auth_and_is_prometheus_text(client, anon_client):
|
||||
assert (await anon_client.get("/api/metrics")).status_code == 401
|
||||
resp = await client.get("/api/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/plain; version=0.0.4")
|
||||
values = _parse(resp.text)
|
||||
assert values["opnexus_hosts"] == 0
|
||||
assert any(key.startswith('opnexus_info{version="') for key in values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_metrics_from_stored_samples_without_calling_the_firewall(client, respx_mock):
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _seed(host["id"], age_s=30, carp="MASTER")
|
||||
before = len(respx_mock.calls)
|
||||
values = _parse((await client.get("/api/metrics")).text)
|
||||
# The scrape is DB-only: not a single request went to a firewall.
|
||||
assert len(respx_mock.calls) == before
|
||||
assert values["opnexus_hosts"] == 1
|
||||
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 1
|
||||
assert values['opnexus_host_memory_used_percent{host="fw1",cluster=""}'] == 25
|
||||
assert values['opnexus_host_load1{host="fw1",cluster=""}'] == 0.42
|
||||
assert values['opnexus_host_disk_used_percent{host="fw1",cluster=""}'] == 12
|
||||
assert values['opnexus_host_carp_master{host="fw1",cluster=""}'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_sample_means_host_down_and_unknown_carp_is_omitted(client):
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
await _seed(host["id"], age_s=600, carp=None)
|
||||
text = (await client.get("/api/metrics")).text
|
||||
values = _parse(text)
|
||||
assert values['opnexus_host_up{host="fw1",cluster=""}'] == 0
|
||||
assert "opnexus_host_carp_master{" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_problems_are_counted_and_resolved_ones_are_not(client):
|
||||
async with SessionLocal() as db:
|
||||
db.add(NotificationProblem(problem_type="host_offline", dedup_key="a", status="open"))
|
||||
db.add(NotificationProblem(problem_type="host_offline", dedup_key="b", status="open"))
|
||||
db.add(NotificationProblem(problem_type="cert_expiry", dedup_key="c", status="resolved"))
|
||||
await db.commit()
|
||||
values = _parse((await client.get("/api/metrics")).text)
|
||||
assert values['opnexus_problems_open{type="host_offline"}'] == 2
|
||||
assert not any("cert_expiry" in key for key in values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_values_are_escaped(client):
|
||||
await _create_host(client, 'we"ird\\name', "https://fw1.test")
|
||||
text = (await client.get("/api/metrics")).text
|
||||
assert 'host="we\\"ird\\\\name"' in text
|
||||
_parse(text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_token_can_scrape(client):
|
||||
created = await _make(client, "read", name="prometheus")
|
||||
async with _bearer(created["token"]) as api:
|
||||
resp = await api.get("/api/metrics")
|
||||
assert resp.status_code == 200 and "opnexus_info" in resp.text
|
||||
@@ -0,0 +1,101 @@
|
||||
"""The operator role: rolls out and operates, but cannot change access or destinations."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from tests.test_api_tokens import _bearer, _login
|
||||
|
||||
PW = "operator-password-1"
|
||||
|
||||
|
||||
async def _operator(client, name="oper"):
|
||||
resp = await client.post("/api/users", json={"username": name, "password": PW, "role": "operator"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["role"] == "operator"
|
||||
return await _login(name, PW)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_can_deploy_but_not_manage_access(client):
|
||||
op = await _operator(client)
|
||||
try:
|
||||
assert (await op.post("/api/groups", json={"name": "ops-group"})).status_code == 201
|
||||
for method, path, body in [
|
||||
("POST", "/api/users", {"username": "x", "password": "x" * 12, "role": "admin"}),
|
||||
("PATCH", "/api/users/00000000-0000-0000-0000-000000000000/role", {"role": "admin"}),
|
||||
("POST", "/api/hosts", {}),
|
||||
("PUT", "/api/notifications/settings", {}),
|
||||
("POST", "/api/system/updater/update", {}),
|
||||
]:
|
||||
resp = await op.request(method, path, json=body)
|
||||
assert resp.status_code == 403, (path, resp.text)
|
||||
assert resp.json()["detail"]["code"] == "ADMIN_REQUIRED"
|
||||
finally:
|
||||
await op.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_keeps_self_service(client):
|
||||
op = await _operator(client, "selfserve")
|
||||
try:
|
||||
assert (await op.put("/api/settings", json={"theme": "dark"})).status_code == 200
|
||||
resp = await op.post("/api/auth/change-password", json={"current_password": PW, "new_password": "another-password-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (await op.get("/api/users")).status_code == 200
|
||||
finally:
|
||||
await op.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_tokens_are_capped_at_deploy_and_inherit_the_limits(client):
|
||||
op = await _operator(client, "tokop")
|
||||
try:
|
||||
denied = await op.post("/api/tokens", json={"name": "a", "scope": "admin", "current_password": PW})
|
||||
assert denied.status_code == 403 and denied.json()["detail"]["code"] == "API_TOKEN_SCOPE_TOO_HIGH"
|
||||
made = await op.post("/api/tokens", json={"name": "ci", "scope": "deploy", "current_password": PW})
|
||||
assert made.status_code == 201, made.text
|
||||
finally:
|
||||
await op.aclose()
|
||||
async with _bearer(made.json()["token"]) as api:
|
||||
assert (await api.post("/api/groups", json={"name": "from-op-token"})).status_code == 201
|
||||
assert (await api.post("/api/users", json={"username": "y", "password": "y" * 12})).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_cannot_download_raw_backups(client, respx_mock):
|
||||
import httpx
|
||||
from tests.test_rules import _create_host
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
op = await _operator(client, "nobackup")
|
||||
try:
|
||||
resp = await op.get(f"/api/hosts/{host['id']}/backups/anything.xml")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
await op.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refused_operator_writes_are_audited(client):
|
||||
op = await _operator(client, "audited")
|
||||
try:
|
||||
await op.post("/api/users", json={"username": "z", "password": "z" * 12})
|
||||
finally:
|
||||
await op.aclose()
|
||||
async with SessionLocal() as db:
|
||||
rows = (await db.scalars(select(AuditEvent).where(AuditEvent.actor == "audited", AuditEvent.status == "denied"))).all()
|
||||
assert rows and rows[0].kind == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demoting_an_admin_to_operator_takes_effect_at_once(client):
|
||||
resp = await client.post("/api/users", json={"username": "wasadmin", "password": PW, "role": "admin"})
|
||||
uid = resp.json()["id"]
|
||||
user = await _login("wasadmin", PW)
|
||||
try:
|
||||
assert (await user.post("/api/groups", json={"name": "g1"})).status_code == 201
|
||||
assert (await client.patch(f"/api/users/{uid}/role", json={"role": "operator"})).status_code == 200
|
||||
assert (await user.post("/api/users", json={"username": "w", "password": "w" * 12})).status_code == 403
|
||||
finally:
|
||||
await user.aclose()
|
||||
+180
-12
@@ -26,12 +26,13 @@ Eine Kurzfassung steht in der [README](../README.md), die Änderungen je Version
|
||||
12. [HA-Cluster und CARP](#ha-cluster-und-carp)
|
||||
13. [Wartung und Updates der Firewalls](#wartung-und-updates-der-firewalls)
|
||||
14. [Überwachung: Dashboard, Logs, Audit, Benachrichtigungen](#überwachung-dashboard-logs-audit-benachrichtigungen)
|
||||
15. [OPNexus betreiben](#opnexus-betreiben)
|
||||
16. [Sicherheit](#sicherheit)
|
||||
17. [Fehlerbehebung](#fehlerbehebung)
|
||||
18. [Grenzen](#grenzen)
|
||||
19. [Bedienung, Sprache, Barrierefreiheit](#bedienung-sprache-barrierefreiheit)
|
||||
20. [Lizenz und Datenschutz](#lizenz-und-datenschutz)
|
||||
15. [Automatisierung: API-Tokens, Prometheus, KI-Assistenten](#automatisierung-api-tokens-prometheus-ki-assistenten)
|
||||
16. [OPNexus betreiben](#opnexus-betreiben)
|
||||
17. [Sicherheit](#sicherheit)
|
||||
18. [Fehlerbehebung](#fehlerbehebung)
|
||||
19. [Grenzen](#grenzen)
|
||||
20. [Bedienung, Sprache, Barrierefreiheit](#bedienung-sprache-barrierefreiheit)
|
||||
21. [Lizenz und Datenschutz](#lizenz-und-datenschutz)
|
||||
|
||||
---
|
||||
|
||||
@@ -214,10 +215,18 @@ Alle Einstellungen stehen in der `.env` neben der `docker-compose.yml`. Nach ein
|
||||
|
||||
## Benutzer, Rollen und Anmeldung
|
||||
|
||||
**Rollen.** Es gibt zwei: `admin` (voller Zugriff, inklusive Benutzerverwaltung) und
|
||||
`read_only`. Ein Nur-Lesen-Benutzer sieht alles, kann aber nichts ändern: alle
|
||||
Schreibaktionen sind in der Oberfläche gesperrt und werden vom Backend mit 403
|
||||
abgelehnt. Nur das eigene Passwort sowie Farbschema und Sprache bleiben änderbar.
|
||||
**Rollen.** Es gibt drei:
|
||||
|
||||
| Rolle | Darf |
|
||||
|---|---|
|
||||
| `admin` | alles, inklusive Benutzer, Einstellungen, Hosts anlegen/löschen, OPNexus-Update, Roh-Download von Konfigurationssicherungen |
|
||||
| `operator` | ausrollen und betreiben: Regeln, Aliase, NAT, VPN, Zertifikate, Wartung, Sicherungen, Sicherheitsprüfung. **Nicht:** Benutzer, Einstellungen (Benachrichtigungen, Backups, Audit-Export, Logs), Hosts anlegen/ändern/löschen, OPNexus-Update, Roh-Backups herunterladen |
|
||||
| `read_only` | sieht alles, ändert nichts |
|
||||
|
||||
Ein Nur-Lesen-Benutzer kann nichts ändern: alle Schreibaktionen sind in der
|
||||
Oberfläche gesperrt und werden vom Backend mit 403 abgelehnt. Ein Operator bekommt bei
|
||||
den Admin-Aktionen ebenfalls 403 („erfordert die Rolle Admin“); der Versuch steht im
|
||||
Audit. Nur das eigene Passwort sowie Farbschema und Sprache bleiben für jede Rolle änderbar.
|
||||
|
||||
**Benutzerverwaltung** (System › Benutzer, nur Admins): Benutzer anlegen, Rolle
|
||||
ändern, Passwort zurücksetzen, Zwei-Faktor eines anderen Benutzers zurücksetzen,
|
||||
@@ -706,6 +715,28 @@ Auf der Host-Detailseite sicherst du die Konfiguration einer Firewall
|
||||
(**Jetzt sichern**) und siehst die lokal gespeicherten Sicherungen. Das ist nicht
|
||||
die Sicherung von OPNexus selbst, siehe [Sichern und Wiederherstellen](#sichern-und-wiederherstellen).
|
||||
|
||||
**Geplant und ausgelagert** (Einstellungen › Backups): OPNexus sichert jede Firewall
|
||||
automatisch (Standard: täglich) und behält die neuesten (Standard 30 je Firewall). Optional wird jede
|
||||
Sicherung **auf deinem Server mit einer Passphrase verschlüsselt** (AES-256-GCM, Schlüssel per scrypt
|
||||
aus der Passphrase) und auf **S3-kompatiblen Speicher** hochgeladen (AWS S3, MinIO, Backblaze B2,
|
||||
Wasabi, Cloudflare R2, Hetzner Object Storage …; Endpunkt muss `https://` sein). „Ziel testen“
|
||||
schreibt, liest und löscht ein Prüfobjekt. Jede Sicherung geht nur einmal hoch, gelöscht wird
|
||||
offsite nie (dafür die Lifecycle-Regeln des Buckets nutzen). **Ohne die Passphrase ist eine
|
||||
Offsite-Kopie unlesbar** – bewahre sie außerhalb von OPNexus auf. Entschlüsseln:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e BACKUP_PASSPHRASE='…' backend python -m app.backup_decrypt < datei.opxbak > config.xml
|
||||
```
|
||||
|
||||
Die Benachrichtigung „Kein aktuelles Backup“ meldet sich, wenn ein Host drei Intervalle (mindestens
|
||||
6 Stunden) ohne Sicherung war, „Offsite-Backup fehlgeschlagen“ bei Problemen mit dem Ziel. Die
|
||||
Offsite-Anbindung ist gegen die offiziellen AWS-Signaturbeispiele geprüft, aber noch nicht gegen einen
|
||||
laufenden S3-Anbieter; „Ziel testen“ zeigt dir sofort, ob dein Anbieter funktioniert.
|
||||
|
||||
**Änderungen ansehen:** Der Knopf „Änderungen“ neben einer Sicherung zeigt, was sich gegenüber der
|
||||
vorherigen geändert hat. Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als
|
||||
Hash: ein geändertes Geheimnis zeigt sich als geänderter Hash, der Wert verlässt den Server nie.
|
||||
|
||||
**Wiederherstellen** prüft eine gespeicherte Sicherung als Vorschau (Format,
|
||||
Metadaten, geheime Felder werden unkenntlich gemacht) und übergibt dann an das
|
||||
OPNsense-Webinterface. **OPNexus schreibt dabei nichts auf die Firewall.** Du
|
||||
@@ -718,6 +749,17 @@ Sicherung) zeigt die Vorschau eine Warnung.
|
||||
|
||||
## Überwachung: Dashboard, Logs, Audit, Benachrichtigungen
|
||||
|
||||
### Regelnutzung
|
||||
|
||||
Auf der Seite **Sicherheitslage** zeigt die Tabelle „Regelnutzung“, wann jede Regel
|
||||
zuletzt Verkehr getroffen hat. OPNexus liest dafür die Paketzähler der Firewall. Weil diese
|
||||
bei jeder Änderung und jedem Neustart von vorn beginnen, merkt sich OPNexus den Zeitpunkt, an dem
|
||||
ein Zähler zuletzt gewachsen ist (Auflösung: eine Prüfung, also bis zu 6 Stunden). Eine Regel gilt
|
||||
als **ungenutzt**, wenn OPNexus sie mindestens 30 Tage beobachtet hat, ohne einen Treffer zu sehen;
|
||||
bis dahin steht sie auf „Daten werden gesammelt“, damit eine neue Installation keinen Fehlalarm
|
||||
auslöst. Nur Regeln aus dem Regel-Editor bzw. der API werden erfasst (die automatischen Regeln von
|
||||
OPNsense nicht). Ungenutzte Regeln fließen als Befund „Regeln in Benutzung“ in die Sicherheitslage ein.
|
||||
|
||||
### Dashboard
|
||||
|
||||
Die Startseite (**Übersicht › Dashboard**) zeigt: offene Aktionen (ausstehende
|
||||
@@ -761,6 +803,17 @@ vorhanden. Einträge bleiben auch nach dem Löschen einer Regel oder eines Hosts
|
||||
erhalten; ein Datenbank-Trigger verhindert Änderungen und Löschen. Passwörter,
|
||||
API-Secrets und Konfigurationsdateien werden nicht protokolliert.
|
||||
|
||||
### Audit-Export
|
||||
|
||||
Unter **Einstellungen › Audit-Export** leitest du das Audit (wer hat wann was mit welchem
|
||||
Ergebnis geändert) an einen **Syslog-Server** (RFC 5424 über UDP, TCP oder TLS; Facility local0,
|
||||
Schweregrad „warning“ bei abgelehnten oder fehlgeschlagenen Aktionen) und/oder einen **Webhook**
|
||||
(JSON-Stapel, optional mit HMAC-SHA256-Signatur im Header `X-OPNexus-Signature`) weiter, damit es
|
||||
auch außerhalb von OPNexus vorliegt. Gesendet werden nur neue Ereignisse ab dem Einschalten; der
|
||||
Inhalt von Firewall-Objekten (Vorher-/Nachher-Daten) ist nie dabei. Die Zustellung ist „mindestens
|
||||
einmal“: scheitert das Ziel, versucht OPNexus dieselben Ereignisse erneut, jedes trägt seine ID zum
|
||||
Entdoppeln. Der Status der letzten Zustellung steht in den Einstellungen.
|
||||
|
||||
### Benachrichtigungen
|
||||
|
||||
Einstellungen › **Benachrichtigungen**. Kanäle:
|
||||
@@ -782,6 +835,36 @@ ausgeführt“: Sie bleibt drei Tage nach dem geplanten Zeitpunkt offen und schl
|
||||
ohne „behoben“-Meldung, weil sich ein verpasster Termin nicht von selbst behebt. Der
|
||||
Text der Benachrichtigungen ist nicht übersetzt.
|
||||
|
||||
### Sicherheitslage
|
||||
|
||||
Unter **Monitoring › Sicherheitslage** prüft OPNexus jede Firewall auf typische
|
||||
Schwachstellen und vergibt einen Wert von 0 bis 100 (ab 90 gut, ab 70 mittel, darunter
|
||||
schlecht). Die Prüfung liest die Konfiguration der Firewall einmal lesend herunter
|
||||
und wertet sie im Speicher aus; es wird nichts an der Firewall geändert und die
|
||||
Konfiguration nicht gespeichert. Alle sechs Stunden läuft die Prüfung von selbst,
|
||||
„Alle prüfen“ und „Erneut prüfen“ starten sie sofort.
|
||||
|
||||
| Prüfung | Gewicht | Wann ein Befund entsteht |
|
||||
|---|---|---|
|
||||
| Keine Allow-all-Regel am WAN | kritisch | Eine aktive Regel am WAN (oder an einer Schnittstelle mit Upstream-Gateway) erlaubt jeden Verkehr von überall nach überall |
|
||||
| Web-Oberfläche nur über HTTPS | hoch | Die Verwaltungsoberfläche läuft über HTTP |
|
||||
| SSH abgesichert | hoch / mittel | SSH ist an und erlaubt Passwort-Anmeldung (hoch, wenn auch root per Passwort darf) |
|
||||
| SNMP-Community nicht „public“ | hoch | SNMP ist an und nutzt „public“ |
|
||||
| Keine Allow-all-Regel mit beliebiger Quelle (intern) | mittel | Eine interne Regel erlaubt beliebige Quellen zu beliebigen Zielen (die Standardregel „LAN to any“ zählt nicht) |
|
||||
| Keine verdeckten Regeln | mittel | Eine Regel wird nie erreicht, weil eine frühere sie schon abdeckt |
|
||||
| Firmware aktuell | mittel | Updates stehen bereit |
|
||||
| Aktuelles Backup | mittel | Das neueste lokale Backup ist 7 Tage oder älter |
|
||||
| Keine doppelten Regeln, Regeln beschriftet, Zeitserver, externe Logs | niedrig | Doppelte Regel, Regel ohne Beschreibung, kein Zeitserver, kein Syslog-Ziel |
|
||||
| Einbruchserkennung aktiv | Hinweis | IDS/IPS ist aus (kostet keine Punkte) |
|
||||
|
||||
Ein kritischer Befund kostet 30 Punkte, ein hoher 15, ein mittlerer 8, ein niedriger 3.
|
||||
Nicht entscheidbare Prüfungen (etwa der Updatestatus einer gerade nicht erreichbaren
|
||||
Firewall) kosten nichts. Verdeckt oder doppelt meldet OPNexus nur, was sich beweisen
|
||||
lässt: Regeln werden Feld für Feld verglichen, Aliase werden nicht aufgelöst, also
|
||||
bleibt eine Überschneidung über Aliase unentdeckt, aber es gibt keine falschen
|
||||
Befunde. Beide Regelformate (OPNsense 26.x und ältere Installationen) werden gelesen.
|
||||
Die Werte stehen auch als Prometheus-Metriken zur Verfügung (siehe unten).
|
||||
|
||||
### Über OPNexus
|
||||
|
||||
Ein Klick auf das Logo öffnet **About**: alle Hosts mit Status, CARP-Rolle, Cluster,
|
||||
@@ -791,6 +874,81 @@ jeder Seite und in den Einstellungen.
|
||||
|
||||
---
|
||||
|
||||
## Automatisierung: API-Tokens, Prometheus, KI-Assistenten
|
||||
|
||||
Unter **System › API-Tokens** legst du persönliche Tokens an. Mit einem Token
|
||||
sprechen Skripte, CI-Jobs, Prometheus und KI-Assistenten die OPNexus-API ohne
|
||||
Anmeldung an:
|
||||
|
||||
```
|
||||
curl -H "Authorization: Bearer opx_…" https://opnexus.example/api/hosts
|
||||
```
|
||||
|
||||
Ein Token gehört deinem Konto und hat nie mehr Rechte als du. Es wird nur einmal
|
||||
angezeigt und nur als Hash gespeichert. Zum Anlegen brauchst du dein Passwort. Standard
|
||||
ist eine Laufzeit von 90 Tagen; du kannst jedes Token jederzeit widerrufen, und ein
|
||||
Passwort-Reset des Kontos widerruft alle seine Tokens. Höchstens 50 Tokens je Benutzer.
|
||||
|
||||
| Bereich | Darf |
|
||||
|---|---|
|
||||
| **Lesen** | Alles ansehen (GET), außer Dinge mit Geheimnissen: Roh-Download der Konfigurations-Backups und die Benachrichtigungseinstellungen. Für Dashboards, Prometheus, KI-Assistenten. |
|
||||
| **Ausrollen** | Zusätzlich ausrollen und warten: Regeln, Aliase, NAT, VPN, Zertifikate, Wartung, Sicherheitsprüfung. Nicht: Benutzer, Einstellungen, Benachrichtigungen, Hosts anlegen oder löschen, OPNexus-Update. |
|
||||
| **Admin** | Alles, was dein Konto darf, einschließlich der Dinge oben. |
|
||||
|
||||
Für jeden Bereich gilt: Tokens verwalten und das eigene Konto (Passwort, Zwei-Faktor)
|
||||
geht nur in einer normalen Anmeldung, nie mit einem Token. Ein Nur-Lese-Konto kann
|
||||
nur „Lesen“-Tokens anlegen; wird das Konto zurückgestuft, sind alle seine Tokens
|
||||
sofort nur noch lesend. Im Audit steht bei Änderungen per Token „Benutzer (API: Name)“.
|
||||
|
||||
**API-Beschreibung:** das OpenAPI-Schema liegt unter `/api/openapi.json` (mit
|
||||
Anmeldung oder Token abrufbar) und lässt sich in Postman, einen Code-Generator oder
|
||||
einen Assistenten laden. Eine interaktive Seite gibt es nicht. Fehler kommen als
|
||||
`{"detail": {"code": "…", "params": {…}}}` mit stabilem `code`.
|
||||
|
||||
### Prometheus
|
||||
|
||||
`GET /api/metrics` liefert den Zustand im Prometheus-Textformat. Die Antwort stammt
|
||||
nur aus der OPNexus-Datenbank (keine Anfragen an die Firewalls), ein häufiges Abfragen
|
||||
belastet also nichts. Mit einem „Lesen“-Token:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: opnexus
|
||||
scheme: https
|
||||
metrics_path: /api/metrics
|
||||
authorization: { credentials: "opx_…" }
|
||||
static_configs: [{ targets: ["opnexus.example"] }]
|
||||
```
|
||||
|
||||
| Metrik | Bedeutung |
|
||||
|---|---|
|
||||
| `opnexus_info{version}` | Version, immer 1 |
|
||||
| `opnexus_hosts` | Anzahl der Firewalls |
|
||||
| `opnexus_host_up{host,cluster}` | 1, wenn der Host in den letzten 3 Minuten geantwortet hat |
|
||||
| `opnexus_host_enabled`, `…_last_sample_timestamp_seconds` | Überwachung an; Zeit der letzten Messung |
|
||||
| `opnexus_host_memory_used_percent`, `opnexus_host_load1`, `opnexus_host_disk_used_percent` | Letzte Messung |
|
||||
| `opnexus_host_carp_master` | 1 = MASTER, 0 = BACKUP (fehlt, wenn unbekannt) |
|
||||
| `opnexus_host_compliance_score`, `opnexus_host_compliance_findings{severity}` | Sicherheitslage |
|
||||
| `opnexus_pending_actions{kind,status}` | Offene Aktionen wie auf dem Dashboard |
|
||||
| `opnexus_problems_open{type}` | Offene Probleme der Benachrichtigungen |
|
||||
| `opnexus_scheduled_maintenance_pending` | Noch nicht gelaufene geplante Wartungen |
|
||||
|
||||
### KI-Assistenten (MCP)
|
||||
|
||||
OPNexus spricht das Model Context Protocol, damit ein KI-Assistent die Flotte
|
||||
ansehen kann, **nur lesend**: Er kann nichts ändern. Beispiel für Claude Code:
|
||||
|
||||
```
|
||||
claude mcp add --transport http opnexus https://opnexus.example/api/mcp \
|
||||
--header "Authorization: Bearer opx_…"
|
||||
```
|
||||
|
||||
Die Werkzeuge: `fleet_overview`, `list_hosts`, `host_resources`, `carp_events`,
|
||||
`recent_audit`, `search` und `api_get` (liest jeden anderen GET-Endpunkt). Alles läuft
|
||||
mit den Rechten des Tokens: ein „Lesen“-Token sieht auch hier keine Geheimnisse.
|
||||
|
||||
---
|
||||
|
||||
## OPNexus betreiben
|
||||
|
||||
### Daten und Volumes
|
||||
@@ -814,12 +972,22 @@ jeder Seite und in den Einstellungen.
|
||||
entsteht vor jeder Schema-Migration ein Dump (die letzten 5 bleiben).
|
||||
**Manuell:** `./opnexus backup` schreibt sofort `manual-<zeit>.dump`.
|
||||
|
||||
**Vollsicherung (empfohlen):** Der Dump allein reicht nicht, um einen OPNexus-Server neu aufzubauen,
|
||||
denn die Geheimnisse liegen in eigenen Volumes. `./opnexus backup --full [DATEI]` packt Datenbank
|
||||
und alle Geheimnis-Volumes (Firewall-Zugänge, Zwei-Faktor, VPN- und Zertifikatsschlüssel,
|
||||
Konfigurationssicherungen) in **eine mit Passphrase verschlüsselte Datei** (`.opxfull`). Die Passphrase
|
||||
wird abgefragt (oder `OPNEXUS_BACKUP_PASSPHRASE`). Auf einer frischen Installation (gleiche oder
|
||||
neuere Version) spielt `./opnexus restore DATEI` alles zurück: Es prüft die Datei, fragt nach, stoppt die
|
||||
Dienste, legt eine Sicherheitskopie des jetzigen Zustands an, spielt Datenbank und Volumes ein und
|
||||
startet neu. Nicht enthalten: Log-Erfassung, Messwerte und `caddy_data` (die interne CA: nach einer
|
||||
Wiederherstellung auf neuer Hardware importierst du das CA-Zertifikat im Browser erneut).
|
||||
Datei und Passphrase getrennt vom Server aufbewahren.
|
||||
|
||||
**Was nicht im Dump ist:**
|
||||
- Die Tabellen der **Log-Erfassung** und der **Systemauslastung** (nur das Schema).
|
||||
Nach einer Wiederherstellung sind sie leer und füllen sich aus den Firewalls.
|
||||
- **Geheimnisse** (API-Secrets, VPN- und Zertifikatsschlüssel, Zwei-Faktor): sie
|
||||
liegen in den eigenen Volumes. Für eine vollständige Sicherung auch diese Volumes
|
||||
und `caddy_data` sichern.
|
||||
liegen in den eigenen Volumes. Für eine vollständige Sicherung `./opnexus backup --full` (siehe oben) verwenden.
|
||||
|
||||
**Wiederherstellen** in eine **leere** Datenbank (nicht mit `--clean` in eine
|
||||
bestehende):
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# Opnexus security review — 2026-10-07
|
||||
|
||||
## Instructions for Claude Code
|
||||
|
||||
This is a remediation handoff, not a record of applied fixes. Work from the finding IDs below. Recheck each finding against the current source before modifying it, preserve unrelated user changes, and add the specified regression coverage. Address SEC-01 through SEC-05 first. Do not deploy to real firewalls to reproduce these issues: use mocked OPNsense responses and a disposable test database. Never include actual credentials, backups, or private keys in tests, logs, or commits.
|
||||
|
||||
The review covers commit `ee7b35953a326e848d0bc9e5b19bf7755f78a937` plus the working tree as inspected. Existing user changes were present in `frontend/app/aliases/page.tsx`, `frontend/app/rules/page.tsx`, and the OneToOnePanel, OutboundNatPanel, and PortForwardPanel components. Those files were not modified by this review. All locations below are repository-relative; line references describe this snapshot and can move during remediation.
|
||||
|
||||
## Scope and evidence
|
||||
|
||||
Reviewed the authentication and authorization boundary; account/password/TOTP flows; all router registrations; firewall rule, alias, NAT, hierarchy, VPN, certificate and ACME operation paths; maintenance and restore controls; secret stores; outbound HTTP and SMTP; frontend API calls and rendering sinks; website worker and scripts; Docker/Compose/proxy configuration; migration and update scripts; CI and release configuration; dependency manifests and lockfile.
|
||||
|
||||
The backend inventory contains 164 Python application files and 187 route decorators. This was a broad static review with targeted tracing and isolated probes, not line-by-line proof of every possible behavior or a complete penetration test.
|
||||
|
||||
Evidence is in [security-review-2026-10-07](security-review-2026-10-07/):
|
||||
|
||||
- `probe-results.json`: 13 synthetic probes, including positive controls. These report current vulnerable behavior, not passing remediation tests.
|
||||
- `reproduce.py`: reusable, mocked reproduction harness. It does not invoke application lifespan, connect to a database, or contact a firewall. Authentication, database, backup and relevant secret reads are replaced with synthetic fixtures.
|
||||
- `npm-audit.json`: npm registry audit of the frontend lockfile, reporting 12 affected package entries (10 high, 2 moderate). Dependency-chain entries are not separate exploitable vulnerabilities.
|
||||
- `pip-audit.json`: resolved requirements audit reporting no known vulnerabilities. This is not a scan of every installed production package or container OS package.
|
||||
- `route-inventory.tsv`: route source locations, methods, local paths and function names. Apply router prefixes from `main.py` and router declarations to obtain public paths.
|
||||
- `source-manifest.json`: hashes of tracked source/deployment/website files for identifying the reviewed snapshot. A hash entry does not mean every line was manually examined.
|
||||
|
||||
The probes were rerun in a disposable backend-image container with no network and no volume mounts. Source hashes of 12 central authentication, routing, schema and SMTP modules were also compared with the existing backend container and matched. Runtime read-only checks found the existing backend and frontend processes running as UID 0 and the Compose PostgreSQL user marked as a superuser. No real backup contents or secret files were read, no live firewall operations were performed, and no application database rows were changed.
|
||||
|
||||
Reproduce using an image built from the reviewed source:
|
||||
|
||||
```sh
|
||||
docker run --rm -i --network none --entrypoint python \
|
||||
local/opnexus-backend:latest - \
|
||||
< docs/security-review-2026-10-07/reproduce.py
|
||||
```
|
||||
|
||||
Run the harness only against this snapshot or after reviewing changes to the functions it mocks. The standalone harness has no pytest fixtures and does not drop/create tables. The normal backend pytest suite has database setup/teardown fixtures; it was not run as part of this report. No load tests, exploit payloads for dependency vulnerabilities, browser exploitation, image vulnerability scan, full git-history secret scan, or real-firewall integration tests were performed.
|
||||
|
||||
## Findings and priority
|
||||
|
||||
Severity is contextual to a firewall-fleet management product. High findings can expose credentials, preserve unauthorized administrative access, or defeat safeguards for live firewall changes. Medium findings require more prerequisites or concern integrity, monitoring or supply-chain exposure. Conditional findings state the necessary deployment conditions. No anonymous remote-code-execution exploit was demonstrated.
|
||||
|
||||
| ID | Severity | Finding | Evidence level |
|
||||
|---|---|---|---|
|
||||
| SEC-01 | High | Read-only users can download secret-bearing firewall backups | Mocked HTTP reproduction |
|
||||
| SEC-02 | High | Password changes/resets do not revoke all account sessions | Source + mocked reset |
|
||||
| SEC-03 | High | SMTP TLS does not verify certificates or hostname | Source + runtime library inspection |
|
||||
| SEC-04 | High | Ambiguous/live deployment failures disable automatic rollback | Source + mocked deploy |
|
||||
| SEC-05 | High, conditional | HTTP firewall URLs send API credentials without TLS | Schema probe + client source |
|
||||
| SEC-06 | Medium | Concurrent TOTP/recovery-code consumption is not atomic | Concurrent mocked reproduction |
|
||||
| SEC-07 | Medium | Login throttle leaks records and password verification blocks the event loop | Source + expiration probe |
|
||||
| SEC-08 | Medium, conditional | Raw upstream errors reach readers and persisted records | Source + synthetic error probe |
|
||||
| SEC-09 | Medium | Host deletion omits VPN/certificate/ACME deployment guards | Source tracing |
|
||||
| SEC-10 | Medium | Security-sensitive operations are absent from durable audit history | Source tracing |
|
||||
| SEC-11 | Medium | Notification secrets are ephemeral and missing signing keys fail open | Compose + source tracing |
|
||||
| SEC-12 | Medium, reachability unverified | Known vulnerable frontend dependencies remain in the lockfile | Registry audit + advisory verification |
|
||||
| SEC-13 | Medium | MFA self-service endpoints lack attempt limits | Source tracing |
|
||||
| SEC-14 | Medium | VPN approval can be omitted and tokens do not bind secret rotations | Source + synthetic token probe |
|
||||
| SEC-15 | Low | Validation errors echo submitted secret values | Mocked HTTP reproduction |
|
||||
| SEC-16 | Medium, conditional | Read-only notification settings expose capability-bearing webhook URLs | Schema + route tracing |
|
||||
|
||||
### SEC-01 — Read-only backup access exposes firewall secrets
|
||||
|
||||
**Locations:** `backend/app/auth_middleware.py:55-58`; `backend/app/routers/hosts.py:242-258`; `backend/app/services/backup_store.py:48-55`; `backend/app/services/opnsense_client.py`, `download_backup()`.
|
||||
|
||||
**Cause:** The read-only role is implemented as “allow every GET.” Backup listing and download do not enforce a separate permission. Download returns the original snapshot bytes, not the redacted restore manifest. The normal snapshot flow downloads full OPNsense `config.xml` revisions.
|
||||
|
||||
**Prerequisites/impact:** Any valid read-only session and an existing backup. Full configurations can contain password hashes, VPN PSKs, private keys and other credentials, depending on the firewall configuration. Exposure can enable offline password attacks and access outside Opnexus. No anonymous access was found. The role's documented allowance for all GETs explains the behavior but does not make secret export least-privileged.
|
||||
|
||||
**Reproduce:** Create a synthetic firewall backup fixture. Authenticate as read-only; list `/api/hosts/{host_id}/backups`, then GET `/api/hosts/{host_id}/backups/{backup_id}`. The probe returned HTTP 200 with a synthetic secret marker; a read-only POST control returned 403.
|
||||
|
||||
**Fix:** Add server-enforced admin-only or explicit `export_firewall_secrets` authorization for raw downloads, consistently for GET and HEAD. Keep any reader-visible backup metadata separate from export permission. Update the frontend accordingly. Audit successful and denied secret exports without storing their contents.
|
||||
|
||||
**Acceptance:** Reader gets 403 for raw download/HEAD, admin gets the exact synthetic bytes, anonymous gets 401. Ordinary reader pages continue working. A path traversal string cannot retrieve another host's backup.
|
||||
|
||||
### SEC-02 — Password rotation leaves compromised sessions valid
|
||||
|
||||
**Locations:** `backend/app/routers/auth.py:221-241`; `backend/app/routers/users.py:71-78`; `backend/app/services/auth_service.py:151-194`; `backend/app/config.py`, `session_lifetime`.
|
||||
|
||||
**Cause:** Self-service password change updates the hash and revokes only the caller's token before creating a replacement. Admin password reset updates only the hash. Session resolution uses token hash, expiry and user existence; it does not check a credential generation. Sessions can remain valid for the configured 30-day default lifetime. MFA enable/disable/reset likewise do not invalidate existing sessions.
|
||||
|
||||
**Prerequisites/impact:** Attacker already possesses a session. Changing or administratively resetting the password does not reliably contain the incident; the old session can continue reading secrets or changing firewall policy according to the current account role. This does not let an expired or unknown token authenticate.
|
||||
|
||||
**Reproduce:** Log in twice in a disposable database. Change the password in session A or reset it through another admin. Call `/api/auth/me` and a protected route using session B. Source predicts continued access; the mocked reset probe recorded one commit and zero revocation SQL statements. Full two-session database reproduction was not performed.
|
||||
|
||||
**Fix:** Revoke every target-account session atomically with password reset/change; for self-service change issue a replacement only after revocation. Consider a credential/session generation for robust concurrency. Define explicit MFA-change session policy, and require reauthentication where appropriate. Do not rely on password hash comparisons at request time.
|
||||
|
||||
**Acceptance:** All preexisting tokens fail on protected routes after reset/change. A replacement self-service session works. Failure rolls back both credential change and revocation. A concurrent session-creation attempt cannot bypass the chosen revocation/generation semantics.
|
||||
|
||||
### SEC-03 — SMTP encryption lacks peer authentication
|
||||
|
||||
**Locations:** `backend/app/services/notification_delivery.py:82-102`, particularly SMTP_SSL construction at line 93 and `client.starttls()` at line 98.
|
||||
|
||||
**Cause:** Both TLS modes omit an explicit verifying SSL context. Inspection of the deployed Python 3.12 standard library showed both falling back to `ssl._create_stdlib_context()`, with `verify_mode=0` (`CERT_NONE`) and `check_hostname=False`.
|
||||
|
||||
**Prerequisites/impact:** An active network attacker, malicious DNS/route or incorrectly configured SMTP endpoint. The attacker can impersonate the mail server and obtain SMTP credentials and operational notification content even when the UI selects `ssl` or `starttls`. Mode `none` intentionally has no TLS and should not be confused with this defect.
|
||||
|
||||
**Fix:** Pass `ssl.create_default_context()` to SMTP_SSL and starttls. Support an explicit custom trust store if needed, without silently disabling verification. Preserve STARTTLS fail-closed behavior when unsupported. Reject authenticated plaintext SMTP by default or require an explicit, documented insecure override.
|
||||
|
||||
**Acceptance:** Both modes reject self-signed/untrusted certificates and hostname mismatches; a trusted, matching local test certificate succeeds. Assert the passed context has CERT_REQUIRED and hostname checks. On handshake failure `login()` and `send_message()` must not run.
|
||||
|
||||
**Reference:** [Python SMTP TLS context documentation](https://docs.python.org/3.12/library/smtplib.html). The vulnerability conclusion also relies on the inspected runtime implementation, not merely the documentation.
|
||||
|
||||
### SEC-04 — Rollback is dropped precisely when a deployment outcome is uncertain
|
||||
|
||||
**Locations:** `backend/app/services/rule_deploy_service.py:56-137,154-172`; corresponding `_deploy_to_host()` and post-deploy hierarchy handling in `alias_deploy_service.py`, `port_forward_deploy_service.py`, `one_to_one_deploy_service.py`, `outbound_nat_deploy_service.py`; `policy_hierarchy_deploy_service.py:220-278`.
|
||||
|
||||
**Cause:** The API may accept a rule write and apply it before the response is lost. On apply error, the code records `status="error"` and no revert deadline. It generally captures rollback payload/deadline only after successful apply. There is a second, stronger case: after confirmed apply, hierarchy reordering failure changes `pending` to `error` and clears the deadline. The sweeper selects only expired `pending` rows. Policy hierarchy positioning failure can return before saving the new payload and rollback baseline.
|
||||
|
||||
**Impact:** A restrictive, permissive or misplaced live rule/NAT/alias change can remain indefinitely despite lack of confirmation. Operators expecting automatic rollback may lose connectivity or leave an unintended opening. Requires an authorized deployment and a failure/race; it is a safety-control failure, not an anonymous authorization bypass. Recovery also requires the management path to remain reachable: Opnexus cannot undo a change over a connection that has become permanently inaccessible.
|
||||
|
||||
**Reproduce:** Mock a successful add with a returned remote UUID, then make apply raise a timeout representing a lost response after execution. The probe returned `error`, a known remote UUID and null revert deadline. Separately mock successful apply followed by failed hierarchy reassertion; verify the worker excludes the now-live change.
|
||||
|
||||
**Fix:** Introduce explicit state for written/applied/unknown outcomes with durable rollback baseline and recovery scheduling. Persist intent/baseline before external mutation where possible. Do not erase the recovery obligation when apply or ordering is ambiguous. Block ordinary retry/edit paths until reconciliation. Avoid blindly retrying non-idempotent remote actions. Apply the same recovery semantics across all affected resource types.
|
||||
|
||||
**Acceptance:** Test failed write before mutation, successful write plus apply timeout, successful apply plus ordering failure, and process interruption between phases. Known or potentially live changes retain recovery metadata and cannot be mistaken for harmless failures. Simulate recovery worker execution without a firewall. Confirmed validation rejection before a write does not spuriously remove a remote rule.
|
||||
|
||||
### SEC-05 — Firewall connections accept plaintext HTTP
|
||||
|
||||
**Locations:** `backend/app/schemas/host.py:10-14`; `backend/app/routers/hosts.py:102-127`; `backend/app/services/opnsense_client.py:28-37`; `backend/app/services/tls_pin_service.py:84-90`.
|
||||
|
||||
**Cause:** `base_url` is a plain string. Create accepts `http://...`; httpx receives the API key/secret as Basic authentication. `verify_tls=True` only affects TLS validation and does not upgrade or reject HTTP. The schema probe accepted an HTTP URL while verification remained true. Setting `verify_tls=False` also deliberately allows unauthenticated HTTPS.
|
||||
|
||||
**Prerequisites/impact:** Admin configures an HTTP host or disables verification, then a network observer/attacker is on the path. API credentials and firewall responses are exposed or forgeable. Existing correctly pinned HTTPS hosts are not shown vulnerable by this finding.
|
||||
|
||||
**Fix:** Validate canonical HTTPS host URLs, rejecting userinfo, fragments, unsuitable paths and unsupported schemes. Enforce the rule in the client too, including existing database rows. If HTTP or disabled verification must remain for development, put it behind an explicit deployment-level insecure override with clear status and audit evidence. Do not ban private IPs: management of internal firewalls is core functionality.
|
||||
|
||||
**Acceptance:** HTTP is rejected before credentials are sent; HTTPS hostname/IP with valid pin works. Existing unsafe records cannot silently continue after migration. Tests distinguish URL scheme protection from `verify_tls` certificate protection.
|
||||
|
||||
### SEC-06 — TOTP and recovery codes can be used concurrently more than once
|
||||
|
||||
**Locations:** `backend/app/routers/auth.py:116-131`; `backend/app/services/totp_service.py:45-58,77-86`; user resolution/authentication before these calls.
|
||||
|
||||
**Cause:** Separate sessions load the same `totp_last_step` or recovery hash list, check locally, modify locally and commit without row locking or conditional updates. Two requests can both pass before either committed update is visible. Sequential replay tests do not cover this.
|
||||
|
||||
**Impact:** An attacker with the password and a captured second factor can race legitimate use, violating single-use protection. This is not a bypass with only the password and no code.
|
||||
|
||||
**Reproduce:** Two independent synthetic user snapshots and a barrier at commit accepted the same current TOTP twice, `[true,true]`. The same concurrent test accepted one recovery code twice. This demonstrates the check/commit race with mocks; database-level concurrent integration remains to be added.
|
||||
|
||||
**Fix:** Lock/reload the user row inside the consumption transaction or use an atomic conditional update that only succeeds once. Consume recovery codes atomically too. Ensure login and MFA-disable use the same primitive and avoid commits that release locks before consumption finishes.
|
||||
|
||||
**Acceptance:** Concurrent real-test-database requests for the same code yield exactly one acceptance. Sequential replay, drift window, remaining recovery codes and unrelated-account concurrency continue working.
|
||||
|
||||
### SEC-07 — Login throttling has unbounded state and synchronous expensive work
|
||||
|
||||
**Locations:** `backend/app/services/auth_service.py:55-60,78-139,199-233`; `backend/app/routers/auth.py:74-113`; backend Dockerfile uvicorn invocation.
|
||||
|
||||
**Cause:** `_records` and `_failures_since_success` have no TTL eviction or size cap. Expired attempt records remain, and arbitrary nonexistent usernames create counters. A probe created 200 bucket records for 100 synthetic sources; advancing time and checking every bucket left all 200 records and 100 username counters. Password verification is synchronous Argon2 work inside an async request handler. The limiter is per-process, per-IP/account and per-IP; it has no shared/account-wide policy across sources/processes. A correct password on an MFA account with omitted code exits without recording a failure, allowing repeated expensive checks on that path.
|
||||
|
||||
**Impact:** Public login requests can consume memory over time and stall the single event loop, also delaying status, deployment and rollback tasks. One IP's existing lockout reduces simple repeated attacks, so sustained abuse generally needs multiple sources/windows; multiworker deployment introduces additional independent buckets. The review did not load-test a running instance.
|
||||
|
||||
**Fix:** Bound/expire all throttle maps, including informational username counters. Use a shared limiter if supporting multiple workers and an account-aware policy that avoids trivially locking users out. Offload Argon2 into a bounded worker pool with admission/concurrency limits. Limit request body size before JSON parsing; field max lengths do not bound the raw HTTP body. Count appropriate incomplete/failed MFA attempts.
|
||||
|
||||
**Acceptance:** Expired records are removed, capacity remains bounded under many usernames, and throttles work across supported workers. Heartbeat/health requests stay responsive during bounded synthetic password verification. Missing MFA codes cannot create an unlimited costly path. Oversized chunked and Content-Length bodies are rejected before buffering.
|
||||
|
||||
### SEC-08 — Untrusted upstream errors cross the reader/secret boundary
|
||||
|
||||
**Locations:** `backend/app/services/opnsense_client.py:40-67`; `backend/app/services/host_status_service.py:35-50`; `backend/app/routers/hosts.py:42-57,69-75,131-136`; `backend/app/routers/maintenance.py:53-61`; `backend/app/services/managed_certificate_service.py`, `push_certificate()`; analogous `str(exc)` assignments in VPN/ACME/deploy services.
|
||||
|
||||
**Cause:** HTTP error exceptions include the entire upstream response body. Some direct host routes sanitize with `_upstream_error`, but status returns `str(exc)` through `status_detail`, maintenance returns it as `error`, and multiple deploy/certificate/ACME paths persist it as user-visible messages. An upstream response or validation object may contain reflected secret-bearing inputs. `_mutation_result()` also embeds up to 350 characters of unfiltered JSON.
|
||||
|
||||
**Prerequisites/impact:** Upstream emits sensitive details or is compromised/misconfigured. Readers can see otherwise privileged error content; messages may additionally enter database backups/audit exports/logs. The synthetic status probe returned the embedded marker. Actual firewall credential reflection was not tested or claimed.
|
||||
|
||||
**Fix:** Use structured upstream exceptions with safe code/status/operation fields. Centralize redaction before persistence, logging and response serialization; avoid raw bodies even in generic backend logs. Bound retained diagnostic data. Cover success-shaped-but-invalid mutation responses too.
|
||||
|
||||
**Acceptance:** Mock an upstream error/validation response containing API credentials, PSK, private key and arbitrary markers. None appear in reader JSON, database messages, audit details or logs. Safe operation/status diagnostics remain useful.
|
||||
|
||||
### SEC-09 — Deleting a host can leave live VPN/certificate state untracked
|
||||
|
||||
**Locations:** `backend/app/routers/hosts.py:175-201`; host foreign keys in `backend/app/models/ipsec_tunnel_deployment.py:40`, `openvpn_instance_deployment.py:44`, `wireguard_tunnel_deployment.py:44`, `managed_certificate.py:61`, `acme_account.py:47`, `acme_certificate_request.py:62`.
|
||||
|
||||
**Cause:** Host deletion checks rule/alias/NAT/hierarchy deployments and active maintenance operations but omits VPN deployments and certificate/ACME records. Their host FKs cascade deletion. Deleting the host removes local tracking and its API secret without undeploying the remote objects. File-backed entity secrets can also outlive cascaded entity deletion.
|
||||
|
||||
**Impact:** Authorized cleanup can leave unintended VPN trust/tunnels/certificates live while destroying their local management records. This is an integrity/lifecycle issue requiring admin access, not a privilege escalation.
|
||||
|
||||
**Reproduce:** In a disposable DB, associate a host only with a deployed VPN or managed certificate. DELETE the host; inspect cascaded local records and mocked remote-call history. Do not run this on a real firewall.
|
||||
|
||||
**Fix:** Extend guards across every managed remote resource. Require explicit reconciliation/undeployment or a separately acknowledged orphaning workflow. Clean entity secret files only after a safe, successful cleanup; keep identifiers for partial failures. Serialize creation/deletion races with the same operation lock.
|
||||
|
||||
**Acceptance:** Each VPN/certificate/ACME case blocks unsafe deletion or performs the documented explicit cleanup. Partial remote failure preserves tracking and secret access needed for recovery. Ordinary undeployed-host deletion succeeds.
|
||||
|
||||
### SEC-10 — Durable audit misses MFA, VPN, certificate and notification changes
|
||||
|
||||
**Locations:** `backend/app/auth_middleware.py:65-80`; `backend/app/services/audit_service.py:27-50`; `backend/app/routers/auth.py`; `backend/app/routers/ipsec.py`, `openvpn.py`, `wireguard.py`, `certificates.py`, `notifications.py`.
|
||||
|
||||
**Cause:** Middleware's resource mapping covers rules, aliases, NAT, hosts and users. The transactional deployment-history hook covers rule/alias/NAT/hierarchy deployment models, not VPN/certificate/ACME. Those routers/services have no compensating durable audit events. MFA changes and password change use ordinary logs or no audit event. Raw backup downloads also are not tracked.
|
||||
|
||||
**Impact:** VPN trust, certificate issuance, notification disablement and account security changes can occur without attributable records in `/api/audit`. Some related operations do have audit coverage and should not be duplicated. An account's current role still restricts writes.
|
||||
|
||||
**Fix:** Define an explicit security event inventory and record actor, action, target, correlation ID, timestamp and safe result for each missing operation. Include secret exports and failed authorization for sensitive actions. Preserve transactional history and durable pre-operation intent where externally visible operations require it. Never audit submitted secret bodies.
|
||||
|
||||
**Acceptance:** Exercise success/failure for each operation class and verify durable, attributable events. Passwords, keys, cookies, webhook tokens and raw backups never appear. Interrupted remote calls have an interpretable incomplete state. Existing audit views/exports remain compatible.
|
||||
|
||||
### SEC-11 — Notification secrets disappear on container replacement; signing fails open
|
||||
|
||||
**Locations:** `backend/app/config.py:82`; `docker-compose.yml:103-134,193-205`; `backend/app/services/notification_secret_store.py:15-30`; `backend/app/services/notification_delivery.py:67-72,82-100`; `backend/app/services/notification_settings_service.py:35-49`.
|
||||
|
||||
**Cause:** Notification secrets default to `/data/notification-secrets`, but Compose mounts sibling secret directories individually and omits this directory. SMTP/signing material remains in the backend writable layer and disappears on recreation/update; DB `*_set` flags survive. When `webhook_signing_secret_set` is true but the file is missing, webhook delivery silently sends without a signature. Missing SMTP password can lead to sending without login or failed delivery.
|
||||
|
||||
**Impact:** Routine replacement can disable authenticated alert delivery or drop expected message authentication. A receiver that correctly rejects unsigned messages will reject delivery; a permissive receiver may accept unauthenticated messages. Mere container restart normally preserves the writable layer; recreation is the trigger.
|
||||
|
||||
**Fix:** Mount a dedicated persistent notification-secret volume and document backup/restore. Migrate existing secrets before replacing a container. If a configured secret is absent, fail closed with a safe diagnostic and visible unhealthy notification state rather than silently changing authentication behavior.
|
||||
|
||||
**Acceptance:** Save synthetic secrets, recreate disposable backend containers with the intended volume, and verify persistence. With signing flag true but missing file, no HTTP request is sent. With SMTP authentication configured but secret missing, no unauthenticated send occurs. Diagnose state without revealing values.
|
||||
|
||||
### SEC-12 — Dependency audit is no longer clean
|
||||
|
||||
**Locations:** `frontend/package-lock.json:710,1135,1144,3718,4068,4203`; `frontend/package.json`; `frontend/Dockerfile`; `.github/workflows/ci.yml`. Full chain/version data is in `npm-audit.json`.
|
||||
|
||||
**Observed versions:** `sharp 0.35.4`, `source-map-js 1.2.1`, `braces 3.0.3`, `brace-expansion 1.1.18` and nested `5.0.9`, `postcss-selector-parser 6.1.4`. npm also flags their dependent chains. Most listed packages are dev dependencies; sharp and source-map-js are not marked dev in the lockfile.
|
||||
|
||||
**Impact/limitations:** Registry advisories establish affected versions, not exploitability in this application. The sharp advisory discusses memory corruption under specific runtime conditions, including glibc; the supplied frontend runner is Alpine/musl. The review found no frontend `next/image` usage and demonstrated no attacker-controlled SVG decoding or remote-code execution. Other advisories concern parsing untrusted patterns/selectors/maps and are primarily build/tooling risks here. Check standalone-image contents and actual input reachability before assigning a higher product severity.
|
||||
|
||||
**Fix:** Refresh affected dependencies to compatible patched versions, review transitive overrides, and regenerate the lockfile. Do not blindly use `npm audit fix --force`: its suggested framework downgrade/major Tailwind changes require compatibility analysis. Audit the actual runtime image and add recurring dependency auditing to CI. Revise dated documentation that claims no known vulnerabilities without a current scan.
|
||||
|
||||
**Acceptance:** Fresh lockfile audit has no unresolved relevant advisories, or documented time-bounded exceptions with reachability evidence. Frontend build/lint succeed. Verify the standalone runtime uses patched native libraries. Backend audit remains clean for the resolved dependency set, and separately scan OS images.
|
||||
|
||||
**Verified primary references:** [sharp maintainer advisory](https://github.com/advisories/GHSA-wq5f-xc86-pv6w), [braces advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). Additional advisory URLs are preserved in npm's JSON evidence. The sharp advisory lists 0.35.5 as patched; confirm compatible current releases before changing dependencies.
|
||||
|
||||
### SEC-13 — MFA management bypasses login attempt limiting
|
||||
|
||||
**Locations:** `backend/app/routers/auth.py:153-200`; `backend/app/services/auth_service.py:78-139`.
|
||||
|
||||
**Cause:** Setup verifies a password without the login throttle; enable checks a six-digit code without a limit; disable checks password plus second factor without a limit. Only `/login` consults/updates the throttle. Pending enrollment has no explicit expiry or session binding.
|
||||
|
||||
**Prerequisites/impact:** An authenticated session. A stolen session allows repeated password guesses against setup/disable; with the password, an attacker can repeatedly guess the disable second factor. An attacker with a session and a pending enrollment can try to complete it without a bounded attempt budget. Argon2 cost and network latency reduce throughput but are not an explicit attempt limit.
|
||||
|
||||
**Fix:** Add a bounded per-account/per-action rate limiter and short-lived, session-bound enrollment state. Require recent authentication for security-sensitive changes. Avoid resetting the attempt budget merely by recreating pending enrollment. Reuse atomic factor consumption from SEC-06.
|
||||
|
||||
**Acceptance:** Repeated invalid password/code requests become 429 or a documented lockout; counters and enrollment survive the supported worker model. Expired or another session's enrollment cannot enable MFA. Normal read-only self-service still works.
|
||||
|
||||
### SEC-14 — VPN preview approval is optional and ignores secret-only changes
|
||||
|
||||
**Locations:** `backend/app/routers/ipsec.py:152-160`; `backend/app/routers/openvpn.py:155-163`; `backend/app/routers/wireguard.py:164-172`; `backend/app/services/ipsec_service.py:90-100`; analogous token construction in `openvpn_service.py:109-119`, `wireguard_service.py:104-114`.
|
||||
|
||||
**Cause:** Deploy accepts `approval=None`, and checks the token only if supplied. Tokens are built from snapshots with secret-present booleans and masked diff rows, not the current secret's version/hash. Rotating only a PSK/private key while keeping flags true can produce the same token. The IPsec probe used an enabled synthetic target and two different PSKs and observed equal tokens.
|
||||
|
||||
**Impact:** An admin API call can skip the claimed preview gate, and an existing approval can apply different secret material than reviewed. This concerns change integrity/lockout prevention; admin already has deployment permission. The source explicitly describes some confirmations as UI-only, so implementing a backend gate changes that policy intentionally.
|
||||
|
||||
**Fix:** Make approval mandatory when preview is part of the promised safety workflow. Bind it to entity revision, resolved targets, relevant transport trust and secret version/change identifiers. Keep actual secrets and offline-guessable low-entropy secret fingerprints out of user-visible responses; use an opaque server-side approval record or keyed fingerprint where appropriate. Add expiry/single-use semantics if treating approval as an authorization artifact. Correct comparisons of raw payloads versus redacted stored payloads so unchanged secrets do not always appear modified.
|
||||
|
||||
**Acceptance:** Missing token fails without a remote call. Secret-only rotation invalidates old approval; metadata/target/transport changes also invalidate it. An unchanged draft permits the intended no-op. Test all three VPN types, not just IPsec. Exposed preview/revision JSON contains no plaintext secret.
|
||||
|
||||
### SEC-15 — Validation responses echo secret-bearing input
|
||||
|
||||
**Locations:** `backend/app/main.py` (default validation exception handling); `backend/app/schemas/certificates.py:57-79`; other password/key request schemas.
|
||||
|
||||
**Cause:** FastAPI's default validation response includes `input`. Model-level validators can include an entire submitted object; field validators can include invalid secret values. The synthetic request to `/api/certificates/managed` returned 422 containing the submitted private-key marker.
|
||||
|
||||
**Impact:** The submitting client already knows the value, so this alone is not cross-user disclosure. It unnecessarily puts secrets into response bodies that error telemetry, browser diagnostics or proxies may capture. Classified low rather than claiming account takeover.
|
||||
|
||||
**Fix:** Install a safe RequestValidationError response serializer retaining useful field locations, safe messages and codes while removing/redacting inputs and sensitive context. Do not log raw validation objects. `SecretStr` alone is insufficient unless serialization behavior is explicitly tested.
|
||||
|
||||
**Acceptance:** Invalid password, private key, PSK and model-level requests return useful 422 responses without markers anywhere in their JSON/logs. Non-secret validation remains understandable.
|
||||
|
||||
### SEC-16 — Reader settings expose webhook credentials embedded in URLs
|
||||
|
||||
**Locations:** `backend/app/routers/notifications.py:19-21`; `backend/app/schemas/notification.py:18-19`; `backend/app/services/notification_settings_service.py:22-32`; webhook URL column in `backend/app/models/notification.py`.
|
||||
|
||||
**Cause:** Settings GET returns the full webhook URL to any authenticated role. Many webhook integrations authenticate possession of a token in the URL path/query; that URL is therefore a credential, unlike an ordinary hostname. Signing-secret and SMTP-password flags correctly avoid returning those secret values but do not protect URL-embedded tokens.
|
||||
|
||||
**Prerequisites/impact:** A configured URL contains a capability token, and a reader account accesses settings. The reader can reuse it outside Opnexus to submit forged messages or use whatever access that receiver grants. For a token-free URL protected separately by a signature, this finding's credential impact does not apply. No real configured URLs were inspected.
|
||||
|
||||
**Fix:** Treat capability URLs as write-only secrets or return a carefully redacted display value. Keep a `webhook_url_set` indicator; preserve omitted values on updates and provide explicit clear/replace behavior. At minimum require an explicit privileged permission for the unredacted value. Avoid tokens in DB dumps, audit records and logs where the threat model requires secret-store separation.
|
||||
|
||||
**Acceptance:** Reader GET never returns a synthetic path/query token. Saving/reloading settings preserves delivery functionality. Masked UI text is not accidentally written back as the real URL. Both signed token-free URLs and capability URLs work with the intended authorization.
|
||||
|
||||
## Additional hardening and policy decisions
|
||||
|
||||
These are separate from the 16 findings above. Do not call them demonstrated remote exploits.
|
||||
|
||||
| ID | Locations | Observation, prerequisites and concrete next step |
|
||||
|---|---|---|
|
||||
| HARD-01 | `backend/Dockerfile`; `frontend/Dockerfile:13-23`; `docker-compose.yml:29-39,103` | Backend/frontend run as root; application uses the PostgreSQL bootstrap superuser. This amplifies a future application compromise. Add non-root runtime users, compatible volume ownership, reduced capabilities/no-new-privileges and a least-privileged DB role. Keep migration credentials separate. Verify runtime UID, secret-store access and migrations before rollout. |
|
||||
| HARD-02 | `backend/app/security_middleware.py:25-31` | Same-origin check compares only netloc to Host, so `http://host` is accepted for an HTTPS host. Probe confirmed cross-scheme acceptance. Exploitability depends on same-site delivery and access to an insecure origin; HSTS and Secure cookies reduce it. Compare a canonical scheme/host/port origin, respecting only explicitly trusted forwarding. Add cross-scheme and default-port tests. Headerless requests being allowed is not by itself proof of browser CSRF; no such exploit was demonstrated. |
|
||||
| HARD-03 | All `*_secret_store.py`; `totp_service.py:95-99`; `wireguard_key_store.py:39-44,57-62`; `backup_store.py:19-29` | Files are written before chmod 0600. With umask 022 a newly created file can briefly be 0644; writes are not atomic and follow symlinks. Requires local access to the secret directory; no remote path to that access was found. Create restrictive directories and mode-0600 temporary files at open time, avoid symlink following, then atomically replace. Test permissions before writing and crash/partial-write behavior. |
|
||||
| HARD-04 | `schemas/host.py`; `tls_pin_service.py:93-113`; `notification_delivery.py:55-100`; `update_check_service.py:65-78` | Admin-controlled hosts, TLS probes and notification targets can cause outbound connections. Private targets are necessary for this product, and only admins can set these inputs; this was not classified as anonymous SSRF. Restrict schemes/ports and optionally provide operator-defined management/notification egress policy, protecting metadata services where relevant. Enforce streaming response-size limits; update-feed size is checked only after full buffering. |
|
||||
| HARD-05 | `docs/HANDBUCH.md:213-217`; `proxy/Caddyfile`, `/opnexus-ca.crt` handler | First-time CA installation is bootstrapped from the same initially untrusted HTTPS endpoint, without an independent fingerprint in those instructions. An attacker on the first-use path could substitute a root users then trust system-wide. Provide an operator-side fingerprint command/out-of-band comparison before import, or use an already-trusted organizational CA. Do not tell users that encryption alone authenticates the initial download. |
|
||||
| HARD-06 | `docker-compose.yml:115-116`; `.env.example`; `backend/app/config.py`, `cookie_secure` | Bundled proxy is enabled but Secure-cookie defaults remain false. Production handbook correctly says true. Make secure production defaults/validation align with the shipped TLS topology and retain an explicit local-dev mode. HSTS protects established HTTPS clients but does not replace a Secure cookie on first use. Verify cookie flags behind the proxy and HTTP-only dev behavior. |
|
||||
| HARD-07 | `.github/workflows/ci.yml`, `release.yml`; Dockerfiles; `scripts/opnexus:130-139,163-166` | CI actions/base images are referenced by mutable tags, Python transitives are not fully hash-locked, and release images have no documented verified signature/provenance gate. Rollback sources a local state file as shell code; exploitation requires ability to write that trusted installation file. Pin/verify build inputs, publish/verifiably check provenance, and parse rollback state as data with strict field validation and restrictive permissions. Do not assume a new remote exploit without establishing who can modify these inputs. |
|
||||
| HARD-08 | `backup_store.py`; `routers/hosts.py:231-258`; `migrate.py`, `_dump_to()`; `docker-compose.yml`, backup service | Firewall snapshots have no retention/deletion/total-size policy; downloads buffer complete responses. Database dumps are not explicitly chmod-hardened and include authentication-related records. Define backup retention/capacity, streaming caps and restrictive file modes, plus encrypted/off-host backup and complete secret-volume recovery procedures. This is availability/confidentiality hardening requiring access/usage conditions, not proof that stored backups were exposed over the network. |
|
||||
|
||||
## Controls checked and non-findings
|
||||
|
||||
- Session tokens use cryptographic randomness; only their SHA-256 hashes are stored. Passwords use Argon2. Reader write restrictions and anonymous protected-route restrictions are present. No authentication bypass was demonstrated.
|
||||
- ORM queries and inspected SQL use bound parameters or trusted static statements. No request-driven SQL injection or shell-command injection path was established. Migration subprocesses use argument lists rather than a shell.
|
||||
- XML restore parsing uses defusedxml, an input size check and an allowlisted metadata manifest. Raw backup export is a distinct path covered by SEC-01.
|
||||
- Backup filename checks prevent straightforward directory traversal. UUID-based secret filenames do not use request-controlled arbitrary path strings.
|
||||
- VPN deployment payloads are currently redacted/hashed before persistence, despite stale secret-store docstrings claiming otherwise. The report does not repeat those obsolete plaintext-payload claims as a current vulnerability. Error paths remain covered by SEC-08, and full git-history/legacy database data were not scanned.
|
||||
- Inspected React content is normally escaped. The layout's `dangerouslySetInnerHTML` contains a static script; the website lightbox `innerHTML` uses static markup and two fixed language labels. No attacker-controlled HTML sink was established. The proxy's `unsafe-inline` CSP remains a defense-in-depth limitation, not proof of XSS.
|
||||
- Caddy terminates TLS and adds HSTS/CSP/frame/content headers. Compose publishes backend/frontend to loopback by default and does not publish PostgreSQL. Widening `BIND_ADDRESS` can expose HTTP directly; operators should keep the documented proxy topology.
|
||||
- Firewall TLS pinning uses a verifying trust context; the unauthenticated probe is deliberately only a fingerprint-discovery step. Trusting an uploaded CA intentionally trusts certificates it signs and is not identical to an exact leaf fingerprint pin. Confirm the chosen trust scope in UI/docs.
|
||||
- Maintenance execute checks bind preview to actor, host, freshness and observed state and consume it before a non-idempotent remote action. VPN approval is weaker and is separately covered by SEC-14.
|
||||
- Audit UPDATE/DELETE triggers exist. They are useful accidental-tampering controls, not resistance to the application database superuser, which can disable triggers/truncate tables.
|
||||
|
||||
## Remediation execution order
|
||||
|
||||
1. Implement explicit secret-export authorization, atomic credential/session revocation, verifying SMTP TLS and durable uncertain-deployment recovery (SEC-01–04).
|
||||
2. Reject plaintext firewall URLs by default and make production transport settings consistent (SEC-05, HARD-06).
|
||||
3. Make MFA consumption atomic and bound every authentication/security-management attempt path (SEC-06, SEC-07, SEC-13).
|
||||
4. Remove raw-error/validation secret reflection, protect capability URLs and persist notification secrets with fail-closed delivery (SEC-08, SEC-11, SEC-15, SEC-16).
|
||||
5. Guard managed-resource lifecycle, complete durable auditing and bind VPN approval to current secret versions (SEC-09, SEC-10, SEC-14).
|
||||
6. Update dependencies after compatibility/reachability analysis, then apply infrastructure/build/backup hardening (SEC-12, HARD-01–08).
|
||||
|
||||
For each ticket, report the final affected files, regression evidence, migration/backward-compatibility consequences and unresolved limitations. Keep deployment approval separate from implementation work. A clean dependency scan or passing mocks alone must not be described as proof the whole product is secure.
|
||||
|
||||
## Remediation status (2026-10-07, Claude Code)
|
||||
|
||||
Every finding was re-checked against the source before changing it; all 16 SEC findings were confirmed as described (SEC-03 by reading `smtplib` in the Python 3.12 image, SEC-11 by listing `/data` in the live backend container: no `notification-secrets`, and no notification secrets are configured there). Regression tests: `backend/tests/test_security_review.py` (42 tests); full backend suite 475 passed. Nothing was deployed and no firewall was contacted; the frontend lint/typecheck/build passed in a scratch copy.
|
||||
|
||||
| ID | Status | What was done / what remains |
|
||||
|---|---|---|
|
||||
| SEC-01 | Fixed | Raw backup download is admin-only (403 `READ_ONLY_ACCESS` otherwise), listing stays visible; every attempt audited (`backup/download`, status `accepted`/`denied`); UI hides the button for readers. |
|
||||
| SEC-02 | Fixed | Password change/reset revoke all sessions atomically with the new hash; admin 2FA reset revokes all; 2FA enable/disable revoke the *other* sessions; login re-checks the password hash under a row lock so a login racing a reset cannot create a session. |
|
||||
| SEC-03 | Fixed | Verifying `ssl.create_default_context()` for SSL and STARTTLS (optional `SMTP_CA_FILE`); authenticated plaintext SMTP refused unless `ALLOW_INSECURE_SMTP_AUTH`. |
|
||||
| SEC-04 | Fixed | New shared write path `services/deploy_flow.py` (all six deploy types): a durable intent row is committed before the first remote call (existing deployment: armed pending with previous payload; first deployment: `revert_failed` = "outcome unknown", blocks retries, notifies, "Rollback wiederholen" forgets it); UUID committed right after a confirmed write; failed apply keeps the deadline; timeouts/lost replies/5xx are classed ambiguous (`OpnsenseAmbiguousError`), connect failures and answered refusals are plain errors. Residual: a crash in the few milliseconds between the firewall executing `addRule` and the intent row being the only trace leaves the "unknown" row, which needs a manual look by design. |
|
||||
| SEC-05 | Fixed, one deliberate gap | `http://`, userinfo, query/fragment rejected on create and in the client (existing rows get "offline", no credentials sent); `ALLOW_INSECURE_FIREWALL_URLS` as explicit override. `verify_tls=false` over HTTPS is **kept**: it is an existing explicit opt-in (pinning is the supported alternative). |
|
||||
| SEC-06 | Fixed | User row re-read `FOR UPDATE` before consuming a TOTP step or recovery code; concurrent-request tests with the real database. |
|
||||
| SEC-07 | Mostly fixed | Bounded/expiring throttle state, Argon2 in a bounded thread pool (4 concurrent, 32 queued, then 503/429), 2 MB request-body cap in Caddy. **Not changed:** the missing-MFA-code path is not counted as a failure (counting it would lock out ordinary two-step logins; the password was already correct and the cost is now bounded); the limiter stays per process (the backend runs one uvicorn worker). |
|
||||
| SEC-08 | Fixed | Upstream error bodies are scrubbed (secret-named JSON fields, PEM blocks, key=value pairs, the host's own key/secret) and cut to 300 characters where the exception is built, so status, maintenance, deploy messages, DB rows and logs all get the safe form; webhook delivery errors no longer contain the URL. |
|
||||
| SEC-09 | Fixed | Deletion blocked while VPN deployments or deployed certificate/ACME objects exist; secret files of never-deployed drafts are removed with the host. |
|
||||
| SEC-10 | Fixed | Middleware now records VPN, certificate/ACME, notification, MFA/password and user-admin operations, plus denied writes of read-only accounts. Paths only — no bodies, no query strings. |
|
||||
| SEC-11 | Fixed | `notification_secrets` volume + `NOTIFICATION_SECRET_DIR` in `docker-compose.yml`; missing signing secret/SMTP password now fails closed. No migration needed on the live instance (no secrets stored there); `docker compose up -d` creates the volume. |
|
||||
| SEC-12 | Partly fixed | Lockfile refreshed (`sharp` 0.35.5, `source-map-js` 1.2.2, `brace-expansion`); these were the only findings present in the runtime image. Remaining 9 npm findings are build tooling (Tailwind 3/ESLint chain), no patched `braces` exists, `postcss-selector-parser` fix needs a Tailwind major. Re-scan the image after the next build; no recurring audit job added to CI yet. |
|
||||
| SEC-13 | Fixed, one gap | Attempt budget on 2FA setup/enable/disable and password change (not reset by a new enrolment); pending enrolment expires after 15 minutes. Session binding of the enrolment not added. |
|
||||
| SEC-14 | Fixed | Approval token mandatory for all three VPN types, bound to the stored secret's version (no secret-derived value in the token); preview now compares in the redacted form, so unchanged secrets no longer show as modified. Expiry/single-use not added. |
|
||||
| SEC-15 | Fixed | Custom 422 handler returns only `loc`/`msg`/`type`. |
|
||||
| SEC-16 | Fixed | Non-admins get the webhook URL reduced to scheme/host/port. |
|
||||
| HARD-02 | Fixed | Same-host origins must be `https` when `COOKIE_SECURE=true`. |
|
||||
| HARD-03 | Fixed | Secret files are created 0600 under a temp name with `O_EXCL|O_NOFOLLOW`, fsynced and renamed into place. |
|
||||
| HARD-05 | Documented | Handbook now gives the fingerprint comparison before importing the CA. |
|
||||
| HARD-06 | Partly | Startup warning when `FRONTEND_ORIGIN` is https but `COOKIE_SECURE` is false; the compose default is unchanged (would break HTTP development). |
|
||||
| HARD-01, 04, 07, 08 | Open | Non-root containers/least-privilege DB role (volume ownership and migration role need a rollout plan), egress policy and streaming limits, CI pinning/signing and `scripts/opnexus` state-file parsing, backup retention/permissions — separate work items. |
|
||||
|
||||
Rollout notes: rebuild backend, frontend **and proxy** images (Caddyfile is baked in); apply the lockfile with the frontend rebuild; no database migration.
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"auditReportVersion": 2,
|
||||
"vulnerabilities": {
|
||||
"@next/eslint-plugin-next": {
|
||||
"name": "@next/eslint-plugin-next",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"fast-glob"
|
||||
],
|
||||
"effects": [
|
||||
"eslint-config-next"
|
||||
],
|
||||
"range": ">=14.3.0-canary.0",
|
||||
"nodes": [
|
||||
"node_modules/@next/eslint-plugin-next"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint-config-next",
|
||||
"version": "14.2.35",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"brace-expansion": {
|
||||
"name": "brace-expansion",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1240100,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: Quadratic-time expansion of the `{a},b}` rewrite causes CPU denial of service",
|
||||
"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr",
|
||||
"severity": "moderate",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-407"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 5.3,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
|
||||
},
|
||||
"range": "<1.1.21"
|
||||
},
|
||||
{
|
||||
"source": 1240103,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: Quadratic-time expansion of the `{a},b}` rewrite causes CPU denial of service",
|
||||
"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr",
|
||||
"severity": "moderate",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-407"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 5.3,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
|
||||
},
|
||||
"range": ">=4.0.0 <5.0.12"
|
||||
},
|
||||
{
|
||||
"source": 1240104,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: DoS via uncontrolled recursion on nested brace groups causing stack exhaustion",
|
||||
"url": "https://github.com/advisories/GHSA-qhr7-859c-m2p7",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-674"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": "<1.1.20"
|
||||
},
|
||||
{
|
||||
"source": 1240107,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: DoS via uncontrolled recursion on nested brace groups causing stack exhaustion",
|
||||
"url": "https://github.com/advisories/GHSA-qhr7-859c-m2p7",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-674"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": ">=4.0.0 <5.0.11"
|
||||
},
|
||||
{
|
||||
"source": 1240108,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: DoS via uncontrolled recursion in parseCommaParts causing stack exhaustion",
|
||||
"url": "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-674"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": "<1.1.19"
|
||||
},
|
||||
{
|
||||
"source": 1240111,
|
||||
"name": "brace-expansion",
|
||||
"dependency": "brace-expansion",
|
||||
"title": "brace-expansion: DoS via uncontrolled recursion in parseCommaParts causing stack exhaustion",
|
||||
"url": "https://github.com/advisories/GHSA-6j4f-fj2g-mc7p",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-674"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": ">=4.0.0 <5.0.10"
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"range": "<=1.1.20 || 4.0.0 - 5.0.11",
|
||||
"nodes": [
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion",
|
||||
"node_modules/brace-expansion"
|
||||
],
|
||||
"fixAvailable": true
|
||||
},
|
||||
"braces": {
|
||||
"name": "braces",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1240992,
|
||||
"name": "braces",
|
||||
"dependency": "braces",
|
||||
"title": "braces vulnerable to stack-exhaustion denial of service through deeply nested patterns",
|
||||
"url": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-674"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": "<=3.0.3"
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
"chokidar",
|
||||
"micromatch"
|
||||
],
|
||||
"range": "*",
|
||||
"nodes": [
|
||||
"node_modules/braces"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "tailwindcss",
|
||||
"version": "4.3.3",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"name": "chokidar",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"braces"
|
||||
],
|
||||
"effects": [
|
||||
"tailwindcss"
|
||||
],
|
||||
"range": "2.0.0 - 3.6.0",
|
||||
"nodes": [
|
||||
"node_modules/chokidar"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "tailwindcss",
|
||||
"version": "4.3.3",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"eslint-config-next": {
|
||||
"name": "eslint-config-next",
|
||||
"severity": "high",
|
||||
"isDirect": true,
|
||||
"via": [
|
||||
"@next/eslint-plugin-next"
|
||||
],
|
||||
"effects": [],
|
||||
"range": ">=14.3.0-canary.0",
|
||||
"nodes": [
|
||||
"node_modules/eslint-config-next"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint-config-next",
|
||||
"version": "14.2.35",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"fast-glob": {
|
||||
"name": "fast-glob",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"micromatch"
|
||||
],
|
||||
"effects": [
|
||||
"@next/eslint-plugin-next"
|
||||
],
|
||||
"range": "*",
|
||||
"nodes": [
|
||||
"node_modules/fast-glob",
|
||||
"node_modules/tailwindcss/node_modules/fast-glob"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint-config-next",
|
||||
"version": "14.2.35",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"micromatch": {
|
||||
"name": "micromatch",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"braces"
|
||||
],
|
||||
"effects": [
|
||||
"fast-glob",
|
||||
"tailwindcss"
|
||||
],
|
||||
"range": ">=0.2.0",
|
||||
"nodes": [
|
||||
"node_modules/micromatch"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "tailwindcss",
|
||||
"version": "4.3.3",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"postcss-nested": {
|
||||
"name": "postcss-nested",
|
||||
"severity": "moderate",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"postcss-selector-parser"
|
||||
],
|
||||
"effects": [],
|
||||
"range": "2.0.3 - 6.2.0",
|
||||
"nodes": [
|
||||
"node_modules/postcss-nested"
|
||||
],
|
||||
"fixAvailable": true
|
||||
},
|
||||
"postcss-selector-parser": {
|
||||
"name": "postcss-selector-parser",
|
||||
"severity": "moderate",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1241232,
|
||||
"name": "postcss-selector-parser",
|
||||
"dependency": "postcss-selector-parser",
|
||||
"title": "PostCSS: Quadratic complexity in flat selector parsing allows CPU exhaustion",
|
||||
"url": "https://github.com/advisories/GHSA-rj75-hqrm-r3gf",
|
||||
"severity": "moderate",
|
||||
"cwe": [
|
||||
"CWE-400",
|
||||
"CWE-407"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 5.9,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": "<7.1.6"
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
"postcss-nested",
|
||||
"tailwindcss"
|
||||
],
|
||||
"range": "<7.1.6",
|
||||
"nodes": [
|
||||
"node_modules/postcss-selector-parser"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "tailwindcss",
|
||||
"version": "4.3.3",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"sharp": {
|
||||
"name": "sharp",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1241331,
|
||||
"name": "sharp",
|
||||
"dependency": "sharp",
|
||||
"title": "sharp : Vulnerability in librsvg dependency CVE-2026-96889",
|
||||
"url": "https://github.com/advisories/GHSA-wq5f-xc86-pv6w",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-416",
|
||||
"CWE-1395"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 0,
|
||||
"vectorString": null
|
||||
},
|
||||
"range": "<0.35.5"
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"range": "<0.35.5",
|
||||
"nodes": [
|
||||
"node_modules/sharp"
|
||||
],
|
||||
"fixAvailable": true
|
||||
},
|
||||
"source-map-js": {
|
||||
"name": "source-map-js",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1241209,
|
||||
"name": "source-map-js",
|
||||
"dependency": "source-map-js",
|
||||
"title": "source-map-js allows event-loop denial of service through indexed source-map section offsets",
|
||||
"url": "https://github.com/advisories/GHSA-68fv-2mgg-jv7q",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-1284"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 7.5,
|
||||
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
||||
},
|
||||
"range": ">=1.0.0 <1.2.2"
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"range": "1.0.0 - 1.2.1",
|
||||
"nodes": [
|
||||
"node_modules/source-map-js"
|
||||
],
|
||||
"fixAvailable": true
|
||||
},
|
||||
"tailwindcss": {
|
||||
"name": "tailwindcss",
|
||||
"severity": "high",
|
||||
"isDirect": true,
|
||||
"via": [
|
||||
"chokidar",
|
||||
"fast-glob",
|
||||
"micromatch",
|
||||
"postcss-nested",
|
||||
"postcss-selector-parser"
|
||||
],
|
||||
"effects": [],
|
||||
"range": "<=0.0.0-oxide-insiders.ff2c25f || 0.5.0 - 3.4.19",
|
||||
"nodes": [
|
||||
"node_modules/tailwindcss"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "tailwindcss",
|
||||
"version": "4.3.3",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"vulnerabilities": {
|
||||
"info": 0,
|
||||
"low": 0,
|
||||
"moderate": 2,
|
||||
"high": 10,
|
||||
"critical": 0,
|
||||
"total": 12
|
||||
},
|
||||
"dependencies": {
|
||||
"prod": 46,
|
||||
"dev": 332,
|
||||
"optional": 14,
|
||||
"peer": 0,
|
||||
"peerOptional": 0,
|
||||
"total": 390
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"dependencies": [{"name": "fastapi", "version": "0.141.1", "vulns": []}, {"name": "uvicorn", "version": "0.52.4", "vulns": []}, {"name": "sqlalchemy", "version": "2.0.52", "vulns": []}, {"name": "asyncpg", "version": "0.31.0", "vulns": []}, {"name": "pydantic", "version": "2.13.5", "vulns": []}, {"name": "pydantic-settings", "version": "2.15.0", "vulns": []}, {"name": "httpx", "version": "0.28.1", "vulns": []}, {"name": "defusedxml", "version": "0.7.1", "vulns": []}, {"name": "argon2-cffi", "version": "25.1.0", "vulns": []}, {"name": "pytest", "version": "9.1.1", "vulns": []}, {"name": "pytest-asyncio", "version": "1.4.0", "vulns": []}, {"name": "respx", "version": "0.23.1", "vulns": []}, {"name": "cryptography", "version": "50.0.2", "vulns": []}, {"name": "alembic", "version": "1.20.0", "vulns": []}, {"name": "pydantic-core", "version": "2.46.5", "vulns": []}, {"name": "httpcore", "version": "1.0.9", "vulns": []}, {"name": "pluggy", "version": "1.6.0", "vulns": []}, {"name": "annotated-doc", "version": "0.0.5", "vulns": []}, {"name": "annotated-types", "version": "0.8.0", "vulns": []}, {"name": "cffi", "version": "2.1.1", "vulns": []}, {"name": "click", "version": "8.5.0", "vulns": []}, {"name": "greenlet", "version": "3.5.6", "vulns": []}, {"name": "h11", "version": "0.16.0", "vulns": []}, {"name": "httptools", "version": "0.8.0", "vulns": []}, {"name": "iniconfig", "version": "2.3.1", "vulns": []}, {"name": "pygments", "version": "2.21.0", "vulns": []}, {"name": "python-dotenv", "version": "1.2.4", "vulns": []}, {"name": "pyyaml", "version": "6.0.3", "vulns": []}, {"name": "starlette", "version": "1.7.0", "vulns": []}, {"name": "anyio", "version": "4.15.1", "vulns": []}, {"name": "idna", "version": "3.20", "vulns": []}, {"name": "typing-extensions", "version": "4.16.0", "vulns": []}, {"name": "typing-inspection", "version": "0.4.4", "vulns": []}, {"name": "uvloop", "version": "0.23.0", "vulns": []}, {"name": "watchfiles", "version": "1.3.0", "vulns": []}, {"name": "websockets", "version": "17.2", "vulns": []}, {"name": "argon2-cffi-bindings", "version": "26.1.0", "vulns": []}, {"name": "certifi", "version": "2026.7.22", "vulns": []}, {"name": "mako", "version": "1.4.3", "vulns": []}, {"name": "markupsafe", "version": "3.0.4", "vulns": []}, {"name": "pycparser", "version": "3.0", "vulns": []}], "fixes": []}
|
||||
@@ -0,0 +1,70 @@
|
||||
[
|
||||
{
|
||||
"probe": "read_only_backup_download",
|
||||
"status": 200,
|
||||
"synthetic_marker_returned": true
|
||||
},
|
||||
{
|
||||
"probe": "read_only_write_control",
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"probe": "validation_error_secret_echo",
|
||||
"status": 422,
|
||||
"synthetic_marker_returned": true
|
||||
},
|
||||
{
|
||||
"probe": "password_reset_revoke",
|
||||
"sql_statements": [],
|
||||
"commits": 1
|
||||
},
|
||||
{
|
||||
"probe": "totp_parallel_replay",
|
||||
"accepted": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"probe": "recovery_parallel_replay",
|
||||
"accepted": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"probe": "status_error_disclosure",
|
||||
"synthetic_marker_returned": true
|
||||
},
|
||||
{
|
||||
"probe": "smtp_default_tls_context",
|
||||
"verify_mode": 0,
|
||||
"check_hostname": false
|
||||
},
|
||||
{
|
||||
"probe": "cross_scheme_origin",
|
||||
"accepted": true
|
||||
},
|
||||
{
|
||||
"probe": "plaintext_firewall_url",
|
||||
"accepted": true,
|
||||
"verify_tls": true
|
||||
},
|
||||
{
|
||||
"probe": "expired_throttle_records",
|
||||
"before": 200,
|
||||
"after": 200,
|
||||
"username_counters": 100
|
||||
},
|
||||
{
|
||||
"probe": "ambiguous_apply_rollback",
|
||||
"status": "error",
|
||||
"revert_deadline": null,
|
||||
"previous_payload": null,
|
||||
"remote_uuid_known": true
|
||||
},
|
||||
{
|
||||
"probe": "vpn_preview_secret_binding",
|
||||
"same_token_after_secret_change": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
import asyncio, copy, json, ssl, uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
from app.routers import auth, users, hosts
|
||||
from app.services import auth_service, totp_service, host_status_service, opnsense_client, rule_deploy_service, ipsec_service
|
||||
from app.security_middleware import _origin_allowed
|
||||
from app.schemas.host import HostCreate
|
||||
|
||||
results=[]
|
||||
def record(name, **details):
|
||||
results.append({'probe':name, **details})
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, user=None): self.user=user; self.executed=[]; self.commits=0
|
||||
async def get(self,*args,**kwargs): return self.user
|
||||
async def commit(self): self.commits+=1
|
||||
def add(self,obj): pass
|
||||
async def scalar(self,q): return None
|
||||
async def execute(self,q): self.executed.append(str(q)); return SimpleNamespace(scalar_one_or_none=lambda: self.user)
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self,*args): pass
|
||||
|
||||
async def main():
|
||||
user=SimpleNamespace(id=uuid.uuid4(),username='synthetic-reader',role='read_only')
|
||||
fake=FakeDB(user)
|
||||
async def dep(): yield fake
|
||||
app.dependency_overrides[get_db]=dep
|
||||
host_id=uuid.uuid4()
|
||||
with patch('app.auth_middleware.SessionLocal',return_value=fake),patch.object(auth_service,'resolve_session',AsyncMock(return_value=user)),patch.object(hosts,'_get_host_or_404',AsyncMock(return_value=SimpleNamespace(id=host_id))),patch.object(hosts.backup_store,'read_snapshot',return_value=b'<opnsense><synthetic-secret>MARKER</synthetic-secret></opnsense>'):
|
||||
async with AsyncClient(transport=ASGITransport(app=app),base_url='https://synthetic.test',cookies={auth.COOKIE_NAME:'synthetic-token'}) as c:
|
||||
r=await c.get(f'/api/hosts/{host_id}/backups/synthetic.xml')
|
||||
record('read_only_backup_download',status=r.status_code,synthetic_marker_returned='MARKER' in r.text)
|
||||
w=await c.post('/api/hosts',json={})
|
||||
record('read_only_write_control',status=w.status_code)
|
||||
user.role='admin'
|
||||
with patch('app.services.audit_service.persist',AsyncMock()):
|
||||
r=await c.post('/api/certificates/managed',json={'action':'internal','private_key':'SYNTHETIC-PRIVATE-KEY-MARKER'})
|
||||
record('validation_error_secret_echo',status=r.status_code,synthetic_marker_returned='SYNTHETIC-PRIVATE-KEY-MARKER' in r.text)
|
||||
app.dependency_overrides.clear()
|
||||
# No real user, session, database or secret is consulted below.
|
||||
target=SimpleNamespace(id=uuid.uuid4(),password_hash='synthetic-hash')
|
||||
db=FakeDB(target)
|
||||
with patch.object(auth_service,'hash_password',return_value='new-synthetic-hash'):
|
||||
await users.reset_password(target.id,users.UserPasswordReset(new_password='synthetic-password-123'),db)
|
||||
record('password_reset_revoke',sql_statements=db.executed,commits=db.commits)
|
||||
secret=totp_service.generate_secret(); code=totp_service.code_at(secret)
|
||||
barrier=asyncio.Event(); entered=0
|
||||
class ConcurrentDB(FakeDB):
|
||||
async def commit(self):
|
||||
nonlocal entered
|
||||
entered+=1
|
||||
if entered==2: barrier.set()
|
||||
await barrier.wait()
|
||||
u1=SimpleNamespace(id=uuid.uuid4(),username='synthetic-user',totp_last_step=0,totp_recovery_hashes=[])
|
||||
u2=copy.copy(u1)
|
||||
with patch.object(totp_service,'read_secret',return_value=secret):
|
||||
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
|
||||
record('totp_parallel_replay',accepted=accepted)
|
||||
code='synthetic-recovery'; recovery=totp_service.hash_recovery(code)
|
||||
u1.totp_recovery_hashes=[recovery];u2.totp_recovery_hashes=[recovery];entered=0;barrier=asyncio.Event()
|
||||
with patch.object(totp_service,'read_secret',return_value=None):
|
||||
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
|
||||
record('recovery_parallel_replay',accepted=accepted)
|
||||
with patch.object(host_status_service,'get_vip_status',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('401 synthetic upstream secret MARKER'))):
|
||||
status=await host_status_service._status_for(SimpleNamespace(enabled=True))
|
||||
record('status_error_disclosure',synthetic_marker_returned='MARKER' in status['status_detail'])
|
||||
ctx=ssl._create_stdlib_context()
|
||||
record('smtp_default_tls_context',verify_mode=int(ctx.verify_mode),check_hostname=ctx.check_hostname)
|
||||
record('cross_scheme_origin',accepted=_origin_allowed('http://synthetic.test','synthetic.test'))
|
||||
h=HostCreate(name='synthetic',base_url='http://synthetic.test',api_key='synthetic',api_secret='synthetic')
|
||||
record('plaintext_firewall_url',accepted=h.base_url.startswith('http://'),verify_tls=h.verify_tls)
|
||||
throttle=auth_service._LoginThrottle()
|
||||
for i in range(100): throttle.record_failure(f'synthetic-{i}',f'user-{i}')
|
||||
before=len(throttle._records)
|
||||
with patch('app.services.auth_service.time.monotonic',return_value=10**20):
|
||||
for i in range(100): throttle.retry_after(f'synthetic-{i}',f'user-{i}')
|
||||
record('expired_throttle_records',before=before,after=len(throttle._records),username_counters=len(throttle._failures_since_success))
|
||||
rule=SimpleNamespace(id=uuid.uuid4(),enabled=True,action='pass',interface='wan',direction='in',ipprotocol='inet',protocol='any',source_net='any',source_port=None,destination_net='any',destination_port=None,description='synthetic rule')
|
||||
with patch.object(opnsense_client,'add_rule',AsyncMock(return_value={'uuid':'synthetic-remote-uuid'})),patch.object(opnsense_client,'apply',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('synthetic timeout after apply'))):
|
||||
d=await rule_deploy_service._deploy_to_host(FakeDB(),rule,SimpleNamespace(id=uuid.uuid4()))
|
||||
record('ambiguous_apply_rollback',status=d.status,revert_deadline=d.revert_deadline,previous_payload=d.previous_payload,remote_uuid_known=bool(d.opnsense_rule_uuid))
|
||||
tunnel=SimpleNamespace(id=uuid.uuid4(),description='synthetic',local_address='192.0.2.1',remote_address='192.0.2.2',ike_version='ikev2',proposal='aes256-sha256-modp2048',local_identity='synthetic-local',remote_identity='synthetic-remote',local_networks='192.0.2.0/25',remote_networks='198.51.100.0/25',esp_proposal='aes256-sha256',enabled=True,pre_shared_key_set=True,targets=[],groups=[],deployments=[])
|
||||
tunnel.targets=[SimpleNamespace(id=uuid.uuid4(),name='synthetic-host',enabled=True,base_url='https://synthetic.test',verify_tls=True)]
|
||||
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-old-secret'):
|
||||
a=ipsec_service.preview(tunnel)
|
||||
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-new-secret'):
|
||||
b=ipsec_service.preview(tunnel)
|
||||
record('vpn_preview_secret_binding',same_token_after_secret_change=a['token']==b['token'])
|
||||
print(json.dumps(results,indent=2))
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,187 @@
|
||||
backend/app/routers/aliases.py 74 get '' list_aliases
|
||||
backend/app/routers/aliases.py 90 post '' create_alias
|
||||
backend/app/routers/aliases.py 117 post '/{alias_id}/deploy' deploy
|
||||
backend/app/routers/aliases.py 135 post '/{alias_id}/confirm' confirm
|
||||
backend/app/routers/aliases.py 142 delete '/{alias_id}' delete_alias
|
||||
backend/app/routers/aliases.py 156 put '/{alias_id}' edit_alias
|
||||
backend/app/routers/aliases.py 181 get '/{alias_id}/preview' preview_alias
|
||||
backend/app/routers/aliases.py 186 get '/{alias_id}/revisions' list_revisions
|
||||
backend/app/routers/aliases.py 193 post '/{alias_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/aliases.py 218 post '/{alias_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/audit.py 11 get '' history
|
||||
backend/app/routers/auth.py 74 post '/login' login
|
||||
backend/app/routers/auth.py 148 get '/2fa' totp_status
|
||||
backend/app/routers/auth.py 153 post '/2fa/setup' totp_setup
|
||||
backend/app/routers/auth.py 167 post '/2fa/enable' totp_enable
|
||||
backend/app/routers/auth.py 187 post '/2fa/disable' totp_disable
|
||||
backend/app/routers/auth.py 204 post '/logout' logout
|
||||
backend/app/routers/auth.py 212 get '/me' me
|
||||
backend/app/routers/auth.py 221 post '/change-password' change_password
|
||||
backend/app/routers/certificates.py 56 get '' list_managed_certificates
|
||||
backend/app/routers/certificates.py 62 post '' create_managed_certificate
|
||||
backend/app/routers/certificates.py 77 put '/{cert_id}' edit_managed_certificate
|
||||
backend/app/routers/certificates.py 95 post '/{cert_id}/push' push_managed_certificate
|
||||
backend/app/routers/certificates.py 106 delete '/{cert_id}' delete_managed_certificate
|
||||
backend/app/routers/certificates.py 120 get '' list_acme_accounts
|
||||
backend/app/routers/certificates.py 126 post '' create_acme_account
|
||||
backend/app/routers/certificates.py 141 put '/{account_id}' edit_acme_account
|
||||
backend/app/routers/certificates.py 159 post '/{account_id}/push' push_acme_account
|
||||
backend/app/routers/certificates.py 170 post '/{account_id}/register' register_acme_account
|
||||
backend/app/routers/certificates.py 189 delete '/{account_id}' delete_acme_account
|
||||
backend/app/routers/certificates.py 205 get '' list_acme_certificate_requests
|
||||
backend/app/routers/certificates.py 220 post '' create_acme_certificate_request
|
||||
backend/app/routers/certificates.py 231 put '/{request_id}' edit_acme_certificate_request
|
||||
backend/app/routers/certificates.py 247 post '/{request_id}/push' push_acme_certificate_request
|
||||
backend/app/routers/certificates.py 261 post '/{request_id}/sign' sign_acme_certificate_request
|
||||
backend/app/routers/certificates.py 278 post '/{request_id}/poll' poll_acme_certificate_request
|
||||
backend/app/routers/certificates.py 292 delete '/{request_id}' delete_acme_certificate_request
|
||||
backend/app/routers/cluster.py 13 get '/carp-events' list_carp_events
|
||||
backend/app/routers/dashboard.py 188 get '' summary
|
||||
backend/app/routers/groups.py 51 get '' list_groups
|
||||
backend/app/routers/groups.py 72 post '' create_group
|
||||
backend/app/routers/groups.py 78 put '/{group_id}' update_group
|
||||
backend/app/routers/groups.py 90 delete '/{group_id}' delete_group
|
||||
backend/app/routers/groups.py 161 post '/{group_id}/preview' preview_group
|
||||
backend/app/routers/hosts.py 69 get '' list_hosts
|
||||
backend/app/routers/hosts.py 91 post '/tls-probe' tls_probe
|
||||
backend/app/routers/hosts.py 102 post '' create_host
|
||||
backend/app/routers/hosts.py 131 get '/{host_id}' get_host
|
||||
backend/app/routers/hosts.py 138 patch '/{host_id}' update_host
|
||||
backend/app/routers/hosts.py 175 delete '/{host_id}' delete_host
|
||||
backend/app/routers/hosts.py 205 get '/{host_id}/live-rules' live_rules
|
||||
backend/app/routers/hosts.py 214 get '/{host_id}/certificates' certificates
|
||||
backend/app/routers/hosts.py 223 get '/{host_id}/cas' cas
|
||||
backend/app/routers/hosts.py 232 post '/{host_id}/backups' trigger_backup
|
||||
backend/app/routers/hosts.py 243 get '/{host_id}/backups' list_host_backups
|
||||
backend/app/routers/hosts.py 249 get '/{host_id}/backups/{backup_id}' download_host_backup
|
||||
backend/app/routers/hosts.py 262 get '/{host_id}/firmware-info' firmware_info
|
||||
backend/app/routers/hosts.py 271 get '/{host_id}/interfaces' interfaces
|
||||
backend/app/routers/hosts.py 280 get '/{host_id}/protocols' protocols
|
||||
backend/app/routers/hosts.py 289 get '/{host_id}/aliases' aliases
|
||||
backend/app/routers/ipsec.py 92 get '' list_tunnels
|
||||
backend/app/routers/ipsec.py 99 post '' create_tunnel
|
||||
backend/app/routers/ipsec.py 118 put '/{tunnel_id}' edit_tunnel
|
||||
backend/app/routers/ipsec.py 139 get '/{tunnel_id}/preview' preview_tunnel
|
||||
backend/app/routers/ipsec.py 144 get '/{tunnel_id}/revisions' list_revisions
|
||||
backend/app/routers/ipsec.py 154 post '/{tunnel_id}/deploy' deploy
|
||||
backend/app/routers/ipsec.py 172 delete '/{tunnel_id}' delete_tunnel
|
||||
backend/app/routers/ipsec.py 185 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/logs.py 25 get '/settings' read_log_settings
|
||||
backend/app/routers/logs.py 30 put '/settings' update_log_settings
|
||||
backend/app/routers/logs.py 79 get '' search_logs
|
||||
backend/app/routers/logs.py 108 get '/stats' log_stats
|
||||
backend/app/routers/logs.py 157 get '/filter-options' filter_options
|
||||
backend/app/routers/maintenance.py 53 get '' status
|
||||
backend/app/routers/maintenance.py 62 post '/preview' preview
|
||||
backend/app/routers/maintenance.py 73 post '/execute' execute
|
||||
backend/app/routers/maintenance.py 99 post '/check-updates' check_updates
|
||||
backend/app/routers/maintenance.py 112 get '/progress' progress
|
||||
backend/app/routers/maintenance.py 118 post '/carp-preview' carp_preview
|
||||
backend/app/routers/maintenance.py 129 post '/carp-execute' carp_execute
|
||||
backend/app/routers/maintenance.py 163 post '/{operation_id}/acknowledge' acknowledge
|
||||
backend/app/routers/maintenance.py 193 post '/{operation_id}/cancel' cancel
|
||||
backend/app/routers/notifications.py 20 get '/settings' read_settings
|
||||
backend/app/routers/notifications.py 25 put '/settings' update_settings
|
||||
backend/app/routers/notifications.py 30 get '/problems' list_problems
|
||||
backend/app/routers/notifications.py 46 post '/test' test_notification
|
||||
backend/app/routers/one_to_one.py 83 get '' list_one_to_one_rules
|
||||
backend/app/routers/one_to_one.py 91 post '' create_one_to_one_rule
|
||||
backend/app/routers/one_to_one.py 121 post '/{rule_id}/deploy' deploy
|
||||
backend/app/routers/one_to_one.py 157 post '/{rule_id}/confirm' confirm
|
||||
backend/app/routers/one_to_one.py 164 delete '/{rule_id}' delete_one_to_one_rule
|
||||
backend/app/routers/one_to_one.py 186 put '/{rule_id}' edit_one_to_one_rule
|
||||
backend/app/routers/one_to_one.py 207 get '/{rule_id}/preview' preview_rule
|
||||
backend/app/routers/one_to_one.py 212 get '/{rule_id}/revisions' list_revisions
|
||||
backend/app/routers/one_to_one.py 219 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/one_to_one.py 244 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/one_to_one.py 262 get '/hosts/{host_id}/live' live_rules
|
||||
backend/app/routers/openvpn.py 93 get '' list_instances
|
||||
backend/app/routers/openvpn.py 100 post '' create_instance
|
||||
backend/app/routers/openvpn.py 120 put '/{instance_id}' edit_instance
|
||||
backend/app/routers/openvpn.py 141 get '/{instance_id}/preview' preview_instance
|
||||
backend/app/routers/openvpn.py 146 get '/{instance_id}/revisions' list_revisions
|
||||
backend/app/routers/openvpn.py 156 post '/{instance_id}/deploy' deploy
|
||||
backend/app/routers/openvpn.py 174 delete '/{instance_id}' delete_instance
|
||||
backend/app/routers/openvpn.py 187 post '/{instance_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/outbound_nat.py 86 get '' list_outbound_nat_rules
|
||||
backend/app/routers/outbound_nat.py 94 post '' create_outbound_nat_rule
|
||||
backend/app/routers/outbound_nat.py 126 post '/{rule_id}/deploy' deploy
|
||||
backend/app/routers/outbound_nat.py 164 post '/{rule_id}/confirm' confirm
|
||||
backend/app/routers/outbound_nat.py 171 delete '/{rule_id}' delete_outbound_nat_rule
|
||||
backend/app/routers/outbound_nat.py 193 put '/{rule_id}' edit_outbound_nat_rule
|
||||
backend/app/routers/outbound_nat.py 214 get '/{rule_id}/preview' preview_rule
|
||||
backend/app/routers/outbound_nat.py 219 get '/{rule_id}/revisions' list_revisions
|
||||
backend/app/routers/outbound_nat.py 226 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/outbound_nat.py 251 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/outbound_nat.py 269 get '/hosts/{host_id}/live' live_rules
|
||||
backend/app/routers/outbound_nat.py 282 get '/hosts/{host_id}/mode' outbound_mode
|
||||
backend/app/routers/policy_hierarchy.py 116 get '/rules' list_rules
|
||||
backend/app/routers/policy_hierarchy.py 123 post '/rules' create_rule
|
||||
backend/app/routers/policy_hierarchy.py 158 put '/rules/{rule_id}' edit_rule
|
||||
backend/app/routers/policy_hierarchy.py 178 put '/groups/{group_id}/rules/{rulebase}/order' reorder_rules
|
||||
backend/app/routers/policy_hierarchy.py 225 post '/rules/{rule_id}/move' move_rule
|
||||
backend/app/routers/policy_hierarchy.py 245 post '/rules/{rule_id}/deploy' deploy
|
||||
backend/app/routers/policy_hierarchy.py 283 post '/rules/{rule_id}/confirm' confirm
|
||||
backend/app/routers/policy_hierarchy.py 290 delete '/rules/{rule_id}' delete_rule
|
||||
backend/app/routers/policy_hierarchy.py 304 get '/rules/{rule_id}/preview' preview_rule
|
||||
backend/app/routers/policy_hierarchy.py 310 get '/rules/{rule_id}/revisions' list_revisions
|
||||
backend/app/routers/policy_hierarchy.py 320 post '/rules/{rule_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/policy_hierarchy.py 346 post '/rules/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/policy_hierarchy.py 365 post '/groups/{group_id}/reassert-ordering' reassert_ordering
|
||||
backend/app/routers/policy_hierarchy_groups.py 46 get '' list_groups
|
||||
backend/app/routers/policy_hierarchy_groups.py 54 post '' create_group
|
||||
backend/app/routers/policy_hierarchy_groups.py 67 put '/{group_id}' update_group
|
||||
backend/app/routers/policy_hierarchy_groups.py 83 delete '/{group_id}' delete_group
|
||||
backend/app/routers/port_forward.py 84 get '' list_port_forward_rules
|
||||
backend/app/routers/port_forward.py 92 post '' create_port_forward_rule
|
||||
backend/app/routers/port_forward.py 123 post '/{rule_id}/deploy' deploy
|
||||
backend/app/routers/port_forward.py 159 post '/{rule_id}/confirm' confirm
|
||||
backend/app/routers/port_forward.py 166 delete '/{rule_id}' delete_port_forward_rule
|
||||
backend/app/routers/port_forward.py 188 put '/{rule_id}' edit_port_forward_rule
|
||||
backend/app/routers/port_forward.py 209 get '/{rule_id}/preview' preview_rule
|
||||
backend/app/routers/port_forward.py 214 get '/{rule_id}/revisions' list_revisions
|
||||
backend/app/routers/port_forward.py 221 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/port_forward.py 246 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/port_forward.py 264 get '/hosts/{host_id}/live' live_rules
|
||||
backend/app/routers/resource_samples.py 26 get '' list_samples
|
||||
backend/app/routers/restore.py 57 get '' list_operations
|
||||
backend/app/routers/restore.py 69 get '/{operation_id}' get_operation
|
||||
backend/app/routers/restore.py 76 post '/preview' preview
|
||||
backend/app/routers/restore.py 118 post '/{operation_id}/handoff' handoff
|
||||
backend/app/routers/restore.py 150 post '/{operation_id}/confirm' confirm
|
||||
backend/app/routers/restore.py 173 post '/{operation_id}/cancel' cancel
|
||||
backend/app/routers/rules.py 81 get '' list_rules
|
||||
backend/app/routers/rules.py 88 post '' create_rule
|
||||
backend/app/routers/rules.py 118 post '/{rule_id}/deploy' deploy
|
||||
backend/app/routers/rules.py 155 post '/{rule_id}/confirm' confirm
|
||||
backend/app/routers/rules.py 168 delete '/{rule_id}' delete_rule
|
||||
backend/app/routers/rules.py 190 put '/{rule_id}' edit_rule
|
||||
backend/app/routers/rules.py 210 get '/{rule_id}/preview' preview_rule
|
||||
backend/app/routers/rules.py 215 get '/{rule_id}/revisions' list_revisions
|
||||
backend/app/routers/rules.py 222 post '/{rule_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
backend/app/routers/rules.py 247 post '/{rule_id}/deployments/{host_id}/retry-rollback' retry_rollback
|
||||
backend/app/routers/scheduled_maintenance.py 41 get '' list_scheduled
|
||||
backend/app/routers/scheduled_maintenance.py 55 post '' create_scheduled
|
||||
backend/app/routers/scheduled_maintenance.py 74 delete '/{schedule_id}' cancel_scheduled
|
||||
backend/app/routers/search.py 88 get '' search
|
||||
backend/app/routers/services.py 22 get '' list_services
|
||||
backend/app/routers/services.py 58 post '/{name}/start' start_service
|
||||
backend/app/routers/services.py 63 post '/{name}/stop' stop_service
|
||||
backend/app/routers/services.py 68 post '/{name}/restart' restart_service
|
||||
backend/app/routers/settings.py 19 get '' read
|
||||
backend/app/routers/settings.py 27 put '' update
|
||||
backend/app/routers/system.py 9 get '/version-check' version_check
|
||||
backend/app/routers/users.py 25 get '' list_users
|
||||
backend/app/routers/users.py 31 post '' create_user
|
||||
backend/app/routers/users.py 46 patch '/{user_id}/role' update_role
|
||||
backend/app/routers/users.py 58 post '/{user_id}/reset-2fa' reset_two_factor
|
||||
backend/app/routers/users.py 72 post '/{user_id}/reset-password' reset_password
|
||||
backend/app/routers/users.py 82 delete '/{user_id}' delete_user
|
||||
backend/app/routers/wireguard.py 97 get '' list_tunnels
|
||||
backend/app/routers/wireguard.py 104 post '' create_tunnel
|
||||
backend/app/routers/wireguard.py 126 put '/{tunnel_id}' edit_tunnel
|
||||
backend/app/routers/wireguard.py 150 get '/{tunnel_id}/preview' preview_tunnel
|
||||
backend/app/routers/wireguard.py 155 get '/{tunnel_id}/revisions' list_revisions
|
||||
backend/app/routers/wireguard.py 165 post '/{tunnel_id}/deploy' deploy
|
||||
backend/app/routers/wireguard.py 185 delete '/{tunnel_id}' delete_tunnel
|
||||
backend/app/routers/wireguard.py 199 post '/{tunnel_id}/deployments/{host_id}/remove' remove_previous_target
|
||||
|
File diff suppressed because it is too large.
Load diff
@@ -10,19 +10,21 @@ import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { api, AliasOption, Host, HostBackup, LiveRule, PolicyAlias, RestoreOperation, Rule } from "@/lib/api";
|
||||
import { ArrowLeft, Download, ExternalLink, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { ArrowLeft, Download, ExternalLink, GitCompare, RotateCcw, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { BackupDiffModal } from "@/components/BackupDiffModal";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Collapsible } from "@/components/ui/Collapsible";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
|
||||
export default function HostDetailPage() {
|
||||
const { t, ts, errorMessage, locale } = useLanguage();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
// The raw config.xml contains secrets: its download is admin-only on the server too.
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const [diffTarget, setDiffTarget] = useState<{ older: string; newer: string } | null>(null);
|
||||
const [host, setHost] = useState<Host | null>(null);
|
||||
const [rules, setRules] = useState<Rule[] | null>(null);
|
||||
const [liveRules, setLiveRules] = useState<LiveRule[] | null>(null);
|
||||
@@ -391,11 +393,17 @@ export default function HostDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.map((backup) => (
|
||||
{backups.map((backup, index) => (
|
||||
<tr key={backup.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-3 text-slate-900">{new Date(backup.created_at).toLocaleString(locale)}</td>
|
||||
<td className="px-5 py-3 text-slate-500">{formatSize(backup.size)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
{index + 1 < backups.length && (
|
||||
<Button variant="ghost" onClick={() => setDiffTarget({ older: backups[index + 1].id, newer: backup.id })}>
|
||||
<GitCompare size={14} />
|
||||
{t("hostDetail.changes")}
|
||||
</Button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button variant="ghost" onClick={() => handleDownloadBackup(backup.id)}>
|
||||
<Download size={14} />
|
||||
@@ -447,6 +455,7 @@ export default function HostDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{diffTarget && host && <BackupDiffModal hostId={host.id} older={diffTarget.older} newer={diffTarget.newer} onClose={() => setDiffTarget(null)} />}
|
||||
{restoreTarget && (
|
||||
<RestoreWizard
|
||||
hostId={id}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, Host, HostCreate, HostUpdate, PolicyHierarchyGroup } from "@/lib/api";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { ExternalLink, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -29,7 +29,7 @@ const emptyForm: HostCreate = {
|
||||
};
|
||||
|
||||
export default function HostsPage() {
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const toast = useToast();
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const [hosts, setHosts] = useState<Host[] | null>(null);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, ComplianceFinding, ComplianceHost, RuleHit, Severity } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { CheckCircle2, CircleHelp, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SEVERITY_TONE: Record<Severity, "danger" | "warning" | "info" | "neutral"> = {
|
||||
critical: "danger", high: "danger", medium: "warning", low: "info", info: "neutral",
|
||||
};
|
||||
const SEVERITY_RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
const GRADE_TONE = { good: "success", fair: "warning", poor: "danger" } as const;
|
||||
|
||||
function ScoreBadge({ host }: { host: ComplianceHost }) {
|
||||
const { t } = useLanguage();
|
||||
if (host.score === null || host.grade === null) return <Badge tone="neutral">{host.error ? t(`security.error.${host.error}`) : t("security.notChecked")}</Badge>;
|
||||
return <Badge tone={GRADE_TONE[host.grade]}>{host.score} / 100</Badge>;
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const toast = useToast();
|
||||
const [hosts, setHosts] = useState<ComplianceHost[] | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ComplianceHost | null>(null);
|
||||
const [hits, setHits] = useState<RuleHit[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const list = await api.listCompliance();
|
||||
setHosts(list);
|
||||
setSelected((current) => current ?? [...list].filter((h) => h.score !== null).sort((a, b) => (a.score ?? 0) - (b.score ?? 0))[0]?.host_id ?? list[0]?.host_id ?? null);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "security.loadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) { setDetail(null); return; }
|
||||
let active = true;
|
||||
api.getRuleHits(selected).then((h) => active && setHits(h)).catch(() => active && setHits([]));
|
||||
api.getHostCompliance(selected).then((d) => active && setDetail(d)).catch((e) => active && setError(errorMessage(e, "security.loadFailed")));
|
||||
return () => { active = false; };
|
||||
}, [selected, hosts]);
|
||||
|
||||
async function runCheck(all: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (all || !selected) await api.checkFleetCompliance();
|
||||
else await api.checkHostCompliance(selected);
|
||||
await load();
|
||||
toast.show(t("security.checked"), "success");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "security.checkFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const findings = [...(detail?.findings ?? [])].sort(
|
||||
(a, b) => Number(a.passed === true) - Number(b.passed === true) || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
|
||||
{t("security.title")}
|
||||
<InfoTip text={t("security.intro")} size={18} />
|
||||
</h1>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
|
||||
<RefreshCw size={16} />
|
||||
{t("security.checkAll")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{hosts === null && <Skeleton className="h-24 w-full" />}
|
||||
{hosts?.length === 0 && <EmptyState icon={ShieldAlert} title={t("security.noHosts")} description={t("security.noHostsHint")} />}
|
||||
|
||||
{hosts && hosts.length > 0 && (
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{hosts.map((host) => (
|
||||
<button
|
||||
key={host.host_id}
|
||||
type="button"
|
||||
onClick={() => setSelected(host.host_id)}
|
||||
aria-pressed={selected === host.host_id}
|
||||
className={`rounded-2xl border bg-white p-4 text-left transition-colors hover:border-slate-400 ${selected === host.host_id ? "border-slate-900 ring-1 ring-slate-900" : "border-slate-200"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-slate-900">{host.host_name}</span>
|
||||
<ScoreBadge host={host} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
|
||||
{host.counts && (["critical", "high", "medium", "low"] as Severity[]).filter((s) => host.counts![s] > 0).map((s) => (
|
||||
<Badge key={s} tone={SEVERITY_TONE[s]}>{host.counts![s]} {t(`security.severity.${s}`)}</Badge>
|
||||
))}
|
||||
{host.counts && Object.entries(host.counts).every(([k, v]) => k === "info" || v === 0) && host.score !== null && (
|
||||
<span className="text-slate-500">{t("security.allGood")}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{host.checked_at ? t("security.checkedAt", { when: new Date(host.checked_at).toLocaleString(locale) }) : t("security.neverChecked")}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<Card className="p-0">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-5 py-3">
|
||||
<h2 className="font-medium text-slate-900">{detail.host_name}</h2>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button variant="outline" onClick={() => runCheck(false)} disabled={busy}>
|
||||
<RefreshCw size={14} />
|
||||
{t("security.checkHost")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
{detail.error && <p className="px-5 py-4 text-sm text-red-600">{t(`security.errorHint.${detail.error}`)}</p>}
|
||||
{!detail.error && findings.length === 0 && <p className="px-5 py-6 text-sm text-slate-500">{t("security.neverChecked")}</p>}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{findings.map((finding) => (
|
||||
<FindingRow key={finding.id} finding={finding} />
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{detail && hits.length > 0 && (
|
||||
<Card className="mt-6 p-0">
|
||||
<div className="border-b border-slate-100 px-5 py-3">
|
||||
<h2 className="flex items-center gap-2 font-medium text-slate-900">
|
||||
{t("security.hits.title")}
|
||||
<InfoTip text={t("security.hits.intro")} />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">{t("security.hits.rule")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("security.hits.state")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("security.hits.lastHit")}</th>
|
||||
<th className="px-5 py-3 text-right font-medium">{t("security.hits.packets")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hits.map((hit) => (
|
||||
<tr key={hit.rule_uuid} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-2 text-slate-900">{hit.description || hit.rule_uuid}</td>
|
||||
<td className="px-5 py-2">
|
||||
<Badge tone={hit.state === "unused" ? "warning" : hit.state === "used" ? "success" : "neutral"}>{t(`security.hits.states.${hit.state}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-2 text-slate-500">{hit.last_hit_at ? new Date(hit.last_hit_at).toLocaleString(locale) : <span className="text-slate-300">—</span>}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums text-slate-500">{hit.packets.toLocaleString(locale)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingRow({ finding }: { finding: ComplianceFinding }) {
|
||||
const { t } = useLanguage();
|
||||
const failed = finding.passed === false;
|
||||
// A note (severity "info") is a suggestion, not an error: neutral icon instead of the red cross.
|
||||
const note = failed && finding.severity === "info";
|
||||
const Icon = finding.passed === null ? CircleHelp : note ? Info : failed ? XCircle : CheckCircle2;
|
||||
const color = finding.passed === null || note ? "text-slate-400" : failed ? "text-red-600" : "text-emerald-600";
|
||||
const { examples = [], count = 0, reason, protocol, age_days } = finding.params;
|
||||
const vars = { count, reason: reason ? t(`security.reason.${reason}`) : "", protocol: protocol ?? "", days: age_days ?? "" };
|
||||
return (
|
||||
<li className="flex gap-3 px-5 py-3">
|
||||
<Icon size={18} className={`mt-0.5 shrink-0 ${color}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{t(`security.checks.${finding.id}.title`)}</span>
|
||||
{failed && <Badge tone={SEVERITY_TONE[finding.severity]}>{t(`security.severity.${finding.severity}`)}</Badge>}
|
||||
<span className="sr-only">{finding.passed === null ? t("security.unknown") : failed ? t("security.failed") : t("security.passed")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{t(`security.checks.${finding.id}.${failed ? "fail" : finding.passed === null ? "unknown" : "ok"}`, vars)}</p>
|
||||
{failed && examples.length > 0 && (
|
||||
<ul className="mt-1 list-disc pl-5 text-sm text-slate-700">
|
||||
{examples.map((example, i) => <li key={i} className="break-words">{example}</li>)}
|
||||
{count > examples.length && <li className="list-none text-slate-400">{t("security.andMore", { n: count - examples.length })}</li>}
|
||||
</ul>
|
||||
)}
|
||||
{failed && <p className="mt-1 text-sm text-slate-500">{t(`security.checks.${finding.id}.fix`)}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { useTheme } from "@/components/ThemeProvider";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Select } from "@/components/ui/Field";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin, useReadOnly } from "@/lib/useReadOnly";
|
||||
import { BackupSettingsSection } from "@/components/BackupSettingsSection";
|
||||
import { AuditExportSection } from "@/components/AuditExportSection";
|
||||
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
|
||||
import { translate } from "@/lib/i18n";
|
||||
|
||||
@@ -29,7 +31,7 @@ function SetupAssistantSection() {
|
||||
|
||||
function NotificationsSection() {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const [ns, setNs] = useState<NotificationSettings | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -61,6 +63,7 @@ function NotificationsSection() {
|
||||
const [networkDrift, setNetworkDrift] = useState(true);
|
||||
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
||||
const [scheduledFailed, setScheduledFailed] = useState(true);
|
||||
const [backupFailed, setBackupFailed] = useState(true);
|
||||
const [pollInterval, setPollInterval] = useState(300);
|
||||
|
||||
function applyFromServer(data: NotificationSettings) {
|
||||
@@ -84,6 +87,7 @@ function NotificationsSection() {
|
||||
setNetworkDrift(data.network_drift_enabled);
|
||||
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
||||
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
|
||||
setBackupFailed(data.backup_failed_enabled);
|
||||
setPollInterval(data.poll_interval_seconds);
|
||||
setWebhookSecret("");
|
||||
setClearWebhookSecret(false);
|
||||
@@ -125,6 +129,7 @@ function NotificationsSection() {
|
||||
network_drift_enabled: networkDrift,
|
||||
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
||||
scheduled_maintenance_failed_enabled: scheduledFailed,
|
||||
backup_failed_enabled: backupFailed,
|
||||
poll_interval_seconds: pollInterval,
|
||||
});
|
||||
applyFromServer(data);
|
||||
@@ -257,6 +262,7 @@ function NotificationsSection() {
|
||||
[t("settings.notify.event.networkDrift"), networkDrift, setNetworkDrift],
|
||||
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
||||
[t("settings.notify.event.scheduledFailed"), scheduledFailed, setScheduledFailed],
|
||||
[t("settings.notify.event.backupFailed"), backupFailed, setBackupFailed],
|
||||
].map(([label, value, setter]) => (
|
||||
<label key={label as string} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
|
||||
@@ -310,7 +316,7 @@ function LogUsage({ used, entries, limitGb, oldest }: { used: number; entries: n
|
||||
|
||||
function LogIngestSection() {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const [ls, setLs] = useState<LogSettings | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -396,5 +402,5 @@ export default function SettingsPage(){
|
||||
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
|
||||
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><BackupSettingsSection/><AuditExportSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { FieldGroup, Input, Select } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, ApiToken, TokenScope } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SCOPE_TONE: Record<TokenScope, "neutral" | "info" | "warning"> = { read: "neutral", deploy: "info", admin: "warning" };
|
||||
|
||||
export default function TokensPage() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [tokens, setTokens] = useState<ApiToken[] | null>(null);
|
||||
const [role, setRole] = useState<string>("read_only");
|
||||
const isAdmin = role === "admin";
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [created, setCreated] = useState<(ApiToken & { token: string }) | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [list, me] = await Promise.all([api.listApiTokens(), api.me()]);
|
||||
setTokens(list);
|
||||
setRole(me.role ?? "read_only");
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "tokens.loadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function handleRevoke(token: ApiToken) {
|
||||
if (!confirm(t("tokens.revokeConfirm", { name: token.name }))) return;
|
||||
try {
|
||||
await api.revokeApiToken(token.id);
|
||||
await load();
|
||||
toast.show(t("tokens.revoked", { name: token.name }), "success");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "common.deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const when = (value: string | null) => (value ? new Date(value).toLocaleString(locale) : null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold text-slate-900">
|
||||
{t("tokens.title")}
|
||||
<InfoTip text={t("tokens.intro")} size={18} />
|
||||
</h1>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus size={16} />
|
||||
{t("tokens.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<Card className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">{t("tokens.colName")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("tokens.colScope")}</th>
|
||||
{isAdmin && <th className="px-5 py-3 font-medium">{t("tokens.colOwner")}</th>}
|
||||
<th className="px-5 py-3 font-medium">{t("tokens.colExpires")}</th>
|
||||
<th className="px-5 py-3 font-medium">{t("tokens.colLastUsed")}</th>
|
||||
<th className="px-5 py-3 font-medium"><span className="sr-only">{t("common.actions")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens === null &&
|
||||
[0, 1].map((i) => (
|
||||
<tr key={i} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-3"><Skeleton className="h-4 w-28" /></td>
|
||||
<td className="px-5 py-3"><Skeleton className="h-4 w-14 rounded-full" /></td>
|
||||
<td className="px-5 py-3"><Skeleton className="h-4 w-24" /></td>
|
||||
<td className="px-5 py-3"><Skeleton className="h-4 w-24" /></td>
|
||||
<td className="px-5 py-3" />
|
||||
</tr>
|
||||
))}
|
||||
{tokens?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-8 text-center text-slate-500">{t("tokens.empty")}</td>
|
||||
</tr>
|
||||
)}
|
||||
{tokens?.map((token) => (
|
||||
<tr key={token.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-slate-900">{token.name}</div>
|
||||
<div className="font-mono text-xs text-slate-400">{token.prefix}…</div>
|
||||
</td>
|
||||
<td className="px-5 py-3"><Badge tone={SCOPE_TONE[token.scope]}>{t(`tokens.scope.${token.scope}`)}</Badge></td>
|
||||
{isAdmin && <td className="px-5 py-3 text-slate-600">{token.owner}</td>}
|
||||
<td className="px-5 py-3 text-slate-500">{when(token.expires_at) ?? t("tokens.never")}</td>
|
||||
<td className="px-5 py-3 text-slate-500">
|
||||
{when(token.last_used_at) ?? <span className="text-slate-300">{t("tokens.neverUsed")}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Button variant="dangerSubtle" onClick={() => handleRevoke(token)}>
|
||||
<Trash2 size={14} />
|
||||
{t("tokens.revoke")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{showCreate && (
|
||||
<CreateTokenModal
|
||||
role={role}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={async (token) => {
|
||||
setShowCreate(false);
|
||||
setCreated(token);
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{created && <TokenCreatedModal token={created} onClose={() => setCreated(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTokenModal({
|
||||
role,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
role: string;
|
||||
onClose: () => void;
|
||||
onCreated: (token: ApiToken & { token: string }) => void;
|
||||
}) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const [name, setName] = useState("");
|
||||
const [scope, setScope] = useState<TokenScope>("read");
|
||||
const [days, setDays] = useState("90");
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await api.createApiToken({
|
||||
name: name.trim(),
|
||||
scope,
|
||||
expires_in_days: days === "never" ? null : Number(days),
|
||||
current_password: password,
|
||||
});
|
||||
onCreated(token);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "tokens.createFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("tokens.new")} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FieldGroup label={t("tokens.colName")} hint={t("tokens.nameHint")}>
|
||||
<Input required maxLength={80} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("tokens.colScope")} hint={t(`tokens.scopeHint.${scope}`)}>
|
||||
<Select value={scope} onChange={(e) => setScope(e.target.value as TokenScope)}>
|
||||
<option value="read">{t("tokens.scope.read")}</option>
|
||||
{(role === "admin" || role === "operator") && <option value="deploy">{t("tokens.scope.deploy")}</option>}
|
||||
{role === "admin" && <option value="admin">{t("tokens.scope.admin")}</option>}
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("tokens.colExpires")}>
|
||||
<Select value={days} onChange={(e) => setDays(e.target.value)}>
|
||||
<option value="30">{t("tokens.days", { n: 30 })}</option>
|
||||
<option value="90">{t("tokens.days", { n: 90 })}</option>
|
||||
<option value="365">{t("tokens.days", { n: 365 })}</option>
|
||||
<option value="never">{t("tokens.never")}</option>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label={t("tokens.password")} hint={t("tokens.passwordHint")}>
|
||||
<Input required type="password" autoComplete="current-password" maxLength={1024} value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</FieldGroup>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} loading={saving}>
|
||||
{saving ? t("common.saving") : t("tokens.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCreatedModal({ token, onClose }: { token: ApiToken & { token: string }; onClose: () => void }) {
|
||||
const { t } = useLanguage();
|
||||
const toast = useToast();
|
||||
const example = `curl -H "Authorization: Bearer ${token.token}" ${typeof window === "undefined" ? "" : window.location.origin}/api/hosts`;
|
||||
|
||||
function copy(text: string) {
|
||||
void navigator.clipboard?.writeText(text);
|
||||
toast.show(t("tokens.copied"), "success");
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("tokens.createdTitle", { name: token.name })} onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-amber-700">{t("tokens.shownOnce")}</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="flex-1 break-all rounded-md border border-slate-200 bg-slate-50 p-2 font-mono text-xs text-slate-900">{token.token}</code>
|
||||
<Button type="button" variant="outline" onClick={() => copy(token.token)}>
|
||||
<Copy size={14} />
|
||||
{t("tokens.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-slate-500">{t("tokens.example")}</p>
|
||||
<code className="block break-all rounded-md border border-slate-200 bg-slate-50 p-2 font-mono text-xs text-slate-700">{example}</code>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={onClose}>{t("tokens.done")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -10,18 +10,19 @@ import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, User, UserRole } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function RoleBadge({ role }: { role: UserRole }) {
|
||||
const { t } = useLanguage();
|
||||
return <Badge tone={role === "admin" ? "info" : "neutral"}>{role === "admin" ? t("users.roleAdmin") : t("users.roleReadOnly")}</Badge>;
|
||||
const label = role === "admin" ? t("users.roleAdmin") : role === "operator" ? t("users.roleOperator") : t("users.roleReadOnly");
|
||||
return <Badge tone={role === "admin" ? "info" : role === "operator" ? "accent" : "neutral"}>{label}</Badge>;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
const [ownUsername, setOwnUsername] = useState<string | null>(null);
|
||||
@@ -207,6 +208,7 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
|
||||
<FieldGroup label={t("users.colRole")}>
|
||||
<Select value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
<option value="read_only">{t("users.roleReadOnly")}</option>
|
||||
<option value="operator">{t("users.roleOperator")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
@@ -256,6 +258,7 @@ function EditUserModal({ user, onClose, onSaved }: { user: User; onClose: () =>
|
||||
<FieldGroup label={t("users.colRole")}>
|
||||
<Select value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
<option value="read_only">{t("users.roleReadOnly")}</option>
|
||||
<option value="operator">{t("users.roleOperator")}</option>
|
||||
<option value="admin">{t("users.roleAdmin")}</option>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Select } from "@/components/ui/Field";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, AuditExportSettings } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Forwarding of the audit trail to syslog and/or a webhook. Admin only: renders nothing for other roles. */
|
||||
export function AuditExportSection() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [cfg, setCfg] = useState<AuditExportSettings | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [syslogEnabled, setSyslogEnabled] = useState(false);
|
||||
const [syslogHost, setSyslogHost] = useState("");
|
||||
const [syslogPort, setSyslogPort] = useState(514);
|
||||
const [syslogProtocol, setSyslogProtocol] = useState<AuditExportSettings["syslog_protocol"]>("udp");
|
||||
const [webhookEnabled, setWebhookEnabled] = useState(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [clearSecret, setClearSecret] = useState(false);
|
||||
|
||||
function apply(data: AuditExportSettings) {
|
||||
setCfg(data);
|
||||
setSyslogEnabled(data.syslog_enabled);
|
||||
setSyslogHost(data.syslog_host ?? "");
|
||||
setSyslogPort(data.syslog_port);
|
||||
setSyslogProtocol(data.syslog_protocol);
|
||||
setWebhookEnabled(data.webhook_enabled);
|
||||
setWebhookUrl(data.webhook_url ?? "");
|
||||
setSecret("");
|
||||
setClearSecret(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getAuditExport().then(apply).catch(() => setHidden(true));
|
||||
}, []);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
apply(
|
||||
await api.updateAuditExport({
|
||||
syslog_enabled: syslogEnabled,
|
||||
syslog_host: syslogHost,
|
||||
syslog_port: syslogPort,
|
||||
syslog_protocol: syslogProtocol,
|
||||
webhook_enabled: webhookEnabled,
|
||||
webhook_url: webhookUrl,
|
||||
...(clearSecret ? { clear_webhook_signing_secret: true } : secret ? { webhook_signing_secret: secret } : {}),
|
||||
}),
|
||||
);
|
||||
toast.show(t("auditExport.saved"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.testAuditExport();
|
||||
toast.show(t("auditExport.testSent"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "auditExport.testFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="audit-export" className="scroll-mt-4 space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
{t("auditExport.title")}
|
||||
<InfoTip text={t("auditExport.intro")} />
|
||||
</h2>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={syslogEnabled} onChange={(e) => setSyslogEnabled(e.target.checked)} />
|
||||
{t("auditExport.syslog")}
|
||||
</label>
|
||||
<fieldset disabled={!syslogEnabled} className="grid grid-cols-6 gap-3 disabled:opacity-50">
|
||||
<label className="col-span-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">{t("auditExport.host")}</span>
|
||||
<Input value={syslogHost} onChange={(e) => setSyslogHost(e.target.value)} placeholder="syslog.example" />
|
||||
</label>
|
||||
<label className="col-span-1 block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">{t("auditExport.port")}</span>
|
||||
<Input type="number" min={1} max={65535} value={syslogPort} onChange={(e) => setSyslogPort(Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="col-span-2 block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">{t("auditExport.protocol")}</span>
|
||||
<Select value={syslogProtocol} onChange={(e) => setSyslogProtocol(e.target.value as AuditExportSettings["syslog_protocol"])}>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="tls">TLS</option>
|
||||
</Select>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={webhookEnabled} onChange={(e) => setWebhookEnabled(e.target.checked)} />
|
||||
{t("auditExport.webhook")}
|
||||
</label>
|
||||
<fieldset disabled={!webhookEnabled} className="space-y-3 disabled:opacity-50">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">{t("auditExport.webhookUrl")}</span>
|
||||
<Input value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} placeholder="https://…" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">
|
||||
{t("auditExport.secret", { state: cfg?.webhook_signing_secret_set && !clearSecret ? t("auditExport.isSet") : t("auditExport.notSet") })}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={secret}
|
||||
onChange={(e) => { setSecret(e.target.value); setClearSecret(false); }}
|
||||
placeholder={cfg?.webhook_signing_secret_set ? t("auditExport.keep") : t("auditExport.optional")}
|
||||
/>
|
||||
</label>
|
||||
{cfg?.webhook_signing_secret_set && (
|
||||
<label className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<input type="checkbox" checked={clearSecret} onChange={(e) => { setClearSecret(e.target.checked); if (e.target.checked) setSecret(""); }} />
|
||||
{t("auditExport.removeSecret")}
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{cfg?.last_error && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{t("auditExport.lastError", { when: cfg.last_error_at ? new Date(cfg.last_error_at).toLocaleString(locale) : "", message: cfg.last_error })}
|
||||
</p>
|
||||
)}
|
||||
{!cfg?.last_error && cfg?.last_success_at && (
|
||||
<p className="text-sm text-slate-500">{t("auditExport.lastSuccess", { when: new Date(cfg.last_success_at).toLocaleString(locale) })}</p>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={busy || !cfg} loading={busy}>{t("common.save")}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy || !cfg || !(cfg.syslog_enabled || cfg.webhook_enabled)} onClick={test}>
|
||||
{t("auditExport.test")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { api, BackupDiff } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** What changed between two stored backups of a host. Secrets arrive already replaced by hashes. */
|
||||
export function BackupDiffModal({ hostId, older, newer, onClose }: { hostId: string; older: string; newer: string; onClose: () => void }) {
|
||||
const { t, errorMessage } = useLanguage();
|
||||
const [diff, setDiff] = useState<BackupDiff | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getBackupDiff(hostId, older, newer).then(setDiff).catch((e) => setError(errorMessage(e, "backupDiff.failed")));
|
||||
}, [hostId, older, newer]);
|
||||
|
||||
return (
|
||||
<Modal title={t("backupDiff.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">{t("backupDiff.hint")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
{!diff && !error && <Skeleton className="h-24 w-full" />}
|
||||
{diff?.identical && <p className="text-sm text-slate-700">{t("backupDiff.identical")}</p>}
|
||||
{diff && !diff.identical && (
|
||||
<>
|
||||
<p className="text-sm text-slate-700">{t("backupDiff.summary", { added: diff.added, removed: diff.removed })}</p>
|
||||
<pre className="max-h-[50vh] overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2 font-mono text-xs leading-5">
|
||||
{diff.lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
line.startsWith("+") && !line.startsWith("+++") ? "bg-emerald-500/15 text-emerald-800"
|
||||
: line.startsWith("-") && !line.startsWith("---") ? "bg-red-500/15 text-red-800"
|
||||
: line.startsWith("@@") ? "text-sky-700" : "text-slate-600"
|
||||
}
|
||||
>
|
||||
{line || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
{diff.truncated && <p className="text-sm text-amber-700">{t("backupDiff.truncated")}</p>}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button onClick={onClose}>{t("tokens.done")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Select } from "@/components/ui/Field";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { api, BackupSettings } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Scheduled backups, retention and the encrypted offsite copy. Admin only: renders nothing for other roles. */
|
||||
export function BackupSettingsSection() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const toast = useToast();
|
||||
const [cfg, setCfg] = useState<BackupSettings | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState<"save" | "test" | "run" | null>(null);
|
||||
const [scheduled, setScheduled] = useState(true);
|
||||
const [interval, setIntervalHours] = useState(24);
|
||||
const [keep, setKeep] = useState(30);
|
||||
const [offsite, setOffsite] = useState(false);
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [region, setRegion] = useState("us-east-1");
|
||||
const [bucket, setBucket] = useState("");
|
||||
const [prefix, setPrefix] = useState("opnexus");
|
||||
const [accessKey, setAccessKey] = useState("");
|
||||
const [pathStyle, setPathStyle] = useState(true);
|
||||
const [secretKey, setSecretKey] = useState("");
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
|
||||
function apply(d: BackupSettings) {
|
||||
setCfg(d);
|
||||
setScheduled(d.scheduled_enabled);
|
||||
setIntervalHours(d.interval_hours);
|
||||
setKeep(d.keep_count);
|
||||
setOffsite(d.offsite_enabled);
|
||||
setEndpoint(d.s3_endpoint ?? "");
|
||||
setRegion(d.s3_region);
|
||||
setBucket(d.s3_bucket ?? "");
|
||||
setPrefix(d.s3_prefix);
|
||||
setAccessKey(d.s3_access_key ?? "");
|
||||
setPathStyle(d.s3_path_style);
|
||||
setSecretKey("");
|
||||
setPassphrase("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getBackupSettings().then(apply).catch(() => setHidden(true));
|
||||
}, []);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
async function save(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
setBusy("save");
|
||||
setError("");
|
||||
try {
|
||||
apply(
|
||||
await api.updateBackupSettings({
|
||||
scheduled_enabled: scheduled, interval_hours: interval, keep_count: keep, offsite_enabled: offsite,
|
||||
s3_endpoint: endpoint, s3_region: region, s3_bucket: bucket, s3_prefix: prefix, s3_access_key: accessKey, s3_path_style: pathStyle,
|
||||
...(secretKey ? { s3_secret_key: secretKey } : {}), ...(passphrase ? { passphrase } : {}),
|
||||
}),
|
||||
);
|
||||
toast.show(t("backupSettings.saved"), "success");
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
if (!(await save())) return;
|
||||
setBusy("test");
|
||||
try {
|
||||
await api.testBackupOffsite();
|
||||
toast.show(t("backupSettings.testOk"), "success");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "backupSettings.testFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
setBusy("run");
|
||||
setError("");
|
||||
try {
|
||||
const r = await api.runBackupsNow();
|
||||
toast.show(t("backupSettings.ran", { taken: r.taken, failed: r.failed, uploaded: r.uploaded }), r.failed ? "danger" : "success");
|
||||
apply(await api.getBackupSettings());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "common.saveFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const label = (key: string) => <span className="mb-1 block font-medium text-slate-700">{t(key)}</span>;
|
||||
|
||||
return (
|
||||
<section id="backups" className="scroll-mt-4 space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
{t("backupSettings.title")}
|
||||
<InfoTip text={t("backupSettings.intro")} />
|
||||
</h2>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={scheduled} onChange={(e) => setScheduled(e.target.checked)} />
|
||||
{t("backupSettings.scheduled")}
|
||||
</label>
|
||||
<fieldset disabled={!scheduled} className="grid grid-cols-2 gap-3 disabled:opacity-50">
|
||||
<label className="block text-sm">
|
||||
{label("backupSettings.interval")}
|
||||
<Select value={interval} onChange={(e) => setIntervalHours(Number(e.target.value))}>
|
||||
{[6, 12, 24, 48, 168].map((h) => <option key={h} value={h}>{t("backupSettings.everyHours", { n: h })}</option>)}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{label("backupSettings.keep")}
|
||||
<Input type="number" min={3} max={1000} value={keep} onChange={(e) => setKeep(Number(e.target.value))} />
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-slate-100 p-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={offsite} onChange={(e) => setOffsite(e.target.checked)} />
|
||||
{t("backupSettings.offsite")}
|
||||
</label>
|
||||
<fieldset disabled={!offsite} className="space-y-3 disabled:opacity-50">
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<label className="col-span-4 block text-sm">{label("backupSettings.endpoint")}<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="https://s3.example.com" /></label>
|
||||
<label className="col-span-2 block text-sm">{label("backupSettings.region")}<Input value={region} onChange={(e) => setRegion(e.target.value)} /></label>
|
||||
<label className="col-span-3 block text-sm">{label("backupSettings.bucket")}<Input value={bucket} onChange={(e) => setBucket(e.target.value)} /></label>
|
||||
<label className="col-span-3 block text-sm">{label("backupSettings.prefix")}<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} /></label>
|
||||
<label className="col-span-3 block text-sm">{label("backupSettings.accessKey")}<Input value={accessKey} onChange={(e) => setAccessKey(e.target.value)} autoComplete="off" /></label>
|
||||
<label className="col-span-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">{t("backupSettings.secretKey", { state: cfg?.s3_secret_set ? t("auditExport.isSet") : t("auditExport.notSet") })}</span>
|
||||
<Input type="password" value={secretKey} onChange={(e) => setSecretKey(e.target.value)} placeholder={cfg?.s3_secret_set ? t("auditExport.keep") : ""} autoComplete="new-password" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" checked={pathStyle} onChange={(e) => setPathStyle(e.target.checked)} />
|
||||
{t("backupSettings.pathStyle")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-slate-700">
|
||||
{t("backupSettings.passphrase", { state: cfg?.passphrase_set ? t("auditExport.isSet") : t("auditExport.notSet") })}
|
||||
<InfoTip text={t("backupSettings.passphraseHint")} className="ml-1 align-middle" />
|
||||
</span>
|
||||
<Input type="password" minLength={12} value={passphrase} onChange={(e) => setPassphrase(e.target.value)} placeholder={cfg?.passphrase_set ? t("auditExport.keep") : t("backupSettings.passphraseNew")} autoComplete="new-password" />
|
||||
</label>
|
||||
{cfg?.last_offsite_error && <p role="alert" className="text-sm text-red-600">{t("backupSettings.offsiteError", { message: cfg.last_offsite_error })}</p>}
|
||||
{!cfg?.last_offsite_error && cfg?.last_offsite_ok_at && <p className="text-sm text-slate-500">{t("backupSettings.offsiteOk", { when: new Date(cfg.last_offsite_ok_at).toLocaleString(locale) })}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{cfg?.last_run_at && <p className="text-sm text-slate-500">{t("backupSettings.lastRun", { when: new Date(cfg.last_run_at).toLocaleString(locale) })}</p>}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" disabled={busy !== null || !cfg} loading={busy === "save"}>{t("common.save")}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy !== null || !cfg || !offsite} loading={busy === "test"} onClick={test}>{t("backupSettings.test")}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy !== null || !cfg} loading={busy === "run"} onClick={runNow}>{t("backupSettings.runNow")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
Users,
|
||||
@@ -121,6 +122,7 @@ export const navGroups: NavGroup[] = [
|
||||
icon: ScrollText,
|
||||
items: [
|
||||
{ href: "/logs", label: "Logs", icon: ScrollText, keywords: ["Firewall-Logs", "Geblockt", "Blocked", "Top Ports"] },
|
||||
{ href: "/security", label: "Sicherheitslage", icon: ShieldAlert, keywords: ["Compliance", "Härtung", "Score", "Regel-Hygiene", "Any-Any", "SSH", "Sicherheit", "Prüfung"] },
|
||||
{ href: "/audit", label: "Audit", icon: ScrollText, keywords: ["Deploy-Historie", "Protokoll", "Verlauf"] },
|
||||
],
|
||||
},
|
||||
@@ -163,6 +165,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: Users,
|
||||
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
|
||||
},
|
||||
{
|
||||
href: "/tokens",
|
||||
label: "API-Tokens",
|
||||
icon: KeyRound,
|
||||
keywords: ["API", "Token", "Bearer", "Prometheus", "Metriken", "Automatisierung", "CI", "Skript", "MCP", "Schlüssel"],
|
||||
},
|
||||
{
|
||||
href: "/setup",
|
||||
label: "Einrichtung",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Modal } from "@/components/ui/Modal";
|
||||
import { InfoTip } from "@/components/ui/Tooltip";
|
||||
import { api, UpdaterStatus } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -22,7 +22,7 @@ const RELOAD_DELAY_MS = 3500;
|
||||
*/
|
||||
export function OpnexusUpdateSection() {
|
||||
const { t, errorMessage, locale } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const [status, setStatus] = useState<UpdaterStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [confirm, setConfirm] = useState<"update" | "rollback" | null>(null);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { api, VersionCheck } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { useNotAdmin } from "@/lib/useReadOnly";
|
||||
import { ArrowUpCircle, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -25,7 +25,7 @@ function readDismissed(): string | null {
|
||||
*/
|
||||
export function UpdateBanner() {
|
||||
const { t } = useLanguage();
|
||||
const readOnly = useReadOnly();
|
||||
const readOnly = useNotAdmin();
|
||||
const [check, setCheck] = useState<VersionCheck | null>(null);
|
||||
const [dismissed, setDismissed] = useState<string | null>(null);
|
||||
|
||||
|
||||
+129
-2
@@ -433,6 +433,40 @@ export interface AliasOption {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BackupDiff {
|
||||
a: string;
|
||||
b: string;
|
||||
identical: boolean;
|
||||
added: number;
|
||||
removed: number;
|
||||
truncated: boolean;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
scheduled_enabled: boolean;
|
||||
interval_hours: number;
|
||||
keep_count: number;
|
||||
offsite_enabled: boolean;
|
||||
last_run_at: string | null;
|
||||
last_offsite_ok_at: string | null;
|
||||
last_offsite_error: string | null;
|
||||
s3_endpoint: string | null;
|
||||
s3_region: string;
|
||||
s3_bucket: string | null;
|
||||
s3_prefix: string;
|
||||
s3_access_key: string | null;
|
||||
s3_path_style: boolean;
|
||||
s3_secret_set: boolean;
|
||||
passphrase_set: boolean;
|
||||
}
|
||||
|
||||
export type BackupSettingsUpdate = Partial<Pick<BackupSettings, "scheduled_enabled" | "interval_hours" | "keep_count" | "offsite_enabled" | "s3_endpoint" | "s3_region" | "s3_bucket" | "s3_prefix" | "s3_access_key" | "s3_path_style">> & {
|
||||
s3_secret_key?: string;
|
||||
clear_s3_secret?: boolean;
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export interface HostBackup {
|
||||
id: string;
|
||||
created_at: string;
|
||||
@@ -662,7 +696,7 @@ export interface LogFilterOptions {
|
||||
protocols: string[];
|
||||
}
|
||||
|
||||
export type UserRole = "admin" | "read_only";
|
||||
export type UserRole = "admin" | "operator" | "read_only";
|
||||
|
||||
export interface AuthState {
|
||||
authenticated: boolean;
|
||||
@@ -670,6 +704,71 @@ export interface AuthState {
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
export type TokenScope = "read" | "deploy" | "admin";
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: TokenScope;
|
||||
prefix: string;
|
||||
owner: string;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
last_used_ip: string | null;
|
||||
}
|
||||
|
||||
export type Severity = "critical" | "high" | "medium" | "low" | "info";
|
||||
|
||||
export interface ComplianceFinding {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
passed: boolean | null;
|
||||
params: { count?: number; examples?: string[]; reason?: string; protocol?: string; age_days?: number | null };
|
||||
}
|
||||
|
||||
export interface RuleHit {
|
||||
rule_uuid: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
state: "used" | "unused" | "collecting" | "disabled";
|
||||
packets: number;
|
||||
bytes: number;
|
||||
last_hit_at: string | null;
|
||||
first_seen_at: string;
|
||||
}
|
||||
|
||||
export interface ComplianceHost {
|
||||
host_id: string;
|
||||
host_name: string;
|
||||
cluster_name: string | null;
|
||||
enabled: boolean;
|
||||
checked_at: string | null;
|
||||
score: number | null;
|
||||
grade: "good" | "fair" | "poor" | null;
|
||||
error: "unreachable" | "invalid_config" | null;
|
||||
counts: Record<Severity, number> | null;
|
||||
findings?: ComplianceFinding[];
|
||||
}
|
||||
|
||||
export interface AuditExportSettings {
|
||||
syslog_enabled: boolean;
|
||||
syslog_host: string | null;
|
||||
syslog_port: number;
|
||||
syslog_protocol: "udp" | "tcp" | "tls";
|
||||
webhook_enabled: boolean;
|
||||
webhook_url: string | null;
|
||||
webhook_signing_secret_set: boolean;
|
||||
last_success_at: string | null;
|
||||
last_error: string | null;
|
||||
last_error_at: string | null;
|
||||
}
|
||||
|
||||
export type AuditExportUpdate = Partial<Omit<AuditExportSettings, "webhook_signing_secret_set" | "last_success_at" | "last_error" | "last_error_at">> & {
|
||||
webhook_signing_secret?: string;
|
||||
clear_webhook_signing_secret?: boolean;
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -1103,6 +1202,9 @@ export const api = {
|
||||
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
logSettings: () => request<LogSettings>("/logs/settings"),
|
||||
saveLogSettings: (data: LogSettingsUpdate) => request<LogSettings>("/logs/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getAuditExport: () => request<AuditExportSettings>("/settings/audit-export"),
|
||||
updateAuditExport: (data: Partial<AuditExportUpdate>) => request<AuditExportSettings>("/settings/audit-export", { method: "PUT", body: JSON.stringify(data) }),
|
||||
testAuditExport: () => request<{ success: boolean }>("/settings/audit-export/test", { method: "POST" }),
|
||||
testNotification: (channel: "webhook" | "email") => request<{ success: boolean }>("/notifications/test", { method: "POST", body: JSON.stringify({ channel }) }),
|
||||
notificationProblems: (includeResolved = false) => request<NotificationProblem[]>(`/notifications/problems?include_resolved=${includeResolved}`),
|
||||
carpEvents: (clusterName?: string) => request<CarpEvent[]>(`/cluster/carp-events${clusterName ? `?cluster_name=${encodeURIComponent(clusterName)}` : ""}`),
|
||||
@@ -1203,6 +1305,16 @@ export const api = {
|
||||
getHostCas: (id: string) => request<HostCa[]>(`/hosts/${id}/cas`),
|
||||
listHostBackups: (id: string) => request<HostBackup[]>(`/hosts/${id}/backups`),
|
||||
triggerHostBackup: (id: string) => request<HostBackup>(`/hosts/${id}/backups`, { method: "POST" }),
|
||||
getBackupDiff: (id: string, a?: string, b?: string) => {
|
||||
const query = new URLSearchParams();
|
||||
if (a) query.set("a", a);
|
||||
if (b) query.set("b", b);
|
||||
return request<BackupDiff>(`/hosts/${id}/backup-diff${query.size ? `?${query}` : ""}`);
|
||||
},
|
||||
getBackupSettings: () => request<BackupSettings>("/settings/backups"),
|
||||
updateBackupSettings: (data: BackupSettingsUpdate) => request<BackupSettings>("/settings/backups", { method: "PUT", body: JSON.stringify(data) }),
|
||||
testBackupOffsite: () => request<{ success: boolean }>("/settings/backups/test-offsite", { method: "POST" }),
|
||||
runBackupsNow: () => request<{ hosts: number; taken: number; failed: number; pruned: number; uploaded: number }>("/backups/run", { method: "POST" }),
|
||||
downloadHostBackup: (id: string, backupId: string) => requestBlob(`/hosts/${id}/backups/${backupId}`),
|
||||
|
||||
previewRestore: (id: string, backupId: string) =>
|
||||
@@ -1286,6 +1398,17 @@ export const api = {
|
||||
request<User>(`/users/${id}/reset-password`, { method: "POST", body: JSON.stringify({ new_password }) }),
|
||||
deleteUser: (id: string) => request<void>(`/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
listCompliance: () => request<ComplianceHost[]>("/compliance"),
|
||||
getHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance`),
|
||||
getRuleHits: (id: string) => request<RuleHit[]>(`/hosts/${id}/rule-hits`),
|
||||
checkHostCompliance: (id: string) => request<ComplianceHost>(`/hosts/${id}/compliance/check`, { method: "POST" }),
|
||||
checkFleetCompliance: () => request<ComplianceHost[]>("/compliance/check", { method: "POST" }),
|
||||
|
||||
listApiTokens: () => request<ApiToken[]>("/tokens"),
|
||||
createApiToken: (data: { name: string; scope: TokenScope; expires_in_days: number | null; current_password: string }) =>
|
||||
request<ApiToken & { token: string }>("/tokens", { method: "POST", body: JSON.stringify(data) }),
|
||||
revokeApiToken: (id: string) => request<void>(`/tokens/${id}`, { method: "DELETE" }),
|
||||
|
||||
listPolicyHierarchyGroups: () => request<PolicyHierarchyGroup[]>("/policy-hierarchy/groups"),
|
||||
createPolicyHierarchyGroup: (data: PolicyHierarchyGroupWrite) => request<PolicyHierarchyGroup>("/policy-hierarchy/groups", { method: "POST", body: JSON.stringify(data) }),
|
||||
updatePolicyHierarchyGroup: (id: string, data: PolicyHierarchyGroupWrite) => request<PolicyHierarchyGroup>(`/policy-hierarchy/groups/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
@@ -1554,6 +1677,7 @@ export interface NotificationSettings {
|
||||
network_drift_enabled: boolean;
|
||||
carp_auto_revert_failed_enabled: boolean;
|
||||
scheduled_maintenance_failed_enabled: boolean;
|
||||
backup_failed_enabled: boolean;
|
||||
poll_interval_seconds: number;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -1582,6 +1706,7 @@ export interface NotificationSettingsUpdate {
|
||||
network_drift_enabled?: boolean;
|
||||
carp_auto_revert_failed_enabled?: boolean;
|
||||
scheduled_maintenance_failed_enabled?: boolean;
|
||||
backup_failed_enabled?: boolean;
|
||||
poll_interval_seconds?: number;
|
||||
}
|
||||
|
||||
@@ -1605,7 +1730,9 @@ export interface LogSettingsUpdate {
|
||||
export type ProblemType =
|
||||
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
|
||||
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift"
|
||||
| "scheduled_maintenance_failed";
|
||||
| "scheduled_maintenance_failed"
|
||||
| "backup_stale"
|
||||
| "backup_offsite_failed";
|
||||
|
||||
export interface CarpEvent {
|
||||
id: string;
|
||||
|
||||
+252
-3
@@ -64,6 +64,7 @@
|
||||
"natDrift": "NAT-Drift im Cluster",
|
||||
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
||||
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
|
||||
"backupFailed": "Kein aktuelles Backup / Offsite-Kopie fehlgeschlagen",
|
||||
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
||||
},
|
||||
"pollInterval": "Prüfintervall (Sekunden)",
|
||||
@@ -212,9 +213,11 @@
|
||||
"certificates": "Zertifikate",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit",
|
||||
"security": "Sicherheitslage",
|
||||
"updates": "Update Center",
|
||||
"settings": "Einstellungen",
|
||||
"users": "Benutzer",
|
||||
"tokens": "API-Tokens",
|
||||
"network-devices": "Geräte",
|
||||
"network-compare": "Abgleich",
|
||||
"hostsystem-tunables": "Tunables",
|
||||
@@ -579,7 +582,13 @@
|
||||
"UPDATER_REJECTED": "Der Updater hat die Anfrage abgelehnt ({reason}).",
|
||||
"UPDATE_NOT_OFFERED": "Diese Version wird nicht zur Installation angeboten — nur die neueste stabile Version.",
|
||||
"UPDATE_MAJOR_CHANGE": "Hauptversions-Sprünge bitte bewusst auf dem Server mit „opnexus update“ ausführen.",
|
||||
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“."
|
||||
"ADMIN_REQUIRED": "Diese Funktion erfordert die Rolle „Admin“.",
|
||||
"API_TOKEN_LIMIT_REACHED": "Es sind höchstens {limit} API-Tokens pro Benutzer möglich. Widerrufe zuerst nicht mehr benötigte Tokens.",
|
||||
"BACKUP_UNLESBAR": "Das Backup lässt sich nicht lesen: {reason}",
|
||||
"OFFSITE_TEST_FAILED": "Der Test des Offsite-Ziels ist fehlgeschlagen: {reason}",
|
||||
"API_TOKEN_NOT_FOUND": "API-Token nicht gefunden",
|
||||
"API_TOKEN_SCOPE_FORBIDDEN": "Dieses API-Token (Bereich „{scope}“) darf diese Aktion nicht ausführen.",
|
||||
"API_TOKEN_SCOPE_TOO_HIGH": "Mit deiner Rolle ist dieser Token-Bereich nicht möglich (Nur-Lesen: „Lesen“, Operator: bis „Ausrollen“)."
|
||||
},
|
||||
"workflow": {
|
||||
"attention": "Fehler prüfen",
|
||||
@@ -1424,7 +1433,245 @@
|
||||
"peerPsk": "Peer-PSK",
|
||||
"pskPlaceholder": "PSK einfügen"
|
||||
},
|
||||
"auditExport": {
|
||||
"title": "Audit-Export",
|
||||
"intro": "Leitet das Audit (wer hat was mit welchem Ergebnis geändert) an einen Syslog-Server und/oder einen Webhook weiter, damit es auch außerhalb von OPNexus vorliegt. Gesendet werden nur neue Ereignisse ab dem Einschalten; Inhalte von Firewall-Objekten sind nie dabei. Die Zustellung ist mindestens einmal: jedes Ereignis trägt seine ID.",
|
||||
"saved": "Audit-Export gespeichert",
|
||||
"testSent": "Testnachricht gesendet",
|
||||
"testFailed": "Die Testnachricht konnte nicht zugestellt werden",
|
||||
"syslog": "Syslog (RFC 5424)",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"protocol": "Protokoll",
|
||||
"webhook": "Webhook",
|
||||
"webhookUrl": "Webhook-URL",
|
||||
"secret": "Signaturschlüssel ({state})",
|
||||
"isSet": "gesetzt",
|
||||
"notSet": "nicht gesetzt",
|
||||
"keep": "leer lassen zum Behalten",
|
||||
"optional": "optional",
|
||||
"removeSecret": "Signaturschlüssel entfernen",
|
||||
"test": "Testnachricht senden",
|
||||
"lastError": "Letzte Zustellung fehlgeschlagen {when}: {message}",
|
||||
"lastSuccess": "Letzte Zustellung {when}"
|
||||
},
|
||||
"backupSettings": {
|
||||
"title": "Backups",
|
||||
"intro": "Sichert die Konfiguration jeder Firewall nach Zeitplan und behält die neuesten. Optional wird eine Kopie hier mit deiner Passphrase verschlüsselt und auf S3-kompatiblen Speicher hochgeladen (AWS S3, MinIO, Backblaze B2, Wasabi, Cloudflare R2, Hetzner …), damit ein verlorener OPNexus-Server oder eine verlorene Firewall nicht das Ende deiner Konfiguration ist. Alte Offsite-Kopien löschst du über die Lifecycle-Regeln des Buckets.",
|
||||
"saved": "Backup-Einstellungen gespeichert",
|
||||
"scheduled": "Alle Firewalls nach Zeitplan sichern",
|
||||
"interval": "Wie oft",
|
||||
"everyHours": "alle {n} Stunden",
|
||||
"keep": "Die neuesten behalten (je Firewall)",
|
||||
"offsite": "Backups auf S3-kompatiblen Speicher kopieren (verschlüsselt)",
|
||||
"endpoint": "Endpunkt",
|
||||
"region": "Region",
|
||||
"bucket": "Bucket",
|
||||
"prefix": "Ordner im Bucket",
|
||||
"accessKey": "Zugriffsschlüssel",
|
||||
"secretKey": "Geheimer Schlüssel ({state})",
|
||||
"pathStyle": "Path-Style-Adressierung (funktioniert überall; nur abschalten, wenn der Anbieter bucket.host verlangt)",
|
||||
"passphrase": "Passphrase zur Verschlüsselung ({state})",
|
||||
"passphraseNew": "mindestens 12 Zeichen",
|
||||
"passphraseHint": "Backups werden verschlüsselt, bevor sie diesen Server verlassen. Ohne die Passphrase ist eine Offsite-Kopie nicht lesbar — bewahre sie außer in OPNexus noch an einem sicheren Ort auf. Eine Änderung gilt nur für neue Uploads.",
|
||||
"offsiteError": "Offsite-Kopie fehlgeschlagen: {message}",
|
||||
"offsiteOk": "Letzte Offsite-Kopie {when}",
|
||||
"lastRun": "Letzter geplanter Lauf {when}",
|
||||
"test": "Ziel testen",
|
||||
"testOk": "Das Ziel funktioniert (geschrieben, zurückgelesen, gelöscht)",
|
||||
"testFailed": "Der Test des Ziels ist fehlgeschlagen",
|
||||
"runNow": "Jetzt sichern",
|
||||
"ran": "{taken} gesichert, {failed} fehlgeschlagen, {uploaded} hochgeladen"
|
||||
},
|
||||
"backupDiff": {
|
||||
"title": "Änderungen seit dem vorherigen Backup",
|
||||
"hint": "Geheimnisse (Passwörter, Schlüssel, Tokens, Zertifikate) erscheinen nur als Hash: ein geändertes Geheimnis zeigt sich als geänderter Hash.",
|
||||
"identical": "Keine Unterschiede.",
|
||||
"summary": "{added} Zeilen hinzugefügt, {removed} entfernt",
|
||||
"truncated": "Die Liste ist abgeschnitten; den Rest in OPNsense vergleichen.",
|
||||
"failed": "Der Vergleich ist fehlgeschlagen"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheitslage",
|
||||
"intro": "Prüft die Konfiguration jeder Firewall auf typische Schwachstellen (offene Regeln, schwaches SSH/SNMP, fehlende Backups …) und vergibt einen Wert von 0 bis 100. Nur lesend: an den Firewalls wird nichts geändert. Automatisch alle 6 Stunden geprüft.",
|
||||
"loadFailed": "Sicherheitslage konnte nicht geladen werden",
|
||||
"checkFailed": "Die Prüfung ist fehlgeschlagen",
|
||||
"checked": "Prüfung abgeschlossen",
|
||||
"checkAll": "Alle prüfen",
|
||||
"checkHost": "Erneut prüfen",
|
||||
"noHosts": "Noch keine Firewalls",
|
||||
"noHostsHint": "Verbinde eine Firewall, um ihre Sicherheitslage zu sehen.",
|
||||
"notChecked": "Nicht geprüft",
|
||||
"neverChecked": "Noch nicht geprüft",
|
||||
"checkedAt": "Geprüft {when}",
|
||||
"allGood": "Keine offenen Befunde",
|
||||
"andMore": "… und {n} weitere",
|
||||
"unknown": "unbekannt",
|
||||
"failed": "nicht bestanden",
|
||||
"passed": "bestanden",
|
||||
"severity": {
|
||||
"critical": "kritisch",
|
||||
"high": "hoch",
|
||||
"medium": "mittel",
|
||||
"low": "niedrig",
|
||||
"info": "Hinweis"
|
||||
},
|
||||
"error": {
|
||||
"unreachable": "nicht erreichbar",
|
||||
"invalid_config": "nicht lesbar"
|
||||
},
|
||||
"errorHint": {
|
||||
"unreachable": "Die Firewall war nicht erreichbar und konnte deshalb nicht geprüft werden.",
|
||||
"invalid_config": "Die Konfiguration konnte nicht gelesen werden."
|
||||
},
|
||||
"reason": {
|
||||
"root_password": "root darf sich per Passwort über SSH anmelden",
|
||||
"password": "SSH erlaubt die Anmeldung per Passwort"
|
||||
},
|
||||
"checks": {
|
||||
"webgui_https": {
|
||||
"title": "Web-Oberfläche nur über HTTPS",
|
||||
"ok": "Die Verwaltungsoberfläche läuft über HTTPS.",
|
||||
"fail": "Die Verwaltungsoberfläche läuft über unverschlüsseltes HTTP. Zugangsdaten sind im Netz mitlesbar.",
|
||||
"fix": "System › Einstellungen › Administration: Protokoll auf HTTPS stellen."
|
||||
},
|
||||
"ssh_hardened": {
|
||||
"title": "SSH abgesichert",
|
||||
"ok": "SSH ist aus oder erlaubt nur Schlüssel.",
|
||||
"fail": "{reason}.",
|
||||
"fix": "SSH ausschalten, oder unter System › Einstellungen › Administration Root-Login und Passwort-Anmeldung abschalten und Schlüssel nutzen."
|
||||
},
|
||||
"snmp_community": {
|
||||
"title": "SNMP-Community ist nicht „public“",
|
||||
"ok": "SNMP nutzt eine eigene Community.",
|
||||
"fail": "SNMP nutzt die voreingestellte Community „public“. Jeder im Netz kann Systemdaten lesen.",
|
||||
"fix": "Eine eigene, lange Community setzen oder SNMP ausschalten."
|
||||
},
|
||||
"wan_any_any": {
|
||||
"title": "Keine Allow-all-Regel am WAN",
|
||||
"ok": "Am WAN erlaubt keine Regel allen Verkehr überallhin.",
|
||||
"fail": "{count} Regel(n) am WAN erlauben jeden Verkehr zu jedem Ziel — die Firewall schützt dort nichts.",
|
||||
"fix": "Regel auf die nötigen Quellen, Ziele und Ports beschränken oder löschen."
|
||||
},
|
||||
"internal_any_any": {
|
||||
"title": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
||||
"ok": "Keine interne Regel erlaubt beliebige Quellen zu beliebigen Zielen.",
|
||||
"fail": "{count} Regel(n) erlauben beliebige Quellen zu beliebigen Zielen.",
|
||||
"fix": "Die Quelle auf das jeweilige Netz beschränken."
|
||||
},
|
||||
"rules_shadowed": {
|
||||
"title": "Keine verdeckten Regeln",
|
||||
"ok": "Jede Regel wird erreicht.",
|
||||
"fail": "{count} Regel(n) werden nie erreicht, weil eine frühere Regel sie schon abdeckt.",
|
||||
"fix": "Reihenfolge prüfen; die überflüssige Regel entfernen oder davor einsortieren."
|
||||
},
|
||||
"rules_duplicate": {
|
||||
"title": "Keine doppelten Regeln",
|
||||
"ok": "Keine Regel kommt doppelt vor.",
|
||||
"fail": "{count} Regel(n) sind identisch zu einer früheren.",
|
||||
"fix": "Die doppelte Regel löschen."
|
||||
},
|
||||
"rules_documented": {
|
||||
"title": "Regeln sind beschriftet",
|
||||
"ok": "Alle aktiven Regeln haben eine Beschreibung.",
|
||||
"fail": "{count} aktive Regel(n) haben keine Beschreibung.",
|
||||
"fix": "Beschreibung ergänzen, damit später klar bleibt, wofür die Regel da ist."
|
||||
},
|
||||
"ntp_configured": {
|
||||
"title": "Zeitserver gesetzt",
|
||||
"ok": "Zeitserver sind eingetragen.",
|
||||
"fail": "Kein Zeitserver konfiguriert. Logs und Zertifikate brauchen die richtige Uhrzeit.",
|
||||
"fix": "Zeitserver in den Zeiteinstellungen eintragen."
|
||||
},
|
||||
"remote_logging": {
|
||||
"title": "Logs liegen auch woanders",
|
||||
"ok": "Ein externes Syslog-Ziel ist eingetragen.",
|
||||
"fail": "Logs liegen nur auf der Firewall. Nach einem Einbruch oder Ausfall fehlen sie.",
|
||||
"fix": "Unter System › Einstellungen › Logging einen Syslog-Server eintragen."
|
||||
},
|
||||
"ids_enabled": {
|
||||
"title": "Einbruchserkennung aktiv",
|
||||
"ok": "Die Einbruchserkennung ist an.",
|
||||
"fail": "Die Einbruchserkennung (IDS/IPS) ist nicht eingeschaltet.",
|
||||
"fix": "Optional: Dienste › Intrusion Detection aktivieren."
|
||||
},
|
||||
"firmware_current": {
|
||||
"title": "Firmware aktuell",
|
||||
"ok": "Keine Updates offen.",
|
||||
"fail": "Es stehen Updates bereit.",
|
||||
"fix": "Im Update Center einspielen.",
|
||||
"unknown": "Der Updatestatus konnte nicht geprüft werden."
|
||||
},
|
||||
"backup_recent": {
|
||||
"title": "Aktuelles Backup",
|
||||
"ok": "Das neueste Backup ist {days} Tag(e) alt.",
|
||||
"fail": "Es gibt kein Backup aus den letzten 7 Tagen.",
|
||||
"fix": "Auf der Host-Seite „Jetzt sichern“ ausführen oder eine Wartung planen."
|
||||
},
|
||||
"rules_unused": {
|
||||
"title": "Regeln in Benutzung",
|
||||
"ok": "Jede Regel hat in den letzten 30 Tagen Verkehr getroffen.",
|
||||
"fail": "{count} Regel(n) haben seit mindestens 30 Tagen nichts getroffen.",
|
||||
"unknown": "Noch in der Datensammlung: eine Regel gilt erst nach 30 Tagen ohne Treffer als ungenutzt.",
|
||||
"fix": "Prüfen, ob die Regel noch gebraucht wird, und sie deaktivieren oder löschen."
|
||||
}
|
||||
},
|
||||
"hits": {
|
||||
"title": "Regelnutzung",
|
||||
"intro": "Wann jede Regel zuletzt Verkehr getroffen hat, wie OPNexus es beobachtet. Die Zähler der Firewall beginnen bei jeder Änderung und jedem Neustart von vorn, deshalb merkt sich OPNexus, wann ein Zähler zuletzt gewachsen ist. Nach 30 Tagen ohne Treffer gilt eine Regel als ungenutzt.",
|
||||
"rule": "Regel",
|
||||
"state": "Status",
|
||||
"lastHit": "Letzter beobachteter Treffer",
|
||||
"packets": "Pakete seit Neuladen",
|
||||
"states": {
|
||||
"used": "in Benutzung",
|
||||
"unused": "ungenutzt",
|
||||
"collecting": "Daten werden gesammelt",
|
||||
"disabled": "deaktiviert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
"title": "API-Tokens",
|
||||
"new": "Neues Token",
|
||||
"intro": "Mit Tokens nutzen Skripte, CI-Jobs, Prometheus und KI-Assistenten die OPNexus-API ohne Anmeldung. Jedes Token gehört dir, hat einen Bereich und lässt sich jederzeit widerrufen. Es bekommt nie mehr Rechte als dein Konto.",
|
||||
"loadFailed": "API-Tokens konnten nicht geladen werden",
|
||||
"createFailed": "Token konnte nicht erstellt werden",
|
||||
"empty": "Noch keine Tokens.",
|
||||
"colName": "Name",
|
||||
"colScope": "Bereich",
|
||||
"colOwner": "Besitzer",
|
||||
"colExpires": "Läuft ab",
|
||||
"colLastUsed": "Zuletzt benutzt",
|
||||
"never": "Nie",
|
||||
"neverUsed": "Noch nicht benutzt",
|
||||
"days": "{n} Tage",
|
||||
"nameHint": "Wofür das Token gedacht ist, z. B. „Grafana“ oder „CI-Deploy“.",
|
||||
"scope": {
|
||||
"read": "Lesen",
|
||||
"deploy": "Ausrollen",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"scopeHint": {
|
||||
"read": "Nur lesen: alles ansehen, nichts ändern. Für Dashboards, Prometheus und KI-Assistenten.",
|
||||
"deploy": "Lesen plus Regeln, Aliase, NAT, VPN und Wartung ausrollen. Kann keine Benutzer, Einstellungen oder Hosts verwalten.",
|
||||
"admin": "Alles, was dein Konto darf, außer Tokens und dein eigenes Konto zu verwalten."
|
||||
},
|
||||
"password": "Dein Passwort",
|
||||
"passwordHint": "Zum Erstellen eines Tokens ist dein aktuelles Passwort nötig.",
|
||||
"create": "Token erstellen",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirm": "Token „{name}“ widerrufen? Alles, was es nutzt, funktioniert sofort nicht mehr.",
|
||||
"revoked": "Token „{name}“ widerrufen",
|
||||
"createdTitle": "Token „{name}“ erstellt",
|
||||
"shownOnce": "Kopiere das Token jetzt. Es wird nur einmal angezeigt und lässt sich nicht erneut anzeigen.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"example": "Beispiel",
|
||||
"done": "Fertig"
|
||||
},
|
||||
"users": {
|
||||
"roleOperator": "Operator",
|
||||
"roleAdmin": "Admin",
|
||||
"roleReadOnly": "Nur-Lesen",
|
||||
"loadFailed": "Benutzer konnten nicht geladen werden",
|
||||
@@ -1432,7 +1679,7 @@
|
||||
"deleted": "Benutzer „{name}“ gelöscht",
|
||||
"title": "Benutzer",
|
||||
"new": "Neuer Benutzer",
|
||||
"intro": "„Admin“ darf alles, „Nur-Lesen“ kann alle Seiten und Daten ansehen, aber keine Änderungen speichern, Regeln deployen oder Einstellungen ändern — nur das eigene Passwort bleibt jederzeit änderbar.",
|
||||
"intro": "„Admin“ darf alles. „Operator“ rollt aus und betreibt (Regeln, NAT, VPN, Wartung), verwaltet aber keine Benutzer, Einstellungen oder Hosts. „Nur-Lesen“ sieht alle Seiten und Daten, kann aber nichts speichern — nur das eigene Passwort lässt sich immer ändern.",
|
||||
"colUsername": "Benutzername",
|
||||
"colRole": "Rolle",
|
||||
"colCreated": "Angelegt",
|
||||
@@ -1555,7 +1802,8 @@
|
||||
"preview": "Vorschau",
|
||||
"schedule": "Eingeplant",
|
||||
"major_update": "Major-Upgrade",
|
||||
"download": "Download",
|
||||
"changes": "Änderungen",
|
||||
"download": "Download",
|
||||
"push": "Übertragen",
|
||||
"register": "Registrieren",
|
||||
"sign": "Signieren",
|
||||
@@ -1627,6 +1875,7 @@
|
||||
"noBackups": "Noch keine Sicherung vorhanden.",
|
||||
"colTime": "Zeitpunkt",
|
||||
"colSize": "Größe",
|
||||
"changes": "Änderungen",
|
||||
"download": "Herunterladen",
|
||||
"restore": "Wiederherstellen",
|
||||
"restoreHistory": "Restore-Historie",
|
||||
|
||||
+252
-3
@@ -64,6 +64,7 @@
|
||||
"natDrift": "NAT drift in the cluster",
|
||||
"carpAutoRevertFailed": "CARP auto-revert failed",
|
||||
"scheduledFailed": "Scheduled maintenance did not run",
|
||||
"backupFailed": "No recent backup / offsite copy failed",
|
||||
"networkDrift": "Network differences in the cluster"
|
||||
},
|
||||
"pollInterval": "Check interval (seconds)",
|
||||
@@ -212,9 +213,11 @@
|
||||
"certificates": "Certificates",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit",
|
||||
"security": "Security posture",
|
||||
"updates": "Update Center",
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"tokens": "API tokens",
|
||||
"network-devices": "Devices",
|
||||
"network-compare": "Compare",
|
||||
"hostsystem-tunables": "Tunables",
|
||||
@@ -579,7 +582,13 @@
|
||||
"UPDATER_REJECTED": "The updater rejected the request ({reason}).",
|
||||
"UPDATE_NOT_OFFERED": "This version is not offered for installation — only the latest stable version.",
|
||||
"UPDATE_MAJOR_CHANGE": "Please run major version jumps deliberately on the server with “opnexus update”.",
|
||||
"ADMIN_REQUIRED": "This function requires the “Admin” role."
|
||||
"ADMIN_REQUIRED": "This function requires the “Admin” role.",
|
||||
"API_TOKEN_LIMIT_REACHED": "At most {limit} API tokens are allowed per user. Revoke tokens you no longer need first.",
|
||||
"BACKUP_UNLESBAR": "The backup cannot be read: {reason}",
|
||||
"OFFSITE_TEST_FAILED": "The offsite target test failed: {reason}",
|
||||
"API_TOKEN_NOT_FOUND": "API token not found",
|
||||
"API_TOKEN_SCOPE_FORBIDDEN": "This API token (scope “{scope}”) is not allowed to perform this action.",
|
||||
"API_TOKEN_SCOPE_TOO_HIGH": "Your role does not allow a token with this scope (read-only: read, operator: up to deploy)."
|
||||
},
|
||||
"workflow": {
|
||||
"attention": "Check errors",
|
||||
@@ -1424,7 +1433,245 @@
|
||||
"peerPsk": "Peer PSK",
|
||||
"pskPlaceholder": "Paste PSK"
|
||||
},
|
||||
"auditExport": {
|
||||
"title": "Audit export",
|
||||
"intro": "Forwards the audit trail (who changed what, with status) to a syslog server and/or a webhook, so it also exists outside OPNexus. Only new events are sent from the moment you switch it on; firewall object contents are never included. Delivery is at least once: every event carries its id.",
|
||||
"saved": "Audit export saved",
|
||||
"testSent": "Test message sent",
|
||||
"testFailed": "The test message could not be delivered",
|
||||
"syslog": "Syslog (RFC 5424)",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"protocol": "Protocol",
|
||||
"webhook": "Webhook",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"secret": "Signing secret ({state})",
|
||||
"isSet": "set",
|
||||
"notSet": "not set",
|
||||
"keep": "leave empty to keep",
|
||||
"optional": "optional",
|
||||
"removeSecret": "Remove the signing secret",
|
||||
"test": "Send test message",
|
||||
"lastError": "Last delivery failed {when}: {message}",
|
||||
"lastSuccess": "Last delivery {when}"
|
||||
},
|
||||
"backupSettings": {
|
||||
"title": "Backups",
|
||||
"intro": "Takes a configuration backup of every firewall on a schedule and keeps the newest ones. Optionally a copy is encrypted here with your passphrase and uploaded to S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi, Cloudflare R2, Hetzner…), so a lost OPNexus server or a lost firewall is not the end of your configuration. Delete old offsite copies with the bucket's lifecycle rules.",
|
||||
"saved": "Backup settings saved",
|
||||
"scheduled": "Back up all firewalls on a schedule",
|
||||
"interval": "How often",
|
||||
"everyHours": "every {n} hours",
|
||||
"keep": "Keep the newest (per firewall)",
|
||||
"offsite": "Copy backups to S3-compatible storage (encrypted)",
|
||||
"endpoint": "Endpoint",
|
||||
"region": "Region",
|
||||
"bucket": "Bucket",
|
||||
"prefix": "Folder in the bucket",
|
||||
"accessKey": "Access key",
|
||||
"secretKey": "Secret key ({state})",
|
||||
"pathStyle": "Path-style addressing (works everywhere; turn off only if your provider needs bucket.host)",
|
||||
"passphrase": "Encryption passphrase ({state})",
|
||||
"passphraseNew": "at least 12 characters",
|
||||
"passphraseHint": "Backups are encrypted before they leave this server. Without the passphrase an offsite copy cannot be read — keep it somewhere safe besides OPNexus. Changing it only affects new uploads.",
|
||||
"offsiteError": "Offsite copy failed: {message}",
|
||||
"offsiteOk": "Last offsite copy {when}",
|
||||
"lastRun": "Last scheduled run {when}",
|
||||
"test": "Test the target",
|
||||
"testOk": "The target works (written, read back, deleted)",
|
||||
"testFailed": "The target test failed",
|
||||
"runNow": "Back up now",
|
||||
"ran": "{taken} backed up, {failed} failed, {uploaded} uploaded"
|
||||
},
|
||||
"backupDiff": {
|
||||
"title": "Changes since the previous backup",
|
||||
"hint": "Secrets (passwords, keys, tokens, certificates) are shown as hashes only: a changed secret appears as a changed hash.",
|
||||
"identical": "No differences.",
|
||||
"summary": "{added} lines added, {removed} removed",
|
||||
"truncated": "The list is cut off; compare the files in OPNsense for the rest.",
|
||||
"failed": "The comparison failed"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security posture",
|
||||
"intro": "Checks every firewall's configuration for common weaknesses (open rules, weak SSH/SNMP, missing backups…) and gives a score from 0 to 100. Read-only: nothing is changed on the firewalls. Checked automatically every 6 hours.",
|
||||
"loadFailed": "Could not load the security posture",
|
||||
"checkFailed": "The check failed",
|
||||
"checked": "Check finished",
|
||||
"checkAll": "Check all",
|
||||
"checkHost": "Check again",
|
||||
"noHosts": "No firewalls yet",
|
||||
"noHostsHint": "Connect a firewall to see its security posture.",
|
||||
"notChecked": "Not checked",
|
||||
"neverChecked": "Not checked yet",
|
||||
"checkedAt": "Checked {when}",
|
||||
"allGood": "No open findings",
|
||||
"andMore": "… and {n} more",
|
||||
"unknown": "unknown",
|
||||
"failed": "failed",
|
||||
"passed": "passed",
|
||||
"severity": {
|
||||
"critical": "critical",
|
||||
"high": "high",
|
||||
"medium": "medium",
|
||||
"low": "low",
|
||||
"info": "note"
|
||||
},
|
||||
"error": {
|
||||
"unreachable": "unreachable",
|
||||
"invalid_config": "unreadable"
|
||||
},
|
||||
"errorHint": {
|
||||
"unreachable": "The firewall could not be reached, so it could not be checked.",
|
||||
"invalid_config": "The configuration could not be read."
|
||||
},
|
||||
"reason": {
|
||||
"root_password": "root may log in over SSH with a password",
|
||||
"password": "SSH accepts password login"
|
||||
},
|
||||
"checks": {
|
||||
"webgui_https": {
|
||||
"title": "Web interface over HTTPS only",
|
||||
"ok": "The management interface is served over HTTPS.",
|
||||
"fail": "The management interface runs over unencrypted HTTP. Credentials can be read on the network.",
|
||||
"fix": "System › Settings › Administration: set the protocol to HTTPS."
|
||||
},
|
||||
"ssh_hardened": {
|
||||
"title": "SSH locked down",
|
||||
"ok": "SSH is off or accepts keys only.",
|
||||
"fail": "{reason}.",
|
||||
"fix": "Switch SSH off, or disable root login and password login under System › Settings › Administration and use keys."
|
||||
},
|
||||
"snmp_community": {
|
||||
"title": "SNMP community is not “public”",
|
||||
"ok": "SNMP uses a custom community.",
|
||||
"fail": "SNMP uses the default community “public”. Anyone on the network can read system data.",
|
||||
"fix": "Set your own long community or turn SNMP off."
|
||||
},
|
||||
"wan_any_any": {
|
||||
"title": "No allow-all rule on the WAN",
|
||||
"ok": "No rule on the WAN allows all traffic to everywhere.",
|
||||
"fail": "{count} rule(s) on the WAN allow all traffic to any destination — the firewall protects nothing there.",
|
||||
"fix": "Restrict the rule to the sources, destinations and ports you need, or delete it."
|
||||
},
|
||||
"internal_any_any": {
|
||||
"title": "No allow-all rule from any source (internal)",
|
||||
"ok": "No internal rule allows any source to any destination.",
|
||||
"fail": "{count} rule(s) allow any source to any destination.",
|
||||
"fix": "Limit the source to the network the rule is meant for."
|
||||
},
|
||||
"rules_shadowed": {
|
||||
"title": "No shadowed rules",
|
||||
"ok": "Every rule can be reached.",
|
||||
"fail": "{count} rule(s) are never reached because an earlier rule already covers them.",
|
||||
"fix": "Check the order; remove the useless rule or move it up."
|
||||
},
|
||||
"rules_duplicate": {
|
||||
"title": "No duplicate rules",
|
||||
"ok": "No rule is repeated.",
|
||||
"fail": "{count} rule(s) are identical to an earlier one.",
|
||||
"fix": "Delete the duplicate."
|
||||
},
|
||||
"rules_documented": {
|
||||
"title": "Rules are described",
|
||||
"ok": "All active rules have a description.",
|
||||
"fail": "{count} active rule(s) have no description.",
|
||||
"fix": "Add a description so it stays clear later what the rule is for."
|
||||
},
|
||||
"ntp_configured": {
|
||||
"title": "Time server set",
|
||||
"ok": "Time servers are configured.",
|
||||
"fail": "No time server is configured. Logs and certificates need the right time.",
|
||||
"fix": "Enter NTP servers in the time settings."
|
||||
},
|
||||
"remote_logging": {
|
||||
"title": "Logs are kept elsewhere too",
|
||||
"ok": "A remote syslog target is configured.",
|
||||
"fail": "Logs exist only on the firewall. After a break-in or a failure they are gone.",
|
||||
"fix": "Add a syslog server under System › Settings › Logging."
|
||||
},
|
||||
"ids_enabled": {
|
||||
"title": "Intrusion detection active",
|
||||
"ok": "Intrusion detection is on.",
|
||||
"fail": "Intrusion detection (IDS/IPS) is not switched on.",
|
||||
"fix": "Optional: enable Services › Intrusion Detection."
|
||||
},
|
||||
"firmware_current": {
|
||||
"title": "Firmware up to date",
|
||||
"ok": "No updates are pending.",
|
||||
"fail": "Updates are available.",
|
||||
"fix": "Install them in the Update Center.",
|
||||
"unknown": "Could not determine the update status."
|
||||
},
|
||||
"backup_recent": {
|
||||
"title": "Recent backup",
|
||||
"ok": "The newest backup is {days} day(s) old.",
|
||||
"fail": "There is no backup from the last 7 days.",
|
||||
"fix": "Use “Back up now” on the host page or schedule maintenance."
|
||||
},
|
||||
"rules_unused": {
|
||||
"title": "Rules in use",
|
||||
"ok": "Every rule matched traffic in the last 30 days.",
|
||||
"fail": "{count} rule(s) have matched nothing for at least 30 days.",
|
||||
"unknown": "Still collecting data: a rule counts as unused only after 30 days without a hit.",
|
||||
"fix": "Check whether the rule is still needed and disable or delete it."
|
||||
}
|
||||
},
|
||||
"hits": {
|
||||
"title": "Rule usage",
|
||||
"intro": "When each rule last matched traffic, as observed by OPNexus. The firewall's own counters restart with every change and reboot, so OPNexus keeps the last time it saw one grow. A rule counts as unused after 30 days without a hit.",
|
||||
"rule": "Rule",
|
||||
"state": "Status",
|
||||
"lastHit": "Last hit seen",
|
||||
"packets": "Packets since reload",
|
||||
"states": {
|
||||
"used": "in use",
|
||||
"unused": "unused",
|
||||
"collecting": "collecting data",
|
||||
"disabled": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
"title": "API tokens",
|
||||
"new": "New token",
|
||||
"intro": "Tokens let scripts, CI jobs, Prometheus and AI assistants use the OPNexus API without a sign-in. Each token belongs to you, has a scope and can be revoked at any time. It never gets more rights than your account.",
|
||||
"loadFailed": "Could not load API tokens",
|
||||
"createFailed": "Could not create the token",
|
||||
"empty": "No tokens yet.",
|
||||
"colName": "Name",
|
||||
"colScope": "Scope",
|
||||
"colOwner": "Owner",
|
||||
"colExpires": "Expires",
|
||||
"colLastUsed": "Last used",
|
||||
"never": "Never",
|
||||
"neverUsed": "Not used yet",
|
||||
"days": "{n} days",
|
||||
"nameHint": "What the token is for, e.g. “Grafana” or “CI deploy”.",
|
||||
"scope": {
|
||||
"read": "Read",
|
||||
"deploy": "Deploy",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"scopeHint": {
|
||||
"read": "Read only: view everything, change nothing. For dashboards, Prometheus and AI assistants.",
|
||||
"deploy": "Read plus roll out rules, aliases, NAT, VPN and maintenance. Cannot manage users, settings or hosts.",
|
||||
"admin": "Everything your account may do, except managing tokens and your own account."
|
||||
},
|
||||
"password": "Your password",
|
||||
"passwordHint": "Creating a token needs your current password.",
|
||||
"create": "Create token",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "Revoke token “{name}”? Anything using it stops working immediately.",
|
||||
"revoked": "Token “{name}” revoked",
|
||||
"createdTitle": "Token “{name}” created",
|
||||
"shownOnce": "Copy the token now. It is shown only once and cannot be displayed again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"example": "Example",
|
||||
"done": "Done"
|
||||
},
|
||||
"users": {
|
||||
"roleOperator": "Operator",
|
||||
"roleAdmin": "Admin",
|
||||
"roleReadOnly": "Read-only",
|
||||
"loadFailed": "Could not load users",
|
||||
@@ -1432,7 +1679,7 @@
|
||||
"deleted": "User “{name}” deleted",
|
||||
"title": "Users",
|
||||
"new": "New user",
|
||||
"intro": "“Admin” may do everything, “Read-only” can view all pages and data but cannot save changes, deploy rules or change settings — only your own password can always be changed.",
|
||||
"intro": "“Admin” may do everything. “Operator” rolls out and operates (rules, NAT, VPN, maintenance) but cannot manage users, settings or hosts. “Read-only” can view all pages and data but cannot save changes — only your own password can always be changed.",
|
||||
"colUsername": "Username",
|
||||
"colRole": "Role",
|
||||
"colCreated": "Created",
|
||||
@@ -1555,7 +1802,8 @@
|
||||
"preview": "Preview",
|
||||
"schedule": "Scheduled",
|
||||
"major_update": "Major upgrade",
|
||||
"download": "Download",
|
||||
"changes": "Changes",
|
||||
"download": "Download",
|
||||
"push": "Push",
|
||||
"register": "Register",
|
||||
"sign": "Sign",
|
||||
@@ -1627,6 +1875,7 @@
|
||||
"noBackups": "No backup available yet.",
|
||||
"colTime": "Time",
|
||||
"colSize": "Size",
|
||||
"changes": "Changes",
|
||||
"download": "Download",
|
||||
"restore": "Restore",
|
||||
"restoreHistory": "Restore history",
|
||||
|
||||
@@ -26,3 +26,25 @@ export function useReadOnly(): boolean {
|
||||
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
// True once we know the session's role is NOT admin (read_only or operator). For the
|
||||
// few things only an admin may do: users, settings, hosts, OPNexus updates, raw backups.
|
||||
// Same "false until known" rule as useReadOnly.
|
||||
export function useNotAdmin(): boolean {
|
||||
const [notAdmin, setNotAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.me()
|
||||
.then((state) => {
|
||||
if (active) setNotAdmin(state.role !== undefined && state.role !== "admin");
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return notAdmin;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||
// entries already kept in the project README/CHANGELOG.md, no build
|
||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
export const APP_VERSION = "1.17.0";
|
||||
export const APP_CODENAME = "Imperial";
|
||||
export const APP_VERSION = "1.19.0";
|
||||
export const APP_CODENAME = "Lanner";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
Reference in new issue
Block a user