Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Three things the security pass on the portal pairing turned up. The first two were already true of a screen on the local network; what changed is that a panel credential is now presentable from the internet, which is what makes them worth closing rather than recording. The artifact endpoint authenticates for itself, because a worker's credential has to open it and that token is no use anywhere else. It resolved the caller without handing over the request, so the one credential that is scoped by route was judged by no route at all — a panel could read and write the store as whoever approved it. It passes the request it already holds now. The websocket has no route to judge either, and there the bound has to be on what is sent: a panel is given the values its own dashboards draw and nothing else — no node status, no logs, no shape of the graph. The keys stay in the message, emptied, because a screen on a wall runs the bundle it was paired with. `messages_for` reads that set off the published dashboards, and is the walk the `/messages/` allowlist has wanted for a while. And locality is no longer a header anyone can type. The marker the connector stamps is a value minted per process, so reaching this API directly cannot buy a device the credential meant for one that cannot reach it at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""Panels: 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
|
|
panels dialog to say which panel the device is. The device polls, collects the
|
|
credential the approval minted, and never asks again.
|
|
|
|
A screen hanging somewhere this installation is not reachable from does the
|
|
same thing through the portal, which forwards those two calls down the tunnel
|
|
without a session — a device with no credential is the whole point of them —
|
|
and mints the credential itself when the approval comes. Which side minted it
|
|
changes nothing about what it may do: the scope check is here either way.
|
|
|
|
The credential is scoped: ``app.api.deps`` lets it reach the dashboards that
|
|
panel was assigned and nothing else. Removing the panel revokes it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import time
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
|
from app.cloud import config as cloud_config
|
|
from app.core import security
|
|
from app.core.config import settings
|
|
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
|
from app.models import Message
|
|
|
|
#: Gated per route rather than on the router: the two 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="/panels", tags=["panels"])
|
|
|
|
#: Long, because a panel 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
|
|
|
|
#: Refuse to grow past this. Codes are free to ask for and cost memory.
|
|
MAX_PENDING = 50
|
|
|
|
|
|
class _Pending:
|
|
"""A device waiting to be told what it is."""
|
|
|
|
def __init__(self, secret_value: str, device: str, remote: bool) -> None:
|
|
self.secret = secret_value
|
|
self.expires = time.monotonic() + PAIR_TTL
|
|
self.token: str = ""
|
|
self.panel: str = ""
|
|
#: What the request looked like, shown to whoever approves the code so
|
|
#: they can tell the screen in the hall from one they were not
|
|
#: expecting. Self-reported and worth what that is worth.
|
|
self.device = device
|
|
#: Whether it came down the tunnel. A device that reached the portal
|
|
#: cannot reach this installation, so its credential has to be minted
|
|
#: where it can collect it.
|
|
self.remote = remote
|
|
|
|
|
|
# ponytail: in-process, so pairing needs the API to be one process — which it
|
|
# is. Move to a table if it ever runs behind more than one worker.
|
|
_pending: dict[str, _Pending] = {}
|
|
|
|
|
|
def _prune() -> None:
|
|
now = time.monotonic()
|
|
for code in [c for c, p in _pending.items() if p.expires < now]:
|
|
del _pending[code]
|
|
|
|
|
|
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
|
|
expires_in: int = PAIR_TTL
|
|
|
|
|
|
class PairStatus(BaseModel):
|
|
"""Nothing yet, or the credential and the panel it is for."""
|
|
|
|
access_token: str | None = None
|
|
panel: str | None = None
|
|
|
|
|
|
class PairRequest(BaseModel):
|
|
code: str
|
|
|
|
|
|
class PendingDevice(BaseModel):
|
|
"""Who is asking, as far as the request itself says."""
|
|
|
|
device: str
|
|
remote: bool = False
|
|
|
|
|
|
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]
|
|
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
# Proxied requests are replayed into this process over an ASGI transport,
|
|
# which reports every caller as localhost; the forwarded address is the
|
|
# only true one there, and behind the local reverse proxy it is too.
|
|
address = forwarded or (request.client.host if request.client else "")
|
|
return " · ".join(part for part in (agent or "Unknown device", address) if part)
|
|
|
|
|
|
def _mint_at_hub(panel_id: str) -> str:
|
|
"""Ask the portal for this panel's credential.
|
|
|
|
A device that arrived through the portal cannot reach this installation, so
|
|
a token this installation signed would be one it could never present: the
|
|
portal verifies what crosses its tunnel, and it verifies against its own
|
|
key. It mints, we say which panel — and the scope check here decides the
|
|
rest, on this call and on every later one.
|
|
"""
|
|
config = cloud_config.load()
|
|
if config is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="That device came through a portal this installation 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": panel_id},
|
|
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
|
|
|
|
|
|
class PanelsPublic(BaseModel):
|
|
"""The panels, 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 to it: an admin working through the portal is on the
|
|
portal's origin, and this one is for a screen on this network.
|
|
|
|
An installation enrolled with a portal has a second address, built by the
|
|
dialog from what ``/cloud/status`` reports rather than from here — a panel
|
|
is not the thing that knows whether remote access is on.
|
|
"""
|
|
|
|
panels: list[PanelDef] = Field(default_factory=list)
|
|
#: Whatever this installation was told it is reachable at. The same setting
|
|
#: the password-reset links are built from, so an installation that has it
|
|
#: wrong has it wrong in both places.
|
|
frontend_host: str = ""
|
|
|
|
|
|
def _public(config: PanelsConfig) -> PanelsPublic:
|
|
return PanelsPublic(
|
|
panels=config.panels, frontend_host=settings.FRONTEND_HOST.rstrip("/")
|
|
)
|
|
|
|
|
|
@router.get("/", response_model=PanelsPublic, dependencies=[Depends(get_current_user)])
|
|
async def read_panels() -> Any:
|
|
"""Every panel, what each one shows, and where to point a device."""
|
|
return _public(await run_in_threadpool(read_config))
|
|
|
|
|
|
@router.put(
|
|
"/",
|
|
response_model=PanelsPublic,
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
)
|
|
async def save_panels(body: PanelsConfig) -> Any:
|
|
"""Replace the panels. Takes effect on the devices' next read.
|
|
|
|
A panel that disappears here takes its credential with it, so this is also
|
|
how a device is unpaired.
|
|
"""
|
|
seen = set()
|
|
for panel in body.panels:
|
|
if panel.id in seen:
|
|
raise HTTPException(
|
|
status_code=422, detail=f"Two panels named {panel.id!r}"
|
|
)
|
|
seen.add(panel.id)
|
|
|
|
await run_in_threadpool(write_config, body)
|
|
return _public(body)
|
|
|
|
|
|
@router.post("/pair", response_model=PairStarted)
|
|
def start_pairing(request: Request) -> 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 a line in
|
|
a dictionary that expires ten minutes later. Reachable from the internet
|
|
when this installation is enrolled with a portal, which is what the cap and
|
|
the portal's own per-address limits are between.
|
|
"""
|
|
_prune()
|
|
if len(_pending) >= MAX_PENDING:
|
|
raise HTTPException(
|
|
status_code=429, detail="Too many devices are waiting to be paired"
|
|
)
|
|
|
|
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
|
while code in _pending:
|
|
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
|
|
|
remote = secrets.compare_digest(
|
|
request.headers.get(cloud_config.VIA_HEADER, ""), cloud_config.VIA_PORTAL
|
|
)
|
|
entry = _Pending(secrets.token_urlsafe(16), _describe(request), remote)
|
|
_pending[code] = entry
|
|
return PairStarted(code=code, secret=entry.secret)
|
|
|
|
|
|
@router.get("/pair/{code}", response_model=PairStatus)
|
|
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.
|
|
"""
|
|
_prune()
|
|
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 memory is a second copy of
|
|
# it, and the device has the only one it needs.
|
|
del _pending[code]
|
|
return PairStatus(access_token=entry.token, panel=entry.panel)
|
|
|
|
|
|
@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.
|
|
|
|
Approving a code adopts whatever is holding it, so it is worth seeing that
|
|
it looks like the screen you just hung.
|
|
"""
|
|
_prune()
|
|
entry = _pending.get(code.strip().upper())
|
|
if entry is None:
|
|
raise HTTPException(status_code=404, detail="No device is waiting on that code")
|
|
return PendingDevice(device=entry.device, remote=entry.remote)
|
|
|
|
|
|
@router.post(
|
|
"/{panel_id}/pair",
|
|
response_model=Message,
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
)
|
|
def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) -> Any:
|
|
"""Say which panel the device showing this code is.
|
|
|
|
A credential minted here names the approver, so what the panel does stays
|
|
attributable to a person rather than to nobody. One minted by the portal —
|
|
for a device that reached this installation only through it — names the
|
|
account this installation was enrolled with instead, since that is the one
|
|
every portal-borne request already acts as.
|
|
"""
|
|
_prune()
|
|
if find(panel_id) is None:
|
|
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
|
|
|
entry = _pending.get(body.code.strip().upper())
|
|
if entry is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="No device is waiting on that code — check it again",
|
|
)
|
|
|
|
if entry.remote:
|
|
entry.token = _mint_at_hub(panel_id)
|
|
else:
|
|
entry.token = security.create_panel_token(
|
|
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
|
|
)
|
|
entry.panel = panel_id
|
|
return Message(message=f"Paired {entry.device} with {panel_id}")
|
|
|
|
|
|
@router.get(
|
|
"/{panel_id}", response_model=PanelDef, dependencies=[Depends(get_current_user)]
|
|
)
|
|
async def read_panel(panel_id: str) -> Any:
|
|
"""One panel — read by a person, or by the device holding its credential.
|
|
|
|
Declared after the pairing routes so a device polling ``/panels/pair/...``
|
|
is never mistaken for someone reading a panel named ``pair``.
|
|
"""
|
|
panel = await run_in_threadpool(find, panel_id)
|
|
if panel is None:
|
|
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
|
|
return panel
|