@steve02081504/fount-p2p 0.0.6 → 0.0.7

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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 规范化 TCP 端口(advert / peer hint 共用)。
3
+ * @param {unknown} port 原始端口
4
+ * @returns {number | null} 有效端口或 null(未提供 / 非法)
5
+ */
6
+ export function normalizeTcpPort(port) {
7
+ if (port == null || port === '') return null
8
+ const value = Number(port)
9
+ if (!Number.isInteger(value) || value < 1 || value > 65535) return null
10
+ return value
11
+ }
@@ -0,0 +1,21 @@
1
+ import { normalizeTcpPort } from '../core/tcp_port.mjs'
2
+
3
+ import { noteBtPeerHint } from './bt/peer_hints.mjs'
4
+ import { noteLanPeerHint } from './lan_peer_hints.mjs'
5
+
6
+ /**
7
+ * 从已验证的 discovery advert + provider meta 写入 LAN/BT peer hints。
8
+ * 任意 discovery 路径(node / group / scoped topic)收到 advert 时都应调用。
9
+ * @param {string} verifiedNodeHash 验签通过的 nodeHash
10
+ * @param {{ tcpPort?: unknown } | null | undefined} body advert body
11
+ * @param {{ address?: unknown, peripheralId?: unknown } | null | undefined} meta discovery provider meta
12
+ * @returns {void}
13
+ */
14
+ export function noteAdvertPeerHints(verifiedNodeHash, body, meta) {
15
+ if (meta?.peripheralId)
16
+ noteBtPeerHint(verifiedNodeHash, meta.peripheralId)
17
+ const tcpPort = normalizeTcpPort(body?.tcpPort)
18
+ const address = String(meta?.address || '').trim()
19
+ if (tcpPort != null && address)
20
+ noteLanPeerHint(verifiedNodeHash, { host: address, port: tcpPort })
21
+ }
@@ -1,56 +1,19 @@
1
1
  import { Buffer } from 'node:buffer'
2
- import process from 'node:process'
2
+
3
+ import { getBtPeerHint } from './peer_hints.mjs'
4
+ import { loadBleno, loadNoble, resolveBtRole, waitPoweredOn } from './runtime.mjs'
5
+
6
+ /** 重导出 waitPoweredOn,供 discovery 调用方使用。 */
7
+ export { waitPoweredOn } from './runtime.mjs'
3
8
 
4
9
  const BT_SERVICE_UUID = 'f017f017f017f017f017f017f017f017'
5
10
  const BT_CHARACTERISTIC_UUID = 'f017f017f017f017f017f017f017f018'
11
+ const BT_SIGNAL_CHAR_UUID = 'f017f017f017f017f017f017f017f01b'
6
12
  const BT_DEVICE_NAME = 'fount-bt'
7
13
  const MAX_ADVERT_BLOB_BYTES = 12 * 1024
14
+ const MAX_SIGNAL_BLOB_BYTES = 8 * 1024
8
15
  const PERIPHERAL_RESCAN_MS = 15_000
9
16
 
10
- /**
11
- * 解析 Bluetooth 发现角色(scan / dual)。
12
- * @returns {'scan' | 'dual'} 当前平台或环境变量指定的角色
13
- */
14
- function resolveBtRole() {
15
- const override = String(process.env.FOUNT_BT_DISCOVERY_ROLE || '').trim().toLowerCase()
16
- if (override === 'dual') return 'dual'
17
- if (override === 'scan') return 'scan'
18
- return process.platform === 'win32' ? 'scan' : 'dual'
19
- }
20
-
21
- /**
22
- * 加载 Noble BLE central 库。
23
- * @returns {Promise<any>} Noble 运行时实例
24
- */
25
- async function loadNoble() {
26
- const mod = await import('@stoprocent/noble')
27
- if (typeof mod.withBindings === 'function') return mod.withBindings('default')
28
- return mod.default ?? mod
29
- }
30
-
31
- /**
32
- * 加载 Bleno BLE peripheral 库。
33
- * @returns {Promise<any>} Bleno 运行时实例
34
- */
35
- async function loadBleno() {
36
- const mod = await import('@stoprocent/bleno')
37
- if (typeof mod.withBindings === 'function') return mod.withBindings('default')
38
- return mod.default ?? mod
39
- }
40
-
41
- /**
42
- * 等待 BLE 运行时进入 poweredOn(兼容 noble v1/v2 与 bleno)。
43
- * @param {*} runtime noble 或 bleno 实例
44
- * @param {number} [timeout] 超时毫秒
45
- * @returns {Promise<void>}
46
- */
47
- export async function waitPoweredOn(runtime, timeout) {
48
- const wait = runtime.waitForPoweredOnAsync ?? runtime.waitForPoweredOn
49
- if (typeof wait !== 'function')
50
- throw new Error('p2p: bluetooth runtime missing waitForPoweredOn(Async)')
51
- return wait.call(runtime, timeout)
52
- }
53
-
54
17
  /**
55
18
  * 探测本机 noble 运行时是否具备 BT 扫描所需 API。
56
19
  * @returns {Promise<boolean>} noble 可加载且具备 startScanningAsync 与 waitForPoweredOn(Async) 时为 true
@@ -125,6 +88,7 @@ function addListener(bucket, topic, listener) {
125
88
  * - 默认在 Windows 上只启用 scan 侧发现(单适配器 central+peripheral 常冲突)
126
89
  * - 其他平台默认 dual:advertise + scan
127
90
  * - 通过固定 BLE service + read characteristic 传输完整 advert 列表,避免 31-byte 广告包限制
91
+ * - dual 下额外暴露 write characteristic 传短信令;central 可按 peer hint 写信令
128
92
  *
129
93
  * @returns {import('./index.mjs').DiscoveryProvider} Bluetooth 发现提供者
130
94
  */
@@ -134,6 +98,8 @@ export function createBluetoothDiscoveryProvider() {
134
98
  const adverts = new Map()
135
99
  /** @type {Map<string, Set<Function>>} */
136
100
  const advertListeners = new Map()
101
+ /** @type {Map<string, Set<Function>>} */
102
+ const signalListeners = new Map()
137
103
  /** @type {Map<string, number>} */
138
104
  const inspectedAt = new Map()
139
105
  let nobleRuntime = null
@@ -149,17 +115,17 @@ export function createBluetoothDiscoveryProvider() {
149
115
  if (role === 'scan') return null
150
116
  if (blenoRuntime) return blenoRuntime
151
117
  const bleno = await loadBleno()
152
- const characteristic = new bleno.Characteristic({
118
+ const advertCharacteristic = new bleno.Characteristic({
153
119
  uuid: BT_CHARACTERISTIC_UUID,
154
120
  properties: ['read'],
155
121
  /**
156
- * BLE characteristic 读请求回调。
157
- * @param {*} _handle Bleno handle(未使用)
158
- * @param {number} offset 读取偏移
159
- * @param {Function} callback Bleno 结果回调
122
+ * stoprocent/bleno onReadRequest(connection, offset, callback)
123
+ * @param {*} _connection 连接句柄
124
+ * @param {number} offset 偏移
125
+ * @param {Function} callback 结果
160
126
  * @returns {void}
161
127
  */
162
- onReadRequest(_handle, offset, callback) {
128
+ onReadRequest(_connection, offset, callback) {
163
129
  try {
164
130
  const blob = serializeAdvertBlob(adverts)
165
131
  if (offset > blob.length) {
@@ -173,11 +139,39 @@ export function createBluetoothDiscoveryProvider() {
173
139
  }
174
140
  },
175
141
  })
142
+ const signalCharacteristic = new bleno.Characteristic({
143
+ uuid: BT_SIGNAL_CHAR_UUID,
144
+ properties: ['write', 'writeWithoutResponse'],
145
+ /**
146
+ * stoprocent/bleno onWriteRequest(connection, data, offset, withoutResponse, callback)
147
+ * @param {*} _connection 连接句柄
148
+ * @param {Buffer} data 信令 blob
149
+ * @param {number} _offset 偏移
150
+ * @param {boolean} _withoutResponse 无响应写
151
+ * @param {Function} callback 结果
152
+ * @returns {void}
153
+ */
154
+ onWriteRequest(_connection, data, _offset, _withoutResponse, callback) {
155
+ try {
156
+ const parsed = JSON.parse(Buffer.from(data).toString('utf8'))
157
+ const topic = String(parsed?.topic || '')
158
+ const bytes = Uint8Array.from(Buffer.from(String(parsed?.data || ''), 'base64'))
159
+ if (topic && bytes.byteLength)
160
+ for (const listener of signalListeners.get(topic) || [])
161
+ listener(bytes, { provider: 'bt' })
162
+
163
+ callback(bleno.Characteristic.RESULT_SUCCESS)
164
+ }
165
+ catch {
166
+ callback(bleno.Characteristic.RESULT_UNLIKELY_ERROR)
167
+ }
168
+ },
169
+ })
176
170
  await waitPoweredOn(bleno, 5_000)
177
171
  await bleno.setServicesAsync([
178
172
  new bleno.PrimaryService({
179
173
  uuid: BT_SERVICE_UUID,
180
- characteristics: [characteristic],
174
+ characteristics: [advertCharacteristic, signalCharacteristic],
181
175
  }),
182
176
  ])
183
177
  blenoRuntime = bleno
@@ -192,14 +186,14 @@ export function createBluetoothDiscoveryProvider() {
192
186
  if (role === 'scan') return
193
187
  const bleno = await ensurePeripheralRuntime()
194
188
  if (!bleno) return
195
- if (!adverts.size) {
189
+ if (!adverts.size && !signalListeners.size) {
196
190
  if (advertisingStarted) {
197
191
  await bleno.stopAdvertisingAsync().catch(() => { })
198
192
  advertisingStarted = false
199
193
  }
200
194
  return
201
195
  }
202
- serializeAdvertBlob(adverts)
196
+ if (adverts.size) serializeAdvertBlob(adverts)
203
197
  if (!advertisingStarted) {
204
198
  await bleno.startAdvertisingAsync(BT_DEVICE_NAME, [BT_SERVICE_UUID])
205
199
  advertisingStarted = true
@@ -256,10 +250,86 @@ export function createBluetoothDiscoveryProvider() {
256
250
  scanningStarted = true
257
251
  }
258
252
 
253
+ /**
254
+ * Central:按 peer hint 连接并对端 signal characteristic 写短包。
255
+ * @param {string} topic 信令 topic
256
+ * @param {string} to 目标 nodeHash
257
+ * @param {Uint8Array} bytes 载荷
258
+ * @returns {Promise<void>}
259
+ */
260
+ async function sendSignalViaGatt(topic, to, bytes) {
261
+ const hint = getBtPeerHint(to)
262
+ if (!hint) throw new Error('p2p: bt signal missing peer hint')
263
+ const blob = Buffer.from(JSON.stringify({
264
+ topic: String(topic),
265
+ to: String(to),
266
+ data: Buffer.from(bytes).toString('base64'),
267
+ }), 'utf8')
268
+ if (blob.byteLength > MAX_SIGNAL_BLOB_BYTES)
269
+ throw new Error(`p2p: bt signal blob exceeds ${MAX_SIGNAL_BLOB_BYTES} bytes`)
270
+ await ensureScanRuntime()
271
+ const noble = nobleRuntime
272
+ const wantId = hint.peripheralId
273
+ /**
274
+ * 先查 noble 已缓存的 peripheral,避免只等下一次 advertise 漏报。
275
+ * @returns {*|null} 已缓存的 peripheral,未命中为 null
276
+ */
277
+ function cachedPeripheral() {
278
+ const table = noble?._peripherals
279
+ if (!table || typeof table !== 'object') return null
280
+ for (const found of Object.values(table)) {
281
+ const id = String(found?.id || found?.address || '')
282
+ if (id === wantId) return found
283
+ }
284
+ return null
285
+ }
286
+ const peripheral = cachedPeripheral() ?? await new Promise((resolve, reject) => {
287
+ const deadline = setTimeout(() => {
288
+ cleanup()
289
+ reject(new Error('p2p: bt signal peripheral timeout'))
290
+ }, 8_000)
291
+ /**
292
+ * @returns {void}
293
+ */
294
+ function cleanup() {
295
+ clearTimeout(deadline)
296
+ noble.removeListener('discover', onDiscover)
297
+ }
298
+ /**
299
+ * @param {*} found peripheral
300
+ * @returns {void}
301
+ */
302
+ function onDiscover(found) {
303
+ const id = String(found?.id || found?.address || '')
304
+ if (id !== wantId) return
305
+ cleanup()
306
+ resolve(found)
307
+ }
308
+ noble.on('discover', onDiscover)
309
+ const again = cachedPeripheral()
310
+ if (again) {
311
+ cleanup()
312
+ resolve(again)
313
+ }
314
+ })
315
+ try {
316
+ await peripheral.connectAsync()
317
+ const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
318
+ [BT_SERVICE_UUID],
319
+ [BT_SIGNAL_CHAR_UUID],
320
+ )
321
+ if (!characteristics?.length) throw new Error('p2p: bt signal characteristic missing')
322
+ await characteristics[0].writeAsync(blob, false)
323
+ }
324
+ finally {
325
+ try { await peripheral.disconnectAsync() } catch { /* ignore */ }
326
+ }
327
+ }
328
+
259
329
  return {
260
330
  id: 'bt',
261
331
  priority: 20,
262
- caps: { canDiscover: true, canSignal: false, canRelay: false },
332
+ caps: { canDiscover: true, canSignal: true, canRelay: false },
263
333
  /**
264
334
  * 广播指定 topic 的 advert。
265
335
  * @param {string} topic advert 主题
@@ -285,5 +355,31 @@ export function createBluetoothDiscoveryProvider() {
285
355
  await ensureScanRuntime()
286
356
  return addListener(advertListeners, String(topic), onAdvert)
287
357
  },
358
+ /**
359
+ * 经 GATT 向近场 peer 发送信令。
360
+ * @param {string} topic 信令 topic
361
+ * @param {string} to 目标 nodeHash
362
+ * @param {Uint8Array} bytes 载荷
363
+ * @returns {Promise<void>}
364
+ */
365
+ async sendSignal(topic, to, bytes) {
366
+ await sendSignalViaGatt(topic, to, bytes)
367
+ },
368
+ /**
369
+ * 监听经本机 peripheral signal characteristic 写入的信令。
370
+ * @param {string} topic 信令 topic
371
+ * @param {Function} onSignal 回调
372
+ * @returns {Promise<() => void>} 取消订阅
373
+ */
374
+ async onSignal(topic, onSignal) {
375
+ if (role === 'scan') return () => { }
376
+ await ensurePeripheralRuntime()
377
+ await refreshAdvertising()
378
+ const stop = addListener(signalListeners, String(topic), onSignal)
379
+ return () => {
380
+ stop()
381
+ void refreshAdvertising().catch(() => { })
382
+ }
383
+ },
288
384
  }
289
385
  }
@@ -0,0 +1,41 @@
1
+ import { normalizeHex64 } from '../../core/hexIds.mjs'
2
+ import { createTtlMap } from '../../utils/ttl_map.mjs'
3
+
4
+ /** BT peer hint 存活时间。 */
5
+ export const BT_PEER_HINT_TTL_MS = 5 * 60_000
6
+
7
+ /** @type {ReturnType<typeof createTtlMap<{ peripheralId: string }>>} */
8
+ const hints = createTtlMap(BT_PEER_HINT_TTL_MS)
9
+
10
+ /**
11
+ * 记录近场 BT 扫描到的 nodeHash → peripheral 映射。
12
+ * @param {string} nodeHash 节点 64 hex
13
+ * @param {string} peripheralId noble peripheral id / address
14
+ * @returns {void}
15
+ */
16
+ export function noteBtPeerHint(nodeHash, peripheralId) {
17
+ const hash = normalizeHex64(nodeHash)
18
+ const id = String(peripheralId || '').trim()
19
+ if (!hash || !id) return
20
+ hints.set(hash, { peripheralId: id })
21
+ }
22
+
23
+ /**
24
+ * 查询未过期的 BT peer hint。
25
+ * @param {string} nodeHash 节点 64 hex
26
+ * @param {number} [now=Date.now()] 当前时间(测试可注入)
27
+ * @returns {{ peripheralId: string } | null} hint 或 null
28
+ */
29
+ export function getBtPeerHint(nodeHash, now = Date.now()) {
30
+ const hash = normalizeHex64(nodeHash)
31
+ if (!hash) return null
32
+ return hints.get(hash, now)
33
+ }
34
+
35
+ /**
36
+ * 清空全部 BT peer hints(测试用)。
37
+ * @returns {void}
38
+ */
39
+ export function clearBtPeerHints() {
40
+ hints.clear()
41
+ }
@@ -0,0 +1,46 @@
1
+ import process from 'node:process'
2
+
3
+ /**
4
+ * 解析 Bluetooth 角色(scan / dual)。
5
+ * Win32 默认 scan(单适配器 central+peripheral 常冲突);可用 FOUNT_BT_DISCOVERY_ROLE 覆盖。
6
+ * @returns {'scan' | 'dual'} 生效角色
7
+ */
8
+ export function resolveBtRole() {
9
+ const override = String(process.env.FOUNT_BT_DISCOVERY_ROLE || '').trim().toLowerCase()
10
+ if (override === 'dual') return 'dual'
11
+ if (override === 'scan') return 'scan'
12
+ return process.platform === 'win32' ? 'scan' : 'dual'
13
+ }
14
+
15
+ /**
16
+ * 加载 Noble BLE central。
17
+ * @returns {Promise<any>} noble 运行时
18
+ */
19
+ export async function loadNoble() {
20
+ const mod = await import('@stoprocent/noble')
21
+ if (typeof mod.withBindings === 'function') return mod.withBindings('default')
22
+ return mod.default ?? mod
23
+ }
24
+
25
+ /**
26
+ * 加载 Bleno BLE peripheral。
27
+ * @returns {Promise<any>} bleno 运行时
28
+ */
29
+ export async function loadBleno() {
30
+ const mod = await import('@stoprocent/bleno')
31
+ if (typeof mod.withBindings === 'function') return mod.withBindings('default')
32
+ return mod.default ?? mod
33
+ }
34
+
35
+ /**
36
+ * 等待 BLE 运行时 poweredOn(兼容 noble/bleno v1/v2)。
37
+ * @param {*} runtime noble 或 bleno
38
+ * @param {number} [timeout] 超时毫秒
39
+ * @returns {Promise<void>}
40
+ */
41
+ export async function waitPoweredOn(runtime, timeout) {
42
+ const wait = runtime.waitForPoweredOnAsync ?? runtime.waitForPoweredOn
43
+ if (typeof wait !== 'function')
44
+ throw new Error('p2p: bluetooth runtime missing waitForPoweredOn(Async)')
45
+ return wait.call(runtime, timeout)
46
+ }
@@ -0,0 +1,43 @@
1
+ import { normalizeHex64 } from '../core/hexIds.mjs'
2
+ import { normalizeTcpPort } from '../core/tcp_port.mjs'
3
+ import { createTtlMap } from '../utils/ttl_map.mjs'
4
+
5
+ /** LAN peer hint 存活时间。 */
6
+ export const LAN_PEER_HINT_TTL_MS = 5 * 60_000
7
+
8
+ /** @type {ReturnType<typeof createTtlMap<{ host: string, port: number }>>} */
9
+ const hints = createTtlMap(LAN_PEER_HINT_TTL_MS)
10
+
11
+ /**
12
+ * 记录 LAN 上观察到的 nodeHash → host:port。
13
+ * @param {string} nodeHash 节点 64 hex
14
+ * @param {{ host: string, port: number }} endpoint 端点
15
+ * @returns {void}
16
+ */
17
+ export function noteLanPeerHint(nodeHash, endpoint) {
18
+ const hash = normalizeHex64(nodeHash)
19
+ const host = String(endpoint?.host || '').trim()
20
+ const port = normalizeTcpPort(endpoint?.port)
21
+ if (!hash || !host || !port) return
22
+ hints.set(hash, { host, port })
23
+ }
24
+
25
+ /**
26
+ * 查询未过期的 LAN peer hint。
27
+ * @param {string} nodeHash 节点 64 hex
28
+ * @param {number} [now=Date.now()] 当前时间(测试可注入)
29
+ * @returns {{ host: string, port: number } | null} hint 或 null
30
+ */
31
+ export function getLanPeerHint(nodeHash, now = Date.now()) {
32
+ const hash = normalizeHex64(nodeHash)
33
+ if (!hash) return null
34
+ return hints.get(hash, now)
35
+ }
36
+
37
+ /**
38
+ * 清空全部 LAN peer hints(测试用)。
39
+ * @returns {void}
40
+ */
41
+ export function clearLanPeerHints() {
42
+ hints.clear()
43
+ }
@@ -38,7 +38,7 @@ export function createMdnsDiscoveryProvider(options = {}) {
38
38
  resolve()
39
39
  })
40
40
  })
41
- socket.on('message', raw => {
41
+ socket.on('message', (raw, rinfo) => {
42
42
  let packet
43
43
  try { packet = JSON.parse(String(raw)) } catch { return }
44
44
  const listeners = packet.type === 'advert'
@@ -46,8 +46,12 @@ export function createMdnsDiscoveryProvider(options = {}) {
46
46
  : signalListeners.get(String(packet.topic || ''))
47
47
  if (!listeners?.size) return
48
48
  const bytes = Uint8Array.from(Buffer.from(String(packet.data || ''), 'base64'))
49
+ const meta = {
50
+ provider: 'mdns',
51
+ address: String(rinfo?.address || ''),
52
+ }
49
53
  for (const listener of listeners)
50
- listener(bytes, { provider: 'mdns' })
54
+ listener(bytes, meta)
51
55
  })
52
56
  bound = true
53
57
  })()
package/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Federation P2P 门面:通用引导与房间/发现入口。
3
- * 重型子系统请直接从子路径导入(如 `./dag/index.mjs`)。
2
+ * Federation P2P 门面:fount 网络引导与房间/发现入口。
3
+ * 上层只面对 nodeHash + envelope,不选择 WebRTC/BLE 等传输。
4
+ * 重型子系统请从子路径导入(如 `./dag`);勿导入未导出的 `link/`。
4
5
  */
5
6
  import { registerDiscoveryProvider } from './discovery/index.mjs'
6
7
  import { ensureNodeDefaults, getNodeHash } from './node/identity.mjs'
@@ -2,34 +2,50 @@ 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 { normalizeTcpPort } from '../core/tcp_port.mjs'
5
6
  import { keyPairFromSeed, pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
6
7
  import { ensureNodeSeed, getNodeHash } from '../node/identity.mjs'
7
8
 
8
9
  import { normalizeDtlsFingerprint } from './sdp_fingerprint.mjs'
9
10
 
11
+ /** advert / peer-hint 端口规范化(re-export,调用方可直接从 core/tcp_port 导入) */
12
+ export const normalizeAdvertTcpPort = normalizeTcpPort
13
+
10
14
  /**
11
15
  * Link 握手签名域标识符。
12
16
  */
13
17
  export const LINK_HANDSHAKE_DOMAIN = 'fount-link'
14
18
 
19
+ /**
20
+ * 规范化链路绑定材料:DTLS fingerprint 或 64-hex linkId。
21
+ * @param {unknown} value 原始 binding
22
+ * @returns {string | null} 规范化 binding,无效时 null
23
+ */
24
+ export function normalizeLinkBinding(value) {
25
+ const dtls = normalizeDtlsFingerprint(value)
26
+ if (dtls) return dtls
27
+ const hex = normalizeHex64(value)
28
+ return isHex64(hex) ? hex : null
29
+ }
30
+
15
31
  /**
16
32
  * 构造 link auth 待签名字节串。
17
33
  * @param {string} peerNonce 对端 hello 中的 nonce(64 位 hex)
18
- * @param {string} localFingerprint 本地 DTLS fingerprint
34
+ * @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
19
35
  * @param {string} localNodeHash 本地节点 nodeHash(64 位 hex)
20
36
  * @returns {Uint8Array} 待签名消息字节
21
37
  */
22
- export function buildAuthMessage(peerNonce, localFingerprint, localNodeHash) {
38
+ export function buildAuthMessage(peerNonce, localBinding, localNodeHash) {
23
39
  const nonce = normalizeHex64(peerNonce)
24
- const fingerprint = normalizeDtlsFingerprint(localFingerprint)
40
+ const binding = normalizeLinkBinding(localBinding)
25
41
  const nodeHash = normalizeHex64(localNodeHash)
26
42
  if (!/^[\da-f]{64}$/u.test(nonce))
27
43
  throw new Error('p2p: auth nonce must be 64 hex characters')
28
- if (!fingerprint)
29
- throw new Error('p2p: DTLS fingerprint missing or invalid')
44
+ if (!binding)
45
+ throw new Error('p2p: link binding missing or invalid')
30
46
  if (!isHex64(nodeHash))
31
47
  throw new Error('p2p: nodeHash must be 64 hex characters')
32
- return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${fingerprint}\0${nodeHash}`, 'utf8')
48
+ return Buffer.from(`${LINK_HANDSHAKE_DOMAIN}\0${nonce}\0${binding}\0${nodeHash}`, 'utf8')
33
49
  }
34
50
 
35
51
  /**
@@ -56,11 +72,11 @@ export function buildHello(options = {}) {
56
72
  /**
57
73
  * 对 link auth 消息签名。
58
74
  * @param {string} peerNonce 对端 hello 中的 nonce
59
- * @param {string} localFingerprint 本地 DTLS fingerprint
75
+ * @param {string} localBinding 本地绑定材料(DTLS fingerprint 或 linkId)
60
76
  * @param {{ secretKey?: Uint8Array, nodeHash?: string }} [options] 签名密钥与 nodeHash 覆盖
61
77
  * @returns {Promise<{ sig: string }>} hex 签名
62
78
  */
63
- export async function buildAuth(peerNonce, localFingerprint, options = {}) {
79
+ export async function buildAuth(peerNonce, localBinding, options = {}) {
64
80
  const seed = options.secretKey
65
81
  ? Buffer.from(options.secretKey)
66
82
  : Buffer.from(ensureNodeSeed(), 'hex')
@@ -68,7 +84,7 @@ export async function buildAuth(peerNonce, localFingerprint, options = {}) {
68
84
  const nodeHash = normalizeHex64(options.nodeHash || pubKeyHash(publicKey))
69
85
  if (nodeHash !== pubKeyHash(publicKey))
70
86
  throw new Error('p2p: auth nodeHash does not match secretKey')
71
- const message = buildAuthMessage(peerNonce, localFingerprint, nodeHash)
87
+ const message = buildAuthMessage(peerNonce, localBinding, nodeHash)
72
88
  const signature = await sign(message, secretKey)
73
89
  return { sig: Buffer.from(signature).toString('hex') }
74
90
  }
@@ -98,18 +114,18 @@ export function parseHello(hello) {
98
114
  * @param {unknown} hello 对端 hello
99
115
  * @param {unknown} auth 对端 auth(含 sig)
100
116
  * @param {string} expectedNonce 本地 hello 发出的 nonce
101
- * @param {string} remoteFingerprintFromSdp SDP 提取的远端 DTLS fingerprint
117
+ * @param {string} remoteBinding 对端绑定材料(DTLS fingerprint linkId)
102
118
  * @returns {Promise<string | null>} 验证通过的 nodeHash,失败返回 null
103
119
  */
104
- export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFromSdp) {
120
+ export async function verifyAuth(hello, auth, expectedNonce, remoteBinding) {
105
121
  const parsedHello = parseHello(hello)
106
122
  if (!parsedHello) return null
107
123
  const signatureHex = String(auth?.sig ?? '').trim().toLowerCase()
108
- const fingerprint = normalizeDtlsFingerprint(remoteFingerprintFromSdp)
109
- if (!/^[\da-f]{128}$/u.test(signatureHex) || !fingerprint) return null
124
+ const binding = normalizeLinkBinding(remoteBinding)
125
+ if (!/^[\da-f]{128}$/u.test(signatureHex) || !binding) return null
110
126
  const normalizedNonce = normalizeHex64(expectedNonce)
111
127
  if (!isHex64(normalizedNonce)) return null
112
- const message = buildAuthMessage(normalizedNonce, fingerprint, parsedHello.nodeHash)
128
+ const message = buildAuthMessage(normalizedNonce, binding, parsedHello.nodeHash)
113
129
  const ok = await verify(
114
130
  Buffer.from(signatureHex, 'hex'),
115
131
  message,
@@ -123,18 +139,21 @@ export async function verifyAuth(hello, auth, expectedNonce, remoteFingerprintFr
123
139
  * @param {string} topic 广播主题
124
140
  * @param {number} ts 时间戳(毫秒)
125
141
  * @param {string} nodeHash 节点 nodeHash
142
+ * @param {number | null} [tcpPort=null] 可选 LAN TCP 监听端口(签入消息)
126
143
  * @returns {Uint8Array} 待签名消息字节
127
144
  */
128
- export function buildAdvertMessage(topic, ts, nodeHash) {
129
- return Buffer.from(`fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`, 'utf8')
145
+ export function buildAdvertMessage(topic, ts, nodeHash, tcpPort = null) {
146
+ const base = `fount-advert\0${String(topic)}\0${String(ts)}\0${normalizeHex64(nodeHash)}`
147
+ const port = normalizeTcpPort(tcpPort)
148
+ return Buffer.from(port == null ? base : `${base}\0${port}`, 'utf8')
130
149
  }
131
150
 
132
151
  /**
133
152
  * 构造带签名的 discovery advert。
134
153
  * @param {string} topic 广播主题
135
154
  * @param {number} [ts=Date.now()] 时间戳(毫秒)
136
- * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string } | null} [options] 签名身份,省略则用本地节点
137
- * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string }>} 签名 advert
155
+ * @param {{ secretKey?: Uint8Array, nodeHash?: string, nodePubKey?: string, tcpPort?: number } | null} [options] 签名身份与可选 tcpPort
156
+ * @returns {Promise<{ nodeHash: string, nodePubKey: string, ts: number, sig: string, tcpPort?: number }>} 签名 advert
138
157
  */
139
158
  export async function buildSignedAdvert(topic, ts = Date.now(), options = null) {
140
159
  const seed = options?.secretKey
@@ -145,14 +164,19 @@ export async function buildSignedAdvert(topic, ts = Date.now(), options = null)
145
164
  const nodePubKey = normalizeHex64(options?.nodePubKey || Buffer.from(publicKey).toString('hex'))
146
165
  if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash)
147
166
  throw new Error('p2p: advert nodePubKey does not match nodeHash')
148
- const message = buildAdvertMessage(topic, ts, nodeHash)
167
+ const tcpPort = normalizeTcpPort(options?.tcpPort)
168
+ if (options?.tcpPort != null && options.tcpPort !== '' && tcpPort == null)
169
+ throw new Error('p2p: advert tcpPort invalid')
170
+ const message = buildAdvertMessage(topic, ts, nodeHash, tcpPort)
149
171
  const sig = await sign(message, secretKey)
150
- return {
172
+ const advert = {
151
173
  nodeHash,
152
174
  nodePubKey,
153
175
  ts,
154
176
  sig: Buffer.from(sig).toString('hex'),
155
177
  }
178
+ if (tcpPort != null) advert.tcpPort = tcpPort
179
+ return advert
156
180
  }
157
181
 
158
182
  /**
@@ -169,7 +193,10 @@ export async function verifySignedAdvert(topic, advert, now = Date.now(), maxSke
169
193
  const ts = Number(advert?.ts)
170
194
  const sig = String(advert?.sig ?? '').trim().toLowerCase()
171
195
  if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkewMs || !/^[\da-f]{128}$/u.test(sig)) return null
172
- const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash)
196
+ const hasTcpPortField = advert?.tcpPort != null && advert.tcpPort !== ''
197
+ const tcpPort = normalizeTcpPort(advert?.tcpPort)
198
+ if (hasTcpPortField && tcpPort == null) return null
199
+ const message = buildAdvertMessage(topic, ts, parsedHello.nodeHash, tcpPort)
173
200
  const ok = await verify(Buffer.from(sig, 'hex'), message, Buffer.from(parsedHello.nodePubKey, 'hex'))
174
201
  return ok ? parsedHello.nodeHash : null
175
202
  }