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:
+4
-1
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user