diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-22 16:40:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-22 16:40:54 +0200 |
| commit | 9c136a0e37add42f5d0c8675a797969cfe690de0 (patch) | |
| tree | d5dfd3a7e2289a769dc987f086eec3f475054f85 | |
| parent | 2dbd70484e65ae57e18c323381136077cd00adad (diff) | |
| download | meshbay-9c136a0e37add42f5d0c8675a797969cfe690de0.tar.gz | |
fix: first-run wizard reliability and node startup performance
Node daemon no longer blocks startup on slow directory scans — initial
indexing runs in the background so the node reaches "running" immediately
after transports are up. Fixes the wizard failing to detect the node when
large USB/NAS roots take minutes to scan.
Also: wizard key-linking deadlock resolved (main.js links during poll),
invite form stays in DOM during reconnects (disabled instead of destroyed),
pairing code bridges to renderer, and firewall docs for LAN casting added.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | packages/meshbay-client/src/main.js | 40 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 49 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 64 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/indexer.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_daemon.py | 46 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_security_regressions.py | 2 | ||||
| -rw-r--r-- | packaging/README.md | 22 |
7 files changed, 163 insertions, 72 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index bfa9b48..24c1b6a 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -751,6 +751,7 @@ function registerBridge() { { signal: AbortSignal.timeout(3000) }); if (!r.ok) return null; const status = await r.json(); + if (status.status !== 'running') return null; _nodeToken = token; _nodePort = port; return { pk_node_ed25519: status.pk_node_ed25519 || '' }; @@ -798,7 +799,7 @@ function registerBridge() { provisionNode(opts.hubUrl, opts.username); } - const deadline = Date.now() + 15000; + const deadline = Date.now() + 60000; const configFile = nodeConfigPath(); let launched = false; @@ -851,13 +852,48 @@ function registerBridge() { child.unref(); } + let keyLinked = false; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 500)); + + // If the node's admin UI is up but hub auth is stuck, link the key now. + if (!keyLinked && opts && opts.token) { + try { + const nc = readNodeConfig(); + const dd = nc ? nc.dataDir + : path.join(os.homedir(), '.local', 'share', 'meshbay'); + const tk = readNodeToken(dd); + if (tk) { + const port = nc ? nc.uiPort : 18000; + const sr = await fetch( + `http://127.0.0.1:${port}/api/status?t=${tk}`, + { signal: AbortSignal.timeout(3000) }); + if (sr.ok) { + const st = await sr.json(); + if (st.pk_node_ed25519 && + (st.status === 'waiting_for_node_key' || + st.status === 'waiting_for_account')) { + const lr = await fetch( + `${opts.hubUrl}/v1/users/me/node_key`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${opts.token}` }, + body: JSON.stringify({ + pk_node_ed25519: st.pk_node_ed25519 }), + signal: AbortSignal.timeout(5000), + }); + if (lr.ok) keyLinked = true; + } + } + } + } catch { /* best effort */ } + } + const result = await probeNode(); if (result) return { started: true, ...result }; } throw new Error( - 'meshbay-node was started but did not become ready within 15 seconds'); + 'meshbay-node was started but did not become ready within 60 seconds'); }); ipcMain.handle('node:call', async (_e, method, apiPath, body) => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 595674d..5c2bada 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1201,8 +1201,7 @@ function CreateGroupWizard({ token, username, onCreated }) { setNodeStarting(true); setError(''); try { - const result = await platform.node.start({ hubUrl: HUB, username }); - await linkNodeKey(result.pk_node_ed25519); + const result = await platform.node.start({ hubUrl: HUB, username, token }); setNodeStatus({ detected: true, ...result }); setNodeStarting(false); setStep(1); @@ -1299,6 +1298,7 @@ function CreateGroupWizard({ token, username, onCreated }) { const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { await platform.node.setPairingCode(pairResult.code); + _pendingJoinCode = pairResult.code; } update('done'); @@ -2977,37 +2977,38 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} ${/* Inviting needs the node: it is the node that wraps the group key and - issues the code, not the hub. Public groups admit anyone — no invite. */ + issues the code, not the hub. Public groups admit anyone — no invite. + The form stays in the DOM so a brief reconnect does not destroy the + input the user is typing into — controls are disabled instead. */ isAdmin && group?.join_policy !== 'open' && html` <div class="settings-section"> <h3 class="settings-heading">${t('members.invite_title')}</h3> - ${!connected && html` + ${!connected ? html` <p class="settings-hint">${t('group.offline_title')}</p> - `} - ${connected && !operatorPaired && html` + ` : !operatorPaired ? html` <p class="settings-hint"> ${isNodeAdmin ? t('members.invite_needs_pairing') : t('members.invite_ask_operator')} </p> - `} - ${connected && operatorPaired && html` - <form onSubmit=${doInvite}> - ${inviteCode && html` - <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> - <p class="code-display">${inviteCode.code}</p> - <p>${t('members.invite_code_hint')}</p> - </div> - `} - <div class="form-row"> - <input type="text" placeholder="${t('members.username_placeholder')}" - value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> - <button class="admin-btn" type="submit" disabled=${inviting}> - ${inviting ? '...' : t('members.invite_btn')} - </button> + ` : ''} + <form onSubmit=${doInvite}> + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p class="code-display">${inviteCode.code}</p> + <p>${t('members.invite_code_hint')}</p> </div> - </form> - `} + `} + <div class="form-row"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + <button class="admin-btn" type="submit" + disabled=${inviting || !connected || !operatorPaired}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> </div> `} diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index e11a654..5fc70fe 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -241,14 +241,13 @@ class NodeDaemon: gek=gek, on_change=self._on_index_change, ) - await indexer.start() + await indexer.start(defer_scan=True) self._indexers.append(indexer) self._state["indexes"][group_cfg.id] = indexer.index self._state["indexers"][group_cfg.id] = indexer - log.info("Indexing group %s: %s (%d files)", + log.info("Group %s configured: %s (scan deferred)", group_cfg.name, - ", ".join(f"{r.name}={r.path}" for r in roots), - indexer.index.count) + ", ".join(f"{r.name}={r.path}" for r in roots)) groups_ctx[group_cfg.id] = { "gek": gek, @@ -268,8 +267,8 @@ class NodeDaemon: } if not groups_ctx: - log.error("No valid groups configured — exiting") - return + log.warning("No groups configured yet — admin UI and hub " + "connection stay up; attach a group to go live") # 5. Chat stores (one SQLite DB per group) for gid in groups_ctx: @@ -290,14 +289,14 @@ class NodeDaemon: denylist = self._denylist # 6. WebRTC transport (browser clients) - first = next(iter(groups_ctx.values())) + first = next(iter(groups_ctx.values()), None) if WEBRTC_AVAILABLE: self._webrtc = WebRTCTransport( sk_node=keys.sk_ed25519, hub_pk_pem=session.hub_pk_pem, - gek=first["gek"], - roots=first["roots"], - index=first["index"], + gek=first["gek"] if first else None, + roots=first["roots"] if first else None, + index=first["index"] if first else None, groups=groups_ctx, denylist=denylist, max_concurrent_streams=self._config.node.max_concurrent_streams, @@ -307,6 +306,7 @@ class NodeDaemon: # _group_ctx(). Assigning the first group's store transport-wide # sent every group's chat to one database and served it back to # members of every other group (finding H1). + self._webrtc._ctx["groups"] = groups_ctx self._webrtc._ctx["hub_ws"] = _WsSender(hub) self._webrtc._ctx["node_user_id"] = session.user_id self._webrtc._ctx["audit_store"] = self._audit_store @@ -342,14 +342,15 @@ class NodeDaemon: self._quic_server = QuicChunkServer( sk_node=keys.sk_ed25519, hub_pk_pem=session.hub_pk_pem, - gek=first["gek"], - roots=first["roots"], - index=first["index"], + gek=first["gek"] if first else None, + roots=first["roots"] if first else None, + index=first["index"] if first else None, host="::", port=self._config.node.quic_port, groups=groups_ctx, denylist=denylist, ) + self._quic_server._ctx["groups"] = groups_ctx await self._quic_server.start() log.info("QUIC server on port %d (%d groups)", self._config.node.quic_port, len(groups_ctx)) @@ -434,18 +435,23 @@ class NodeDaemon: "yes" if self._webrtc else "no", "yes" if self._quic_server else "no") - # 11. Initial swarm registration — PUBLIC groups only. - # Finding H7: registering every group's hashes hands the hub a content - # fingerprint of every private file on the node, which is exactly the - # metadata the "hub stores no content metadata" claim rules out. It also - # lets anyone confirm whether a known file exists in the network. - endpoint = f"webrtc:{self._config.node.quic_port}" - for gctx in groups_ctx.values(): - if gctx.get("visibility") != "public": - continue - hashes = [e.id for e in gctx["index"].entries] - if hashes: - asyncio.ensure_future(self._register_swarm(hashes, endpoint)) + # 11. Background initial scan — files appear progressively. + async def _bg_scan(indexer, name, gctx): + await indexer.initial_scan() + log.info("Background scan complete for %s: %d files", + name, indexer.index.count) + # Swarm registration for public groups (after files are known). + if gctx.get("visibility") == "public": + endpoint = f"webrtc:{self._config.node.quic_port}" + hashes = [e.id for e in gctx["index"].entries] + if hashes: + await self._register_swarm(hashes, endpoint) + + for idx, group_cfg in zip(self._indexers, self._config.groups): + gctx = groups_ctx.get(group_cfg.id) + if gctx: + self._tasks.append(asyncio.create_task( + _bg_scan(idx, group_cfg.name, gctx))) # 12. Wait for shutdown stop_event = asyncio.Event() @@ -486,7 +492,7 @@ class NodeDaemon: log.error("Reload failed, keeping the running config: %s", e) return - groups_ctx = self._state.get("groups_ctx") or {} + groups_ctx = self._state.get("groups_ctx", {}) hosted = set(groups_ctx) incoming = {g.id for g in fresh.groups if g.id} @@ -647,7 +653,7 @@ class NodeDaemon: log.warning( "Node key not linked. Open the admin UI, copy this " "node's key, and paste it in Settings > Link Node on " - "%s. Retrying in 30s...", + "%s. Retrying in 5s...", self._config.hub.url, ) else: @@ -655,10 +661,10 @@ class NodeDaemon: log.warning( "Hub rejected the node credentials for user %r. " "Register that account on %s first, then link this " - "node's key. Retrying in 30s...", + "node's key. Retrying in 5s...", self._config.hub.username, self._config.hub.url, ) - await asyncio.sleep(30) + await asyncio.sleep(5) else: raise except Exception as e: diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index d9b1d2c..482b556 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -240,10 +240,16 @@ class DirectoryIndexer: # ── Watchdog integration ────────────────────────────────────────────────── - async def start(self) -> None: - """Start initial scan + filesystem watcher + reconciler.""" + async def start(self, *, defer_scan: bool = False) -> None: + """Start initial scan + filesystem watcher + reconciler. + + With ``defer_scan=True`` the watcher and reconciler start + immediately but the initial scan is skipped — call + :meth:`initial_scan` yourself when ready. + """ self._loop = asyncio.get_event_loop() - await self.initial_scan() + if not defer_scan: + await self.initial_scan() self._start_observer() self._reconciler = asyncio.create_task(self._reconcile_loop()) diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py index 7974be5..ab1613d 100644 --- a/packages/meshbay-node/tests/test_daemon.py +++ b/packages/meshbay-node/tests/test_daemon.py @@ -160,11 +160,11 @@ async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem) assert store._db is None @pytest.mark.asyncio -async def test_daemon_no_groups_exits(tmp_path): - """Daemon with no valid groups exits cleanly.""" +async def test_daemon_no_groups_stays_up(tmp_path, hub_pk_pem): + """Daemon with no valid groups stays up (admin UI + hub connection alive).""" config = Config( hub=HubConfig(url="http://localhost:9999", username="testuser"), - node=NodeConfig(), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), groups=[GroupConfig(id="", name="empty", shared_dir="")], keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), data_dir=tmp_path / "data", @@ -177,28 +177,48 @@ async def test_daemon_no_groups_exits(tmp_path): mock_session = MagicMock() mock_session.node_id = "node123" mock_session.user_id = "user123" - mock_session.hub_pk_pem = b"pem" + mock_session.hub_pk_pem = hub_pk_pem - mock_server = AsyncMock() - mock_server.serve = AsyncMock() + shutdown_event = asyncio.Event() with patch("meshbay_node.daemon.load_or_create_keystore", return_value=mock_keys), \ - patch("meshbay_node.daemon.HubClient") as MockHub, \ - patch("meshbay_node.daemon.uvicorn") as mock_uvicorn: - - mock_uvicorn.Config = MagicMock() - mock_uvicorn.Server = MagicMock(return_value=mock_server) + patch("meshbay_node.daemon.HubClient") as MockHub: hub_instance = AsyncMock() hub_instance.startup = AsyncMock(return_value=mock_session) + hub_instance.send_ws = AsyncMock() + hub_instance._ws = None hub_instance.close = AsyncMock() hub_instance.__aenter__ = AsyncMock(return_value=hub_instance) hub_instance.__aexit__ = AsyncMock(return_value=False) MockHub.return_value = hub_instance - await daemon.run() + async def mock_maintain_ws(**kwargs): + await shutdown_event.wait() + + hub_instance.maintain_ws = mock_maintain_ws - assert len(daemon._chat_stores) == 0 + async def run_daemon(): + with patch("signal.SIGINT", 2), \ + patch("signal.SIGTERM", 15): + try: + await asyncio.wait_for(daemon.run(), timeout=5) + except (asyncio.TimeoutError, Exception): + pass + + task = asyncio.create_task(run_daemon()) + await asyncio.sleep(1) + + assert daemon._state["status"] == "running" + assert len(daemon._chat_stores) == 0 + + shutdown_event.set() + await daemon._shutdown() + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass @pytest.mark.asyncio async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hub_pk_pem): diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 78a631a..725b800 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -488,7 +488,7 @@ def test_swarm_registration_skips_private_groups(): / "daemon.py").read_text() assert 'visibility' in source and '_register_swarm' in source # Both registration sites must gate on public visibility. - for marker in ['gctx.get("visibility") != "public"', + for marker in ['gctx.get("visibility") == "public"', 'group_cfg.visibility == "public"']: assert marker in source, f"swarm registration not gated: {marker}" diff --git a/packaging/README.md b/packaging/README.md index 1b92947..13b8fbf 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -70,6 +70,28 @@ nano ~/.config/meshbay/node.toml systemctl --user enable --now meshbay-node ``` +## Firewall (LAN casting) + +The Electron client runs an HTTP relay on **TCP 19550-19553** to stream +decrypted video to Chromecast / Smart TV devices on the local network. +Chromecast discovery uses **mDNS (UDP 5353)**. These ports must be open on the +machine running the client. + +### Fedora / RHEL (firewalld) + +```bash +sudo firewall-cmd --permanent --add-service=mdns +sudo firewall-cmd --permanent --add-port=19550-19553/tcp +sudo firewall-cmd --reload +``` + +### Ubuntu / Debian (ufw) + +```bash +sudo ufw allow 5353/udp comment "mDNS - Chromecast discovery" +sudo ufw allow 19550:19553/tcp comment "meshbay cast relay" +``` + ## Systemd service files | File | Location | |