""" 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 logging import os import re import time import uuid from pathlib import Path from typing import Any import blake3 from aiortc import RTCDataChannel, RTCPeerConnection, RTCSessionDescription from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) from meshbay_common import MNP_VERSION from meshbay_common.adminop import ( ADMIN_CHALLENGE_TTL, OP_APP_DIRECTORIES, OP_APPS_ENABLED, OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, OP_DIR_DELETE, OP_FILE_DELETE, OP_GEK_ROTATE, OP_GROUP_ATTACH, OP_GROUP_DETACH, OP_INVITE_CANCEL, OP_INVITE_CREATE, OP_INVITE_LINK_CREATE, OP_MEMBER_REVOKE, OP_MEMBER_UNPIN, OP_MUSICBRAINZ_ENABLED, OP_ROOT_ADD, OP_ROOT_EJECT, OP_ROOT_PLUG, OP_ROOT_REMOVE, OP_ROOT_UPDATE, OP_SEARCH_LISTED, OP_SET_SCAN_SETTINGS, OP_TMDB_CONFIG, OP_TMDB_ENABLED, OP_TMDB_OVERRIDE, OP_TMDB_REMATCH, OP_TRANSFER_LIMITS, admin_transcript, ) from meshbay_common.chatbox import ( NONCE_LEN as CHAT_NONCE_LEN, ) from meshbay_common.chatbox import ( SIG_LEN as CHAT_SIG_LEN, ) from meshbay_common.crypto import pk_to_b64, wrap_gek_aes from meshbay_common.device import ( DEVICE_TTL, device_add_transcript, device_hello_transcript, device_request_transcript, ) from meshbay_common.groupbox import ( PURPOSE_ACK, PURPOSE_CHAT_KEYS, PURPOSE_ROSTER, seal, ) from meshbay_common.handshake import ( MNP_MIN_SUPPORTED, NONCE_LEN, ROLE_CLIENT, ROLE_NODE, HandshakeError, authorize_token, challenge_transcript, check_version, handshake_transcript, make_proof, verify_proof, webrtc_binding, ) from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, ROLE_OPERATOR, join_transcript, ) from meshbay_common.protocol import ( MNP, UPLOAD_PROBE_INDEX, chunk_ciphertext, file_chunk_wire, file_upload_ack_wire, file_upload_payload, ) from meshbay_node import hwaccel, linkpreview, ops, platform from meshbay_node import transfers as transfers_mod from meshbay_node import uploads as uploads_mod from meshbay_node.chat import FORMAT_SEALED_V1, ReplayedMessage from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer # 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, ) from meshbay_node.media_probe import ( probe_video as _probe_video, ) from meshbay_node.roots import ( ROOT_NOT_SERVED, SAFE_UPLOAD_NAME, RootSet, _free_name, off_disk, safe_subdir, ) from meshbay_node.roster import KIND_ACCOUNT, KIND_LINK from meshbay_node.transfers import TransferSlots from meshbay_node.transport.webrtc.apps.subtitles import SubtitlesMixin from meshbay_node.transport.webrtc.channel import ( _REPLY_TO, _DataChannelBuffer, _extract_dtls_fingerprint, _get_remote_ip, _pack, ) from meshbay_node.transport.webrtc.disk import _locate from meshbay_node.transport.webrtc.limits import CHUNK_SIZE, MAX_MSG from meshbay_node.transport.webrtc.media_tools import ( _seek_lands_at, _transcode_audio_to_aac, ) from meshbay_node.transport.wire import index_sync_message log = logging.getLogger(__name__) # Per-account blobs (docs/playlists.md §4.3). These are an unbounded write # primitive pointed at somebody else's disk, so every one of them is checked — # and every check **refuses**, never truncates. A truncating cap silently loses # tracks, which is the one failure the whole playlist design exists to prevent. # # The numbers are sized against the measured shape: ~300 bytes per track before # compression, deflate worth about three on a payload this repetitive. A 1 MB # body is therefore roughly ten thousand tracks in one playlist, and the # manifest holds names and revisions only. USER_BLOB_MANIFEST_MAX = 64 * 1024 USER_BLOB_BODY_MAX = 1024 * 1024 USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024 # "playlists" is the manifest; "playlist:" is one playlist's tracks. A # pattern rather than a set, because the ids are client-generated — but a # pattern, not anything at all, or the table becomes a key/value store for # whatever a client feels like writing. # # The character class is deliberately wider than a UUID: the reserved id is the # word "favorites" (docs/playlists.md §5.1), so a hex-only pattern refuses the # one playlist every account has. It stays narrow enough to carry no structure # of its own — no "/", no ".", no second ":" — so a kind can never be read as a # path or as anything but one name in one namespace. _USER_BLOB_KIND_RE = re.compile( r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$") # An invitation link's handle, as `roster.create_link_invite` mints it. _INVITE_ID_RE = re.compile(r"[0-9a-f]{32}") # Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5: # 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 # A free-text TMDB search spends the *operator's* credential, which is rated by # TMDB and shared by everyone in the group: one member typing in the search box # can exhaust what every other member's automatic matching depends on, and the # operator is the one who has to notice. §6.5's rule is a bound and a named # adversary in the same commit; this one arrived without either. # # Per member rather than per connection, unlike link previews above: three tabs # is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the # group context so it survives a reconnect, which is the other thing a per-session # count cannot do. # # Generous next to what a person types — ten searches a minute is a search every # six seconds, sustained — and small next to a loop. _TMDB_SEARCH_WINDOW = 60.0 _TMDB_SEARCH_PER_MEMBER = 10 _TMDB_SEARCH_NODE = 30 # Chat limits. A message is a member-supplied write onto the operator's disk # (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there # to every other connected member and turned into a notification for every member # of the group. Nothing bounded any of it: the only ceiling was the frame size, # 64 MB once the handshake is done, so one member in a loop could fill the # operator's disk and saturate everyone else's connection. Uploads — the other # member-supplied write — have carried four protections and a size cap since # C5a; this is the same question asked of the path nobody had asked it of. # # 64 KB of ciphertext is about sixty thousand characters. The sealed payload is # the text, a thread id, a display name and a timestamp: an attachment is a file # on a root and travels as a reference (§4.5), so nothing legitimate comes close. MAX_CHAT_CIPHERTEXT = 64 * 1024 # Per account per group, not per connection: a second tab does not make a person # type faster, and keying on the session would hand a script one budget per # socket. Sixty a minute is far above a human and far below a flood. _CHAT_RATE_WINDOW = 60.0 _CHAT_RATE_PER_ACCOUNT = 60 # When the map of senders grows past this, the stale entries are dropped. A node # with more live chatters than this in one window is not the case being bounded. _CHAT_RATE_MAX_TRACKED = 1000 # 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). # The ceiling is the operator's to set (`max_upload_gb` in node.toml, the Node # page and `meshbay-node transfers max-size`) because it is their disk that # fills: this is only the default a node starts from when they have said # nothing. It is read from the transport context on every chunk, so a change # applies to an upload already in flight. MAX_UPLOAD_BYTES = 8 * 1024 * 1024 * 1024 # 8 GB per file GB_BYTES = 1024 * 1024 * 1024 # What the `tr` on a chunk request turned out to be (see `_lease_of`). LEASE_GRANTED = "granted" LEASE_QUEUED = "queued" LEASE_NONE = "none" # 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 # How many peer connections this node holds at once, and how long one may stay # without completing the MNP handshake. The budget above bounds what *one* # unauthenticated peer costs; these bound how many there may be and how long # each lasts, which is the other half and was missing. The hub caps three # offers in flight per account — a limit on each caller, not on this machine — # so the cost to an operator grew with the number of people in their groups. # Sized to be unreachable in ordinary use: a browser holds one connection per # open group, and a handshake unfinished after a minute is not going to finish. MAX_PEER_SESSIONS = 64 UNAUTHENTICATED_SESSION_TIMEOUT = 60 # seconds # 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