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).
921 lines
33 KiB
Python
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}")
|