- **Konfiguration als Code:** Unter **System › Konfiguration als Code** (und per `opnexus export` / `opnexus apply`) lassen sich Gerätegruppen, Hierarchiegruppen, Aliase, Filterregeln, Port-Forwards, 1:1-NAT, Outbound-NAT, Hierarchie-Regeln und Standort-Variablen als **eine YAML-Datei exportieren** (sortiert, ohne Zeitstempel, ohne Zugänge oder Schlüssel, für Git und Reviews) und wieder **importieren**: erst ein **Plan** (neu, geändert, entfernt, unverändert, blockiert, mit Hinweisen), dann das **Übernehmen** durch einen Administrator in einer Transaktion mit dem Token genau dieses Plans. **Der Import ändert nur die Entwürfe in OPNexus, nie eine Firewall**; ausgerollt wird wie immer über Vorschau → Ausrollen → Bestätigen. Ausgerollte oder noch verwendete Objekte entfernt er nie (Prune blockiert den Plan). Die Datei wird nur als Daten gelesen (keine Anker, Tags, Merge-Schlüssel, Mehrfach-Dokumente; 1 MiB, strenges Schema mit Zeile und Pfad bei Fehlern). Plan und Export gehen auch mit „Lesen“-Token und Nur-Lese-Konten, `opnexus apply --dry-run` liefert Exit-Codes für CI (0 / 2 / 3 / 4). Regeln und NAT-Objekte tragen dafür einen stabilen `key` (Migration, wird für Bestand automatisch vergeben). Gruppenänderungen stehen jetzt im Audit. - **Rückweg zur vorherigen Firmware:** Vor jedem Firmware-Update und Major-Upgrade (manuell, geplant, Rollout, CARP-Übergabe) legt OPNexus auf der Firewall ein **ZFS-Boot-Environment** `opnexus-pre-<Version>-<Zeit>` als Rückweg-Punkt an (ohne den Punkt startet das Update nicht; ohne ZFS mit Hinweis). Neue Aktion **Zur vorherigen Firmware** (Host-Wartung): Vorschau, Hostname, Downtime-Bestätigung, vorher lokale Sicherung der Konfiguration, dann Aktivieren, Prüfung und Neustart (scheitert ein Schritt, wird das vorherige Boot-Environment wieder eingeschaltet). **Der Rückweg setzt auch die Konfiguration zurück** (live belegt): Ausrollvorgänge seit dem Punkt werden als unklar markiert, damit das nächste Ausrollen wirklich schreibt. Ein Rollout macht nie von selbst einen Rückweg. - **Rollout-Gate: Erreichbarkeitsproben.** Bis zu zehn Ping-/TCP-Proben je Plan, von der Firewall selbst ausgeführt, vor der Welle und nach der Beobachtungszeit: vorher ok und jetzt nicht mehr hält den Rollout an, schon vorher gestört ist nur ein Hinweis. (Erreichbarkeit von der Firewall aus, nicht der Verkehr durch sie hindurch.) - Migrationen 0030 (`config_key`) und 0031 (`gate_probes`).
187 lines
8.5 KiB
Python
187 lines
8.5 KiB
Python
"""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")
|
|
# GUI tickets open a firewall's web interface in the browser: an interactive admin session only.
|
|
# Rule cleanup (1.31): "I still have access" is a human decision, so confirming a cleanup is never possible with a
|
|
# token, whatever its scope (a script must not be able to confirm blindly).
|
|
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)|^/api/hosts/[^/]+/gui-ticket$|^/api/hosts/[^/]+/rule-cleanup/[^/]+/confirm/?$")
|
|
# 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/|^/api/overlay/peers/[^/]+/instructions$")
|
|
# The MCP endpoint is POST (JSON-RPC) but its tools only ever read; so does the configuration-as-code plan (1.32):
|
|
# it compares a posted file with the database and writes nothing.
|
|
_READ_ONLY_POSTS = ("/api/mcp", "/api/config/import/plan")
|
|
_ADMIN_ONLY_WRITES = re.compile(
|
|
r"^/api/(users|settings|system|notifications|overlay)(/|$)"
|
|
r"|^/api/logs/settings$"
|
|
r"|^/api/hosts(/tls-probe|/[^/]+)?$"
|
|
r"|^/api/hosts/[^/]+/gui-ticket$"
|
|
# The per-gateway alert switch is a notification setting (same rights as PUT /api/notifications/settings).
|
|
r"|^/api/hosts/[^/]+/gateways/[^/]+/alert$"
|
|
# Configuration as code (1.32): a bulk change incl. prune and host hierarchy membership (host writes are admin-only).
|
|
r"|^/api/config/import/apply$"
|
|
)
|
|
|
|
|
|
# Rule cleanup: executing a cleanup, retrying a failed revert/reconcile and restoring a deleted rule are admin-only.
|
|
# operators_may_deploy defaults to True, so _ROLLOUT_POST alone cannot express "admin only"; this pattern is checked
|
|
# for operator accounts (operator_denies) and for tokens below scope admin (denies). Not part of _ADMIN_ONLY_WRITES:
|
|
# that one means "who may access OPNexus / where data goes". The router checks the same rule once more.
|
|
_ADMIN_ONLY_CLEANUP = re.compile(r"^/api/hosts/[^/]+/rule-cleanup/([^/]+/(deploy|retry)|items/[^/]+/restore)/?$")
|
|
|
|
|
|
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 in _READ_ONLY_POSTS:
|
|
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) or _ADMIN_ONLY_CLEANUP.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) or _ADMIN_ONLY_CLEANUP.match(path))
|
|
|
|
|
|
# Writes that put a change onto the fleet (as opposed to preparing one): deploy, remove/rollback a deployment,
|
|
# maintenance (reboot/update/CARP switch), scheduling it, applying network changes, pushing certificates and
|
|
# ACME accounts, re-asserting the hierarchy order, controlling services, and DELETING a centrally managed object
|
|
# or snapshot (deleting a rule removes it from every firewall it was deployed to).
|
|
_ROLLOUT_POST = re.compile(
|
|
r"/(deploy|push|sign|poll|register|reassert-ordering"
|
|
r"|deployments/[^/]+/(remove|retry-rollback)"
|
|
# Rule cleanup (1.31): switching rules off (/deploy matches above) and taking it back. Confirming and
|
|
# previewing stay open, like confirm_rule.
|
|
r"|rule-cleanup/[^/]+/(revert|retry)"
|
|
r"|maintenance/(execute|carp-execute|schedule)"
|
|
r"|network/changes"
|
|
r"|services/[^/]+/(start|stop|restart)"
|
|
# Firmware rollouts (1.25): starting/arming a plan and moving a run forward. Pausing is deliberately absent.
|
|
r"|rollouts/plans/[^/]+/(start|activate)"
|
|
r"|rollouts/runs/[^/]+/(resume|approve|skip-host|abort))$"
|
|
)
|
|
_ROLLOUT_DELETE = re.compile(
|
|
r"^/api/(rules|aliases|port-forward|one-to-one|outbound-nat|policy-hierarchy/rules|ipsec/tunnels|openvpn/instances"
|
|
r"|wireguard/tunnels|certificates/managed|acme/accounts|acme/certificates)/[^/]+$"
|
|
r"|^/api/hosts/[^/]+/(snapshots|maintenance/schedule)/[^/]+$"
|
|
)
|
|
|
|
|
|
# Recurring maintenance windows decide WHEN unattended firmware actions run (1.25): every write counts.
|
|
_ROLLOUT_ANY_WRITE = re.compile(r"^/api/maintenance-windows(/|$)")
|
|
|
|
|
|
def is_rollout(method: str, path: str) -> bool:
|
|
if method in ("POST", "PUT", "PATCH", "DELETE") and _ROLLOUT_ANY_WRITE.match(path):
|
|
return True
|
|
if method == "POST":
|
|
return bool(_ROLLOUT_POST.search(path))
|
|
return method == "DELETE" and bool(_ROLLOUT_DELETE.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()
|