Restart safety: transient credential checks are 1013/503, not a refusal

- A device credential that cannot be checked now (device store not running,
  nonces unreadable) raises DeviceUnavailable: 503 over HTTP, and the
  /flows/ws handshake and the tunnel stream accept and close with 1013.
  1008 (a 403 before accepting) stays for a revoked or invalid one
- Device files are written through a temporary file, so a read mid-save no
  longer looks like a deleted device
- The first report turns Touch friendly on only for a device paired since
  pairings are noted (DeviceState.paired_at), so devices paired before keep
  the switch they were given
- NodeDef reads `device`/`device_policy` from older clients as the worker
  label, for a release
- Tests: transient vs revoked socket, the touch default, the old worker keys,
  and a restart with a device node and a node_health rule sending no push
This commit is contained in:
Melvin Strobl
2026-10-16 18:18:42 +02:00
parent 4286acadfd
commit 13d23f6baf
11 changed files with 228 additions and 26 deletions
+9 -6
View File
@@ -19,7 +19,7 @@ from fluksio.flow import devices
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardDef, DashboardStore
from fluksio.flow.devices import DeviceDef, DeviceStore, presence
from fluksio.flow.devices import DeviceDef, DeviceStore, DeviceUnavailable, presence
from fluksio.flow.workers import PythonWorkerPool
from fluksio.models import OAuthClient, TokenPayload, User, WorkerToken
@@ -59,12 +59,15 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
The claim is still called ``panel``: tokens in circulation say it, and
nothing pairs again for a rename.
What cannot be checked right now raises ``DeviceUnavailable`` rather than
refusing: a device told its credential is refused forgets it.
"""
store: DeviceStore | None = (
getattr(app.state, "device_store", None) if app else None
)
if app is None:
raise InvalidTokenError("Nothing to check a device credential against")
store: DeviceStore | None = getattr(app.state, "device_store", None)
if store is None:
raise InvalidTokenError("The device store is not running")
raise DeviceUnavailable("The device store is not running")
device = store.find(str(payload.get("panel", "")))
if device is None:
raise InvalidTokenError("This device no longer exists")
@@ -72,7 +75,7 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as exc:
raise InvalidTokenError(f"The device nonces are unreadable: {exc}")
raise DeviceUnavailable(f"The device nonces are unreadable: {exc}")
if int(payload.get("pnc") or 0) != nonce:
raise InvalidTokenError("This device was paired again")
presence.seen(device.id)
+10 -2
View File
@@ -625,7 +625,8 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
The first report of a touch screen makes the device touch friendly, as
the switch would have been set by hand. Later ones leave the setting to
whoever changed it since.
whoever changed it since, and so does the first report of a device paired
before pairings were noted: its switch was set by hand already.
"""
device: DeviceDef | None = getattr(request.state, "device", None)
if device is None or device.id != device_id:
@@ -650,7 +651,13 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
),
)
first = await run_in_threadpool(store.report, device_id, report)
if first and report.display and report.display.touch and not device.touch:
if (
first
and report.display
and report.display.touch
and not device.touch
and (await run_in_threadpool(store.state, device_id)).paired_at
):
with suppress(StaleVersion, DeviceNotFound):
await run_in_threadpool(
store.write,
@@ -724,6 +731,7 @@ def _adopt(
)
entry.device_id = device_id
_pending.save(code, entry)
store.paired(device_id)
return f"Paired {entry.name or entry.device} with {device_id}"
+18 -5
View File
@@ -1,6 +1,7 @@
"""The flow API. Everything the editor can do is available here first."""
import asyncio
import logging
import os
import time
from pathlib import Path
@@ -68,6 +69,8 @@ from fluksio.flow.store import (
)
from fluksio.models import Flavor, Message, Run
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)]
)
@@ -1221,15 +1224,25 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
# every other socket and every request is sharing.
def _authenticate() -> Any:
with Session(engine) as session:
return user_from_token(session, token, app=websocket.app)
user = user_from_token(session, token, app=websocket.app)
only = panel_scope(token, websocket.app) if user is not None else None
# A device is online for as long as this socket is open.
device = device_of(token, websocket.app) if only is not None else ""
return user, only, device
user = await run_in_threadpool(_authenticate)
try:
user, only, device = await run_in_threadpool(_authenticate)
except Exception as exc:
# Not a refusal: the database or the device store could not say. A
# device takes 1008, or a 403 before accepting, as its credential
# being gone and checks it, so this one is told to try again later.
logger.warning("Could not check a socket's credential: %s", exc)
await websocket.accept()
await websocket.close(code=1013)
return
if user is None:
await websocket.close(code=1008)
return
only = panel_scope(token, websocket.app)
# A device is online for as long as this socket is open.
device = device_of(token, websocket.app) if only is not None else ""
# Nothing until a client says it is drawing something: bytes are what this
# socket cannot afford to send speculatively.
wanted: set[str] = set()