diff options
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 123 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 219 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/__init__.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 134 |
4 files changed, 478 insertions, 2 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py new file mode 100644 index 0000000..27301af --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -0,0 +1,123 @@ +""" +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 +# See: https://meshbay.org/docs/node-config + +[hub] +url = "http://meshbay.org" +username = "myusername" +# password stored separately via keystore + +[node] +port = 19000 +ui_port = 18000 +shared_dirs = ["/home/user/MeshBay"] + +[group] +id = "" # set after joining a group +name = "" + +[keystore] +# unlock_file = "~/.config/meshbay/unlock.key" +# or set MESHBAY_UNLOCK_KEY env var +""" + + +@dataclass +class HubConfig: + url: str = "http://meshbay.org" + username: str = "" + password: str = "" # loaded from keystore or env; never written to TOML + + +@dataclass +class NodeConfig: + port: int = 19000 + ui_port: int = 18000 + shared_dirs: list[str] = field(default_factory=list) + + +@dataclass +class GroupConfig: + id: str = "" + name: str = "" + + +@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) + group: GroupConfig = field(default_factory=GroupConfig) + keystore: KeystoreConfig = field(default_factory=KeystoreConfig) + + +def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: + """ + Load config from TOML file. Missing file returns defaults. + 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", {}) + cfg.node.port = nd.get("port", cfg.node.port) + cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port) + cfg.node.shared_dirs = nd.get("shared_dirs", cfg.node.shared_dirs) + + grp = raw.get("group", {}) + cfg.group.id = grp.get("id", cfg.group.id) + cfg.group.name = grp.get("name", cfg.group.name) + + 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 pwd := os.environ.get("MESHBAY_PASSWORD"): + cfg.hub.password = pwd + if port := os.environ.get("MESHBAY_PORT"): + cfg.node.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) diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 83c0ed7..bdac6b8 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1,8 +1,223 @@ -"""Entry point for the meshbay-node systemd service.""" +""" +MeshBay Node daemon — main process. +Startup sequence: + 1. Load config (~/.config/meshbay/node.toml) + 2. Load or create keystore (Argon2id unlock) + 3. Connect to hub: register → login → announce node + 4. Fetch GEK bundle from hub (if group configured) + 5. Start directory indexer (watchdog) + 6. Start TCP+TLS chunk server on node.port + 7. Start local web UI on node.ui_port (localhost only) + 8. Run until SIGINT/SIGTERM + +Usage: + meshbay-node # interactive password prompt + meshbay-node --config /path # custom config + meshbay-node init # write example config + create keystore + meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters +""" + +import asyncio +import logging +import signal +import sys +from pathlib import Path + +import uvicorn + +from meshbay_node.config import Config, load_config, write_example_config +from meshbay_node.hub_client import HubClient, HubConfig +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.keystore import load_or_create_keystore +from meshbay_node.transport import ChunkServer +from meshbay_node.ui import create_ui_app + +log = logging.getLogger(__name__) + + +# ── Argon2id calibration ────────────────────────────────────────────────────── + +def calibrate_argon2(target_ms: int = 500) -> None: + """Benchmark Argon2id and suggest parameters targeting ~target_ms.""" + import time + import os + from meshbay_common.crypto import derive_keystore_key + + print(f"Calibrating Argon2id (target: {target_ms}ms) ...") + salt = os.urandom(16) + for mem in [65536, 131072, 262144, 524288]: + from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + t0 = time.perf_counter() + Argon2id(salt=salt, length=32, iterations=3, + lanes=4, memory_cost=mem).derive(b"benchmark") + elapsed_ms = (time.perf_counter() - t0) * 1000 + print(f" memory_cost={mem:>7} ({mem//1024:>4}MB): {elapsed_ms:.0f}ms", end="") + if abs(elapsed_ms - target_ms) < target_ms * 0.3: + print(" ← recommended") + else: + print() + print("Set memory_cost in meshbay_common/crypto.py: ARGON2_MEMORY_COST") + + +# ── Daemon ──────────────────────────────────────────────────────────────────── + +class NodeDaemon: + def __init__(self, config: Config): + self._config = config + self._state = { + "status": "starting", + "hub_url": config.hub.url, + "username": config.hub.username, + "group_id": config.group.id, + "group_name": config.group.name, + "node_port": config.node.port, + "endpoint_hint": None, + "index": None, + } + self._server: ChunkServer | None = None + self._indexers: list[DirectoryIndexer] = [] + self._tasks: list[asyncio.Task] = [] + + async def run(self) -> None: + log.info("MeshBay Node starting up") + + # 1. Keystore + keys = load_or_create_keystore( + path=self._config.keystore.path, + unlock_file=self._config.keystore.unlock_file, + ) + log.info("Keys loaded: %s", keys.pk_ed25519_b64[:16]) + + # 2. Hub connection + hub_cfg = HubConfig( + hub_url=self._config.hub.url, + username=self._config.hub.username, + password=self._config.hub.password, + ) + async with HubClient(hub_cfg, keys) as hub: + session = await hub.startup(endpoint_hint=None) + self._state["endpoint_hint"] = session.node_id + + # 3. Fetch GEK if group configured + if self._config.group.id: + try: + gek = await hub.fetch_gek(self._config.group.id) + keys.gek = gek + log.info("GEK loaded for group %s", self._config.group.id[:8]) + except LookupError: + log.warning("No GEK bundle found for group %s — " + "wait for admin to add you", self._config.group.id[:8]) + + # 4. Directory indexers + async def on_index_change(indexer: DirectoryIndexer) -> None: + self._state["index"] = indexer.index + + for shared_dir in self._config.node.shared_dirs: + d = Path(shared_dir).expanduser().resolve() + if not d.exists(): + log.warning("Shared directory not found: %s — skipping", d) + continue + indexer = DirectoryIndexer( + root=d, + group_id=self._config.group.id, + sk_node=keys.sk_ed25519, + gek=keys.gek, + on_change=on_index_change, + ) + await indexer.start() + self._indexers.append(indexer) + self._state["index"] = indexer.index + log.info("Indexing: %s (%d files)", d, indexer.index.count) + + # 5. Chunk server + if self._indexers and keys.gek: + self._server = ChunkServer( + sk_node=keys.sk_ed25519, + hub_pk_pem=session.hub_pk_pem, + gek=keys.gek, + shared_root=Path(self._config.node.shared_dirs[0]).expanduser(), + index=self._indexers[0].index, + host="0.0.0.0", + port=self._config.node.port, + ) + await self._server.start() + log.info("Chunk server on port %d", self._config.node.port) + + # 6. Local web UI + ui_app = create_ui_app(self._state) + ui_cfg = uvicorn.Config( + ui_app, + host="127.0.0.1", + port=self._config.node.ui_port, + log_level="warning", + ) + ui_server = uvicorn.Server(ui_cfg) + self._tasks.append(asyncio.create_task(ui_server.serve())) + log.info("Local UI at http://localhost:%d", self._config.node.ui_port) + + self._state["status"] = "running" + log.info("Node ready") + + # 7. Wait for shutdown + stop_event = asyncio.Event() + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + await stop_event.wait() + + await self._shutdown() + + async def _shutdown(self) -> None: + log.info("Shutting down...") + self._state["status"] = "stopping" + + for task in self._tasks: + task.cancel() + for indexer in self._indexers: + await indexer.stop() + if self._server: + await self._server.stop() + + log.info("Node stopped") + + +# ── Entry point ─────────────────────────────────────────────────────────────── def main() -> None: - raise NotImplementedError("Node daemon not yet implemented — see Phase 2") + import argparse + + parser = argparse.ArgumentParser(description="MeshBay Node daemon") + parser.add_argument("command", nargs="?", + choices=["init", "calibrate-argon2"], + help="init: write example config | calibrate-argon2: benchmark") + parser.add_argument("--config", type=Path, default=None, + help="Config file path") + parser.add_argument("--log-level", default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + ) + + if args.command == "init": + write_example_config() + print(f"Example config written. Edit it and run: meshbay-node") + return + + if args.command == "calibrate-argon2": + calibrate_argon2() + return + + cfg = load_config(args.config) + if not cfg.hub.username: + print("Error: hub.username not set in config. Run: meshbay-node init") + sys.exit(1) + + daemon = NodeDaemon(cfg) + asyncio.run(daemon.run()) if __name__ == "__main__": diff --git a/packages/meshbay-node/src/meshbay_node/ui/__init__.py b/packages/meshbay-node/src/meshbay_node/ui/__init__.py index e69de29..a7ba872 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/__init__.py +++ b/packages/meshbay-node/src/meshbay_node/ui/__init__.py @@ -0,0 +1,4 @@ +"""Local web UI served on localhost:18000.""" +from .app import create_ui_app + +__all__ = ["create_ui_app"] diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py new file mode 100644 index 0000000..f8978d1 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -0,0 +1,134 @@ +""" +MeshBay Node — local web UI (localhost:18000). + +Minimal FastAPI app providing: + GET / → status page (HTML) + GET /api/status → JSON status + GET /api/files → JSON file list from the group index + GET /api/config → JSON config summary (no secrets) + +Served only on 127.0.0.1 — not exposed to the network. +""" + +import logging +from typing import TYPE_CHECKING + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse + +from meshbay_node import __version__ + +if TYPE_CHECKING: + from meshbay_node.indexer import GroupIndex + +log = logging.getLogger(__name__) + + +def create_ui_app(state: dict) -> FastAPI: + """ + Create the UI FastAPI app. + + state dict is updated by the daemon and read by UI endpoints: + state["status"] : str — "starting" | "running" | "error" + state["group_id"] : str + state["group_name"] : str + state["hub_url"] : str + state["username"] : str + state["node_port"] : int + state["index"] : GroupIndex | None + state["endpoint_hint"]: str | None + """ + app = FastAPI( + title="MeshBay Node UI", + version=__version__, + docs_url=None, # disable Swagger on local UI + redoc_url=None, + ) + + @app.get("/api/status") + async def api_status(): + index = state.get("index") + return { + "version": __version__, + "status": state.get("status", "starting"), + "hub_url": state.get("hub_url", ""), + "username": state.get("username", ""), + "group_id": state.get("group_id", ""), + "group_name": state.get("group_name", ""), + "node_port": state.get("node_port", 0), + "endpoint_hint": state.get("endpoint_hint"), + "file_count": index.count if index else 0, + "index_version": index.version if index else 0, + } + + @app.get("/api/files") + async def api_files(): + index = state.get("index") + if not index: + return {"files": []} + return { + "files": [ + { + "id": e.id[:16] + "…", + "name": e.name, + "path": e.path, + "size": e.size, + "type": e.type, + "duration": e.duration, + } + for e in index.entries + ] + } + + @app.get("/", response_class=HTMLResponse) + async def root(): + index = state.get("index") + status = state.get("status", "starting") + file_count = index.count if index else 0 + status_color = {"running": "#22c55e", "error": "#ef4444"}.get(status, "#f59e0b") + + files_html = "" + if index: + rows = "".join( + f"<tr><td>{e.name}</td><td>{e.type}</td>" + f"<td>{e.size // 1024} KB</td><td>{e.path or '/'}</td></tr>" + for e in index.entries + ) + files_html = f""" + <h2>Files ({file_count})</h2> + <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%"> + <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead> + <tbody>{rows}</tbody> + </table>""" + + return f"""<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>MeshBay Node</title> + <style> + body {{ font-family: monospace; max-width: 900px; margin: 40px auto; padding: 0 20px; }} + .badge {{ display:inline-block; padding:3px 10px; border-radius:4px; + color:#fff; background:{status_color}; font-weight:bold; }} + table {{ font-size: 0.9em; }} + th {{ background: #f3f4f6; }} + </style> + <meta http-equiv="refresh" content="10"> +</head> +<body> + <h1>🔗 MeshBay Node <span class="badge">{status}</span></h1> + <p> + <b>Hub:</b> {state.get("hub_url", "—")} | + <b>User:</b> {state.get("username", "—")} | + <b>Group:</b> {state.get("group_name") or state.get("group_id") or "—"} | + <b>Port:</b> {state.get("node_port", "—")} + </p> + <p><b>Endpoint:</b> {state.get("endpoint_hint") or "unknown"}</p> + {files_html} + <hr> + <small>MeshBay Node v{__version__} — <a href="/api/status">JSON status</a> + — <a href="/api/files">JSON files</a></small> +</body> +</html>""" + + return app |