diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:12:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:12:34 +0200 |
| commit | ece4b01405b8edcf5cbe6367a2882433db1491b5 (patch) | |
| tree | 339844449dc68a9675da47195665ad7d3d6fced7 /packages/meshbay-node/src/meshbay_node/daemon.py | |
| parent | 6abb68ae95f6c4da4a66453398006183a73db9d9 (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 219 |
1 files changed, 217 insertions, 2 deletions
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__": |