summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:12:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:12:34 +0200
commitece4b01405b8edcf5cbe6367a2882433db1491b5 (patch)
tree339844449dc68a9675da47195665ad7d3d6fced7
parent6abb68ae95f6c4da4a66453398006183a73db9d9 (diff)
downloadmeshbay-ece4b01405b8edcf5cbe6367a2882433db1491b5.tar.gz
feat(node): add config, daemon, and local web UI
config.py: TOML + env var overrides, sane defaults. daemon.py: full startup sequence (keystore→hub→GEK→indexer→ server→UI), SIGINT/SIGTERM shutdown, calibrate-argon2 command. ui/app.py: FastAPI on localhost:18000, status+files JSON API, HTML status page (auto-refresh 10s). All bound to 127.0.0.1. Full suite: 29/29 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py123
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py219
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/__init__.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py134
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", "—")} &nbsp;|&nbsp;
+ <b>User:</b> {state.get("username", "—")} &nbsp;|&nbsp;
+ <b>Group:</b> {state.get("group_name") or state.get("group_id") or "—"} &nbsp;|&nbsp;
+ <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