@steve02081504/fount-p2p 0.0.46 → 0.0.48
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/AGENTS.md +13 -1
- package/discovery/nostr/constants.mjs +1 -3
- package/discovery/nostr/index.mjs +23 -11
- package/discovery/nostr/session.mjs +96 -12
- package/docs/signaling.md +14 -3
- package/docs/transports.md +15 -1
- package/index.mjs +5 -0
- package/link/providers/ble_gatt.mjs +2 -2
- package/link/providers/index.mjs +2 -1
- package/link/providers/nostr/index.mjs +19 -6
- package/link/providers/webrtc.mjs +235 -56
- package/link/rtc/polyfill.mjs +9 -6
- package/node/signaling_config.mjs +30 -8
- package/node/verification.mjs +163 -0
- package/package.json +4 -2
- package/sim/AGENTS.md +1 -1
- package/sim/{cold_start.md → docs/cold_start.md} +1 -1
- package/transport/link_registry.mjs +75 -6
- package/transport/peer_health.mjs +43 -25
- package/transport/runtime_bootstrap.mjs +44 -9
- package/pages/index.html +0 -36
- package/pages/index.mjs +0 -66
|
@@ -12,11 +12,65 @@ import {
|
|
|
12
12
|
onBufferedAmountLow,
|
|
13
13
|
} from '../channel_mux.mjs'
|
|
14
14
|
import { asLinkHandle, createLinkPipe } from '../pipe.mjs'
|
|
15
|
+
import { iceLocalHostnameLadder } from '../../node/signaling_config.mjs'
|
|
15
16
|
import { loadNodeRtcPolyfill, waitForChannelState } from '../rtc/index.mjs'
|
|
16
17
|
import { extractDtlsFingerprint } from '../sdp_fingerprint.mjs'
|
|
17
18
|
|
|
18
19
|
import { LINK_LEVEL_WEBRTC } from './levels.mjs'
|
|
19
20
|
|
|
21
|
+
/** 收到候选后多久无新候选就视为 gathering 收齐(polyfill 不推 complete 时的兜底)。 */
|
|
22
|
+
export const ICE_CANDIDATE_SETTLE_MS = 300
|
|
23
|
+
/** gathering 状态轮询间隔。 */
|
|
24
|
+
const ICE_GATHERING_POLL_MS = 50
|
|
25
|
+
/** 一个候选都没收到时,等这么久就放行让 DTLS/数据通道自行判成败。 */
|
|
26
|
+
export const ICE_GATHERING_STALL_MS = ms('3s')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 无 trickle ICE 时等本地候选收齐。
|
|
30
|
+
*
|
|
31
|
+
* 不把 `iceGatheringState === 'complete'` 当作唯一完成条件,也不靠比较 SDP 字符串:服务端 polyfill
|
|
32
|
+
* (node-datachannel)把候选通过 `icecandidate` 事件派发,`localDescription.sdp` 里并不含候选行,
|
|
33
|
+
* 而 gathering 状态在「全部 relay 超时」等情况下会长期停在 'gathering'(fount-p2p#37 次要观察)。
|
|
34
|
+
* 所以完成判据是:状态 complete、收到过候选且一段时间内再无新候选、或一个候选都没收到时直接放行。
|
|
35
|
+
* 最后一种走 [ICE_GATHERING_STALL_MS],让 DTLS/数据通道自己判成败,而不是在握手超时后才硬失败。
|
|
36
|
+
* @param {{ iceGatheringState: () => string, candidateCount?: () => number, handshakeTimeoutMs: number, settleMs?: number, stallMs?: number, onStall?: (elapsedMs: number) => void }} options 观测、超时与窗口配置
|
|
37
|
+
* @returns {Promise<'complete' | 'stable' | 'stalled'>} 结束原因
|
|
38
|
+
* @throws {Error} 超过 handshakeTimeoutMs 仍未收齐
|
|
39
|
+
*/
|
|
40
|
+
export async function collectIceGathering(options) {
|
|
41
|
+
const { iceGatheringState, candidateCount, handshakeTimeoutMs, onStall } = options
|
|
42
|
+
const settleMs = options.settleMs ?? ICE_CANDIDATE_SETTLE_MS
|
|
43
|
+
const stallMs = options.stallMs ?? ICE_GATHERING_STALL_MS
|
|
44
|
+
const startedAt = Date.now()
|
|
45
|
+
const deadline = startedAt + handshakeTimeoutMs
|
|
46
|
+
const failIfOverdue = () => {
|
|
47
|
+
if (Date.now() >= deadline)
|
|
48
|
+
throw new Error(`p2p: ice gathering incomplete after ${handshakeTimeoutMs}ms`)
|
|
49
|
+
}
|
|
50
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
51
|
+
while (true) {
|
|
52
|
+
if (iceGatheringState() === 'complete') return 'complete'
|
|
53
|
+
await sleep(ICE_GATHERING_POLL_MS)
|
|
54
|
+
if (iceGatheringState() === 'complete') return 'complete'
|
|
55
|
+
const candidates = candidateCount?.() ?? 0
|
|
56
|
+
// 一个候选都没收到,再等也不会变:放行让 DTLS/数据通道自己判成败。
|
|
57
|
+
if (!candidates) {
|
|
58
|
+
const elapsedMs = Date.now() - startedAt
|
|
59
|
+
if (elapsedMs >= stallMs) {
|
|
60
|
+
onStall?.(elapsedMs)
|
|
61
|
+
return 'stalled'
|
|
62
|
+
}
|
|
63
|
+
failIfOverdue()
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
// 有候选了:等「再无新候选」的窗口过去;窗口内又来候选就重新计时。
|
|
67
|
+
await sleep(settleMs)
|
|
68
|
+
if (iceGatheringState() === 'complete') return 'complete'
|
|
69
|
+
if ((candidateCount?.() ?? 0) === candidates) return 'stable'
|
|
70
|
+
failIfOverdue()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
20
74
|
/**
|
|
21
75
|
* @param {unknown} error 原始错误
|
|
22
76
|
* @returns {string} 短 reason
|
|
@@ -52,6 +106,11 @@ export async function canUseWebRtcLink() {
|
|
|
52
106
|
|
|
53
107
|
/**
|
|
54
108
|
* 建立 WebRTC link(双 DataChannel + discovery 信令)。
|
|
109
|
+
*
|
|
110
|
+
* ICE 本地主机名策略按观测结果逐级升级(见 [iceLocalHostnameLadder]):一级 gathering 后候选集为空
|
|
111
|
+
* 就换更宽松的一级重建 peer connection 重发 offer(**全新一次建链尝试**,不是重新协商已有连接,
|
|
112
|
+
* 所以 DTLS 指纹绑定语义不变)。接受方按 offer 里的 `rung` 在同级重建并回 answer,自身不升级,
|
|
113
|
+
* 故收敛有界。
|
|
55
114
|
* @param {object} options link 配置
|
|
56
115
|
* @param {string | null} [options.nodeHash] 期望的对端 nodeHash
|
|
57
116
|
* @param {boolean} options.initiator 是否为连接发起方
|
|
@@ -59,19 +118,25 @@ export async function canUseWebRtcLink() {
|
|
|
59
118
|
* @param {RTCConfiguration['iceServers']} [options.iceServers] ICE 服务器列表
|
|
60
119
|
* @param {number} [options.heartbeatMs] 心跳间隔
|
|
61
120
|
* @param {number} [options.idleTimeoutMs] 无入站流量超时
|
|
62
|
-
* @param {number} [options.handshakeTimeoutMs]
|
|
121
|
+
* @param {number} [options.handshakeTimeoutMs] 握手超时(同时是整个升级阶梯的总预算)
|
|
63
122
|
* @param {import('../rtc/polyfill.mjs').LoadedRtcPolyfill} [options.rtc] RTC 构造器
|
|
123
|
+
* @param {typeof import('../rtc/polyfill.mjs').loadNodeRtcPolyfill} [options.loadRtc] RTC 加载函数(测试注入)
|
|
124
|
+
* @param {typeof createLinkPipe} [options.createPipe] pipe 工厂(测试注入)
|
|
125
|
+
* @param {number} [options.iceCandidateSettleMs] 候选收齐窗口覆盖(测试注入)
|
|
126
|
+
* @param {number} [options.iceGatheringStallMs] gathering 停滞窗口覆盖(测试注入)
|
|
64
127
|
* @param {{ nodeHash?: string, nodePubKey?: string, secretKey?: Uint8Array, nonce?: string } | null} [options.localIdentity] 本地握手身份
|
|
65
128
|
* @returns {Promise<import('./index.mjs').LinkHandle>} link 句柄
|
|
66
129
|
*/
|
|
67
130
|
export async function createWebRtcLink(options) {
|
|
68
131
|
const handshakeTimeoutMs = Number(options.handshakeTimeoutMs) || ms('10s')
|
|
69
132
|
const channelOpenTimeoutMs = Math.max(handshakeTimeoutMs, ms('30s'))
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
133
|
+
const loadRtc = options.loadRtc ?? loadNodeRtcPolyfill
|
|
134
|
+
const icePolicy = getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
|
|
135
|
+
const rungs = iceLocalHostnameLadder(icePolicy)
|
|
73
136
|
const remoteSignalQueue = []
|
|
74
137
|
const seenRemoteSignals = createLruMap(1024)
|
|
138
|
+
/** @type {InstanceType<import('../rtc/polyfill.mjs').LoadedRtcPolyfill['RTCPeerConnection']> & { onicecandidate?: unknown, ondatachannel?: unknown, onconnectionstatechange?: unknown }} */
|
|
139
|
+
let peerConnection = null
|
|
75
140
|
let remoteDescriptionSet = false
|
|
76
141
|
let controlChannel = null
|
|
77
142
|
let bulkChannel = null
|
|
@@ -80,6 +145,9 @@ export async function createWebRtcLink(options) {
|
|
|
80
145
|
let controlLowEvents = 0
|
|
81
146
|
let bulkLowEvents = 0
|
|
82
147
|
let reconnectCount = 0
|
|
148
|
+
let rungIndex = 0
|
|
149
|
+
/** 当前一级收到的 ICE 候选数(polyfill 不把候选写进 SDP,故单独计数)。 */
|
|
150
|
+
let localCandidateCount = 0
|
|
83
151
|
|
|
84
152
|
/**
|
|
85
153
|
* @param {unknown} message 信令载荷
|
|
@@ -89,7 +157,8 @@ export async function createWebRtcLink(options) {
|
|
|
89
157
|
await Promise.resolve(options.signal.send(message))
|
|
90
158
|
}
|
|
91
159
|
|
|
92
|
-
const
|
|
160
|
+
const createPipe = options.createPipe ?? createLinkPipe
|
|
161
|
+
const pipe = createPipe({
|
|
93
162
|
providerId: 'webrtc',
|
|
94
163
|
level: LINK_LEVEL_WEBRTC,
|
|
95
164
|
initiator: !!options.initiator,
|
|
@@ -98,10 +167,10 @@ export async function createWebRtcLink(options) {
|
|
|
98
167
|
heartbeatMs: options.heartbeatMs,
|
|
99
168
|
idleTimeoutMs: options.idleTimeoutMs,
|
|
100
169
|
handshakeTimeoutMs,
|
|
101
|
-
/** @returns {string} 本端 DTLS fingerprint */
|
|
102
|
-
getLocalBinding: () => extractDtlsFingerprint(peerConnection
|
|
170
|
+
/** @returns {string} 本端 DTLS fingerprint(取当前一级的 peer connection) */
|
|
171
|
+
getLocalBinding: () => extractDtlsFingerprint(peerConnection?.localDescription?.sdp || ''),
|
|
103
172
|
/** @returns {string} 对端 DTLS fingerprint */
|
|
104
|
-
getRemoteBinding: () => extractDtlsFingerprint(peerConnection
|
|
173
|
+
getRemoteBinding: () => extractDtlsFingerprint(peerConnection?.remoteDescription?.sdp || ''),
|
|
105
174
|
/**
|
|
106
175
|
* @param {string} text control JSON
|
|
107
176
|
* @returns {void}
|
|
@@ -127,15 +196,17 @@ export async function createWebRtcLink(options) {
|
|
|
127
196
|
sendQueues?.clear()
|
|
128
197
|
try { controlChannel?.close() } catch { /* ignore */ }
|
|
129
198
|
try { bulkChannel?.close() } catch { /* ignore */ }
|
|
130
|
-
try { await peerConnection
|
|
199
|
+
try { await peerConnection?.close() } catch { /* ignore */ }
|
|
131
200
|
},
|
|
132
201
|
/**
|
|
133
202
|
* @returns {object} WebRTC 附加 stats
|
|
134
203
|
*/
|
|
135
204
|
extraStats() {
|
|
136
205
|
return {
|
|
137
|
-
connectionState: peerConnection
|
|
138
|
-
iceConnectionState: peerConnection
|
|
206
|
+
connectionState: peerConnection?.connectionState,
|
|
207
|
+
iceConnectionState: peerConnection?.iceConnectionState,
|
|
208
|
+
iceLocalHostnamePolicy: rungs[rungIndex],
|
|
209
|
+
iceRung: rungIndex,
|
|
139
210
|
reconnectCount,
|
|
140
211
|
pending: sendQueues?.pending() ?? { control: 0, bulk: 0 },
|
|
141
212
|
controlBufferedAmount: controlChannel?.bufferedAmount ?? 0,
|
|
@@ -175,17 +246,102 @@ export async function createWebRtcLink(options) {
|
|
|
175
246
|
}
|
|
176
247
|
|
|
177
248
|
/**
|
|
249
|
+
* 取第 index 级策略对应的 RTC 构造器(策略在包装期烘入,故每级都要重新取)。
|
|
250
|
+
* @param {number} index 阶梯下标
|
|
251
|
+
* @returns {Promise<import('../rtc/polyfill.mjs').LoadedRtcPolyfill>} RTC 构造器
|
|
252
|
+
*/
|
|
253
|
+
function rtcForRung(index) {
|
|
254
|
+
if (options.rtc) return options.rtc
|
|
255
|
+
return loadRtc({ policy: rungs[Math.min(index, rungs.length - 1)] })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 用第 index 级策略重建 peer connection(换级即换 pc:一次全新建链尝试)。
|
|
260
|
+
* @param {number} index 阶梯下标
|
|
261
|
+
* @returns {Promise<void>}
|
|
262
|
+
*/
|
|
263
|
+
async function buildPeerConnection(index) {
|
|
264
|
+
const previous = peerConnection
|
|
265
|
+
rungIndex = Math.min(index, rungs.length - 1)
|
|
266
|
+
const rtc = await rtcForRung(rungIndex)
|
|
267
|
+
peerConnection = new rtc.RTCPeerConnection(options.iceServers?.length ? { iceServers: options.iceServers } : undefined)
|
|
268
|
+
remoteDescriptionSet = false
|
|
269
|
+
remoteSignalQueue.length = 0
|
|
270
|
+
localCandidateCount = 0
|
|
271
|
+
attachPeerConnection(peerConnection)
|
|
272
|
+
if (previous)
|
|
273
|
+
try { await previous.close() } catch { /* ignore */ }
|
|
274
|
+
nodeDebug('p2p:webrtc ice rung', {
|
|
275
|
+
rung: rungIndex,
|
|
276
|
+
policy: rungs[rungIndex],
|
|
277
|
+
rungs: rungs.length,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 确保当前已有一级 peer connection:接受方在收到第一份信令前也要有实例才能 setRemoteDescription。
|
|
178
283
|
* @returns {Promise<void>}
|
|
179
284
|
*/
|
|
285
|
+
async function ensurePeerConnection() {
|
|
286
|
+
if (!peerConnection) await buildPeerConnection(rungIndex)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 给 peer connection 挂上事件与回调(换级重建后需重新挂)。
|
|
291
|
+
* @param {RTCPeerConnection} connection peer connection
|
|
292
|
+
* @returns {void}
|
|
293
|
+
*/
|
|
294
|
+
function attachPeerConnection(connection) {
|
|
295
|
+
// 候选随 description 一次性带出(对端要先有 remoteDescription 才吃候选),故这里只计数、不外发。
|
|
296
|
+
// 事件 API 由后端提供(W3C 是 addEventListener);缺失时退化为 onicecandidate 计数。
|
|
297
|
+
const countCandidate = event => {
|
|
298
|
+
if (event?.candidate) localCandidateCount++
|
|
299
|
+
}
|
|
300
|
+
if (connection.addEventListener) connection.addEventListener('icecandidate', countCandidate)
|
|
301
|
+
else {
|
|
302
|
+
const previousHandler = connection.onicecandidate
|
|
303
|
+
connection.onicecandidate = event => {
|
|
304
|
+
countCandidate(event)
|
|
305
|
+
previousHandler?.(event)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
connection.ondatachannel = event => {
|
|
309
|
+
attachChannel(event.channel)
|
|
310
|
+
void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
|
|
311
|
+
}
|
|
312
|
+
connection.onconnectionstatechange = () => {
|
|
313
|
+
if (['failed', 'closed', 'disconnected'].includes(connection.connectionState)) {
|
|
314
|
+
reconnectCount++
|
|
315
|
+
void pipe.close(`connection-${connection.connectionState}`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 等本地候选收齐(停滞判定见 [collectIceGathering]),并回报本次是否产出了候选。
|
|
322
|
+
* @returns {Promise<boolean>} 是否收到过候选
|
|
323
|
+
*/
|
|
180
324
|
async function waitForIceGatheringComplete() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
325
|
+
try {
|
|
326
|
+
await collectIceGathering({
|
|
327
|
+
iceGatheringState: () => peerConnection.iceGatheringState,
|
|
328
|
+
candidateCount: () => localCandidateCount,
|
|
329
|
+
handshakeTimeoutMs,
|
|
330
|
+
settleMs: options.iceCandidateSettleMs,
|
|
331
|
+
stallMs: options.iceGatheringStallMs,
|
|
332
|
+
onStall: elapsedMs => nodeDebug('p2p:webrtc ice gathering stalled without candidates', {
|
|
333
|
+
elapsedMs,
|
|
334
|
+
rung: rungIndex,
|
|
335
|
+
policy: rungs[rungIndex],
|
|
336
|
+
iceServers: options.iceServers?.length || 0,
|
|
337
|
+
}),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
186
341
|
await pipe.close('ice-gathering-timeout')
|
|
187
|
-
throw
|
|
342
|
+
throw error
|
|
188
343
|
}
|
|
344
|
+
return localCandidateCount > 0
|
|
189
345
|
}
|
|
190
346
|
|
|
191
347
|
/**
|
|
@@ -214,21 +370,35 @@ export async function createWebRtcLink(options) {
|
|
|
214
370
|
*/
|
|
215
371
|
async function handleRemoteSignal(message) {
|
|
216
372
|
if (!message?.type) return
|
|
373
|
+
// 信令与建链是并发的:任何入站信令都要先确保当前这一级已有可用 pc。
|
|
374
|
+
await ensurePeerConnection()
|
|
217
375
|
const signalKey = message.type === 'ice' && message.candidate
|
|
218
376
|
? `ice:${message.candidate.candidate ?? ''}:${message.candidate.sdpMid ?? ''}:${message.candidate.sdpMLineIndex ?? ''}`
|
|
219
377
|
: JSON.stringify(message)
|
|
220
378
|
if (seenRemoteSignals.has(signalKey)) return
|
|
221
379
|
seenRemoteSignals.touch(signalKey, true)
|
|
222
380
|
if (message.type === 'description' && message.description) {
|
|
381
|
+
// 接受方按 offer 的级号重建,保证自己的候选过滤策略与发起方同一级;自身不升级,
|
|
382
|
+
// 所以阶梯收敛有界(由发起方的观测结果驱动)。
|
|
383
|
+
const remoteRung = Number.isInteger(message.rung) ? Math.max(0, message.rung) : 0
|
|
384
|
+
if (message.description.type === 'offer' && !options.initiator && remoteRung !== rungIndex)
|
|
385
|
+
await buildPeerConnection(remoteRung)
|
|
223
386
|
if (message.description.type === 'answer' && peerConnection.signalingState === 'stable') return
|
|
224
387
|
await applyRemoteDescription(message.description)
|
|
225
388
|
if (message.description.type === 'offer') {
|
|
226
389
|
const answer = await peerConnection.createAnswer()
|
|
227
390
|
await peerConnection.setLocalDescription(answer)
|
|
228
391
|
await flushQueuedIceCandidates()
|
|
229
|
-
await waitForIceGatheringComplete()
|
|
392
|
+
const hasCandidates = await waitForIceGatheringComplete()
|
|
393
|
+
nodeDebug('p2p:webrtc answer gathered', {
|
|
394
|
+
rung: rungIndex,
|
|
395
|
+
policy: rungs[rungIndex],
|
|
396
|
+
candidates: localCandidateCount,
|
|
397
|
+
hasCandidates,
|
|
398
|
+
})
|
|
230
399
|
await sendSignal({
|
|
231
400
|
type: 'description',
|
|
401
|
+
rung: rungIndex,
|
|
232
402
|
description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? answer,
|
|
233
403
|
})
|
|
234
404
|
await pipe.maybeSendAuth()
|
|
@@ -299,46 +469,55 @@ export async function createWebRtcLink(options) {
|
|
|
299
469
|
void handleRemoteSignal(message).catch(error => pipe.close(`signal-error:${formatErrorReason(error)}`))
|
|
300
470
|
}) ?? null
|
|
301
471
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
* @returns {void}
|
|
305
|
-
*/
|
|
306
|
-
peerConnection.onicecandidate = event => {
|
|
307
|
-
if (trickleIceOff || !event.candidate) return
|
|
308
|
-
void sendSignal({
|
|
309
|
-
type: 'ice',
|
|
310
|
-
candidate: event.candidate.toJSON?.() || event.candidate,
|
|
311
|
-
}).catch(error => pipe.close(`signal-send-failed:${formatErrorReason(error)}`))
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* @param {RTCDataChannelEvent} event 远端 data channel 事件
|
|
315
|
-
* @returns {void}
|
|
316
|
-
*/
|
|
317
|
-
peerConnection.ondatachannel = event => {
|
|
318
|
-
attachChannel(event.channel)
|
|
319
|
-
void maybeStartPostOpenFlow().catch(error => pipe.close(`channel-attach-failed:${formatErrorReason(error)}`))
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* 连接失败/断开时递增重连计数并关闭 pipe。
|
|
324
|
-
*/
|
|
325
|
-
peerConnection.onconnectionstatechange = () => {
|
|
326
|
-
if (['failed', 'closed', 'disconnected'].includes(peerConnection.connectionState)) {
|
|
327
|
-
reconnectCount++
|
|
328
|
-
void pipe.close(`connection-${peerConnection.connectionState}`)
|
|
329
|
-
}
|
|
330
|
-
}
|
|
472
|
+
// 信令随时可能到,故先为当前一级建好 pc(发起方随后会在阶梯里逐级重建)。
|
|
473
|
+
await ensurePeerConnection()
|
|
331
474
|
|
|
332
475
|
if (options.initiator) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
476
|
+
// ICE 阶梯:一级 gathering 后候选集为空就升到更宽松的一级,用全新 peer connection 重发 offer。
|
|
477
|
+
// 总预算沿用 handshakeTimeoutMs,故整条阶梯(含最后一级的数据通道等待)仍有界。
|
|
478
|
+
const ladderDeadline = Date.now() + handshakeTimeoutMs
|
|
479
|
+
let sentOffer = false
|
|
480
|
+
for (let index = 0; index < rungs.length; index++) {
|
|
481
|
+
await buildPeerConnection(index)
|
|
482
|
+
attachChannel(peerConnection.createDataChannel(CHANNEL_CONTROL))
|
|
483
|
+
attachChannel(peerConnection.createDataChannel(CHANNEL_BULK))
|
|
484
|
+
const offer = await peerConnection.createOffer()
|
|
485
|
+
await peerConnection.setLocalDescription(offer)
|
|
486
|
+
const hasCandidates = await waitForIceGatheringComplete()
|
|
487
|
+
nodeDebug('p2p:webrtc offer gathered', {
|
|
488
|
+
rung: index,
|
|
489
|
+
policy: rungs[index],
|
|
490
|
+
candidates: localCandidateCount,
|
|
491
|
+
})
|
|
492
|
+
// 候选集为空说明这一级策略把本机候选全滤掉了(例如只产出 mDNS 候选),换更宽松的一级重建。
|
|
493
|
+
// 只在预算内、且还有更宽松的一级时升级;`rewrite-loopback` 起点不会走到这里(阶梯只有它自己)。
|
|
494
|
+
if (!hasCandidates && Date.now() < ladderDeadline && index + 1 < rungs.length) {
|
|
495
|
+
nodeDebug('p2p:webrtc ice rung escalated', {
|
|
496
|
+
from: rungs[index],
|
|
497
|
+
to: rungs[index + 1],
|
|
498
|
+
rung: index + 1,
|
|
499
|
+
})
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
await sendSignal({
|
|
503
|
+
type: 'description',
|
|
504
|
+
rung: index,
|
|
505
|
+
description: peerConnection.localDescription?.toJSON?.() ?? peerConnection.localDescription ?? offer,
|
|
506
|
+
})
|
|
507
|
+
sentOffer = true
|
|
508
|
+
if (!hasCandidates)
|
|
509
|
+
nodeDebug('p2p:webrtc offer has no usable ice candidates', {
|
|
510
|
+
rung: index,
|
|
511
|
+
policy: rungs[index],
|
|
512
|
+
escalatable: index + 1 < rungs.length,
|
|
513
|
+
iceServers: options.iceServers?.length || 0,
|
|
514
|
+
})
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
if (!sentOffer) {
|
|
518
|
+
await pipe.close('ice-candidates-empty')
|
|
519
|
+
throw new Error('p2p: no usable ice candidates after the local hostname ladder')
|
|
520
|
+
}
|
|
342
521
|
}
|
|
343
522
|
|
|
344
523
|
void maybeStartPostOpenFlow().catch(error => pipe.close(`open-flow-failed:${formatErrorReason(error)}`))
|
package/link/rtc/polyfill.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { nodeDebug } from '../../node/log.mjs'
|
|
|
5
5
|
|
|
6
6
|
import { wrapRtcPeerConnectionForIceLocalHostname } from './ice_local_hostname.mjs'
|
|
7
7
|
|
|
8
|
+
/** @typedef {import('./ice_local_hostname.mjs').IceLocalHostnamePolicy} IceLocalHostnamePolicy */
|
|
9
|
+
|
|
8
10
|
/** @type {boolean} */
|
|
9
11
|
let exitCleanupHooked = false
|
|
10
12
|
|
|
@@ -87,7 +89,7 @@ function defaultRtcBackends() {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
|
-
* @param {{ backends?: RtcBackend[] }} options
|
|
92
|
+
* @param {{ backends?: RtcBackend[], policy?: IceLocalHostnamePolicy }} options 后端列表与策略覆盖
|
|
91
93
|
* @returns {Promise<LoadedRtcPolyfill>} 首个可用后端的 polyfill
|
|
92
94
|
*/
|
|
93
95
|
async function loadNodeRtcPolyfillUncached(options) {
|
|
@@ -99,7 +101,7 @@ async function loadNodeRtcPolyfillUncached(options) {
|
|
|
99
101
|
for (const backend of backends)
|
|
100
102
|
try {
|
|
101
103
|
const mod = await backend.load()
|
|
102
|
-
const iceLocalHostnamePolicy = getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
|
|
104
|
+
const iceLocalHostnamePolicy = options.policy ?? getSignalingRuntimeConfig().channels.webrtc?.iceLocalHostnamePolicy
|
|
103
105
|
return {
|
|
104
106
|
RTCPeerConnection: wrapRtcPeerConnectionForIceLocalHostname(
|
|
105
107
|
mod.RTCPeerConnection,
|
|
@@ -122,13 +124,14 @@ async function loadNodeRtcPolyfillUncached(options) {
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
125
|
-
* 加载 RTC polyfill(node-datachannel 优先,失败则 node-rtc-connection
|
|
126
|
-
*
|
|
127
|
-
*
|
|
127
|
+
* 加载 RTC polyfill(node-datachannel 优先,失败则 node-rtc-connection),并按 ICE 本地主机名策略包装
|
|
128
|
+
* RTCPeerConnection。策略是构造期烘进包装类的,所以 ICE 阶梯每换一级都要带 `policy` 重新取一次。
|
|
129
|
+
* 默认后端路径会缓存首次成功结果(仅在不带 policy 覆盖时);注入 backends / policy 时不走缓存。
|
|
130
|
+
* @param {{ backends?: RtcBackend[], policy?: IceLocalHostnamePolicy }} [options] 可注入后端列表与策略覆盖(测试/阶梯用)
|
|
128
131
|
* @returns {Promise<LoadedRtcPolyfill>} RTC 构造器
|
|
129
132
|
*/
|
|
130
133
|
export async function loadNodeRtcPolyfill(options = {}) {
|
|
131
|
-
if (options.backends?.length)
|
|
134
|
+
if (options.backends?.length || options.policy)
|
|
132
135
|
return loadNodeRtcPolyfillUncached(options)
|
|
133
136
|
const epoch = getRtcPolyfillCacheEpoch()
|
|
134
137
|
if (!cachedDefaultPolyfill || cachedDefaultPolyfillEpoch !== epoch) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 信令运行时配置。
|
|
3
|
+
*
|
|
4
|
+
* webrtc 通道不再有 `trickleIceOff`:本仓库的 ICE 候选是靠 description 一次性带出去的
|
|
5
|
+
* (对端要先有 remoteDescription 才吃候选),所以「收齐后一次性发送」是信令层的固定语义,
|
|
6
|
+
* 不是可配置项。`iceLocalHostnamePolicy` 是 ICE 阶梯的起点(见 [iceLocalHostnameLadder])。
|
|
7
|
+
*/
|
|
2
8
|
|
|
3
9
|
/** @typedef {'none' | 'rewrite-loopback' | 'drop'} IceLocalHostnamePolicy */
|
|
4
10
|
|
|
@@ -13,20 +19,36 @@ import process from 'node:process'
|
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
22
|
+
* ICE 本地主机名策略的升级阶梯(宽松度递增)。
|
|
23
|
+
*
|
|
24
|
+
* `.local`(mDNS)候选对端通常解析不了,所以从 `drop` 起;若丢弃后候选集为空,说明这台主机只产出了
|
|
25
|
+
* mDNS 候选,就升到 `none` 让对端自己处理(对端支持 mDNS 时就能用上)。判据是运行时观测到的候选集,
|
|
26
|
+
* 不是平台——任何后端、任何主机都走同一条流程。
|
|
27
|
+
*
|
|
28
|
+
* `rewrite-loopback` 不在阶梯里:它把候选改写成 `127.0.0.1`,只对同机有意义,发给跨机对端是坏候选。
|
|
18
29
|
*/
|
|
19
|
-
|
|
20
|
-
const iceLocalHostnamePolicy = process.platform === 'win32' ? 'drop' : 'none'
|
|
21
|
-
return { iceLocalHostnamePolicy, trickleIceOff: iceLocalHostnamePolicy !== 'none' }
|
|
22
|
-
}
|
|
30
|
+
export const ICE_LOCAL_HOSTNAME_LADDER = Object.freeze(['drop', 'none'])
|
|
23
31
|
|
|
24
32
|
/** 各介质的默认 channel 配置;对象值是启用并覆盖默认。 */
|
|
25
33
|
const DEFAULT_CHANNEL_CONFIG = {
|
|
26
34
|
nostr: true,
|
|
27
35
|
lan: true,
|
|
28
36
|
bt: true,
|
|
29
|
-
webrtc:
|
|
37
|
+
webrtc: { iceLocalHostnamePolicy: ICE_LOCAL_HOSTNAME_LADDER[0] },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从起点策略推导要依次尝试的策略(宽松度递增),用于把「候选集为空」当成升级信号。
|
|
42
|
+
* `rewrite-loopback` 作为显式起点时只试它自己:它是调试/同机用途,不应自动放宽成对外候选。
|
|
43
|
+
* @param {IceLocalHostnamePolicy | undefined | null} [from] 起点策略;未配置/非阶梯项时用阶梯首项
|
|
44
|
+
* @returns {IceLocalHostnamePolicy[]} 依次尝试的策略(至少一项)
|
|
45
|
+
*/
|
|
46
|
+
export function iceLocalHostnameLadder(from) {
|
|
47
|
+
if (from === 'rewrite-loopback') return ['rewrite-loopback']
|
|
48
|
+
if (!ICE_LOCAL_HOSTNAME_LADDER.includes(/** @type {IceLocalHostnamePolicy} */ (from)))
|
|
49
|
+
return [...ICE_LOCAL_HOSTNAME_LADDER]
|
|
50
|
+
const startIndex = ICE_LOCAL_HOSTNAME_LADDER.indexOf(/** @type {IceLocalHostnamePolicy} */ (from))
|
|
51
|
+
return ICE_LOCAL_HOSTNAME_LADDER.slice(startIndex)
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
/**
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { isHex64 } from '../core/hexIds.mjs'
|
|
4
|
+
import { sendToNodeLink } from '../transport/link_registry.mjs'
|
|
5
|
+
import { attachNodeScopeFeature } from '../transport/node_scope/features.mjs'
|
|
6
|
+
|
|
7
|
+
import { getNodeHash } from './identity.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_TIMEOUT = 30 * 60 * 1000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ challenge: string, requesterNodeHash: string, expiresAt: number }} NetworkChallenge 挑战
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 创建有界的挑战服务;发送方身份一律取自已认证的网络入口,不信任载荷自称。
|
|
17
|
+
* @param {{nodeHash: string, send: (peer: string, action: string, payload: object) => Promise<boolean>, now?: () => number}} options 依赖
|
|
18
|
+
* @returns {object} 挑战服务
|
|
19
|
+
*/
|
|
20
|
+
export function createNetworkVerificationService({ nodeHash, send, now = Date.now }) {
|
|
21
|
+
const requests = new Map()
|
|
22
|
+
const proofs = new Map()
|
|
23
|
+
/**
|
|
24
|
+
* 清理已过期的挑战记录。
|
|
25
|
+
* @returns {void} 无返回值
|
|
26
|
+
*/
|
|
27
|
+
function prune() {
|
|
28
|
+
for (const [id, entry] of requests) if (entry.expiresAt + 60000 < now()) requests.delete(id)
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 取挑战快照,并把已过期的 pending 收敛为 failed。
|
|
32
|
+
* @param {object} entry 挑战状态
|
|
33
|
+
* @returns {object} 快照
|
|
34
|
+
*/
|
|
35
|
+
function snapshot(entry) {
|
|
36
|
+
if (entry.status === 'pending' && now() >= entry.expiresAt) entry.status = 'failed', entry.reason = 'timeout'
|
|
37
|
+
return { ...entry }
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
/**
|
|
41
|
+
* 新建一个挑战:限幅超时、限制在途数量,返回待验证快照。
|
|
42
|
+
* @param {{ timeoutMs?: number }} [options] 超时选项
|
|
43
|
+
* @returns {object} 待验证快照
|
|
44
|
+
*/
|
|
45
|
+
create({ timeoutMs = 300000 } = {}) {
|
|
46
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT) throw new Error('invalid timeoutMs')
|
|
47
|
+
prune()
|
|
48
|
+
if (requests.size >= 1024) throw new Error('too many verification requests')
|
|
49
|
+
const challenge = randomBytes(32).toString('hex')
|
|
50
|
+
const entry = { challenge, requesterNodeHash: nodeHash, expiresAt: now() + timeoutMs, status: 'pending' }
|
|
51
|
+
requests.set(challenge, entry)
|
|
52
|
+
return snapshot(entry)
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* 按挑战串读取快照;不存在返回 null。
|
|
56
|
+
* @param {string} challenge 挑战串
|
|
57
|
+
* @returns {object|null} 快照
|
|
58
|
+
*/
|
|
59
|
+
get(challenge) {
|
|
60
|
+
prune()
|
|
61
|
+
const entry = requests.get(challenge)
|
|
62
|
+
return entry ? snapshot(entry) : null
|
|
63
|
+
},
|
|
64
|
+
/**
|
|
65
|
+
* 处理入站 verification_claim / verification_receipt 动作。
|
|
66
|
+
* @param {string} action 网络动作
|
|
67
|
+
* @param {object} payload 网络载荷
|
|
68
|
+
* @param {string} sender 已认证身份
|
|
69
|
+
*/
|
|
70
|
+
async receive(action, payload, sender) {
|
|
71
|
+
if (!isHex64(sender) || !isHex64(payload?.challenge)) return
|
|
72
|
+
if (action === 'verification_claim') {
|
|
73
|
+
const entry = requests.get(payload.challenge)
|
|
74
|
+
if (!entry || now() >= entry.expiresAt || snapshot(entry).status === 'failed' || entry.expiresAt !== payload.expiresAt) return
|
|
75
|
+
if (entry.status === 'verified' && entry.nodeHash !== sender) return
|
|
76
|
+
entry.status = 'verified'
|
|
77
|
+
entry.nodeHash = sender
|
|
78
|
+
await send(sender, 'verification_receipt', { challenge: entry.challenge, expiresAt: entry.expiresAt })
|
|
79
|
+
}
|
|
80
|
+
if (action === 'verification_receipt') {
|
|
81
|
+
const proof = proofs.get(payload.challenge)
|
|
82
|
+
if (proof && proof.requesterNodeHash === sender && proof.expiresAt === payload.expiresAt && now() < proof.expiresAt)
|
|
83
|
+
proof.resolve({ status: 'verified', nodeHash })
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* 向请求方发起验证并等待回执(去重、并发上限、超时收敛为 failed)。
|
|
88
|
+
* @param {NetworkChallenge} request 挑战请求
|
|
89
|
+
* @returns {Promise<object>} 验证结果
|
|
90
|
+
*/
|
|
91
|
+
async prove({ requesterNodeHash, challenge, expiresAt }) {
|
|
92
|
+
if (!isHex64(requesterNodeHash) || !isHex64(challenge) || !Number.isSafeInteger(expiresAt) || expiresAt <= now() || expiresAt > now() + MAX_TIMEOUT)
|
|
93
|
+
return { status: 'failed', reason: 'invalid challenge' }
|
|
94
|
+
if (proofs.has(challenge)) {
|
|
95
|
+
const existing = proofs.get(challenge)
|
|
96
|
+
if (existing.requesterNodeHash !== requesterNodeHash || existing.expiresAt !== expiresAt) return { status: 'failed', reason: 'challenge mismatch' }
|
|
97
|
+
return existing.promise
|
|
98
|
+
}
|
|
99
|
+
if (proofs.size >= 64) return { status: 'failed', reason: 'busy' }
|
|
100
|
+
let resolve
|
|
101
|
+
const promise = new Promise(done => { resolve = done })
|
|
102
|
+
proofs.set(challenge, { requesterNodeHash, expiresAt, resolve, promise })
|
|
103
|
+
const timer = setTimeout(() => resolve({ status: 'failed', reason: 'timeout' }), Math.min(expiresAt - now(), 15000))
|
|
104
|
+
try {
|
|
105
|
+
void send(requesterNodeHash, 'verification_claim', { challenge, expiresAt }).then(sent => {
|
|
106
|
+
if (!sent) resolve({ status: 'failed', reason: 'unreachable' })
|
|
107
|
+
}, () => resolve({ status: 'failed', reason: 'unreachable' }))
|
|
108
|
+
return await promise
|
|
109
|
+
}
|
|
110
|
+
finally { clearTimeout(timer); proofs.delete(challenge) }
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let service
|
|
116
|
+
/**
|
|
117
|
+
* 把验证动作挂到正在运行的节点的 node scope 上。
|
|
118
|
+
* @returns {() => void} 卸载 dispose
|
|
119
|
+
*/
|
|
120
|
+
export function attachNetworkVerification() {
|
|
121
|
+
return attachNodeScopeFeature('verification', wire => {
|
|
122
|
+
const current = getNetworkVerificationService()
|
|
123
|
+
const disposers = ['verification_claim', 'verification_receipt'].map(action => wire.on(action, (payload, sender) => {
|
|
124
|
+
void current.receive(action, payload, sender).catch(() => {})
|
|
125
|
+
}))
|
|
126
|
+
return () => { for (const dispose of disposers) dispose(); service = null }
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 取当前节点的验证服务(懒创建单例)。
|
|
132
|
+
* @returns {ReturnType<typeof createNetworkVerificationService>} 验证服务
|
|
133
|
+
*/
|
|
134
|
+
export function getNetworkVerificationService() {
|
|
135
|
+
if (!service) service = createNetworkVerificationService({ nodeHash: getNodeHash(), send: sendVerificationEnvelope })
|
|
136
|
+
return service
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 经已认证链路投递验证消息;请求方就是本节点时直接走本地入口。
|
|
141
|
+
* @param {string} peer 目标身份
|
|
142
|
+
* @param {string} action 网络动作
|
|
143
|
+
* @param {object} payload 网络载荷
|
|
144
|
+
* @returns {Promise<boolean>} 是否已投递
|
|
145
|
+
*/
|
|
146
|
+
async function sendVerificationEnvelope(peer, action, payload) {
|
|
147
|
+
if (peer === getNodeHash()) {
|
|
148
|
+
await getNetworkVerificationService().receive(action, payload, peer)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
return await sendToNodeLink(peer, { scope: 'node', action, payload })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 单次自证:证明本节点可达请求方并收到其回执。
|
|
156
|
+
* @param {NetworkChallenge} request 挑战请求
|
|
157
|
+
* @returns {Promise<object>} 验证结果
|
|
158
|
+
*/
|
|
159
|
+
export async function proveNetworkVerification(request) {
|
|
160
|
+
const dispose = attachNetworkVerification()
|
|
161
|
+
try { return await getNetworkVerificationService().prove(request) }
|
|
162
|
+
finally { dispose() }
|
|
163
|
+
}
|