@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/AGENTS.md
CHANGED
|
@@ -24,6 +24,8 @@ Detail docs: [transports](docs/transports.md) · [mesh](docs/mesh.md) · [signal
|
|
|
24
24
|
| `crypto/crypto.mjs` | Node + browser | `@noble/hashes` + `@noble/curves` only — never `node:crypto` |
|
|
25
25
|
| `crypto/key.mjs` / `crypto/channel.mjs`, disk I/O, LAN/BT, `ws`, CLI / `startNode` | Node (+ Deno bridge) | Do not load the whole package via esm.sh in the browser |
|
|
26
26
|
|
|
27
|
+
**Browser pages reusing package code:** pages import browser-safe package modules (`core/*`, `crypto/crypto.mjs`, `discovery/nostr/constants|_math|_verify|_monitor`) from esm.sh; their bare `@noble/*` deps resolve via an injected importmap. The frontend static test server (`test/frontend/census_page.test.mjs`) serves the whole package root (offline), injects an importmap into served HTML (`https://esm.sh/@steve02081504/fount-p2p/` → local package root, `@noble/` → `https://esm.sh/@noble/`), and runs the page live against a fake relay (`test/helpers/fake_relay.mjs` with `store`), injecting its `ws://` URL via `?relay=` — no demo mode. Page code stays a thin shell — pass display fn + optional relays; logic lives in the package (`discovery/nostr/census_monitor.mjs`).
|
|
28
|
+
|
|
27
29
|
Deno / native / BT: [runtime.md](docs/runtime.md).
|
|
28
30
|
|
|
29
31
|
## Conventions
|
|
@@ -33,6 +35,7 @@ Deno / native / BT: [runtime.md](docs/runtime.md).
|
|
|
33
35
|
- **Heterogeneous backends:** normalize at the load boundary (e.g. `link/rtc/ice_local_hostname.mjs` wraps W3C RTC backends); call sites speak one contract.
|
|
34
36
|
- **File naming:** parent directory is scope — short child names. Tunables default `<dir>/tunables.json` (exception: `schemas/part_query.tunables.json`). Subpath `package.json` exports mirror filenames.
|
|
35
37
|
- **Import boundary:** `test/integration/p2p_shell_import_guard.test.mjs`.
|
|
38
|
+
- **Security fixes are not backward compatible:** fount guarantees the whole network upgrades to the latest build together. Fix wire formats directly — no version fields, no compat branches.
|
|
36
39
|
- **No scattered `trim` / `toLowerCase`:** hex IDs must already be lowercase and without a `0x` prefix — a `0x`-prefixed, mixed-case, or whitespace value is rejected by `isHex64`/`isEntityHash128`, never cleaned. Exceptions: JSONL blank lines, SDP fingerprint, CLI/`scripts` parsing.
|
|
37
40
|
- **No `String(x)` / `x || ''` on typed `string`:** if `@param {string}`, use it directly; `String(...)` / `|| ''` / `?? ''` only at optional / `unknown` / disk / inbound boundaries, or number→string.
|
|
38
41
|
- **Optional methods:** `if (fn) return await fn(...)` / `if (fn) …` — never `typeof x === 'function'`.
|
|
@@ -3,12 +3,29 @@ import os from 'node:os'
|
|
|
3
3
|
/** advert / 组播 beacon 携带的 LAN IPv4 上限 */
|
|
4
4
|
export const MAX_LAN_HOSTS = 4
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* 私网 / 链路本地 IPv4(RFC1918 + 169.254/16)。
|
|
8
|
+
* advert 自报地址只允许这些:否则攻击者可借受害者的拨号对内网/公网发起 TCP 探测(SSRF)。
|
|
9
|
+
* @param {string} host 候选 IPv4
|
|
10
|
+
* @returns {boolean} 是否允许作为可拨号 LAN hint
|
|
11
|
+
*/
|
|
12
|
+
function isPrivateLanIpv4(host) {
|
|
13
|
+
const parts = host.split('.')
|
|
14
|
+
if (parts.length !== 4) return false
|
|
15
|
+
const octets = parts.map(part => Number(part))
|
|
16
|
+
if (octets.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
17
|
+
const [a, b] = octets
|
|
18
|
+
if (a === 10) return true
|
|
19
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
20
|
+
if (a === 192 && b === 168) return true
|
|
21
|
+
if (a === 169 && b === 254) return true
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
/**
|
|
9
26
|
* untrusted ingress:清洗 advert body 中的 LAN IPv4 列表。
|
|
10
27
|
* @param {unknown} input 原始 lanHosts
|
|
11
|
-
* @returns {string[]}
|
|
28
|
+
* @returns {string[]} 去重后的私网/链路本地 IPv4 列表
|
|
12
29
|
*/
|
|
13
30
|
export function normalizeLanHosts(input) {
|
|
14
31
|
if (!input) return []
|
|
@@ -18,7 +35,7 @@ export function normalizeLanHosts(input) {
|
|
|
18
35
|
const out = []
|
|
19
36
|
for (const item of arr) {
|
|
20
37
|
const host = String(item || '')
|
|
21
|
-
if (!host || !
|
|
38
|
+
if (!host || !isPrivateLanIpv4(host) || seen.has(host)) continue
|
|
22
39
|
seen.add(host)
|
|
23
40
|
out.push(host)
|
|
24
41
|
if (out.length >= MAX_LAN_HOSTS) break
|
|
@@ -15,45 +15,33 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Buffer } from 'node:buffer'
|
|
17
17
|
|
|
18
|
-
import { isHex64
|
|
19
|
-
import { keyPairFromSeed, pubKeyHash, sign
|
|
18
|
+
import { isHex64 } from '../../core/hexIds.mjs'
|
|
19
|
+
import { keyPairFromSeed, pubKeyHash, sign } from '../../crypto/crypto.mjs'
|
|
20
20
|
import { ensureNodeSeed, getNodeHash } from '../../node/identity.mjs'
|
|
21
21
|
import { getP2PFeatures, isNodeInitialized } from '../../node/instance.mjs'
|
|
22
22
|
import { nodeDebug } from '../../node/log.mjs'
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
-
|
|
25
|
+
CENSUS_TAG_FOUNT,
|
|
26
|
+
CENSUS_TAG_X,
|
|
27
|
+
NOSTR_CENSUS_KIND,
|
|
28
|
+
} from './constants.mjs'
|
|
29
|
+
import {
|
|
26
30
|
CENSUS_TARGET_EVENTS,
|
|
27
31
|
estimatePopulation,
|
|
28
32
|
nextInclusionProbability,
|
|
29
33
|
} from './census_math.mjs'
|
|
34
|
+
import { CENSUS_TTL_MS, buildCensusMessage, verifyCensusBytes } from './census_verify.mjs'
|
|
30
35
|
import { resolveRelayConnectTarget } from './relays.mjs'
|
|
31
36
|
|
|
32
|
-
/**
|
|
33
|
-
export const NOSTR_CENSUS_KIND = 30789
|
|
34
|
-
|
|
35
|
-
/** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
|
|
36
|
-
const CENSUS_TAG_FOUNT = 'fount'
|
|
37
|
-
const CENSUS_TAG_X = 'census'
|
|
37
|
+
/** census 订阅标签数组(发布用)。 */
|
|
38
38
|
const CENSUS_TAGS = [['t', CENSUS_TAG_FOUNT], ['x', CENSUS_TAG_X]]
|
|
39
39
|
|
|
40
|
-
/** 事件/窗口存活时间(与 advert TTL 一致)。 */
|
|
41
|
-
const CENSUS_TTL_MS = 10 * 60_000
|
|
42
40
|
/** 发布/统计周期。 */
|
|
43
41
|
const CENSUS_INTERVAL_MS = 10 * 60_000
|
|
44
42
|
/** 冷启动初始包含概率。 */
|
|
45
43
|
const CENSUS_INITIAL_P = 0.5
|
|
46
44
|
|
|
47
|
-
/**
|
|
48
|
-
* @param {number} ts 时间戳(毫秒)
|
|
49
|
-
* @param {string} nodeHash 64 hex 节点 hash
|
|
50
|
-
* @param {number} p 包含概率
|
|
51
|
-
* @returns {Buffer} 待签名消息
|
|
52
|
-
*/
|
|
53
|
-
function buildCensusMessage(ts, nodeHash, p) {
|
|
54
|
-
return Buffer.from(`fount-census\0${ts}\0${nodeHash}\0${p}`, 'utf8')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
45
|
/**
|
|
58
46
|
* 用指定 seed 身份构建签名 census 包(nodeHash 由 seed 派生)。
|
|
59
47
|
* 供工具与测试构造任意身份的 peer 包。
|
|
@@ -75,47 +63,6 @@ export async function buildCensusPacketFromSeed(seedHex, { p, ts = Date.now() })
|
|
|
75
63
|
}
|
|
76
64
|
}
|
|
77
65
|
|
|
78
|
-
/**
|
|
79
|
-
* 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
|
|
80
|
-
* @param {unknown} packet 原始 census 包
|
|
81
|
-
* @param {number} [now=Date.now()] 当前时间(毫秒)
|
|
82
|
-
* @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
|
|
83
|
-
* @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
|
|
84
|
-
*/
|
|
85
|
-
export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
|
|
86
|
-
const nodeHash = isHex64(packet?.nodeHash)
|
|
87
|
-
const nodePubKey = isHex64(packet?.nodePubKey)
|
|
88
|
-
const sig = isSignatureHex128(packet?.sig)
|
|
89
|
-
const ts = Number(packet?.ts)
|
|
90
|
-
const p = Number(packet?.p)
|
|
91
|
-
if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
|
|
92
|
-
if (Math.abs(now - ts) > ttlMs) return null
|
|
93
|
-
if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
|
|
94
|
-
try {
|
|
95
|
-
if (pubKeyHash(Buffer.from(nodePubKey, 'hex')) !== nodeHash) return null
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
100
|
-
return await verify(Buffer.from(sig, 'hex'), buildCensusMessage(ts, nodeHash, p), Buffer.from(nodePubKey, 'hex')) ? { nodeHash, p, ts } : null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* 解 base64 content 字节并校验 census 包。
|
|
105
|
-
* @param {Uint8Array} bytes content 解码字节
|
|
106
|
-
* @param {number} [now=Date.now()] 当前时间(毫秒)
|
|
107
|
-
* @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
|
|
108
|
-
* @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
|
|
109
|
-
*/
|
|
110
|
-
export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
|
|
111
|
-
try {
|
|
112
|
-
return await verifyCensusPacket(JSON.parse(Buffer.from(bytes).toString('utf8')), now, ttlMs)
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return null
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
66
|
/** 窗口事件:nodeHash → { p, at }(模块级,与 visibleByHash 同模式)。 */
|
|
120
67
|
const censusEvents = new Map()
|
|
121
68
|
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器端人口统计监控器(无 Node 依赖;WebSocket + Ed25519 验签)。
|
|
3
|
+
*
|
|
4
|
+
* 供 pages 等纯前端消费:`createPopulationMonitor({ onUpdate, relays, signal })`。
|
|
5
|
+
* 开始监听即自动:
|
|
6
|
+
* 1. 连接全部默认(或传入)relay,订阅 census 事件(kind 30789,t=fount / x=census);
|
|
7
|
+
* 2. 经 NIP-66(kind 30166)发现更多 relay 并加入监听(断开自动指数退避重连);
|
|
8
|
+
* 3. 每轮刷新取人口估计最大的 relay 作为显示源,把
|
|
9
|
+
* `{ estimate, sampleSize, eventsInWindow, relayUrl, relays }` 快照传给 onUpdate。
|
|
10
|
+
*/
|
|
11
|
+
import { base64ToBytes } from '../../core/bytes_codec.mjs'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
CENSUS_TAG_FOUNT,
|
|
15
|
+
CENSUS_TAG_X,
|
|
16
|
+
DEFAULT_RELAY_URLS,
|
|
17
|
+
NIP66_BOOTSTRAP_RELAYS,
|
|
18
|
+
NOSTR_CENSUS_KIND,
|
|
19
|
+
} from './constants.mjs'
|
|
20
|
+
import { estimatePopulation } from './census_math.mjs'
|
|
21
|
+
import { CENSUS_TTL_MS, verifyCensusBytes } from './census_verify.mjs'
|
|
22
|
+
|
|
23
|
+
/** NIP-66 relay 注册 kind。 */
|
|
24
|
+
const NIP66_KIND = 30166
|
|
25
|
+
/** NIP-66 发现周期。 */
|
|
26
|
+
const NIP66_REFRESH_MS = 30 * 60_000
|
|
27
|
+
/** 单次 NIP-66 REQ 上限。 */
|
|
28
|
+
const NIP66_REQ_LIMIT = 300
|
|
29
|
+
/** 监控 relay 数上限(防 socket 爆炸)。 */
|
|
30
|
+
const MAX_MONITOR_RELAYS = 32
|
|
31
|
+
/** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
|
|
32
|
+
const MAX_FRAME_BYTES = 16 * 1024
|
|
33
|
+
/** 并发验签任务上限;达上限时多余帧直接丢弃。 */
|
|
34
|
+
const MAX_INFLIGHT_VERIFICATIONS = 8
|
|
35
|
+
/** 单次连接超时。 */
|
|
36
|
+
const CONNECT_TIMEOUT_MS = 10_000
|
|
37
|
+
/** 断线重连指数退避:初始延迟与上限。 */
|
|
38
|
+
const RECONNECT_BASE_MS = 1_000
|
|
39
|
+
const RECONNECT_MAX_MS = 30_000
|
|
40
|
+
/** 事件入库后刷新防抖。 */
|
|
41
|
+
const REFRESH_DEBOUNCE_MS = 300
|
|
42
|
+
/** 无事件时周期刷新默认间隔(也用于 TTL 逐出)。 */
|
|
43
|
+
const DEFAULT_REFRESH_MS = 5_000
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 规范化 Nostr relay URL(浏览器端轻量版):仅 ws/wss,小写 host,去默认端口与尾部斜杠。
|
|
47
|
+
* @param {unknown} raw 原始 URL
|
|
48
|
+
* @returns {string | null} 规范化字符串,无效返回 null
|
|
49
|
+
*/
|
|
50
|
+
function normalizeRelayUrl(raw) {
|
|
51
|
+
const value = String(raw || '').trim()
|
|
52
|
+
if (!value) return null
|
|
53
|
+
let url
|
|
54
|
+
try { url = new URL(value) } catch { return null }
|
|
55
|
+
if (url.protocol !== 'wss:' && url.protocol !== 'ws:') return null
|
|
56
|
+
if (!url.hostname) return null
|
|
57
|
+
url.hostname = url.hostname.toLowerCase()
|
|
58
|
+
if (url.port && ((url.protocol === 'wss:' && url.port === '443') || (url.protocol === 'ws:' && url.port === '80')))
|
|
59
|
+
url.port = ''
|
|
60
|
+
const path = url.pathname.replace(/\/+$/, '') || '/'
|
|
61
|
+
url.pathname = path
|
|
62
|
+
return url.toString().replace(/\/$/, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {{
|
|
67
|
+
* url: string,
|
|
68
|
+
* ws: WebSocket | null,
|
|
69
|
+
* subId: string,
|
|
70
|
+
* events: Map<string, { p: number, at: number }>,
|
|
71
|
+
* inflight: number,
|
|
72
|
+
* reconnectDelayMs: number,
|
|
73
|
+
* reconnectTimer: ReturnType<typeof setTimeout> | null,
|
|
74
|
+
* }} RelaySession
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 创建人口统计监控器:立即连接并监听,返回 `{ stop }` 控制句柄。
|
|
79
|
+
* @param {{
|
|
80
|
+
* onUpdate: (snapshot: { estimate: number, sampleSize: number, eventsInWindow: number, relayUrl: string, relays: number }) => void,
|
|
81
|
+
* relays?: string[],
|
|
82
|
+
* signal?: AbortSignal,
|
|
83
|
+
* refreshMs?: number,
|
|
84
|
+
* discover?: boolean,
|
|
85
|
+
* nip66Bootstrap?: string[],
|
|
86
|
+
* }} options 选项
|
|
87
|
+
* @returns {{ stop: () => void }} 停止函数
|
|
88
|
+
*/
|
|
89
|
+
export function createPopulationMonitor(options = {}) {
|
|
90
|
+
const {
|
|
91
|
+
onUpdate,
|
|
92
|
+
relays,
|
|
93
|
+
signal,
|
|
94
|
+
refreshMs = DEFAULT_REFRESH_MS,
|
|
95
|
+
discover = true,
|
|
96
|
+
nip66Bootstrap = NIP66_BOOTSTRAP_RELAYS,
|
|
97
|
+
} = options
|
|
98
|
+
if (typeof onUpdate !== 'function')
|
|
99
|
+
throw new Error('p2p: population monitor requires onUpdate callback')
|
|
100
|
+
/** @type {string[]} */
|
|
101
|
+
const relayUrls = []
|
|
102
|
+
for (const raw of relays && relays.length ? relays : DEFAULT_RELAY_URLS) {
|
|
103
|
+
const url = normalizeRelayUrl(raw)
|
|
104
|
+
if (url && !relayUrls.includes(url)) relayUrls.push(url)
|
|
105
|
+
}
|
|
106
|
+
if (!relayUrls.length)
|
|
107
|
+
throw new Error('p2p: population monitor requires at least one relay url')
|
|
108
|
+
|
|
109
|
+
let stopped = false
|
|
110
|
+
/** @type {Map<string, RelaySession>} */
|
|
111
|
+
const sessions = new Map()
|
|
112
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
113
|
+
let refreshTimer = null
|
|
114
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
115
|
+
let refreshDebounce = null
|
|
116
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
117
|
+
let discoveryTimer = null
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} url relay URL
|
|
121
|
+
* @returns {RelaySession} 新会话
|
|
122
|
+
*/
|
|
123
|
+
function createSession(url) {
|
|
124
|
+
return {
|
|
125
|
+
url,
|
|
126
|
+
ws: null,
|
|
127
|
+
subId: '',
|
|
128
|
+
events: new Map(),
|
|
129
|
+
inflight: 0,
|
|
130
|
+
reconnectDelayMs: RECONNECT_BASE_MS,
|
|
131
|
+
reconnectTimer: null,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 连接会话(含指数退避重连调度)。
|
|
137
|
+
* @param {RelaySession} session 会话
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
function connectSession(session) {
|
|
141
|
+
if (stopped || session.ws) return
|
|
142
|
+
const ws = new WebSocket(session.url)
|
|
143
|
+
session.ws = ws
|
|
144
|
+
const connectTimer = setTimeout(() => { try { ws.close() } catch { /* ignore */ } }, CONNECT_TIMEOUT_MS)
|
|
145
|
+
/**
|
|
146
|
+
* WebSocket 已连接:订阅 census 事件。
|
|
147
|
+
* @returns {void}
|
|
148
|
+
*/
|
|
149
|
+
const onOpen = () => {
|
|
150
|
+
if (session.ws !== ws) return
|
|
151
|
+
clearTimeout(connectTimer)
|
|
152
|
+
session.reconnectDelayMs = RECONNECT_BASE_MS
|
|
153
|
+
session.subId = 'census-' + Math.random().toString(36).slice(2)
|
|
154
|
+
ws.send(JSON.stringify(['REQ', session.subId, {
|
|
155
|
+
kinds: [NOSTR_CENSUS_KIND],
|
|
156
|
+
'#t': [CENSUS_TAG_FOUNT],
|
|
157
|
+
'#x': [CENSUS_TAG_X],
|
|
158
|
+
}]))
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* @param {MessageEvent} event WebSocket 消息事件
|
|
162
|
+
* @returns {void}
|
|
163
|
+
*/
|
|
164
|
+
const onMessage = event => {
|
|
165
|
+
if (session.ws !== ws) return
|
|
166
|
+
handleRelayMessage(session, event.data)
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 连接关闭:清会话并调度重连。
|
|
170
|
+
* @returns {void}
|
|
171
|
+
*/
|
|
172
|
+
const onClose = () => {
|
|
173
|
+
clearTimeout(connectTimer)
|
|
174
|
+
if (session.ws !== ws) return
|
|
175
|
+
session.ws = null
|
|
176
|
+
scheduleReconnect(session)
|
|
177
|
+
}
|
|
178
|
+
ws.addEventListener('open', onOpen)
|
|
179
|
+
ws.addEventListener('message', onMessage)
|
|
180
|
+
ws.addEventListener('close', onClose)
|
|
181
|
+
ws.addEventListener('error', () => { /* close follows error */ })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 指数退避调度重连(去重保护)。
|
|
186
|
+
* @param {RelaySession} session 会话
|
|
187
|
+
* @returns {void}
|
|
188
|
+
*/
|
|
189
|
+
function scheduleReconnect(session) {
|
|
190
|
+
if (stopped || session.reconnectTimer) return
|
|
191
|
+
session.reconnectTimer = setTimeout(() => {
|
|
192
|
+
session.reconnectTimer = null
|
|
193
|
+
connectSession(session)
|
|
194
|
+
}, session.reconnectDelayMs)
|
|
195
|
+
session.reconnectDelayMs = Math.min(session.reconnectDelayMs * 2, RECONNECT_MAX_MS)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 处理单条 relay 消息:验签 census 帧并入库。
|
|
200
|
+
* @param {RelaySession} session 会话
|
|
201
|
+
* @param {unknown} data 消息数据
|
|
202
|
+
* @returns {void}
|
|
203
|
+
*/
|
|
204
|
+
function handleRelayMessage(session, data) {
|
|
205
|
+
if (session.inflight >= MAX_INFLIGHT_VERIFICATIONS) return
|
|
206
|
+
if (typeof data !== 'string' || data.length > MAX_FRAME_BYTES) return
|
|
207
|
+
let parsed
|
|
208
|
+
try { parsed = JSON.parse(data) } catch { return }
|
|
209
|
+
if (parsed?.[0] !== 'EVENT' || parsed[1] !== session.subId) return
|
|
210
|
+
if (parsed[2]?.kind !== NOSTR_CENSUS_KIND) return
|
|
211
|
+
const content = parsed[2]?.content
|
|
212
|
+
if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
|
|
213
|
+
session.inflight++
|
|
214
|
+
void (async () => {
|
|
215
|
+
try {
|
|
216
|
+
const verified = await verifyCensusBytes(base64ToBytes(content))
|
|
217
|
+
if (!verified) return
|
|
218
|
+
const existing = session.events.get(verified.nodeHash)
|
|
219
|
+
if (existing && verified.ts < existing.at) return
|
|
220
|
+
session.events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
|
|
221
|
+
scheduleRefresh()
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore malformed */ }
|
|
224
|
+
finally { session.inflight-- }
|
|
225
|
+
})()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 会话快照(逐出过期事件后 HT 估计)。
|
|
230
|
+
* @param {RelaySession} session 会话
|
|
231
|
+
* @returns {{ estimate: number, sampleSize: number, eventsInWindow: number }} 快照
|
|
232
|
+
*/
|
|
233
|
+
function sessionSnapshot(session) {
|
|
234
|
+
const now = Date.now()
|
|
235
|
+
const events = []
|
|
236
|
+
for (const [hash, event] of session.events)
|
|
237
|
+
if (now - event.at > CENSUS_TTL_MS) session.events.delete(hash)
|
|
238
|
+
else events.push(event)
|
|
239
|
+
const { estimate, sampleSize } = estimatePopulation(events)
|
|
240
|
+
return { estimate, sampleSize, eventsInWindow: events.length }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 取人口估计最大的 relay 作为显示源,回调 onUpdate。
|
|
245
|
+
* @returns {void}
|
|
246
|
+
*/
|
|
247
|
+
function refresh() {
|
|
248
|
+
let best = null
|
|
249
|
+
for (const session of sessions.values()) {
|
|
250
|
+
const snapshot = sessionSnapshot(session)
|
|
251
|
+
if (!best || snapshot.estimate > best.snapshot.estimate)
|
|
252
|
+
best = { url: session.url, snapshot }
|
|
253
|
+
}
|
|
254
|
+
const chosen = best || { url: relayUrls[0], snapshot: { estimate: 0, sampleSize: 0, eventsInWindow: 0 } }
|
|
255
|
+
onUpdate({
|
|
256
|
+
...chosen.snapshot,
|
|
257
|
+
relayUrl: chosen.url,
|
|
258
|
+
relays: sessions.size,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 防抖调度刷新。
|
|
264
|
+
* @returns {void}
|
|
265
|
+
*/
|
|
266
|
+
function scheduleRefresh() {
|
|
267
|
+
if (refreshDebounce || stopped) return
|
|
268
|
+
refreshDebounce = setTimeout(() => {
|
|
269
|
+
refreshDebounce = null
|
|
270
|
+
refresh()
|
|
271
|
+
}, REFRESH_DEBOUNCE_MS)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 单轮 NIP-66 发现:从引导集 + 现有会话拉 kind 30166 候选,加入监听(有界)。
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
async function runDiscovery() {
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
const candidates = []
|
|
281
|
+
/** @type {string[]} */
|
|
282
|
+
const sources = []
|
|
283
|
+
for (const url of [...nip66Bootstrap, ...sessions.keys()])
|
|
284
|
+
if (!sources.includes(url)) sources.push(url)
|
|
285
|
+
await Promise.allSettled(sources.map(url => collectNip66Candidates(url, candidate => {
|
|
286
|
+
if (!candidates.includes(candidate)) candidates.push(candidate)
|
|
287
|
+
})))
|
|
288
|
+
for (const url of candidates) {
|
|
289
|
+
if (stopped || sessions.size >= MAX_MONITOR_RELAYS) break
|
|
290
|
+
if (sessions.has(url)) continue
|
|
291
|
+
const session = createSession(url)
|
|
292
|
+
sessions.set(url, session)
|
|
293
|
+
connectSession(session)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 从单个中继收集 NIP-66 候选(d tag,wss)。
|
|
299
|
+
* @param {string} relayUrl 发现源中继
|
|
300
|
+
* @param {(candidate: string) => void} onCandidate 候选回调
|
|
301
|
+
* @returns {Promise<void>}
|
|
302
|
+
*/
|
|
303
|
+
function collectNip66Candidates(relayUrl, onCandidate) {
|
|
304
|
+
return new Promise(resolve => {
|
|
305
|
+
let ws
|
|
306
|
+
let settled = false
|
|
307
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
308
|
+
let quietTimer = null
|
|
309
|
+
/**
|
|
310
|
+
* @returns {void}
|
|
311
|
+
*/
|
|
312
|
+
const finish = () => {
|
|
313
|
+
if (settled) return
|
|
314
|
+
settled = true
|
|
315
|
+
clearTimeout(timer)
|
|
316
|
+
if (quietTimer) clearTimeout(quietTimer)
|
|
317
|
+
try { ws?.close() } catch { /* ignore */ }
|
|
318
|
+
resolve()
|
|
319
|
+
}
|
|
320
|
+
/** 收到候选后 500ms 无新候选即结束(兼容不响应 EOSE 的中继)。 */
|
|
321
|
+
const armQuiet = () => {
|
|
322
|
+
if (quietTimer) clearTimeout(quietTimer)
|
|
323
|
+
quietTimer = setTimeout(finish, 500)
|
|
324
|
+
}
|
|
325
|
+
const timer = setTimeout(finish, CONNECT_TIMEOUT_MS)
|
|
326
|
+
try { ws = new WebSocket(relayUrl) } catch { finish(); return }
|
|
327
|
+
/**
|
|
328
|
+
* @param {MessageEvent} event WebSocket 消息事件
|
|
329
|
+
* @returns {void}
|
|
330
|
+
*/
|
|
331
|
+
const onMessage = event => {
|
|
332
|
+
if (typeof event.data !== 'string') return
|
|
333
|
+
let parsed
|
|
334
|
+
try { parsed = JSON.parse(event.data) } catch { return }
|
|
335
|
+
if (parsed?.[0] === 'EOSE') { finish(); return }
|
|
336
|
+
if (parsed?.[0] !== 'EVENT' || parsed[2]?.kind !== NIP66_KIND) return
|
|
337
|
+
const d = (parsed[2]?.tags || []).find(tag => tag?.[0] === 'd')?.[1]
|
|
338
|
+
const url = normalizeRelayUrl(d)
|
|
339
|
+
if (url) {
|
|
340
|
+
onCandidate(url)
|
|
341
|
+
armQuiet()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
ws.addEventListener('open', () => {
|
|
345
|
+
const subId = 'nip66-' + Math.random().toString(36).slice(2)
|
|
346
|
+
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP66_KIND, 10166], limit: NIP66_REQ_LIMIT }]))
|
|
347
|
+
})
|
|
348
|
+
ws.addEventListener('message', onMessage)
|
|
349
|
+
ws.addEventListener('close', finish)
|
|
350
|
+
ws.addEventListener('error', () => { /* close follows error */ })
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 启动周期 NIP-66 发现。
|
|
356
|
+
* @returns {void}
|
|
357
|
+
*/
|
|
358
|
+
function startDiscovery() {
|
|
359
|
+
void runDiscovery().catch(() => { })
|
|
360
|
+
discoveryTimer = setInterval(() => { void runDiscovery().catch(() => { }) }, NIP66_REFRESH_MS)
|
|
361
|
+
discoveryTimer.unref?.()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 停止监控:关闭全部连接与定时器。
|
|
366
|
+
* @returns {void}
|
|
367
|
+
*/
|
|
368
|
+
function stop() {
|
|
369
|
+
if (stopped) return
|
|
370
|
+
stopped = true
|
|
371
|
+
signal?.removeEventListener('abort', stop)
|
|
372
|
+
if (refreshTimer) clearInterval(refreshTimer)
|
|
373
|
+
refreshTimer = null
|
|
374
|
+
if (refreshDebounce) clearTimeout(refreshDebounce)
|
|
375
|
+
refreshDebounce = null
|
|
376
|
+
if (discoveryTimer) clearInterval(discoveryTimer)
|
|
377
|
+
discoveryTimer = null
|
|
378
|
+
for (const session of sessions.values()) {
|
|
379
|
+
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
|
380
|
+
session.reconnectTimer = null
|
|
381
|
+
try { session.ws?.close() } catch { /* ignore */ }
|
|
382
|
+
session.ws = null
|
|
383
|
+
}
|
|
384
|
+
sessions.clear()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const url of relayUrls) {
|
|
388
|
+
const session = createSession(url)
|
|
389
|
+
sessions.set(url, session)
|
|
390
|
+
connectSession(session)
|
|
391
|
+
}
|
|
392
|
+
if (discover) startDiscovery()
|
|
393
|
+
refresh()
|
|
394
|
+
if (refreshMs > 0) {
|
|
395
|
+
refreshTimer = setInterval(refresh, refreshMs)
|
|
396
|
+
refreshTimer.unref?.()
|
|
397
|
+
}
|
|
398
|
+
if (signal) {
|
|
399
|
+
if (signal.aborted) { stop(); return { stop } }
|
|
400
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
401
|
+
}
|
|
402
|
+
return { stop }
|
|
403
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* census 包验签(Untrusted ingress;Node 与浏览器共用,无 Node 依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 签名体:{ nodeHash, nodePubKey, ts, p, sig },消息 `fount-census\0ts\0nodeHash\0p`,
|
|
5
|
+
* 用节点身份(Ed25519)签名,nodeHash = sha256(nodePubKey)。
|
|
6
|
+
*/
|
|
7
|
+
import { hexToBytes } from '../../core/bytes_codec.mjs'
|
|
8
|
+
import { isHex64, isSignatureHex128 } from '../../core/hexIds.mjs'
|
|
9
|
+
import { pubKeyHash, verify } from '../../crypto/crypto.mjs'
|
|
10
|
+
|
|
11
|
+
import { CENSUS_MIN_P } from './census_math.mjs'
|
|
12
|
+
|
|
13
|
+
/** 事件/窗口存活时间(与 advert TTL 一致)。 */
|
|
14
|
+
export const CENSUS_TTL_MS = 10 * 60_000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {number} ts 时间戳(毫秒)
|
|
18
|
+
* @param {string} nodeHash 64 hex 节点 hash
|
|
19
|
+
* @param {number} p 包含概率
|
|
20
|
+
* @returns {Uint8Array} 待签名消息
|
|
21
|
+
*/
|
|
22
|
+
export function buildCensusMessage(ts, nodeHash, p) {
|
|
23
|
+
return new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 校验 census 包(Untrusted ingress):canonicalize + 验签 + 时间窗 + p 范围。
|
|
28
|
+
* @param {unknown} packet 原始 census 包
|
|
29
|
+
* @param {number} [now=Date.now()] 当前时间(毫秒)
|
|
30
|
+
* @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
|
|
31
|
+
* @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过返回 nodeHash/p/ts,否则 null
|
|
32
|
+
*/
|
|
33
|
+
export async function verifyCensusPacket(packet, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
|
|
34
|
+
const nodeHash = isHex64(packet?.nodeHash)
|
|
35
|
+
const nodePubKey = isHex64(packet?.nodePubKey)
|
|
36
|
+
const sig = isSignatureHex128(packet?.sig)
|
|
37
|
+
const ts = Number(packet?.ts)
|
|
38
|
+
const p = Number(packet?.p)
|
|
39
|
+
if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
|
|
40
|
+
if (Math.abs(now - ts) > ttlMs) return null
|
|
41
|
+
if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
|
|
42
|
+
try {
|
|
43
|
+
if (pubKeyHash(hexToBytes(nodePubKey)) !== nodeHash) return null
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
return await verify(hexToBytes(sig), buildCensusMessage(ts, nodeHash, p), hexToBytes(nodePubKey)) ? { nodeHash, p, ts } : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 解 base64 content 字节并校验 census 包。
|
|
53
|
+
* @param {Uint8Array} bytes content 解码字节
|
|
54
|
+
* @param {number} [now=Date.now()] 当前时间(毫秒)
|
|
55
|
+
* @param {number} [ttlMs=CENSUS_TTL_MS] 允许的时间窗
|
|
56
|
+
* @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 校验通过结果或 null
|
|
57
|
+
*/
|
|
58
|
+
export async function verifyCensusBytes(bytes, now = Date.now(), ttlMs = CENSUS_TTL_MS) {
|
|
59
|
+
try {
|
|
60
|
+
return await verifyCensusPacket(JSON.parse(new TextDecoder().decode(bytes)), now, ttlMs)
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -41,3 +41,20 @@ export const NIP66_BOOTSTRAP_RELAYS = [
|
|
|
41
41
|
'wss://relaypag.es',
|
|
42
42
|
'wss://monitorlizard.nostr1.com',
|
|
43
43
|
]
|
|
44
|
+
|
|
45
|
+
/** 默认公共中继(source='public',永不淘汰;浏览器端人口统计监控亦以此为默认监听集)。 */
|
|
46
|
+
export const DEFAULT_RELAY_URLS = [
|
|
47
|
+
'wss://relay.nostr.com',
|
|
48
|
+
'wss://nos.lol',
|
|
49
|
+
'wss://nostr.bitcoiner.social',
|
|
50
|
+
'wss://nostr.mom',
|
|
51
|
+
'wss://relay.snort.social',
|
|
52
|
+
'wss://relay.primal.net',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Nostr census 事件 kind(每节点每窗口至多一条,按 nodeHash 去重)。 */
|
|
56
|
+
export const NOSTR_CENSUS_KIND = 30789
|
|
57
|
+
|
|
58
|
+
/** census 订阅/发布标签:`t=fount` + `x=census`(subscribeNostrKind 以 rendezvousKey/tagX 匹配)。 */
|
|
59
|
+
export const CENSUS_TAG_FOUNT = 'fount'
|
|
60
|
+
export const CENSUS_TAG_X = 'census'
|
|
@@ -18,9 +18,9 @@ import {
|
|
|
18
18
|
} from '../internal/signal_crypto.mjs'
|
|
19
19
|
import { noteDiscoveryPeerClue } from '../peer_clue.mjs'
|
|
20
20
|
|
|
21
|
+
import { DEFAULT_RELAY_URLS } from './constants.mjs'
|
|
21
22
|
import { createNostrCensus } from './census.mjs'
|
|
22
23
|
import {
|
|
23
|
-
DEFAULT_RELAY_URLS,
|
|
24
24
|
getListenRelays,
|
|
25
25
|
getPeerRoute,
|
|
26
26
|
getPoolByUrl,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../../node/storage.mjs'
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
|
+
DEFAULT_RELAY_URLS,
|
|
18
19
|
DEFAULT_RTT_MS,
|
|
19
20
|
FAILURE_WEIGHT,
|
|
20
21
|
LAST_GOOD_RELAYS_MAX,
|
|
@@ -29,13 +30,6 @@ import {
|
|
|
29
30
|
} from './constants.mjs'
|
|
30
31
|
import { connectRelay, dedupeRelayUrls, pinnedLookup } from './session.mjs'
|
|
31
32
|
|
|
32
|
-
/** 默认公共中继(source='public',永不淘汰)。 */
|
|
33
|
-
export const DEFAULT_RELAY_URLS = [
|
|
34
|
-
'wss://relay.damus.io',
|
|
35
|
-
'wss://nos.lol',
|
|
36
|
-
'wss://relay.primal.net',
|
|
37
|
-
]
|
|
38
|
-
|
|
39
33
|
/** 持久化写盘节流延迟。 */
|
|
40
34
|
const FLUSH_DELAY_MS = 2_000
|
|
41
35
|
/** probe 单次连接超时。 */
|