""" MeshBay Node — WebRTC DataChannel server for browser clients. Browsers cannot use QUIC for NAT traversal (WebTransport doesn't allow choosing the UDP source port — Port-Restricted Cone NAT requires exact port matching). WebRTC DataChannel with ICE/STUN handles this automatically. The MNP protocol (handshake, file_request, file_chunk, chat, etc.) runs identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption, same message types, same msgpack wire format. Wire format on the DataChannel: - Each message is length-prefixed msgpack (4-byte big-endian + msgpack payload) - Same as QUIC streams and TCP+TLS - DataChannel is ordered and reliable (SCTP over DTLS) Signaling flow (handled externally by the hub): Browser → Hub : POST /v1/nodes/{id}/webrtc/offer {sdp, ice_candidates} Hub → Node : WS push {type: "webrtc_offer", sdp, ice_candidates, peer_id} Node → Hub : WS push {type: "webrtc_answer", sdp, ice_candidates, peer_id} Hub → Browser : SSE/response {sdp, ice_candidates} After signaling, DataChannel is P2P — hub is out of the loop. """ import asyncio import base64 import hashlib import hmac import logging import os import struct import tempfile import time from pathlib import Path from typing import Any import blake3 import jwt import msgpack from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) from meshbay_common import MNP_VERSION from meshbay_common.handshake import ( MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, check_version, handshake_transcript, make_proof, verify_proof, webrtc_binding, ) from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, OP_DIR_DELETE, OP_FILE_DELETE, OP_INVITE_CREATE, OP_MEMBER_REVOKE, OP_GEK_ROTATE, OP_MEMBER_UNPIN, OP_MEMBER_UPLOAD, OP_APPS_ENABLED, OP_SET_SCAN_SETTINGS, OP_TMDB_CONFIG, OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, OP_TMDB_REMATCH, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_ROOT_UPDATE, OP_ROOT_EJECT, OP_ROOT_PLUG, OP_GROUP_ATTACH, OP_GROUP_DETACH, admin_transcript, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes from meshbay_common.device import ( DEVICE_TTL, device_add_transcript, device_code_hash, device_request_transcript, ) from meshbay_common.groupbox import PURPOSE_ACK, seal from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, ROLE_OPERATOR, join_transcript, ) from meshbay_common.protocol import MNP, chunk_ciphertext, file_chunk_wire from meshbay_node.transport.wire import index_sync_message from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer from meshbay_node import linkpreview, ops, platform # Re-imported under its original name: every call site and existing test in # this module still refers to it as `_probe_video`. The implementation lives # in media_probe.py so the indexer package (imported just above) can call it # too, for index-time enrichment, without a circular import. from meshbay_node.media_probe import ( BROWSER_INCOMPATIBLE_VIDEO_CODECS, probe_video as _probe_video, ) from meshbay_node.roots import ( RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, ) log = logging.getLogger(__name__) CHUNK_SIZE = 1024 * 1024 MAX_MSG = 64 * 1024 * 1024 # Chat link-preview results, kept in memory only (draft-v6 §2.7: the node # produces enrichment on demand and keeps nothing durable — the asking device # caches). Bounded and time-limited so a busy group cannot grow it without end # and a page that changed its card is picked up within the hour. _LINK_PREVIEW_TTL = 3600 _LINK_PREVIEW_MAX = 256 _link_preview_cache: dict[str, tuple[float, dict]] = {} def _link_preview_cache_get(url: str) -> dict | None: hit = _link_preview_cache.get(url) if hit is None: return None ts, value = hit if time.time() - ts > _LINK_PREVIEW_TTL: _link_preview_cache.pop(url, None) return None return value def _link_preview_cache_put(url: str, value: dict) -> None: if not url: return if len(_link_preview_cache) >= _LINK_PREVIEW_MAX: oldest = min(_link_preview_cache, key=lambda k: _link_preview_cache[k][0]) _link_preview_cache.pop(oldest, None) _link_preview_cache[url] = (time.time(), value) # A member pasting a link is normal; a member — or a hub minting tokens for many # accounts — firing hundreds is amplification/DoS and a way to make the node # reach arbitrary hosts on demand (finding M3). Only a real outbound fetch is # counted (a cache hit costs nothing), and the ceilings are generous enough that # ordinary chat never meets them. _LINK_PREVIEW_RATE_WINDOW = 60.0 _LINK_PREVIEW_RATE_PER_CONN = 15 _LINK_PREVIEW_RATE_NODE = 60 # Upload limits (finding C5a). Uploads used to land directly in the shared root under # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its # recorded uploader, then delete it legitimately). MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file # Budget for an unauthenticated peer: enough for a handshake and a bundle fetch, # nowhere near enough to be a memory-exhaustion primitive (H6). PRE_HANDSHAKE_MAX_MSG = 64 * 1024 # ffmpeg is spawned per stream request; without a cap any member can fork-bomb # the node by requesting many streams at once (H6). # # Two was sized when a stream was a burst: the client took segments as fast as # it could append them, so a slot was held for the minute it took to push the # file and then came back. Now that the client only pulls ninety seconds ahead # of the playhead, a slot is held for as long as the film runs — so two slots # means two people can watch anything at all, and the third is refused for the # next hour and a half. The work behind a slot has not changed and is small: # ffmpeg runs `-c copy`, a remux with no encoding in it, and spends most of the # film blocked on a pipe nobody is reading. # # This is the default, not the policy: the right number depends on the machine, # so the operator sets `max_concurrent_streams` under [node] in node.toml. This # value applies when they have said nothing. MAX_CONCURRENT_TRANSCODES = 8 # Extensions no mainstream browser's