@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/AGENTS.md +3 -0
- package/discovery/lan_interfaces.mjs +20 -3
- package/discovery/nostr/census.mjs +9 -62
- package/discovery/nostr/census_monitor.mjs +403 -0
- package/discovery/nostr/census_verify.mjs +65 -0
- package/discovery/nostr/constants.mjs +17 -0
- package/discovery/nostr/index.mjs +1 -1
- package/discovery/nostr/relays.mjs +1 -7
- package/docs/evfs.md +1 -0
- package/docs/transports.md +4 -0
- package/docs/wire.md +6 -0
- package/federation/part_query/cache.mjs +19 -4
- package/federation/part_query/runtime.mjs +186 -43
- package/files/chunk/fetch.mjs +5 -0
- package/files/chunk/pending.mjs +7 -7
- package/infra/service.mjs +2 -28
- package/mailbox/deliver_or_store.mjs +4 -0
- package/overlay/index.mjs +66 -9
- package/overlay/tunables.json +4 -0
- package/package.json +2 -2
- package/pages/index.html +3 -3
- package/pages/index.mjs +62 -0
- package/reputation/engine.mjs +3 -2
- package/schemas/part_query.mjs +9 -2
- package/transport/offer_answer.mjs +25 -1
- package/transport/tunables.json +2 -1
- package/utils/token_bucket.mjs +25 -0
- package/wire/part/query.mjs +1 -1
- package/pages/app.mjs +0 -172
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)
|
|
10
|
-
let overlayRateGate =
|
|
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 ||
|
|
41
|
+
overlayRateGate = rateGate || defaultOverlayRateGate
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
/**
|
|
22
|
-
*
|
|
45
|
+
* 清除自定义 overlay 限速门,恢复默认限速。
|
|
23
46
|
* @returns {void}
|
|
24
47
|
*/
|
|
25
48
|
export function clearOverlayRateGate() {
|
|
26
|
-
overlayRateGate =
|
|
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
|
-
|
|
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 到达本节点(路径末端)的载荷。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve02081504/fount-p2p",
|
|
3
|
-
"version": "0.0.
|
|
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": "
|
|
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
|
|
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"
|
|
34
|
-
<script type="module" src="./
|
|
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>
|
package/pages/index.mjs
ADDED
|
@@ -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()
|
package/reputation/engine.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/schemas/part_query.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/transport/tunables.json
CHANGED
|
@@ -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
|
+
}
|
package/wire/part/query.mjs
CHANGED
|
@@ -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()
|