Device security and robustness (openpanel C0): check code, pnc, lenient report

- POST /devices/pair answers a 2-character `check`, listed by /devices/pending
  and shown beside each waiting entry; five codes a minute per address, and at
  50 waiting the oldest gives way instead of a 429
- portal-minted device tokens are asked for with the device's nonce (`pnc`) and
  held against it, so unpair and delete revoke them; one without `pnc` stands
  only while the nonce is 0
- a device credential acts as the device (`device_principal`), not as the
  approving superuser, so deleting that account unpairs nothing
- the report is validated port by port: unknown enum values read as `unknown`,
  unreadable ports and sections are left out and logged, `config` may name an
  entry of `config_schemas`; 422 only for no schema 1 or no object
- devices.json is parsed once per change (cached by stat), delete drops the
  report and keeps the nonce, a busy recheck runs in the background
- scope and io only from enabled flows (start/stop tells the devices), a port
  bound twice gives one io row
- the Devices tab's saves cancel and hold back device refetches until settled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
This commit is contained in:
Melvin Strobl
2026-10-16 19:26:05 +02:00
co-authored by Claude Opus 5.5
parent 1e920c5bad
commit 4701e3a5fb
21 changed files with 773 additions and 119 deletions
+44 -15
View File
@@ -53,9 +53,10 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
effect at once. Or the device's nonce moved on, which revokes exactly one
screen's and leaves the device and its dashboards alone.
The nonce is only held against a credential this instance signed. One the
portal minted for a remote screen carries none, the portal names the
device and nothing else, and is revoked at the hub instead.
The nonce is held against a credential the portal minted for a remote
screen as well, which carries the nonce this instance named when it asked
for it. One from an older portal carries none and reads as 0, so it
stands only until the device is first unpaired or deleted.
The claim is still called ``panel``: tokens in circulation say it, and
nothing pairs again for a rename.
@@ -71,13 +72,12 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
device = store.find(str(payload.get("panel", "")))
if device is None:
raise InvalidTokenError("This device no longer exists")
if payload.get("aud") == security.PANEL_AUDIENCE:
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as 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")
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as 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)
return device
@@ -189,8 +189,9 @@ def _device_digests(device: DeviceDef, request: Request) -> set[str]:
def _device_may(payload: dict[str, Any], request: Request) -> None:
"""Refuse anything a wall panel has no business asking for.
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
A panel credential acts as the device itself, an account of no rights
(``device_principal``), and what that account may reach is decided here
and nowhere else. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition and report, the messages its own widgets and
ports bind to, and the bytes those messages currently point at, for a tile
@@ -214,9 +215,9 @@ def _device_may(payload: dict[str, Any], request: Request) -> None:
The device is left on ``request.state`` for the route, which credits a
value published on one of its ports to the node holding that port.
# ponytail: the device file, its nonce and the published dashboards are
# re-read per request. Cache them behind the store's revision if this
# shows up in a profile.
# ponytail: the device file and the published dashboards are re-read per
# request (the nonces are cached by the file's stat). Cache them behind
# the store's revision if this shows up in a profile.
"""
device = _device_for(payload, request.app)
request.state.device = device
@@ -402,9 +403,35 @@ def _gate_panel(
return payload
#: Where the ids of device principals come from, so each device has its own.
_DEVICE_IDS = uuid.UUID("5e1f2a0c-6d3b-4c1e-9f0a-0d3c1e5b7a20")
def device_principal(device_id: str) -> User:
"""The account a device credential acts as: the device, and no person.
Not stored. A device's credential is checked against the device itself
(``_device_for``) and bounded by route (``_device_may``), so no account's
rights are in play, and deleting or deactivating the superuser who
approved the pairing leaves the device paired. Never a superuser, so a
route gated on one refuses it even past the scope check.
"""
return User(
id=uuid.uuid5(_DEVICE_IDS, device_id),
email=f"{device_id}@device.invalid",
hashed_password="",
full_name=device_id,
is_active=True,
is_superuser=False,
)
def _user_for(session: Session, token_data: TokenPayload) -> User | None:
"""The local account a payload names, by id or by portal identity.
A device credential, already checked by ``decode_token``, names the
device instead, which acts as itself.
A token the portal minted names a person on the portal, not a user here, so
a mapping is what turns one into the other. There are two ways one comes to
exist: a superuser typed their code in, which makes the account up front,
@@ -418,6 +445,8 @@ def _user_for(session: Session, token_data: TokenPayload) -> User | None:
the account it named, and a person dropped at the portal is not adopted
again.
"""
if token_data.panel:
return device_principal(token_data.panel)
if token_data.portal_sub:
user = session.exec(
select(User).where(User.portal_sub == token_data.portal_sub)
+85 -26
View File
@@ -6,6 +6,10 @@ device's page to say which device it is, or picks the request from the list of
devices waiting on this network. The device polls, collects the credential the
approval minted, and never asks again.
A request from the list carries a two-character check that the device shows
beside its code, so whoever picks it can tell it is the screen in front of
them: behind some proxies every address looks private.
A screen hanging somewhere this instance is not reachable from does the
same thing through the portal, which forwards the two pairing calls down the
tunnel without a session and mints the credential itself when the approval
@@ -16,7 +20,8 @@ The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards
that device was assigned, the messages their widgets bind to, the messages the
device nodes in published flows wire its own ports to, and nothing else.
Deleting the device revokes it, and so does unpairing, which bumps the
device's nonce and leaves what it shows as it was.
device's nonce and leaves what it shows as it was. That holds for a credential
the portal minted too, which carries the nonce the device had when it paired.
Once paired, a device says what it has with ``PUT /devices/{id}/report``: its
board, its screen and the ports it serves, which the device node's ports are
@@ -29,6 +34,8 @@ device has updated.
from __future__ import annotations
import asyncio
import logging
import secrets
import time
from contextlib import suppress
@@ -47,6 +54,7 @@ from fluksio.api.deps import (
get_current_active_superuser,
get_current_user,
)
from fluksio.api.routes.oauth import too_many
from fluksio.cloud import config as cloud_config
from fluksio.core import security
from fluksio.core.config import settings
@@ -57,16 +65,18 @@ from fluksio.flow.devices import (
DeviceExists,
DeviceIo,
DeviceNotFound,
DeviceReport,
DeviceStore,
is_lan,
presence,
read_report,
)
from fluksio.flow.events import event_bus
from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S
from fluksio.flow.store import StaleVersion
from fluksio.models import Message, User
logger = logging.getLogger(__name__)
#: Gated per route rather than on the router: the pairing endpoints are the
#: only unauthenticated ones in the app, because a device with no credential is
#: the whole point of them.
@@ -84,10 +94,18 @@ PAIR_TTL = 600
#: No I/O/0/1: the code is read off a wall and typed on a keyboard.
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 6
#: The check a device shows beside its code, to compare with the pending list.
CHECK_LENGTH = 2
#: Refuse to grow past this. Codes are free to ask for and cost memory.
#: Never more waiting than this: codes are free to ask for and cost memory.
#: The oldest makes room for a new one, so a flood cannot lock a device out.
MAX_PENDING = 50
#: Codes one address may ask for per window. A device asks once per code,
#: every ten minutes, and again after a failure.
PAIR_PER_ADDRESS = 5
PAIR_WINDOW_S = 60.0
#: Ids a device cannot take: the routes beside ``/{device_id}``.
RESERVED = {"pair", "pending"}
@@ -107,6 +125,8 @@ class _Pending(BaseModel):
#: Names the request in the pending list, so it can be adopted without
#: the code being handed to anyone.
request: str = ""
#: Shown by the device and beside its entry in the list. Not a secret.
check: str = ""
#: Wall-clock rather than monotonic: an entry outlives the process that
#: minted it.
expires: float
@@ -164,7 +184,11 @@ class _PendingStore:
return f"{_PENDING_KEY}:{code}"
def _codes(self) -> list[str]:
"""The live codes, the expired ones dropped first."""
"""The live codes, oldest first, the expired ones dropped first.
Oldest first because every code lives as long: the index is scored by
expiry, and the dictionary keeps the order codes were added in.
"""
now = time.time()
if self._redis is None:
for code in [c for c, e in self._local.items() if e.expires < now]:
@@ -173,6 +197,12 @@ class _PendingStore:
self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now)
return list(cast("list[str]", self._redis.zrange(_PENDING_INDEX, 0, -1)))
def make_room(self, room: int) -> None:
"""Drop the oldest codes until fewer than ``room`` are left."""
codes = self._codes()
for code in codes[: max(0, len(codes) - room + 1)]:
self.drop(code)
def count(self) -> int:
return len(self._codes())
@@ -251,6 +281,9 @@ class PairStarted(BaseModel):
#: Held by the device, never shown. Without it the code alone would let
#: anyone who reads it off the wall collect the credential first.
secret: str
#: Shown beside the code. The pending list shows it next to this request,
#: so whoever adopts it from there can tell it is this screen.
check: str = ""
expires_in: int = PAIR_TTL
@@ -275,6 +308,8 @@ class PendingDevice(BaseModel):
#: What ``POST /devices/{id}/pair`` takes to adopt it without the code.
request: str = ""
#: What the device shows beside its code, to compare before adopting it.
check: str = ""
device: str
name: str = ""
board: str = ""
@@ -289,6 +324,7 @@ class PendingDevice(BaseModel):
def _pending_public(entry: _Pending) -> PendingDevice:
return PendingDevice(
request=entry.request,
check=entry.check,
device=entry.device,
name=entry.name,
board=entry.board,
@@ -356,13 +392,14 @@ def _describe(request: Request) -> str:
return " · ".join(part for part in (agent or "Unknown device", address) if part)
def _mint_at_hub(device_id: str) -> str:
def _mint_at_hub(device_id: str, nonce: int) -> str:
"""Ask the portal for this device's credential.
A device that arrived through the portal cannot reach this instance, so a
token this instance signed would be one it could never present: the portal
verifies what crosses its tunnel against its own key. It mints, we say
which device, and the scope check here decides the rest.
which device and its nonce, and the checks here decide the rest: the
nonce is held against the device's as for a credential minted here.
"""
config = cloud_config.load()
if config is None:
@@ -375,7 +412,7 @@ def _mint_at_hub(device_id: str) -> str:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/",
headers={"Authorization": f"Bearer {config.token}"},
json={"panel": device_id},
json={"panel": device_id, "pnc": nonce},
timeout=15.0,
)
except httpx.HTTPError as exc:
@@ -403,16 +440,28 @@ def _document(device: DeviceDef, store: DeviceStore) -> DeviceDocument:
)
#: A recheck waiting for a rebuild to finish. One is enough: it reads the
#: devices as they are once it runs.
_rechecks: set[asyncio.Task[None]] = set()
async def _recheck(request: Request) -> None:
"""Validate the flows again: a device node may name this device.
The device is saved either way, so a busy engine is not this request's
failure: the next rebuild validates again.
failure. The check waits for the rebuild in the background instead, since
one that started before this change may have read the devices before it.
"""
controller = getattr(request.app.state, "flow_controller", None)
if controller is not None:
with suppress(RebuildBusy):
await controller.recheck()
if controller is None:
return
try:
await controller.recheck()
except RebuildBusy:
if not _rechecks:
task = asyncio.create_task(controller.recheck(wait=None))
_rechecks.add(task)
task.add_done_callback(_rechecks.discard)
def _changed() -> None:
@@ -433,19 +482,24 @@ def start_pairing(request: Request, body: PairHello | None = None) -> Any:
All this hands out is a code that means nothing until somebody with an
account approves it, so the worst an unwelcome caller achieves is an entry
that expires ten minutes later. Reachable from the internet when this
instance is enrolled with a portal, which is what the cap and the portal's
own per-address limits are between.
instance is enrolled with a portal, which is what the per-address limit,
the cap and the portal's own limits are between. At the cap the oldest
request makes room, so a flood delays a device rather than locking it out.
"""
if _pending.count() >= MAX_PENDING:
if too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S):
raise HTTPException(
status_code=429, detail="Too many devices are waiting to be paired"
status_code=429,
detail="Too many pairing codes asked for from here. Try again shortly.",
headers={"Retry-After": str(int(PAIR_WINDOW_S))},
)
_pending.make_room(MAX_PENDING)
remote = cloud_config.via_portal(request.headers)
hello = body or PairHello()
entry = _Pending(
secret=secrets.token_urlsafe(16),
request=secrets.token_urlsafe(9),
check="".join(secrets.choice(CODE_ALPHABET) for _ in range(CHECK_LENGTH)),
expires=time.time() + PAIR_TTL,
device=_describe(request),
name=hello.name,
@@ -462,7 +516,7 @@ def start_pairing(request: Request, body: PairHello | None = None) -> Any:
break
else:
raise HTTPException(status_code=503, detail="Could not mint a pairing code")
return PairStarted(code=code, secret=entry.secret)
return PairStarted(code=code, secret=entry.secret, check=entry.check)
def poll_pairing(code: str, secret: str = "") -> Any:
@@ -642,7 +696,7 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
detail=f"A report is at most {REPORT_MAX // 1024} KB",
)
try:
report = DeviceReport.model_validate_json(bytes(raw))
report, left_out = read_report(bytes(raw))
except ValidationError as exc:
raise HTTPException(
status_code=422,
@@ -650,6 +704,8 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
include_url=False, include_input=False, include_context=False
),
)
for part in left_out:
logger.warning("Device '%s' reported %s, left out", device_id, part)
first = await run_in_threadpool(store.report, device_id, report)
if (
first
@@ -666,7 +722,8 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
)
_changed()
await _recheck(request)
return Message(message=f"{len(report.ports)} ports reported")
said = f"{len(report.ports)} ports reported"
return Message(message=f"{said}, {len(left_out)} left out" if left_out else said)
@router.put(
@@ -719,15 +776,17 @@ def _adopt(
) -> str:
"""Mint the credential the device behind ``entry`` collects on its next poll.
One minted here names the approver, so what the device does stays
attributable to a person. One minted by the portal names the account this
instance was enrolled with instead.
Either one carries the device's nonce, so unpairing or deleting the
device refuses it. One minted here also names the approver, for the
record: the device acts as itself and not as that account, so deleting
the account leaves the device paired.
"""
nonce = store.nonce(device_id)
if entry.remote:
entry.token = _mint_at_hub(device_id)
entry.token = _mint_at_hub(device_id, nonce)
else:
entry.token = security.create_panel_token(
device_id, user.id, timedelta(days=TOKEN_DAYS), store.nonce(device_id)
device_id, user.id, timedelta(days=TOKEN_DAYS), nonce
)
entry.device_id = device_id
_pending.save(code, entry)
@@ -791,9 +850,9 @@ async def unpair_device(device_id: str, store: DeviceStoreDep) -> Any:
The screen hanging there is refused on its next request and goes back to
showing a pairing code, while its dashboards and their arrangement stay.
A credential the portal minted for a remote screen carries no nonce, so
this does not reach it. That one is revoked at the hub.
The same holds for one the portal minted. An older portal minted without
the nonce, and such a credential stands only while the nonce is still 0,
so this refuses it too.
"""
if await run_in_threadpool(store.find, device_id) is None:
raise _not_found(device_id)
+5 -6
View File
@@ -166,15 +166,14 @@ def decode_portal_token(token: str) -> dict[str, Any]:
scope = claims.get("scope")
if scope == "panel":
# A screen that paired through the portal. The portal named the panel
# and nothing else; what that panel may read is decided here, by the
# same check a panel paired on this network passes. It acts as the
# enrolling account for want of any other, but the scope check is what
# actually bounds it — so a token of this scope that names no panel is
# refused rather than left holding the account it borrows.
# and the nonce this instance gave it when asking, nothing else; what
# that panel may read is decided here, by the same checks a panel
# paired on this network passes, the nonce included. A token of this
# scope that names no panel is refused.
panel = str(claims.get("sub") or "")
if not panel:
raise InvalidTokenError("a panel token must name its panel")
return {"sub": config.local_user_id, "panel": panel}
return {"panel": panel, "pnc": claims.get("pnc")}
if scope != "proxy":
raise InvalidTokenError("not a proxy token")
# A portal session names the person holding it, and that name is the whole
+6 -6
View File
@@ -186,12 +186,12 @@ def create_panel_token(
) -> str:
"""The credential a paired wall panel holds.
Signed with the app's own secret like a browser session, because it names a
person in exactly the same way: ``sub`` is the account that approved the
pairing, so everything the panel does is attributable to them. What keeps
it from being a full session is the ``panel`` claim — the request filter in
``fluksio.api.deps`` lets it reach only that panel's dashboards and the message
endpoints its widgets need.
Signed with the app's own secret like a browser session, and told apart
from one by its audience. ``sub`` records the account that approved the
pairing. The panel acts as itself rather than as that account (see
``deps.device_principal``), so deleting the account leaves it paired. The
``panel`` claim is what the request filter in ``fluksio.api.deps`` holds
it to: that panel's dashboards and the message endpoints its widgets need.
``pnc`` is the panel's nonce at the moment of pairing, and the filter
refuses a credential naming any other. Bumping the panel's nonce is
+13 -5
View File
@@ -619,6 +619,8 @@ class FlowController:
finally:
self._lock.release()
# A stopped flow wires no device ports.
self._rewired()
self._publish(
{
"type": "pipeline_rebuilt",
@@ -836,7 +838,7 @@ class FlowController:
)
self._rewired()
async def recheck(self) -> None:
async def recheck(self, wait: float | None = FLOW_REBUILD_WAIT) -> None:
"""Validate the built graph again without rebuilding anything.
For what validation reads from outside the flows: a device created or
@@ -844,7 +846,7 @@ class FlowController:
"""
if self.pipeline is None:
return
await self._acquire(FLOW_REBUILD_WAIT)
await self._acquire(wait)
try:
self.issues = _collect_issues(
self.loaded, self.pipeline, self._all_flow_inputs(), self.devices
@@ -2124,6 +2126,14 @@ def _collect_issues(
_NUMBERS = frozenset({"int", "float"})
def _ways(said: dict[str, Any]) -> list[str]:
"""The directions a reported port may be wired in, none when it does not
say: a direction this version does not know is no reason to refuse one."""
if said.get("modes"):
return list(said["modes"])
return [said["direction"]] if said.get("direction") in ("in", "out") else []
def _device_issues(
loaded: dict[str, LoadedNode], devices: Any
) -> list[ValidationIssue]:
@@ -2190,9 +2200,7 @@ def _device_issues(
elif said is None:
problem = f"'{device}' does not report a port '{spec.port}'."
advisory = True
elif said.get("direction") and direction not in (
said.get("modes") or [said["direction"]]
):
elif (ways := _ways(said)) and direction not in ways:
problem = (
f"'{spec.port}' of '{device}' is an {said['direction']} "
f"port, wired here as {direction}."
+204 -17
View File
@@ -34,7 +34,16 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, TypeAdapter, ValidationError, field_validator
from pydantic import (
BaseModel,
Field,
TypeAdapter,
ValidationError,
ValidationInfo,
ValidatorFunctionWrapHandler,
field_validator,
model_validator,
)
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.events import event_bus
@@ -167,21 +176,48 @@ class DeviceState(BaseModel):
_STATES = TypeAdapter(dict[str, DeviceState])
#: Read in place of a value this version does not know, such as a newer
#: firmware's display shape or port direction: no reason to refuse the rest of
#: what the device says.
UNKNOWN = "unknown"
def _known(value: Any, known: tuple[str, ...]) -> Any:
return value if value in known else UNKNOWN
def _left_out(info: ValidationInfo, what: str, why: str) -> None:
"""Note a part of a report that was dropped, for whoever reads it to log."""
if isinstance(info.context, dict):
info.context.setdefault("left_out", []).append(f"{what} ({why})")
def _why(exc: ValidationError) -> str:
error = exc.errors(include_url=False)[0]
where = ".".join(str(part) for part in error["loc"])
return f"{where}: {error['msg']}" if where else str(error["msg"])
class ReportDisplay(BaseModel):
"""The device's screen, in pixels."""
w: int = Field(ge=0)
h: int = Field(ge=0)
shape: Literal["rect", "round"] = "rect"
shape: Literal["rect", "round", "unknown"] = "rect"
touch: bool = False
@field_validator("shape", mode="before")
@classmethod
def _shape(cls, value: Any) -> Any:
return _known(value, ("rect", "round"))
class ReportPort(BaseModel):
"""One port the device has, and how it may be wired."""
port: str = Field(pattern=PORT_PATTERN.pattern)
#: Its default direction: ``in`` publishes, ``out`` follows.
direction: Literal["in", "out"]
direction: Literal["in", "out", "unknown"]
dtype: DType
#: A JSON schema of the ``config`` the device reads for this port.
config: dict[str, Any] | None = None
@@ -190,6 +226,19 @@ class ReportPort(BaseModel):
#: The directions it may be wired in, when it may be either: a GPIO pin.
modes: list[Literal["in", "out"]] | None = None
@field_validator("direction", mode="before")
@classmethod
def _direction(cls, value: Any) -> Any:
return _known(value, ("in", "out"))
@field_validator("modes", mode="before")
@classmethod
def _modes(cls, value: Any) -> Any:
# The ones this version wires. Another is not said, rather than wrong.
if isinstance(value, list):
return [mode for mode in value if mode in ("in", "out")]
return value
class ReportCamera(BaseModel):
"""The camera sensor the device found, and what it delivers."""
@@ -203,8 +252,16 @@ class ReportCamera(BaseModel):
class DeviceReport(BaseModel):
"""What a device says it has, version 1.
Keys this version does not know are dropped, so a newer firmware still
reports to an older instance. The whole report replaces the one before.
Read leniently, so a newer firmware still reports to an older instance.
Keys this version does not know are dropped, a value of a closed set it
does not know reads as ``unknown``, and a port or a section it cannot read
is left out on its own. Only a report that is not version 1, or not an
object at all, is refused. :func:`read_report` says what was left out.
A port's ``config`` may name an entry of ``config_schemas`` instead of
spelling the schema out, so a board with thirty pins sends the pin schema
once. It is put in place when the report is read. The whole report
replaces the one before.
"""
version: Literal[1] = Field(alias="schema")
@@ -217,11 +274,85 @@ class DeviceReport(BaseModel):
#: The build's features, true when there: ``{"camera": true}``.
features: dict[str, bool] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
def _named_configs(cls, data: Any, info: ValidationInfo) -> Any:
if not isinstance(data, dict) or not isinstance(data.get("ports"), list):
return data
schemas = data.get("config_schemas")
schemas = schemas if isinstance(schemas, dict) else {}
ports = []
for port in data["ports"]:
if isinstance(port, dict) and isinstance(port.get("config"), str):
schema = schemas.get(port["config"])
if not isinstance(schema, dict):
_left_out(
info,
f"the config of port {port.get('port')!r}",
f"no schema named {port['config']!r}",
)
schema = None
port = {**port, "config": schema}
ports.append(port)
return {**data, "ports": ports}
@field_validator("ports", mode="before")
@classmethod
def _each_port(cls, value: Any, info: ValidationInfo) -> Any:
if not isinstance(value, list):
_left_out(info, "ports", "not a list")
return []
kept = []
for raw in value:
try:
kept.append(ReportPort.model_validate(raw))
except ValidationError as exc:
name = raw.get("port") if isinstance(raw, dict) else raw
_left_out(info, f"port {name!r}", _why(exc))
return kept
@field_validator("features", mode="before")
@classmethod
def _flags(cls, value: Any, info: ValidationInfo) -> Any:
if not isinstance(value, dict):
_left_out(info, "features", "not an object")
return {}
flags = {}
for name, flag in value.items():
if isinstance(flag, bool):
flags[str(name)] = flag
else:
_left_out(info, f"feature {name!r}", f"{flag!r} is not true or false")
return flags
@field_validator("platform", "board", "firmware", "display", "camera", mode="wrap")
@classmethod
def _or_left_out(
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
) -> Any:
try:
return handler(value)
except ValidationError as exc:
name = info.field_name or ""
_left_out(info, name, _why(exc))
return cls.model_fields[name].get_default()
def stored(self) -> dict[str, Any]:
"""As ``devices.json`` keeps it: the device's own keys, nothing unset."""
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
def read_report(raw: bytes) -> tuple[DeviceReport, list[str]]:
"""A device's report, and each part of it that was left out.
Raises ``ValidationError`` only for a report that is not version 1 or
not an object.
"""
context: dict[str, list[str]] = {"left_out": []}
report = DeviceReport.model_validate_json(raw, context=context)
return report, context["left_out"]
class DeviceNotFound(KeyError):
def __init__(self, device_id: str) -> None:
super().__init__(device_id)
@@ -265,6 +396,11 @@ class DeviceStore:
self._wiring: dict[str, list[Binding]] = {}
self._wiring_at = -1
self._wiring_lock = threading.Lock()
#: ``devices.json`` as last parsed, by the file's stat. Every device
#: request holds its nonce against it.
self._states_seen: (
tuple[tuple[int, int, int], dict[str, DeviceState]] | None
) = None
def _file(self, device_id: str) -> Path:
return self.root / f"{checked(device_id)}.json"
@@ -281,18 +417,24 @@ class DeviceStore:
# -- what the published flows wire ------------------------------------
def wiring(self) -> dict[str, list[Binding]]:
"""Every device's bound ports, keyed by device id.
"""Every device's bound ports in enabled published flows, by device id.
A stopped flow wires nothing: its ports go out of the device's ``io``
and out of its credential's reach until it is started again.
Each list is in one fixed order, port first, so what a device reads is
the same bytes for as long as the flows are the same: the firmware
compares the whole document and rebuilds its screen on any change.
Read again when the flow store commits, a device save included.
Read again when the flow store commits, a device save, starting and
stopping a flow included.
"""
with self._wiring_lock:
revision = self.flows.revision
if self._wiring_at != revision:
found: dict[str, list[Binding]] = {}
for flow in self.flows.read_all():
if not self.flows.read_enabled(flow.name):
continue
for binding in bindings_of(flow):
found.setdefault(binding.device, []).append(binding)
for rows in found.values():
@@ -303,7 +445,19 @@ class DeviceStore:
return self._wiring
def bound(self, device_id: str) -> list[Binding]:
return self.wiring().get(device_id, [])
"""What the device's ports are wired to, one binding per port.
A port wired twice is an issue on both nodes. Until it is fixed the
device follows the first of the two in the fixed order, for its
``io`` and its credential's reach alike, so neither flips between them.
"""
found: list[Binding] = []
seen: set[str] = set()
for binding in self.wiring().get(device_id, []):
if binding.port not in seen:
seen.add(binding.port)
found.append(binding)
return found
def io(self, device_id: str) -> list[DeviceIo]:
"""The ports as the device reads them."""
@@ -315,7 +469,10 @@ class DeviceStore:
]
def bindings(self, device_id: str) -> list[DeviceBinding]:
"""The same ports by the node holding each, for whoever wires them."""
"""The same ports by the node holding each, for whoever wires them.
Every binding, a port wired twice twice, so the one to remove shows.
"""
return sorted(
(
DeviceBinding(
@@ -325,7 +482,7 @@ class DeviceStore:
message=b.message,
direction=b.direction,
)
for b in self.bound(device_id)
for b in self.wiring().get(device_id, [])
),
key=lambda b: (b.flow, b.node, b.port, b.message),
)
@@ -403,7 +560,8 @@ class DeviceStore:
The nonce is bumped rather than dropped: a device created again under
the same id, or brought back by a revert, must not take the old
screen's credential back with it.
screen's credential back with it. Its report goes, since a device
created again is not the board that made it.
"""
with self.flows._write_lock:
path = self._file(device_id)
@@ -411,14 +569,15 @@ class DeviceStore:
raise DeviceNotFound(device_id)
path.unlink()
self.flows._commit(f"Delete device '{device_id}'")
self.bump(device_id)
self.bump(device_id, forget=True)
presence.forget(device_id)
# -- outside git -------------------------------------------------------
def _read_states(self) -> dict[str, DeviceState]:
"""Raises on a file that does not parse, rather than reading it as empty.
"""The file as it is now, for a writer to change and write back.
Raises on a file that does not parse, rather than reading it as empty.
Empty would put every nonce back to 0, which honours again every
credential an unpair refused.
"""
@@ -426,6 +585,26 @@ class DeviceStore:
return {}
return _STATES.validate_json(self.state_file.read_bytes())
def _states(self) -> dict[str, DeviceState]:
"""The same for reading only, parsed again only once the file changed.
Every device request reads its nonce here. A write from this process
replaces what is held at once, and one from anywhere else changes the
file's stat. Shared between callers, so never changed in place.
"""
try:
seen = self.state_file.stat()
except FileNotFoundError:
return {}
key = (seen.st_mtime_ns, seen.st_size, seen.st_ino)
held = self._states_seen
if held is not None and held[0] == key:
return held[1]
# Stat before read, so what is held is never older than its key.
states = _STATES.validate_json(self.state_file.read_bytes())
self._states_seen = (key, states)
return states
def _write_states(self, states: dict[str, DeviceState]) -> None:
# Through a temporary file, so a crash mid-write cannot leave a torn
# file behind.
@@ -433,19 +612,27 @@ class DeviceStore:
tmp = self.state_file.with_name(self.state_file.name + ".tmp")
tmp.write_bytes(_STATES.dump_json(states, indent=2))
os.replace(tmp, self.state_file)
seen = self.state_file.stat()
self._states_seen = ((seen.st_mtime_ns, seen.st_size, seen.st_ino), states)
def state(self, device_id: str) -> DeviceState:
return self._read_states().get(device_id, DeviceState())
return self._states().get(device_id, DeviceState())
def nonce(self, device_id: str) -> int:
return self.state(device_id).nonce
def bump(self, device_id: str) -> int:
"""Refuse the credential this device holds now. Returns the new nonce."""
def bump(self, device_id: str, *, forget: bool = False) -> int:
"""Refuse the credential this device holds now. Returns the new nonce.
``forget`` drops everything else kept about the device as well.
"""
with self._state_lock:
states = self._read_states()
state = states.setdefault(device_id, DeviceState())
state = states.get(device_id) or DeviceState()
if forget:
state = DeviceState(nonce=state.nonce)
state.nonce += 1
states[device_id] = state
self._write_states(states)
return state.nonce
+3
View File
@@ -94,6 +94,9 @@ class TokenPayload(SQLModel):
#: up from it. A portal identity nobody here was mapped to resolves to no
#: user at all, which is the refusal.
portal_sub: str | None = None
#: The device a device credential stands for, which acts as itself rather
#: than as the account ``sub`` names.
panel: str | None = None
class NewPassword(SQLModel):
+221 -1
View File
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from fluksio.api.routes import devices as devices_route
from fluksio.api.routes import oauth
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.flow.devices import DeviceState
@@ -18,6 +19,14 @@ PANELS = f"{settings.API_V1_STR}/panels"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
@pytest.fixture(autouse=True)
def _pairing_unlimited() -> None:
"""Every test pairs from the test client's one address, which the
per-address limit is about. The test of that limit spends it itself."""
for bucket in [b for b in oauth._hits if b.startswith("pair:")]:
del oauth._hits[bucket]
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
"""Make the stored devices exactly ``config["panels"]``, one call each."""
wanted = {device["id"]: device for device in config["panels"]}
@@ -754,6 +763,16 @@ def test_a_port_wired_twice_or_an_unknown_device_is_an_issue(
("device_port", "gpio.4", False),
("unknown_device", None, False),
}
# The device follows the first of the two, and the tab shows both.
read = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers).json()
assert [(row["port"], row["message"]) for row in read["io"]] == [
("backlight", "porch.backlight"),
("gpio.4", "porch.doorbell"),
]
assert [b["flow"] for b in read["bindings"] if b["port"] == "gpio.4"] == [
"porch",
"porch_too",
]
# Creating the device is enough, nothing has to be published again.
_panels(
client,
@@ -1192,6 +1211,13 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
assert polled.get("STALE1") is None
assert polled.count() == 0
# Making room drops the oldest, whichever worker minted it.
for offset, code in enumerate(("OLD111", "MID222", "NEW333")):
later = devices_route._Pending(secret="s", expires=time.time() + 60 + offset)
assert minted.add(code, later)
polled.make_room(3)
assert minted.get("OLD111") is None and polled.count() == 2
def test_removing_the_panel_revokes_its_credential(
client: TestClient, superuser_token_headers: dict[str, str]
@@ -1262,6 +1288,10 @@ def test_a_device_on_this_network_is_adopted_from_the_pending_list(
mine = next(entry for entry in pending if entry["name"] == "knob-3f2a")
assert mine["board"] == "knob" and mine["firmware"] == "1.4"
assert mine["needs_code"] is False
# The check the device shows, to compare before adopting it from here.
assert len(started["check"]) == devices_route.CHECK_LENGTH
assert set(started["check"]) <= set(devices_route.CODE_ALPHABET)
assert mine["check"] == started["check"]
portal = next(entry for entry in pending if entry["remote"])
assert portal["needs_code"] is True
assert len(portal["name"]) == devices_route.HELLO_LENGTH
@@ -1375,7 +1405,9 @@ def test_a_remote_device_is_paired_at_the_portal(
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
assert calls[0]["headers"]["Authorization"] == "Bearer instance-token" # type: ignore[index]
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
# With the device's nonce, which the hub puts in the credential.
nonce = app.state.device_store.nonce("hallway")
assert calls[0]["json"] == {"panel": "hallway", "pnc": nonce} # type: ignore[index]
collected = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
@@ -1646,3 +1678,191 @@ def test_only_the_newest_frame_of_a_batch_is_pushed(tmp_path) -> None:
length = int.from_bytes(frames[0][:4], "big")
assert orjson.loads(frames[0][4 : 4 + length])["digest"] == second["digest"]
assert frames[0][4 + length :] == b"two"
# --------------------------------------------------------------------------
# C0: limits on asking, a lenient report, and what a credential stands on
# --------------------------------------------------------------------------
def test_one_address_asks_for_a_few_codes_a_minute(client: TestClient) -> None:
lan = TestClient(app, client=("192.168.1.77", 50000))
for _ in range(devices_route.PAIR_PER_ADDRESS):
assert lan.post(f"{PREFIX}/pair").status_code == 200
refused = lan.post(f"{PANELS}/pair")
assert refused.status_code == 429
assert refused.headers["retry-after"]
# Another address is not held to this one's.
assert client.post(f"{PREFIX}/pair").status_code == 200
def test_at_the_cap_the_oldest_request_makes_room(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
store = devices_route._PendingStore()
store._redis = None
monkeypatch.setattr(devices_route, "_pending", store)
monkeypatch.setattr(devices_route, "PAIR_PER_ADDRESS", 1000)
started = [
client.post(f"{PREFIX}/pair").json()
for _ in range(devices_route.MAX_PENDING + 1)
]
assert store.count() == devices_route.MAX_PENDING
oldest, newest = started[0], started[-1]
assert (
client.get(
f"{PREFIX}/pair/{oldest['code']}", params={"secret": oldest["secret"]}
).status_code
== 404
)
assert (
client.get(
f"{PREFIX}/pair/{newest['code']}", params={"secret": newest["secret"]}
).status_code
== 200
)
def test_a_report_is_read_port_by_port(
client: TestClient,
superuser_token_headers: dict[str, str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""What this version cannot read is left out and logged, not refused.
A shape or direction it does not know reads as unknown, and a named config
schema is put in place.
"""
_panels(client, superuser_token_headers, {"panels": [{"id": "lenient"}]})
device = _pair(client, superuser_token_headers, "lenient")
pin = {"type": "object", "properties": {"invert": {"type": "boolean"}}}
report = {
**REPORT,
"display": {"w": 466, "h": 466, "shape": "hexagon", "touch": False},
"config_schemas": {"pin": pin},
"ports": [
{"port": "gpio.4", "direction": "in", "dtype": "bool", "config": "pin"},
{"port": "gpio.5", "direction": "both", "dtype": "bool", "config": "pin"},
{"port": "Not A Port", "direction": "in", "dtype": "bool"},
{"port": "lidar", "direction": "in", "dtype": "pointcloud"},
],
"features": {"display": True, "camera": "imx415"},
}
with caplog.at_level("WARNING", logger="fluksio.api.routes.devices"):
put = client.put(f"{PREFIX}/lenient/report", headers=device, json=report)
assert put.status_code == 200, put.text
assert "2 ports reported, 3 left out" in put.json()["message"]
logged = " ".join(r.getMessage() for r in caplog.records)
for part in ("'Not A Port'", "'lidar'", "feature 'camera'"):
assert part in logged
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
stored = next(d for d in listed["data"] if d["id"] == "lenient")["report"]
assert stored["display"]["shape"] == "unknown"
assert stored["ports"] == [
{"port": "gpio.4", "direction": "in", "dtype": "bool", "config": pin},
{"port": "gpio.5", "direction": "unknown", "dtype": "bool", "config": pin},
]
assert stored["features"] == {"display": True}
assert "config_schemas" not in stored
# Only no structure at all is refused.
for body in ([], "report", {"ports": []}):
refused = client.put(f"{PREFIX}/lenient/report", headers=device, json=body)
assert refused.status_code == 422, body
# Deleting the device drops the report and keeps the nonce moving.
store = app.state.device_store
nonce = store.nonce("lenient")
client.delete(f"{PREFIX}/lenient", headers=superuser_token_headers)
assert store.state("lenient").report is None
assert store.nonce("lenient") == nonce + 1
def test_a_busy_engine_rechecks_once_it_is_free(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
from fluksio.flow.controller import RebuildBusy
_panels(client, superuser_token_headers, {"panels": [{"id": "busy"}]})
device = _pair(client, superuser_token_headers, "busy")
controller = app.state.flow_controller
waits: list[float | None] = []
async def recheck(wait: float | None = 5.0) -> None:
waits.append(wait)
if wait is not None:
raise RebuildBusy("busy")
monkeypatch.setattr(controller, "recheck", recheck)
put = client.put(f"{PREFIX}/busy/report", headers=device, json=REPORT)
assert put.status_code == 200, put.text
# The background recheck waits for the lock rather than giving up.
client.get(f"{PREFIX}/busy", headers=device)
assert waits == [5.0, None]
def test_a_device_outlives_the_account_that_paired_it(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""It acts as itself, not as whoever approved it."""
from tests.utils.user import user_authentication_headers
from tests.utils.utils import random_email, random_lower_string
email, password = random_email(), random_lower_string()
created = client.post(
f"{settings.API_V1_STR}/users/",
headers=superuser_token_headers,
json={"email": email, "password": password, "is_superuser": True},
)
assert created.status_code == 200, created.text
approver = user_authentication_headers(
client=client, email=email, password=password
)
_dashboard(client, superuser_token_headers, "outlived")
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "orphan", "dashboards": ["outlived"]}]},
)
device = _pair(client, approver, "orphan")
gone = client.delete(
f"{settings.API_V1_STR}/users/{created.json()['id']}",
headers=superuser_token_headers,
)
assert gone.status_code == 200, gone.text
assert client.get(f"{PREFIX}/orphan", headers=device).status_code == 200
assert client.get(f"{DASHBOARDS}/outlived", headers=device).status_code == 200
# Still no more than a device: nothing a superuser's route serves.
assert client.get(f"{PREFIX}/", headers=device).status_code == 403
client.delete(f"{PREFIX}/orphan", headers=superuser_token_headers)
def test_a_stopped_flow_wires_no_ports(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
device = _pair(client, superuser_token_headers, "porch_io")
messages = f"{settings.API_V1_STR}/messages"
def io() -> list[str]:
read = client.get(f"{PREFIX}/porch_io", headers=device).json()
return [row["port"] for row in read["io"]]
assert io() == ["backlight", "gpio.4"]
client.post(f"{FLOWS}/porch/stop", headers=superuser_token_headers)
assert io() == []
assert (
client.post(
f"{messages}/porch.doorbell", headers=device, json={"value": True}
).status_code
== 403
)
client.post(f"{FLOWS}/porch/start", headers=superuser_token_headers)
assert io() == ["backlight", "gpio.4"]
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
+15
View File
@@ -239,3 +239,18 @@ def test_panels_with_io_reach_the_flows_in_one_start(tmp_path: Path) -> None:
assert [(r.port, r.message, r.config) for r in store.io("knob")] == [
("backlight", "kiosk.brightness", {"wake_s": 30})
]
def test_the_nonces_are_read_again_only_when_the_file_changes(tmp_path: Path) -> None:
"""Every device request reads its nonce, so the file is parsed once per
change, and a change from outside this process is still seen."""
store = DeviceStore(FlowStore(tmp_path / "flows"), tmp_path / "devices.json")
assert store.nonce("hall") == 0
store.bump("hall")
assert store.nonce("hall") == 1
assert store._states() is store._states()
raw = json.loads(store.state_file.read_text())
raw["hall"]["nonce"] = 7
store.state_file.write_text(json.dumps(raw, indent=4))
assert store.nonce("hall") == 7
+35 -2
View File
@@ -24,6 +24,7 @@ from sqlmodel import Session
from fluksio.api.deps import decode_token, user_from_token
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.main import app
from fluksio.models import User
from tests.utils.portal import ISSUER, jwks, portal_token
from tests.utils.user import create_random_user
@@ -290,7 +291,9 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
json={"version": version},
)
token = portal_token(portal_key, subject="hallway", scope="panel")
# As the hub mints it: with the nonce this instance named when it asked.
nonce = app.state.device_store.nonce("hallway")
token = portal_token(portal_key, subject="hallway", scope="panel", pnc=nonce)
headers = {"Authorization": f"Bearer {token}"}
assert client.get(f"{devices}/hallway", headers=headers).status_code == 200
@@ -312,9 +315,39 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403
)
# Deleting the device is how the screen is retired, whoever minted its token.
# Unpairing refuses it as it refuses one minted here, and so does
# deleting the device, which a device created again does not undo.
client.post(f"{devices}/hallway/unpair", headers=superuser_token_headers)
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
nonce = app.state.device_store.nonce("hallway")
token = portal_token(portal_key, subject="hallway", scope="panel", pnc=nonce)
headers = {"Authorization": f"Bearer {token}"}
assert client.get(f"{devices}/hallway", headers=headers).status_code == 200
client.delete(f"{devices}/hallway", headers=superuser_token_headers)
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
client.post(f"{devices}/hallway", headers=superuser_token_headers)
assert client.get(f"{devices}/hallway", headers=headers).status_code == 401
client.delete(f"{devices}/hallway", headers=superuser_token_headers)
def test_a_portal_token_from_before_nonces_stands_until_an_unpair(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
) -> None:
"""An older hub minted without ``pnc``, which reads as nonce 0."""
devices = f"{settings.API_V1_STR}/devices"
device_id = f"porch_{uuid.uuid4().hex[:6]}"
assert (
client.post(f"{devices}/{device_id}", headers=superuser_token_headers)
).status_code == 200
token = portal_token(portal_key, subject=device_id, scope="panel")
headers = {"Authorization": f"Bearer {token}"}
assert client.get(f"{devices}/{device_id}", headers=headers).status_code == 200
client.post(f"{devices}/{device_id}/unpair", headers=superuser_token_headers)
assert client.get(f"{devices}/{device_id}", headers=headers).status_code == 401
client.delete(f"{devices}/{device_id}", headers=superuser_token_headers)
def test_a_panel_token_naming_no_panel_is_refused(
+2
View File
@@ -31,6 +31,7 @@ def portal_token(
audience: str = INSTANCE_ID,
issuer: str = ISSUER,
scope: str = "proxy",
**claims: Any,
) -> str:
now = datetime.now(UTC)
pem = key.private_bytes(
@@ -46,6 +47,7 @@ def portal_token(
"iat": now,
"exp": now + timedelta(hours=1),
"scope": scope,
**claims,
},
pem,
algorithm="RS256",
+4 -4
View File
@@ -192,13 +192,13 @@ on its own, so this is for reclaiming space sooner.
| `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` |
| `GET` | `/devices/` | list devices, with `online`, `last_seen`, `report` and `reported_at` |
| `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first. A read adds `io` and `bindings`, its ports as the device nodes of published flows wire them |
| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; unauthenticated |
| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; answers `code`, `secret` and the two-character `check` the device shows beside its code; unauthenticated, five a minute per address, the oldest of 50 waiting requests gives way |
| `GET` | `/devices/pair/{code}` | poll pairing with its `secret`; answers `device` (and `panel` for older firmware); unauthenticated |
| `GET` | `/devices/pending` | devices waiting for a code, as they describe themselves |
| `GET` | `/devices/pending` | devices waiting for a code, as they describe themselves, each with its `check` |
| `GET` | `/devices/pair/{code}/device` | what is waiting on a code, before it is approved |
| `POST` | `/devices/{id}/pair` | adopt by `{code}`, or by `{request}` from the pending list for a request from your network |
| `POST` | `/devices/{id}/unpair` | revoke the device credential |
| `PUT` | `/devices/{id}/report` | what the device has, from its own credential only; at most 16 KB, replaces the last report |
| `POST` | `/devices/{id}/unpair` | revoke the device credential, one the portal issued included |
| `PUT` | `/devices/{id}/report` | what the device has, from its own credential only; at most 16 KB, replaces the last report, read leniently (see [What a device reports](../interface/dashboards.md#what-a-device-reports)) |
`/panels/pair`, `/panels/pair/{code}` and `GET /panels/{id}` answer as their
`/devices/` counterparts, for firmware that has not updated yet.
+40 -16
View File
@@ -455,8 +455,9 @@ As a superuser:
4. Open the pairing link on the device. It shows a six-character code.
5. Enter that code under **Pairing**, check the device description, then press
**Pair**. A device asking from your own network also appears in the list
below, with what it says about itself, and **Pair this one** adopts it
without the code.
below, with what it says about itself and a two-character check. The device
shows the same check beside its code. When the two match, **Pair this one**
adopts it without the code.
The screen collects its credential and shows the assigned published dashboards.
Several dashboards get a navigation rail. The page says whether the device is
@@ -466,16 +467,22 @@ since the instance started.
Each save carries the version it was read at. When someone else saved the
device first, the save is refused and the page shows their version.
One address may ask for five codes a minute. At most 50 requests wait at once,
and a new one pushes out the oldest.
A device credential reads its assigned dashboards, their bound messages and the
artifacts they show. It writes only the targets of their controls and query
charts, and the messages of its own `in` [ports](#the-devices-own-ports). A
message its tiles only draw, or a dashboard setting is bound to, stays read
only. On a locked dashboard only the query charts may still publish.
**Unpair screen** revokes a locally paired credential and keeps the device and
its dashboards. Deleting the device removes the assignment and its access, and
a device added again under the same name does not bring the old credential
back.
A device acts as itself and not as the superuser who paired it, so deleting or
deactivating that account leaves the device paired.
**Unpair screen** revokes the device's credential and keeps the device and its
dashboards. Deleting the device removes the assignment, its access and its
report, and a device added again under the same name does not bring the old
credential back.
The device definitions are stored in the flow repository (`_devices/<id>.json`),
so their history is in git with the flows. The credential generation lives in
@@ -517,8 +524,11 @@ ports it reports and what the published flows wire, each linked to its node.
| type | The payload type, checked like any message's |
| config | A JSON object passed to the device unread, such as `{"pull": "up", "debounce_ms": 50}` |
Only published flows count. A port wired in two of them, or a device node
naming a device that does not exist, is an error on the canvas. Until a device
Only enabled published flows count, so stopping a flow takes its ports out of
the device's `io` and its credential's reach. A port wired in two of them, or a
device node naming a device that does not exist, is an error on the canvas.
Until it is fixed the device follows the first binding of the port, by message
name. Until a device
reports what it has, a port name is free text; once it has, a port it does not
list is a warning and one wired the wrong way round, or to another type, an
error. `int` and `float` pass for each other.
@@ -537,8 +547,17 @@ Home, not a fault, since a panel switched off or asleep breaks nothing.
A paired device says what it has with `PUT /devices/{id}/report`, from its own
credential only. openpanel sends it after pairing and once per boot. The report
replaces the one before, is at most 16 KB and keys this version does not know
are dropped:
replaces the one before and is at most 16 KB. It is read leniently, so a newer
firmware still reports to an older instance:
- keys this version does not know are dropped
- a value it does not know for a display `shape` or a port `direction` reads as
`unknown`, and a `features` value that is not true or false is left out
- a port it cannot read, such as one with an unknown `dtype`, is left out on its
own and named in the server log
Only a report that is not `schema` 1, or not an object at all, is refused with
422.
```json
{
@@ -558,9 +577,12 @@ are dropped:
```
`config` is a JSON schema of the settings the device reads for that port, which
the Device node turns into a form. `modes` lists the directions a port may be
wired in when it may be either, as a GPIO pin. `on_demand` marks a port that
sends only while somebody wants it.
the Device node turns into a form. It may instead name an entry of a top-level
`config_schemas` object, so pins that share a schema send it once:
`"config_schemas": {"pin": {...}}` and `"config": "pin"` on each port. The
schema is put in place when the report is read. `modes` lists the directions a
port may be wired in when it may be either, as a GPIO pin. `on_demand` marks a
port that sends only while somebody wants it.
The device's page shows the report under **Capabilities**, with each port's
binding or **Use in flow** for it. A reported screen becomes a canvas preset in
@@ -591,9 +613,11 @@ a request that came through it, or from outside your network, is adopted by
its code only and never from the list.
The instance enforces the same dashboard and message limits. Remote
credentials are issued by the portal, so **Unpair screen** does not revoke
them; revoke them at the portal. Deleting the device removes access on the
instance.
credentials are issued by the portal with the device's credential generation
in them, so **Unpair screen** and deleting the device revoke them as they do a
local one. A credential from a portal older than this carries none and keeps
working only while the device was never unpaired or deleted. Pair such a
screen again once the portal is updated.
## See also
+10
View File
@@ -3257,6 +3257,11 @@ export const PairStartedSchema = {
type: 'string',
title: 'Secret'
},
check: {
type: 'string',
title: 'Check',
default: ''
},
expires_in: {
type: 'integer',
title: 'Expires In',
@@ -3317,6 +3322,11 @@ export const PendingDeviceSchema = {
title: 'Request',
default: ''
},
check: {
type: 'string',
title: 'Check',
default: ''
},
device: {
type: 'string',
title: 'Device'
+8 -6
View File
@@ -812,8 +812,9 @@ export class DevicesService {
* All this hands out is a code that means nothing until somebody with an
* account approves it, so the worst an unwelcome caller achieves is an entry
* that expires ten minutes later. Reachable from the internet when this
* instance is enrolled with a portal, which is what the cap and the portal's
* own per-address limits are between.
* instance is enrolled with a portal, which is what the per-address limit,
* the cap and the portal's own limits are between. At the cap the oldest
* request makes room, so a flood delays a device rather than locking it out.
* @param data The data for the request.
* @param data.requestBody
* @returns PairStarted Successful Response
@@ -1016,7 +1017,8 @@ export class DevicesService {
*
* 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.
* @param data The data for the request.
* @param data.deviceId
* @returns Message Successful Response
@@ -1065,9 +1067,9 @@ export class DevicesService {
*
* The screen hanging there is refused on its next request and goes back to
* showing a pairing code, while its dashboards and their arrangement stay.
*
* A credential the portal minted for a remote screen carries no nonce, so
* this does not reach it. That one is revoked at the hub.
* The same holds for one the portal minted. An older portal minted without
* the nonce, and such a credential stands only while the nonce is still 0,
* so this refuses it too.
* @param data The data for the request.
* @param data.deviceId
* @returns Message Successful Response
+2
View File
@@ -1093,6 +1093,7 @@ export type PairRequest = {
export type PairStarted = {
code: string;
secret: string;
check?: string;
expires_in?: number;
};
@@ -1110,6 +1111,7 @@ export type PairStatus = {
*/
export type PendingDevice = {
request?: string;
check?: string;
device: string;
name?: string;
board?: string;
@@ -477,11 +477,29 @@ function Pairing({
? "Waiting to be paired"
: "No device is waiting"}
</span>
{pending?.some((entry) => !entry.needs_code) ? (
<p className="text-xs text-muted-foreground">
Compare the check beside each with the one the device shows next
to its code before choosing Pair this one.
</p>
) : null}
{(pending ?? []).map((entry) => (
<div
key={entry.request}
className="flex items-center gap-2 rounded-sm text-sm"
>
{entry.check ? (
<span
className="shrink-0 rounded-sm border border-border px-2 py-1 font-mono text-base font-medium tracking-[0.2em]"
title="The check this device shows next to its code"
data-testid="pending-check"
>
<span aria-hidden="true">{entry.check}</span>
<span className="sr-only">
Check {entry.check.split("").join(" ")}
</span>
</span>
) : null}
<span className="grid min-w-0 flex-1">
<span className="truncate">
{entry.name || entry.board || "Unnamed device"}
@@ -546,8 +564,7 @@ function Pairing({
</Button>
<InfoTip label="Unpair screen">
Sends the screen back to a pairing code and keeps the device and
its dashboards. A screen paired through the portal is revoked
there instead.
its dashboards, also for a screen paired through the portal.
</InfoTip>
</div>
</>
+1 -1
View File
@@ -25,7 +25,7 @@ export type DeviceReport = {
display?: {
w: number
h: number
shape?: "rect" | "round"
shape?: "rect" | "round" | "unknown"
touch?: boolean
} | null
camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null
+34 -5
View File
@@ -1,4 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
type QueryClient,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import {
type DeviceDef,
@@ -11,6 +15,20 @@ export const deviceKeys = {
all: ["devices"] as const,
detail: (id: string) => ["devices", id] as const,
pending: ["devices-pending"] as const,
/** Every save of a device, whichever one. */
save: ["devices", "save"] as const,
}
/**
* Read the devices again, unless a save is under way.
*
* A refetch started during a save can land before the save does, which shows
* the change undone and hands the next save an older version, which the
* server refuses with 409. The last save to settle reads the list instead.
*/
export function refetchDevices(client: QueryClient, exact = true) {
if (client.isMutating({ mutationKey: deviceKeys.save })) return
client.invalidateQueries({ queryKey: deviceKeys.all, exact })
}
/** Every device, what it shows and whether it is connected. */
@@ -43,8 +61,10 @@ export const pendingQueryOptions = () => ({
* A change is shown at once, and the saves of one device go one at a time,
* each laid over the device as the cache holds it when it is sent and
* against the version the save before it left. So a title and a switch
* changed in one gesture neither refuse nor undo each other. Any failure, a
* 409 from someone else's save included, reads the list again.
* changed in one gesture neither refuse nor undo each other. A refetch the
* socket asks for meanwhile waits (`refetchDevices`), and the last save to
* settle reads the list again, after a failure too, a 409 from someone else's
* save included.
*/
export function useSaveDevice(id: string) {
const queryClient = useQueryClient()
@@ -64,8 +84,13 @@ export function useSaveDevice(id: string) {
: all,
)
return useMutation({
mutationKey: deviceKeys.save,
scope: { id: `device:${id}` },
onMutate: (changes: Partial<DeviceDef>) => patch(changes),
onMutate: async (changes: Partial<DeviceDef>) => {
// One already on its way would land over this change.
await queryClient.cancelQueries({ queryKey: deviceKeys.all, exact: true })
patch(changes)
},
mutationFn: (changes: Partial<DeviceDef>) => {
const current = stored()
if (!current) throw new Error(`No device named ${id}`)
@@ -87,7 +112,11 @@ export function useSaveDevice(id: string) {
// The version alone: the cache already shows this change, and a save
// queued behind it may have shown another since.
onSuccess: (saved) => patch({ version: saved.version }),
onError: () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }),
// Still counted while it settles, so 1 is this one alone.
onSettled: () => {
if (queryClient.isMutating({ mutationKey: deviceKeys.save }) === 1)
queryClient.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
},
})
}
@@ -4,7 +4,7 @@ import { useEffect } from "react"
import { ApiError, FlowsService, OpenAPI } from "@/client"
import { dashboardKeys } from "@/components/Dashboard/queries"
import { deviceKeys } from "@/components/Devices/queries"
import { deviceKeys, refetchDevices } from "@/components/Devices/queries"
import { healthKeys } from "@/components/Health/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
@@ -418,16 +418,16 @@ function open(url: string) {
// `exact` matters — the list key is a prefix of every detail key,
// including the draft an editor may have open.
client?.invalidateQueries({ queryKey: dashboardKeys.all, exact: true })
client?.invalidateQueries({
queryKey: message.dashboard
? dashboardKeys.detail(message.dashboard)
: deviceKeys.all,
})
if (message.dashboard)
client?.invalidateQueries({
queryKey: dashboardKeys.detail(message.dashboard),
})
else if (client) refetchDevices(client, false)
break
case "device_status":
// A device's socket opened or closed: the Devices tab reads presence
// off the list.
client?.invalidateQueries({ queryKey: deviceKeys.all, exact: true })
if (client) refetchDevices(client)
break
case "run_started":
case "run_finished":
+15
View File
@@ -170,6 +170,21 @@ function PairPanel() {
) : null}
</div>
{/* Through the portal only the code pairs, so there is no list to
pick this screen from. */}
{started?.check && !isPortal() ? (
<p className="text-sm text-muted-foreground">
Picked from the waiting list instead? Its check is{" "}
<span
className="font-mono font-medium text-foreground"
data-testid="pairing-check"
>
{started.check}
</span>
.
</p>
) : null}
<p className="text-sm text-muted-foreground">
The code lasts ten minutes; this screen replaces it when it runs out.
</p>