A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
341 lines
13 KiB
Python
341 lines
13 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: ``fluksio.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 fluksio.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
|
from fluksio.cloud import config as cloud_config
|
|
from fluksio.core import security
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
|
from fluksio.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)
|
|
# Which dashboards hang on which panel just changed. An empty name says
|
|
# that much and no more: every screen listening rescopes and refetches
|
|
# what it shows, rather than waiting for whenever it next reads.
|
|
event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()})
|
|
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
|