Files
app/backend/fluksio/cloud/connector.py
T
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

1015 lines
43 KiB
Python

"""The link this instance opens to its portal.
Same shape as the remote-worker agent, with the direction of the favour
reversed: there, a GPU box dials the engine so the engine can give it work;
here, the engine dials the portal so the portal can pass on what a browser
asked for. Both exist because the interesting machine is the one nobody can
route to.
A proxied request is dispatched straight into this process's own ASGI app
rather than over localhost HTTP. That matters beyond saving a hop: HTTP
trigger nodes install themselves into ``app.routes`` while flows run, and an
in-process transport sees that table live, with no port, TLS or self-address
to discover.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import logging
import random
import time
import uuid
from collections.abc import Callable, MutableMapping
from dataclasses import replace
from datetime import timedelta
from typing import Any
import httpx
import jwt
from fastapi import FastAPI
import fluksio
from fluksio.cloud import config as cloud_config
from fluksio.cloud.enroll import is_loopback
logger = logging.getLogger(__name__)
PROTOCOL = 1
#: The hub closes a credential it will never accept again with this code (see
#: the hub's ``tunnel/protocol.py``). The link then stands down until enrolled
#: again or restarted, rather than dialling a deleted instance forever.
REVOKED_CLOSE = 4410
HEARTBEAT_S = 20.0
STATUS_S = 60.0
BASE_BACKOFF_S = 1.0
MAX_BACKOFF_S = 30.0
#: How long a silent socket is given before it is treated as gone. A laptop
#: that suspended, or a wifi that changed underneath us, leaves a connection
#: the operating system still believes in — nothing arrives and nothing fails.
#: Only an unanswered ping tells us, so this is the worst case for noticing.
PING_INTERVAL_S = 20.0
PING_TIMEOUT_S = 20.0
CHUNK_BYTES = 256 * 1024
MAX_WS_MESSAGE = 1024 * 1024
#: What the tunnel serves: the versioned API, which the hub opens only to a
#: portal session; the MCP endpoint and the OAuth documents leading an agent
#: to it, which take the agent's own token; and the flows' hooks, of which
#: only those with a secret answer a request from the tunnel. Everything else,
#: the API docs included, stays on this machine.
ALLOWED_PREFIXES = ("/api/v1/", "/hooks/")
ALLOWED_PATHS = frozenset(
{
"/mcp",
"/.well-known/oauth-protected-resource/mcp",
"/.well-known/oauth-authorization-server",
"/.well-known/jwks.json",
}
)
#: Proxied calls, and bridged streams, this instance will run at once.
#: Neither dict was bounded, and an id the hub reused silently dropped the
#: reference to a task that was still running.
MAX_IN_FLIGHT = 256
#: How many of a client's frames are kept for a stream not listening yet.
#: One past it closes the stream rather than losing a frame: a live view's
#: candidates are worth nothing with one missing.
MAX_EARLY = 64
#: How often to look for a config that appeared while the engine was up.
#: Enrolment is something a person does and then waits on, so this is the
#: delay they sit through — short enough not to be worth a restart.
ENROL_POLL_S = 3.0
def _identity(config: cloud_config.CloudConfig | None) -> tuple[str, str] | None:
"""Which enrolment a link is running on, so a replaced one is noticed."""
return (config.instance_id, config.token) if config is not None else None
def start(app: FastAPI) -> None:
"""Dial the portal, replacing any link already up."""
existing = getattr(app.state, "cloud_task", None)
if existing is not None:
existing.cancel()
connector = CloudConnector(app)
app.state.cloud_connector = connector
app.state.cloud_identity = _identity(cloud_config.load())
app.state.cloud_task = asyncio.create_task(
connector.serve_forever(), name="cloud-connector"
)
async def watch_enrolment(app: FastAPI) -> None:
"""Notice an enrolment that happened outside this process.
``fluksio enroll`` writes the config against the database directly, with no
idea whether an engine is running — so without this, pairing an instance
that is already serving would take a restart to come into effect. Enrolling
through the API starts the link itself and this sees a task already there.
"""
while True:
await asyncio.sleep(ENROL_POLL_S)
task = getattr(app.state, "cloud_task", None)
# `load()`, not `exists()`: a file that cannot be read is not an
# enrolment, and the connector would give up on it the instant it
# started — which, started from here, is a restart every few seconds.
# A config that is fine but unreachable keeps its task, and the
# retrying belongs to the connector rather than to this.
config = cloud_config.load()
if task is not None and task.done():
connector = getattr(app.state, "cloud_connector", None)
if (
connector is not None
and connector.revoked
and _identity(config) == getattr(app.state, "cloud_identity", None)
):
# Stood down on the portal's word, and kept for its status.
# Only a new enrolment brings the link back.
continue
# It returns of its own accord when the config goes away, which is
# what Disconnect, here or on the portal, does.
app.state.cloud_task = None
app.state.cloud_connector = None
task = None
if task is not None and _identity(config) != getattr(
app.state, "cloud_identity", None
):
# Enrolled again, at this portal or another one. `fluksio enroll`
# is its own process and cannot cancel this task, so the link would
# otherwise stay up on the credential that was replaced.
logger.info("Enrolment replaced while running; redialling")
task.cancel()
task = None
if task is None and config is not None:
logger.info("Enrolled while running; dialling the portal")
start(app)
class CloudConnector:
"""Holds one websocket to the portal open, and answers over it."""
def __init__(self, app: FastAPI) -> None:
self._app = app
self._calls: dict[str, asyncio.Task[None]] = {}
self._streams: dict[str, asyncio.Task[None]] = {}
self._connected = False
self._connected_since: float | None = None
self._last_error: str | None = None
self._revoked = False
#: The portal account that owns this instance, as the last welcome said.
self._owner: str | None = None
#: Where the portal opens this instance, as the last welcome said. An
#: older hub does not say.
self.app_url: str | None = None
#: What the hub sent each bridged hook socket, waiting to be received.
self._inboxes: dict[str, asyncio.Queue[dict[str, Any]]] = {}
#: What reads a client's frames on each bridged live stream, and what
#: a client said before its stream was listening.
self._listeners: dict[str, Callable[[str], None]] = {}
self._early: dict[str, list[str]] = {}
@property
def revoked(self) -> bool:
return self._revoked
# -------------------------------------------------------------------------
# Status, for the local settings screen
# -------------------------------------------------------------------------
def status(self) -> dict[str, Any]:
config = cloud_config.load()
return {
"enrolled": config is not None,
"connected": self._connected,
"portal_url": config.portal_url if config else None,
# Where a browser reaches the portal, which is not always where
# this process does: enrolment may have named a container.
"issuer": config.issuer if config else None,
"portal_account": config.portal_account if config else None,
"instance_id": config.instance_id if config else None,
"last_error": self._last_error,
"connected_since": self._connected_since,
"revoked": self._revoked,
}
# -------------------------------------------------------------------------
# The connection
# -------------------------------------------------------------------------
async def serve_forever(self) -> None:
"""Hold the link up, and put it back up when it goes down.
Every ending is the same ending here: a portal that closed the socket,
a network that changed under it, a laptop that woke up somewhere else.
The link is dialled again in all of them — the only question is how
soon, and that turns on whether this attempt got as far as being
attached. One that did and then dropped is a network event, so it
retries at once; one that never stood up is met with a longer wait
each time, because whatever is refusing is unlikely to stop within a
second.
"""
delay = BASE_BACKOFF_S
while True:
config = cloud_config.load()
if config is None:
# Disconnected locally while we were running.
return
attached = False
try:
attached = await self._session(config)
except asyncio.CancelledError:
raise
except Exception as exc:
self._last_error = str(exc)
if _is_revoked(exc):
# Dialling again would be refused the same way forever.
# The config stays, so enrolling again replaces it.
self._revoked = True
logger.warning("The portal revoked this instance; standing down")
return
logger.warning("Portal link down: %s", exc)
finally:
self._connected = False
self._connected_since = None
delay = BASE_BACKOFF_S if attached else min(MAX_BACKOFF_S, delay * 2)
# Jittered, so a portal coming back up is not met by every
# instance it serves in the same instant.
wait = delay * (0.75 + random.random() * 0.5)
logger.info("Reconnecting to the portal in %.0fs", wait)
await asyncio.sleep(wait)
async def _session(self, config: cloud_config.CloudConfig) -> bool:
"""One connection, from dial to close. True if it ever attached."""
import websockets
attached = False
# A header rather than `?token=`, since a URL ends up in access logs.
# A hub from before the header reads only the query: upgrade the hub
# before its instances.
async with websockets.connect(
config.ws_url,
additional_headers={"Authorization": f"Bearer {config.token}"},
max_size=MAX_WS_MESSAGE,
ping_interval=PING_INTERVAL_S,
ping_timeout=PING_TIMEOUT_S,
) as socket:
await socket.send(
_dump({"op": "hello", "protocol": PROTOCOL, "app_version": _version()})
)
welcome = _load(await socket.recv())
if welcome.get("op") != "welcome":
raise RuntimeError(
str(welcome.get("reason") or "refused by the portal")
)
self._owner = welcome.get("owner")
self.app_url = welcome.get("app_url")
self._adopt_owner(config, self._owner)
self._adopt_jwks(welcome.get("jwks"))
attached = True
self._connected = True
self._connected_since = time.time()
self._last_error = None
logger.info("Attached to the portal as instance %s", config.instance_id)
keepalive = asyncio.create_task(self._keepalive(socket, config))
try:
async for raw in socket:
frame = _load(raw)
op = frame.get("op")
if op == "req":
self._start_call(socket, frame)
elif op == "cancel":
self._cancel(str(frame.get("id") or ""))
elif op == "ws_open":
self._start_stream(socket, frame)
elif op == "ws_msg":
stream = str(frame.get("id") or "")
text = str(frame.get("text") or "")
inbox = self._inboxes.get(stream)
listener = self._listeners.get(stream)
if inbox is not None:
inbox.put_nowait(
{"type": "websocket.receive", "text": text}
)
elif listener is not None:
listener(text)
elif stream in self._streams:
# A browser speaks as soon as the hub accepts it,
# before this end has checked its credential.
early = self._early.setdefault(stream, [])
if len(early) < MAX_EARLY:
early.append(text)
else:
logger.warning(
"Closing stream %s: %d frames before it listened",
stream,
MAX_EARLY,
)
self._cancel_stream(stream)
await socket.send(
_dump(
{"op": "ws_close", "id": stream, "code": 1011}
)
)
elif op == "ws_close":
self._cancel_stream(str(frame.get("id") or ""))
elif op == "member_removed":
await asyncio.to_thread(
self._forget_member,
str(frame.get("user") or ""),
self._owner,
)
finally:
keepalive.cancel()
# Awaited, not just cancelled: a keepalive that died of its own
# accord holds the reason the link went, and an un-awaited task
# takes it to the garbage collector instead of the log.
with contextlib.suppress(asyncio.CancelledError, Exception):
await keepalive
self._connected = False
self._connected_since = None
for task in list(self._calls.values()) + list(self._streams.values()):
task.cancel()
self._calls.clear()
self._streams.clear()
self._early.clear()
return attached
@staticmethod
def _adopt_owner(config: cloud_config.CloudConfig, owner: Any) -> None:
"""Map the enrolling account to the portal account that owns us.
Enrolment does this itself. This is for the enrolments that predate
per-user mapping: without it their owner would be refused here until
somebody enrolled the machine again — which, for a machine reached only
through the portal, means standing in front of it. Runs on every attach
because it is a no-op once the mapping is there.
Never moves a mapping somebody else holds: that would be this code
guessing at something enrolment was told.
"""
from sqlmodel import Session, select
from fluksio.core.db import engine
from fluksio.models import User
if not owner:
return
owner_id = str(owner)
try:
with Session(engine) as session:
if session.exec(
select(User).where(User.portal_sub == owner_id)
).first():
return
user = CloudConnector._local_account(session, config)
if user is None or user.portal_sub:
return
user.portal_sub = owner_id
session.add(user)
session.commit()
logger.info("Mapped %s to the portal account that owns us", user.email)
except Exception:
# A mapping that could not be written is not a reason to drop the
# link: everything else this connection does still works.
logger.exception("Could not map the enrolling account to the portal owner")
@staticmethod
def _adopt_jwks(jwks: Any) -> None:
"""Replace the pinned signing keys with the set the hub sends on attach.
Taken only from a link that is encrypted or on this machine, the rule
enrolment applies to the portal address. Over cleartext anything on the
path could hand this instance a key of its own.
"""
config = cloud_config.load()
if config is None or not isinstance(jwks, dict) or jwks == config.jwks:
return
if not config.ws_url.startswith("wss://") and not is_loopback(config.ws_url):
return
try:
jwt.PyJWKSet.from_dict(jwks)
except jwt.PyJWTError as exc:
logger.warning("Ignoring unusable portal keys: %s", exc)
return
cloud_config.save(replace(config, jwks=jwks))
logger.info("Took the portal's current signing keys")
@staticmethod
def _forget_member(portal_sub: str, owner: str | None) -> None:
"""Delete the local user of somebody the portal no longer admits.
Never the owner, and never a superuser: the portal only ever creates
ordinary accounts here, so those are not its to take away.
"""
from sqlmodel import Session, select
from fluksio.core.db import engine
from fluksio.models import User
if not portal_sub or portal_sub == owner:
return
try:
with Session(engine) as session:
user = session.exec(
select(User).where(User.portal_sub == portal_sub)
).first()
if user is None or user.is_superuser:
return
session.delete(user)
session.commit()
logger.info(
"Deleted %s, removed from this instance at the portal", user.email
)
except Exception:
logger.exception("Could not delete the user the portal removed")
@staticmethod
def _local_account(session: Any, config: cloud_config.CloudConfig) -> Any:
"""The account this instance acts as, or the one that now stands for it.
``local_user_id`` names whoever redeemed the claim code. A database
restored from a backup, or rebuilt under an enrolment that outlived it,
has the same operator behind a different row — and then this resolves to
nobody, which would refuse the owner on a machine they may only be able
to reach through the portal. That is the lockout the enrolment handshake
exists to prevent, so it is closed here too: one superuser is not a guess,
it is the account enrolment would have used. Several is a guess, and this
code does not make it.
The new id is written back, because the same field is what a screen
paired through the portal borrows — a panel would otherwise be refused
for the same reason and with no way to say so.
"""
from fluksio.models import User
try:
user = session.get(User, uuid.UUID(config.local_user_id))
except ValueError:
user = None
if user is not None:
return user
from sqlmodel import select
superusers = list(
session.exec(select(User).where(User.is_superuser == True)) # noqa: E712
)
if len(superusers) != 1:
logger.warning(
"The account this instance enrolled as is gone, and there "
"%s to adopt unambiguously — enrol again from Settings.",
"is no superuser" if not superusers else "are several superusers",
)
return None
adopted = superusers[0]
cloud_config.save(replace(config, local_user_id=str(adopted.id)))
logger.warning(
"The account this instance enrolled as is gone; acting as %s instead",
adopted.email,
)
return adopted
async def _keepalive(self, socket: Any, config: cloud_config.CloudConfig) -> None:
"""Heartbeats, plus a health snapshot the portal can show while offline."""
last_status = 0.0
while True:
now = time.time()
if now - last_status >= STATUS_S or last_status == 0.0:
summary = await self._health_summary(config)
await socket.send(
_dump(
{"op": "status", "summary": summary, "app_version": _version()}
)
)
last_status = now
else:
await socket.send(_dump({"op": "heartbeat"}))
await asyncio.sleep(HEARTBEAT_S)
async def _health_summary(
self, config: cloud_config.CloudConfig
) -> dict[str, Any] | None:
"""This instance's own health, as the dashboard reads it.
Fetched through the same in-process transport as everything else, with
a short-lived local token: the observability API is authenticated, and
this process is entitled to mint one for the enrolling user.
``failures_24h`` is added here rather than by ``/summary``: it is the
portal's tile and nothing on this instance reads it, and summing
the same rollups over the same window the app's own Home tile sums
keeps the two from drifting apart the way they already did once.
"""
from fluksio.core import security
token = security.create_access_token(config.local_user_id, timedelta(minutes=5))
headers = {"Authorization": f"Bearer {token}"}
try:
async with self._client() as client:
response = await client.get(
"/api/v1/observability/summary", headers=headers
)
if response.status_code != 200:
return None
summary: dict[str, Any] = response.json()
rollups = await client.get(
"/api/v1/observability/flows",
params={"hours": 24},
headers=headers,
)
if rollups.status_code == 200:
summary["failures_24h"] = sum(row["errors"] for row in rollups.json())
return summary
except Exception:
logger.debug("Could not collect a health summary for the portal")
return None
# -------------------------------------------------------------------------
# Proxied requests
# -------------------------------------------------------------------------
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=self._app),
base_url="http://instance.local",
timeout=httpx.Timeout(300.0, connect=5.0),
)
def _start_call(self, socket: Any, frame: dict[str, Any]) -> None:
call_id = str(frame.get("id") or "")
if not call_id:
return
if len(self._calls) >= MAX_IN_FLIGHT:
logger.warning(
"Refusing proxied call %s: %d already in flight",
call_id,
len(self._calls),
)
return
# An id the hub reuses would otherwise drop the reference to a task
# still running, leaving it with nothing able to cancel it.
self._cancel(call_id)
task = asyncio.create_task(self._serve(socket, frame))
self._calls[call_id] = task
task.add_done_callback(lambda _t: self._calls.pop(call_id, None))
async def _serve(self, socket: Any, frame: dict[str, Any]) -> None:
call_id = str(frame["id"])
path = str(frame.get("path") or "")
try:
if not _allowed(path):
# Defence in depth: the hub forwards nothing else, and an
# instance that trusted it to would be an SSRF gadget aimed at
# its own process.
await socket.send(
_dump({"op": "err", "id": call_id, "message": "path not allowed"})
)
return
body = frame.get("body")
content = base64.b64decode(body) if body else None
query = str(frame.get("query") or "")
headers = dict(frame.get("headers") or {})
# Set here rather than trusted from the frame: a browser can send
# any header it likes through the proxy, and this one decides where
# a pairing device's credential is minted. Arriving on this socket
# is the only thing that makes it true, and the value is this
# process's own so nothing off the network can imitate it.
headers[cloud_config.VIA_HEADER] = cloud_config.VIA_PORTAL
# Addressed as the caller addressed it, which is the hub. The MCP
# endpoint checks the Host against the names it answers to.
headers["host"] = _hub_host()
async with self._client() as client:
request = client.build_request(
str(frame.get("method") or "GET"),
f"{path}?{query}" if query else path,
headers=headers,
content=content,
)
response = await client.send(request, stream=True)
try:
await socket.send(
_dump(
{
"op": "res",
"id": call_id,
"status": response.status_code,
"headers": dict(response.headers),
}
)
)
async for chunk in response.aiter_bytes(CHUNK_BYTES):
await socket.send(
_dump(
{
"op": "res_body",
"id": call_id,
"body": base64.b64encode(chunk).decode("ascii"),
"done": False,
}
)
)
await socket.send(
_dump({"op": "res_body", "id": call_id, "done": True})
)
finally:
await response.aclose()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Proxied %s failed: %s", path, exc)
with contextlib.suppress(Exception):
await socket.send(
_dump({"op": "err", "id": call_id, "message": str(exc)})
)
def _cancel(self, call_id: str) -> None:
task = self._calls.pop(call_id, None)
if task is not None:
task.cancel()
# -------------------------------------------------------------------------
# Bridged live stream
# -------------------------------------------------------------------------
def _start_stream(self, socket: Any, frame: dict[str, Any]) -> None:
stream_id = str(frame.get("id") or "")
if not stream_id:
return
if len(self._streams) >= MAX_IN_FLIGHT:
logger.warning(
"Refusing stream %s: %d already open", stream_id, len(self._streams)
)
return
self._cancel_stream(stream_id)
task = asyncio.create_task(self._stream(socket, frame))
self._streams[stream_id] = task
task.add_done_callback(lambda done: self._stream_done(stream_id, done))
def _stream_done(self, stream_id: str, task: asyncio.Task[None]) -> None:
# Its own entry only: an id the hub reused belongs to the newer task.
if self._streams.get(stream_id) is task:
del self._streams[stream_id]
self._early.pop(stream_id, None)
async def _stream(self, socket: Any, frame: dict[str, Any]) -> None:
"""Feed the engine's live events down the tunnel.
The flows websocket is subscribed to directly rather than dialled:
httpx cannot speak websocket, and everything that endpoint does — check
the token, send a snapshot, forward the bus — is a few lines against
objects this process already holds. The snapshot is built by the route
itself, so the two cannot drift apart behind the portal's back.
"""
from sqlmodel import Session
from fluksio.api.deps import hidden_from, user_from_token
from fluksio.api.routes.flows import (
carried,
demand_frame,
device_of,
panel_scope,
revoked,
snapshot_payload,
wanted_names,
)
from fluksio.api.socket_auth import redeem_ticket
from fluksio.core.db import engine
from fluksio.flow import live as live_view
from fluksio.flow.demand import demand
from fluksio.flow.devices import presence
from fluksio.flow.events import event_bus
stream_id = str(frame["id"])
path = str(frame.get("path") or "")
query = str(frame.get("query") or "")
if _allowed(path) and path.startswith("/hooks/"):
await self._hook_stream(socket, stream_id, path, query)
return
# The browser's ticket when it brought one; the hub restates the
# session it checked as `token` either way, which is the tunnel's own
# channel and stays when the public `?token=` goes.
ticket = _query_value(query, "ticket")
token = redeem_ticket(ticket) if ticket else _query_value(query, "token")
if not path.startswith("/api/v1/") or not path.endswith("/flows/ws"):
await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1008}))
return
try:
with Session(engine) as session:
user = user_from_token(session, token, app=self._app)
only = panel_scope(token, self._app) if user is not None else None
# A remote device is online for as long as this stream is open.
device = device_of(token, self._app) if only is not None else ""
except Exception as exc:
# Could not tell, which is not a refusal: 1008 is what makes a
# device check whether it is still paired.
logger.warning("Could not check a stream's credential: %s", exc)
await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1013}))
return
if user is None:
await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1008}))
return
await socket.send(_dump({"op": "ws_open_ok", "id": stream_id}))
# The cameras a person may not see, looked at again per event.
hidden = hidden_from(user, self._app, device=only is not None)
controller = getattr(self._app.state, "flow_controller", None)
told: list[str] = []
if controller is not None:
# A device here sends its frames over HTTP: the tunnel carries
# text only.
snapshot = snapshot_payload(controller, only, device, "http", hidden)
told = list(snapshot.get("demand", []))
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": _dump(snapshot)})
)
# The media this client's tiles are drawing count as demand on the
# devices sending them, as a direct socket's do. The bytes themselves
# are fetched one by one through the portal.
viewer = ("tunnel", stream_id)
wanted: set[str] = set()
# Where a live view this stream is one end of reaches it. Its
# signalling is text, so it crosses, on an outbox of its own.
endpoint = live_view.Endpoint()
outbox: asyncio.Task[str] | None = None
sender: asyncio.Task[dict[str, Any]] | None = None
def heard(text: str) -> None:
nonlocal wanted
asked = wanted_names(text, only, hidden)
if asked is not None:
wanted = asked
demand.want(viewer, asked)
elif (signal := live_view.parse(text)) is not None:
live_view.sessions.heard(
signal,
endpoint,
person=str(user.id) if only is None else "",
device=device,
devices=getattr(self._app.state, "device_store", None),
superuser=user.is_superuser,
)
self._listeners[stream_id] = heard
for text in self._early.pop(stream_id, []):
heard(text)
if device:
presence.opened(device)
live_view.sessions.attach(device, endpoint)
try:
async with event_bus.subscribe() as queue:
outbox = asyncio.create_task(endpoint.next())
if device and (ports := demand.ports(device)) != told:
await socket.send(
_dump(
{
"op": "ws_msg",
"id": stream_id,
"text": demand_frame(ports),
}
)
)
while True:
sender = asyncio.create_task(queue.get())
done, _ = await asyncio.wait(
{sender, outbox}, return_when=asyncio.FIRST_COMPLETED
)
if outbox in done:
# Signalling, for this stream alone.
if sender not in done:
sender.cancel()
text = outbox.result()
outbox = asyncio.create_task(endpoint.next())
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": text})
)
if sender not in done:
continue
event = sender.result()
# Behind far enough to lose events, a device catches up
# as after a dashboard: the lost one may have been its
# demand or its unpairing. A person whose cameras opened
# or closed is sent what may be seen now.
resync = only is not None and event_bus.dropped(queue)
if only is None:
fresh = hidden_from(user, self._app)
if fresh != hidden:
hidden = fresh
wanted -= hidden
demand.want(viewer, wanted)
resync = True
kind = event.get("type")
if kind == "device_demand":
# For the device it names, and for nobody else.
if device and event.get("device") == device:
text = demand_frame(list(event.get("ports") or []))
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": text})
)
elif only is not None and kind == "dashboard_changed":
resync = True
elif carried(event, only, hidden):
await socket.send(
_dump(
{"op": "ws_msg", "id": stream_id, "text": _dump(event)}
)
)
if not resync:
continue
if only is not None:
if device and revoked(token, self._app):
# Unpaired or deleted: 1008 sends it to check.
await socket.send(
_dump({"op": "ws_close", "id": stream_id, "code": 1008})
)
return
# What this screen shows may have just changed under
# it. Rescope, then resend the snapshot so a dashboard
# it has only now been assigned draws values instead of
# blanks. ``or set()`` is the point: a panel that was
# deleted scopes to nothing, and None would widen this
# socket to everything on the bus.
only = panel_scope(token, self._app) or set()
wanted &= only
demand.want(viewer, wanted)
if controller is not None:
catch_up = snapshot_payload(
controller, only, device, "http", hidden
)
await socket.send(
_dump(
{
"op": "ws_msg",
"id": stream_id,
"text": _dump(catch_up),
}
)
)
if kind == "dashboard_changed" and only is not None:
# After the snapshot, as the device socket sends it.
await socket.send(
_dump(
{"op": "ws_msg", "id": stream_id, "text": _dump(event)}
)
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("Live stream %s ended: %s", stream_id, exc)
with contextlib.suppress(Exception):
await socket.send(
_dump({"op": "ws_close", "id": stream_id, "code": 1011})
)
finally:
for task in (outbox, sender):
if task is not None:
task.cancel()
self._listeners.pop(stream_id, None)
self._early.pop(stream_id, None)
demand.drop(viewer)
live_view.sessions.detach(endpoint)
if device:
presence.closed(device)
async def _hook_stream(
self, socket: Any, stream_id: str, path: str, query: str
) -> None:
"""Serve a flow's socket over the tunnel, as uvicorn would serve it here.
The app is called with a websocket scope of its own, so the route a hook
installed answers exactly as it does a local client, secret included.
"""
inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
inbox.put_nowait({"type": "websocket.connect"})
self._inboxes[stream_id] = inbox
state = {"accepted": False, "closed": False}
async def send(message: MutableMapping[str, Any]) -> None:
kind = message["type"]
if kind == "websocket.accept":
state["accepted"] = True
await socket.send(_dump({"op": "ws_open_ok", "id": stream_id}))
elif kind == "websocket.send":
text = message.get("text")
if text is None:
text = (message.get("bytes") or b"").decode(errors="replace")
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": text})
)
elif kind == "websocket.close":
state["closed"] = True
# Refused before it was accepted is a refusal whatever the code,
# so no route, a wrong secret and no secret all look alike.
code = message.get("code", 1000) if state["accepted"] else 1008
await socket.send(
_dump({"op": "ws_close", "id": stream_id, "code": code})
)
scope = {
"type": "websocket",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"scheme": "ws",
"path": path,
"raw_path": path.encode(),
"root_path": "",
"query_string": query.encode(),
"headers": [
(b"host", _hub_host().encode()),
(cloud_config.VIA_HEADER.encode(), cloud_config.VIA_PORTAL.encode()),
],
"client": ("127.0.0.1", 0),
"server": None,
"subprotocols": [],
}
code = 1000
try:
await self._app(scope, inbox.get, send)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("Hook stream %s ended: %s", stream_id, exc)
code = 1011
finally:
self._inboxes.pop(stream_id, None)
if not state["closed"]:
with contextlib.suppress(Exception):
await socket.send(
_dump({"op": "ws_close", "id": stream_id, "code": code})
)
def _cancel_stream(self, stream_id: str) -> None:
# The frames it was holding go with it, including those of a stream
# cancelled before it ever ran its own cleanup.
self._early.pop(stream_id, None)
task = self._streams.pop(stream_id, None)
if task is not None:
task.cancel()
def _allowed(path: str) -> bool:
"""Whether the tunnel may serve this path.
A dot segment is refused, literal or encoded: httpx resolves a literal one
before dispatching, so ``/hooks/../api/v1/…`` would otherwise reach the API
as a path the hub forwarded without a session.
"""
from urllib.parse import unquote
if {".", ".."} & set(unquote(path).split("/")):
return False
return path in ALLOWED_PATHS or path.startswith(ALLOWED_PREFIXES)
def _hub_host() -> str:
"""The host a caller addressed through the tunnel, the hub's own."""
from urllib.parse import urlsplit
config = cloud_config.load()
return urlsplit(config.issuer).netloc if config is not None else "instance.local"
def _is_revoked(exc: Exception) -> bool:
"""Whether the hub closed the link as revoked, at the handshake or later."""
from websockets.exceptions import ConnectionClosed
return (
isinstance(exc, ConnectionClosed)
and exc.rcvd is not None
and exc.rcvd.code == REVOKED_CLOSE
)
def _dump(payload: Any) -> str:
import json
return json.dumps(payload, default=str)
def _load(raw: Any) -> dict[str, Any]:
import json
data: dict[str, Any] = json.loads(raw)
return data
def _query_value(query: str, key: str) -> str:
from urllib.parse import parse_qs
values = parse_qs(query).get(key) or [""]
return values[0]
def _version() -> str:
return fluksio.__version__