diff options
Diffstat (limited to 'packages')
| -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 |
6 files changed, 141 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}" |