Files
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

373 lines
13 KiB
Python

"""Live view: WebRTC signalling between a viewer's socket and a device's.
A dashboard tile asks a device's camera for H.264 over WebRTC. The video goes
from the device to the browser directly, or through a TURN relay, never
through here: this only carries the few messages that set a connection up.
Both ends already hold a ``/flows/ws`` socket, which also crosses the portal
tunnel because it carries text, so signalling rides on it.
A person's socket speaks ``{"type": "live", "device", "session", "action"}``
and a device's ``{"type": "signal", "session", "action"}``. ``start`` from
the viewer is handed to the device with ICE servers of its own, the device
offers (``sdp``), which reaches the viewer with ICE servers of the viewer's
own, the viewer answers (``sdp``), both trickle ``candidate``, and ``bye``
from either side ends it. A device past its cap answers ``busy``, and so does
this end once a device has ``MAX_SESSIONS``.
Signalling never rides the event bus, which drops a slow socket's oldest
events: one lost candidate is a connection that never comes up. Each socket
is an :class:`Endpoint` with an outbox of its own that its loop drains, so
only that socket writes to its connection, and a full outbox ends the session
instead of losing a message. One process holds every socket, so one registry
is the whole picture.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any
import orjson
from fluksio.core.config import settings
logger = logging.getLogger(__name__)
#: The port a device reports when it can stream live. Signalled, never wired.
LIVE_PORT = "live"
#: Live viewers per device. The NANO encodes once for all of them, and S3
#: measured two at 720p; past that a tile stays on frames.
MAX_SESSIONS = 2
#: How long a session has to get from ``start`` to the viewer's answer.
ANSWER_TIMEOUT_S = 10.0
#: How long TURN credentials stay good. Minted per session, for whoever is
#: one end of it. coturn checks them on every refresh, so a view relayed
#: through it ends this long after it started.
ICE_TTL_S = 3600
#: Signalling messages one socket may have waiting. A session trickles a
#: dozen candidates at most, so a socket this far behind is stuck.
OUTBOX = 64
_SESSION = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
#: An offer is one to three kilobytes, a candidate a line.
MAX_SDP = 16 * 1024
MAX_CANDIDATE = 1024
#: Which actions are passed on once a session stands.
_RELAYED = ("sdp", "candidate")
def ice_servers(who: str) -> list[dict[str, Any]]:
"""The STUN and TURN servers for one end, with TURN credentials of its own.
coturn's REST scheme (``use-auth-secret``): the username is
``<expiry>:<who>`` and the password the base64 HMAC-SHA1 of it under the
shared secret, so coturn checks it without asking anyone.
"""
servers: list[dict[str, Any]] = []
if settings.STUN_URLS:
servers.append({"urls": list(settings.STUN_URLS)})
if settings.TURN_URLS and settings.TURN_SECRET:
username = f"{int(time.time()) + ICE_TTL_S}:{who}"
digest = hmac.new(
settings.TURN_SECRET.encode(), username.encode(), hashlib.sha1
).digest()
servers.append(
{
"urls": list(settings.TURN_URLS),
"username": username,
"credential": base64.b64encode(digest).decode(),
}
)
return servers
def parse(frame: str) -> dict[str, Any] | None:
"""A signalling message a socket sent, or None for anything else."""
try:
payload = orjson.loads(frame)
except orjson.JSONDecodeError:
return None
if isinstance(payload, dict) and payload.get("type") in ("live", "signal"):
return payload
return None
class Endpoint:
"""One socket signalling can reach, and what is waiting to go out on it."""
def __init__(self) -> None:
self._outbox: asyncio.Queue[str] = asyncio.Queue(maxsize=OUTBOX)
def deliver(self, message: dict[str, Any]) -> bool:
"""Put a message in the outbox. False when it is full."""
try:
self._outbox.put_nowait(orjson.dumps(message).decode())
except asyncio.QueueFull:
return False
return True
async def next(self) -> str:
"""The next message for this socket to send, as text."""
return await self._outbox.get()
@dataclass(eq=False)
class Session:
id: str
device: str
viewer: Endpoint
camera: Endpoint
#: Who is watching, whose TURN credentials the viewer is handed.
who: str
offered: bool = False
answered: bool = False
started: float = field(default_factory=time.monotonic)
timer: asyncio.TimerHandle | None = None
class LiveSessions:
"""Every live view being set up or running, and the devices that serve them.
Called only from the event loop, by the sockets themselves.
"""
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
#: Each device's open sockets, newest last. A device reconnecting can
#: hold two for a moment; a new session goes to the newer one.
self._devices: dict[str, list[Endpoint]] = {}
def attach(self, device_id: str, endpoint: Endpoint) -> None:
"""A device's socket opened."""
self._devices.setdefault(device_id, []).append(endpoint)
def detach(self, endpoint: Endpoint) -> None:
"""A socket closed: every session it was one end of is over."""
for device_id, endpoints in list(self._devices.items()):
if endpoint in endpoints:
endpoints.remove(endpoint)
if not endpoints:
del self._devices[device_id]
for session in list(self._sessions.values()):
if session.viewer is endpoint:
self._end(session, "closed", tell_viewer=False)
elif session.camera is endpoint:
self._end(session, "offline", tell_camera=False)
def count(self, device_id: str) -> int:
return sum(1 for s in self._sessions.values() if s.device == device_id)
def heard(
self,
message: dict[str, Any],
endpoint: Endpoint,
*,
person: str,
device: str,
devices: Any,
superuser: bool = False,
) -> None:
"""One signalling message from a socket.
``person`` is the id of the account holding the socket and ``device``
the device whose credential does, one of them "". A person watches a
device whose ``viewers`` are members, or any as a ``superuser``. A
device speaks only for the sessions it was asked to serve on this
socket.
"""
session_id = str(message.get("session") or "")
if not _SESSION.match(session_id):
return
if message.get("type") == "live" and person:
self._from_viewer(message, session_id, endpoint, person, devices, superuser)
elif message.get("type") == "signal" and device:
self._from_device(message, session_id, endpoint, device)
# -- the two directions ---------------------------------------------------
def _from_viewer(
self,
message: dict[str, Any],
session_id: str,
endpoint: Endpoint,
who: str,
devices: Any,
superuser: bool,
) -> None:
device_id = str(message.get("device") or "")
action = message.get("action")
session = self._sessions.get(session_id)
if action == "start":
if session is not None:
# Said twice, or somebody else's id: neither gets a second one.
return
reason = self._refusal(device_id, devices, superuser)
if reason:
endpoint.deliver(
_viewer_message(device_id, session_id, "bye", reason=reason)
)
return
if self.count(device_id) >= MAX_SESSIONS:
endpoint.deliver(_viewer_message(device_id, session_id, "busy"))
return
session = Session(
session_id, device_id, endpoint, self._devices[device_id][-1], who
)
session.timer = asyncio.get_running_loop().call_later(
ANSWER_TIMEOUT_S, self._expire, session
)
self._sessions[session_id] = session
logger.info("Live view %s on %s asked for", session_id, device_id)
self._to_camera(
session,
{
"action": "start",
"ice_servers": ice_servers(f"device-{device_id}"),
},
)
return
if (
session is None
or session.viewer is not endpoint
or session.device != device_id
):
return
if action == "bye":
self._end(session, "closed", tell_viewer=False)
return
relayed = _relayed(message, action)
if relayed is None:
return
if action == "sdp" and session.offered and not session.answered:
session.answered = True
if session.timer is not None:
session.timer.cancel()
self._to_camera(session, relayed)
def _from_device(
self,
message: dict[str, Any],
session_id: str,
endpoint: Endpoint,
device_id: str,
) -> None:
session = self._sessions.get(session_id)
if (
session is None
or session.device != device_id
or session.camera is not endpoint
):
return
action = message.get("action")
if action in ("bye", "busy"):
reason = str(message.get("reason") or "")[:64] or "closed"
self._end(session, reason, tell_camera=False, action=str(action))
return
relayed = _relayed(message, action)
if relayed is None:
return
if action == "sdp" and not session.offered:
# The viewer builds its connection on the offer, so its ICE
# servers come with it: minted only for a session that got a slot.
session.offered = True
relayed["ice_servers"] = ice_servers(session.who)
self._to_viewer(session, relayed)
# -- delivering and ending ------------------------------------------------
def _to_camera(self, session: Session, message: dict[str, Any]) -> None:
sent = {"type": "signal", "session": session.id, **message}
if not session.camera.deliver(sent):
self._end(session, "overflow", tell_camera=False)
def _to_viewer(self, session: Session, message: dict[str, Any]) -> None:
sent = _viewer_message(session.device, session.id, **message)
if not session.viewer.deliver(sent):
self._end(session, "overflow", tell_viewer=False)
def _refusal(self, device_id: str, devices: Any, superuser: bool) -> str:
"""Why this device cannot serve this viewer a live view now, or ""."""
if not superuser:
found = devices.find(device_id) if devices is not None else None
if found is None or found.viewers != "members":
return "forbidden"
if not self._devices.get(device_id):
return "offline"
reported = devices.reported_ports(device_id) if devices is not None else None
if LIVE_PORT not in (reported or {}):
return "unsupported"
return ""
def _expire(self, session: Session) -> None:
if self._sessions.get(session.id) is session and not session.answered:
self._end(session, "timeout")
def _end(
self,
session: Session,
reason: str,
*,
tell_viewer: bool = True,
tell_camera: bool = True,
action: str = "bye",
) -> None:
if self._sessions.pop(session.id, None) is not session:
return
if session.timer is not None:
session.timer.cancel()
# Told once, without a retry: a side whose outbox is full is stuck,
# and its own timeout or a closing socket ends it there.
if tell_viewer:
session.viewer.deliver(
_viewer_message(session.device, session.id, action, reason=reason)
)
if tell_camera:
session.camera.deliver(
{
"type": "signal",
"session": session.id,
"action": "bye",
"reason": reason,
}
)
logger.info(
"Live view %s on %s ended after %.1f s: %s",
session.id,
session.device,
time.monotonic() - session.started,
reason,
)
def _relayed(message: dict[str, Any], action: Any) -> dict[str, Any] | None:
"""What of an ``sdp`` or ``candidate`` message is passed on, or None."""
if action not in _RELAYED:
return None
value = message.get(action)
limit = MAX_SDP if action == "sdp" else MAX_CANDIDATE
if not isinstance(value, str) or not value or len(value) > limit:
return None
return {"action": action, action: value}
def _viewer_message(
device: str, session: str, action: str, **rest: Any
) -> dict[str, Any]:
return {
"type": "live",
"device": device,
"session": session,
"action": action,
**rest,
}
sessions = LiveSessions()