""" 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 try: import tomllib # Python 3.11+ except ImportError: import tomli as tomllib # type: ignore[no-redef] log = logging.getLogger(__name__) DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "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_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access ui_port = 18000 # local admin UI (127.0.0.1 only) # 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 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 # 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 its own directory [[groups]] id = "" # set after joining name = "My Media" shared_dir = "/home/user/Media" quic_port = 19010 [[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 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 # 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 @dataclass class GroupConfig: id: str = "" name: str = "" shared_dir: str = "" 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 @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=lambda: Path.home() / ".local" / "share" / "meshbay") # 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 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()) 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.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.max_concurrent_streams = _positive( nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams), cfg.node.max_concurrent_streams, "max_concurrent_streams") # 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", ""), shared_dir=g.get("shared_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 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)