From b7733e812fadd6007976d262bd1d793572a36ba7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 11:15:54 +0200 Subject: feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/daemon.py | 190 +++++++++++++++++++++-- 1 file changed, 176 insertions(+), 14 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 74ab7b1..779ad91 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -240,6 +240,11 @@ class NodeDaemon: session = await self._login_with_retry(hub) self._state["endpoint_hint"] = session.node_id + try: + session.email = await hub.fetch_owner_email() + except Exception as e: + log.warning("Could not fetch owner email: %s", e) + # 4. Bundle store (P2P GEK bundles) data_dir = self._config.data_dir data_dir.mkdir(parents=True, exist_ok=True) @@ -433,12 +438,11 @@ class NodeDaemon: # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own # enricher (mutagen, not ffmpeg) and its own MusicBrainz client. - # No token to read here (§3.1): only a contact string, and unset - # simply means the client makes no calls (musicbrainz.py). + # The User-Agent contact is the owner's hub email, resolved at + # login — no roster setting or env var needed any more. self._audio_enricher = AudioEnricher(self._media_cache) - self._musicbrainz_client = MusicBrainzClient(roster=self._roster) - musicbrainz_contact = await self._roster.musicbrainz_contact() - self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) + self._musicbrainz_client = MusicBrainzClient(owner_email=session.email) + self._state["musicbrainz_contact_configured"] = bool(session.email) # 6d. Photos app (docs/photos.md) — same media_cache.db, its own # enricher (Pillow, not ffmpeg/mutagen). No credential, no @@ -1570,11 +1574,12 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "status", "ui", "gek-init", "gek", - "operator", "member", "group", "file", + choices=["init", "reset", "status", "ui", "gek-init", + "gek", "operator", "member", "group", "file", "denylist", "reload", "restart-daemon", "calibrate-argon2"], - help="init: write example config | status: node state and keys " + help="init: provision config + keystore | reset: erase all " + "node state | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " @@ -1590,6 +1595,10 @@ def main() -> None: help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " "denylist clear") + parser.add_argument("--hub-url", default=None, + help="hub URL, for init (e.g. https://meshbay.org)") + parser.add_argument("--username", default=None, + help="hub username, for init") parser.add_argument("--dir", default=None, help="shared directory, for group add") parser.add_argument("--upload-dir", default=None, @@ -1607,7 +1616,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", "member", "group", "file", "denylist", "reload", - "restart-daemon") + "restart-daemon", "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1615,19 +1624,145 @@ def main() -> None: if args.command == "init": cfg_path = args.config or DEFAULT_CONFIG_PATH - if not cfg_path.exists(): - write_example_config(cfg_path) - print(f"Config written to {cfg_path}") + config_dir = cfg_path.parent + config_dir.mkdir(parents=True, exist_ok=True) + + hub_url = args.hub_url + username = args.username + + if not hub_url: + hub_url = input("Hub URL [https://meshbay.org]: ").strip() or "https://meshbay.org" + if not username: + username = input("Hub username: ").strip() + if not username: + print("Username is required.") + sys.exit(1) + + if cfg_path.exists(): + existing = cfg_path.read_text() + import re as _re + m = _re.search(r'username\s*=\s*"([^"]*)"', existing) + existing_user = m.group(1) if m else "" + if existing_user and existing_user != "myusername" and existing_user != username: + print(f"Config already exists with username {existing_user!r}.") + print("This node belongs to another operator. Use 'meshbay-node reset' first.") + sys.exit(1) + if existing_user in ("", "myusername"): + updated = _re.sub( + r'(username\s*=\s*)"[^"]*"', rf'\1"{username}"', existing) + updated = _re.sub( + r'(url\s*=\s*)"[^"]*"', rf'\1"{hub_url}"', updated, count=1) + cfg_path.write_text(updated) + print(f"Config updated: username={username}, hub={hub_url}") + else: + print(f"Config already exists: {cfg_path}") else: - print(f"Config already exists: {cfg_path}") + unlock_file = config_dir / "unlock.key" + toml_lines = [ + "[hub]", + f'url = "{hub_url}"', + f'username = "{username}"', + "", + "[node]", + "quic_port = 19010", + "ui_port = 18000", + "", + "[keystore]", + f'unlock_file = "{unlock_file}"', + "", + ] + cfg_path.write_text("\n".join(toml_lines) + "\n") + os.chmod(cfg_path, 0o600) + print(f"Config written to {cfg_path}") + + unlock_file = config_dir / "unlock.key" + if not unlock_file.exists(): + import secrets + key = secrets.token_urlsafe(32) + unlock_file.write_text(key + "\n") + os.chmod(unlock_file, 0o600) + print(f"Unlock key created: {unlock_file}") + cfg = load_config(cfg_path) if cfg.keystore.path.exists(): print(f"Keystore already exists: {cfg.keystore.path}") + keys = load_keystore( + path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) else: keys = create_keystore( path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) print(f"Keystore created: {cfg.keystore.path}") - print(f"Node key: {keys.pk_ed25519_b64}") + + print(f"Node key: {keys.pk_ed25519_b64}") + print() + print("Next steps:") + print(f" 1. Link this node key on {hub_url} → Settings → Link Node") + print(" 2. systemctl --user enable --now meshbay-node") + print(" 3. meshbay-node group add --dir /path/to/files") + print(" 4. meshbay-node gek init") + print(" 5. meshbay-node operator pair") + return + + if args.command == "reset": + import shutil + + config_dir = Path.home() / ".config" / "meshbay" + data_dir = Path.home() / ".local" / "share" / "meshbay" + state_dir = Path.home() / ".local" / "state" / "meshbay" + + items = [] + for d in (config_dir, data_dir): + if d.exists(): + for child in sorted(d.iterdir()): + items.append(child) + + if not items: + print("Nothing to reset — no node state found.") + return + + print("This will permanently erase all node state:") + for p in items: + print(f" {p}") + print() + print("WARNING: a new keystore means a new identity. All group") + print("memberships, operator pairings, and invitations are lost.") + + if not args.yes: + answer = input("\nProceed? [y/N] ").strip().lower() + if answer != "y": + print("Aborted.") + return + + import subprocess as _sp + import json as _json + import urllib.request + import urllib.error + + token_file = data_dir / "ui-token" + if token_file.exists(): + try: + cfg = Config(config_dir / "node.toml") + tok = token_file.read_text().strip() + url = (f"http://127.0.0.1:{cfg.node.ui_port}" + f"/api/unlink?t={tok}") + req = urllib.request.Request(url, method="DELETE") + with urllib.request.urlopen(req, timeout=5) as r: + _json.loads(r.read()) + print("Unlinked node key from hub.") + except Exception: + print("Could not unlink from hub (daemon not reachable).") + + _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], + capture_output=True) + + for d in (config_dir, data_dir): + if d.exists(): + shutil.rmtree(d) + print(f"Removed {d}") + if state_dir.is_dir(): + shutil.rmtree(state_dir) + print(f"Removed {state_dir}") + print("Node state erased. Run 'meshbay-node init' to start over.") return if args.command == "calibrate-argon2": @@ -1668,6 +1803,33 @@ def main() -> None: f" files {live.get('total_files', 0)}" f" peers {live.get('webrtc_peers', 0)}") print(f"admin UI meshbay-node ui") + + needs = live.get("needs", []) + if needs: + _GUIDANCE = { + "node_key_link": ( + "Link node key", + f"Copy the node key above and paste it in Settings → Link Node on {cfg.hub.url}"), + "group_add": ( + "Add a group", + "meshbay-node group add --dir /path/to/files"), + "operator_pair": ( + "Pair as operator", + "meshbay-node operator pair"), + } + print() + print("action needed:") + for need in needs: + if need.startswith("gek_init:"): + name = need.split(":", 1)[1] + print(f" → Initialize group key for {name}") + print(f" meshbay-node gek init --group \"{name}\"") + elif need in _GUIDANCE: + label, hint = _GUIDANCE[need] + print(f" → {label}") + print(f" {hint}") + else: + print(f" → {need}") else: print("daemon not running") -- cgit v1.2.3