""" MeshBay Node configuration. Config file: ~/.config/meshbay/node.toml All values have sensible defaults and can be overridden by env vars prefixed with MESHBAY_ (e.g. MESHBAY_HUB_URL). """ import logging import os from dataclasses import dataclass, field from pathlib import Path from meshbay_node.platform import config_dir, data_dir try: import tomllib # Python 3.11+ except ImportError: import tomli as tomllib # type: ignore[no-redef] log = logging.getLogger(__name__) DEFAULT_STUN_SERVERS: list[str] = [ "stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302", "stun:stun.cloudflare.com:3478", # stun.services.mozilla.com was here — Mozilla retired it, the name no # longer resolves, and each gather waited out its DNS timeout. Two # providers (Google, Cloudflare) still cover a single-provider outage. ] DEFAULT_CONFIG_PATH = config_dir() / "node.toml" EXAMPLE_CONFIG = """\ # MeshBay Node configuration — multi-group example # See: https://meshbay.org/docs/node-config [hub] url = "https://meshbay.org" username = "myusername" [node] # QUIC (MNP) direct path — LAN, port-forwarded, hub-less. Off by default: no # client speaks QUIC yet, so leaving it on only opens a UDP port. quic_enabled = false quic_port = 19010 ui_port = 18000 # local control API — JSON, 127.0.0.1 only, token-gated # One-time codes. An invitation waits for someone to read their messages; an # operator pairing code is typed during the SSH session that printed it. invite_ttl_hours = 168 # 7 days pair_ttl_hours = 24 # How long a new device may wait for one of your existing devices to approve it. device_request_ttl_minutes = 60 # How many people may watch a video at once. One ffmpeg runs per viewer for as # long as they watch — it remuxes rather than re-encodes, so it costs little CPU # and around 50 MB of memory, and spends most of the film idle. Past this, a # viewer is told the server is busy. Raise it on a machine with memory to spare; # lower it on a Pi. max_concurrent_streams = 8 # HEVC sources have no browser decoder on most platforms, so streaming one is # transcoded to H264 rather than the usual free copy — real CPU per viewer. # Set to false only if every viewer's client is known to decode HEVC itself. transcode_incompatible_video = true # ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale, # libvirt, Docker) are auto-excluded — a STUN request that can't reach the # server holds the WebRTC answer for 5 seconds. Set this to restrict # gathering to specific interfaces. An entry matches the OS adapter name, the # device description, or one of the adapter's own IPv4 addresses, so a Windows # host can be named by description or address rather than by its GUID. An # entry that matches nothing is ignored with a warning rather than leaving the # node with no candidates at all. # ice_interfaces = ["wlp0s20f3", "eth0", "192.168.1.22"] # STUN servers for WebRTC ICE candidate gathering (NAT traversal). By default # four public servers are used; set this to override. Every server in the list # is queried in parallel each gather and the first answer wins, so one slow or # blocked server no longer stalls the WebRTC answer. # stun_servers = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"] # Browser and native clients reach this node over WebRTC DataChannel via hub # signaling — no inbound port to open. QUIC is the optional direct path. # Multiple groups — each with one or more named directories ("roots"). # # A root's name is the directory's basename, and it becomes the first segment of # every path members see: /home/user/Media appears to everyone as "Media/". # Two roots cannot share a name (compared without regard to case), and no root # may sit inside another. A writable root accepts uploads from group members. [[groups]] id = "" # set after joining name = "My Media" quic_port = 19010 [[groups.roots]] path = "/home/user/Media" writable = true [[groups.roots]] path = "/run/media/user/USB/Musique" kind = "audio" removable = true # eject before unplugging # The single-directory form still works and means the same thing — one root, # named after the directory, receiving uploads. [[groups]] id = "" name = "Public Archive" shared_dir = "/home/user/Archive" quic_port = 19012 visibility = "public" # discoverable on the hub # join_policy = "open" # anyone the hub says is a member gets the group key, # with no pairing code. Only for groups where that is # genuinely intended: it means the hub can join too. [keystore] # unlock_file = "~/.config/meshbay/unlock.key" # or set MESHBAY_UNLOCK_KEY env var # Operator authority is not configured here. Run `meshbay-node operator pair` and # enter the code in your browser: the node pins that browser's key, and invites # and file deletion are signed with it. """ @dataclass class HubConfig: url: str = "https://meshbay.org" username: str = "" @dataclass class NodeConfig: quic_port: int = 19010 # The QUIC MNP listener. Off by default: no shipping client speaks QUIC yet # (the browser and the desktop client use WebRTC; the hub-less `group://` # sidecar is unbuilt), so starting it only opens a UDP port with nothing to # reach it. Turn on for LAN / port-forwarded / hub-less direct access once a # client for it exists. `punch_nat()` is a direct-connection helper, not a # NAT-traversal stack — a peer behind NAT still needs the port forwarded. quic_enabled: bool = False ui_port: int = 18000 # How long a one-time code stays usable. Invitations travel through a human # conversation and are answered days later; operator pairing happens during # the SSH session that printed it. invite_ttl_hours: int = 168 # 7 days pair_ttl_hours: int = 24 # A device-add code is read off one screen and typed into another, in one # sitting. Comfort rather than security: the code is bound to the requesting # keys by its hash, so a longer window widens nothing an attacker can use. device_request_ttl_minutes: int = 60 # How many people may watch a video at the same time. One ffmpeg runs per # viewer for as long as they watch, so this is the knob that decides when # the node answers "server busy" — see MAX_CONCURRENT_TRANSCODES in # transport/webrtc_server.py for what one costs. max_concurrent_streams: int = 8 # HEVC (and any future codec in media_probe.py's # BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so # streaming it needs a real re-encode to H264 rather than the usual free # copy. On by default since the alternative is a hard "codec not # supported" error; set to false if this node's viewers are all clients # that already decode the source codec directly, since transcoding costs # real CPU per concurrent viewer, unlike the copy path. transcode_incompatible_video: bool = True # ICE candidate gathering: which network interfaces to include or exclude. # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are # auto-excluded because a STUN request that can't reach the server holds # the gather for the full 5-second timeout — measured at 6 s total on a # machine with a Tailscale wt0 interface. ice_interfaces: list[str] = field(default_factory=list) # include-list overrides auto stun_servers: list[str] = field(default_factory=list) # empty = DEFAULT_STUN_SERVERS ffmpeg_path: str = "ffmpeg" ffprobe_path: str = "ffprobe" @dataclass class RootSpec: """One named directory inside a group. See `meshbay_node/roots.py`.""" path: str = "" name: str = "" # empty → the directory's basename, derived at load kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now writable: bool = False # RW roots accept uploads from group members removable: bool = False # operator can eject this root before unplugging the device @dataclass class GroupConfig: id: str = "" name: str = "" # A group's content is several named roots. `shared_dir` is the single-root # form and is still read: it becomes one root named after its basename, which # is why every path gained a segment. See roots.py for why there is no # unprefixed shape. roots: list[RootSpec] = field(default_factory=list) shared_dir: str = "" # legacy single-root form, migrated at load upload_dir: str = "" # legacy — migrated to a writable root visibility: str = "private" # public|private — discoverability, not admission # Admission. "invite" (default) means a newcomer needs a one-time pairing code # before the node wraps the group key for them; "open" means the node pins # whoever turns up first (TOFU) and serves them. # # Deliberately read from THIS file and never from the hub: a hub that could # declare a group open would walk into any group it liked. Being findable # (`visibility`) and being open (`join_policy`) are different questions. join_policy: str = "invite" # invite|open quic_port: int = 19010 # QUIC MNP port def __post_init__(self) -> None: """ The single-directory form becomes one root, whoever built this. On the dataclass rather than in the TOML reader, because a GroupConfig is also built by the CLI, by `group attach` and by tests. Putting the migration in the parser alone left every one of those paths with a group that had no directory at all — and it presented as "skipping group", which reads like configuration rather than a bug. """ if not self.roots and self.shared_dir.strip(): self.roots = [RootSpec(path=self.shared_dir.strip(), writable=True)] if self.upload_dir.strip(): for r in self.roots: r.writable = False self.roots.append(RootSpec( path=self.upload_dir.strip(), writable=True)) @dataclass class KeystoreConfig: path: Path = field(default_factory=lambda: DEFAULT_CONFIG_PATH.parent / "keystore.enc") unlock_file: Path | None = None @dataclass class Config: hub: HubConfig = field(default_factory=HubConfig) node: NodeConfig = field(default_factory=NodeConfig) groups: list[GroupConfig] = field(default_factory=list) keystore: KeystoreConfig = field(default_factory=KeystoreConfig) data_dir: Path = field(default_factory=data_dir) # Back-compat: single-group access @property def group(self) -> GroupConfig: return self.groups[0] if self.groups else GroupConfig() def _positive(value: object, default: int, name: str) -> int: """A count that must be at least one, or the default with a word about it. Zero is the dangerous one: `asyncio.Semaphore(0)` is not "no limit", it is a node where no video ever plays and nothing in the log says why. """ # bool before int: TOML `true` is a bool, and `int(True)` is 1 — a node # where exactly one person may watch, arrived at by a typo and announced # nowhere. if isinstance(value, bool) or not isinstance(value, (int, str)): log.warning("%s = %r is not a count — using %d", name, value, default) return default try: n = int(value) except (TypeError, ValueError): log.warning("%s = %r is not a number — using %d", name, value, default) return default if n < 1: log.warning("%s = %d would stop the feature entirely — using %d", name, n, default) return default return n def _read_roots(group: dict) -> list[RootSpec]: """ A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`. Both forms are accepted and `shared_dir` is not deprecated for a single directory — it is the same thing said shorter. Naming both is refused rather than merged: which one receives uploads would be a guess, and a wrong guess is discovered weeks later. """ specs = [] for r in group.get("roots", []) or []: # Backward compat: old configs have `upload = true` instead of `writable` writable = bool(r.get("writable", r.get("upload", False))) specs.append(RootSpec( path=str(r.get("path", "")), name=str(r.get("name", "")), kind=str(r.get("kind", "generic")), writable=writable, removable=bool(r.get("removable", False)), )) legacy = str(group.get("shared_dir", "") or "").strip() if specs and legacy: log.warning( "group %r declares both shared_dir and [[groups.roots]] — using " "roots and ignoring shared_dir = %s", group.get("name", ""), legacy) # A bare shared_dir needs no handling here: GroupConfig.__post_init__ turns # it into one root for every construction path, not just this one. return specs def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """ Load config from TOML file. Supports both single [group] and multiple [[groups]] sections. Env vars override file values. """ cfg = Config() if path.exists(): raw = tomllib.loads(path.read_text(encoding="utf-8")) hub = raw.get("hub", {}) cfg.hub.url = hub.get("url", cfg.hub.url) cfg.hub.username = hub.get("username", cfg.hub.username) nd = raw.get("node", {}) # `port` (TCP+TLS) and `http_port` no longer exist — both listeners were removed # in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`. cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port) cfg.node.quic_enabled = bool(nd.get("quic_enabled", cfg.node.quic_enabled)) cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port) cfg.node.invite_ttl_hours = int( nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours)) cfg.node.pair_ttl_hours = int( nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours)) cfg.node.device_request_ttl_minutes = int( nd.get("device_request_ttl_minutes", cfg.node.device_request_ttl_minutes)) cfg.node.max_concurrent_streams = _positive( nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams), cfg.node.max_concurrent_streams, "max_concurrent_streams") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) ice_if = nd.get("ice_interfaces") if isinstance(ice_if, list): cfg.node.ice_interfaces = [str(s) for s in ice_if] stun = nd.get("stun_servers") if isinstance(stun, list): cfg.node.stun_servers = [str(s) for s in stun] if "ffmpeg_path" in nd: cfg.node.ffmpeg_path = str(nd["ffmpeg_path"]) if "ffprobe_path" in nd: cfg.node.ffprobe_path = str(nd["ffprobe_path"]) # Multi-group: [[groups]] array if "groups" in raw: for g in raw["groups"]: cfg.groups.append(GroupConfig( id=g.get("id", ""), name=g.get("name", ""), roots=_read_roots(g), # Ignored when roots are given explicitly (warned about in # _read_roots); otherwise __post_init__ migrates it. shared_dir="" if _read_roots(g) else g.get("shared_dir", ""), upload_dir=g.get("upload_dir", ""), visibility=g.get("visibility", "private"), join_policy=g.get("join_policy", "invite"), quic_port=g.get("quic_port", cfg.node.quic_port), )) # Back-compat: single [group] section elif "group" in raw: grp = raw["group"] cfg.groups.append(GroupConfig( id=grp.get("id", ""), name=grp.get("name", ""), )) if "data_dir" in raw: cfg.data_dir = Path(raw["data_dir"]).expanduser().resolve() if "admin_pk_ed25519" in raw: # Removed, not merely unused: a key named here granted operator # authority, and dropping it silently would refuse invites and file # deletion with a signature error that looks like something else. log.warning( "admin_pk_ed25519 in %s is ignored — operator authority now comes " "from the roster. Run `meshbay-node operator pair` and delete the " "line.", path) ks = raw.get("keystore", {}) if "path" in ks: cfg.keystore.path = Path(ks["path"]).expanduser() if "unlock_file" in ks: cfg.keystore.unlock_file = Path(ks["unlock_file"]).expanduser() # Env var overrides if url := os.environ.get("MESHBAY_HUB_URL"): cfg.hub.url = url if user := os.environ.get("MESHBAY_USERNAME"): cfg.hub.username = user if port := os.environ.get("MESHBAY_QUIC_PORT"): cfg.node.quic_port = int(port) if (qe := os.environ.get("MESHBAY_QUIC_ENABLED")) is not None: cfg.node.quic_enabled = qe.strip().lower() in ("1", "true", "yes", "on") if streams := os.environ.get("MESHBAY_MAX_CONCURRENT_STREAMS"): cfg.node.max_concurrent_streams = _positive( streams, cfg.node.max_concurrent_streams, "MESHBAY_MAX_CONCURRENT_STREAMS") return cfg def write_example_config(path: Path = DEFAULT_CONFIG_PATH) -> None: """Write an example config file if none exists.""" if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(EXAMPLE_CONFIG, encoding="utf-8", newline="\n")