From 05ea188ac950c3dc500a130cd2b5b863dcac185e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 1 Oct 2026 06:16:50 +0000 Subject: [PATCH] Docs: RemoteProxy.md for the link model Configuration is one copy-paste of a wss link URL (Satellites panel or the paskia satellite CLI), registration is the online 3-way handshake, scope is adjustable post-registration, the upstream is authoritative for pushed domains, revocation unlinks both sides. --- docs/RemoteProxy.md | 229 +++++++++++++++++++++++++------------------- 1 file changed, 131 insertions(+), 98 deletions(-) diff --git a/docs/RemoteProxy.md b/docs/RemoteProxy.md index 10c3132..cdaa2cb 100644 --- a/docs/RemoteProxy.md +++ b/docs/RemoteProxy.md @@ -1,6 +1,6 @@ # Remote Proxy / Satellite -Paskia can serve a configured domain (rp-id) from a **remote** paskia +Paskia can serve configured domains (rp-ids) from a **remote** paskia instance instead of the local database, so latency-sensitive checks (`/auth/api/forward`, `/auth/api/validate`) answer in ~1 ms even when the auth server is on another continent. Example: `app2.example.com` runs on @@ -13,98 +13,128 @@ backend (forward-auth checks) only repoint to the local satellite satellite ultimately uses `auth.example.com`. Status: **implemented**. The feature lives in `paskia/satellite.py` -(satellite side: replica, sync client, host dispatch, forwarding) and -`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (remote side: change -feed and sync WebSocket). The design review comparing the rejected -alternatives is at the end of this document. +(satellite side: uplinks, replicas, host dispatch, forwarding) and +`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (upstream side: change +feed, registration handshake, sync WebSocket). The design review comparing +the rejected alternatives is at the end of this document. ## Configuration -Setup is a single paste. On the **upstream** (the authoritative instance), -open the domain in the admin UI and use *Satellite access → Generate sync -URL*. That mints a token scoped to the domain's rp-id and returns one URL -carrying everything the satellite needs: +Setup is a single copy-paste of a **link URL**, and everything else flows +from it. On the **upstream** (the authoritative instance), open the admin +*Satellites* panel and use *New satellite link*, choosing the domains the +satellite will serve. That mints a registration offer and shows one URL: ``` -https://@auth.example.com +wss://@auth.example.com ``` -On the **satellite**, open (or add) the same domain in its admin UI, -enable *Remote instance*, and paste the sync URL. The dialog probes the -upstream's `/auth/api/settings` as you type: it must be reachable, serve -this very rp-id authoritatively (a satellite cannot upstream another -satellite), and have an auth host configured — only such an instance can -serve satellites at all. Once the uplink verifies, the remote -configuration takes over completely: the domain's origins become -`**.` plus the upstream's auth host, and saving requires the -verified uplink. +The `wss://` scheme is deliberate: the URL is meant only for a satellite +to connect to, and browsers refuse to open it. The initial key is held in +the upstream's RAM only, expires in 10 minutes, and is single-use — losing +or leaking it just means minting a new one. The permanent credentials are +created during registration (see *How it works*). -In the stored config (`DomainConfig.remote`): +On the **satellite**, either: + +- paste the URL in its admin *Satellites* panel under *Uplinks → Add + uplink* — registration runs immediately and reports failures inline; or +- run `paskia satellite ` on the satellite host — the whole setup of + a satellite-only installation (it creates the database on first run; no + local domain or admin site is needed). + +That's all: the upstream pushes the link's domain set — rp-id, display +name and the origins table verbatim, including its auth host — and the +satellite persists it. The upstream is authoritative: pushed domains are +validated against the satellite's local configuration (an rp-id already +served locally, or origins colliding with it, are rejected and reported +back into the upstream's link list), and remote domains are read-only on +the satellite — the only satellite-side configuration is linking and +unlinking. + +A link's scope is **adjustable at any time** from the upstream's link list +(edit which domains it serves): the new set is pushed over the live link — +register once, no new cryptographic setup. Revoking a link drops the +satellite's connections; the satellite observes the policy close and +unlinks its side (its copy of the domains is removed). + +A satellite-only instance serves its backend apps on local addresses and +needs no inbound connectivity at all — the link is an outbound WebSocket +from the satellite. + +In the stored config, a remote domain carries only the link reference: ```json -"remote": { - "url": "https://auth.example.com", - "token": "", - "cache_ttl": 60, - "refresh_interval": 300 -} +"remote": { "link": "" } ``` -- `url` — the upstream's auth host URL (the userinfo token is split off - before storing). The satellite connects to `{url}/auth/api/sync/ws`; - the connection is server-to-server and not host-dispatched, so internal - addresses work. -- `token` — bearer token for the sync channel, stored **hashed in the - upstream's database** and scoped to one rp-id. Managed in the upstream - admin UI (the domain's *Satellite access* section: generate, list, - revoke); the raw token is shown once, inside the sync URL. On the - satellite it is write-only over the admin API (a sync URL without - userinfo keeps the stored token). Revoking a token drops the satellite's - live connections immediately. -- `cache_ttl` — seconds the replica remains trusted after the sync channel - goes down; then checks fail closed (503). Set it large (up to the 24 h - session lifetime) for fail-open behavior. Minimum 1. -- `refresh_interval` — seconds between reconnects; every connect starts - from a full snapshot, which reconciles any drift. Minimum 30. +and the uplink itself lives in the database's `uplinks` table (the +upstream URL, the shared `key` bytes from the handshake, and the local +tolerance knobs): -Each remote domain gets its own replica and its own token; several domains -may point at the same upstream instance independently. +- `cache_ttl` (default 60) — seconds the replica remains trusted after + the sync channel goes down; then checks fail closed (503). Set it large + (up to the 24 h session lifetime) for fail-open behavior. +- `refresh_interval` (default 300) — seconds between reconnects; every + connect starts from a full snapshot, which reconciles any drift. -A remote domain **must mark an auth host** (the uplink's own — the dialog -derives it): the profile, admin and sign-in pages live there, so browsers -and WebSockets go directly to the upstream. Other domains on the same -satellite remain fully local — the multi-domain config mixes both kinds -freely. +On the upstream, links live in the `satellite_links` table: the peer's +`key` bytes, its self-reported label, and the scope (`rp_ids`). ## How it works -**Dispatch is keyed by host, and only this module knows about stores.** -`satellite.store_for_host(host)` returns the local DB or the replica of -the remote backing the host's domain (raising 503 `HTTPException` when -the replica is unavailable). The session read path (`session_ctx`, -`authz.verify`, `build_user_info`, `/check`) just passes the host it +**Registration and the sync channel share one endpoint** — +`{upstream}/auth/api/sync/ws` — multiplexed by the first binary frame. +Registration is a 3-way dialogue over WebSocket binary frames: + +``` +satellite → upstream hello {link: null, key: , label} +upstream → satellite welcome {link, key: , domains: [...]} +satellite → upstream ready {key: , rejected: {...}} +``` + +The permanent link credential is created during the handshake from key +parts both sides contribute; both sides then store the link in their +databases. (Cryptography is currently a placeholder: the parts are +exchanged as-is. The final version derives the channel key from the +handshake without sending key material, and the JSON frames that follow +are transparently encrypted.) + +An established link runs the same dialogue with `hello {link, key}` +proving the stored credential; the welcome re-pushes the link's domain set +(the scope may have changed while the satellite was offline), the ready is +a bare ack carrying any domain rejections. After the handshake the channel +switches to plain JSON frames: a snapshot of the scoped tables, `ready`, +then live upsert/delete events; the satellite sends `session_refresh` +write-backs. A live `domains` frame pushes scope or origin changes; +the satellite applies and acknowledges. + +**Dispatch is keyed by host, and only the satellite module knows about +stores.** `satellite.store_for_host(host)` returns the local DB or the +replica of the uplink backing the host's domain (raising 503 +`HTTPException` when the replica is unavailable). The session read path +(`session_ctx`, `authz.verify`, `build_user_info`) just passes the host it already has; writes dispatch likewise (`satellite.refresh_session` — write-behind for remote, `db.update_session` for local; `satellite.evict_session` on logout). `satellite.forward_request(request)` returns the proxied response for remote domains or `None` for local ones. -**The replica** is a `ReplicaDB` instance in RAM, never persisted. On -connect the upstream sends a snapshot of the replicated tables -(the domain's credentials and sessions), then live upsert/delete events -emitted from the struct `store()`/`delete()` hooks (which also cover -cascade deletes) and field-mutating operations. The feed is scoped to the -token's rp-id, and the user graph never leaves the upstream: every session -upsert carries an embedded `ctx` — the same `ApiSessionContext` sent to -users — holding the session's user/org/role display data and effective -permissions, computed for the session's bound host. Mutations of users, -orgs, roles or permissions are not published; instead the upstream eagerly -re-pushes the affected sessions with freshly computed contexts, so renames -and permission revocations take effect within ~1 RTT. The replica refuses -to go live when the scope in the ready message does not match its domain. -A single ordered WebSocket cannot gap; a slow subscriber is dropped and -resyncs. The feed carries no usable secrets: sessions are keyed by -`hash_secret` output, credentials carry public keys only, and the OIDC -signing key is never replicated. +**The replica** is a `ReplicaDB` instance in RAM, never persisted, one per +uplink connection. On connect the upstream sends a snapshot of the scoped +tables (the link's domains' credentials and sessions), then live +upsert/delete events emitted from the struct `store()`/`delete()` hooks +(which also cover cascade deletes) and field-mutating operations. The feed +is scoped to the link's rp-ids, and the user graph never leaves the +upstream: every session upsert carries an embedded `ctx` — the same +`ApiSessionContext` sent to users — holding the session's user/org/role +display data and effective permissions, computed for the session's bound +host. Mutations of users, orgs, roles or permissions are not published; +instead the upstream eagerly re-pushes the affected sessions with freshly +computed contexts, so renames and permission revocations take effect +within ~1 RTT. A single ordered WebSocket cannot gap; a slow subscriber is +dropped and resyncs. The feed carries no usable secrets: sessions are +keyed by `hash_secret` output, credentials carry public keys only, and the +OIDC signing key is never replicated. ## Endpoint behavior for remote domains @@ -113,44 +143,46 @@ signing key is never replicated. | `GET /auth/api/forward`, `GET /settings` | served from the replica (sub-ms) | | `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally | | `GET /check`, `GET /user-info` | proxied — these need the user graph (per-session contexts do not cover arbitrary user lookups or full profile data) | -| `POST /auth/api/logout` | proxied to the remote (original Host preserved) and evicted from the replica immediately | -| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the remote); the session arrives via sync event | -| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the remote) | -| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the remote directly | +| `POST /auth/api/logout` | proxied to the upstream (original Host preserved) and evicted from the replica immediately | +| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the upstream); the session arrives via sync event | +| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the upstream) | +| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the upstream directly | -Proxied calls answer 503 when the remote is unreachable. `/auth/api/settings` -reports `remote: true` for a satellite-served domain (this is what the admin -dialog's uplink probe checks). Should a remote domain's auth host be -misrouted to the satellite anyway, profile/avatar requests -(`/auth/api/user/*`) are forwarded to the remote rather than answered from -local storage — the satellite never takes writes for a remote domain. +Proxied calls answer 503 when the upstream is unreachable. +`/auth/api/settings` reports `remote: true` for a satellite-served domain. +Should a remote domain's auth host be misrouted to the satellite anyway, +profile/avatar requests (`/auth/api/user/*`) are forwarded to the upstream +rather than answered from local storage — the satellite never takes writes +for a remote domain. Freshness hierarchy: 1. Changes made **through** the satellite: immediate (write-behind, optimistic eviction). -2. Changes made **directly on the remote**: a sync event, ~1 network RTT. +2. Changes made **directly on the upstream**: a sync event, ~1 network + RTT. 3. Channel down: the replica stays authoritative until `cache_ttl` past the disconnect (dead-peer detection is bounded by the ~10 s keepalive), then 503. Every reconnect starts from a fresh snapshot. -## The remote side +## The upstream side -Strictly additive and RAM-only besides the token table: `syncfeed` (a -subscriber set fed by the commit hooks) and the `/auth/api/sync/ws` -endpoint serving snapshot + live events and accepting `session_refresh` -write-backs within the token's scope. With no satellites connected, the -hooks are a no-op. +Nearly additive: the link table, RAM-only registration offers and the +`syncfeed` subscriber set fed by the commit hooks, plus the +`/auth/api/sync/ws` endpoint doing the handshake, serving snapshots and +live events, pushing domain configuration, and accepting +`session_refresh` write-backs within the link's scope. With no satellites +connected, the hooks are a no-op. ## Trust and caveats -- The satellite host holds only its own domain's sessions (with their +- The satellite host holds only its link's domains' sessions (with their identity contexts) and credentials in RAM — it learns the users that - actually sign in to its domain, never the directory, and never the OIDC - key. Still treat it as trusted: it is authoritative for session checks - within `cache_ttl`. -- Avatars are stored on the remote's disk; profile data such as - `user-info` is proxied to the remote rather than answered locally. + actually sign in to those domains, never the directory, and never the + OIDC key. Still treat it as trusted: it is authoritative for session + checks within `cache_ttl`. +- Avatars are stored on the upstream's disk; profile data such as + `user-info` is proxied to the upstream rather than answered locally. - OIDC sessions in a proxied `user-info` show the client UUID rather than its name (OIDC clients are not replicated). @@ -191,7 +223,7 @@ within one RTT instead of at TTL. - **Multi-domain uniformity.** Remote backing is a property of a domain in the existing registry; local and remote rp-ids coexist in one instance. A is a separate component bolted in front of specific URLs. -- **User simplicity.** Configured once in domain config; A needs +- **User simplicity.** One link registration per satellite; A needs deployment and cache-key discipline per frontend application. ## What it costs @@ -202,9 +234,10 @@ within one RTT instead of at TTL. contextvar-dependent `db.data()` was rejected: a global accessor whose meaning shifts under the caller. Dispatch is instead keyed explicitly by the request host.) -- A sync protocol (snapshot + live events + reconnect reconciliation). -- A trusted satellite host (its domain's sessions and credentials in RAM). -- Additive remote code (sync endpoint + commit hooks), where A needs +- A sync protocol (registration handshake, snapshot + live events + + reconnect reconciliation, live domain pushes). +- A trusted satellite host (its link's sessions and credentials in RAM). +- Additive upstream code (sync endpoint + commit hooks), where A needs none. - Replica housekeeping (expiry sweeper, write-behind, optimistic eviction).