171 lines
6.4 KiB
Python
171 lines
6.4 KiB
Python
"""Paired signalling, private ICE candidates and bounded live data channels."""
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import queue
|
|
import time
|
|
from contextlib import suppress
|
|
from uuid import uuid4
|
|
|
|
import aioice.ice
|
|
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
|
|
|
PRIVATE_NETWORKS = tuple(
|
|
ipaddress.ip_network(v)
|
|
for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
|
|
)
|
|
|
|
|
|
def private(address):
|
|
try:
|
|
value = ipaddress.ip_address(address)
|
|
return value.version == 4 and any(value in network for network in PRIVATE_NETWORKS)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def admit_sdp(sdp):
|
|
if not isinstance(sdp, str) or not 1 <= len(sdp) <= 32768:
|
|
raise ValueError("Invalid media invitation")
|
|
media = [line for line in sdp.splitlines() if line.startswith("m=")]
|
|
if not sdp.startswith("v=0") or len(media) != 1 or not media[0].startswith("m=application "):
|
|
raise ValueError("A single data-channel media section is required")
|
|
for line in sdp.splitlines():
|
|
if line.startswith("a=candidate:"):
|
|
parts = line.split()
|
|
if (
|
|
len(parts) < 8
|
|
or parts[7] != "host"
|
|
or not (private(parts[4]) or parts[4].endswith(".local"))
|
|
):
|
|
raise ValueError("Only private host ICE candidates are admitted")
|
|
|
|
|
|
class NodeMediaPeers:
|
|
def __init__(self, hub, camera):
|
|
self.hub, self.camera, self.items = hub, camera, {}
|
|
# Process-local adapter in this dedicated worker; no public/STUN/TURN ICE.
|
|
original = aioice.ice.get_host_addresses
|
|
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
|
|
value for value in original(use_ipv4=True, use_ipv6=False) if private(value)
|
|
]
|
|
|
|
async def offer(self, parameters):
|
|
admit_sdp(parameters.get("sdp"))
|
|
if len(self.items) >= 2:
|
|
raise ValueError("Close another live viewer")
|
|
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
|
identifier = "peer_" + uuid4().hex
|
|
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()}
|
|
self.items[identifier] = entry
|
|
|
|
@pc.on("datachannel")
|
|
def datachannel(channel):
|
|
if channel.label not in {"rrd", "camera"} or channel.label in entry["labels"]:
|
|
channel.close()
|
|
return
|
|
entry["labels"].add(channel.label)
|
|
|
|
@channel.on("message")
|
|
def message(value):
|
|
if value == "keepalive":
|
|
entry["seen"] = time.monotonic()
|
|
|
|
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
|
|
|
|
@pc.on("connectionstatechange")
|
|
async def changed():
|
|
if pc.connectionState in {"closed", "failed"}:
|
|
await self.close(identifier)
|
|
|
|
try:
|
|
await pc.setRemoteDescription(
|
|
RTCSessionDescription(sdp=parameters["sdp"], type="offer")
|
|
)
|
|
await pc.setLocalDescription(await pc.createAnswer())
|
|
|
|
async def expiry():
|
|
await asyncio.sleep(25)
|
|
if pc.connectionState != "connected":
|
|
await self.close(identifier)
|
|
|
|
entry["tasks"].append(asyncio.create_task(expiry()))
|
|
snapshot = self.camera.snapshot()
|
|
return {
|
|
"peer_id": identifier,
|
|
"sdp": pc.localDescription.sdp,
|
|
"type": "answer",
|
|
"camera_mime": (snapshot.get("delivery") or {}).get("media_type"),
|
|
"profile": "live-acquisition",
|
|
"transport": "webrtc-rrd-fmp4",
|
|
}
|
|
except BaseException:
|
|
await self.close(identifier)
|
|
raise
|
|
|
|
async def send(self, channel, payload):
|
|
if len(payload) > 8 * 1024 * 1024:
|
|
raise RuntimeError("Preview fragment exceeds bound")
|
|
for offset in range(0, len(payload), 16384):
|
|
deadline = time.monotonic() + 2
|
|
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
|
|
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
|
|
raise RuntimeError("Preview consumer unavailable")
|
|
await asyncio.sleep(0.01)
|
|
channel.send(payload[offset : offset + 16384])
|
|
await asyncio.sleep(0)
|
|
|
|
async def deliver(self, identifier, channel):
|
|
subscriber = lease = None
|
|
try:
|
|
entry = self.items[identifier]
|
|
if channel.label == "rrd":
|
|
subscriber = await asyncio.to_thread(self.hub.subscribe)
|
|
else:
|
|
generation = self.camera.snapshot().get("generation")
|
|
if generation is None:
|
|
channel.close()
|
|
return
|
|
lease = await asyncio.to_thread(self.camera.open_delivery, generation)
|
|
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
|
|
if subscriber:
|
|
payload = await asyncio.to_thread(subscriber.read)
|
|
else:
|
|
try:
|
|
segment = await asyncio.to_thread(lease.segments.get, 0.5)
|
|
except queue.Empty:
|
|
continue
|
|
if segment is None:
|
|
break
|
|
kind, payload = segment
|
|
if kind == "media":
|
|
self.camera.mark_streaming(lease)
|
|
if payload is None:
|
|
break
|
|
if payload:
|
|
await self.send(channel, payload)
|
|
except (Exception, asyncio.CancelledError):
|
|
pass
|
|
finally:
|
|
if subscriber:
|
|
subscriber.close()
|
|
if lease:
|
|
self.camera.release_delivery(lease, client_closed=True)
|
|
if channel.label == "camera":
|
|
channel.close()
|
|
else:
|
|
await self.close(identifier)
|
|
|
|
async def close(self, identifier):
|
|
entry = self.items.pop(identifier, None)
|
|
if entry:
|
|
for task in entry["tasks"]:
|
|
if task is not asyncio.current_task():
|
|
task.cancel()
|
|
with suppress(Exception):
|
|
await entry["pc"].close()
|
|
|
|
async def close_all(self):
|
|
for identifier in list(self.items):
|
|
await self.close(identifier)
|