dsh-lan-peers 0.8.3 → 0.8.4

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/host/server.mjs CHANGED
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * GET /v1/health 无认证(capability: ask/pair/pair-ecdh/stream)
9
9
  * POST /v1/pair 配对码(无 ecdh = 0.3.1 明文路径;有 ecdh = ECDH 第一轮,不消费码)
10
+ * 失败按来源 IP 记数退避,超限 429 rate-limited(S1)
10
11
  * POST /v1/pair/confirm 配对码消费 + ECDH 第二轮(wrapped 交换双方密钥)
11
12
  * POST /v1/ask 签名 + allowRemotePrompts
13
+ * 按 peerId 令牌桶 + 全局会话上限,超限 429 rate-limited(H4)
12
14
  * GET /v1/answer?sessionId=<id>[&waitMs=] 签名(waitMs>0 时服务端 hold 成长轮询)
13
15
  * GET /v1/events?sessionId=<id> 签名 + SSE(每条事件独立加密)
14
16
  * GET /v1/peers 签名
@@ -118,7 +120,7 @@ import { parseArgs } from 'node:util';
118
120
  // ---------------------------------------------------------------------------
119
121
 
120
122
  /** 插件版本,用于 /v1/health.version */
121
- export const PLUGIN_VERSION = '0.8.3';
123
+ export const PLUGIN_VERSION = '0.8.4';
122
124
 
123
125
  /** §4.1 允许的时钟偏移 */
124
126
  export const CLOCK_SKEW_MS = 60_000;
@@ -229,6 +231,32 @@ export const DEFAULT_TRUSTED_CIDRS = Object.freeze([
229
231
 
230
232
  const MAX_SESSIONS = 500;
231
233
 
234
+ /**
235
+ * S1(安全审计):/v1/pair 与 /v1/pair/confirm 曾经没有任何失败计数 ——
236
+ * 6 位码(10^6)在 10 分钟 TTL 内被 16 并发穷举穿。这里补的是**按来源 IP** 的
237
+ * 失败计数 + 指数退避:第 n 次失败后压 1s·2^(n-1),上限 30s;累计 5 次锁 30s;
238
+ * 累计 20 次直接作废当前码并清零(对手已经把空间试穿一角,留着码没有意义)。
239
+ */
240
+ export const PAIR_FAIL_BACKOFF_BASE_MS = 1_000;
241
+ export const PAIR_FAIL_BACKOFF_MAX_MS = 30_000;
242
+ export const PAIR_FAIL_LOCK_THRESHOLD = 5;
243
+ export const PAIR_FAIL_LOCK_MS = 30_000;
244
+ export const PAIR_FAIL_INVALIDATE_THRESHOLD = 20;
245
+ /** 失败计数表上限(LRU 淘汰):来源 IP 由外网决定,表不能无界 */
246
+ export const MAX_PAIR_RATE_ENTRIES = 1_000;
247
+
248
+ /**
249
+ * H4(安全审计):/v1/ask 曾经没有任何频率/并发限制 —— 配对成功即可无限起会话,
250
+ * MAX_SESSIONS 只淘汰 map 记录、不终止 runner 会话。这里补两个旋钮:
251
+ * askRatePerMin 每个 peerId 的令牌桶(默认 6 次/分钟)
252
+ * maxConcurrentSessions 全局在跑会话上限(默认 8)
253
+ */
254
+ export const DEFAULT_ASK_RATE_PER_MIN = 6;
255
+ export const ASK_RATE_WINDOW_MS = 60_000;
256
+ export const DEFAULT_MAX_CONCURRENT_SESSIONS = 8;
257
+ /** 并发名额占满时的重试建议(毫秒):下一条会话什么时候让位无法预测,给个固定提示 */
258
+ export const ASK_BUSY_RETRY_MS = 1_000;
259
+
232
260
  const GROUP_FILE = 'group.json';
233
261
 
234
262
  /** 尾部斜杠(地址归一化共用;写成常量是为了别在正则字面量里反复转义) */
@@ -491,6 +519,14 @@ function normalizeAnswerWaitMs(value) {
491
519
  return Math.min(Math.floor(numeric), ANSWER_WAIT_MAX_MS);
492
520
  }
493
521
 
522
+ /** 正整数旋钮(S1/H4):非法值一律回落默认,不抛错(老配置里没有这些键) */
523
+ function normalizeCount(value, fallback, { min = 1, max = 10_000 } = {}) {
524
+ if (value == null || value === '') return fallback;
525
+ const numeric = Math.floor(Number(value));
526
+ if (!Number.isFinite(numeric) || numeric < min) return fallback;
527
+ return Math.min(numeric, max);
528
+ }
529
+
494
530
  export function resolveConfig(overrides = {}) {
495
531
  const stateDir = expandHome(overrides.stateDir ?? defaultStateDir());
496
532
  const name = String(overrides.name ?? os.hostname());
@@ -527,6 +563,11 @@ export function resolveConfig(overrides = {}) {
527
563
  // §6 v0.4.0:配对模式(auto 同时接受 ECDH 与旧明文)与长轮询 hold 上限
528
564
  pairingMode: normalizePairingMode(overrides.pairingMode),
529
565
  answerWaitMs: normalizeAnswerWaitMs(overrides.answerWaitMs),
566
+ // 安全审计 S1/H4:两条限流默认开启,显式 false 关掉(自建场景与针对性用例用)
567
+ pairRateLimit: overrides.pairRateLimit !== false,
568
+ askRateLimit: overrides.askRateLimit !== false,
569
+ askRatePerMin: normalizeCount(overrides.askRatePerMin, DEFAULT_ASK_RATE_PER_MIN),
570
+ maxConcurrentSessions: normalizeCount(overrides.maxConcurrentSessions, DEFAULT_MAX_CONCURRENT_SESSIONS),
530
571
  // §2 v0.5.0 群:默认关闭,hub 留空表示本机是 hub
531
572
  group: normalizeGroupConfig(overrides.group),
532
573
  };
@@ -876,6 +917,14 @@ export class StateStore {
876
917
  return { ok: true, expiresAt: Number(pair.expiresAt ?? 0) };
877
918
  }
878
919
 
920
+ /**
921
+ * S1:作废当前码(累计失败到阈值时用)。
922
+ * 只清内存里那枚码,不动 state.json —— 配对码本来就不落盘(M6)。
923
+ */
924
+ clearPairingCode() {
925
+ this.#pairingCode = null;
926
+ }
927
+
879
928
  #readPeers() {
880
929
  const loaded = readJsonFile(this.peersPath);
881
930
  if (!loaded) return { peers: {} };
@@ -2427,6 +2476,135 @@ export function createPeerServer(overrides = {}) {
2427
2476
  return { ok: true, peerId, peer };
2428
2477
  }
2429
2478
 
2479
+ // ---- S1:/v1/pair 失败计数 + 指数退避(按来源 IP) ----------------------
2480
+ //
2481
+ // 状态只在进程内存:配对码本身就不落盘(M6),退避状态跟着码走才合理 ——
2482
+ // 重启后计数归零可以接受(攻击者重启不了别人机器上的服务),而写盘会多一条
2483
+ // 需要保护、需要清理、会写坏的文件。
2484
+ // 表里只有计数与时间戳:**不存配对码正文、不存 IP 明文到日志**。
2485
+
2486
+ /** ip -> { fails, backoffUntil, lockedUntil }(LRU,容量 MAX_PAIR_RATE_ENTRIES) */
2487
+ const pairFailures = new Map();
2488
+
2489
+ /** 归一化来源地址(IPv4-mapped 的 ::ffff:1.2.3.4 与 zone id 都要压平) */
2490
+ function clientAddress(req) {
2491
+ const raw = String(req?.socket?.remoteAddress ?? '');
2492
+ const normalized = raw.replace(/^::ffff:/i, '').split('%')[0].trim();
2493
+ return normalized === '' ? 'unknown' : normalized;
2494
+ }
2495
+
2496
+ function rememberPairFailure(ip, entry) {
2497
+ // 重新插入到队尾 = 最近有动静;超上限时丢最久没动的那条
2498
+ pairFailures.delete(ip);
2499
+ pairFailures.set(ip, entry);
2500
+ while (pairFailures.size > MAX_PAIR_RATE_ENTRIES) {
2501
+ const oldest = pairFailures.keys().next().value;
2502
+ pairFailures.delete(oldest);
2503
+ }
2504
+ }
2505
+
2506
+ /**
2507
+ * 退避 / 锁定期内一律 429,且**连码都不比较**(不消耗算力、不透露正确性)。
2508
+ * 关掉开关时直接放行,计数也不做(自建场景/测试要的就是老行为)。
2509
+ * 日志每个窗口只写一行:这条路径每秒能被踩上千次,逐次记录等于送给对手一个刷日志的开关。
2510
+ */
2511
+ function pairRateGate(req) {
2512
+ if (!config.pairRateLimit) return null;
2513
+ const entry = pairFailures.get(clientAddress(req));
2514
+ if (!entry) return null;
2515
+ const nowMs = config.now();
2516
+ const until = Math.max(Number(entry.backoffUntil ?? 0), Number(entry.lockedUntil ?? 0));
2517
+ if (until <= nowMs) return null;
2518
+ if (config.logger && entry.loggedUntil !== until) {
2519
+ entry.loggedUntil = until;
2520
+ config.logger(`pair: rate-limited (retryAfterMs=${until - nowMs})`);
2521
+ }
2522
+ return { status: 429, body: { error: 'rate-limited', retryAfterMs: until - nowMs } };
2523
+ }
2524
+
2525
+ function resetPairFailures(req) {
2526
+ if (!config.pairRateLimit) return;
2527
+ pairFailures.delete(clientAddress(req));
2528
+ }
2529
+
2530
+ /** 一次「码猜错了」:加计数、压退避、够阈值就锁,够更高阈值就作废当前码 */
2531
+ function notePairFailure(req, nowMs) {
2532
+ if (!config.pairRateLimit) return;
2533
+ const ip = clientAddress(req);
2534
+ const entry = pairFailures.get(ip) ?? { fails: 0, backoffUntil: 0, lockedUntil: 0 };
2535
+ entry.fails += 1;
2536
+ entry.backoffUntil = nowMs
2537
+ + Math.min(PAIR_FAIL_BACKOFF_BASE_MS * 2 ** (entry.fails - 1), PAIR_FAIL_BACKOFF_MAX_MS);
2538
+ if (entry.fails >= PAIR_FAIL_LOCK_THRESHOLD) {
2539
+ entry.lockedUntil = Math.max(entry.lockedUntil, nowMs + PAIR_FAIL_LOCK_MS);
2540
+ }
2541
+ if (entry.fails >= PAIR_FAIL_INVALIDATE_THRESHOLD) {
2542
+ // 对手已经把 10^6 的空间试穿了一角:当前码作废、挂起握手一并丢掉,计数归零
2543
+ store.clearPairingCode();
2544
+ pendingPairs.clear();
2545
+ pairFailures.delete(ip);
2546
+ if (config.logger) {
2547
+ config.logger(`pair: pairing code invalidated after ${entry.fails} failed attempts`);
2548
+ }
2549
+ return;
2550
+ }
2551
+ rememberPairFailure(ip, entry);
2552
+ if (entry.fails >= PAIR_FAIL_LOCK_THRESHOLD && config.logger) {
2553
+ config.logger(`pair: source locked after ${entry.fails} failed attempts (lockMs=${PAIR_FAIL_LOCK_MS})`);
2554
+ }
2555
+ }
2556
+
2557
+ /**
2558
+ * 猜对一次:重置该来源的计数,并作废当前码 ——
2559
+ * 配对成功后码本来就被 consume 掉了,这里是显式收口(也覆盖 ECDH 第二轮)。
2560
+ */
2561
+ function notePairSuccess(req) {
2562
+ resetPairFailures(req);
2563
+ store.clearPairingCode();
2564
+ }
2565
+
2566
+ // ---- H4:/v1/ask 的 peerId 令牌桶 + 全局会话上限 -------------------------
2567
+
2568
+ /** peerId -> { tokens, at }(LRU,容量同样是 1000 条) */
2569
+ const askBuckets = new Map();
2570
+ /** 限流日志节流:被拒请求可以成串到达,日志不该跟着成串 */
2571
+ let lastAskThrottleLogAt = -Infinity;
2572
+
2573
+ /** 取一枚令牌;取不到就给出 retryAfterMs(>0) */
2574
+ function takeAskToken(peerId, nowMs) {
2575
+ const capacity = config.askRatePerMin;
2576
+ const refillPerMs = capacity / ASK_RATE_WINDOW_MS;
2577
+ const bucket = askBuckets.get(peerId) ?? { tokens: capacity, at: nowMs };
2578
+ const elapsed = Math.max(0, nowMs - bucket.at);
2579
+ bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerMs);
2580
+ bucket.at = nowMs;
2581
+ let result;
2582
+ if (bucket.tokens >= 1) {
2583
+ bucket.tokens -= 1;
2584
+ result = { ok: true, retryAfterMs: 0 };
2585
+ } else {
2586
+ // 攒够 1 枚还需要多久(向上取整,避免返回 0 让客户端立刻重试)
2587
+ result = { ok: false, retryAfterMs: Math.max(1, Math.ceil((1 - bucket.tokens) / refillPerMs)) };
2588
+ }
2589
+ askBuckets.delete(peerId);
2590
+ askBuckets.set(peerId, bucket);
2591
+ while (askBuckets.size > MAX_PAIR_RATE_ENTRIES) {
2592
+ askBuckets.delete(askBuckets.keys().next().value);
2593
+ }
2594
+ return result;
2595
+ }
2596
+
2597
+ /** 正在跑的会话数:已结算或已过 deadline 的不占名额(MAX_SESSIONS 之外的另一把闸) */
2598
+ function runningSessionCount(nowMs) {
2599
+ let count = 0;
2600
+ for (const session of sessions.values()) {
2601
+ if (session.settled === true) continue;
2602
+ if (nowMs >= session.deadline) continue;
2603
+ count += 1;
2604
+ }
2605
+ return count;
2606
+ }
2607
+
2430
2608
  // ---- /v1/ask 的会话与超时 ----------------------------------------------
2431
2609
 
2432
2610
  function normalizeTimeoutSec(value) {
@@ -2436,13 +2614,41 @@ export function createPeerServer(overrides = {}) {
2436
2614
  return Math.min(Math.ceil(numeric), MAX_TIMEOUT_SEC);
2437
2615
  }
2438
2616
 
2617
+ /**
2618
+ * H4:淘汰会话时必须**显式终止 runner 会话**,否则 MAX_SESSIONS 只是丢账本 ——
2619
+ * runner 那边照跑,进程被拖垮,安全审计里那条「无限起会话」一点没修。
2620
+ * runner 提供了 cancel/close 就调用;没有就记一行日志说明无法取消(不编造成功)。
2621
+ */
2622
+ function cancelRunnerSession(session) {
2623
+ const cancel = typeof runner.cancel === 'function'
2624
+ ? runner.cancel
2625
+ : (typeof runner.close === 'function' ? runner.close : null);
2626
+ if (!cancel) {
2627
+ if (config.logger) config.logger('ask: pruned session has no runner cancel/close; runner session stays running');
2628
+ return;
2629
+ }
2630
+ try {
2631
+ const pending = cancel.call(runner, session.runnerSessionId);
2632
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
2633
+ } catch {
2634
+ if (config.logger) config.logger('ask: runner cancel failed while pruning a session');
2635
+ }
2636
+ }
2637
+
2439
2638
  function pruneSessions() {
2440
2639
  if (sessions.size <= MAX_SESSIONS) return;
2441
- const oldestFirst = [...sessions.values()].sort((a, b) => a.startedAt - b.startedAt);
2442
- while (sessions.size > MAX_SESSIONS && oldestFirst.length > 0) {
2443
- const session = oldestFirst.shift();
2640
+ // 先丢已结算的(没有副作用),再按 startedAt 由旧到新
2641
+ const victims = [...sessions.values()].sort((a, b) => {
2642
+ const left = a.settled === true ? 0 : 1;
2643
+ const right = b.settled === true ? 0 : 1;
2644
+ if (left !== right) return left - right;
2645
+ return a.startedAt - b.startedAt;
2646
+ });
2647
+ while (sessions.size > MAX_SESSIONS && victims.length > 0) {
2648
+ const session = victims.shift();
2444
2649
  sessions.delete(session.sessionId);
2445
2650
  requestIds.delete(`${session.peerId}\u0000${session.requestId}`);
2651
+ if (session.settled !== true) cancelRunnerSession(session);
2446
2652
  }
2447
2653
  }
2448
2654
 
@@ -2465,12 +2671,15 @@ export function createPeerServer(overrides = {}) {
2465
2671
  startedAt,
2466
2672
  timeoutSec,
2467
2673
  deadline: startedAt + timeoutSec * 1000,
2674
+ // H4:结算后释放全局并发名额(answerFor 是所有观测路径的唯一出口)
2675
+ settled: false,
2468
2676
  });
2469
2677
  requestIds.set(key, sessionId);
2470
2678
  pruneSessions();
2471
2679
  return { sessionId, reused: false };
2472
2680
  }
2473
2681
 
2682
+ /** 观测一次会话;done/error 即视为结算并释放 H4 的并发名额 */
2474
2683
  async function answerFor(session) {
2475
2684
  let result;
2476
2685
  try {
@@ -2481,14 +2690,19 @@ export function createPeerServer(overrides = {}) {
2481
2690
  return { status: 'error', error: 'runner-error' };
2482
2691
  }
2483
2692
  result = result ?? {};
2693
+ /** 终态(done/error/timeout)都算结算:名额立刻还给 H4 的并发闸 */
2694
+ const settle = (outcome) => {
2695
+ session.settled = true;
2696
+ return outcome;
2697
+ };
2484
2698
  if (result.status === 'done') {
2485
- return { status: 'done', reply: String(result.reply ?? '') };
2699
+ return settle({ status: 'done', reply: String(result.reply ?? '') });
2486
2700
  }
2487
2701
  if (result.status === 'error') {
2488
- return { status: 'error', error: String(result.error ?? 'runner-error') };
2702
+ return settle({ status: 'error', error: String(result.error ?? 'runner-error') });
2489
2703
  }
2490
2704
  // 会话已完成就不算超时;只有还在跑且过了 deadline 才算 timeout
2491
- if (config.now() >= session.deadline) return { status: 'error', error: 'timeout' };
2705
+ if (config.now() >= session.deadline) return settle({ status: 'error', error: 'timeout' });
2492
2706
  return { status: 'running' };
2493
2707
  }
2494
2708
 
@@ -2513,7 +2727,7 @@ export function createPeerServer(overrides = {}) {
2513
2727
  };
2514
2728
  }
2515
2729
 
2516
- function pair(rawBody) {
2730
+ function pair(req, rawBody) {
2517
2731
  let body;
2518
2732
  try {
2519
2733
  body = parseJsonBody(rawBody);
@@ -2521,7 +2735,7 @@ export function createPeerServer(overrides = {}) {
2521
2735
  return { status: 400, body: { error: 'bad-request' } };
2522
2736
  }
2523
2737
  // §2 v0.4.0:带 ecdh 走两轮握手(不消费码);不带则一字不改地保留 0.3.1 明文路径
2524
- if (body.ecdh !== undefined && body.ecdh !== null) return pairEcdhRound1(body);
2738
+ if (body.ecdh !== undefined && body.ecdh !== null) return pairEcdhRound1(req, body);
2525
2739
  if (config.pairingMode === 'ecdh') return { status: 400, body: { error: 'ecdh-required' } };
2526
2740
  const { code, peerId, peerName, secret } = body;
2527
2741
  if (!nonEmptyString(peerId, 200) || !nonEmptyString(peerName, 200) || !nonEmptyString(secret, 512)) {
@@ -2530,8 +2744,15 @@ export function createPeerServer(overrides = {}) {
2530
2744
  if (code == null || !/^\d{6}$/.test(String(code))) {
2531
2745
  return { status: 401, body: { error: 'invalid-code' } };
2532
2746
  }
2747
+ // S1:格式合法的码才算「猜了一次」;退避/锁定期内连比较都不做
2748
+ const gate = pairRateGate(req);
2749
+ if (gate) return gate;
2533
2750
  const consumed = store.consumePairingCode(String(code));
2534
- if (!consumed.ok) return { status: 401, body: { error: 'invalid-code' } };
2751
+ if (!consumed.ok) {
2752
+ notePairFailure(req, config.now());
2753
+ return { status: 401, body: { error: 'invalid-code' } };
2754
+ }
2755
+ notePairSuccess(req);
2535
2756
 
2536
2757
  // §3 step 3:B 保存 A 给的 S(B→A 用),并生成给 A 的 T(A→B 用)
2537
2758
  const inboundSecret = randomSecret();
@@ -2559,7 +2780,7 @@ export function createPeerServer(overrides = {}) {
2559
2780
  }
2560
2781
 
2561
2782
  /** §2 ECDH 第一轮:只发服务端临时公钥,**不消费**配对码 */
2562
- function pairEcdhRound1(body) {
2783
+ function pairEcdhRound1(req, body) {
2563
2784
  if (config.pairingMode === 'legacy') return { status: 400, body: { error: 'bad-request' } };
2564
2785
  const { code, peerId, peerName, ecdh } = body;
2565
2786
  if (!nonEmptyString(peerId, 200) || !nonEmptyString(peerName, 200)) {
@@ -2577,10 +2798,16 @@ export function createPeerServer(overrides = {}) {
2577
2798
  if (code == null || !/^\d{6}$/.test(String(code))) {
2578
2799
  return { status: 401, body: { error: 'invalid-code' } };
2579
2800
  }
2801
+ // S1:第一轮同样受退避约束(它是另一条可以无限试码的通道)
2802
+ const gate = pairRateGate(req);
2803
+ if (gate) return gate;
2580
2804
  // 只校验不消费:否则第一轮失败一次就把码废了
2581
2805
  if (!store.peekPairingCode(String(code)).ok) {
2806
+ notePairFailure(req, config.now());
2582
2807
  return { status: 401, body: { error: 'invalid-code' } };
2583
2808
  }
2809
+ // 猜对了但码还没被消费(confirm 才消费):只清计数,不动作废
2810
+ resetPairFailures(req);
2584
2811
  const nowMs = config.now();
2585
2812
  prunePendingPairs(nowMs);
2586
2813
  const ephemeral = generateKeyPair();
@@ -2612,7 +2839,7 @@ export function createPeerServer(overrides = {}) {
2612
2839
  * §2 ECDH 第二轮:W = HKDF(ECDH(serverPriv, clientPub)),解 wrapped 里的 S,
2613
2840
  * 成功才消费配对码、写 peer,再用同一把 W 回 wrapped({secret: T})。
2614
2841
  */
2615
- function pairConfirm(rawBody) {
2842
+ function pairConfirm(req, rawBody) {
2616
2843
  let body;
2617
2844
  try {
2618
2845
  body = parseJsonBody(rawBody);
@@ -2626,11 +2853,15 @@ export function createPeerServer(overrides = {}) {
2626
2853
  if (code == null || !/^\d{6}$/.test(String(code))) {
2627
2854
  return { status: 401, body: { error: 'invalid-code' } };
2628
2855
  }
2856
+ // S1:格式合法的码才进门(400 类请求不受退避影响,它们不构成一次猜测)
2857
+ const gate = pairRateGate(req);
2858
+ if (gate) return gate;
2629
2859
  prunePendingPairs(config.now());
2630
2860
  const pending = pendingPairs.get(String(code));
2631
2861
  if (!pending) {
2632
2862
  // 码还在但没有挂起的握手 → pairing-expired;码本身不对/已被用掉 → invalid-code
2633
2863
  const known = store.peekPairingCode(String(code)).ok;
2864
+ if (!known) notePairFailure(req, config.now());
2634
2865
  return { status: 401, body: { error: known ? 'pairing-expired' : 'invalid-code' } };
2635
2866
  }
2636
2867
  if (pending.peerId !== peerId) return { status: 400, body: { error: 'bad-request' } };
@@ -2652,6 +2883,7 @@ export function createPeerServer(overrides = {}) {
2652
2883
  const consumed = store.consumePairingCode(String(code));
2653
2884
  pendingPairs.delete(String(code));
2654
2885
  if (!consumed.ok) return { status: 401, body: { error: 'pairing-expired' } };
2886
+ notePairSuccess(req);
2655
2887
 
2656
2888
  // 与明文路径同一语义:存对端给的 S(我→对端),生成 T(对端→我)
2657
2889
  const inboundSecret = randomSecret();
@@ -2693,6 +2925,31 @@ export function createPeerServer(overrides = {}) {
2693
2925
  if (timeoutSec == null) {
2694
2926
  return { status: 400, body: { error: 'bad-request' }, peerId: auth.peerId };
2695
2927
  }
2928
+ // H4:同一 requestId 的重复请求不建新会话、也不应该白扣配额(幂等语义优先)
2929
+ const reuseKey = `${auth.peerId}\u0000${requestId}`;
2930
+ const reusedId = requestIds.get(reuseKey);
2931
+ const isReuse = reusedId !== undefined && sessions.has(reusedId);
2932
+ if (!isReuse && config.askRateLimit) {
2933
+ const nowMs = config.now();
2934
+ // 先看全局并发名额:占满时直接拒,连令牌都不动(被拒的请求不该被扣配额)
2935
+ const running = runningSessionCount(nowMs);
2936
+ if (running >= config.maxConcurrentSessions) {
2937
+ if (config.logger && nowMs - lastAskThrottleLogAt >= 10_000) {
2938
+ lastAskThrottleLogAt = nowMs;
2939
+ config.logger(`ask: rate-limited (running=${running} max=${config.maxConcurrentSessions})`);
2940
+ }
2941
+ return { status: 429, body: { error: 'rate-limited', retryAfterMs: ASK_BUSY_RETRY_MS }, peerId: auth.peerId };
2942
+ }
2943
+ const token = takeAskToken(auth.peerId, nowMs);
2944
+ if (!token.ok) {
2945
+ // 与 S1 同样的理由:被拒的请求可能连着来,日志全局 10s 一行即可
2946
+ if (config.logger && nowMs - lastAskThrottleLogAt >= 10_000) {
2947
+ lastAskThrottleLogAt = nowMs;
2948
+ config.logger('ask: rate-limited (per-peer token bucket empty)');
2949
+ }
2950
+ return { status: 429, body: { error: 'rate-limited', retryAfterMs: token.retryAfterMs }, peerId: auth.peerId };
2951
+ }
2952
+ }
2696
2953
  let created;
2697
2954
  try {
2698
2955
  created = await createAskSession({ peerId: auth.peerId, requestId, prompt, timeoutSec });
@@ -5893,8 +6150,8 @@ export function createPeerServer(overrides = {}) {
5893
6150
  return { status: 401, body: { error: 'group-auth-scope' } };
5894
6151
  }
5895
6152
  if (method === 'GET' && url.pathname === '/v1/health') return health();
5896
- if (method === 'POST' && url.pathname === '/v1/pair') return pair(rawBody);
5897
- if (method === 'POST' && url.pathname === '/v1/pair/confirm') return pairConfirm(rawBody);
6153
+ if (method === 'POST' && url.pathname === '/v1/pair') return pair(req, rawBody);
6154
+ if (method === 'POST' && url.pathname === '/v1/pair/confirm') return pairConfirm(req, rawBody);
5898
6155
  if (method === 'POST' && url.pathname === '/v1/ask') return ask(req, rawBody);
5899
6156
  if (method === 'GET' && url.pathname === '/v1/answer') return answer(req, rawBody, url, res);
5900
6157
  if (method === 'GET' && url.pathname === '/v1/events') return events(req, rawBody, url, res);
@@ -114,6 +114,12 @@ export async function startServer(t, overrides = {}) {
114
114
  listenHost: '127.0.0.1',
115
115
  // 这组用例验证签名/时钟/重放语义;加密路径见 encryption.test.mjs
116
116
  requireEncryption: false,
117
+ // 安全审计 S1/H4 的限流默认开启,但这批用例验证的是功能语义,
118
+ // 不是限流语义:同一回环 IP 会做多次失败配对、同一 peerId 会连发多次 ask,
119
+ // 开着限流会把「一次性配对码」「ask→answer」这类断言变成 429。
120
+ // 限流本身由 rate-limit.test.mjs 显式打开开关覆盖;调用方可以按需覆盖回去。
121
+ pairRateLimit: false,
122
+ askRateLimit: false,
117
123
  ...overrides,
118
124
  stateDir,
119
125
  });
@@ -0,0 +1,408 @@
1
+ /**
2
+ * host/test/rate-limit.test.mjs
3
+ *
4
+ * 安全审计剩下两条的验收用例:
5
+ * S1 /v1/pair 与 /v1/pair/confirm 按来源 IP 失败计数 + 指数退避
6
+ * (6 位码 10^6、TTL 10 分钟,没有计数就能在 TTL 内穷举穿)
7
+ * H4 /v1/ask 按 peerId 令牌桶 + 全局在跑会话上限;pruneSessions 显式取消 runner 会话
8
+ *
9
+ * 时间一律用注入的假时钟推进:退避 1s/30s、锁定 30s 都是可断言的确定值,
10
+ * 不用真等 30 秒。helpers.mjs 默认把两个开关关掉,这里逐个用例显式打开。
11
+ */
12
+
13
+ import test from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+
16
+ import {
17
+ ASK_BUSY_RETRY_MS,
18
+ DEFAULT_ASK_RATE_PER_MIN,
19
+ DEFAULT_MAX_CONCURRENT_SESSIONS,
20
+ MAX_PAIR_RATE_ENTRIES,
21
+ PAIR_FAIL_LOCK_MS,
22
+ PAIR_FAIL_LOCK_THRESHOLD,
23
+ PAIR_FAIL_INVALIDATE_THRESHOLD,
24
+ PAIR_FAIL_BACKOFF_MAX_MS,
25
+ PAIR_FAIL_BACKOFF_BASE_MS,
26
+ createRunner,
27
+ resolveConfig,
28
+ } from '../server.mjs';
29
+
30
+ import {
31
+ makeTempStateDir,
32
+ peerFixture,
33
+ pollAnswer,
34
+ request,
35
+ seedPeers,
36
+ startServer,
37
+ } from './helpers.mjs';
38
+
39
+ const PEER = peerFixture('peer-a');
40
+
41
+ /** MAX_SESSIONS = 500:造到第 502 个会话时淘汰必然发生过两次 */
42
+ const MAX_PRUNE_PROBE = 502;
43
+
44
+ /** 只用于测试的「另一枚 6 位码」:保证和真码不同,且格式合法 */
45
+ function otherCode(code) {
46
+ const numeric = Number(code);
47
+ return String((numeric + 1) % 1_000_000).padStart(6, '0');
48
+ }
49
+
50
+ function pairBody(code, overrides = {}) {
51
+ return { code, peerId: 'peer-attacker', peerName: 'A', secret: 'S-a', ...overrides };
52
+ }
53
+
54
+ function post(baseUrl, path, body) {
55
+ return request(baseUrl, { method: 'POST', path, signed: false, body });
56
+ }
57
+
58
+ function askBody(requestId, overrides = {}) {
59
+ return { requestId, prompt: 'hello from the LAN peer', ...overrides };
60
+ }
61
+
62
+ function ask(baseUrl, requestId, overrides = {}) {
63
+ return request(baseUrl, {
64
+ method: 'POST',
65
+ path: '/v1/ask',
66
+ peerId: PEER.peerId,
67
+ secret: PEER.inboundSecret,
68
+ body: askBody(requestId, overrides),
69
+ });
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // S1 · 配对码穷举
74
+ // ---------------------------------------------------------------------------
75
+
76
+ test('S1 攻击形状:50 并发错误码之后该来源被拒,第 51 次 429 且锁定期内正确码也不接受', async (t) => {
77
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
78
+ const clock = { now: Date.now() };
79
+ const stateDir = makeTempStateDir();
80
+ const { server, baseUrl } = await startServer(t, {
81
+ stateDir,
82
+ now: () => clock.now,
83
+ pairRateLimit: true,
84
+ });
85
+ const issued = server.issuePairingCode();
86
+ const wrong = otherCode(issued.code);
87
+
88
+ const burst = await Promise.all(
89
+ Array.from({ length: 50 }, () => post(baseUrl, '/v1/pair', pairBody(wrong))),
90
+ );
91
+ const guesses = burst.filter((res) => res.status === 401);
92
+ const throttled = burst.filter((res) => res.status === 429);
93
+ assert.equal(guesses.length + throttled.length, 50, '只允许 401(真猜了)或 429(被退避挡住)');
94
+ assert.ok(guesses.length >= 1, '第一次请求必须真的走到码比较');
95
+ assert.ok(throttled.length >= 49, `并发burst 应当只剩极少数漏过去,实际 429=${throttled.length}`);
96
+ assert.ok(throttled.every((res) => res.json.error === 'rate-limited' && res.json.retryAfterMs > 0));
97
+
98
+ // 第 51 次:直接拒绝,不比较码、不动 store、不起任何握手
99
+ const fiftyFirst = await post(baseUrl, '/v1/pair', pairBody(wrong));
100
+ assert.equal(fiftyFirst.status, 429);
101
+ assert.equal(fiftyFirst.json.error, 'rate-limited');
102
+ assert.ok(fiftyFirst.json.retryAfterMs > 0, 'retryAfterMs 必须 > 0,客户端才有依据退避');
103
+
104
+ // 锁定期内正确码同样不被接受(否则退避只挡「错码」等于没挡)
105
+ const correctDuringLock = await post(baseUrl, '/v1/pair', pairBody(issued.code));
106
+ assert.equal(correctDuringLock.status, 429);
107
+ assert.equal(correctDuringLock.json.error, 'rate-limited');
108
+
109
+ // 等过退避/锁定窗口,正确码照常可用:不能把正常流程一起挡死
110
+ clock.now += PAIR_FAIL_LOCK_MS + 1_000;
111
+ const afterLock = await post(baseUrl, '/v1/pair', pairBody(issued.code));
112
+ assert.equal(afterLock.status, 200, afterLock.text);
113
+ assert.match(afterLock.json.secret, /^[A-Za-z0-9_-]{43}$/);
114
+ });
115
+
116
+ test('S1 累计 5 次失败 → 该来源锁 30s(退避之外的硬锁),锁过期后恢复', async (t) => {
117
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
118
+ const clock = { now: Date.now() };
119
+ const stateDir = makeTempStateDir();
120
+ const { server, baseUrl } = await startServer(t, {
121
+ stateDir,
122
+ now: () => clock.now,
123
+ pairRateLimit: true,
124
+ });
125
+ const issued = server.issuePairingCode();
126
+ const wrong = otherCode(issued.code);
127
+
128
+ // 每次失败前把时钟推过退避/锁定窗口,让 5 次都是「真猜」,累计到锁定阈值
129
+ for (let index = 0; index < PAIR_FAIL_LOCK_THRESHOLD; index += 1) {
130
+ if (index > 0) clock.now += PAIR_FAIL_BACKOFF_MAX_MS + 1_000;
131
+ const res = await post(baseUrl, '/v1/pair', pairBody(wrong));
132
+ assert.equal(res.status, 401, `第 ${index + 1} 次失败应当是 401,实际 ${res.status}`);
133
+ }
134
+
135
+ // 只推 29s:指数退避(16s)已过,锁(30s)还在 —— 挡住请求的必须是硬锁
136
+ clock.now += 29_000;
137
+ const locked = await post(baseUrl, '/v1/pair', pairBody(issued.code));
138
+ assert.equal(locked.status, 429);
139
+ assert.ok(locked.json.retryAfterMs > 0);
140
+ assert.ok(locked.json.retryAfterMs <= PAIR_FAIL_LOCK_MS);
141
+
142
+ // 推过 30s:放行,且码没被这 5 次失败作废(401 = 走到了码比较,不是 429)
143
+ clock.now += 2_000;
144
+ const allowed = await post(baseUrl, '/v1/pair', pairBody(wrong));
145
+ assert.equal(allowed.status, 401, `锁过期后应当重新比较码,实际 ${allowed.status}`);
146
+ assert.equal(allowed.json.error, 'invalid-code');
147
+ });
148
+
149
+ test('S1 累计 20 次失败 → 当前码作废:旧码正确也配不上,换新码才恢复', async (t) => {
150
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
151
+ const clock = { now: Date.now() };
152
+ const stateDir = makeTempStateDir();
153
+ const { server, baseUrl } = await startServer(t, {
154
+ stateDir,
155
+ now: () => clock.now,
156
+ pairRateLimit: true,
157
+ });
158
+ const issued = server.issuePairingCode();
159
+ const wrong = otherCode(issued.code);
160
+
161
+ const statuses = [];
162
+ for (let index = 0; index < PAIR_FAIL_INVALIDATE_THRESHOLD; index += 1) {
163
+ const res = await post(baseUrl, '/v1/pair', pairBody(wrong));
164
+ statuses.push(res.status);
165
+ // 越过退避 + 锁定,保证每一次都真的做了一次码比较(否则累计不上去)
166
+ clock.now += PAIR_FAIL_LOCK_MS + PAIR_FAIL_BACKOFF_MAX_MS + 1_000;
167
+ }
168
+ assert.deepEqual(statuses, Array(PAIR_FAIL_INVALIDATE_THRESHOLD).fill(401));
169
+
170
+ // 原码作废:即使拿对了码也配不上,且不是被锁(401 invalid-code 而不是 429)
171
+ const stale = await post(baseUrl, '/v1/pair', pairBody(issued.code));
172
+ assert.equal(stale.status, 401);
173
+ assert.equal(stale.json.error, 'invalid-code', '旧码应当在累计失败阈值处被作废');
174
+
175
+ // 换一枚新码立刻可用:证明作废的是「那枚码」,不是把这台机器关死
176
+ // (stale 那次本身也是一次失败,先把 1s 退避走过去)
177
+ clock.now += PAIR_FAIL_BACKOFF_BASE_MS + 1_000;
178
+ const fresh = server.issuePairingCode();
179
+ const paired = await post(baseUrl, '/v1/pair', pairBody(fresh.code));
180
+ assert.equal(paired.status, 200, paired.text);
181
+ });
182
+
183
+ test('S1 关闭开关(pairRateLimit=false):回到不限流,失败不作废码', async (t) => {
184
+ const stateDir = makeTempStateDir();
185
+ const { server, baseUrl } = await startServer(t, { stateDir, pairRateLimit: false });
186
+ const issued = server.issuePairingCode();
187
+ const wrong = otherCode(issued.code);
188
+
189
+ const statuses = [];
190
+ for (let index = 0; index < 30; index += 1) {
191
+ const res = await post(baseUrl, '/v1/pair', pairBody(wrong));
192
+ statuses.push(res.status);
193
+ }
194
+ assert.deepEqual(statuses, Array(30).fill(401), '关掉限流后一次 429 都不该出现');
195
+
196
+ const paired = await post(baseUrl, '/v1/pair', pairBody(issued.code));
197
+ assert.equal(paired.status, 200, '关掉限流时 30 次失败也不能作废码');
198
+ });
199
+
200
+ test('S1 退避与锁定日志只记计数与状态,不带配对码、不带 IP 明文', async (t) => {
201
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
202
+ const clock = { now: Date.now() };
203
+ const stateDir = makeTempStateDir();
204
+ const lines = [];
205
+ const { server, baseUrl } = await startServer(t, {
206
+ stateDir,
207
+ now: () => clock.now,
208
+ pairRateLimit: true,
209
+ logger: (line) => lines.push(String(line)),
210
+ });
211
+ const issued = server.issuePairingCode();
212
+ const wrong = otherCode(issued.code);
213
+
214
+ for (let index = 0; index < PAIR_FAIL_LOCK_THRESHOLD; index += 1) {
215
+ if (index > 0) clock.now += PAIR_FAIL_BACKOFF_MAX_MS + 1_000;
216
+ await post(baseUrl, '/v1/pair', pairBody(wrong));
217
+ }
218
+ await post(baseUrl, '/v1/pair', pairBody(issued.code)); // 触发一次 429 日志
219
+
220
+ assert.ok(lines.some((line) => line.startsWith('pair: rate-limited')), lines.join('|'));
221
+ assert.ok(lines.some((line) => line.includes('source locked')), '锁定要有可 grep 的一行');
222
+ for (const line of lines) {
223
+ assert.equal(line.includes(issued.code), false, `日志里出现了配对码:${line}`);
224
+ assert.equal(line.includes(wrong), false, `日志里出现了配对码:${line}`);
225
+ assert.equal(line.includes('127.0.0.1'), false, `日志里出现了来源 IP 明文:${line}`);
226
+ }
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // H4 · /v1/ask 频率 + 并发
231
+ // ---------------------------------------------------------------------------
232
+
233
+ test('H4 同一 peerId 的令牌桶:默认额度内放行,超一次 429 且 retryAfterMs>0', async (t) => {
234
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
235
+ const clock = { now: Date.now() };
236
+ const stateDir = makeTempStateDir();
237
+ seedPeers(stateDir, [PEER]);
238
+ const { baseUrl } = await startServer(t, {
239
+ stateDir,
240
+ now: () => clock.now,
241
+ allowRemotePrompts: true,
242
+ runner: createRunner({ delayMs: 60_000 }),
243
+ askRateLimit: true,
244
+ });
245
+
246
+ for (let index = 0; index < DEFAULT_ASK_RATE_PER_MIN; index += 1) {
247
+ const res = await ask(baseUrl, `req-${index}`);
248
+ assert.equal(res.status, 202, `第 ${index + 1} 次 ask 应当放行,实际 ${res.status} ${res.text}`);
249
+ }
250
+
251
+ const seventh = await ask(baseUrl, 'req-over');
252
+ assert.equal(seventh.status, 429);
253
+ assert.equal(seventh.json.error, 'rate-limited');
254
+ assert.ok(seventh.json.retryAfterMs > 0, '必须告诉客户端等多久');
255
+ assert.equal(seventh.json.sessionId, undefined, '被拒的请求不能建会话');
256
+
257
+ // 令牌桶按时间补充:过一分钟重新有额度
258
+ clock.now += 60_000;
259
+ const refilled = await ask(baseUrl, 'req-after-window');
260
+ assert.equal(refilled.status, 202, refilled.text);
261
+ });
262
+
263
+ test('H4 同一 requestId 重放不扣配额(幂等语义优先于限流)', async (t) => {
264
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
265
+ const clock = { now: Date.now() };
266
+ const stateDir = makeTempStateDir();
267
+ seedPeers(stateDir, [PEER]);
268
+ const { baseUrl } = await startServer(t, {
269
+ stateDir,
270
+ now: () => clock.now,
271
+ allowRemotePrompts: true,
272
+ runner: createRunner({ delayMs: 60_000 }),
273
+ askRateLimit: true,
274
+ askRatePerMin: 2,
275
+ });
276
+
277
+ const first = await ask(baseUrl, 'req-same');
278
+ assert.equal(first.status, 202);
279
+ for (let index = 0; index < 4; index += 1) {
280
+ const replay = await ask(baseUrl, 'req-same');
281
+ assert.equal(replay.status, 202, '重复 requestId 不建新会话,也不该被 429');
282
+ assert.equal(replay.json.sessionId, first.json.sessionId);
283
+ }
284
+
285
+ assert.equal((await ask(baseUrl, 'req-second')).status, 202);
286
+ const over = await ask(baseUrl, 'req-third');
287
+ assert.equal(over.status, 429, '两个不同请求已经用完 askRatePerMin=2 的额度');
288
+ assert.ok(over.json.retryAfterMs > 0);
289
+ });
290
+
291
+ test('H4 全局在跑会话上限:占满后新请求 429,会话结算后立刻让位', async (t) => {
292
+ // 假时钟从真实时间起步:签名的时间戳用的是真实 Date.now,偏差必须留在 CLOCK_SKEW_MS 内
293
+ const clock = { now: Date.now() };
294
+ const stateDir = makeTempStateDir();
295
+ seedPeers(stateDir, [PEER]);
296
+ const runner = createRunner({ delayMs: 0 });
297
+ const { baseUrl } = await startServer(t, {
298
+ stateDir,
299
+ now: () => clock.now,
300
+ allowRemotePrompts: true,
301
+ runner,
302
+ askRateLimit: true,
303
+ askRatePerMin: 100,
304
+ maxConcurrentSessions: 2,
305
+ });
306
+
307
+ const one = await ask(baseUrl, 'req-1');
308
+ const two = await ask(baseUrl, 'req-2');
309
+ assert.equal(one.status, 202);
310
+ assert.equal(two.status, 202);
311
+
312
+ const saturated = await ask(baseUrl, 'req-3');
313
+ assert.equal(saturated.status, 429, '名额占满时新请求必须被拒,而不是继续起会话');
314
+ assert.equal(saturated.json.error, 'rate-limited');
315
+ assert.ok(saturated.json.retryAfterMs > 0);
316
+ assert.equal(runner.sessions.size, 2, '被拒的请求不能落到 runner');
317
+
318
+ // 结算第一个会话(poll 到 done)→ 名额释放 → 新请求放行
319
+ const settled = await pollAnswer(baseUrl, {
320
+ sessionId: one.json.sessionId,
321
+ peerId: PEER.peerId,
322
+ secret: PEER.inboundSecret,
323
+ now: () => clock.now,
324
+ });
325
+ assert.equal(settled.status, 'done');
326
+
327
+ const afterRelease = await ask(baseUrl, 'req-4');
328
+ assert.equal(afterRelease.status, 202, afterRelease.text);
329
+ });
330
+
331
+ test('H4 关闭开关(askRateLimit=false):回到不限流,连发 12 次全部放行', async (t) => {
332
+ const stateDir = makeTempStateDir();
333
+ seedPeers(stateDir, [PEER]);
334
+ const { baseUrl } = await startServer(t, {
335
+ stateDir,
336
+ allowRemotePrompts: true,
337
+ runner: createRunner({ delayMs: 60_000 }),
338
+ askRateLimit: false,
339
+ maxConcurrentSessions: 2,
340
+ });
341
+
342
+ for (let index = 0; index < 12; index += 1) {
343
+ const res = await ask(baseUrl, `req-${index}`);
344
+ assert.equal(res.status, 202, `关掉限流后第 ${index + 1} 次也应当放行,实际 ${res.status}`);
345
+ }
346
+ });
347
+
348
+ test('H4 pruneSessions 淘汰会话时显式取消 runner 会话(提供 cancel 就调用)', async (t) => {
349
+ const stateDir = makeTempStateDir();
350
+ seedPeers(stateDir, [PEER]);
351
+ const cancelled = [];
352
+ const runner = createRunner({ delayMs: 60_000 });
353
+ runner.cancel = (runnerSessionId) => { cancelled.push(runnerSessionId); };
354
+ const { baseUrl } = await startServer(t, {
355
+ stateDir,
356
+ allowRemotePrompts: true,
357
+ runner,
358
+ askRateLimit: false,
359
+ });
360
+
361
+ const oldestRunnerIds = [];
362
+ for (let index = 0; index < MAX_PRUNE_PROBE; index += 1) {
363
+ const res = await ask(baseUrl, `req-prune-${index}`);
364
+ assert.equal(res.status, 202, res.text);
365
+ if (index < 2) oldestRunnerIds.push([...runner.sessions.keys()][index]);
366
+ }
367
+ // 最老的会话被淘汰时必须显式取消,且传的是 runner 侧的会话 id
368
+ assert.deepEqual(cancelled.slice(0, 2), oldestRunnerIds);
369
+ assert.ok(cancelled.every((runnerSessionId) => String(runnerSessionId).startsWith('mem-')));
370
+ });
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // 配置归一化(默认开着,显式 false 才关)
374
+ // ---------------------------------------------------------------------------
375
+
376
+ test('S1/H4 配置默认值:两条限流默认开启,旋钮可覆盖、非法值回落默认', () => {
377
+ const defaults = resolveConfig({ stateDir: makeTempStateDir() });
378
+ assert.equal(defaults.pairRateLimit, true);
379
+ assert.equal(defaults.askRateLimit, true);
380
+ assert.equal(defaults.askRatePerMin, DEFAULT_ASK_RATE_PER_MIN);
381
+ assert.equal(defaults.maxConcurrentSessions, DEFAULT_MAX_CONCURRENT_SESSIONS);
382
+
383
+ const tuned = resolveConfig({
384
+ stateDir: makeTempStateDir(),
385
+ pairRateLimit: false,
386
+ askRateLimit: false,
387
+ askRatePerMin: 30,
388
+ maxConcurrentSessions: 64,
389
+ });
390
+ assert.equal(tuned.pairRateLimit, false);
391
+ assert.equal(tuned.askRateLimit, false);
392
+ assert.equal(tuned.askRatePerMin, 30);
393
+ assert.equal(tuned.maxConcurrentSessions, 64);
394
+
395
+ const junk = resolveConfig({
396
+ stateDir: makeTempStateDir(),
397
+ askRatePerMin: 'abc',
398
+ maxConcurrentSessions: -3,
399
+ });
400
+ assert.equal(junk.askRatePerMin, DEFAULT_ASK_RATE_PER_MIN);
401
+ assert.equal(junk.maxConcurrentSessions, DEFAULT_MAX_CONCURRENT_SESSIONS);
402
+
403
+ assert.equal(PAIR_FAIL_BACKOFF_BASE_MS, 1_000);
404
+ assert.equal(PAIR_FAIL_BACKOFF_MAX_MS, 30_000);
405
+ assert.ok(PAIR_FAIL_LOCK_THRESHOLD >= 1);
406
+ assert.ok(MAX_PAIR_RATE_ENTRIES >= 1_000);
407
+ assert.ok(ASK_BUSY_RETRY_MS > 0);
408
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lan-peers",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "局域网 DSH 协同:一次性配对互认、HMAC 签名 + AES-256-GCM 加密、agent 互派任务",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",