Files
paskia/paskia/satellite.py
T
LeoVasanko 3a6e2bb370 CLI: paskia satellite <wss-url> — one-step satellite-only setup
The same register_uplink path as the admin UI, runnable without any web
interface (a satellite may be a purely local instance serving backend
apps on one server): the handshake runs immediately, the link credential
and pushed domains are stored, rejections are printed. With no database
yet, one is created first (localhost domain + admin bootstrap) — the
command is the whole setup; 'paskia' then serves the linked domains.
2026-10-01 06:09:14 +00:00

714 lines
26 KiB
Python

"""Satellite side of remote domains: uplinks, RAM-only replicas, dispatch.
A domain configured with ``DomainConfig.remote`` is backed by an upstream
paskia instance over a **link**: registered online (3-way handshake over
the sync WebSocket — one ``register_uplink`` path serves the admin UI and
the ``paskia satellite`` CLI), then kept up by a long-lived connection per
uplink. The upstream owns the domain set: it pushes the link's domains
(rp-id, display name, origins verbatim) at handshake and whenever the
scope or a domain changes; the satellite validates them against its local
config, persists the accepted set, and reports rejections. Local edits of
remote domains are rejected — the only satellite-side configuration is
link creation and unlinking.
This module owns the rest of the feature: it resolves which store serves
a request host (local DB or an uplink's read replica), dispatches session
writes (refresh write-behind, logout eviction), and forwards requests the
satellite cannot answer (exchange-code redemption, OIDC, reset tokens,
session-less lookups like /check) to the upstream.
A replica is a ReplicaDB instance, never persisted, fed by the sync
channel (snapshot on connect, then live events) and swept for expired
sessions locally. The link's scope confines the replica to the pushed
domains' sessions and credentials, each session carrying the
ApiSessionContext the upstream computed for it (identity display data and
effective permissions for the session's host) — the user graph itself
never leaves the upstream. While the channel is down the replica stays
trusted for the uplink's cache_ttl, then reads fail closed (503; a large
cache_ttl gives fail-open behavior bounded by session expiry).
"""
import asyncio
import contextlib
import logging
import secrets
import socket
import time
from datetime import UTC, datetime
from urllib.parse import unquote, urlsplit, urlunsplit
from uuid import UUID
import httpx
import msgspec
import websockets
from fastapi import HTTPException, Request, Response
from paskia import db, domains
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import (
DB,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
RemoteConfig,
Role,
Session,
SessionContext,
Uplink,
User,
)
from paskia import syncfeed
from paskia.util.apistructs import ApiSessionContext
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
_TABLES = {
"credentials": (Credential, True),
"sessions": (Session, False),
}
_RECONNECT_DELAY = 5
_SWEEP_INTERVAL = 60
class LinkRevoked(Exception):
"""The upstream closed the handshake with a policy violation: the link
is revoked (or the key desynced) — the satellite unlinks its side."""
class ReplicaDB(DB):
"""A sync replica's store: the uplink's sessions and credentials plus
the per-session contexts (identity and effective permissions) the
upstream pushes. Holds no user graph of its own; lookups join the
session with its pushed context."""
def __post_init__(self):
super().__post_init__()
self.session_contexts: dict[str, ApiSessionContext] = {}
def session_ctx_for(self, s: Session, host: str | None) -> SessionContext | None:
if s.client_uuid is not None or s.host != host:
return None
stored = self.session_contexts.get(s.key)
if stored is None:
return None
try:
credential = self.credentials[s.credential_uuid]
except KeyError:
return None
user = User(
display_name=stored.user.display_name,
role_uuid=stored.role.uuid,
created_at=s.validated,
visits=0,
theme=stored.user.theme,
)
user.uuid = stored.user.uuid
role = Role(org_uuid=stored.org.uuid, display_name=stored.role.display_name)
role.uuid = stored.role.uuid
org = Org(display_name=stored.org.display_name)
org.uuid = stored.org.uuid
return SessionContext(
session=s,
user=user,
org=org,
role=role,
credential=credential,
permissions=[
Permission(scope=scope, display_name=scope)
for scope in stored.permissions
],
)
def ws_url_for(base_url: str) -> str:
"""The upstream's sync WebSocket URL for an http(s) base URL."""
return base_url.replace("http", "ws", 1) + "/auth/api/sync/ws"
def parse_link_url(url: str) -> tuple[str, str]:
"""Split a satellite link URL — ``wss://<initial-key>@<host>`` — into
the upstream's https base URL and the initial registration key.
The wss scheme is deliberate: the URL is meant for a satellite to
connect to, and browsers refuse to open it.
"""
parts = urlsplit(url.strip())
if parts.scheme not in ("wss", "ws"):
raise ValueError(
"A link URL is a wss:// URL copied from the upstream's satellites panel"
)
key = unquote(parts.password or parts.username or "")
if not key:
raise ValueError("The link URL carries no registration key")
netloc = parts.hostname or ""
if not netloc:
raise ValueError("The link URL carries no host")
if parts.port:
netloc = f"{netloc}:{parts.port}"
scheme = "https" if parts.scheme == "wss" else "http"
base = urlunsplit((scheme, netloc, parts.path, "", "")).rstrip("/")
return base, key
def _label() -> str:
"""This instance's self-reported name on links (informational)."""
return socket.gethostname() or "satellite"
def _uplink_domains(uplink_id: str) -> dict[str, DomainConfig]:
"""The config domains currently served by an uplink."""
return {
rp_id: d
for rp_id, d in db.data().config.domains.items()
if d.remote is not None and d.remote.link == uplink_id
}
def _accepted_domains(
uplink_id: str, infos: list[dict]
) -> tuple[dict[str, DomainConfig], dict[str, str]]:
"""Validate a pushed domain set against the local configuration.
The upstream is authoritative for the domains it pushes, but they must
not collide with what this instance already serves: an rp-id served
locally or by another uplink is rejected, as is a push that would break
the merged config (e.g. an auth-host collision with a local domain).
Returns (accepted, {rp_id: rejection reason}).
"""
merged = {
rp_id: d
for rp_id, d in db.data().config.domains.items()
if not (d.remote is not None and d.remote.link == uplink_id)
}
accepted: dict[str, DomainConfig] = {}
rejected: dict[str, str] = {}
for info in infos:
rp_id = (info.get("rp_id") or "").strip().lower()
if not rp_id:
continue
existing = db.data().config.domains.get(rp_id)
if existing is not None and not (
existing.remote is not None and existing.remote.link == uplink_id
):
rejected[rp_id] = "served locally or by another uplink"
continue
try:
origins = msgspec.convert(
info.get("origins") or {}, dict[str, bool | OriginEntry]
)
except msgspec.ValidationError:
rejected[rp_id] = "malformed origins"
continue
domain = DomainConfig(
rp_name=info.get("rp_name") or None,
origins=origins,
remote=RemoteConfig(link=uplink_id),
)
try:
domains.validate_config(
Config(domains={**merged, **accepted, rp_id: domain})
)
except ValueError as e:
rejected[rp_id] = str(e)
continue
accepted[rp_id] = domain
return accepted, rejected
def _drop_replica_rows(replica: ReplicaDB, removed_rp_ids: set[str]) -> None:
"""Evict a removed domain's rows from a replica."""
for key, s in list(replica.sessions.items()):
if s.rp_id in removed_rp_ids:
del replica.sessions[key]
replica.session_contexts.pop(key, None)
for cred_uuid, c in list(replica.credentials.items()):
if c.rp_id in removed_rp_ids:
del replica.credentials[cred_uuid]
def apply_domains_push(
uplink_id: str, infos: list[dict], replica_db: ReplicaDB | None = None
) -> dict[str, str]:
"""Apply a domain set pushed by the uplink: persist the accepted set
(adding, updating and removing the link's domains) and rebuild the
registry. Returns the {rp_id: reason} rejections to report upstream."""
accepted, rejected = _accepted_domains(uplink_id, infos)
current = _uplink_domains(uplink_id)
if accepted != current:
db.apply_uplink_domains(uplink_id, accepted)
domains.init_registry(db.data().config)
if replica_db is not None and (removed := set(current) - set(accepted)):
_drop_replica_rows(replica_db, removed)
return rejected
async def register_uplink(url: str) -> dict:
"""Register this instance as a satellite of an upstream.
The one registration path, serving the admin UI and the ``paskia
satellite`` CLI alike: connect to the link URL's sync endpoint, run the
3-way handshake (spending the URL's RAM-only initial key), then commit
the permanent link credential and the pushed domain set to the local
database. The replica connection itself is (re)established by the
manager afterwards, through the same handshake as an established link.
"""
base_url, initial_key = parse_link_url(url)
if any(u.url == base_url for u in db.data().uplinks.values()):
raise ValueError(f"Already linked to {base_url} — unlink first to re-register")
try:
async with websockets.connect(
ws_url_for(base_url), ping_interval=5, ping_timeout=5
) as ws:
await ws.send(
syncfeed.encode(
{
"type": "hello",
"link": None,
"key": initial_key,
"label": _label(),
}
)
)
try:
welcome = msgspec.json.decode(await ws.recv())
except websockets.exceptions.ConnectionClosed as e:
raise RuntimeError(
"the upstream rejected the link URL (unknown or expired offer)"
) from e
if welcome.get("type") != "welcome":
raise RuntimeError("the upstream did not complete the registration")
link_id = welcome.get("link") or ""
try:
server_part = bytes.fromhex(welcome.get("key") or "")
except ValueError:
server_part = b""
if not link_id or not server_part:
raise RuntimeError("the upstream sent a malformed registration")
accepted, rejected = _accepted_domains(
link_id, welcome.get("domains") or []
)
client_part = secrets.token_bytes(32)
await ws.send(
syncfeed.encode(
{"type": "ready", "key": client_part.hex(), "rejected": rejected}
)
)
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"the upstream is unreachable: {e}") from e
uplink = Uplink(
url=base_url, created=datetime.now(UTC), key=server_part + client_part
)
db.create_uplink(link_id, uplink, accepted)
domains.init_registry(db.data().config)
if manager.started:
await manager.sync_uplinks()
return {
"link": link_id,
"url": base_url,
"domains": sorted(accepted),
"rejected": rejected,
}
class UplinkReplica:
"""One uplink's replica, its sync client and write-behind queue."""
def __init__(self, uplink: Uplink):
self.uplink = uplink # uplink.uplink_id is set
self.db = ReplicaDB()
self.last_contact = 0.0 # monotonic time the feed last went down
self.connected = False
self._pending_refresh: dict[str, dict] = {}
self._refresh_signal = asyncio.Event()
self._task: asyncio.Task | None = None
self._sweeper: asyncio.Task | None = None
self._stopped = True
def available(self) -> bool:
"""Synced, and connected now or within cache_ttl of the disconnect."""
if not self.last_contact:
return False
return self.connected or (
time.monotonic() - self.last_contact <= self.uplink.cache_ttl
)
def refresh_session(
self, key: str, validated: datetime, ip: str | None, user_agent: str | None
) -> None:
"""Apply a /validate refresh locally and queue it for the upstream."""
session = self.db.sessions.get(key)
if session is not None:
session.validated = validated
if ip is not None:
session.ip = ip
if user_agent is not None:
session.user_agent = user_agent
self._pending_refresh[key] = {
"type": "session_refresh",
"key": key,
"validated": msgspec.to_builtins(validated),
"ip": ip,
"user_agent": user_agent,
}
self._refresh_signal.set()
async def start(self) -> None:
self._stopped = False
self._task = asyncio.create_task(self._run())
self._task.add_done_callback(lambda t: _replica_done(self, t))
self._sweeper = asyncio.create_task(self._sweep())
async def stop(self) -> None:
self._stopped = True
for task in (self._task, self._sweeper):
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, LinkRevoked):
await task
async def _sweep(self) -> None:
while True:
await asyncio.sleep(_SWEEP_INTERVAL)
limit = datetime.now(UTC) - SESSION_LIFETIME
for key in [k for k, s in self.db.sessions.items() if s.validated < limit]:
del self.db.sessions[key]
self.db.session_contexts.pop(key, None)
async def _run(self) -> None:
while not self._stopped:
try:
await self._connect()
except asyncio.CancelledError:
raise
except LinkRevoked:
raise # the manager unlinks via the done-callback
except Exception as e:
_logger.info("Sync to %s failed: %s", self.uplink.url, e)
if self.connected:
# The TTL clock starts when the feed goes down, not at the
# last message — an idle connection is healthy.
self.connected = False
self.last_contact = time.monotonic()
if not self._stopped:
await asyncio.sleep(_RECONNECT_DELAY)
async def _recv(self, ws) -> dict:
try:
return msgspec.json.decode(await ws.recv())
except websockets.exceptions.ConnectionClosedError as e:
if e.code == 1008:
raise LinkRevoked(self.uplink.url) from e
raise
async def _connect(self) -> None:
async with websockets.connect(
ws_url_for(self.uplink.url),
# Prompt dead-peer detection: availability semantics count on it
ping_interval=5,
ping_timeout=5,
) as ws:
await ws.send(
syncfeed.encode(
{
"type": "hello",
"link": self.uplink.uplink_id,
"key": self.uplink.key.hex(),
"label": _label(),
}
)
)
welcome = await self._recv(ws)
if welcome.get("type") != "welcome":
raise ValueError("sync: protocol error (expected welcome)")
# The upstream re-pushes the link's domain set on every connect
# (the scope may have changed while offline) — apply and ack.
rejected = apply_domains_push(
self.uplink.uplink_id, welcome.get("domains") or [], replica_db=self.db
)
await ws.send(syncfeed.encode({"type": "ready", "rejected": rejected}))
sender = asyncio.create_task(self._send_loop(ws))
staging: ReplicaDB | None = None
ready_at = 0.0
try:
while True:
if ready_at:
# Periodic reconnects give full-snapshot reconciliation
remaining = self.uplink.refresh_interval - (
time.monotonic() - ready_at
)
if remaining <= 0:
return
try:
message = msgspec.json.decode(
await asyncio.wait_for(ws.recv(), remaining)
)
except TimeoutError:
return # periodic resync: reconnect for a snapshot
else:
message = msgspec.json.decode(await ws.recv())
mtype = message.get("type")
if mtype == "snapshot":
staging = staging or ReplicaDB()
for key, fields in message["items"]:
_apply(staging, message["table"], key, fields)
elif mtype == "event":
if staging is not None:
raise ValueError("sync: event before ready")
_apply(
self.db,
message["table"],
message["key"],
message.get("fields"),
)
elif mtype == "domains":
# Live scope/origin change: apply and acknowledge.
rejected = apply_domains_push(
self.uplink.uplink_id,
message.get("domains") or [],
replica_db=self.db,
)
await ws.send(
syncfeed.encode({"type": "domains_ack", "rejected": rejected})
)
elif mtype == "ready":
if staging is not None:
self.db = staging
staging = None
self.connected = True
self.last_contact = ready_at = time.monotonic()
finally:
sender.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sender
async def _send_loop(self, ws) -> None:
while True:
self._refresh_signal.clear()
while self._pending_refresh:
_, message = self._pending_refresh.popitem()
await ws.send(syncfeed.encode(message))
await self._refresh_signal.wait()
def _replica_done(replica: UplinkReplica, task: asyncio.Task) -> None:
"""A replica's loop ended: a revocation (the only way out besides
cancellation) unlinks the satellite side."""
if task.cancelled():
return
exc = task.exception()
if not isinstance(exc, LinkRevoked):
return
_logger.info("Uplink %s revoked the link — unlinking", replica.uplink.url)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(manager.unlink(replica.uplink.uplink_id))
def _apply(replica: ReplicaDB, table: str, key: str, fields: dict | None) -> None:
"""Apply an upsert (fields given) or delete (fields None) to a replica.
Session upserts carry their upstream-computed context as ``ctx``."""
cls, uuid_key = _TABLES[table]
store = getattr(replica, table)
store_key = UUID(key) if uuid_key else key
if fields is None:
store.pop(store_key, None)
if table == "sessions":
replica.session_contexts.pop(key, None)
return
fields = dict(fields)
ctx = fields.pop("ctx", None) if table == "sessions" else None
obj = msgspec.convert(fields, cls)
if uuid_key:
obj.uuid = store_key
else:
obj.key = key
store[store_key] = obj
if table == "sessions":
if ctx is not None:
replica.session_contexts[key] = msgspec.convert(ctx, ApiSessionContext)
else:
replica.session_contexts.pop(key, None)
class SatelliteManager:
"""Replicas keyed by uplink id; one connection per uplink carries all
of the link's domains."""
def __init__(self):
self.replicas: dict[str, UplinkReplica] = {}
self.started = False
async def start(self) -> None:
self.started = True
await self.sync_uplinks()
async def stop(self) -> None:
self.started = False
for replica in self.replicas.values():
await replica.stop()
self.replicas.clear()
for url in list(_clients):
await _drop_client(url)
async def sync_uplinks(self) -> None:
"""Start/stop replicas to match the stored uplinks."""
wanted = db.data().uplinks
for uplink_id in list(self.replicas):
if uplink_id not in wanted:
replica = self.replicas.pop(uplink_id)
await replica.stop()
await _drop_client(replica.uplink.url)
for uplink_id, uplink in wanted.items():
uplink.uplink_id = uplink_id # fresh objects lack it until load
replica = self.replicas.get(uplink_id)
if replica is None or replica.uplink != uplink:
if replica is not None:
await replica.stop()
replica = UplinkReplica(uplink)
self.replicas[uplink_id] = replica
await replica.start()
async def unlink(self, uplink_id: str) -> None:
"""Drop an uplink and every domain it serves (admin unlink, or the
upstream revoked the link)."""
replica = self.replicas.pop(uplink_id, None)
if replica is not None:
await replica.stop()
await _drop_client(replica.uplink.url)
db.delete_uplink(uplink_id)
domains.init_registry(db.data().config)
manager = SatelliteManager()
# -------------------------------------------------------------------------
# Host-keyed dispatch: the only interface the rest of the app uses
# -------------------------------------------------------------------------
def replica_for_host(host: str | None) -> UplinkReplica | None:
"""The replica serving this host, or None for locally served hosts."""
domain = domains.registry().resolve(host)
if domain is None or domain.remote is None:
return None
return manager.replicas.get(domain.remote.link)
def store_for_host(host: str | None) -> DB:
"""The data store to read for a request host: the local database, or
the replica of the uplink backing the host's domain."""
domain = domains.registry().resolve(host)
if domain is None or domain.remote is None:
return db.data()
replica = manager.replicas.get(domain.remote.link)
if replica is None or not replica.available():
raise HTTPException(503, "Remote authentication service unavailable")
return replica.db
def refresh_session(
key: str,
host: str | None,
ip: str | None,
user_agent: str | None,
validated: datetime,
ctx=None,
):
"""/validate refresh: write-behind for remote domains, else local DB."""
replica = replica_for_host(host)
if replica is not None:
replica.refresh_session(key, validated, ip, user_agent)
else:
db.update_session(
key, ip=ip, user_agent=user_agent, validated=validated, ctx=ctx
)
def evict_session(auth: str, host: str | None) -> None:
"""Drop a session from the replica (its remote deletion arrives via sync)."""
replica = replica_for_host(host)
if replica is not None:
key = hash_secret("cookie", auth)
replica.db.sessions.pop(key, None)
replica.db.session_contexts.pop(key, None)
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
_HOP_BY_HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length",
"accept-encoding",
"content-encoding",
}
_clients: dict[str, httpx.AsyncClient] = {}
async def _drop_client(url: str) -> None:
"""Close and forget the forwarding client for an uplink URL."""
client = _clients.pop(url, None)
if client is not None:
await client.aclose()
def _uplink_url(domain: domains.Domain) -> str:
"""The base URL of the uplink backing a remote domain (503 if the
config and the uplinks table desynced — a bug, not a local answer)."""
uplink = db.data().uplinks.get(domain.remote.link)
if uplink is None:
raise HTTPException(503, "Remote authentication service unavailable")
return uplink.url
async def forward_request(request: Request) -> Response | None:
"""Forward the request to its domain's uplink, or None when local.
The original Host header is preserved so the upstream dispatches to the
same domain (sessions are host-bound). The user's cookie authenticates
the forwarded call; the satellite needs no credentials of its own.
"""
domain = domains.registry().resolve(request.headers.get("host"))
if domain is None or domain.remote is None:
return None
url = _uplink_url(domain)
client = _clients.get(url)
if client is None:
client = _clients[url] = httpx.AsyncClient(base_url=url, timeout=_TIMEOUT)
try:
upstream = await client.request(
request.method,
request.url.path,
params=request.url.query,
content=await request.body(),
headers={
k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP
},
)
except httpx.TransportError as e:
raise HTTPException(503, "Remote authentication service unavailable") from e
response = Response(content=upstream.content, status_code=upstream.status_code)
# Raw headers to preserve repeated Set-Cookie
response.raw_headers = [
(k, v) for k, v in upstream.headers.raw if k.decode().lower() not in _HOP_BY_HOP
]
return response