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.
This commit is contained in:
LeoVasanko committed 2026-10-01 06:16:50 +00:00
1 parent ebe18912fc
commit 05ea188ac9
1 file changed
+131 -98
+131 -98
View File
@@ -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://<token>@auth.example.com
wss://<initial-key>@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
`**.<rp-id>` 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 <url>` 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": "<sync token>",
"cache_ttl": 60,
"refresh_interval": 300
}
"remote": { "link": "<uplink id>" }
```
- `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: <initial key>, label}
upstream → satellite welcome {link, key: <server part>, domains: [...]}
satellite → upstream ready {key: <client part>, 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).