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
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
(Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
(z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
`__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
385 lines
19 KiB
Python
385 lines
19 KiB
Python
"""Tickets and gateway sessions for the firewall GUI access (1.27, gui-gateway container).
|
|
|
|
Flow (see docs in routers/gui_access.py):
|
|
* An admin asks for a ticket (POST /api/hosts/{id}/gui-ticket). 32 random bytes, valid 30 s, single use;
|
|
only its SHA-256 is stored, bound to the OPNexus session (same hash as sessions.token_hash), the user,
|
|
the host and a slot (gateway port = its own browser origin).
|
|
* The gateway redeems it through /internal/gui/redeem and gets what it needs to reach the firewall —
|
|
base URL, pinned CA, overlay proxy, timeouts. Never the API key or secret.
|
|
* Every 20 s the gateway re-checks the gateway session (/internal/gui/revalidate): OPNexus session still
|
|
valid, user still admin, switch still on, host still eligible, absolute limit not reached.
|
|
|
|
A slot stays assigned to one firewall for as long as that browser session has the firewall open; another
|
|
firewall gets another slot (another origin), so two firewall GUIs never share an origin at the same time.
|
|
After the last gateway session of a slot ended, the slot goes to a DIFFERENT firewall only after SLOT_COOLDOWN
|
|
(1.29, S1): a popup of a possibly compromised firewall GUI may still be open and its cookies belong to that
|
|
origin. The same firewall may take the slot again at once.
|
|
|
|
The target the gateway talks to (origin, pinned CA, overlay proxy) is fingerprinted at redeem time; every
|
|
re-check compares it with the host's current configuration and ends the session when it changed (S4).
|
|
"""
|
|
import hashlib
|
|
import secrets
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from urllib.parse import urlsplit
|
|
|
|
from sqlalchemy import delete, func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.errors import ErrorCode
|
|
from app.models.gui_access import GuiAccessSettings, GuiSession, GuiTicket
|
|
from app.models.host import OpnsenseHost
|
|
from app.models.user import AdminUser, Session
|
|
from app.services import audit_service, firewall_url, overlay_service
|
|
|
|
TICKET_TTL = timedelta(seconds=30)
|
|
# 20 s: a switch-off, logout or role change must end a gateway session within 30 s of the change, and
|
|
# the last re-check can lie up to one interval before it (live test 2026-10-21: 30 s gave 31 s).
|
|
REVALIDATE_SECONDS = 20
|
|
# A gateway session nobody re-checked for this long is treated as ended (gateway crashed without telling us).
|
|
SESSION_STALE_AFTER = timedelta(seconds=REVALIDATE_SECONDS * 3)
|
|
MAX_SLOTS = 4
|
|
# A slot freed by firewall A goes to firewall B no earlier than this long after A's session ended.
|
|
SLOT_COOLDOWN = timedelta(minutes=5)
|
|
# Open gateway sessions at once (the gateway container enforces the same number; test_gui_access checks they match).
|
|
MAX_GATEWAY_SESSIONS = 64
|
|
MIN_GATEWAY_TOKEN_LENGTH = 32
|
|
AUDIT_KIND = "gui_access"
|
|
|
|
|
|
@dataclass
|
|
class GuiAccessError(Exception):
|
|
status: int
|
|
code: ErrorCode
|
|
params: dict
|
|
|
|
|
|
def hash_value(raw: str) -> str:
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# ── configuration ─────────────────────────────────────────────────────────
|
|
|
|
def slot_sites() -> dict[int, list[str]]:
|
|
"""Slot port -> configured https origins with that port (OPNEXUS_GUI_SITES)."""
|
|
sites: dict[int, list[str]] = {}
|
|
for origin in settings.gui_site_origins:
|
|
try:
|
|
parts = urlsplit(origin)
|
|
port = parts.port
|
|
except ValueError:
|
|
continue
|
|
if parts.scheme != "https" or not parts.hostname or not port or parts.path not in ("", "/"):
|
|
continue
|
|
sites.setdefault(port, []).append(f"https://{parts.netloc}")
|
|
return sites
|
|
|
|
|
|
def gateway_configured() -> bool:
|
|
return len(settings.gui_gateway_token) >= MIN_GATEWAY_TOKEN_LENGTH and bool(slot_sites())
|
|
|
|
|
|
def unavailable_reasons() -> list[str]:
|
|
reasons = []
|
|
if not settings.cookie_secure:
|
|
reasons.append("cookie_secure")
|
|
if len(settings.gui_gateway_token) < MIN_GATEWAY_TOKEN_LENGTH:
|
|
reasons.append("gateway_token")
|
|
if not slot_sites():
|
|
reasons.append("gui_sites")
|
|
return reasons
|
|
|
|
|
|
def active_slots(row: GuiAccessSettings) -> list[int]:
|
|
return sorted(slot_sites())[: max(1, min(row.slot_count, MAX_SLOTS))]
|
|
|
|
|
|
async def get_settings(db: AsyncSession) -> GuiAccessSettings:
|
|
row = await db.get(GuiAccessSettings, 1)
|
|
if row is None:
|
|
row = GuiAccessSettings(id=1, enabled=False, idle_timeout_minutes=15, absolute_timeout_minutes=60, slot_count=MAX_SLOTS)
|
|
db.add(row)
|
|
await db.flush()
|
|
return row
|
|
|
|
|
|
def host_refusal(host: OpnsenseHost) -> str | None:
|
|
"""Why this firewall must not be opened through the gateway (None = eligible)."""
|
|
if not host.enabled:
|
|
return "disabled"
|
|
if urlsplit(host.base_url.strip()).scheme != "https" or firewall_url.check_base_url(host.base_url):
|
|
return "https"
|
|
if not host.verify_tls:
|
|
return "tls"
|
|
return None
|
|
|
|
|
|
def firewall_origin(base_url: str) -> str:
|
|
parts = urlsplit(base_url.strip())
|
|
return f"https://{parts.netloc.rsplit('@', 1)[-1]}"
|
|
|
|
|
|
def target_fingerprint(host: OpnsenseHost) -> str:
|
|
"""What a gateway session talks to: origin, pinned CA and overlay route. Changes -> the session must end."""
|
|
material = "\n".join([firewall_origin(host.base_url), host.tls_ca_pem or "", overlay_service.proxy_for(host.base_url) or "",
|
|
"verify" if host.verify_tls else "noverify"])
|
|
return hash_value(material)
|
|
|
|
|
|
# ── audit ─────────────────────────────────────────────────────────────────
|
|
|
|
async def _audit(actor: str, *, action: str, status: str, host: OpnsenseHost | None = None,
|
|
host_id=None, entity_id=None, message: str | None = None, details: dict | None = None) -> None:
|
|
token = audit_service.context.set({"actor": actor, "id": str(uuid.uuid4()), "action": action})
|
|
try:
|
|
await audit_service.persist(
|
|
kind=AUDIT_KIND, action=action, status=status, entity_id=str(entity_id) if entity_id else None,
|
|
host_id=str(host.id) if host is not None else (str(host_id) if host_id else None),
|
|
host_name=host.name if host is not None else None, message=message, details=details or {},
|
|
)
|
|
finally:
|
|
audit_service.context.reset(token)
|
|
|
|
|
|
# ── tickets ───────────────────────────────────────────────────────────────
|
|
|
|
def _redeem_origin(slot: int, request_host: str) -> str:
|
|
candidates = slot_sites().get(slot, [])
|
|
wanted = (urlsplit(f"//{request_host}").hostname or "").lower()
|
|
for origin in candidates:
|
|
if (urlsplit(origin).hostname or "").lower() == wanted:
|
|
return origin
|
|
return candidates[0]
|
|
|
|
|
|
async def _pick_slot(db: AsyncSession, row: GuiAccessSettings, session_hash: str, user_id, host_id) -> int:
|
|
now = _now()
|
|
slots = active_slots(row)
|
|
absolute = timedelta(minutes=row.absolute_timeout_minutes)
|
|
in_use: dict[int, uuid.UUID] = {}
|
|
open_sessions = await db.scalars(select(GuiSession).where(
|
|
GuiSession.session_hash == session_hash, GuiSession.ended_at.is_(None), GuiSession.started_at > now - absolute))
|
|
for gs in open_sessions:
|
|
in_use[gs.slot] = gs.host_id
|
|
pending = await db.scalars(select(GuiTicket).where(
|
|
GuiTicket.session_hash == session_hash, GuiTicket.redeemed_at.is_(None), GuiTicket.expires_at > now))
|
|
for ticket in pending:
|
|
in_use.setdefault(ticket.slot, ticket.host_id)
|
|
for slot in slots:
|
|
if in_use.get(slot) == host_id:
|
|
return slot # this firewall is already open in this browser session: same origin again
|
|
free = [s for s in slots if s not in in_use]
|
|
if not free:
|
|
raise GuiAccessError(409, ErrorCode.GUI_NO_FREE_SLOT, {"slots": str(len(slots))})
|
|
# S1: a slot this user's browser may still have a window of ANOTHER firewall on stays closed to this
|
|
# firewall — while a gateway session of the user is open there (any browser session) and for SLOT_COOLDOWN
|
|
# after the last one ended. The same firewall may take it again at once.
|
|
held = (await db.execute(
|
|
select(GuiSession.slot, GuiSession.host_id, GuiSession.ended_at, GuiSession.last_checked_at).where(
|
|
GuiSession.user_id == user_id,
|
|
((GuiSession.ended_at.is_(None)) & ((GuiSession.started_at > now - absolute) | (GuiSession.last_checked_at > now - SLOT_COOLDOWN)))
|
|
| (GuiSession.ended_at > now - SLOT_COOLDOWN)))).all()
|
|
cooling: dict[int, datetime] = {} # slot -> when it may go to another firewall
|
|
for slot, h_id, ended_at, checked_at in held:
|
|
if h_id == host_id or slot not in free:
|
|
continue
|
|
if ended_at is None and checked_at is not None and now - checked_at > SESSION_STALE_AFTER:
|
|
ended_at = checked_at # the gateway stopped re-checking it (crash, kill): it ended when it was last seen
|
|
release = (ended_at + SLOT_COOLDOWN) if ended_at is not None else now + SLOT_COOLDOWN
|
|
if release <= now:
|
|
continue # cooled down long ago (a session that stopped answering a while back)
|
|
cooling[slot] = max(cooling.get(slot, release), release)
|
|
usable = [s for s in free if s not in cooling]
|
|
if not usable:
|
|
wait = min(cooling.values()) - now
|
|
raise GuiAccessError(409, ErrorCode.GUI_SLOT_COOLING_DOWN,
|
|
{"minutes": str(max(1, -(-int(wait.total_seconds()) // 60)))})
|
|
free = usable
|
|
# Prefer the slot this user had this firewall on before, then the slot the user used least recently
|
|
# (an old tab of another firewall on a reused origin is the documented residual risk).
|
|
history = (await db.execute(
|
|
select(GuiSession.slot, GuiSession.host_id, func.max(GuiSession.started_at))
|
|
.where(GuiSession.user_id == user_id).group_by(GuiSession.slot, GuiSession.host_id)
|
|
)).all()
|
|
for slot, h_id, _ in sorted(history, key=lambda r: r[2], reverse=True):
|
|
if h_id == host_id and slot in free:
|
|
return slot
|
|
last_used = {}
|
|
for slot, _, started in history:
|
|
last_used[slot] = max(last_used.get(slot, started), started)
|
|
epoch = datetime.min.replace(tzinfo=timezone.utc)
|
|
return min(free, key=lambda s: (last_used.get(s, epoch), s))
|
|
|
|
|
|
async def issue_ticket(db: AsyncSession, *, user: AdminUser, session_token: str, host: OpnsenseHost,
|
|
request_host: str) -> dict:
|
|
row = await get_settings(db)
|
|
now = _now()
|
|
await db.execute(delete(GuiTicket).where(GuiTicket.expires_at < now - timedelta(hours=1)))
|
|
session_hash = hash_value(session_token)
|
|
slot = await _pick_slot(db, row, session_hash, user.id, host.id)
|
|
# Reopening a slot in the same browser session replaces its earlier gateway session, so that one does not count.
|
|
open_count = await db.scalar(select(func.count()).select_from(GuiSession).where(
|
|
GuiSession.ended_at.is_(None),
|
|
GuiSession.started_at > now - timedelta(minutes=row.absolute_timeout_minutes),
|
|
~((GuiSession.session_hash == session_hash) & (GuiSession.slot == slot))))
|
|
if (open_count or 0) >= MAX_GATEWAY_SESSIONS:
|
|
raise GuiAccessError(409, ErrorCode.GUI_TOO_MANY_SESSIONS, {"max": str(MAX_GATEWAY_SESSIONS)})
|
|
raw = secrets.token_urlsafe(32)
|
|
db.add(GuiTicket(ticket_hash=hash_value(raw), session_hash=session_hash, user_id=user.id, host_id=host.id,
|
|
slot=slot, created_at=now, expires_at=now + TICKET_TTL))
|
|
await db.commit()
|
|
await _audit(user.username, action="ticket", status="accepted", host=host, details={"slot": slot})
|
|
return {"url": f"{_redeem_origin(slot, request_host)}/__onx/redeem?t={raw}", "slot": slot,
|
|
"expires_in": int(TICKET_TTL.total_seconds())}
|
|
|
|
|
|
# ── gateway side (internal endpoints) ─────────────────────────────────────
|
|
|
|
async def _live_check(db: AsyncSession, row: GuiAccessSettings, session_hash: str, user_id, host_id,
|
|
slot: int) -> tuple[str | None, AdminUser | None, OpnsenseHost | None]:
|
|
session = await db.scalar(select(Session).where(Session.token_hash == session_hash))
|
|
if session is None or session.expires_at <= _now() or session.user_id != user_id:
|
|
return "session", None, None
|
|
user = await db.get(AdminUser, user_id)
|
|
if user is None or user.role != "admin":
|
|
return "role", user, None
|
|
if not row.enabled:
|
|
return "disabled", user, None
|
|
if not settings.cookie_secure or not gateway_configured():
|
|
return "config", user, None
|
|
host = await db.get(OpnsenseHost, host_id)
|
|
if host is None or host_refusal(host):
|
|
return "host", user, host
|
|
if slot not in active_slots(row):
|
|
return "slot", user, host
|
|
return None, user, host
|
|
|
|
|
|
def _timing(row: GuiAccessSettings, started_at: datetime) -> dict:
|
|
deadline = started_at + timedelta(minutes=row.absolute_timeout_minutes)
|
|
return {"idle_timeout_seconds": row.idle_timeout_minutes * 60,
|
|
"absolute_deadline": int(deadline.timestamp()),
|
|
"revalidate_seconds": REVALIDATE_SECONDS}
|
|
|
|
|
|
async def redeem(db: AsyncSession, *, ticket_hash: str, session_hash: str, slot: int, client_ip: str | None) -> dict:
|
|
"""{'ok': True, ...upstream data...} or {'ok': False, 'reason': ...}. Any attempt burns the ticket."""
|
|
now = _now()
|
|
ticket = await db.scalar(select(GuiTicket).where(GuiTicket.ticket_hash == ticket_hash).with_for_update())
|
|
if ticket is None:
|
|
await db.rollback()
|
|
return {"ok": False, "reason": "unknown"}
|
|
replayed = ticket.redeemed_at is not None
|
|
ticket.redeemed_at = ticket.redeemed_at or now
|
|
row = await get_settings(db)
|
|
reason = None
|
|
if replayed:
|
|
reason = "replayed"
|
|
elif ticket.expires_at <= now:
|
|
reason = "expired"
|
|
elif not secrets.compare_digest(ticket.session_hash, session_hash):
|
|
reason = "session"
|
|
elif ticket.slot != slot:
|
|
reason = "slot"
|
|
user = host = None
|
|
if reason is None:
|
|
reason, user, host = await _live_check(db, row, ticket.session_hash, ticket.user_id, ticket.host_id, slot)
|
|
if user is None:
|
|
user = await db.get(AdminUser, ticket.user_id)
|
|
if host is None:
|
|
host = await db.get(OpnsenseHost, ticket.host_id)
|
|
actor = user.username if user is not None else "unknown"
|
|
if reason is not None:
|
|
await db.commit()
|
|
await _audit(actor, action="redeem", status="denied", host=host, host_id=ticket.host_id,
|
|
message=reason, details={"slot": slot, "client_ip": client_ip, "reason": reason})
|
|
return {"ok": False, "reason": reason}
|
|
# The same browser session reopening this slot replaces the earlier gateway session there.
|
|
superseded = (await db.scalars(select(GuiSession).where(
|
|
GuiSession.session_hash == session_hash, GuiSession.slot == slot, GuiSession.ended_at.is_(None)))).all()
|
|
if any(old.host_id != host.id for old in superseded):
|
|
# Two quick tickets both saw the slot free: the second must not take over another firewall's origin
|
|
# without the cooling-down time (S1).
|
|
await db.commit()
|
|
await _audit(actor, action="redeem", status="denied", host=host, host_id=ticket.host_id,
|
|
message="slot", details={"slot": slot, "client_ip": client_ip, "reason": "slot"})
|
|
return {"ok": False, "reason": "slot"}
|
|
for old in superseded:
|
|
old.ended_at, old.end_reason = now, "superseded"
|
|
gs = GuiSession(session_hash=session_hash, user_id=user.id, host_id=host.id, slot=slot,
|
|
client_ip=(client_ip or None) and client_ip[:45], started_at=now, last_checked_at=now)
|
|
db.add(gs)
|
|
await db.commit()
|
|
await _audit(actor, action="redeem", status="accepted", host=host, entity_id=gs.id,
|
|
details={"slot": slot, "client_ip": client_ip})
|
|
return {
|
|
"ok": True, "gui_session_id": str(gs.id), "host_id": str(host.id), "host_name": host.name,
|
|
"upstream_origin": firewall_origin(host.base_url),
|
|
"ca_pem": host.tls_ca_pem or None, # public certificate (pin); None = system trust store
|
|
"proxy_url": overlay_service.proxy_for(host.base_url),
|
|
"target_fp": target_fingerprint(host),
|
|
**_timing(row, now),
|
|
}
|
|
|
|
|
|
async def _finish(db: AsyncSession, gs: GuiSession, reason: str) -> None:
|
|
now = _now()
|
|
if gs.ended_at is None:
|
|
gs.ended_at, gs.end_reason = now, reason[:32]
|
|
await db.commit()
|
|
user = await db.get(AdminUser, gs.user_id)
|
|
host = await db.get(OpnsenseHost, gs.host_id)
|
|
await _audit(user.username if user else "unknown", action="end", status="accepted", host=host,
|
|
host_id=gs.host_id, entity_id=gs.id, message=reason,
|
|
details={"slot": gs.slot, "client_ip": gs.client_ip, "reason": reason,
|
|
"duration_seconds": int((gs.ended_at - gs.started_at).total_seconds())})
|
|
|
|
|
|
async def revalidate(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str,
|
|
target_fp: str | None = None) -> dict:
|
|
gs = await db.get(GuiSession, gui_session_id)
|
|
if gs is None or not secrets.compare_digest(gs.session_hash, session_hash):
|
|
return {"ok": False, "reason": "unknown"}
|
|
if gs.ended_at is not None:
|
|
return {"ok": False, "reason": gs.end_reason or "ended"}
|
|
row = await get_settings(db)
|
|
if _now() >= gs.started_at + timedelta(minutes=row.absolute_timeout_minutes):
|
|
await _finish(db, gs, "absolute")
|
|
return {"ok": False, "reason": "absolute"}
|
|
reason, _, host = await _live_check(db, row, gs.session_hash, gs.user_id, gs.host_id, gs.slot)
|
|
# S4: the gateway presents the fingerprint it got at redeem time. Firewall address, pinned certificate or
|
|
# overlay route changed since -> the session (and the upstream connection it holds) ends now.
|
|
if reason is None and target_fp is not None and host is not None \
|
|
and not secrets.compare_digest(target_fp, target_fingerprint(host)):
|
|
reason = "target"
|
|
if reason is not None:
|
|
await _finish(db, gs, reason)
|
|
return {"ok": False, "reason": reason}
|
|
gs.last_checked_at = _now()
|
|
await db.commit()
|
|
return {"ok": True, **_timing(row, gs.started_at)}
|
|
|
|
|
|
async def end(db: AsyncSession, *, gui_session_id: uuid.UUID, session_hash: str, reason: str) -> bool:
|
|
gs = await db.get(GuiSession, gui_session_id)
|
|
if gs is None or not secrets.compare_digest(gs.session_hash, session_hash):
|
|
return False
|
|
if gs.ended_at is None:
|
|
await _finish(db, gs, reason)
|
|
return True
|
|
|
|
|
|
async def end_all(db: AsyncSession, reason: str) -> int:
|
|
"""Switch turned off: mark every open gateway session ended (the gateway drops them on its next check)."""
|
|
result = await db.execute(update(GuiSession).where(GuiSession.ended_at.is_(None))
|
|
.values(ended_at=_now(), end_reason=reason[:32]))
|
|
return result.rowcount or 0
|