""" 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 contextvars import hashlib import hmac import logging import os import struct import tempfile import time import uuid 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_APPS_ENABLED, OP_SET_SCAN_SETTINGS, OP_TRANSFER_LIMITS, OP_TMDB_CONFIG, OP_TMDB_ENABLED, OP_TMDB_OVERRIDE, OP_TMDB_REMATCH, OP_MUSICBRAINZ_ENABLED, OP_APP_DIRECTORIES, OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW, 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_hello_transcript, device_request_transcript, ) from meshbay_common.groupbox import ( PURPOSE_ACK, PURPOSE_CHAT_KEYS, PURPOSE_ROSTER, seal, ) from meshbay_common.join import ( JOIN_TTL, ROLE_MEMBER, ROLE_OPERATOR, join_transcript, ) from meshbay_common.chatbox import ( NONCE_LEN as CHAT_NONCE_LEN, SIG_LEN as CHAT_SIG_LEN, ) from meshbay_common.protocol import ( MNP, chunk_ciphertext, file_chunk_wire, UPLOAD_PROBE_INDEX, file_upload_ack_wire, file_upload_payload, ) from meshbay_node.chat import FORMAT_SEALED_V1, ReplayedMessage 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 from meshbay_node import transfers as transfers_mod from meshbay_node import uploads as uploads_mod from meshbay_node.transfers import TransferSlots # 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 # 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