M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)

This commit is contained in:
ARIA committed 2026-08-20 12:00:13 +02:00
1 parent 0cc8b7aafe
commit bf6bf7e8bd
26 files changed
+2225 -327

No files matched your search

+83 -6
View File
@@ -11,7 +11,9 @@ Per-connection handler:
2. On success: register in the device registry (SQLite) + connection
registry (``device_id -> {ws, caps, fcm_token}``), send
``hello.ack {server_caps, sync_cursor, channels[]}``.
3. Loop: decode frames, dispatch to adapter inbound handlers.
3. Loop: decode frames, dispatch to adapter inbound handlers. Inbound JSON
frames are rate-limited per connection (token bucket, ``INBOUND_RATE_PER_S``
/ ``INBOUND_BURST``); binary media-upload chunks are exempt.
4. On close: deregister.
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
@@ -44,12 +46,46 @@ HELLO_TIMEOUT_S = 10.0
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
SEND_TIMEOUT_S = 10.0
# Inbound JSON control-frame rate limit (per connection, token bucket).
# A legitimate app sends pings + occasional user-initiated requests — far
# below 20/s sustained. Binary media-upload chunks are EXEMPT (see
# ``_on_frame``): a 100 MB upload is 400 x 256 KiB frames in a tight loop
# and would exhaust any sane bucket; uploads are bounded instead by the
# per-frame ``max_size`` and the per-upload total cap (``media.py``).
INBOUND_RATE_PER_S = 20.0
INBOUND_BURST = 40
# Close codes (4000-4999 are reserved for applications).
CLOSE_AUTH_FAILED = 4401
CLOSE_REPLACED = 4402
CLOSE_RATE_LIMITED = 4403
CLOSE_SHUTDOWN = 1001
class _TokenBucket:
"""Minimal token bucket (stdlib only). One instance per connection."""
__slots__ = ("rate", "burst", "tokens", "updated_at")
def __init__(self, rate: float, burst: int):
self.rate = rate
self.burst = burst
self.tokens = float(burst)
self.updated_at = time.monotonic()
def consume(self) -> bool:
"""Try to take one token. Refills at ``rate``/s up to ``burst``."""
now = time.monotonic()
elapsed = now - self.updated_at
if elapsed > 0:
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self.updated_at = now
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False
@dataclass
class DeviceConnection:
"""One live, authenticated device socket."""
@@ -61,6 +97,9 @@ class DeviceConnection:
fcm_token: Optional[str] = None
ntfy_topic: Optional[str] = None
connected_at: float = field(default_factory=time.time)
rate_bucket: _TokenBucket = field(
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
)
class WsServer:
@@ -254,6 +293,9 @@ class WsServer:
)
try:
await ws.send(ack.to_json())
# M7: tell late-joining clients the current gateway health state
# (the startup broadcast only reaches clients already connected).
await ws.send(protocol.status(self._adapter.gateway_status()).to_json())
except Exception:
return
logger.info("android: device paired: %s (%s)", device_name, device_id)
@@ -261,7 +303,11 @@ class WsServer:
# 3. frame loop ------------------------------------------------------
try:
async for raw in ws:
await self._on_frame(ws, device_id, raw)
# ``_on_frame`` returns False once it has closed the socket
# (rate limit); stop draining the buffered frames so a
# flood doesn't re-trigger the error+close per frame.
if not await self._on_frame(ws, device_id, raw):
break
except ConnectionClosed:
pass
except Exception:
@@ -280,16 +326,38 @@ class WsServer:
# ── Inbound dispatch ──────────────────────────────────────────────────
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> None:
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> bool:
"""Dispatch one inbound frame. Returns False once the socket has been
closed (rate limit) so the caller stops draining buffered frames."""
# M4: binary frames are media upload chunks (raw bytes, no JSON
# envelope). Route them to the active upload session.
# envelope). Route them to the active upload session. They are
# EXEMPT from the inbound rate limit: a 100 MB upload is 400 x
# 256 KiB frames in a tight loop, which would exhaust any sane
# frame bucket. Uploads are bounded instead by the per-frame
# ``max_size`` and the per-upload total cap (``media.py``).
if isinstance(raw, (bytes, bytearray, memoryview)):
await self._adapter.on_media_chunk(device_id, bytes(raw))
return
return True
# Inbound rate limit (JSON control frames only). On exceed: error +
# close, same pattern as auth rejection.
conn = self._connection_for(ws)
if conn is not None and not conn.rate_bucket.consume():
logger.warning(
"android: inbound rate limit exceeded for %s; closing", device_id
)
await self._send_quiet(
ws,
protocol.error(
protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"
),
)
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
return False
frame = protocol.Frame.from_json(raw)
if frame is None:
return # malformed JSON: ignore (forward-compat)
return True # malformed JSON: ignore (forward-compat)
if frame.type == protocol.TYPE_PING:
ts = frame.payload.get("ts")
@@ -319,9 +387,18 @@ class WsServer:
elif frame.type == protocol.TYPE_FCM_REGISTER:
await self._adapter.on_fcm_register(frame, device_id)
# Unknown types are ignored (forward-compat).
return True
# ── Helpers ───────────────────────────────────────────────────────────
def _connection_for(self, ws: ServerConnection) -> Optional[DeviceConnection]:
"""The live registry entry for this exact socket (identity match, so
a replaced socket never consumes the new connection's bucket)."""
for conn in self._connections.values():
if conn.ws is ws:
return conn
return None
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
try:
await ws.send(frame.to_json())