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:
1 parent
ebe18912fc
commit
05ea188ac9
1 file changed
+131
-98
+131
-98
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user