""" 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 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] 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) # 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 # Node sovereignty: pin the operator's Ed25519 public key (base64, 32 bytes raw). # Admin operations (file delete) require cryptographic proof of this key. # Auto-pinned on first startup from the node operator's keystore. # admin_pk_ed25519 = "base64-encoded-32-bytes" """ @dataclass class HubConfig: url: str = "https://meshbay.org" username: str = "" @dataclass class NodeConfig: quic_port: int = 19010 ui_port: int = 18000 @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") admin_pk_ed25519: str = "" # base64 raw Ed25519 public key pinned locally # Back-compat: single-group access @property def group(self) -> GroupConfig: return self.groups[0] if self.groups else GroupConfig() 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) # 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: cfg.admin_pk_ed25519 = raw["admin_pk_ed25519"] 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) 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)