@steve02081504/fount-p2p 0.0.41 → 0.0.43

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/overlay/index.mjs CHANGED
@@ -1,29 +1,52 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
 
3
+ import { canonicalStringify } from '../core/canonical_json.mjs'
3
4
  import { pubKeyHash, sign, verify } from '../crypto/crypto.mjs'
4
5
  import { randomFrameIdHex } from '../link/frame.mjs'
5
6
  import { createLruMap } from '../utils/lru.mjs'
7
+ import { consumeToken } from '../utils/token_bucket.mjs'
8
+
9
+ import overlayTunables from './tunables.json' with { type: 'json' }
6
10
 
7
11
  const ROUTE_DOMAIN = 'fount-route'
12
+ const RELAY_DOMAIN = 'fount-relay'
13
+
14
+ /** 默认 overlay 限速桶;不依赖 infra 启动,`startNode` 单独使用时也生效。 */
15
+ const defaultOverlayRateBuckets = new Map()
16
+ /** @type {{ perMin: number, burst: number }} */
17
+ const DEFAULT_OVERLAY_RATE_LIMITS = {
18
+ perMin: Math.max(1, Number(overlayTunables.overlayRatePerMin) || 120),
19
+ burst: Math.max(1, Number(overlayTunables.overlayRateBurst) || 30),
20
+ }
21
+
22
+ /**
23
+ * @param {string} senderNodeHash 发送方节点
24
+ * @param {string} action overlay 动作
25
+ * @returns {boolean} 是否允许
26
+ */
27
+ function defaultOverlayRateGate(senderNodeHash, action) {
28
+ if (action !== 'route_req' && action !== 'relay') return true
29
+ return consumeToken(defaultOverlayRateBuckets, senderNodeHash, Date.now(), DEFAULT_OVERLAY_RATE_LIMITS)
30
+ }
8
31
 
9
- /** @type {((senderNodeHash: string, action: string) => boolean) | null} */
10
- let overlayRateGate = null
32
+ /** @type {((senderNodeHash: string, action: string) => boolean)} */
33
+ let overlayRateGate = defaultOverlayRateGate
11
34
 
12
35
  /**
13
- * 安装 overlay 入站限速门(返回 false 则丢弃)。
36
+ * 安装 overlay 入站限速门(返回 false 则丢弃)。传 null 恢复默认限速。
14
37
  * @param {((senderNodeHash: string, action: string) => boolean) | null} rateGate 返回 false 则丢弃
15
38
  * @returns {void}
16
39
  */
17
40
  export function setOverlayRateGate(rateGate) {
18
- overlayRateGate = rateGate || null
41
+ overlayRateGate = rateGate || defaultOverlayRateGate
19
42
  }
20
43
 
21
44
  /**
22
- * 清除 overlay 限速门。
45
+ * 清除自定义 overlay 限速门,恢复默认限速。
23
46
  * @returns {void}
24
47
  */
25
48
  export function clearOverlayRateGate() {
26
- overlayRateGate = null
49
+ overlayRateGate = defaultOverlayRateGate
27
50
  }
28
51
 
29
52
  /**
@@ -36,6 +59,17 @@ function routeSignBytes(reqId, path) {
36
59
  return Buffer.from(`${ROUTE_DOMAIN}\0${reqId}\0${path.join(',')}`, 'utf8')
37
60
  }
38
61
 
62
+ /**
63
+ * 构造 relay 源身份签名用的字节序列。覆盖完整 path 与 body:
64
+ * 末端据此确认 path[0] 确为原始发送者,且路径与载荷未被中间节点篡改。
65
+ * @param {string[]} path 完整节点路径
66
+ * @param {unknown} body relay 载荷
67
+ * @returns {Uint8Array} 待签名字节
68
+ */
69
+ function relaySignBytes(path, body) {
70
+ return Buffer.from(`${RELAY_DOMAIN}\0${path.join(',')}\0${canonicalStringify(body)}`, 'utf8')
71
+ }
72
+
39
73
  /**
40
74
  * 创建 overlay 多跳路由与 relay 路由器。
41
75
  * @param {object} registry link registry(含 localIdentity、sendToNodeLink、listLinks、subscribeScope)
@@ -47,7 +81,7 @@ export function createOverlayRouter(registry, ttl = 3) {
47
81
  const selfPubKey = registry.localIdentity.nodePubKey
48
82
  const { secretKey } = registry.localIdentity
49
83
  const seenReqs = createLruMap(4096)
50
- /** @type {Map<string, { resolve: (path: string[]) => void, reject: (error: Error) => void, timer: number }>} */
84
+ /** @type {Map<string, { resolve: (path: string[]) => void, reject: (error: Error) => void, timer: number, target: string }>} */
51
85
  const pendingRoutes = new Map()
52
86
  /** @type {Set<(body: unknown, meta: { path: string[], from: string }) => void>} */
53
87
  const relayListeners = new Set()
@@ -118,6 +152,8 @@ export function createOverlayRouter(registry, ttl = 3) {
118
152
  if (path[0] === selfNodeHash) {
119
153
  const pending = pendingRoutes.get(reqId)
120
154
  if (!pending) return
155
+ // 终点必须就是本次发现的目标,否则任何直连邻居都能回一条以自己结尾的合法路径劫持路由。
156
+ if (path[path.length - 1] !== pending.target) return
121
157
  clearTimeout(pending.timer)
122
158
  pendingRoutes.delete(reqId)
123
159
  pending.resolve(path)
@@ -132,6 +168,17 @@ export function createOverlayRouter(registry, ttl = 3) {
132
168
  const path = payload.path || []
133
169
  const index = Number(payload.idx)
134
170
  if (!path.length || path[index] !== selfNodeHash) return
171
+ // origin 必须对 (path, body) 签名,否则任何直连 peer 都能伪造 path[0] 冒充发送者。
172
+ const originPubKey = payload.nodePubKey
173
+ const sigHex = payload.sig
174
+ if (typeof originPubKey !== 'string' || typeof sigHex !== 'string') return
175
+ if (pubKeyHash(Buffer.from(originPubKey, 'hex')) !== path[0]) return
176
+ const ok = await verify(
177
+ Buffer.from(sigHex, 'hex'),
178
+ relaySignBytes(path, payload.body),
179
+ Buffer.from(originPubKey, 'hex'),
180
+ )
181
+ if (!ok) return
135
182
  if (index === path.length - 1) {
136
183
  for (const listener of relayListeners)
137
184
  listener(payload.body, { path, from: senderNodeHash })
@@ -142,6 +189,8 @@ export function createOverlayRouter(registry, ttl = 3) {
142
189
  path,
143
190
  idx: index + 1,
144
191
  body: payload.body,
192
+ nodePubKey: originPubKey,
193
+ sig: sigHex,
145
194
  })
146
195
  }
147
196
  }
@@ -166,7 +215,7 @@ export function createOverlayRouter(registry, ttl = 3) {
166
215
  pendingRoutes.delete(reqId)
167
216
  reject(new Error(`overlay: route discovery timeout for ${targetNodeHash}`))
168
217
  }, timeoutMs)
169
- pendingRoutes.set(reqId, { resolve, reject, timer })
218
+ pendingRoutes.set(reqId, { resolve, reject, timer, target: targetNodeHash })
170
219
  })
171
220
  for (const { nodeHash } of registry.listLinks())
172
221
  await sendOverlay(nodeHash, {
@@ -187,7 +236,15 @@ export function createOverlayRouter(registry, ttl = 3) {
187
236
  async relay(path, body) {
188
237
  if (path?.[0] !== selfNodeHash || path.length < 2)
189
238
  throw new Error('overlay: invalid relay path')
190
- await sendOverlay(path[1], { action: 'relay', path, idx: 1, body })
239
+ const signature = await sign(relaySignBytes(path, body), secretKey)
240
+ await sendOverlay(path[1], {
241
+ action: 'relay',
242
+ path,
243
+ idx: 1,
244
+ body,
245
+ nodePubKey: selfPubKey,
246
+ sig: Buffer.from(signature).toString('hex'),
247
+ })
191
248
  },
192
249
  /**
193
250
  * 订阅 relay 到达本节点(路径末端)的载荷。
@@ -0,0 +1,4 @@
1
+ {
2
+ "overlayRatePerMin": 120,
3
+ "overlayRateBurst": 30
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -84,7 +84,7 @@
84
84
  "optionalDependencies": {
85
85
  "@stoprocent/bleno": "latest",
86
86
  "@stoprocent/noble": "latest",
87
- "node-datachannel": "0.33.0"
87
+ "node-datachannel": "latest"
88
88
  },
89
89
  "allowScripts": {
90
90
  "node-datachannel": true
package/pages/index.html CHANGED
@@ -17,7 +17,7 @@
17
17
  </head>
18
18
  <body>
19
19
  <h1>fount 节点人口统计</h1>
20
- <p>基于 Nostr census 事件(kind 30789,<code>t=fount</code> / <code>x=census</code>)的采样估计。本页为独立前端实现,仅做展示:在线节点数 = <code>Σ(1/p)</code>(Horvitz–Thompson 估计)。</p>
20
+ <p>基于 Nostr census 事件(kind 30789,<code>t=fount</code> / <code>x=census</code>)的采样估计。监听逻辑由包内 <code>census_monitor</code> 封装:自动监听默认/传入 relay 并经 NIP-66 发现更多,取人口最大的 relay 作为显示源。在线节点数 = <code>Σ(1/p)</code>(Horvitz–Thompson 估计)。</p>
21
21
  <label>
22
22
  <input type="checkbox" id="toggle" checked>
23
23
  人口统计(census)
@@ -30,7 +30,7 @@
30
30
  <div class="card">
31
31
  <div>采样事件数 <span id="sample-size">0</span> · 窗口内事件 <span id="window-events">0</span></div>
32
32
  </div>
33
- <footer id="footer" style="opacity:.6;font-size:.85rem"></footer>
34
- <script type="module" src="./app.mjs"></script>
33
+ <footer id="footer" style="opacity:.6;font-size:.85rem">通过 URL 参数 ?relay=wss://... 指定中继;缺省监听默认 relay。</footer>
34
+ <script type="module" src="./index.mjs"></script>
35
35
  </body>
36
36
  </html>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * fount 人口统计前端(薄壳,逻辑由包内 `census_monitor` 封装)。
3
+ * - `createPopulationMonitor` 自动监听全部默认(或传入)relay、经 NIP-66 发现更多,
4
+ * 并取人口估计最大的 relay 作为显示源,把 `{ estimate, sampleSize, eventsInWindow, relayUrl, relays }`
5
+ * 快照连同 relay 地址传给 onUpdate。
6
+ * - 页面只负责:导入监控器、传显示函数与可选 relay 地址。
7
+ */
8
+ import { createPopulationMonitor } from 'https://esm.sh/@steve02081504/fount-p2p/discovery/nostr/census_monitor.mjs'
9
+
10
+ const statusEl = document.querySelector('#status')
11
+ const toggleEl = document.querySelector('#toggle')
12
+
13
+ const params = new URLSearchParams(location.search)
14
+ const relayUrl = params.get('relay')
15
+
16
+ /** @type {{ stop: () => void } | null} */
17
+ let monitor = null
18
+
19
+ /**
20
+ * @param {{ estimate: number, sampleSize: number, eventsInWindow: number }} snapshot 快照
21
+ * @returns {void}
22
+ */
23
+ function render(snapshot) {
24
+ document.querySelector('#estimate').textContent = snapshot.estimate.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
25
+ document.querySelector('#sample-size').textContent = String(snapshot.sampleSize)
26
+ document.querySelector('#window-events').textContent = String(snapshot.eventsInWindow)
27
+ }
28
+
29
+ /** @returns {void} 启动 live 监控(默认/传入 relay 全交给库) */
30
+ function startLive() {
31
+ if (monitor) return
32
+ try {
33
+ monitor = createPopulationMonitor({
34
+ relays: relayUrl ? [relayUrl] : undefined,
35
+ onUpdate: snapshot => {
36
+ render(snapshot)
37
+ statusEl.textContent = `监听 ${snapshot.relayUrl}(census,共 ${snapshot.relays} 个 relay)`
38
+ },
39
+ })
40
+ }
41
+ catch (error) {
42
+ statusEl.textContent = `中继配置无效:${String(error?.message || error)}`
43
+ }
44
+ }
45
+
46
+ /** @returns {void} 停止监控 */
47
+ function stopLive() {
48
+ monitor?.stop()
49
+ monitor = null
50
+ }
51
+
52
+ toggleEl.addEventListener('change', () => {
53
+ if (!toggleEl.checked) {
54
+ stopLive()
55
+ render({ estimate: 0, sampleSize: 0, eventsInWindow: 0 })
56
+ statusEl.textContent = '已关闭'
57
+ return
58
+ }
59
+ startLive()
60
+ })
61
+
62
+ if (toggleEl.checked) startLive()
@@ -57,7 +57,7 @@ export function computeRecidivismMultiplier(streak, tunables = reputationTunable
57
57
  * @returns {NonNullable<ReputationFile['byNodeHash'][string]>} 节点行(可变引用)
58
58
  */
59
59
  function ensureRow(data, nodeId) {
60
- if (!data.byNodeHash[nodeId]) data.byNodeHash[nodeId] = { score: 0 }
60
+ if (!Object.hasOwn(data.byNodeHash, nodeId)) data.byNodeHash[nodeId] = { score: 0 }
61
61
  return data.byNodeHash[nodeId]
62
62
  }
63
63
 
@@ -147,7 +147,8 @@ export function incrementBadInviteeCount(data, nodeId, badDelta = 1) {
147
147
  * @returns {ReputationFile} 补齐字段后的同一对象
148
148
  */
149
149
  export function ensureReputationShape(data) {
150
- data.byNodeHash ??= {}
150
+ // 无原型字典:键来自不可信 nodeHash,普通 `{}` 会让 `__proto__` 命中 Object.prototype(原型污染)。
151
+ data.byNodeHash = Object.assign(Object.create(null), data.byNodeHash ?? {})
151
152
  data.wantUnknownHits ??= []
152
153
  data.relayBumpSeen ??= []
153
154
  return data
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
 
3
3
  import { canonicalStringify } from '../core/canonical_json.mjs'
4
- import { isHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
5
5
  import { isPlainObject } from '../core/object.mjs'
6
6
  import { parsePartpath } from '../core/partpath.mjs'
7
7
 
@@ -24,6 +24,8 @@ import partQueryTunables from './part_query.tunables.json' with { type: 'json' }
24
24
  * requestId: string
25
25
  * fromNodeHash: string
26
26
  * rows: unknown[]
27
+ * nodePubKey: string
28
+ * sig: string
27
29
  * }} PartQueryRes
28
30
  */
29
31
 
@@ -132,9 +134,14 @@ export function parsePartQueryRes(value, tunables = partQueryTunables) {
132
134
  if (!requestId) return null
133
135
  const fromNodeHash = isHex64(value.fromNodeHash)
134
136
  if (!fromNodeHash) return null
137
+ // 响应必须自证来源:nodePubKey 的哈希即 fromNodeHash,sig 覆盖 (requestId, fromNodeHash, rows)。
138
+ const nodePubKey = isHex64(value.nodePubKey)
139
+ if (!nodePubKey) return null
140
+ const sig = isSignatureHex128(value.sig)
141
+ if (!sig) return null
135
142
  const rows = clampPartQueryRows(value.rows, tunables.maxHits, tunables.maxRowsBytes)
136
143
  if (!rows) return null
137
- return { requestId, fromNodeHash, rows }
144
+ return { requestId, fromNodeHash, rows, nodePubKey, sig }
138
145
  }
139
146
 
140
147
  /**
@@ -5,9 +5,30 @@ import { decryptNodeSignalPacket, sendNodeSignalPacket } from '../discovery/inde
5
5
  import { listLinkProviders } from '../link/providers/index.mjs'
6
6
  import { nodeDebug, shortHash } from '../node/log.mjs'
7
7
 
8
+ import { loadTransportTunables } from './tunables.mjs'
9
+
8
10
  /** accept/dial 挂起期间 ICE 信令 backlog 上限 */
9
11
  const SIGNAL_BACKLOG_MAX = 64
10
12
 
13
+ /** 并发信令会话上限(按插入序淘汰最旧)。 */
14
+ const MAX_SIGNAL_SESSIONS = Math.max(1, Math.floor(Number(loadTransportTunables().maxSignalSessions) || 256))
15
+
16
+ /**
17
+ * 入站 offer 可被攻击者用任意 connId 无上限创建会话;建新会话前淘汰到低于上限。
18
+ * @param {Map<string, { clear: () => void }>} sessions 信令会话表
19
+ * @param {number} maxSize 上限
20
+ * @returns {void}
21
+ */
22
+ export function ensureSignalSessionBudget(sessions, maxSize) {
23
+ const cap = Math.max(1, Math.floor(Number(maxSize) || 1))
24
+ while (sessions.size >= cap) {
25
+ const oldest = sessions.keys().next().value
26
+ if (oldest === undefined) return
27
+ sessions.get(oldest)?.clear?.()
28
+ sessions.delete(oldest)
29
+ }
30
+ }
31
+
11
32
  /**
12
33
  * @param {(message: unknown) => Promise<void>} sendRemote 远端发送回调
13
34
  * @returns {object} 信令会话
@@ -114,7 +135,8 @@ export function createOfferAnswerDial(deps) {
114
135
  */
115
136
  async function buildConnLink({ provider, remoteNodeHash, connId, session, initiator }) {
116
137
  try {
117
- if (initiator) await trimToBudget()
138
+ // 被动 accept 同样受活跃链路预算约束,否则攻击者可无上限占用连接。
139
+ await trimToBudget()
118
140
  const link = await (initiator ? provider.dial : provider.accept)({
119
141
  nodeHash: remoteNodeHash,
120
142
  signal: session,
@@ -165,6 +187,7 @@ export function createOfferAnswerDial(deps) {
165
187
  peer: shortHash(remoteNodeHash),
166
188
  provider: provider.id,
167
189
  })
190
+ ensureSignalSessionBudget(signalSessions, MAX_SIGNAL_SESSIONS)
168
191
  session = createConnSession(remoteNodeHash, connId)
169
192
  signalSessions.set(connId, session)
170
193
  void buildConnLink({ provider, remoteNodeHash, connId, session, initiator: false })
@@ -195,6 +218,7 @@ export function createOfferAnswerDial(deps) {
195
218
  async function dialOfferAnswer(provider, remoteNodeHash) {
196
219
  const connId = randomBytes(16).toString('hex')
197
220
  const session = createConnSession(remoteNodeHash, connId)
221
+ ensureSignalSessionBudget(signalSessions, MAX_SIGNAL_SESSIONS)
198
222
  signalSessions.set(connId, session)
199
223
  return await buildConnLink({ provider, remoteNodeHash, connId, session, initiator: true })
200
224
  }
@@ -7,5 +7,6 @@
7
7
  "meshNLow": 4,
8
8
  "meshKMaxLow": 2,
9
9
  "groupMemberScanIntervalMs": 30000,
10
- "groupMemberScanLimit": 64
10
+ "groupMemberScanLimit": 64,
11
+ "maxSignalSessions": 256
11
12
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 令牌桶限速:桶容量 burst,补充速率 perMin/分钟。
3
+ * @param {Map<string, { tokens: number, updatedAt: number }>} buckets 每键桶状态
4
+ * @param {string} key 桶键(如发送方 nodeHash)
5
+ * @param {number} now 当前时间戳(ms)
6
+ * @param {{ perMin: number, burst: number }} limits 限速参数
7
+ * @returns {boolean} 是否允许本次消费
8
+ */
9
+ export function consumeToken(buckets, key, now, limits) {
10
+ const perMin = Math.max(1, limits.perMin)
11
+ const burst = Math.max(1, limits.burst)
12
+ const refillPerMs = perMin / 60_000
13
+ let bucket = buckets.get(key)
14
+ if (!bucket) bucket = { tokens: burst, updatedAt: now }
15
+ const elapsed = Math.max(0, now - bucket.updatedAt)
16
+ bucket.tokens = Math.min(burst, bucket.tokens + elapsed * refillPerMs)
17
+ bucket.updatedAt = now
18
+ if (bucket.tokens < 1) {
19
+ buckets.set(key, bucket)
20
+ return false
21
+ }
22
+ bucket.tokens -= 1
23
+ buckets.set(key, bucket)
24
+ return true
25
+ }
@@ -61,7 +61,7 @@ export function attachPartQueryWire(wireContext, wire, dependencies = {}) {
61
61
  part_query_res(data, peerId) {
62
62
  const response = parsePartQueryRes(data)
63
63
  if (!response) return
64
- handleIncomingPartQueryResponse(response, peerId, deps)
64
+ void handleIncomingPartQueryResponse(response, peerId, deps)
65
65
  },
66
66
  })
67
67
  }
package/pages/app.mjs DELETED
@@ -1,172 +0,0 @@
1
- /**
2
- * fount 人口统计前端(独立实现,不依赖包内代码)。
3
- * - 浏览器原生 WebSocket 订阅 Nostr 中继 kind=30789 / #t=fount / #x=census。
4
- * - 本地用 @noble 验签(懒加载,仅 live 模式需要)。
5
- * - 在线节点数估计 = Σ(1/p)(HT)。
6
- * - `?demo=1`(或无 relay 参数)进入演示模式:喂入 20 条 p=0.1 的样本 → 估计 200。
7
- */
8
- const CENSUS_KIND = 30789
9
- const CENSUS_TTL_MS = 10 * 60_000
10
- const CENSUS_SUB_ID = 'census'
11
- const CENSUS_MIN_P = 0.001
12
- /** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
13
- const MAX_FRAME_BYTES = 16 * 1024
14
- /** 并发验签任务上限;达上限时多余帧直接丢弃。 */
15
- const MAX_INFLIGHT_VERIFICATIONS = 8
16
-
17
- const statusEl = document.querySelector('#status')
18
- const toggleEl = document.querySelector('#toggle')
19
-
20
- const params = new URLSearchParams(location.search)
21
- const relayUrl = params.get('relay')
22
- const demoMode = params.get('demo') === '1' || !relayUrl
23
-
24
- /** @type {Map<string, { p: number, at: number }>} */
25
- const events = new Map()
26
- let webSocket = null
27
- let enabled = true
28
- let inflightVerifications = 0
29
-
30
- /**
31
- * HT 估计;遍历时逐出过期事件,避免窗口 Map 持续增长。
32
- * @returns {{ estimate: number, sampleSize: number }} 估计与采样数
33
- */
34
- function estimatePopulation() {
35
- let total = 0
36
- let sampleSize = 0
37
- const now = Date.now()
38
- for (const [hash, { p, at }] of events) {
39
- if (!Number.isFinite(at) || now - at > CENSUS_TTL_MS) { events.delete(hash); continue }
40
- total += 1 / p
41
- sampleSize++
42
- }
43
- return { estimate: total, sampleSize }
44
- }
45
-
46
- let noblePromise = null
47
- /** @returns {Promise<[object, object]>} noble 模块 */
48
- function loadNoble() {
49
- return noblePromise ||= Promise.all([
50
- import('https://esm.sh/@noble/curves@1/ed25519.js'),
51
- import('https://esm.sh/@noble/hashes@1/sha2.js'),
52
- ])
53
- }
54
-
55
- /**
56
- * 校验 census 包:nodeHash=sha256(nodePubKey) 且 Ed25519 签名匹配 `fount-census\0ts\0nodeHash\0p`。
57
- * @param {object} packet 原始包
58
- * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 验签结果
59
- */
60
- async function verifyCensusPacket(packet) {
61
- const nodeHash = /^[\da-f]{64}$/u.test(String(packet?.nodeHash ?? '')) ? packet.nodeHash : null
62
- const nodePubKey = /^[\da-f]{64}$/u.test(String(packet?.nodePubKey ?? '')) ? packet.nodePubKey : null
63
- const sig = /^[\da-f]{128}$/u.test(String(packet?.sig ?? '')) ? packet.sig : null
64
- const ts = Number(packet?.ts)
65
- const p = Number(packet?.p)
66
- if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
67
- if (Math.abs(Date.now() - ts) > CENSUS_TTL_MS) return null
68
- if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
69
- const [{ ed25519 }, { sha256 }] = await loadNoble()
70
- const pubBytes = new Uint8Array(nodePubKey.match(/.{2}/gu).map(hex => parseInt(hex, 16)))
71
- if ([...sha256(pubBytes)].map(byte => byte.toString(16).padStart(2, '0')).join('') !== nodeHash) return null
72
- const ok = ed25519.verify(
73
- new Uint8Array(sig.match(/.{2}/gu).map(hex => parseInt(hex, 16))),
74
- new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`),
75
- pubBytes,
76
- )
77
- return ok ? { nodeHash, p, ts } : null
78
- }
79
-
80
- /** @param {{ nodeHash: string, p: number, ts: number }} verified 已验签 census 包 @returns {void} */
81
- function ingest(verified) {
82
- const existing = events.get(verified.nodeHash)
83
- if (existing && verified.ts < existing.at) return
84
- events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
85
- render()
86
- }
87
-
88
- /** @returns {void} 渲染人口估计 */
89
- function render() {
90
- const { estimate, sampleSize } = estimatePopulation()
91
- document.querySelector('#estimate').textContent = estimate.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
92
- document.querySelector('#sample-size').textContent = String(sampleSize)
93
- document.querySelector('#window-events').textContent = String(events.size)
94
- }
95
-
96
- /** @returns {void} 注入演示数据 */
97
- function seedDemo() {
98
- events.clear()
99
- for (let index = 0; index < 20; index++)
100
- events.set((index + 1).toString(16).padStart(2, '0').repeat(32), { p: 0.1, at: Date.now() })
101
- statusEl.textContent = '演示模式:20 条 p=0.1 样本(HT 估计 = 200)'
102
- document.querySelector('#footer').textContent = '演示模式样本为虚构数据;添加 ?relay=wss://... 进入 live 模式。'
103
- }
104
-
105
- /** @returns {void} 连接中继并订阅 */
106
- function connectRelay() {
107
- if (webSocket) {
108
- try { webSocket.close() } catch { /* ignore */ }
109
- webSocket = null
110
- }
111
- if (!relayUrl || !enabled) return
112
- statusEl.textContent = `连接 ${relayUrl} …`
113
- const connection = new WebSocket(relayUrl)
114
- webSocket = connection
115
- /** WebSocket 已连接后发送 REQ 订阅 census 事件。 */
116
- connection.onopen = () => {
117
- if (connection !== webSocket || !enabled) return
118
- connection.send(JSON.stringify(['REQ', CENSUS_SUB_ID, { kinds: [CENSUS_KIND], '#t': ['fount'], '#x': ['census'] }]))
119
- statusEl.textContent = `监听 ${relayUrl}(kind ${CENSUS_KIND})`
120
- }
121
- /**
122
- * @param {MessageEvent} rawMessage 中继消息事件
123
- * @returns {void}
124
- */
125
- connection.onmessage = rawMessage => {
126
- if (connection !== webSocket || !enabled) return
127
- if (inflightVerifications >= MAX_INFLIGHT_VERIFICATIONS) return
128
- if (typeof rawMessage.data !== 'string' || rawMessage.data.length > MAX_FRAME_BYTES) return
129
- let parsed
130
- try { parsed = JSON.parse(String(rawMessage.data)) } catch { return }
131
- if (parsed?.[0] !== 'EVENT' || parsed[1] !== CENSUS_SUB_ID) return
132
- if (parsed[2]?.kind !== CENSUS_KIND) return
133
- const content = parsed[2]?.content
134
- if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
135
- inflightVerifications++
136
- void (async () => {
137
- try {
138
- const verified = await verifyCensusPacket(JSON.parse(atob(content)))
139
- if (connection !== webSocket || !enabled) return
140
- if (verified) ingest(verified)
141
- }
142
- catch { /* ignore malformed */ }
143
- finally { inflightVerifications-- }
144
- })()
145
- }
146
- /** WebSocket 关闭时更新状态为已断开。 */
147
- connection.onclose = () => {
148
- if (connection !== webSocket || !enabled) return
149
- statusEl.textContent = '已断开'
150
- }
151
- /** WebSocket 出错时提示中继连接失败。 */
152
- connection.onerror = () => {
153
- if (connection !== webSocket || !enabled) return
154
- statusEl.textContent = '中继连接失败'
155
- }
156
- }
157
-
158
- toggleEl.addEventListener('change', () => {
159
- enabled = toggleEl.checked
160
- if (!enabled) {
161
- events.clear()
162
- if (webSocket) { try { webSocket.close() } catch { /* ignore */ } webSocket = null }
163
- statusEl.textContent = '已关闭'
164
- }
165
- else if (demoMode) seedDemo()
166
- else connectRelay()
167
- render()
168
- })
169
-
170
- if (demoMode) seedDemo()
171
- else connectRelay()
172
- render()