From b039422f264eab3590830238e70c969ef8469201 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 29 Sep 2026 03:44:57 +0000 Subject: [PATCH] Harden satellite: uplink probe in domain dialog, live e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for remote domains: - Domain dialog probes {url}/auth/api/settings as the admin types: reachability, rp-id match, and non-nesting (via a new remote flag in the settings response) — warnings, never submit blockers - Reject conflicting settings for domains sharing one remote; sanitize aligns with a warning instead - Forward profile/avatar requests (/auth/api/user/*) to the remote when a remote domain's auth host reaches the satellite, never write locally - Proxied calls answer 503 on transport errors (logout then deliberately keeps the local replica session — the remote never saw it) - Close forwarding httpx clients on reconcile removal and shutdown - Clamp cache_ttl/refresh_interval server-side; syncfeed.emit is a true no-op without subscribers; sync pump tolerates unexpected send errors - Rebuild listeners: no running loop warns instead of raising, tasks are strongly referenced and failures logged - tests/test_satellite_e2e.py: live over-the-wire suite — real uvicorn remote, real replica via reconcile: snapshot, events, write-behind, token gate, TTL fail-closed, reconnect reconciliation --- docs/RemoteProxy.md | 21 +- .../src/admin/dialogs/DomainEditDialog.vue | 44 +++ paskia/domains.py | 60 ++++- paskia/fastapi/admin/domains.py | 4 +- paskia/fastapi/api.py | 1 + paskia/fastapi/sync.py | 2 + paskia/fastapi/user.py | 15 +- paskia/satellite.py | 40 ++- paskia/syncfeed.py | 2 + paskia/util/apistructs.py | 1 + tests/test_remote.py | 150 ++++++++++- tests/test_satellite_e2e.py | 252 ++++++++++++++++++ 12 files changed, 566 insertions(+), 26 deletions(-) create mode 100644 tests/test_satellite_e2e.py diff --git a/docs/RemoteProxy.md b/docs/RemoteProxy.md index 515cfcf..da53206 100644 --- a/docs/RemoteProxy.md +++ b/docs/RemoteProxy.md @@ -36,7 +36,11 @@ In the stored config (`DomainConfig.remote`): - `url` — the remote instance's base URL. The satellite connects to `{url}/auth/api/sync/ws`; the connection is server-to-server and not - host-dispatched, so internal addresses work. + host-dispatched, so internal addresses work. The admin dialog probes + `{url}/auth/api/settings` as you type: it must be reachable, serve this + very rp-id, and not be a satellite for it itself (nested satellites are + not supported — always point at the authoritative instance). Probe + findings are warnings; the server-side validation is authoritative. - `token` — bearer token for the sync channel. The **remote accepts tokens via its `PASKIA_SYNC_TOKENS` environment variable** (comma-separated); nothing is stored in the remote's database, and with the variable unset @@ -44,9 +48,13 @@ In the stored config (`DomainConfig.remote`): API (an empty field keeps the stored one). - `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. + session lifetime) for fail-open behavior. Minimum 1. - `refresh_interval` — seconds between reconnects; every connect starts - from a full snapshot, which reconciles any drift. + from a full snapshot, which reconciles any drift. Minimum 30. + +Several domains may share one remote (they share a single replica); their +remote settings must then be identical — conflicting configurations are +rejected at validation and aligned with a warning at load. A remote domain **must mark an auth host** (validated cross-domain and in the UI): the profile, admin and sign-in pages live there, so browsers and @@ -86,6 +94,13 @@ signing key is never replicated. | `/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 | +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. + Freshness hierarchy: 1. Changes made **through** the satellite: immediate (write-behind, diff --git a/frontend/src/admin/dialogs/DomainEditDialog.vue b/frontend/src/admin/dialogs/DomainEditDialog.vue index 26682ac..4eb7745 100644 --- a/frontend/src/admin/dialogs/DomainEditDialog.vue +++ b/frontend/src/admin/dialogs/DomainEditDialog.vue @@ -45,6 +45,44 @@ const remoteMissingAuthHost = computed( () => !!props.dialog.data?.remote && !props.dialog.data?.auth_host ) +// --- Uplink verification --- +// Fetch the remote's /auth/api/settings (CORS-open), like the origin +// connectivity probes: proves reachability, that the peer is a paskia +// instance, and that it serves this very domain authoritatively — not as +// a satellite itself (nested satellites are not supported). Results are +// warnings, never submit blockers (the remote may not be deployed yet). +const uplinkCheck = ref(null) // null|validating|valid|unreachable|mismatch|nested +let uplinkTimer = null +let uplinkSeq = 0 +watch( + () => [props.dialog.data?.remote?.url, dialogRpId.value], + () => { + clearTimeout(uplinkTimer) + uplinkCheck.value = null + const url = props.dialog.data?.remote?.url?.trim().replace(/\/+$/, '') + if (!url || remoteUrlInvalid.value) return + uplinkTimer = setTimeout(() => checkUplink(url), 600) + }, + { immediate: true } +) + +async function checkUplink(url) { + const seq = ++uplinkSeq + uplinkCheck.value = 'validating' + try { + const response = await fetch(`${url}/auth/api/settings`, { + headers: { Accept: 'application/json' }, + }) + if (seq !== uplinkSeq) return // url/rp-id changed while fetching + if (!response.ok) throw new Error('not ok') + const data = await response.json() + uplinkCheck.value = + data.rp_id !== dialogRpId.value ? 'mismatch' : data.remote ? 'nested' : 'valid' + } catch { + if (seq === uplinkSeq) uplinkCheck.value = 'unreachable' + } +} + // Block submit on hard errors: malformed entries, an over-cap related // list (the server rejects the save), a save that would lock the admin // out of the domain they are using, or validation still in flight. @@ -406,6 +444,7 @@ function onDocumentClick(e) { onMounted(() => document.addEventListener('click', onDocumentClick)) onBeforeUnmount(() => { document.removeEventListener('click', onDocumentClick) + clearTimeout(uplinkTimer) for (const t of originValidateTimers.values()) clearTimeout(t) originValidateTimers.clear() }) @@ -556,6 +595,11 @@ function onRemoveOrigin(i) {

Must be an http(s) URL.

+

Checking the uplink…

+

✓ Uplink verified — the remote serves this domain authoritatively.

+

The remote is unreachable — the uplink cannot be verified. Save anyway only if it is not deployed yet.

+

The remote does not serve {{ dialogRpId }} — check the URL. Connect directly to the authoritative instance.

+

The remote is itself a satellite for this domain — nested satellites are not supported. Point directly at the authoritative instance.

A remote domain must mark an auth host above — profile, admin and sign-in pages live there (typically the remote's own site).