Files
app/backend/fluksio/api/routes/devices.py
T
Melvin Strobl 72c1d73e96 Camera permission and review fixes (openpanel C3, C4)
C4: a device's `viewers` (superusers by default, or members) covers the
image and video messages its ports publish. Anyone else gets none of their
values on the socket, in the catalogue or a flow's state, 403 for their
history, for the frames they hold (the last 8 included) and for TURN
credentials, no bytes when asking for them, and `bye: forbidden` for a live
view. A device credential stays bounded by its dashboards. The device page
sets it in its Camera section, and a tile the viewer may not see says
"No access to this camera" and asks the camera for nothing.

C3:
- A volatile upload is held to MAX_FRAME_BYTES (4 MiB, 413 above it, read
  without extra copies), and so is a frame on a device's socket.
- A frame the engine holds in memory is written to the ring directory
  before a local worker is handed it, so load_artifact finds it.
- A socket the bus dropped events for catches up with a snapshot, demand
  included, and a device's credential is checked again per frame, cached by
  the flow store revision and its nonce, so a lost event never keeps an
  unpaired device's frames coming.
- A report is at most 64 KB once its named config_schemas are put in place.
- A device away for 60 s has its true bool messages set to false.
- Refused frames are logged by fixed keys, a frame is named after its
  message, `fluksio serve` waits 5 s for open connections, a tile with a
  stream URL creates no demand, the 8-name cap keeps the newest names.
- Every container's log rotates (json-file, 3 x 10 MB).
2026-10-17 09:20:04 +02:00

921 lines
33 KiB
Python

"""Devices: which dashboards a device shows, and how it gets a credential.
A wall tablet has no keyboard, so it cannot log in. It asks for a code instead,
shows it on the wall, and somebody with an account types that code into the
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
comes. Anyone on the internet can ask the portal for a code, so a request that
came that way is adopted by its code only.
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. 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
checked against.
``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what
openpanel firmware from before devices calls, and stay as aliases until every
device has updated.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import time
from contextlib import suppress
from datetime import datetime, timedelta
from typing import Any, cast
import httpx
import orjson
import redis
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError, field_validator
from fluksio.api.deps import (
CAMERA_IS_SUPERUSERS,
CurrentUser,
DeviceStoreDep,
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
from fluksio.flow.controller import RebuildBusy
from fluksio.flow.devices import (
DeviceBinding,
DeviceDef,
DeviceExists,
DeviceIo,
DeviceNotFound,
DeviceStore,
is_lan,
presence,
read_report,
)
from fluksio.flow.events import event_bus
from fluksio.flow.live import ICE_TTL_S, ice_servers
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.
router = APIRouter(prefix="/devices", tags=["devices"])
#: The calls the firmware makes today, on the same handlers.
panels_router = APIRouter(prefix="/panels", tags=["devices"], include_in_schema=False)
#: Long, because a device is a screen somebody hung once and left running, with
#: no keyboard to sign in again with. Same reasoning as a remote worker's.
TOKEN_DAYS = 365
#: How long a device's code is worth typing. Long enough to walk to a computer.
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
#: Never more waiting than this from each way in, this network or the portal:
#: codes are free to ask for and cost memory. The oldest of the same way in
#: makes room for a new one, so a flood cannot lock a device out, and one
#: through the portal never pushes out a device waiting here.
MAX_PENDING = 50
#: Codes one address may ask for per window, here. A device asks once per
#: code, every ten minutes, and again after a failure. Through the portal the
#: hub limits each address and the instance by the address it sees, since
#: here every such call arrives off the tunnel and would share one bucket.
PAIR_PER_ADDRESS = 5
PAIR_WINDOW_S = 60.0
#: Ids a device cannot take: the routes beside ``/{device_id}``.
RESERVED = {"pair", "pending"}
#: How much of what a device says about itself is kept.
HELLO_LENGTH = 64
#: The most a capability report may weigh. A panel with every header pin
#: reported is a few kB.
REPORT_MAX = 16 * 1024
#: The most it may weigh as kept, with each named ``config_schemas`` entry put
#: in place: thirty pins naming one schema send it once and keep it thirty
#: times. The P4 panel's is about 7 kB.
REPORT_STORED_MAX = 64 * 1024
class _Pending(BaseModel):
"""A device waiting to be told what it is."""
#: Held by the device, never shown.
secret: str
#: 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
token: str = ""
device_id: str = ""
#: What the request looked like, shown to whoever approves it so they can
#: tell the screen in the hall from one they were not expecting.
#: Self-reported and worth what that is worth.
device: str = ""
name: str = ""
board: str = ""
firmware: str = ""
#: Whether it came down the tunnel. A device that reached the portal
#: cannot reach this instance, so its credential has to be minted where it
#: can collect it.
remote: bool = False
#: Whether it may be adopted from the list without its code: asked from a
#: private network, not through the portal.
lan: bool = False
#: The entry itself, one key per code, expiring on its own.
_PENDING_KEY = "panels:pair"
#: An index of the live codes, so :data:`MAX_PENDING` means the same thing to
#: every worker. Scored by expiry, which is how it is pruned.
_PENDING_INDEX = "panels:pair:__codes__"
class _PendingStore:
"""The codes waiting for somebody to say what they are.
In Redis when there is one, so a code minted before a restart, or by
another process, is still found. Without one there is one process by
definition (the pip install, and the tests), and a dictionary is the same
thing for it.
"""
def __init__(self) -> None:
self._local: dict[str, _Pending] = {}
self._redis: redis.Redis | None = (
redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
decode_responses=True,
# A silent Redis must fail this request, not hold it open.
socket_timeout=SOCKET_TIMEOUT_S,
socket_connect_timeout=CONNECT_TIMEOUT_S,
health_check_interval=30,
)
if settings.REDIS_HOST
else None
)
def _key(self, code: str) -> str:
return f"{_PENDING_KEY}:{code}"
def _codes(self) -> list[str]:
"""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]:
del self._local[code]
return list(self._local)
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, remote: bool) -> None:
"""Drop the oldest codes asked for the same way, through the portal
or not, until fewer than ``room`` of them are left."""
same = [
code
for code in self._codes()
if (entry := self.get(code)) is not None and entry.remote == remote
]
for code in same[: max(0, len(same) - room + 1)]:
self.drop(code)
def count(self) -> int:
return len(self._codes())
def waiting(self) -> list[tuple[str, _Pending]]:
"""Every code nobody has approved yet, with its entry."""
found = []
for code in self._codes():
entry = self.get(code)
if entry is not None and not entry.token:
found.append((code, entry))
return found
def add(self, code: str, entry: _Pending) -> bool:
"""Claim this code, or say that somebody already holds it."""
if self._redis is None:
if code in self._local:
return False
self._local[code] = entry
return True
if not self._redis.set(
self._key(code), entry.model_dump_json(), ex=PAIR_TTL, nx=True
):
return False
self._redis.zadd(_PENDING_INDEX, {code: entry.expires})
return True
def get(self, code: str) -> _Pending | None:
if self._redis is None:
entry = self._local.get(code)
else:
raw = cast("str | None", self._redis.get(self._key(code)))
entry = _Pending.model_validate_json(raw) if raw else None
return entry if entry is not None and entry.expires >= time.time() else None
def save(self, code: str, entry: _Pending) -> None:
"""Write an approval back, without moving what it expires at."""
if self._redis is None:
self._local[code] = entry
return
self._redis.set(
self._key(code),
entry.model_dump_json(),
ex=max(1, int(entry.expires - time.time())),
)
def drop(self, code: str) -> None:
if self._redis is None:
self._local.pop(code, None)
return
self._redis.delete(self._key(code))
self._redis.zrem(_PENDING_INDEX, code)
_pending = _PendingStore()
class PairHello(BaseModel):
"""What a device may say about itself when it asks for a code."""
name: str = ""
board: str = ""
firmware: str = ""
@field_validator("name", "board", "firmware", mode="before")
@classmethod
def _trim(cls, value: Any) -> str:
# Cut rather than refused: a device that says too much should still
# get its code.
return str(value or "").strip()[:HELLO_LENGTH]
class PairStarted(BaseModel):
"""What the device puts on the wall, and what it polls with."""
code: str
#: 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
class PairStatus(BaseModel):
"""Nothing yet, or the credential and the device it is for."""
access_token: str | None = None
device: str | None = None
#: The same id, for firmware from before devices.
panel: str | None = None
class PairRequest(BaseModel):
"""Adopt the device showing ``code``, or the pending ``request``."""
code: str = ""
request: str = ""
class PendingDevice(BaseModel):
"""Who is asking, as far as the request itself says."""
#: 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 = ""
firmware: str = ""
remote: bool = False
#: Through the portal or from outside this network: only its code adopts
#: it, since anyone can put a request there.
needs_code: bool = True
expires_in: int = 0
def _pending_public(entry: _Pending) -> PendingDevice:
return PendingDevice(
request=entry.request,
check=entry.check,
device=entry.device,
name=entry.name,
board=entry.board,
firmware=entry.firmware,
remote=entry.remote,
needs_code=not entry.lan,
expires_in=max(0, int(entry.expires - time.time())),
)
class DeviceCreate(BaseModel):
title: str = ""
class DeviceDocument(DeviceDef):
"""A device as it reads itself: what it shows, and what its ports carry.
``io`` is worked out from the device nodes in published flows, in a fixed
order, so an unchanged state reads back as the same bytes.
"""
#: Each port the flows wire, and the message it publishes or follows.
io: list[DeviceIo] = Field(default_factory=list)
#: The same ports by the flow and node holding them.
bindings: list[DeviceBinding] = Field(default_factory=list)
class DevicePublic(DeviceDocument):
"""A device as the Devices tab lists it, with whether it is there."""
online: bool = False
#: The last request it made, since this instance started.
last_seen: datetime | None = None
#: What the device says it has, as it last said so. None until it reports.
report: dict[str, Any] | None = None
reported_at: datetime | None = None
class DevicesPublic(BaseModel):
"""The devices, and the address a device should be pointed at.
The address is the server's own, because the browser's origin is not a
reliable answer: an admin working through the portal is on the portal's
origin, and this one is for a screen on this network.
"""
data: list[DevicePublic] = Field(default_factory=list)
count: int = 0
#: Whatever this instance was told it is reachable at, the setting the
#: password-reset links are built from too.
frontend_host: str = ""
class IceServer(BaseModel):
"""One STUN or TURN server, as `RTCPeerConnection` takes it."""
urls: list[str]
username: str | None = None
credential: str | None = None
class IceServers(BaseModel):
ice_servers: list[IceServer] = Field(default_factory=list)
#: Seconds the TURN credentials stay good.
ttl: int = 0
def _describe(request: Request) -> str:
"""A line naming the device behind a pairing request.
# ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if
# it reads badly on the approval screen.
"""
agent = request.headers.get("user-agent", "").strip()[:120]
# Resolved from X-Forwarded-For by the proxy-headers middleware, which is
# what makes a call replayed off the tunnel show the device's address and
# stops a device naming any address it likes.
address = request.client.host if request.client else ""
return " · ".join(part for part in (agent or "Unknown device", address) if part)
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 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:
raise HTTPException(
status_code=409,
detail="That device came through a portal this instance is no "
"longer enrolled with",
)
try:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/",
headers={"Authorization": f"Bearer {config.token}"},
json={"panel": device_id, "pnc": nonce},
timeout=15.0,
)
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502, detail=f"Could not reach the portal: {exc}"
) from exc
if response.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"The portal refused to mint a credential ({response.status_code})",
)
token: str = response.json()["access_token"]
return token
def _not_found(device_id: str) -> HTTPException:
return HTTPException(status_code=404, detail=f"No device named {device_id!r}")
def _document(device: DeviceDef, store: DeviceStore) -> DeviceDocument:
return DeviceDocument(
**device.model_dump(),
io=store.io(device.id),
bindings=store.bindings(device.id),
)
#: 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 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 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:
"""Tell every device socket that what it shows may have moved.
An empty name says that much and no more: every screen listening rescopes
and refetches, which is also where an unpaired one meets its 401.
"""
event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()})
# -- the device itself, before it holds anything -----------------------------
def start_pairing(request: Request, body: PairHello | None = None) -> Any:
"""A device asks to be adopted. Unauthenticated, by necessity.
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 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.
"""
remote = cloud_config.via_portal(request.headers)
if not remote and too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S):
raise HTTPException(
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)
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,
board=hello.board,
firmware=hello.firmware,
remote=remote,
lan=not remote and is_lan(request.client.host if request.client else ""),
)
# Claiming the code is what settles a collision, rather than looking
# first: between the look and the write sits another worker doing the same.
for _ in range(10):
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
if _pending.add(code, entry):
break
else:
raise HTTPException(status_code=503, detail="Could not mint a pairing code")
return PairStarted(code=code, secret=entry.secret, check=entry.check)
def poll_pairing(code: str, secret: str = "") -> Any:
"""Has anyone claimed this device yet?
Answers the same 404 for a code that never existed, one that expired and
one polled with the wrong secret: a caller reading a code off a wall learns
nothing by guessing.
"""
entry = _pending.get(code)
if entry is None or not secrets.compare_digest(entry.secret, secret):
raise HTTPException(
status_code=404, detail="No pairing is waiting on that code"
)
if not entry.token:
return PairStatus()
# Handed over once. A credential left lying in the store is a second copy
# of it, and the device has the only one it needs.
_pending.drop(code)
return PairStatus(
access_token=entry.token, device=entry.device_id, panel=entry.device_id
)
router.add_api_route(
"/pair", start_pairing, methods=["POST"], response_model=PairStarted
)
router.add_api_route(
"/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus
)
panels_router.add_api_route(
"/pair", start_pairing, methods=["POST"], response_model=PairStarted
)
panels_router.add_api_route(
"/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus
)
# -- people ------------------------------------------------------------------
@router.get("/", response_model=DevicesPublic, dependencies=[Depends(get_current_user)])
async def read_devices(store: DeviceStoreDep) -> Any:
"""Every device, what each one shows, and whether it is connected."""
def gather() -> list[DevicePublic]:
found = []
for device in store.list():
state = store.state(device.id)
found.append(
DevicePublic(
**_document(device, store).model_dump(),
online=presence.online(device.id),
last_seen=presence.last_seen(device.id),
report=state.report,
reported_at=state.reported_at,
)
)
return found
data = await run_in_threadpool(gather)
return DevicesPublic(
data=data,
count=len(data),
frontend_host=settings.FRONTEND_HOST.rstrip("/"),
)
@router.get(
"/pending",
response_model=list[PendingDevice],
dependencies=[Depends(get_current_active_superuser)],
)
def read_pending() -> Any:
"""The devices asking for a code right now, as they describe themselves."""
waiting = sorted(_pending.waiting(), key=lambda item: -item[1].expires)
return [_pending_public(entry) for _, entry in waiting]
@router.get(
"/pair/{code}/device",
response_model=PendingDevice,
dependencies=[Depends(get_current_active_superuser)],
)
def pending_device(code: str) -> Any:
"""What is waiting on this code, before anyone says what it is."""
entry = _pending.get(code.strip().upper())
if entry is None:
raise HTTPException(status_code=404, detail="No device is waiting on that code")
return _pending_public(entry)
@router.post(
"/{device_id}",
response_model=DeviceDef,
dependencies=[Depends(get_current_active_superuser)],
)
async def create_device(
device_id: str,
store: DeviceStoreDep,
request: Request,
body: DeviceCreate | None = None,
) -> Any:
"""Add a device. It shows nothing until it is given dashboards."""
if device_id in RESERVED:
raise HTTPException(status_code=422, detail=f"{device_id!r} is reserved")
try:
device = DeviceDef(id=device_id, title=(body.title if body else "").strip())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
try:
created = await run_in_threadpool(store.create, device)
except DeviceExists:
raise HTTPException(status_code=409, detail=f"{device_id!r} already exists")
await _recheck(request)
return created
@router.get(
"/{device_id}",
response_model=DeviceDocument,
dependencies=[Depends(get_current_user)],
)
async def read_device(device_id: str, store: DeviceStoreDep) -> Any:
"""One device, read by a person or by the device holding its credential.
The definition and what the flows wire its ports to, nothing about this
moment: a device compares what it reads with what it is showing, and a
field that changes on every request would rebuild its screen each time.
Declared after the pairing routes and ``/pending``, so neither is mistaken
for a device of that name.
"""
device = await run_in_threadpool(store.find, device_id)
if device is None:
raise _not_found(device_id)
return await run_in_threadpool(_document, device, store)
@router.get(
"/{device_id}/ice",
response_model=IceServers,
response_model_exclude_none=True,
)
async def read_ice(device_id: str, store: DeviceStoreDep, user: CurrentUser) -> Any:
"""Where a browser looks for a way to this device's live view.
STUN and TURN servers, the TURN ones with credentials of the caller's own
that expire. Empty when none are set, which is enough on one network.
Only for whoever may see the device's camera (``DeviceDef.viewers``).
"""
device = await run_in_threadpool(store.find, device_id)
if device is None:
raise _not_found(device_id)
if not user.is_superuser and device.viewers != "members":
raise HTTPException(status_code=403, detail=CAMERA_IS_SUPERUSERS)
servers = [IceServer(**server) for server in ice_servers(str(user.id))]
return IceServers(ice_servers=servers, ttl=ICE_TTL_S)
panels_router.add_api_route(
"/{device_id}",
read_device,
methods=["GET"],
response_model=DeviceDocument,
dependencies=[Depends(get_current_user)],
)
@router.put(
"/{device_id}/report",
response_model=Message,
dependencies=[Depends(get_current_user)],
)
async def put_report(device_id: str, request: Request, store: DeviceStoreDep) -> Any:
"""A device says what it has: its board, its screen, its ports.
Only the device itself, through its own credential, which ``_device_may``
holds to its own id. The report replaces the one before it, and the flows
are validated again, since a device node's ports are checked against it.
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, 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:
raise HTTPException(
status_code=403, detail="Only the device itself reports what it has"
)
raw = bytearray()
async for chunk in request.stream():
raw += chunk
if len(raw) > REPORT_MAX:
raise HTTPException(
status_code=413,
detail=f"A report is at most {REPORT_MAX // 1024} KB",
)
try:
report, left_out = read_report(bytes(raw))
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=exc.errors(
include_url=False, include_input=False, include_context=False
),
)
if len(orjson.dumps(report.stored())) > REPORT_STORED_MAX:
raise HTTPException(
status_code=413,
detail=f"A report is at most {REPORT_STORED_MAX // 1024} KB once "
"its config_schemas are put in place",
)
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
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,
device.model_copy(update={"touch": True}),
device.version,
)
_changed()
await _recheck(request)
said = f"{len(report.ports)} ports reported"
return Message(message=f"{said}, {len(left_out)} left out" if left_out else said)
@router.put(
"/{device_id}",
response_model=DeviceDef,
dependencies=[Depends(get_current_active_superuser)],
)
async def save_device(device_id: str, body: DeviceDef, store: DeviceStoreDep) -> Any:
"""Save a device. Takes effect on its next read.
The body carries the ``version`` it was based on, and a save someone else
has moved past is refused with 409.
"""
if body.id != device_id:
raise HTTPException(status_code=422, detail="The id in the body must match")
try:
saved = await run_in_threadpool(store.write, body, body.version)
except DeviceNotFound:
raise _not_found(device_id)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved this device first",
"current_version": exc.current,
},
)
_changed()
return saved
@router.delete(
"/{device_id}",
response_model=Message,
dependencies=[Depends(get_current_active_superuser)],
)
async def delete_device(device_id: str, store: DeviceStoreDep, request: Request) -> Any:
"""Remove the device and refuse its credential, wherever it was minted here."""
try:
await run_in_threadpool(store.delete, device_id)
except DeviceNotFound:
raise _not_found(device_id)
await _recheck(request)
_changed()
return Message(message=f"Deleted {device_id}")
def _adopt(
code: str, entry: _Pending, device_id: str, user: User, store: DeviceStore
) -> str:
"""Mint the credential the device behind ``entry`` collects on its next poll.
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, nonce)
else:
entry.token = security.create_panel_token(
device_id, user.id, timedelta(days=TOKEN_DAYS), nonce
)
entry.device_id = device_id
_pending.save(code, entry)
store.paired(device_id)
return f"Paired {entry.name or entry.device} with {device_id}"
@router.post(
"/{device_id}/pair",
response_model=Message,
dependencies=[Depends(get_current_active_superuser)],
)
def approve_pairing(
device_id: str, body: PairRequest, current_user: CurrentUser, store: DeviceStoreDep
) -> Any:
"""Say which device the one showing this code, or making this request, is."""
if store.find(device_id) is None:
raise _not_found(device_id)
if body.code.strip():
code = body.code.strip().upper()
entry = _pending.get(code)
if entry is None:
raise HTTPException(
status_code=404,
detail="No device is waiting on that code. Check it again.",
)
elif body.request:
match = next(
(
(c, e)
for c, e in _pending.waiting()
if secrets.compare_digest(e.request, body.request)
),
None,
)
if match is None:
raise HTTPException(
status_code=404, detail="That device is no longer waiting"
)
code, entry = match
if not entry.lan:
raise HTTPException(
status_code=403,
detail="This request did not come from your network. Enter the "
"code the device shows instead.",
)
else:
raise HTTPException(status_code=422, detail="Send a code or a request")
return Message(message=_adopt(code, entry, device_id, current_user, store))
@router.post(
"/{device_id}/unpair",
response_model=Message,
dependencies=[Depends(get_current_active_superuser)],
)
async def unpair_device(device_id: str, store: DeviceStoreDep) -> Any:
"""Stop honouring this device's credential, and keep the device.
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.
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)
await run_in_threadpool(store.bump, device_id)
# The screen is still holding a socket. One event and it refetches, which
# is where it meets the 401 that sends it back to the pairing code.
_changed()
return Message(message=f"Unpaired {device_id}")