""" 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 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 # 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. Exactly one root receives uploads. [[groups]] id = "" # set after joining name = "My Media" quic_port = 19010 [[groups.roots]] path = "/home/user/Media" upload = true [[groups.roots]] path = "/run/media/user/USB/Musique" # an external drive is fine: if it is kind = "audio" # unplugged the root goes unavailable # and its files stay in the index, # rather than looking deleted # upload_dir: a separate directory for uploads. Files land directly in it, # not in an "uploads" subdirectory. It appears as its own root in the index. # upload_dir = "/home/user/Incoming" # 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 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 @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 upload: bool = False # exactly one root per group receives uploads direct: bool = False # uploads land at root path, not in a subdirectory @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 = "" # separate filesystem path for uploads 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(), upload=True)] if self.upload_dir.strip(): for r in self.roots: r.upload = False self.roots.append(RootSpec( path=self.upload_dir.strip(), upload=True, direct=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=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 _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 = [ RootSpec( path=str(r.get("path", "")), name=str(r.get("name", "")), kind=str(r.get("kind", "generic")), upload=bool(r.get("upload", False)), ) for r in group.get("roots", []) or [] ] 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()) 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.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") # 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 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)