Rename the import package app to fluksio
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>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user