Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“. - **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden, Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“). Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`, `COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“). Live getestet auf OPNsense 26.7. - **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten. - **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session` (bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site` werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
169 lines
7.0 KiB
Python
169 lines
7.0 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.
|
|
_SESSION_ONLY = re.compile(r"^/api/(tokens|auth)(/|$)|^/api/hosts/[^/]+/gui-ticket$")
|
|
# 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.
|
|
_MCP_PATH = "/api/mcp"
|
|
_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$"
|
|
)
|
|
|
|
|
|
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))
|
|
|
|
|
|
# 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)"
|
|
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()
|