@steve02081504/fount-p2p 0.0.13 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,15 @@
1
1
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
2
2
 
3
3
  import { noteBtPeerHint } from './bt/peer_hints.mjs'
4
+ import { normalizeLanHosts } from './lan_interfaces.mjs'
4
5
  import { noteLanPeerHint } from './lan_peer_hints.mjs'
5
6
 
6
7
  /**
7
8
  * 从已验证的 discovery advert + provider meta 写入 LAN/BT peer hints。
8
9
  * 任意 discovery 路径(node / group / scoped)收到 advert 时都应调用。
10
+ * meta.address(观测地址)优先于 body.lanHosts(自报地址)。
9
11
  * @param {string} verifiedNodeHash 验签通过的 nodeHash
10
- * @param {{ tcpPort?: unknown } | null | undefined} body advert body
12
+ * @param {{ tcpPort?: unknown, lanHosts?: unknown } | null | undefined} body advert body
11
13
  * @param {{ address?: unknown, peripheralId?: unknown } | null | undefined} meta discovery provider meta
12
14
  * @returns {void}
13
15
  */
@@ -15,7 +17,11 @@ export function noteAdvertPeerHints(verifiedNodeHash, body, meta) {
15
17
  if (meta?.peripheralId)
16
18
  noteBtPeerHint(verifiedNodeHash, meta.peripheralId)
17
19
  const tcpPort = normalizeTcpPort(body?.tcpPort)
20
+ if (tcpPort == null) return
21
+ // lanHosts 先写(差→优,unshift 后优在前),再写观测 address,使 address 最优先
22
+ for (const host of normalizeLanHosts(body?.lanHosts).toReversed())
23
+ noteLanPeerHint(verifiedNodeHash, { host, port: tcpPort })
18
24
  const address = String(meta?.address || '').trim()
19
- if (tcpPort != null && address)
25
+ if (address)
20
26
  noteLanPeerHint(verifiedNodeHash, { host: address, port: tcpPort })
21
27
  }
@@ -1,6 +1,8 @@
1
1
  import { normalizeHex64 } from '../core/hexIds.mjs'
2
2
  import { buildSignedAdvert, verifySignedAdvert } from '../link/handshake.mjs'
3
3
 
4
+ import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
5
+
4
6
  import {
5
7
  decryptSignalPacket,
6
8
  encryptSignalPacket,
@@ -33,9 +35,13 @@ export function rendezvousKeyForScope(scope, selfNodeHash) {
33
35
  */
34
36
  export async function buildSignedAdvertForScope(scope, localIdentity, tcpPort) {
35
37
  const key = rendezvousKeyForScope(scope, localIdentity.nodeHash)
38
+ const lanHosts = scope === 'network' && tcpPort != null
39
+ ? listMulticastIpv4Addresses()
40
+ : []
36
41
  return await buildSignedAdvert(key, Date.now(), {
37
42
  ...localIdentity,
38
43
  ...tcpPort != null ? { tcpPort } : {},
44
+ ...lanHosts.length ? { lanHosts } : {},
39
45
  })
40
46
  }
41
47
 
package/discovery/lan.mjs CHANGED
@@ -5,8 +5,10 @@ import { base64ToBytes, bytesToBase64 } from '../core/bytes_codec.mjs'
5
5
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
6
6
  import { nodeDebug, shortHash } from '../node/log.mjs'
7
7
 
8
+ import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
8
9
  import { ingestNetworkAdvert } from './adverts.mjs'
9
- import { getLanPeerHint, noteLanPeerHint } from './lan_peer_hints.mjs'
10
+ import { listMulticastIpv4Addresses } from './lan_interfaces.mjs'
11
+ import { getLanPeerHint } from './lan_peer_hints.mjs'
10
12
 
11
13
  const DEFAULT_PORT = 53531
12
14
  const DEFAULT_GROUP = '239.255.42.99'
@@ -51,25 +53,26 @@ export function clearLanVisibleNodes() {
51
53
  /**
52
54
  * Untrusted ingress:验签 network advert 后写入可见池 / peer hint。
53
55
  * @param {Uint8Array} advertBytes 加密 network advert
54
- * @param {{ address?: string }} [meta] 发送方地址
56
+ * @param {{ address?: string, skipNodeHash?: string }} [meta] 发送方地址 / 本机 nodeHash(过滤自回环)
55
57
  * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签结果
56
58
  */
57
59
  export async function acceptLanPresenceAdvert(advertBytes, meta = {}) {
58
60
  if (!advertBytes?.byteLength) return null
59
61
  const ingested = await ingestNetworkAdvert(advertBytes, meta)
60
62
  if (!ingested) return null
63
+ const skipHash = meta.skipNodeHash ? normalizeHex64(meta.skipNodeHash) : null
64
+ if (skipHash && ingested.verifiedNodeHash === skipHash) return ingested
61
65
  const firstSeen = !visibleByHash.has(ingested.verifiedNodeHash)
62
66
  noteLanVisibleNode(ingested.verifiedNodeHash)
63
- const host = String(meta.address || '').trim()
64
- const tcpPort = Number(ingested.body?.tcpPort)
65
- if (host && Number.isFinite(tcpPort) && tcpPort > 0)
66
- noteLanPeerHint(ingested.verifiedNodeHash, { host, port: tcpPort })
67
- if (firstSeen)
67
+ noteAdvertPeerHints(ingested.verifiedNodeHash, ingested.body, meta)
68
+ if (firstSeen) {
69
+ const host = String(meta.address || '').trim()
68
70
  nodeDebug('p2p:lan peer visible', {
69
71
  peer: shortHash(ingested.verifiedNodeHash),
70
72
  host: host || undefined,
71
- tcpPort: tcpPort > 0 ? tcpPort : undefined,
73
+ tcpPort: ingested.body?.tcpPort,
72
74
  })
75
+ }
73
76
  return ingested
74
77
  }
75
78
 
@@ -88,10 +91,27 @@ export function createLanDiscoveryProvider(options = {}) {
88
91
  let refs = 0
89
92
  /** @type {ReturnType<typeof setInterval> | null} */
90
93
  let beaconTimer = null
94
+ /** @type {string | null} */
95
+ let selfNodeHash = null
96
+ /** @type {Set<string>} */
97
+ const joinedAddresses = new Set()
91
98
 
92
99
  /**
93
- * @returns {import('node:dgram').Socket} 复用的组播 UDP socket
100
+ * @param {import('node:dgram').Socket} sock UDP socket
101
+ * @returns {void}
94
102
  */
103
+ function ensureMemberships(sock) {
104
+ for (const addr of listMulticastIpv4Addresses()) {
105
+ if (joinedAddresses.has(addr)) continue
106
+ try {
107
+ sock.addMembership(group, addr)
108
+ joinedAddresses.add(addr)
109
+ }
110
+ catch { /* ignore per-interface join failure */ }
111
+ }
112
+ if (!joinedAddresses.size)
113
+ try { sock.addMembership(group) } catch { /* ignore */ }
114
+ }
95
115
  function getSocket() {
96
116
  return socket ||= dgram.createSocket({ type: 'udp4', reuseAddr: true })
97
117
  }
@@ -108,7 +128,7 @@ export function createLanDiscoveryProvider(options = {}) {
108
128
  sock.once('error', reject)
109
129
  sock.bind(port, '0.0.0.0', () => {
110
130
  sock.off('error', reject)
111
- sock.addMembership(group)
131
+ ensureMemberships(sock)
112
132
  sock.setMulticastTTL(1)
113
133
  resolve()
114
134
  })
@@ -117,6 +137,7 @@ export function createLanDiscoveryProvider(options = {}) {
117
137
  try { sock.close() } catch { /* ignore */ }
118
138
  if (socket === sock) socket = null
119
139
  bound = false
140
+ joinedAddresses.clear()
120
141
  return
121
142
  }
122
143
  sock.on('message', (raw, rinfo) => {
@@ -131,8 +152,11 @@ export function createLanDiscoveryProvider(options = {}) {
131
152
  }
132
153
  catch { return }
133
154
  if (!advertBytes?.byteLength) return
134
- void acceptLanPresenceAdvert(advertBytes, { address: rinfo?.address, provider: 'lan' })
135
- .catch(() => { })
155
+ void acceptLanPresenceAdvert(advertBytes, {
156
+ address: rinfo?.address,
157
+ provider: 'lan',
158
+ skipNodeHash: selfNodeHash || undefined,
159
+ }).catch(() => { })
136
160
  })
137
161
  bound = true
138
162
  })().finally(() => {
@@ -148,10 +172,34 @@ export function createLanDiscoveryProvider(options = {}) {
148
172
  async function multicastBeacon(beacon) {
149
173
  await ensureBound()
150
174
  const sock = getSocket()
175
+ ensureMemberships(sock)
151
176
  const packet = Buffer.from(JSON.stringify({ type: 'presence', ...beacon }))
152
- await new Promise((resolve, reject) => {
177
+ const addrs = listMulticastIpv4Addresses()
178
+ /**
179
+ * @param {string | undefined} ifaceAddr 组播出口地址
180
+ * @returns {Promise<void>}
181
+ */
182
+ const sendOnce = ifaceAddr => new Promise((resolve, reject) => {
183
+ if (ifaceAddr) sock.setMulticastInterface(ifaceAddr)
153
184
  sock.send(packet, port, group, error => error ? reject(error) : resolve())
154
185
  })
186
+ if (!addrs.length) {
187
+ await sendOnce()
188
+ return
189
+ }
190
+ let sent = 0
191
+ /** @type {unknown} */
192
+ let lastError = null
193
+ for (const addr of addrs) {
194
+ try {
195
+ await sendOnce(addr)
196
+ sent++
197
+ }
198
+ catch (error) {
199
+ lastError = error
200
+ }
201
+ }
202
+ if (!sent) throw lastError || new Error('p2p: lan multicast send failed')
155
203
  }
156
204
 
157
205
  /**
@@ -164,6 +212,8 @@ export function createLanDiscoveryProvider(options = {}) {
164
212
  refs = Math.max(0, refs - 1)
165
213
  if (refs > 0 || !socket) return
166
214
  if (beaconTimer) { clearInterval(beaconTimer); beaconTimer = null }
215
+ selfNodeHash = null
216
+ joinedAddresses.clear()
167
217
  try { socket.close() } catch { /* ignore */ }
168
218
  socket = null
169
219
  bound = false
@@ -206,6 +256,7 @@ export function createLanDiscoveryProvider(options = {}) {
206
256
  const send = async () => {
207
257
  const body = await getBeacon?.()
208
258
  if (!body) return
259
+ if (body.nodeHash) selfNodeHash = normalizeHex64(body.nodeHash)
209
260
  const advertBytes = body.advertBytes?.byteLength
210
261
  ? body.advertBytes
211
262
  : null
@@ -0,0 +1,78 @@
1
+ import os from 'node:os'
2
+
3
+ /** advert / 组播 beacon 携带的 LAN IPv4 上限 */
4
+ export const MAX_LAN_HOSTS = 4
5
+
6
+ const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/
7
+
8
+ /**
9
+ * untrusted ingress:清洗 advert body 中的 LAN IPv4 列表。
10
+ * @param {unknown} input 原始 lanHosts
11
+ * @returns {string[]} 去重后的 IPv4 列表
12
+ */
13
+ export function normalizeLanHosts(input) {
14
+ if (!input) return []
15
+ const arr = Array.isArray(input) ? input : [input]
16
+ const seen = new Set()
17
+ /** @type {string[]} */
18
+ const out = []
19
+ for (const item of arr) {
20
+ const host = String(item || '').trim()
21
+ if (!host || !IPV4_RE.test(host) || seen.has(host)) continue
22
+ seen.add(host)
23
+ out.push(host)
24
+ if (out.length >= MAX_LAN_HOSTS) break
25
+ }
26
+ return out
27
+ }
28
+
29
+ /**
30
+ * @param {string} addr IPv4
31
+ * @returns {number} 排序权重(越小越优先)
32
+ */
33
+ function lanAddressRank(addr) {
34
+ if (addr.startsWith('169.254.')) return 100
35
+ if (addr.startsWith('192.168.56.')) return 90
36
+ if (addr.startsWith('192.168.')) return 10
37
+ if (addr.startsWith('10.')) return 20
38
+ const match = /^172\.(\d+)\./u.exec(addr)
39
+ if (match && Number(match[1]) >= 16 && Number(match[1]) <= 31) return 30
40
+ return 50
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} addrs IPv4 列表
45
+ * @returns {string[]} 按 LAN 可达性优先排序
46
+ */
47
+ function prioritizeLanAddresses(addrs) {
48
+ return [...addrs].sort((left, right) => lanAddressRank(left) - lanAddressRank(right))
49
+ }
50
+
51
+ /**
52
+ * @param {string | number} family os.networkInterfaces family
53
+ * @returns {boolean} 是否 IPv4
54
+ */
55
+ function isIpv4Family(family) {
56
+ return family === 'IPv4' || family === 4
57
+ }
58
+
59
+ /**
60
+ * 本机可用于 LAN 组播 / advert 的非 internal IPv4 地址。
61
+ * @returns {string[]} 去重、排序后的地址列表
62
+ */
63
+ export function listMulticastIpv4Addresses() {
64
+ const seen = new Set()
65
+ /** @type {string[]} */
66
+ const addrs = []
67
+ for (const ifaces of Object.values(os.networkInterfaces())) {
68
+ if (!ifaces) continue
69
+ for (const iface of ifaces) {
70
+ if (iface.internal || !isIpv4Family(iface.family)) continue
71
+ const addr = String(iface.address || '').trim()
72
+ if (!addr || seen.has(addr)) continue
73
+ seen.add(addr)
74
+ addrs.push(addr)
75
+ }
76
+ }
77
+ return prioritizeLanAddresses(addrs).slice(0, MAX_LAN_HOSTS)
78
+ }
@@ -5,11 +5,15 @@ import { createTtlMap } from '../utils/ttl_map.mjs'
5
5
  /** LAN peer hint 存活时间。 */
6
6
  export const LAN_PEER_HINT_TTL_MS = 5 * 60_000
7
7
 
8
- /** @type {ReturnType<typeof createTtlMap<{ host: string, port: number }>>} */
8
+ /** peer 保留的 endpoint 上限。 */
9
+ const MAX_ENDPOINTS = 8
10
+
11
+ /** @type {ReturnType<typeof createTtlMap<{ endpoints: { host: string, port: number }[] }>>} */
9
12
  const hints = createTtlMap(LAN_PEER_HINT_TTL_MS)
10
13
 
11
14
  /**
12
15
  * 记录 LAN 上观察到的 nodeHash → host:port。
16
+ * 最新写入排在最前(dial / getLanPeerHint 优先用新观测)。
13
17
  * @param {string} nodeHash 节点 64 hex
14
18
  * @param {{ host: string, port: number }} endpoint 端点
15
19
  * @returns {void}
@@ -19,19 +23,32 @@ export function noteLanPeerHint(nodeHash, endpoint) {
19
23
  const host = String(endpoint?.host || '').trim()
20
24
  const port = normalizeTcpPort(endpoint?.port)
21
25
  if (!hash || !host || !port) return
22
- hints.set(hash, { host, port })
26
+ const existing = hints.get(hash)?.endpoints ?? []
27
+ const next = existing.filter(item => !(item.host === host && item.port === port))
28
+ next.unshift({ host, port })
29
+ hints.set(hash, { endpoints: next.slice(0, MAX_ENDPOINTS) })
23
30
  }
24
31
 
25
32
  /**
26
- * 查询未过期的 LAN peer hint
33
+ * 查询未过期的首个 LAN peer hint(最新观测)。
27
34
  * @param {string} nodeHash 节点 64 hex
28
35
  * @param {number} [now=Date.now()] 当前时间(测试可注入)
29
36
  * @returns {{ host: string, port: number } | null} hint 或 null
30
37
  */
31
38
  export function getLanPeerHint(nodeHash, now = Date.now()) {
39
+ return listLanPeerHints(nodeHash, now)[0] ?? null
40
+ }
41
+
42
+ /**
43
+ * 查询未过期的全部 LAN peer hint(最新在前)。
44
+ * @param {string} nodeHash 节点 64 hex
45
+ * @param {number} [now=Date.now()] 当前时间(测试可注入)
46
+ * @returns {{ host: string, port: number }[]} hint 列表
47
+ */
48
+ export function listLanPeerHints(nodeHash, now = Date.now()) {
32
49
  const hash = normalizeHex64(nodeHash)
33
- if (!hash) return null
34
- return hints.get(hash, now)
50
+ if (!hash) return []
51
+ return hints.get(hash, now)?.endpoints ?? []
35
52
  }
36
53
 
37
54
  /**
@@ -9,6 +9,7 @@ import { sha256Hex } from '../crypto/crypto.mjs'
9
9
  import { nodeDebug, shortHash } from '../node/log.mjs'
10
10
 
11
11
  import { ingestEncryptedAdvert } from './adverts.mjs'
12
+ import { noteAdvertPeerHints } from './advert_peer_hints.mjs'
12
13
  import {
13
14
  encryptSignalPacket,
14
15
  groupRendezvousKey,
@@ -28,10 +29,13 @@ export const NOSTR_CONNECT_TIMEOUT_MS = 2_000
28
29
  /** 先 close,超时未 CLOSED 再 terminate(给对端优雅关闭的窗口)。 */
29
30
  export const NOSTR_CLOSE_GRACE_MS = 1_000
30
31
 
31
- /** Nostr advert 事件 kind。 */
32
- export const NOSTR_ADVERT_KIND = 27235
33
- /** Nostr signal 事件 kind。 */
34
- export const NOSTR_SIGNAL_KIND = 27236
32
+ /** relay 等待 EVENT OK 回执超时。 */
33
+ export const NOSTR_PUBLISH_OK_TIMEOUT_MS = 3_000
34
+
35
+ /** Nostr network advert 事件 kind(addressable,可存储)。 */
36
+ export const NOSTR_ADVERT_KIND = 30787
37
+ /** Nostr signal 事件 kind(ephemeral,实时转发)。 */
38
+ export const NOSTR_SIGNAL_KIND = 20787
35
39
 
36
40
  const ADVERT_TTL_MS = 10 * 60_000
37
41
 
@@ -135,6 +139,7 @@ export async function acceptNostrAdvert(rendezvousKey, bytes, options = {}) {
135
139
  }
136
140
  if (firstSeen)
137
141
  nodeDebug('p2p:nostr peer visible', { peer: shortHash(hash), group: !!roomSecret })
142
+ noteAdvertPeerHints(hash, ingested.body, options.meta || {})
138
143
  return hash
139
144
  }
140
145
 
@@ -252,6 +257,70 @@ function connectRelay(relayUrl, timeoutMs = NOSTR_CONNECT_TIMEOUT_MS, signal) {
252
257
  })
253
258
  }
254
259
 
260
+ /**
261
+ * @param {import('ws').WebSocket} ws 已连接 relay
262
+ * @param {string} relayUrl 中继 URL
263
+ * @param {object} event 待发布事件
264
+ * @param {AbortSignal} [signal] 取消信号
265
+ * @returns {Promise<boolean>} relay 是否接受 EVENT
266
+ */
267
+ function publishEventOnRelay(ws, relayUrl, event, signal) {
268
+ return new Promise((resolve, reject) => {
269
+ if (signal?.aborted) {
270
+ reject(new Error('nostr: aborted'))
271
+ return
272
+ }
273
+ let settled = false
274
+ /**
275
+ * @param {boolean} ok relay 是否接受
276
+ * @param {Error | null} [error] 失败原因
277
+ * @returns {void}
278
+ */
279
+ const finish = (ok, error = null) => {
280
+ if (settled) return
281
+ settled = true
282
+ clearTimeout(timer)
283
+ signal?.removeEventListener('abort', onAbort)
284
+ ws.off('message', onMessage)
285
+ if (error) reject(error)
286
+ else resolve(ok)
287
+ }
288
+ /**
289
+ * @returns {void}
290
+ */
291
+ const onAbort = () => finish(false, new Error('nostr: aborted'))
292
+ /**
293
+ * @param {import('ws').RawData} data relay 消息
294
+ * @returns {void}
295
+ */
296
+ const onMessage = data => {
297
+ let parsed
298
+ try { parsed = JSON.parse(String(data)) } catch { return }
299
+ if (parsed?.[0] !== 'OK' || parsed[1] !== event.id) return
300
+ const accepted = parsed[2] === true
301
+ if (!accepted)
302
+ nodeDebug('p2p:nostr publish rejected', {
303
+ url: relayUrl,
304
+ reason: String(parsed[3] || 'rejected'),
305
+ })
306
+ finish(accepted)
307
+ }
308
+ const timer = setTimeout(
309
+ () => finish(false, new Error(`nostr: publish ok timeout for ${relayUrl}`)),
310
+ NOSTR_PUBLISH_OK_TIMEOUT_MS,
311
+ )
312
+ timer.unref()
313
+ signal?.addEventListener('abort', onAbort, { once: true })
314
+ ws.on('message', onMessage)
315
+ try {
316
+ ws.send(JSON.stringify(['EVENT', event]))
317
+ }
318
+ catch (error) {
319
+ finish(false, error instanceof Error ? error : new Error(String(error)))
320
+ }
321
+ })
322
+ }
323
+
255
324
  /**
256
325
  * @param {string[]} relayUrls 中继 URL 列表
257
326
  * @param {object} event 待发布事件
@@ -267,8 +336,8 @@ async function publishEvent(relayUrls, event, signal) {
267
336
  const ws = await connectRelay(relayUrl, NOSTR_CONNECT_TIMEOUT_MS, signal)
268
337
  try {
269
338
  if (signal?.aborted) throw new Error('nostr: aborted')
270
- ws.send(JSON.stringify(['EVENT', event]))
271
- published = true
339
+ const ok = await publishEventOnRelay(ws, relayUrl, event, signal)
340
+ if (ok) published = true
272
341
  }
273
342
  catch (error) {
274
343
  lastError = error
@@ -313,7 +382,7 @@ function connectRelaysProgressive(relayUrls, onOpen, signal, sockets) {
313
382
  * @returns {() => void} 取消订阅
314
383
  */
315
384
  function subscribeNostrKind(relayUrls, options) {
316
- const { kind, rendezvousKey, tagX, onPayload } = options
385
+ const { kind, rendezvousKey, tagX, onPayload, addressable = false } = options
317
386
  const abortController = new AbortController()
318
387
  /** @type {import('ws').WebSocket[]} */
319
388
  const sockets = []
@@ -332,7 +401,9 @@ function subscribeNostrKind(relayUrls, options) {
332
401
  }
333
402
  catch { /* ignore */ }
334
403
  })
335
- ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }]))
404
+ const filter = { kinds: [kind], '#t': [rendezvousKey], '#x': [tagX] }
405
+ if (addressable) filter['#d'] = [rendezvousKey]
406
+ ws.send(JSON.stringify(['REQ', subscriptionId, filter]))
336
407
  }, abortController.signal, sockets)
337
408
  return () => {
338
409
  abortController.abort()
@@ -378,6 +449,7 @@ export function createNostrDiscoveryProvider(options = {}) {
378
449
  kind: NOSTR_ADVERT_KIND,
379
450
  rendezvousKey,
380
451
  tagX: 'advert',
452
+ addressable: true,
381
453
  /**
382
454
  * @param {Uint8Array} bytes 加密 advert 载荷
383
455
  * @returns {Promise<void>}
@@ -400,6 +472,7 @@ export function createNostrDiscoveryProvider(options = {}) {
400
472
  kind: NOSTR_ADVERT_KIND,
401
473
  rendezvousKey,
402
474
  tagX: 'advert',
475
+ addressable: true,
403
476
  /**
404
477
  * @param {Uint8Array} bytes 加密 advert 载荷
405
478
  * @returns {Promise<void>}
@@ -441,6 +514,7 @@ export function createNostrDiscoveryProvider(options = {}) {
441
514
  kind: NOSTR_ADVERT_KIND,
442
515
  rendezvousKey,
443
516
  tagX: 'advert',
517
+ addressable: true,
444
518
  /**
445
519
  * @param {Uint8Array} bytes 加密 advert 载荷
446
520
  * @returns {Promise<void>}
@@ -467,12 +541,11 @@ export function createNostrDiscoveryProvider(options = {}) {
467
541
  if (abortController.signal.aborted) return
468
542
  const beacon = await getBeacon?.()
469
543
  if (!beacon?.nodeHash) return
470
- noteNostrVisibleNode(beacon.nodeHash)
471
544
  const advertBody = beacon.advertBody || beacon.body || beacon
472
545
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
473
546
  const event = await signNostrEvent(
474
547
  NOSTR_ADVERT_KIND,
475
- [['t', rendezvousKey], ['x', 'advert']],
548
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
476
549
  bytesToBase64(bytes),
477
550
  secretKey,
478
551
  )
@@ -547,6 +620,7 @@ export function createNostrDiscoveryProvider(options = {}) {
547
620
  kind: NOSTR_ADVERT_KIND,
548
621
  rendezvousKey,
549
622
  tagX: 'advert',
623
+ addressable: true,
550
624
  /**
551
625
  * @param {Uint8Array} bytes 加密 advert 载荷
552
626
  * @param {object} meta relay 元数据
@@ -573,12 +647,11 @@ export function createNostrDiscoveryProvider(options = {}) {
573
647
  if (abortController.signal.aborted) return
574
648
  const beacon = await getBeacon?.()
575
649
  if (!beacon?.nodeHash) return
576
- noteNostrGroupVisibleNode(key, beacon.nodeHash)
577
650
  const advertBody = beacon.advertBody || beacon.body || beacon
578
651
  const bytes = encryptSignalPacket(rendezvousKey, { type: 'advert', body: advertBody })
579
652
  const event = await signNostrEvent(
580
653
  NOSTR_ADVERT_KIND,
581
- [['t', rendezvousKey], ['x', 'advert']],
654
+ [['t', rendezvousKey], ['x', 'advert'], ['d', rendezvousKey]],
582
655
  bytesToBase64(bytes),
583
656
  secretKey,
584
657
  )
@@ -605,6 +678,7 @@ export function createNostrDiscoveryProvider(options = {}) {
605
678
  kind: NOSTR_ADVERT_KIND,
606
679
  rendezvousKey,
607
680
  tagX: 'advert',
681
+ addressable: true,
608
682
  /**
609
683
  * @param {Uint8Array} bytes 加密 advert 载荷
610
684
  * @param {object} meta relay 元数据
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'
2
2
  import { randomBytes } from 'node:crypto'
3
3
 
4
4
  import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
5
+ import { normalizeLanHosts } from '../discovery/lan_interfaces.mjs'
5
6
  import { normalizeTcpPort } from '../core/tcp_port.mjs'
6
7
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
7
8
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
@@ -139,20 +140,24 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
139
140
  * @param {number} ts 时间戳(毫秒)
140
141
  * @param {string} nodeHash 节点 nodeHash
141
142
  * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
143
+ * @param {unknown} [lanHosts=null] 可选 LAN IPv4 列表(签入消息)
142
144
  * @returns {Uint8Array} 待签名消息字节
143
145
  */
144
- export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null) {
146
+ export function buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort = null, lanHosts = null) {
145
147
  const base = `fount-advert\0${String(rendezvousKey)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
146
148
  const port = normalizeTcpPort(tcpPort)
147
- return Buffer.from(port ? `${base}\0${port}` : base, 'utf8')
149
+ let message = port ? `${base}\0${port}` : base
150
+ const hosts = normalizeLanHosts(lanHosts)
151
+ if (hosts.length) message += `\0${hosts.join(',')}`
152
+ return Buffer.from(message, 'utf8')
148
153
  }
149
154
 
150
155
  /**
151
156
  * 构造带签名的 discovery advert。
152
157
  * @param {string} rendezvousKey discovery 内部汇合键
153
158
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
154
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
155
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
159
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number, lanHosts?: unknown }} | null} [options] 签名身份与可选 tcpPort / lanHosts
160
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number, lanHosts?: string[] }>} 签名 advert
156
161
  */
157
162
  export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options = null) {
158
163
  const seed = options?.secretKey
@@ -166,7 +171,8 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
166
171
  const tcpPort = normalizeTcpPort(options?.tcpPort)
167
172
  if (options?.tcpPort && !tcpPort)
168
173
  throw new Error('p2p: advert tcpPort invalid')
169
- const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort)
174
+ const lanHosts = normalizeLanHosts(options?.lanHosts)
175
+ const message = buildAdvertMessage(rendezvousKey, ts, nodeHash, tcpPort, lanHosts)
170
176
  const sig = await sign(message, secretKey)
171
177
  const advert = {
172
178
  nodeHash,
@@ -175,6 +181,7 @@ export async function buildSignedAdvert(rendezvousKey, ts = Date.now(), options
175
181
  sig: Buffer.from(sig).toString('hex'),
176
182
  }
177
183
  if (tcpPort) advert.tcpPort = tcpPort
184
+ if (lanHosts.length) advert.lanHosts = lanHosts
178
185
  return advert
179
186
  }
180
187
 
@@ -195,7 +202,8 @@ export async function verifySignedAdvert(rendezvousKey, advert, now = Date.now()
195
202
  const hasTcpPortField = !!advert?.tcpPort
196
203
  const tcpPort = normalizeTcpPort(advert?.tcpPort)
197
204
  if (hasTcpPortField && !tcpPort) return null
198
- const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort)
205
+ const lanHosts = normalizeLanHosts(advert?.lanHosts)
206
+ const message = buildAdvertMessage(rendezvousKey, ts, parsedHello.nodeHash, tcpPort, lanHosts)
199
207
  const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
200
208
  return ok ? parsedHello.nodeHash : null
201
209
  }
@@ -3,13 +3,50 @@ import { randomBytes } from 'node:crypto'
3
3
  import net from 'node:net'
4
4
 
5
5
  import { normalizeHex64 } from '../../core/hexIds.mjs'
6
- import { getLanPeerHint } from '../../discovery/lan_peer_hints.mjs'
6
+ import { getLanPeerHint, listLanPeerHints } from '../../discovery/lan_peer_hints.mjs'
7
7
  import { asLinkHandle } from '../pipe.mjs'
8
8
 
9
9
  import { LINK_LEVEL_LAN_TCP } from './levels.mjs'
10
10
  import { buildLinkOpen, createLinkIdBoundPipe, parseLinkOpen } from './link_id_pipe.mjs'
11
11
 
12
12
  const MAX_FRAME_BYTES = 1 << 20
13
+ /** LAN TCP 单 endpoint 连接超时。 */
14
+ const LAN_TCP_CONNECT_TIMEOUT_MS = 3_000
15
+
16
+ /**
17
+ * @param {string} host 目标 host
18
+ * @param {number} port 目标 port
19
+ * @param {number} [timeoutMs=LAN_TCP_CONNECT_TIMEOUT_MS] 超时
20
+ * @returns {Promise<import('node:net').Socket>} 已连接 socket
21
+ */
22
+ function connectTcp(host, port, timeoutMs = LAN_TCP_CONNECT_TIMEOUT_MS) {
23
+ return new Promise((resolve, reject) => {
24
+ const conn = net.createConnection({ host, port })
25
+ const timer = setTimeout(() => {
26
+ conn.destroy()
27
+ reject(new Error(`p2p: lan_tcp connect timeout ${host}:${port}`))
28
+ }, timeoutMs)
29
+ /**
30
+ * @param {Error} error 连接错误
31
+ * @returns {void}
32
+ */
33
+ const onError = error => {
34
+ clearTimeout(timer)
35
+ conn.off('connect', onConnect)
36
+ reject(error)
37
+ }
38
+ /**
39
+ * @returns {void}
40
+ */
41
+ function onConnect() {
42
+ clearTimeout(timer)
43
+ conn.off('error', onError)
44
+ resolve(conn)
45
+ }
46
+ conn.once('error', onError)
47
+ conn.once('connect', onConnect)
48
+ })
49
+ }
13
50
 
14
51
  /**
15
52
  * 在 socket 上挂 length-prefix 编解码(u32be + payload)。
@@ -168,41 +205,33 @@ async function openTcpPipe(options) {
168
205
  */
169
206
  async function dialLanTcp(options) {
170
207
  const remoteNodeHash = normalizeHex64(options.nodeHash)
171
- const hint = getLanPeerHint(remoteNodeHash)
172
- if (!hint) throw new Error('p2p: lan_tcp no peer hint')
208
+ const hints = listLanPeerHints(remoteNodeHash)
209
+ if (!hints.length) throw new Error('p2p: lan_tcp no peer hint')
173
210
 
174
- const socket = await new Promise((resolve, reject) => {
175
- const conn = net.createConnection({ host: hint.host, port: hint.port })
176
- /**
177
- * @param {Error} error 连接错误
178
- * @returns {void}
179
- */
180
- const onError = error => {
181
- conn.off('connect', onConnect)
182
- reject(error)
211
+ let lastError = null
212
+ for (const hint of hints) {
213
+ /** @type {import('node:net').Socket | null} */
214
+ let socket = null
215
+ try {
216
+ socket = await connectTcp(hint.host, hint.port)
217
+ const link = await openTcpPipe({
218
+ initiator: true,
219
+ linkId: randomBytes(32).toString('hex'),
220
+ nodeHash: remoteNodeHash,
221
+ localIdentity: options.localIdentity,
222
+ socket,
223
+ host: hint.host,
224
+ port: hint.port,
225
+ })
226
+ await link.ready
227
+ return link
183
228
  }
184
- /**
185
- * @returns {void}
186
- */
187
- function onConnect() {
188
- conn.off('error', onError)
189
- resolve(conn)
229
+ catch (error) {
230
+ lastError = error
231
+ try { socket?.destroy() } catch { /* ignore */ }
190
232
  }
191
- conn.once('error', onError)
192
- conn.once('connect', onConnect)
193
- })
194
-
195
- const link = await openTcpPipe({
196
- initiator: true,
197
- linkId: randomBytes(32).toString('hex'),
198
- nodeHash: remoteNodeHash,
199
- localIdentity: options.localIdentity,
200
- socket,
201
- host: hint.host,
202
- port: hint.port,
203
- })
204
- await link.ready
205
- return link
233
+ }
234
+ throw lastError || new Error('p2p: lan_tcp dial failed')
206
235
  }
207
236
 
208
237
  /**
@@ -1,4 +1,5 @@
1
1
  import { getSignalingRuntimeConfig } from '../../node/instance.mjs'
2
+ import { nodeDebug } from '../../node/log.mjs'
2
3
  import { ms } from '../../utils/duration.mjs'
3
4
  import { createLruMap } from '../../utils/lru.mjs'
4
5
  import {
@@ -43,8 +44,9 @@ export async function canUseWebRtcLink() {
43
44
  await loadNodeRtcPolyfill()
44
45
  cachedAvailable = true
45
46
  }
46
- catch {
47
+ catch (error) {
47
48
  cachedAvailable = false
49
+ nodeDebug('p2p:webrtc unavailable', { err: formatErrorReason(error) })
48
50
  }
49
51
  return cachedAvailable
50
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",