@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.
- package/discovery/advert_peer_hints.mjs +8 -2
- package/discovery/adverts.mjs +6 -0
- package/discovery/lan.mjs +64 -13
- package/discovery/lan_interfaces.mjs +78 -0
- package/discovery/lan_peer_hints.mjs +22 -5
- package/discovery/nostr.mjs +86 -12
- package/link/handshake.mjs +14 -6
- package/link/providers/lan_tcp.mjs +62 -33
- package/link/providers/webrtc.mjs +3 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
25
|
+
if (address)
|
|
20
26
|
noteLanPeerHint(verifiedNodeHash, { host: address, port: tcpPort })
|
|
21
27
|
}
|
package/discovery/adverts.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
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, {
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
34
|
-
return hints.get(hash, now)
|
|
50
|
+
if (!hash) return []
|
|
51
|
+
return hints.get(hash, now)?.endpoints ?? []
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
/**
|
package/discovery/nostr.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
32
|
-
export const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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 元数据
|
package/link/handshake.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
172
|
-
if (!
|
|
208
|
+
const hints = listLanPeerHints(remoteNodeHash)
|
|
209
|
+
if (!hints.length) throw new Error('p2p: lan_tcp no peer hint')
|
|
173
210
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
}
|