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).
373 lines
13 KiB
Python
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()
|