Fix replica-path leaks and availability semantics from live testing

- _remote_headers and /check used struct convenience properties that
  read the global database; they now use the SessionContext / the handed
  store (also fixes Remote-Credential carrying a struct repr instead of
  the UUID).
- Replica availability: TTL clock starts at disconnect, not at last
  message or failed reconnect; tight WS keepalive for prompt dead-peer
  detection.
- Proxy preserves repeated Set-Cookie via raw headers; sync endpoint
  does its own accept (wsutil decorator pre-accepts) and bypasses host
  dispatch (server-to-server; satellite may use an out-of-domain
  address).
- Admin-credential bootstrap warning skips remote domains.

Verified live with two instances (remote :4501, satellite :4402):
replica snapshot + events, 204 forward with Remote-* in <1ms, validate
write-behind landing on the remote, proxied logout with instant local
eviction, 503 after cache_ttl of disconnect, resync after remote
restart.
This commit is contained in:
2026-09-20 23:45:01 +00:00
parent 9394c38179
commit 44364fdffc
7 changed files with 268 additions and 58 deletions
+4 -1
View File
@@ -67,7 +67,10 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials on any configured domain
admin_user = admin_users[0]
reg = domains.registry()
configured = sorted(d.rp_id for d in reg.domains)
# Remote domains hold their credentials on the remote instance
configured = sorted(d.rp_id for d in reg.domains if d.remote is None)
if not configured:
return False
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
# Admin exists but has no credential on any domain
+4 -4
View File
@@ -189,13 +189,13 @@ async def check_user(
data = _store(request)
try:
u = data.users[user_uuid]
role = u.role
org = role.org
role = data.roles[u.role_uuid]
org = data.orgs[role.org_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
host = hostutil.normalize_host(request.headers.get("host"))
org_perm_uuids = {p.uuid for p in org.permissions}
org_perm_uuids = {p.uuid for p in data.permissions.values() if org.uuid in p.orgs}
effective_perms = []
for perm_uuid in role.permission_set:
@@ -236,7 +236,7 @@ def _remote_headers(ctx) -> dict[str, str]:
"Remote-Session-Expires": (
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential),
"Remote-Credential": str(ctx.credential.uuid),
}
+5
View File
@@ -63,6 +63,11 @@ class DispatchMiddleware:
host = _header(scope, "host")
host_domain = registry.resolve(host)
if host_domain is None:
# The sync endpoint is server-to-server and token-gated: the
# satellite may reach us via an address outside our domains.
if scope.get("path") == "/auth/api/sync/ws" and registry.domains:
await self._dispatch(scope, receive, send, registry.domains[0])
return
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
return
+8 -10
View File
@@ -50,13 +50,11 @@ async def proxy_to_remote(request: Request, remote: RemoteConfig) -> Response:
content=await request.body(),
headers=headers,
)
response_headers = {
k: v
for k, v in upstream.headers.multi_items()
if k.lower() not in _SKIP_RESPONSE_HEADERS
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=response_headers,
)
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 _SKIP_RESPONSE_HEADERS
]
return response
+31 -26
View File
@@ -12,7 +12,6 @@ import msgspec
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, syncfeed
from paskia.fastapi.wsutil import websocket_error_handler
_logger = logging.getLogger(__name__)
@@ -64,7 +63,6 @@ async def _apply_client_message(message: dict) -> None:
@app.websocket("/ws")
@websocket_error_handler
async def sync_websocket(ws: WebSocket):
tokens = syncfeed.tokens_from_env()
auth = ws.headers.get("authorization", "")
@@ -77,33 +75,40 @@ async def sync_websocket(ws: WebSocket):
feed = syncfeed.feed
await _send(ws, {"type": "hello", "generation": feed.generation, "seq": feed.seq})
# The client always speaks first: resume request (possibly null fields)
resume = msgspec.json.decode(await ws.receive_bytes())
queue = feed.subscribe()
try:
replay = None
if (
resume.get("type") == "resume"
and resume.get("generation") == feed.generation
and isinstance(resume.get("seq"), int)
):
replay = feed.replay_since(resume["seq"])
if replay is not None:
for event in replay:
await _send(ws, event)
else:
for chunk in _snapshot_messages():
await _send(ws, chunk)
await _send(ws, {"type": "ready", "seq": feed.seq})
sender = asyncio.create_task(_pump(ws, queue))
# The client always speaks first: resume request (possibly null fields)
resume = msgspec.json.decode(await ws.receive_bytes())
queue = feed.subscribe()
try:
while True:
await _apply_client_message(msgspec.json.decode(await ws.receive_bytes()))
replay = None
if (
resume.get("type") == "resume"
and resume.get("generation") == feed.generation
and isinstance(resume.get("seq"), int)
):
replay = feed.replay_since(resume["seq"])
if replay is not None:
for event in replay:
await _send(ws, event)
else:
for chunk in _snapshot_messages():
await _send(ws, chunk)
await _send(ws, {"type": "ready", "seq": feed.seq})
sender = asyncio.create_task(_pump(ws, queue))
try:
while True:
await _apply_client_message(
msgspec.json.decode(await ws.receive_bytes())
)
finally:
sender.cancel()
finally:
sender.cancel()
finally:
feed.unsubscribe(queue)
feed.unsubscribe(queue)
except WebSocketDisconnect:
pass
except Exception:
_logger.exception("Sync WebSocket failed")
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
+25 -6
View File
@@ -12,6 +12,7 @@ cache_ttl for fail-open behavior bounded by session expiry).
"""
import asyncio
import contextlib
import logging
import time
from datetime import UTC, datetime
@@ -73,6 +74,7 @@ class RemoteReplica:
self.generation: str | None = None
self.seq = 0
self.last_contact = 0.0 # monotonic time of last snapshot/event
self.connected = False
self._pending_refresh: dict[str, dict] = {}
self._refresh_signal = asyncio.Event()
self._task: asyncio.Task | None = None
@@ -80,9 +82,16 @@ class RemoteReplica:
self._stopped = True
def available(self) -> bool:
return (
self.last_contact > 0
and time.monotonic() - self.last_contact <= self.remote.cache_ttl
"""Synced and either connected now or within cache_ttl of silence.
The websockets keepalive drops a wedged connection, so a live
connection means events arrive within one round trip; after losing
it the replica remains trusted for cache_ttl.
"""
if not self.last_contact:
return False
return self.connected or (
time.monotonic() - self.last_contact <= self.remote.cache_ttl
)
def refresh_session(self, key: str, validated, ip: str, user_agent: str) -> None:
@@ -114,7 +123,7 @@ class RemoteReplica:
for task in (self._task, self._sweeper):
if task:
task.cancel()
with asyncio.suppress(asyncio.CancelledError):
with contextlib.suppress(asyncio.CancelledError):
await task
async def _sweep(self) -> None:
@@ -132,6 +141,11 @@ class RemoteReplica:
raise
except Exception as e:
_logger.info("Sync to %s failed: %s", self.remote.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)
@@ -143,7 +157,11 @@ class RemoteReplica:
)
resume = {} if full_resync else {"generation": self.generation, "seq": self.seq}
async with websockets.connect(
ws_url, additional_headers={"Authorization": f"Bearer {self.remote.token}"}
ws_url,
additional_headers={"Authorization": f"Bearer {self.remote.token}"},
# Prompt dead-peer detection: availability semantics count on it
ping_interval=5,
ping_timeout=5,
) as ws:
hello = msgspec.json.decode(await ws.recv())
if hello.get("type") != "hello":
@@ -182,9 +200,10 @@ class RemoteReplica:
attach_stores()
self.generation = hello["generation"]
self.seq = message["seq"]
self.connected = True
finally:
sender.cancel()
with asyncio.suppress(asyncio.CancelledError):
with contextlib.suppress(asyncio.CancelledError):
await sender
async def _send_loop(self, ws) -> None: