From b8bd5d917a9b2179679cc678194ac43869ad549f Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 15:33:06 +0200 Subject: [PATCH 1/7] refactor(protocol): add optional query filter to getLishs unicast request --- backend/src/protocol/lish-protocol.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/src/protocol/lish-protocol.ts b/backend/src/protocol/lish-protocol.ts index 01b7a56a..8d64fa20 100644 --- a/backend/src/protocol/lish-protocol.ts +++ b/backend/src/protocol/lish-protocol.ts @@ -38,6 +38,15 @@ export interface LISHGetLishRequest { } export interface LISHGetLishsRequest { type: 'getLishs'; + /** + * Optional case-insensitive substring filter applied server-side to each + * LISH's id and (if present) name. When omitted the responder returns its + * full advertised list — backward-compatible with peers that predate this + * field. Used by the unicast fallback in `api/search.ts` so a search can + * succeed even when the gossipsub subscriber set has not yet propagated + * to a freshly-discovered peer (e.g. one just dialed via mDNS). + */ + query?: string; } /** * Unicast "I have this LISH" announcement — response to a pubsub `want`. @@ -154,9 +163,11 @@ export class LISHClient { return response.manifest; } - // Request list of shared LISHs from peer - async requestList(): Promise> { - const request: LISHGetLishsRequest = { type: 'getLishs' }; + // Request list of shared LISHs from peer. `query` is an optional + // case-insensitive substring filter the peer applies server-side; omit it + // to retrieve the full list. + async requestList(query?: string): Promise> { + const request: LISHGetLishsRequest = { type: 'getLishs', ...(query !== undefined ? { query } : {}) }; if (!sendLengthPrefixed(this.stream, codecEncode(request))) { throw new CodedError(ErrorCodes.PEER_UNREACHABLE, `getLishs: stream ${this.stream.status}`); } @@ -364,7 +375,14 @@ export async function handleLISHProtocol(stream: Stream, dataServer: DataServer, // Return list of all shared (upload_enabled) LISHs — id and name only. // Newest first — matches the order shown locally in "Download and Sharing". const allLishs = dataServer.list(); - const shared = allLishs.filter(l => isUploadAdvertisable(l.id)).reverse(); + const q = typeof request.query === 'string' && request.query.length > 0 ? request.query.toLowerCase() : null; + const matches = (l: import('@shared').IStoredLISH): boolean => { + if (!q) return true; + if (l.id.toLowerCase().includes(q)) return true; + const name = l.name?.toLowerCase() ?? ''; + return name.includes(q); + }; + const shared = allLishs.filter(l => isUploadAdvertisable(l.id) && matches(l)).reverse(); const response: LISHGetLishsResponse = { type: 'getLishs-result', lishs: shared.map(l => { From 856e7ca49169025158029cd01438a2253d6af18d Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 15:34:14 +0200 Subject: [PATCH 2/7] feat(search): unicast fallback to connected topic peers in parallel --- backend/src/api/search.ts | 81 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/backend/src/api/search.ts b/backend/src/api/search.ts index 744be7c4..0fa23a55 100644 --- a/backend/src/api/search.ts +++ b/backend/src/api/search.ts @@ -3,9 +3,17 @@ import { type Networks } from '../lishnet/lishnets.ts'; import { type Settings } from '../settings.ts'; import { lishTopic } from '../protocol/constants.ts'; import { trace } from '../logger.ts'; -import { registerSearchResultHandler, unregisterSearchResultHandler, type SearchResultAnnouncement } from '../protocol/lish-protocol.ts'; +import { LISH_PROTOCOL, LISHClient, registerSearchResultHandler, unregisterSearchResultHandler, type SearchResultAnnouncement } from '../protocol/lish-protocol.ts'; import type { LishSearchResult } from '@shared'; +/** + * Concurrency cap for the unicast `getLishs` fallback. Each fan-out opens a + * fresh LISH protocol stream per peer; on large fleets a uncapped loop would + * burst dozens of dials at once. 10 is a balance between LAN search latency + * (sub-second for typical 5-30 peer fleets) and load on the libp2p dialer. + */ +const UNICAST_FALLBACK_PARALLEL = 10; + type BroadcastFn = (event: string, data: any) => void; interface SearchSession { @@ -119,9 +127,80 @@ export function initSearchManager(networks: Networks, settings: Settings, broadc console.warn(`[Search] broadcast on ${config.networkID.slice(0, 8)} failed: ${err?.message ?? err}`); } } + // Kick off the unicast fallback in parallel with the pubsub broadcast. + // floodPublish only reaches peers already in pubsub.getSubscribers(topic) + // AND scored above publishThreshold — a freshly-discovered peer (mDNS / + // peer-announce / bootstrap dial) typically has a 100-500 ms window + // after dial completes before its SUBSCRIBE RPC propagates back to us, + // during which floodPublish silently skips them. Dialing them directly + // via the LISH protocol bypasses gossipsub state entirely, so the + // search works the instant the libp2p connection is up. Fire-and-forget: + // rejections are logged but never bubble up into the FE response. + runUnicastFallback(searchID, query).catch(err => trace(`[Search] unicast fallback ${searchID.slice(0, 8)} failed: ${err?.message ?? err}`)); return { searchID }; } + /** + * Per-search unicast fan-out. Collects the union of topic-subscribed + * peers across every joined network, removes our own peerID, and sends a + * `getLishs(query)` request on a freshly-opened LISH protocol stream to + * each. Successful responses are routed through {@link handleResult}, + * which dedupes peer-id collisions against any reply we may already have + * received via the pubsub `searchResult` path. Bounded concurrency via a + * cursor-based worker pool — see {@link UNICAST_FALLBACK_PARALLEL}. + */ + async function runUnicastFallback(searchID: string, query: string): Promise { + const network = networks.getRunningNetwork(); + const selfPeerID = network.getNodeInfo()?.peerID; + const peers = new Set(); + for (const config of networks.list()) { + if (!config.enabled || !networks.isJoined(config.networkID)) continue; + for (const p of network.getTopicPeers(config.networkID)) { + if (p && p !== selfPeerID) peers.add(p); + } + } + if (peers.size === 0) { + trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: no connected topic peers, skipping`); + return; + } + const peerList = [...peers]; + trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: dispatching to ${peerList.length} peer(s)`); + let cursor = 0; + const workerCount = Math.min(UNICAST_FALLBACK_PARALLEL, peerList.length); + const workers = Array.from({ length: workerCount }, async () => { + for (;;) { + // Bail immediately if the session has been cancelled or timed + // out — no point opening a stream for results we will discard. + if (!sessions.has(searchID)) return; + const idx = cursor++; + if (idx >= peerList.length) return; + await queryOnePeer(searchID, query, peerList[idx]!); + } + }); + await Promise.allSettled(workers); + } + + async function queryOnePeer(searchID: string, query: string, peerID: string): Promise { + if (!sessions.has(searchID)) return; + const network = networks.getRunningNetwork(); + let client: LISHClient | undefined; + try { + const { stream } = await network.dialProtocolByPeerId(peerID, LISH_PROTOCOL); + client = new LISHClient(stream); + const lishs = await client.requestList(query); + if (sessions.has(searchID) && lishs.length > 0) { + // Re-use the same aggregation/dedup path as the pubsub-driven + // responses, so a peer reachable through both channels never + // produces a duplicate row in the FE result list. + handleResult({ searchID, peerID, lishs }); + } + } catch (err: any) { + trace(`[Search] unicast getLishs to ${peerID.slice(0, 12)} failed: ${err?.message ?? err}`); + } finally { + await client?.close().catch(() => {}); + } + } + function cancelSearch(p: { searchID: string }): { ok: true } { endSession(p.searchID, 'cancel'); return { ok: true }; From 73a3475af37b448ae9cd7097f3c8e37ede93f45f Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 15:35:53 +0200 Subject: [PATCH 3/7] test(protocol): relax getLishs guard assertion to allow additional predicates --- backend/tests/unit/protocol/search-visibility.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/tests/unit/protocol/search-visibility.test.ts b/backend/tests/unit/protocol/search-visibility.test.ts index 0368bad4..9ec6b9ba 100644 --- a/backend/tests/unit/protocol/search-visibility.test.ts +++ b/backend/tests/unit/protocol/search-visibility.test.ts @@ -41,7 +41,12 @@ describe('LISH search visibility', () => { }); it('uses the same advertisable guard for direct getLishs and getLish protocol requests', () => { - expect(LISH_PROTOCOL_TS).toContain('filter(l => isUploadAdvertisable(l.id))'); + // The getLishs handler may layer additional predicates (e.g. an + // optional query filter for the unicast-search fallback), so assert + // the guard is present in the filter chain rather than matching an + // exact substring that would break on every new predicate. + const getLishsBlock = LISH_PROTOCOL_TS.slice(LISH_PROTOCOL_TS.indexOf("request.type === 'getLishs'"), LISH_PROTOCOL_TS.indexOf("request.type === 'getLish'")); + expect(getLishsBlock).toContain('isUploadAdvertisable(l.id)'); expect(LISH_PROTOCOL_TS).toContain('if (!isUploadAdvertisable(request.lishID))'); }); From f4092c666fec7d0de26d6c782d9e0ea33ce08ed3 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 20:40:07 +0200 Subject: [PATCH 4/7] feat(network): add onPeerConnect helper with auto-disposing listener --- backend/src/protocol/network.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 7866a841..85049880 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -370,6 +370,40 @@ export class Network { this.listeners.push({ target, event, handler }); } + /** + * Subscribe to libp2p `peer:connect` events for the duration of the + * returned disposer. The handler receives the peer ID as a string. + * + * Unlike the private `addListener`, this is intended for short-lived + * subscriptions tied to a specific operation (e.g. an in-flight LISH + * search session) — the disposer removes the listener from the global + * tracked-listener list so it does not leak across sessions. If the + * network is stopped before the caller disposes, the listener is still + * cleaned up via the normal {@link stop} path. + */ + onPeerConnect(handler: (peerID: string) => void): () => void { + if (!this.node) return () => {}; + const node = this.node; + const listener = (evt: any): void => { + const pid = evt.detail?.toString?.(); + if (pid) handler(pid); + }; + this.addListener(node, 'peer:connect', listener); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + try { + node.removeEventListener('peer:connect', listener as any); + } catch { + // Node may already be stopped — fine, stop() walked the tracked + // list already. + } + const idx = this.listeners.findIndex(l => l.target === node && l.event === 'peer:connect' && l.handler === listener); + if (idx >= 0) this.listeners.splice(idx, 1); + }; + } + /** * Schedule a debounced check of peer counts for all subscribed topics. */ From d31e90f88063e220832f52d8d859c33c5a61f6e0 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 20:41:16 +0200 Subject: [PATCH 5/7] feat(search): broaden unicast fallback to all libp2p peers and react to peer:connect --- backend/src/api/search.ts | 88 ++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/backend/src/api/search.ts b/backend/src/api/search.ts index 0fa23a55..8a968feb 100644 --- a/backend/src/api/search.ts +++ b/backend/src/api/search.ts @@ -13,6 +13,13 @@ import type { LishSearchResult } from '@shared'; * (sub-second for typical 5-30 peer fleets) and load on the libp2p dialer. */ const UNICAST_FALLBACK_PARALLEL = 10; +/** + * Already-queried peer set lives on the session so the initial snapshot + * dispatch and the live `peer:connect` listener can deduplicate against + * each other without re-asking the same peer twice. handleResult's own + * dedup catches the response side; this avoids the wasted dial. + */ +type Queried = Set; type BroadcastFn = (event: string, data: any) => void; @@ -23,6 +30,10 @@ interface SearchSession { timeout: ReturnType; /** Aggregated results, keyed by LISH id. New responders for the same LISH push into `peers`. */ results: Map; + /** Peers we have already dispatched a unicast getLishs to. */ + queried: Queried; + /** Disposer for the `peer:connect` listener, called on timeout/cancel. */ + disposePeerConnect: () => void; } export interface SearchManager { @@ -51,6 +62,7 @@ export function initSearchManager(networks: Networks, settings: Settings, broadc const session = sessions.get(searchID); if (!session) return; clearTimeout(session.timeout); + session.disposePeerConnect(); unregisterSearchResultHandler(searchID); sessions.delete(searchID); broadcast('search:lishs:complete', { searchID, reason }); @@ -106,18 +118,36 @@ export function initSearchManager(networks: Networks, settings: Settings, broadc if (query.length === 0) throw new Error('search query is empty'); const searchID = randomUUID(); const timeoutMs = settings.get('network.searchTimeout') ?? 30_000; + const network = networks.getRunningNetwork(); + const selfPeerID = network.getNodeInfo()?.peerID ?? ''; + const queried: Queried = new Set(); + // Live listener: every peer that completes a libp2p connection while + // this search is in flight gets a unicast `getLishs(query)`. Catches + // the case where a peer appears via mDNS / peer-announce / hole-punch + // AFTER the user clicked Search but BEFORE the timeout fires, so the + // result shows up without the user having to retry. + const disposePeerConnect = network.onPeerConnect(peerID => { + if (!sessions.has(searchID)) return; + if (!peerID || peerID === selfPeerID) return; + if (queried.has(peerID)) return; + queried.add(peerID); + void queryOnePeer(searchID, query, peerID).catch(() => { + /* logged inside queryOnePeer */ + }); + }); const session: SearchSession = { searchID, query, startedAt: Date.now(), results: new Map(), timeout: setTimeout(() => endSession(searchID, 'timeout'), timeoutMs), + queried, + disposePeerConnect, }; sessions.set(searchID, session); registerSearchResultHandler(searchID, handleResult); // Broadcast the query on every joined network topic. If broadcast fails on a particular // topic, log and continue — the search is still useful on other networks. - const network = networks.getRunningNetwork(); const message = { type: 'searchLishs', searchID, query }; for (const config of networks.list()) { if (!config.enabled || !networks.isJoined(config.networkID)) continue; @@ -128,43 +158,41 @@ export function initSearchManager(networks: Networks, settings: Settings, broadc } } // Kick off the unicast fallback in parallel with the pubsub broadcast. - // floodPublish only reaches peers already in pubsub.getSubscribers(topic) - // AND scored above publishThreshold — a freshly-discovered peer (mDNS / - // peer-announce / bootstrap dial) typically has a 100-500 ms window - // after dial completes before its SUBSCRIBE RPC propagates back to us, - // during which floodPublish silently skips them. Dialing them directly - // via the LISH protocol bypasses gossipsub state entirely, so the - // search works the instant the libp2p connection is up. Fire-and-forget: - // rejections are logged but never bubble up into the FE response. - runUnicastFallback(searchID, query).catch(err => trace(`[Search] unicast fallback ${searchID.slice(0, 8)} failed: ${err?.message ?? err}`)); + // The fallback covers two windows the pubsub path leaves open: + // 1. Peer subscribed but skipped by floodPublish (NaN score, dead + // RPC stream, sparse mesh) — `getPeers()` returns them too. + // 2. Peer connected at the libp2p layer but the gossipsub SUBSCRIBE + // RPC has not yet propagated. floodPublish only iterates + // `pubsub.getSubscribers(topic)` so these peers silently miss the + // query; the unicast dial reaches them the moment the connection + // is up, independent of gossipsub state. + runUnicastFallback(session).catch(err => trace(`[Search] unicast fallback ${searchID.slice(0, 8)} failed: ${err?.message ?? err}`)); return { searchID }; } /** - * Per-search unicast fan-out. Collects the union of topic-subscribed - * peers across every joined network, removes our own peerID, and sends a - * `getLishs(query)` request on a freshly-opened LISH protocol stream to - * each. Successful responses are routed through {@link handleResult}, - * which dedupes peer-id collisions against any reply we may already have - * received via the pubsub `searchResult` path. Bounded concurrency via a - * cursor-based worker pool — see {@link UNICAST_FALLBACK_PARALLEL}. + * Initial unicast fan-out at search start. Queries the union of every + * libp2p-connected peer (across all networks; we don't try to map peers + * to lishnets here — the server-side `isUploadAdvertisable` guard plus + * the optional query filter handle that on the responder). Live peers + * that connect AFTER this snapshot are picked up by the + * `peer:connect` listener installed in `startSearch`. */ - async function runUnicastFallback(searchID: string, query: string): Promise { + async function runUnicastFallback(session: SearchSession): Promise { + const { searchID, query, queried } = session; const network = networks.getRunningNetwork(); - const selfPeerID = network.getNodeInfo()?.peerID; - const peers = new Set(); - for (const config of networks.list()) { - if (!config.enabled || !networks.isJoined(config.networkID)) continue; - for (const p of network.getTopicPeers(config.networkID)) { - if (p && p !== selfPeerID) peers.add(p); - } - } - if (peers.size === 0) { - trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: no connected topic peers, skipping`); + const selfPeerID = network.getNodeInfo()?.peerID ?? ''; + // `getPeers()` is the libp2p-connection peer set, NOT the gossipsub + // subscriber set. Includes peers freshly dialed via mDNS for whom + // gossipsub SUBSCRIBE has not yet completed — exactly the case the + // fallback exists to fix. + const peerList = network.getPeers().filter(p => p && p !== selfPeerID && !queried.has(p)); + for (const p of peerList) queried.add(p); + if (peerList.length === 0) { + trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: no connected peers in snapshot`); return; } - const peerList = [...peers]; - trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: dispatching to ${peerList.length} peer(s)`); + trace(`[Search] unicast fallback ${searchID.slice(0, 8)}: snapshot dispatching to ${peerList.length} peer(s)`); let cursor = 0; const workerCount = Math.min(UNICAST_FALLBACK_PARALLEL, peerList.length); const workers = Array.from({ length: workerCount }, async () => { From 7c878807749f60ac6db75d2b59fb1a66337228d2 Mon Sep 17 00:00:00 2001 From: LuRy Date: Tue, 12 May 2026 20:59:20 +0200 Subject: [PATCH 6/7] fix(search): client-side filter for getLishs to handle peers ignoring query field --- backend/src/api/search.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/api/search.ts b/backend/src/api/search.ts index 8a968feb..9b2e81ae 100644 --- a/backend/src/api/search.ts +++ b/backend/src/api/search.ts @@ -216,11 +216,25 @@ export function initSearchManager(networks: Networks, settings: Settings, broadc const { stream } = await network.dialProtocolByPeerId(peerID, LISH_PROTOCOL); client = new LISHClient(stream); const lishs = await client.requestList(query); - if (sessions.has(searchID) && lishs.length > 0) { + if (!sessions.has(searchID)) return; + // Defense-in-depth: peers running an older version silently ignore + // the `query` field in our getLishs request and respond with their + // FULL advertised list. Without a client-side filter that would + // produce false-positive matches in the UI. Apply the same + // case-insensitive substring rule used by the server-side filter + // (network.ts:handleSearchLishs) so old peers behave identically + // to upgraded ones from the caller's perspective. + const q = query.toLowerCase(); + const matches = lishs.filter(l => { + if (typeof l.id !== 'string') return false; + if (l.id.toLowerCase().includes(q)) return true; + return (l.name?.toLowerCase() ?? '').includes(q); + }); + if (matches.length > 0) { // Re-use the same aggregation/dedup path as the pubsub-driven // responses, so a peer reachable through both channels never // produces a duplicate row in the FE result list. - handleResult({ searchID, peerID, lishs }); + handleResult({ searchID, peerID, lishs: matches }); } } catch (err: any) { trace(`[Search] unicast getLishs to ${peerID.slice(0, 12)} failed: ${err?.message ?? err}`); From 3b5f1c38d4d684abe84e3110fe24c94b239cf6f1 Mon Sep 17 00:00:00 2001 From: LuRy Date: Sat, 16 May 2026 23:31:15 +0200 Subject: [PATCH 7/7] feat(network): bootstrap peer dial status + auto-purge stale peerIDs --- backend/src/api/api.ts | 11 + backend/src/api/lishnets.ts | 23 +- backend/src/lishnet/lishnets.ts | 81 +++++-- backend/src/protocol/network.ts | 217 +++++++++++++++++- .../SettingsLISHNetworkBootstrap.svelte | 213 +++++++++++++++++ .../Settings/SettingsLISHNetworkList.svelte | 43 +++- frontend/src/scripts/networks.ts | 77 +++++++ frontend/static/langs/cs.json | 43 ++++ frontend/static/langs/en.json | 43 ++++ scripts/simulate-stale-bootstrap.mjs | 80 +++++++ shared/src/api.ts | 14 +- shared/src/index.ts | 61 +++++ 12 files changed, 873 insertions(+), 33 deletions(-) create mode 100644 frontend/src/pages/Settings/SettingsLISHNetworkBootstrap.svelte create mode 100644 scripts/simulate-stale-bootstrap.mjs diff --git a/backend/src/api/api.ts b/backend/src/api/api.ts index 00570376..07907d44 100644 --- a/backend/src/api/api.ts +++ b/backend/src/api/api.ts @@ -136,6 +136,9 @@ export class APIServer { 'lishnets.getNodeInfo': _lishnets.getNodeInfo, 'lishnets.getStatus': _lishnets.getStatus, 'lishnets.infoAll': _lishnets.infoAll, + 'lishnets.getBootstrapStatus': _lishnets.getBootstrapStatus, + 'lishnets.getAllBootstrapStatuses': _lishnets.getAllBootstrapStatuses, + 'lishnets.updateBootstrapPeers': _lishnets.updateBootstrapPeers, // Browse network — LISH search 'search.startSearch': _search.startSearch, 'search.cancelSearch': _search.cancelSearch, @@ -244,6 +247,14 @@ export class APIServer { for (const client of this.clients) this.emit(client, 'peers:count', counts); }; + // Broadcast per-network bootstrap status updates (per-peer dial outcomes). + // Clients use the lishnets:bootstrapStatus event to surface stale-config + // warnings (configured peerID does not match actual remote identity) and + // offer remediation actions in the LISH networks settings UI. + this.networks.onBootstrapStatusChange = (networkID, status) => { + this.broadcast('lishnets:bootstrapStatus', { networkID, status }); + }; + const protocol = this.secure ? 'wss' : 'ws'; console.log(`[API] Token authentication ${this.apiToken ? 'enabled' : 'disabled'}`); console.log(`[API] WebSocket server listening on ${protocol}://${this.host}:${actualPort}`); diff --git a/backend/src/api/lishnets.ts b/backend/src/api/lishnets.ts index e9d5df50..a3b1ec46 100644 --- a/backend/src/api/lishnets.ts +++ b/backend/src/api/lishnets.ts @@ -1,7 +1,7 @@ import { type Networks } from '../lishnet/lishnets.ts'; import { type DataServer } from '../lish/data-server.ts'; import { type Settings } from '../settings.ts'; -import { type LISHNetworkConfig, type LISHNetworkDefinition, type SuccessResponse, type NetworkNodeInfo, type NetworkStatus, type NetworkInfo, type PeerListEntry, type PeerLishEntry, type IPeerLishDetail, type ILISH, type ImportLISHResponse, type CompressionAlgorithm, CodedError, ErrorCodes } from '@shared'; +import { type LISHNetworkConfig, type LISHNetworkDefinition, type SuccessResponse, type NetworkNodeInfo, type NetworkStatus, type NetworkInfo, type PeerListEntry, type PeerLishEntry, type IPeerLishDetail, type ILISH, type ImportLISHResponse, type CompressionAlgorithm, type BootstrapStatus, CodedError, ErrorCodes } from '@shared'; import { LISHClient, LISH_PROTOCOL } from '../protocol/lish-protocol.ts'; import { Utils } from '../utils.ts'; const assert = Utils.assertParams; @@ -32,6 +32,9 @@ interface LISHnetsHandlers { getNodeInfo: () => NetworkNodeInfo | null; getStatus: (p: { networkID: string }) => NetworkStatus; infoAll: () => NetworkInfo[]; + getBootstrapStatus: (p: { networkID: string }) => BootstrapStatus | null; + getAllBootstrapStatuses: () => BootstrapStatus[]; + updateBootstrapPeers: (p: { networkID: string; bootstrapPeers: string[] }) => Promise; } type ImportManifestFn = (lish: ILISH, downloadPath: string, opts?: { overwrite?: boolean; enableSharing?: boolean; enableDownloading?: boolean }) => Promise; @@ -263,6 +266,21 @@ export function initLISHnetsHandlers(networks: Networks, dataServer: DataServer, datasets: dataServer.getDatasets().length, }; } + function getBootstrapStatus(p: { networkID: string }): BootstrapStatus | null { + assert(p, ['networkID']); + return networks.getBootstrapStatus(p.networkID); + } + function getAllBootstrapStatuses(): BootstrapStatus[] { + return networks.getAllBootstrapStatuses(); + } + async function updateBootstrapPeers(p: { networkID: string; bootstrapPeers: string[] }): Promise { + assert(p, ['networkID', 'bootstrapPeers']); + if (!Array.isArray(p.bootstrapPeers)) throw new CodedError(ErrorCodes.INVALID_INPUT_TYPE, 'bootstrapPeers must be an array'); + const updated = await networks.updateBootstrapPeers(p.networkID, p.bootstrapPeers); + if (!updated) throw new CodedError(ErrorCodes.NETWORK_NOT_FOUND, p.networkID); + broadcast('lishnets:updated', { networkID: updated.networkID }); + return updated; + } function infoAll(): NetworkInfo[] { const configs = networks.list(); const network = networks.getNetwork(); @@ -308,5 +326,8 @@ export function initLISHnetsHandlers(networks: Networks, dataServer: DataServer, getNodeInfo, getStatus, infoAll, + getBootstrapStatus, + getAllBootstrapStatuses, + updateBootstrapPeers, }; } diff --git a/backend/src/lishnet/lishnets.ts b/backend/src/lishnet/lishnets.ts index d2bdbddf..9950673e 100644 --- a/backend/src/lishnet/lishnets.ts +++ b/backend/src/lishnet/lishnets.ts @@ -3,7 +3,7 @@ import { Network } from '../protocol/network.ts'; import { Utils } from '../utils.ts'; import { type DataServer } from '../lish/data-server.ts'; import { type Settings } from '../settings.ts'; -import { type ILISHNetwork, type LISHNetworkConfig, type LISHNetworkDefinition, CodedError, ErrorCodes } from '@shared'; +import { type ILISHNetwork, type LISHNetworkConfig, type LISHNetworkDefinition, type BootstrapStatus, CodedError, ErrorCodes } from '@shared'; import { lishnetExists, getLISHnet, listLISHnets, listEnabledLISHnets, addLISHnet, updateLISHnet, deleteLISHnet, setLISHnetEnabled, addLISHnetIfNotExists, importLISHnets, upsertLISHnet, replaceLISHnets } from '../db/lishnets.ts'; /** @@ -19,6 +19,8 @@ export class Networks { // Callback for peer count changes private _onPeerCountChange: ((counts: { networkID: string; count: number }[]) => void) | null = null; + // Callback for bootstrap status changes + private _onBootstrapStatusChange: ((networkID: string, status: BootstrapStatus) => void) | null = null; constructor(db: Database, dataDir: string, dataServer: DataServer, settings: Settings) { this.db = db; @@ -27,6 +29,10 @@ export class Networks { this.network.onPeerCountChange = counts => { if (this._onPeerCountChange) this._onPeerCountChange(counts); }; + // Forward bootstrap status changes from the network node + this.network.onBootstrapStatusChange = (networkID, status) => { + if (this._onBootstrapStatusChange) this._onBootstrapStatusChange(networkID, status); + }; } /** @@ -36,6 +42,14 @@ export class Networks { this._onPeerCountChange = cb; } + /** + * Set a callback to be called whenever the per-peer bootstrap status for + * any joined lishnet changes (dial pending → connected/error/mismatch/timeout). + */ + set onBootstrapStatusChange(cb: ((networkID: string, status: BootstrapStatus) => void) | null) { + this._onBootstrapStatusChange = cb; + } + init(): void { console.log('✓ Networks initialized'); } @@ -54,16 +68,23 @@ export class Networks { async startEnabledNetworks(): Promise { const enabled = this.getEnabled(); - // Collect bootstrap peers from all enabled lishnets (may be empty) - const bootstrapPeers = this.collectBootstrapPeers(enabled); - - // Always start the node - await this.network.start(bootstrapPeers); + // Start the node with no preset bootstrap list — bootstrap dials happen + // per-network below via addBootstrapPeers so per-network status tracking + // can record which specific peers connected / mismatched / timed out. + // (Previous behaviour used a flat preset list that bypassed our tracking.) + await this.network.start([]); - // Subscribe to topics for all enabled lishnets + // Subscribe to topics for all enabled lishnets and dial their bootstrap peers + // with networkID context so bootstrap status counters get populated. for (const net of enabled) { this.network.subscribeTopic(net.networkID); this.joinedNetworks.add(net.networkID); + if (net.bootstrapPeers.length > 0) { + // Fire-and-forget so a slow / unreachable network does not delay startup of the others. + this.network.addBootstrapPeers(net.bootstrapPeers, net.networkID, 'configured').catch(err => { + console.error(`[Networks] addBootstrapPeers for ${net.networkID} failed:`, err?.message ?? err); + }); + } console.log(`✓ Joined lishnet: ${net.name} (${net.networkID})`); } } @@ -101,7 +122,7 @@ export class Networks { this.joinedNetworks.add(id); const net = this.get(id); - if (net && net.bootstrapPeers.length > 0) await this.network.addBootstrapPeers(net.bootstrapPeers); + if (net && net.bootstrapPeers.length > 0) await this.network.addBootstrapPeers(net.bootstrapPeers, id, 'configured'); console.log(`✓ Joined lishnet: ${net?.name ?? id}`); } @@ -178,15 +199,6 @@ export class Networks { return this.network.getMeshHealth(id); } - /** - * Collect and deduplicate bootstrap peers from a set of network configs. - */ - private collectBootstrapPeers(configs: LISHNetworkConfig[]): string[] { - const allPeers: string[] = []; - for (const config of configs) allPeers.push(...config.bootstrapPeers); - return [...new Set(allPeers)]; - } - // Validate a raw network object into a LISHNetworkDefinition (without storing). validateNetwork(data: ILISHNetwork): LISHNetworkDefinition { if (!data.networkID || !data.name) throw new CodedError(ErrorCodes.NETWORK_INVALID); @@ -271,4 +283,39 @@ export class Networks { replace(networks: LISHNetworkConfig[]): void { replaceLISHnets(this.db, networks); } + + /** + * Return per-peer bootstrap status for one network (or null if no dial + * attempts have been recorded since the node started or the entries were + * last updated). + */ + getBootstrapStatus(id: string): BootstrapStatus | null { + return this.network.getBootstrapStatus(id); + } + + /** Return per-peer bootstrap status for every network that has any tracked dials. */ + getAllBootstrapStatuses(): BootstrapStatus[] { + return this.network.getAllBootstrapStatuses(); + } + + /** + * Replace the bootstrap peer list for an existing network. Resets the + * per-peer status entries that are no longer present in the new list, then + * (if the network is joined) re-dials the new entries so fresh status is + * recorded. Returns the updated config or null if the network is unknown. + */ + async updateBootstrapPeers(id: string, bootstrapPeers: string[]): Promise { + const existing = this.get(id); + if (!existing) return null; + const cleaned = bootstrapPeers.filter(p => typeof p === 'string' && p.trim().length > 0); + const next: LISHNetworkConfig = { ...existing, bootstrapPeers: cleaned }; + updateLISHnet(this.db, next); + this.network.pruneBootstrapStatus(id, cleaned); + if (this.joinedNetworks.has(id) && cleaned.length > 0) { + this.network.addBootstrapPeers(cleaned, id, 'configured').catch(err => { + console.error(`[Networks] re-dial after updateBootstrapPeers failed:`, err?.message ?? err); + }); + } + return next; + } } diff --git a/backend/src/protocol/network.ts b/backend/src/protocol/network.ts index 85049880..ad99c5c5 100644 --- a/backend/src/protocol/network.ts +++ b/backend/src/protocol/network.ts @@ -16,7 +16,7 @@ import { buildLibp2pConfig } from './network-config.ts'; import { type WantMessage } from './downloader.ts'; import { lishTopic, LISH_TOPIC_PREFIX, normalizeTrustedPeerIds, parseAcceptPXThreshold } from './constants.ts'; import { getLocalCidrs, shouldDenyDial } from './address-filter.ts'; -import { CodedError, ErrorCodes } from '@shared'; +import { CodedError, ErrorCodes, type BootstrapStatus, type BootstrapPeerStatus, type BootstrapPeerDialStatus, type BootstrapPeerOrigin } from '@shared'; import { Circuit } from '@multiformats/multiaddr-matcher'; import { createTopicScoreParams } from '@chainsafe/libp2p-gossipsub/score'; import { multiaddr as Multiaddr } from '@multiformats/multiaddr'; @@ -180,6 +180,20 @@ export class Network { private _lastPeerCounts: Map = new Map(); private _lastMeshSizes: Map = new Map(); + /** + * Per-network → per-bootstrap-peer dial outcome status. Outer key is + * networkID; inner key is the exact multiaddr string from the network + * config. Populated by addBootstrapPeers() when called with a networkID + * context (initial join + manual updates). Lets the UI surface which + * SPECIFIC bootstrap entry is stale (identity-mismatch) or unreachable + * (timeout), rather than flagging the whole network. + * + * NOT populated for dynamic bootstrap additions from peer-announce gossip + * (those have no single owning network and would dilute per-network stats). + */ + private bootstrapStats: Map> = new Map(); + private _onBootstrapStatusChange: ((networkID: string, status: BootstrapStatus) => void) | null = null; + // Previous gossipsub peer scores — tracked per-peer to detect significant // score deltas and log threshold crossings (e.g. entered graylist). private _lastScores: Map = new Map(); @@ -874,13 +888,16 @@ export class Network { * Accept inbound peer-announce: dial each advertised multiaddr through addBootstrapPeers * (which dedupes against our existing bootstrap set and skips our own peer ID). */ - private async handlePeerAnnounce(data: PeerAnnounceMessage, fromPeerID?: string): Promise { + private async handlePeerAnnounce(data: PeerAnnounceMessage, networkID: string, fromPeerID?: string): Promise { if (!Array.isArray(data.multiaddrs) || data.multiaddrs.length === 0) return; // Cap at MAX_TOTAL_ADDRS to match sender's transitive payload. const filtered = data.multiaddrs.filter(a => typeof a === 'string' && a.length > 0).slice(0, PEER_ANNOUNCE_MAX_TOTAL_ADDRS); if (filtered.length === 0) return; - trace(`[NET] peer-announce from ${fromPeerID?.slice(0, 16) ?? 'unknown'}: ${filtered.length} addrs`); - await this.addBootstrapPeers(filtered); + trace(`[NET] peer-announce from ${fromPeerID?.slice(0, 16) ?? 'unknown'}: ${filtered.length} addrs (network ${networkID.slice(0, 8)})`); + // Pass networkID so per-peer outcomes from gossiped entries surface in the + // UI under the network through which they arrived. Identity-mismatch + // outcomes inside addBootstrapPeers also trigger purgeStalePeer. + await this.addBootstrapPeers(filtered, networkID, 'discovered'); // Stamp `keep-alive-fleet` on every peer the announce mentioned. libp2p // ReconnectQueue only acts on peers carrying a tag whose key starts with // `keep-alive`; without this tag, fleet-discovered peers that drop are @@ -1214,43 +1231,190 @@ export class Network { /** * Add bootstrap peers dynamically to the running node. * Dials them directly since the bootstrap module only works at config time. + * + * When `networkID` is provided, dial outcomes are recorded into per-network + * bootstrap status counters (used by the UI to surface stale-config warnings). + * Pass `null` for dynamic additions that have no single owning network + * (e.g. peer-announce gossip), in which case stats are skipped. */ - async addBootstrapPeers(peers: string[]): Promise { + async addBootstrapPeers(peers: string[], networkID: string | null = null, origin: BootstrapPeerOrigin = 'discovered'): Promise { if (!this.node) { console.error('Network not started - cannot add bootstrap peers'); return; } const myPeerID = this.node.peerId.toString(); for (const peer of peers) { - // Skip our own address or already-known bootstrap peers + // Skip our own address if (peer.includes(myPeerID)) continue; try { const ma = Multiaddr(peer); const peerID = ma.getComponents().find(c => c.code === 421)?.value ?? null; - if (peerID && this.bootstrapPeerIDs.has(peerID)) continue; - if (peerID) { + const alreadyKnown = !!peerID && this.bootstrapPeerIDs.has(peerID); + if (peerID && !alreadyKnown) { this.bootstrapPeerIDs.add(peerID); this.bootstrapMultiaddrs.push(ma); } console.debug('Adding bootstrap peer:', peer); + this.markBootstrapPending(networkID, peer, peerID, origin); try { - await this.node.dial(ma); + // Skip re-dialing when libp2p already has an active connection to this peer + // (typical when the same bootstrap entry appears in multiple lishnets). + // We still record the outcome so per-network status reflects "connected" + // rather than leaving the entry stuck at "pending". + const reuseExisting = alreadyKnown && peerID && this.node.getConnections(peerIDFromString(peerID)).length > 0; + if (!reuseExisting) await this.node.dial(ma); if (peerID) { await this.node.peerStore.merge(peerIDFromString(peerID), { multiaddrs: [ma], tags: { [KEEP_ALIVE]: { value: 1 } }, }); } + this.recordBootstrapOutcome(networkID, peer, peerID, 'connected', null, null, origin); console.log('✓ Connected to new bootstrap peer'); } catch (err: any) { - console.log('⚠️ Could not connect to bootstrap peer:', err.message); + const message = err?.message ?? String(err); + const kind = classifyBootstrapError(message); + const actualPeerID = kind === 'identity-mismatch' ? extractActualPeerID(message) : null; + this.recordBootstrapOutcome(networkID, peer, peerID, kind, message, actualPeerID, origin); + console.log('⚠️ Could not connect to bootstrap peer:', message); + // Crypto-verified identity mismatch ⇒ peerID stored in our peerStore + // is provably wrong for this address. Purge it so libp2p autodial + // stops retrying the dead identity. Safe because Noise handshake + // is unforgeable — a mismatch is definitive, never a transient + // network issue. Only triggers when we have an expected peerID + // to purge. + if (kind === 'identity-mismatch' && peerID) { + await this.purgeStalePeer(peerID, `${origin} dial identity mismatch`); + // For DISCOVERED entries (peer-announce gossip), also drop the + // status entry — there's no saved config row to "fix" and leaving + // it visible just adds UI noise. For CONFIGURED entries, keep + // it so the user can decide to update or remove the saved row. + if (origin === 'discovered' && networkID) { + const net = this.bootstrapStats.get(networkID); + if (net) { + net.delete(peer); + if (net.size === 0) this.bootstrapStats.delete(networkID); + const snap = this.buildBootstrapStatus(networkID) ?? { networkID, peers: [] }; + this._onBootstrapStatusChange?.(networkID, snap); + } + } + } } } catch (error: any) { + this.recordBootstrapOutcome(networkID, peer, null, 'error', error?.message ?? String(error), null, origin); console.log('⚠️ Skipping invalid multiaddr:', peer, '-', error.message); } } } + /** + * Set a callback for per-network bootstrap status updates. Called whenever + * `addBootstrapPeers(_, networkID)` records a new outcome for any entry. + */ + set onBootstrapStatusChange(cb: ((networkID: string, status: BootstrapStatus) => void) | null) { + this._onBootstrapStatusChange = cb; + } + + /** + * Remove a peerID from libp2p's peerStore + drop it from our bootstrap dedup set. + * + * Called when we have crypto-strong evidence the stored identity is wrong + * (Noise handshake reported a different peerID than the multiaddr's `/p2p/` + * suffix claimed). Removing the entry stops libp2p ReconnectQueue / autodial + * from re-attempting the dead identity. + * + * Best-effort: a peerStore.delete failure is logged at debug but does not throw — + * the same peer will be re-purged next cycle if libp2p keeps trying it. + */ + async purgeStalePeer(peerID: string, reason: string): Promise { + if (!this.node) return; + this.bootstrapPeerIDs.delete(peerID); + try { + const pid = peerIDFromString(peerID); + // Drop existing connections so libp2p considers the entry fully gone. + const conns = this.node.getConnections(pid); + for (const c of conns) { + try { + await c.close(); + } catch { + /* connection may already be closing */ + } + } + await this.node.peerStore.delete(pid); + console.log(`[NET] purged stale peerStore entry ${peerID.slice(0, 16)}… (reason: ${reason})`); + } catch (err: any) { + trace(`[NET] purgeStalePeer ${peerID.slice(0, 16)} failed: ${err?.message ?? err}`); + } + } + + /** Snapshot of all per-network bootstrap statuses. */ + getAllBootstrapStatuses(): BootstrapStatus[] { + return [...this.bootstrapStats.keys()].map(id => this.buildBootstrapStatus(id)!).filter(Boolean); + } + + /** Snapshot of a single network's bootstrap status, or null if no attempts have been recorded. */ + getBootstrapStatus(networkID: string): BootstrapStatus | null { + return this.buildBootstrapStatus(networkID); + } + + /** Drop bootstrap status entries no longer in the configured peer list (after an update). */ + pruneBootstrapStatus(networkID: string, keepMultiaddrs: string[]): void { + const peers = this.bootstrapStats.get(networkID); + if (!peers) return; + const keep = new Set(keepMultiaddrs); + for (const addr of [...peers.keys()]) { + if (!keep.has(addr)) peers.delete(addr); + } + if (peers.size === 0) this.bootstrapStats.delete(networkID); + const snapshot = this.buildBootstrapStatus(networkID); + if (snapshot) this._onBootstrapStatusChange?.(networkID, snapshot); + } + + /** Reset the bootstrap status for a single network (used when re-joining). */ + resetBootstrapStatus(networkID: string): void { + this.bootstrapStats.delete(networkID); + this._onBootstrapStatusChange?.(networkID, { networkID, peers: [] }); + } + + private ensureBootstrapNetwork(networkID: string): Map { + let net = this.bootstrapStats.get(networkID); + if (!net) { + net = new Map(); + this.bootstrapStats.set(networkID, net); + } + return net; + } + + private buildBootstrapStatus(networkID: string): BootstrapStatus | null { + const peers = this.bootstrapStats.get(networkID); + if (!peers) return null; + return { networkID, peers: [...peers.values()].map(p => ({ ...p })) }; + } + + private markBootstrapPending(networkID: string | null, multiaddr: string, expectedPeerID: string | null, origin: BootstrapPeerOrigin): void { + if (!networkID) return; + const net = this.ensureBootstrapNetwork(networkID); + // Preserve a stronger origin classification — once we know an entry is in + // the saved config ('configured'), an inbound peer-announce later restating + // the same multiaddr must not downgrade it to 'discovered'. + const previous = net.get(multiaddr); + const finalOrigin: BootstrapPeerOrigin = previous?.origin === 'configured' ? 'configured' : origin; + net.set(multiaddr, { multiaddr, expectedPeerID, status: 'pending', origin: finalOrigin, actualPeerID: null, lastError: null, updatedAt: new Date().toISOString() }); + const snapshot = this.buildBootstrapStatus(networkID); + if (snapshot) this._onBootstrapStatusChange?.(networkID, snapshot); + } + + private recordBootstrapOutcome(networkID: string | null, multiaddr: string, expectedPeerID: string | null, status: BootstrapPeerDialStatus, message: string | null, actualPeerID: string | null, origin: BootstrapPeerOrigin): void { + if (!networkID) return; + const net = this.ensureBootstrapNetwork(networkID); + const truncated = message ? (message.length > 200 ? message.slice(0, 200) + '…' : message) : null; + const previous = net.get(multiaddr); + const finalOrigin: BootstrapPeerOrigin = previous?.origin === 'configured' ? 'configured' : origin; + net.set(multiaddr, { multiaddr, expectedPeerID, status, origin: finalOrigin, actualPeerID, lastError: truncated, updatedAt: new Date().toISOString() }); + const snapshot = this.buildBootstrapStatus(networkID); + if (snapshot) this._onBootstrapStatusChange?.(networkID, snapshot); + } + // ========================================================================= // Topic (lishnet) management // ========================================================================= @@ -1317,7 +1481,7 @@ export class Network { trace(`[NET] handleWant failed: ${err?.message ?? err}`); }); } else if (data['type'] === 'peer-announce') { - this.handlePeerAnnounce(data as unknown as PeerAnnounceMessage, from).catch(err => { + this.handlePeerAnnounce(data as unknown as PeerAnnounceMessage, networkID, from).catch(err => { trace(`[NET] handlePeerAnnounce failed: ${err?.message ?? err}`); }); } else if (data['type'] === 'searchLishs') { @@ -1807,3 +1971,34 @@ export class Network { } } } + +/** + * Classify a libp2p dial error into a coarse status the UI can render distinctly. + * + * - `identity-mismatch`: the remote completed Noise handshake but reported a + * different peer ID than the multiaddr's `/p2p/` claimed. Always means + * the configured peerID is stale (or the address routes to a wrong node). + * - `timeout`: the dial never completed — peer offline, behind NAT without relay, + * firewall, or unreachable network path. + * - `error`: every other reason (invalid multiaddr, connection refused, protocol + * negotiation failure, etc). + */ +export function classifyBootstrapError(message: string): BootstrapPeerDialStatus { + if (!message) return 'error'; + if (message.includes('does not match expected remote identity key')) return 'identity-mismatch'; + if (message.includes('timed out') || message.includes('operation was aborted') || message.includes('TimeoutError')) return 'timeout'; + return 'error'; +} + +/** + * Parse the actual peerID reported by the remote out of libp2p's identity-mismatch + * error message. Returns null on shape mismatch (so the UI can fall back to a + * generic "stale config" message instead of a confident replacement suggestion). + * + * Expected message format (libp2p Noise plaintext): + * "Payload identity key does not match expected remote identity key " + */ +export function extractActualPeerID(message: string): string | null { + const m = message.match(/Payload identity key (\S+) does not match expected remote identity key /); + return m ? m[1]! : null; +} diff --git a/frontend/src/pages/Settings/SettingsLISHNetworkBootstrap.svelte b/frontend/src/pages/Settings/SettingsLISHNetworkBootstrap.svelte new file mode 100644 index 00000000..4759c99b --- /dev/null +++ b/frontend/src/pages/Settings/SettingsLISHNetworkBootstrap.svelte @@ -0,0 +1,213 @@ + + + + +
+
+ +
+
diff --git a/frontend/src/pages/Settings/SettingsLISHNetworkList.svelte b/frontend/src/pages/Settings/SettingsLISHNetworkList.svelte index 7b50fb73..afbf9c06 100644 --- a/frontend/src/pages/Settings/SettingsLISHNetworkList.svelte +++ b/frontend/src/pages/Settings/SettingsLISHNetworkList.svelte @@ -10,7 +10,7 @@ import { type LISHNetworkConfig, type NetworkNodeInfo } from '@shared'; import { api } from '../../scripts/api.ts'; import { getNetworks, deleteNetwork as deleteNetworkFromAPI, updateNetwork as updateNetworkFromAPI, addNetwork as addNetworkFromAPI, formDataToNetwork, type NetworkFormData } from '../../scripts/lishNetwork.ts'; - import { peerCounts, subscribePeerCounts, unsubscribePeerCounts } from '../../scripts/networks.ts'; + import { peerCounts, subscribePeerCounts, unsubscribePeerCounts, bootstrapStatuses, subscribeBootstrapStatuses, unsubscribeBootstrapStatuses } from '../../scripts/networks.ts'; import ButtonBar from '../../components/Buttons/ButtonBar.svelte'; import Button from '../../components/Buttons/Button.svelte'; import Alert from '../../components/Alert/Alert.svelte'; @@ -20,6 +20,7 @@ import LISHNetworkExport from './SettingsLISHNetworkExport.svelte'; import LISHNetworkExportAll from './SettingsLISHNetworkExportAll.svelte'; import LISHNetworkPublic from './SettingsLISHNetworkPublic.svelte'; + import LISHNetworkBootstrap from './SettingsLISHNetworkBootstrap.svelte'; import NodeInfoRow from '../../components/NodeInfo/NodeInfoRow.svelte'; interface Props { areaID: string; @@ -54,6 +55,23 @@ const exportAllSubPage = createSubPage(navHandle, () => areaID); const deleteSubPage = createSubPage(navHandle, () => areaID); const connectSubPage = createSubPage(navHandle, () => areaID); + const bootstrapSubPage = createSubPage(navHandle, () => areaID); + let bootstrapNetwork = $state(null); + + function openBootstrap(network: LISHNetworkConfig): void { + bootstrapNetwork = network; + bootstrapSubPage.enter(`${network.name} - ${$t('settings.lishNetwork.bootstrap.title')}`, () => void closeBootstrap()); + } + async function closeBootstrap(): Promise { + bootstrapNetwork = null; + await bootstrapSubPage.exit(); + } + + function configuredProblems(networkID: string): number { + const s = $bootstrapStatuses[networkID]; + if (!s) return 0; + return s.peers.filter(p => p.origin === 'configured' && (p.status === 'identity-mismatch' || p.status === 'timeout' || p.status === 'error')).length; + } async function closePublic(): Promise { // Reload networks in case new ones were added @@ -188,11 +206,21 @@ void closeConnect(); } + function handleBootstrapUpdated(updated: LISHNetworkConfig): void { + const index = networks.findIndex(n => n.networkID === updated.networkID); + if (index !== -1) { + networks[index] = { ...networks[index]!, ...updated }; + networks = [...networks]; + } + } + onMount(() => { loadNetworks(); subscribePeerCounts(); + subscribeBootstrapStatuses(); return () => { unsubscribePeerCounts(); + unsubscribeBootstrapStatuses(); }; }); @@ -284,6 +312,8 @@ void exportAllSubPage.exit()} /> {:else if publicSubPage.active} void closePublic()} /> +{:else if bootstrapSubPage.active && bootstrapNetwork} + void closeBootstrap()} /> {:else if deleteSubPage.active && deletingNetwork} {:else if connectSubPage.active && pendingConnectNetwork} @@ -323,16 +353,23 @@ {#if networkErrors[network.networkID]} {/if} + {#if network.enabled && configuredProblems(network.networkID) > 0} + {@const probs = configuredProblems(network.networkID)} + + {/if}
diff --git a/frontend/src/scripts/networks.ts b/frontend/src/scripts/networks.ts index ee87bb58..3fbdc23e 100644 --- a/frontend/src/scripts/networks.ts +++ b/frontend/src/scripts/networks.ts @@ -3,6 +3,7 @@ import { api } from './api.ts'; import { connected } from './ws-client.ts'; import { tt } from './language.ts'; import { addNotification } from './notifications.ts'; +import type { BootstrapStatus } from '@shared'; // Reactive store of peer counts per network, updated via push events from backend, key: networkID, value: number of connected peers export const peerCounts = writable>({}); @@ -172,6 +173,82 @@ export async function subscribePeerCounts(): Promise { }) as () => void; } +/** + * Per-network bootstrap dial status, keyed by networkID. Each entry lists the + * latest dial outcome for every configured bootstrap peer (connected / + * identity-mismatch / timeout / error / pending). The settings UI renders this + * to highlight WHICH specific bootstrap entry is misconfigured rather than + * flagging the whole network as stale. + */ +export const bootstrapStatuses = writable>({}); + +let bootstrapUnsubListener: (() => void) | null = null; +let bootstrapUnsubReconnect: (() => void) | null = null; +let bootstrapSubscriberCount = 0; + +/** Helper: do any of this network's configured peers report a hard failure (identity-mismatch / timeout / error)? */ +export function hasBootstrapProblems(status: BootstrapStatus | undefined): boolean { + if (!status) return false; + return status.peers.some(p => p.status === 'identity-mismatch' || p.status === 'timeout' || p.status === 'error'); +} + +/** + * Subscribe to per-network bootstrap status updates. Loads the current + * snapshot synchronously and then keeps it live via the + * `lishnets:bootstrapStatus` push event. + */ +export async function subscribeBootstrapStatuses(): Promise { + bootstrapSubscriberCount++; + if (bootstrapUnsubListener) return; + // Initial snapshot — events only fire on subsequent state changes. + try { + const initial = await api.lishnets.getAllBootstrapStatuses(); + const map: Record = {}; + for (const s of initial) map[s.networkID] = s; + bootstrapStatuses.set(map); + } catch { + // Network not running or unsupported backend — leave store empty, banner will simply not show. + } + bootstrapUnsubListener = api.on('lishnets:bootstrapStatus', (data: { networkID: string; status: BootstrapStatus }) => { + bootstrapStatuses.update(curr => ({ ...curr, [data.networkID]: data.status })); + }) as () => void; + api.subscribe('lishnets:bootstrapStatus'); + let skipFirst = true; + bootstrapUnsubReconnect = connected.subscribe(isConnected => { + if (skipFirst) { + skipFirst = false; + return; + } + if (isConnected && bootstrapUnsubListener) { + api.subscribe('lishnets:bootstrapStatus'); + // Re-fetch snapshot on reconnect to catch updates we missed while disconnected. + api.lishnets + .getAllBootstrapStatuses() + .then(snap => { + const map: Record = {}; + for (const s of snap) map[s.networkID] = s; + bootstrapStatuses.set(map); + }) + .catch(() => undefined); + } + }) as () => void; +} + +export async function unsubscribeBootstrapStatuses(): Promise { + if (bootstrapSubscriberCount === 0) return; + bootstrapSubscriberCount--; + if (bootstrapSubscriberCount > 0) return; + if (bootstrapUnsubReconnect) { + bootstrapUnsubReconnect(); + bootstrapUnsubReconnect = null; + } + if (!bootstrapUnsubListener) return; + await api.unsubscribe('lishnets:bootstrapStatus'); + bootstrapUnsubListener(); + bootstrapUnsubListener = null; + bootstrapStatuses.set({}); +} + // Unsubscribe from peer count updates. Reference-counted: actual unsubscribe happens only when the last subscriber leaves. export async function unsubscribePeerCounts(): Promise { if (subscriberCount === 0) return; diff --git a/frontend/static/langs/cs.json b/frontend/static/langs/cs.json index 39678534..48bed812 100644 --- a/frontend/static/langs/cs.json +++ b/frontend/static/langs/cs.json @@ -344,6 +344,49 @@ "forming": "Síť se ještě staví - vyhledávání může být neúplné", "unstable": "Účastníci v síti mají nízké skóre - degradované směrování", "stable": "Síť je stabilní - broadcasty dorazí k fleetu" + }, + "bootstrap": { + "title": "Bootstrap účastníci", + "discoveredShort": "z gossipu", + "discoveredHelp": "Získáni za běhu přes gossip. Automaticky odstraněni při neplatné identitě.", + "originConfigured": "uložený", + "originDiscovered": "gossip", + "connected": "Připojeno", + "pending": "Připojuji…", + "identityMismatch": "Neshodná identita", + "timeout": "Vypršel časový limit", + "error": "Nedostupné", + "identityMismatchDetail": "Účastník na této adrese hlásí jinou identitu. Nastavené ID účastníka je zastaralé.", + "timeoutDetail": "Adresa včas neodpověděla. Účastník je pravděpodobně offline nebo za NATem.", + "errorDetail": "Připojení selhalo: {detail}", + "actualPeerID": "Skutečné ID účastníka: {peerID}", + "actualPeerIDLabel": "Skutečné ID", + "expectedPeerID": "Očekávané ID", + "lastError": "Poslední chyba", + "updatedAt": "Aktualizováno", + "replaceWithActual": "Aktualizovat na skutečné ID", + "replaceShort": "Opravit ID", + "removeEntry": "Odebrat tohoto účastníka", + "removeShort": "Odebrat", + "openLabel": "Opravit bootstrap ({count})", + "noProblems": "Všichni nastavení bootstrap účastníci jsou dostupní.", + "refreshFromList": "Aktualizovat z veřejného seznamu", + "refreshSuccess": "Bootstrap účastníci aktualizováni z veřejného seznamu", + "refreshNoMatch": "Veřejný seznam tuto síť neobsahuje", + "refreshFailed": "Nepodařilo se načíst veřejný seznam: {detail}", + "warningOne": "{count} z {total} nastavených bootstrap účastníků není dostupný. Objevování sítě může být omezeno.", + "warningMany": "{count} z {total} nastavených bootstrap účastníků není dostupných. Objevování sítě může být omezeno.", + "warningShortOne": "{count}/{total} vadné", + "warningShortMany": "{count}/{total} vadných", + "searchPlaceholder": "Hledat podle ID nebo adresy…", + "problemsOnly": "Pouze problémy", + "showDiscovered": "Zobrazit z gossipu ({count})", + "noMatch": "Žádný bootstrap účastník neodpovídá filtru.", + "colPeer": "ID účastníka", + "colAddress": "Adresa", + "colOrigin": "Zdroj", + "colActions": "Akce", + "showMore": "Zobrazit dalších {count} ({total} skrytých)" } }, "lishNetworkImport": { diff --git a/frontend/static/langs/en.json b/frontend/static/langs/en.json index 73defc87..84a37d7d 100644 --- a/frontend/static/langs/en.json +++ b/frontend/static/langs/en.json @@ -344,6 +344,49 @@ "forming": "Mesh is still forming — searches may be incomplete", "unstable": "Mesh peers have low scores — degraded routing", "stable": "Mesh is stable — broadcasts will reach the fleet" + }, + "bootstrap": { + "title": "Bootstrap peers", + "discoveredShort": "gossip", + "discoveredHelp": "Learned at runtime from gossip. Auto-removed when stale.", + "originConfigured": "saved", + "originDiscovered": "gossip", + "connected": "Connected", + "pending": "Connecting…", + "identityMismatch": "Identity mismatch", + "timeout": "Timed out", + "error": "Unreachable", + "identityMismatchDetail": "Peer at this address reported a different identity. Configured peer ID is stale.", + "timeoutDetail": "Address did not respond in time. The peer may be offline or behind a NAT.", + "errorDetail": "Dial failed: {detail}", + "actualPeerID": "Actual peer ID: {peerID}", + "actualPeerIDLabel": "Actual peer ID", + "expectedPeerID": "Expected peer ID", + "lastError": "Last error", + "updatedAt": "Last updated", + "replaceWithActual": "Update entry to actual peer ID", + "replaceShort": "Fix ID", + "removeEntry": "Remove this peer", + "removeShort": "Remove", + "openLabel": "Bootstrap ({count})", + "noProblems": "All configured bootstrap peers are reachable.", + "refreshFromList": "Refresh from public list", + "refreshSuccess": "Bootstrap peers refreshed from public list", + "refreshNoMatch": "Public list does not contain this network", + "refreshFailed": "Could not fetch public list: {detail}", + "warningOne": "{count} of {total} configured bootstrap peers is unreachable. Network discovery may be limited.", + "warningMany": "{count} of {total} configured bootstrap peers are unreachable. Network discovery may be limited.", + "warningShortOne": "{count}/{total} stale", + "warningShortMany": "{count}/{total} stale", + "searchPlaceholder": "Search by peer ID or address…", + "problemsOnly": "Problems only", + "showDiscovered": "Show gossip-discovered ({count})", + "noMatch": "No bootstrap peers match the filter.", + "colPeer": "Peer ID", + "colAddress": "Address", + "colOrigin": "Source", + "colActions": "Actions", + "showMore": "Show {count} more ({total} hidden)" } }, "lishNetworkImport": { diff --git a/scripts/simulate-stale-bootstrap.mjs b/scripts/simulate-stale-bootstrap.mjs new file mode 100644 index 00000000..f1dbf617 --- /dev/null +++ b/scripts/simulate-stale-bootstrap.mjs @@ -0,0 +1,80 @@ +// E2E simulation helper: connects to a running LiberShare backend via WebSocket +// and adds a test network whose bootstrapPeers list contains three intentionally +// crafted entries that exercise every BootstrapPeerStatus state: +// 1. identity-mismatch — valid public address (lish2.libershare.com) wrapped in +// a wrong /p2p/ suffix so the libp2p Noise handshake rejects it +// 2. timeout — a TEST-NET-1 (RFC5737) address with random peerID, guaranteed +// to time out (documentation address, never routable) +// 3. connected — a real lish1.libershare.com entry with its correct peerID +// +// Usage: +// bun run scripts/simulate-stale-bootstrap.mjs # default port 1158 +// BACKEND_PORT=2200 bun run scripts/simulate-stale-bootstrap.mjs + +const PORT = process.env.BACKEND_PORT ?? '1158'; +const URL = `ws://localhost:${PORT}`; +const TEST_NETWORK_ID = '99999999-0000-4000-8000-stalebootstrap'; +const FAKE_PEER_ID = '12D3KooWBADBADBADBADBADBADBADBADBADBADBADBADBADBADxx'; +const REAL_LISH1 = '/dns4/lish1.libershare.com/tcp/9090/p2p/12D3KooWAnfqA6Wap96ixVfxhHeGUDMriBG4Nncp5tqu8q71EVv2'; +const STALE_LISH2 = `/dns4/lish2.libershare.com/tcp/9090/p2p/${FAKE_PEER_ID}`; +const TIMEOUT_ADDR = '/ip4/192.0.2.1/tcp/9090/p2p/12D3KooWTimeoutTimeoutTimeoutTimeoutTimeoutTime'; + +let nextId = 1; +const pending = new Map(); + +const ws = new WebSocket(URL); +await new Promise(r => (ws.onopen = r)); + +ws.onmessage = ev => { + const msg = JSON.parse(ev.data); + if (msg.id != null && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(`${msg.error}: ${msg.errorDetail ?? ''}`)); + else resolve(msg.result); + } +}; + +function call(method, params) { + const id = String(nextId++); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`Timeout waiting for ${method}`)); + } + }, 15_000); + }); +} + +const network = { + networkID: TEST_NETWORK_ID, + name: 'STALE-BOOTSTRAP-TEST', + description: 'Synthetic network with one stale, one unreachable, and one valid bootstrap peer.', + bootstrapPeers: [STALE_LISH2, TIMEOUT_ADDR, REAL_LISH1], + created: new Date().toISOString(), + enabled: true, +}; + +const exists = await call('lishnets.exists', { networkID: TEST_NETWORK_ID }); +if (exists) { + console.log(`Test network ${TEST_NETWORK_ID} already exists — re-applying bootstrap peers.`); + await call('lishnets.updateBootstrapPeers', { networkID: TEST_NETWORK_ID, bootstrapPeers: network.bootstrapPeers }); + await call('lishnets.setEnabled', { networkID: TEST_NETWORK_ID, enabled: true }); +} else { + console.log(`Adding test network ${TEST_NETWORK_ID}…`); + // add only inserts into DB; explicit setEnabled triggers joinNetwork + bootstrap dials. + await call('lishnets.add', { network: { ...network, enabled: false } }); + await call('lishnets.setEnabled', { networkID: TEST_NETWORK_ID, enabled: true }); +} + +console.log('Waiting 8s for dial attempts to settle…'); +await new Promise(r => setTimeout(r, 8000)); + +const status = await call('lishnets.getBootstrapStatus', { networkID: TEST_NETWORK_ID }); +console.log('Bootstrap status snapshot:'); +console.log(JSON.stringify(status, null, 2)); + +ws.close(); diff --git a/shared/src/api.ts b/shared/src/api.ts index 466fff34..6dfedcbc 100644 --- a/shared/src/api.ts +++ b/shared/src/api.ts @@ -1,4 +1,4 @@ -import { type NetworkStatus, type NetworkNodeInfo, type NetworkInfo, type PeerListEntry, type PeerLishEntry, type IPeerLishDetail, type LishSearchResult, type Dataset, type FsInfo, type FsListResult, type SuccessResponse, type CreateLISHResponse, type ImportLISHResponse, type DownloadResponse, type LISHNetworkConfig, type LISHNetworkDefinition, type IStoredLISH, type ILISHSummary, type ILISHDetail, type ILISH, type LISHSortField, type SortOrder, type CompressionAlgorithm } from './index.ts'; +import { type NetworkStatus, type NetworkNodeInfo, type NetworkInfo, type PeerListEntry, type PeerLishEntry, type IPeerLishDetail, type LishSearchResult, type Dataset, type FsInfo, type FsListResult, type SuccessResponse, type CreateLISHResponse, type ImportLISHResponse, type DownloadResponse, type LISHNetworkConfig, type LISHNetworkDefinition, type IStoredLISH, type ILISHSummary, type ILISHDetail, type ILISH, type LISHSortField, type SortOrder, type CompressionAlgorithm, type BootstrapStatus } from './index.ts'; type EventCallback = (data: any) => void; @@ -327,6 +327,18 @@ class LISHnetsAPI { infoAll(): Promise { return this.client.call('lishnets.infoAll'); } + + getBootstrapStatus(networkID: string): Promise { + return this.client.call('lishnets.getBootstrapStatus', { networkID }); + } + + getAllBootstrapStatuses(): Promise { + return this.client.call('lishnets.getAllBootstrapStatuses'); + } + + updateBootstrapPeers(networkID: string, bootstrapPeers: string[]): Promise { + return this.client.call('lishnets.updateBootstrapPeers', { networkID, bootstrapPeers }); + } } class LISHsAPI { diff --git a/shared/src/index.ts b/shared/src/index.ts index 6e009908..c87a7bbd 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -116,6 +116,67 @@ export interface NetworkInfo extends LISHNetworkConfig { peersInStore?: number; } +/** + * Per-bootstrap-peer dial outcome. + * + * Tracks the latest dial attempt result for one entry in a network's + * configured `bootstrapPeers` list. This is granular per-entry, so the UI + * can surface exactly which configured peer is misconfigured rather than + * just flagging the whole network as "stale". + */ +export type BootstrapPeerDialStatus = 'pending' | 'connected' | 'identity-mismatch' | 'timeout' | 'error'; + +/** + * Where this bootstrap-peer entry came from: + * - 'configured': it is part of the network's saved `bootstrapPeers` list (user-visible, editable) + * - 'discovered': it arrived via peer-announce gossip from another connected peer (transient, not in config) + * + * The UI separates the two so the user clearly sees what their own config + * contains versus what the network told us about. Cleanup actions on + * 'discovered' entries don't touch the saved config — they purge libp2p + * peerStore so the dead identity stops being re-dialed and re-gossiped. + */ +export type BootstrapPeerOrigin = 'configured' | 'discovered'; + +export interface BootstrapPeerStatus { + /** The multiaddr exactly as observed (from config OR from inbound peer-announce). */ + multiaddr: string; + /** PeerID extracted from the multiaddr (the `/p2p/` component), or null if absent. */ + expectedPeerID: string | null; + /** Latest dial outcome for this entry. */ + status: BootstrapPeerDialStatus; + /** Source of this entry — see {@link BootstrapPeerOrigin}. */ + origin: BootstrapPeerOrigin; + /** + * If `status === 'identity-mismatch'`, the peerID actually reported by the + * remote during Noise handshake (parsed from libp2p's error message). Lets + * the UI offer "update entry to " as a one-click remedy. + */ + actualPeerID: string | null; + /** Truncated message of the most recent dial failure (≤200 chars), if any. */ + lastError: string | null; + /** ISO timestamp of the last update to this entry's status. */ + updatedAt: string; +} + +/** + * Per-network bootstrap dial status — one entry per configured bootstrap peer + * plus aggregate counters. + * + * Populated when the backend attempts to dial the bootstrap peers configured + * for a lishnet. Lets the UI detect which specific entries are stale + * (identity-mismatch) or unreachable (timeout) and offer corrective actions: + * delete bad entry, update peerID to the actual one, or refresh the whole + * list from the public network catalogue. + * + * Stats reset when a peer entry is removed/replaced via lishnets.updateBootstrapPeers. + */ +export interface BootstrapStatus { + networkID: string; + /** Per-bootstrap-entry dial outcomes, keyed implicitly by `multiaddr`. */ + peers: BootstrapPeerStatus[]; +} + // Dataset types (derived from ILISH entries that have a directory) export interface Dataset { id: string;