@dsh-remote/relay 3.1.2 → 3.1.3

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/README.md CHANGED
@@ -34,12 +34,12 @@ curl -s http://127.0.0.1:8787/healthz
34
34
 
35
35
  ### 端点
36
36
 
37
- | 路径 | 说明 |
38
- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
- | `GET /healthz` | 运维契约(**二十三个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
40
- | `GET /api/info` | `{"publicUrl","protocol"}` |
41
- | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
42
- | WebSocket | 路径不设限,任意路径都能升级 |
37
+ | 路径 | 说明 |
38
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `GET /healthz` | 运维契约(**二十四个字段**):`ok` / `version` / `uptimeSec` / `hosts` / `clients` / `conversations` / `pendingPairs` / `hostTokens` / `droppedFrames` / `slowConsumers` / `rejectedPairs` / `shutdownForced` / `persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` / `stateWriteFailures` / `lastPingAgo` / `shuttingDown` / `protocolSelf` / `protocolMin` / `peerProtocolMin` / `peerProtocolMax` / `peersNoProtocol` |
40
+ | `GET /api/info` | `{"publicUrl","protocol"}` |
41
+ | `GET /api/pair-status` | **默认 404**。无需认证地回答"配对码 N 是否有效"等于给 6 位码空间装了扫描 oracle;确需调试时 `DRC_PAIR_STATUS=1`,用完关掉 |
42
+ | WebSocket | 路径不设限,任意路径都能升级 |
43
43
 
44
44
  落盘五字段(`persistence` / `stateRestored` / `stateSavedAtSec` / `stateWrites` /
45
45
  `stateWriteFailures`)是"还在落盘"的唯一读数——换机器先看 `stateWriteFailures`,不是 0 就是没在写。
@@ -23394,7 +23394,7 @@ function date4(params) {
23394
23394
  return _coercedDate(ZodDate, params);
23395
23395
  }
23396
23396
 
23397
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23397
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/limits.js
23398
23398
  var MAX_RELAY_MESSAGE_BYTES = 1024 * 1024;
23399
23399
  var MAX_CIPHERTEXT_BYTES = MAX_RELAY_MESSAGE_BYTES - 8 * 1024;
23400
23400
  var MAX_IMAGE_ATTACHMENTS = 4;
@@ -23403,7 +23403,7 @@ var MAX_ATTACHMENT_BYTES = 512 * 1024;
23403
23403
  var MAX_ATTACHMENT_TOTAL_BYTES = 512 * 1024;
23404
23404
  var IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
23405
23405
 
23406
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23406
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/frames.js
23407
23407
  var PROTOCOL_VERSION = 1;
23408
23408
  var MIN_SUPPORTED_PROTOCOL = 1;
23409
23409
  var PAIRING_TOKEN_RE = /^\d{6}$/;
@@ -23725,7 +23725,19 @@ var BackpressureGate = class {
23725
23725
  var LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
23726
23726
  var levelWeight = (level) => LOG_LEVELS[level];
23727
23727
  var DECIMAL_INTEGER_RE = /^[0-9]+$/;
23728
- function integer2(raw, fallback, name, problems) {
23728
+ function claimMinIntervalMs(env, problems) {
23729
+ const value = integer2(env.DRC_CLAIM_MIN_INTERVAL_MS, 4e3, "DRC_CLAIM_MIN_INTERVAL_MS", problems, {
23730
+ allowZero: true
23731
+ });
23732
+ if (value === 0) {
23733
+ problems.push({
23734
+ level: "warn",
23735
+ message: "DRC_CLAIM_MIN_INTERVAL_MS=0\uFF1A\u8BA4\u9886\u63A5\u53E3\u7684\u9650\u9891**\u5DF2\u5173\u95ED**\uFF08\u540C IP \u53EF\u4EE5\u65E0\u9650\u8FDE\u6253\uFF09\u3002\u53EA\u5728\u6D4B\u8BD5\u6216\u5B8C\u5168\u5185\u7F51\u81EA\u7528\u65F6\u8FD9\u4E48\u8BBE\uFF1B\u516C\u7F51\u4E0A\u7684\u8FD9\u53F0\u4E2D\u7EE7\u8BF7\u7559 4000\u3002"
23736
+ });
23737
+ }
23738
+ return value;
23739
+ }
23740
+ function integer2(raw, fallback, name, problems, options = {}) {
23729
23741
  if (raw === void 0) return fallback;
23730
23742
  if (raw === "") {
23731
23743
  problems.push({
@@ -23739,8 +23751,9 @@ function integer2(raw, fallback, name, problems) {
23739
23751
  return fallback;
23740
23752
  }
23741
23753
  const n = Number(raw.trim());
23742
- if (!Number.isSafeInteger(n) || n <= 0) {
23743
- problems.push({ level: "error", message: `${name} \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF0C\u6536\u5230 ${JSON.stringify(raw)}` });
23754
+ if (!Number.isSafeInteger(n) || n < 0 || n === 0 && options.allowZero !== true) {
23755
+ const shape = options.allowZero === true ? "\u975E\u8D1F\u6574\u6570" : "\u6B63\u6574\u6570";
23756
+ problems.push({ level: "error", message: `${name} \u5FC5\u987B\u662F${shape}\uFF0C\u6536\u5230 ${JSON.stringify(raw)}` });
23744
23757
  return fallback;
23745
23758
  }
23746
23759
  return n;
@@ -23804,6 +23817,8 @@ function loadConfig(env, version2) {
23804
23817
  pairGlobalBudgetPerSec: integer2(env.DRC_PAIR_GLOBAL_PER_SEC, 20, "DRC_PAIR_GLOBAL_PER_SEC", problems),
23805
23818
  pairStatusBudgetPerSec: integer2(env.DRC_PAIR_STATUS_PER_SEC, 5, "DRC_PAIR_STATUS_PER_SEC", problems),
23806
23819
  maxPendingPairs: integer2(env.DRC_MAX_PENDING_PAIRS, 1e3, "DRC_MAX_PENDING_PAIRS", problems),
23820
+ maxHostTokens: integer2(env.DRC_MAX_HOST_TOKENS, 500, "DRC_MAX_HOST_TOKENS", problems),
23821
+ claimMinIntervalMs: claimMinIntervalMs(env, problems),
23807
23822
  conversationIdleTtlMs: integer2(env.DRC_CONV_IDLE_TTL_MS, 7 * 24 * 3600 * 1e3, "DRC_CONV_IDLE_TTL_MS", problems),
23808
23823
  // P2-⑤(2026-10-04 用户拍板 30 分钟):远小于 7 天的空闲 TTL。
23809
23824
  // 删的代价是手机再扫一次码;不删的代价是 conversations 计数虚高、排障对不上。
@@ -23867,7 +23882,7 @@ var import_subprotocol = __toESM(require_subprotocol(), 1);
23867
23882
  var import_websocket = __toESM(require_websocket(), 1);
23868
23883
  var import_websocket_server = __toESM(require_websocket_server(), 1);
23869
23884
 
23870
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23885
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/identity/ids.js
23871
23886
  import { randomBytes, randomUUID } from "node:crypto";
23872
23887
  var CONVERSATION_ID_PREFIX = "c_";
23873
23888
  function newConversationId() {
@@ -23877,10 +23892,10 @@ function newHostId() {
23877
23892
  return randomBytes(4).toString("hex");
23878
23893
  }
23879
23894
 
23880
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
23895
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/platform/sleep.js
23881
23896
  var SLEEP_BACKENDS = ["caffeinate", "systemd-inhibit", "unsupported"];
23882
23897
 
23883
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
23898
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/payloads.js
23884
23899
  var SESSION_STATES = [
23885
23900
  "idle",
23886
23901
  "running",
@@ -24291,7 +24306,7 @@ var PAYLOAD_TYPES = {
24291
24306
  evSessionHistory: "ev.session_history"
24292
24307
  };
24293
24308
 
24294
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24309
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/registry.js
24295
24310
  var ENDPOINT_FRAME_NAMES = [
24296
24311
  "hello",
24297
24312
  "pair-begin",
@@ -24383,7 +24398,7 @@ var CAPABILITY_IDS = [
24383
24398
  ];
24384
24399
  var CAPABILITY_SET = new Set(CAPABILITY_IDS);
24385
24400
 
24386
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
24401
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/negotiate.js
24387
24402
  function negotiateProtocol(peerProtocol, own2 = PROTOCOL_VERSION, min = MIN_SUPPORTED_PROTOCOL) {
24388
24403
  if (peerProtocol === void 0 || peerProtocol === null) {
24389
24404
  return verdictFor(1, own2, min, true);
@@ -24423,13 +24438,13 @@ function verdictFor(peer, own2, min, missing) {
24423
24438
  return { ok: true, version: peer, missing };
24424
24439
  }
24425
24440
 
24426
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24441
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/errors.js
24427
24442
  var WAIT_HINTABLE = /* @__PURE__ */ new Set(["rate_limited", "pair_table_full"]);
24428
24443
  function wantsRetryAfter(code) {
24429
24444
  return typeof code === "string" && WAIT_HINTABLE.has(code);
24430
24445
  }
24431
24446
 
24432
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24447
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/outbound.js
24433
24448
  function helloOkForClient(clientId, protocol) {
24434
24449
  return { t: "hello-ok", role: "client", clientId, ...protocol === void 0 ? {} : { protocol } };
24435
24450
  }
@@ -24507,7 +24522,7 @@ function encBatchToRelay(sessionId, items) {
24507
24522
  return { t: "enc-batch", sessionId, items: items.map((item) => ({ ...item })) };
24508
24523
  }
24509
24524
 
24510
- // node_modules/.pnpm/@dsh-remote+protocol@3.0.6/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24525
+ // node_modules/.pnpm/@dsh-remote+protocol@3.0.7/node_modules/@dsh-remote/protocol/dist/src/wire/classify.js
24511
24526
  function frameTypeOf(value) {
24512
24527
  if (typeof value !== "object" || value === null || Array.isArray(value))
24513
24528
  return void 0;
@@ -24602,6 +24617,30 @@ import {
24602
24617
  import { dirname } from "node:path";
24603
24618
  var STATE_FILE_VERSION = 1;
24604
24619
  var CONVERSATION_ID_RE = new RegExp(`^${CONVERSATION_ID_PREFIX}[0-9a-f]{12}$`);
24620
+ var HOST_TOKEN_RE = /^[0-9a-f]{48}$/;
24621
+ var MAX_HOST_ID_LEN = 200;
24622
+ function sanitizeHostTokens(raw) {
24623
+ if (!Array.isArray(raw)) return { hostTokens: [], dropped: 0 };
24624
+ const hostTokens = [];
24625
+ const seen = /* @__PURE__ */ new Set();
24626
+ for (const item of raw) {
24627
+ if (typeof item !== "object" || item === null) {
24628
+ continue;
24629
+ }
24630
+ const entry = item;
24631
+ if (typeof entry.hostId !== "string" || entry.hostId.length > MAX_HOST_ID_LEN || entry.hostId !== "" && entry.hostId.trim() !== entry.hostId || !isNonEmptyString(entry.token) || !HOST_TOKEN_RE.test(entry.token) || !isTimestamp(entry.issuedAt) || !isTimestamp(entry.lastUsedAt) || seen.has(entry.hostId)) {
24632
+ continue;
24633
+ }
24634
+ seen.add(entry.hostId);
24635
+ hostTokens.push({
24636
+ hostId: entry.hostId,
24637
+ token: entry.token,
24638
+ issuedAt: entry.issuedAt,
24639
+ lastUsedAt: entry.lastUsedAt
24640
+ });
24641
+ }
24642
+ return { hostTokens, dropped: raw.length - hostTokens.length };
24643
+ }
24605
24644
  function isNonEmptyString(value) {
24606
24645
  return typeof value === "string" && value.length > 0;
24607
24646
  }
@@ -24664,8 +24703,27 @@ function readStateFile(path, log) {
24664
24703
  }
24665
24704
  const { conversations, dropped } = sanitize(file2.conversations);
24666
24705
  if (dropped > 0) log.warn("state file entries dropped", { path, dropped, kept: conversations.length });
24667
- log.info("state file loaded", { path, conversations: conversations.length, dropped });
24668
- return { version: STATE_FILE_VERSION, savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0, conversations };
24706
+ const hostTokenResult = sanitizeHostTokens(file2.hostTokens);
24707
+ if (hostTokenResult.dropped > 0) {
24708
+ log.warn("state file host tokens dropped", {
24709
+ path,
24710
+ dropped: hostTokenResult.dropped,
24711
+ kept: hostTokenResult.hostTokens.length
24712
+ });
24713
+ }
24714
+ log.info("state file loaded", {
24715
+ path,
24716
+ conversations: conversations.length,
24717
+ dropped,
24718
+ hostTokens: hostTokenResult.hostTokens.length
24719
+ });
24720
+ const restored = {
24721
+ version: STATE_FILE_VERSION,
24722
+ savedAt: isTimestamp(file2.savedAt) ? file2.savedAt : 0,
24723
+ conversations
24724
+ };
24725
+ if (hostTokenResult.hostTokens.length > 0) restored.hostTokens = hostTokenResult.hostTokens;
24726
+ return restored;
24669
24727
  }
24670
24728
  function snapshotState(state, now) {
24671
24729
  const conversations = [];
@@ -24680,7 +24738,13 @@ function snapshotState(state, now) {
24680
24738
  if (conv.emptySince !== void 0) conversation.emptySince = conv.emptySince;
24681
24739
  conversations.push(conversation);
24682
24740
  }
24683
- return { version: STATE_FILE_VERSION, savedAt: now, conversations };
24741
+ const hostTokens = [];
24742
+ for (const [hostId, entry] of state.hostTokens) {
24743
+ hostTokens.push({ hostId, token: entry.token, issuedAt: entry.issuedAt, lastUsedAt: entry.lastUsedAt });
24744
+ }
24745
+ const snapshot = { version: STATE_FILE_VERSION, savedAt: now, conversations };
24746
+ if (hostTokens.length > 0) snapshot.hostTokens = hostTokens;
24747
+ return snapshot;
24684
24748
  }
24685
24749
  function restoreState(path, state, log, now) {
24686
24750
  const file2 = readStateFile(path, log);
@@ -24702,7 +24766,18 @@ function restoreState(path, state, log, now) {
24702
24766
  if (clamped > 0) {
24703
24767
  log.warn("state file timestamps in the future, clamped to now", { path, clamped });
24704
24768
  }
24705
- log.info("state restored", { path, conversations: state.conversations.size });
24769
+ for (const entry of file2.hostTokens ?? []) {
24770
+ state.hostTokens.set(entry.hostId, {
24771
+ token: entry.token,
24772
+ issuedAt: entry.issuedAt,
24773
+ lastUsedAt: entry.lastUsedAt
24774
+ });
24775
+ }
24776
+ log.info("state restored", {
24777
+ path,
24778
+ conversations: state.conversations.size,
24779
+ hostTokens: state.hostTokens.size
24780
+ });
24706
24781
  return state.conversations.size;
24707
24782
  }
24708
24783
  function syncPath(target, log, strict = true) {
@@ -24751,11 +24826,14 @@ function writeStateFile(path, snapshot, log) {
24751
24826
 
24752
24827
  // src/state.ts
24753
24828
  var WS_OPEN = 1;
24829
+ var UNBOUND_HOST_TOKEN_KEY = "";
24754
24830
  var RelayState = class {
24755
24831
  hosts = /* @__PURE__ */ new Map();
24756
24832
  clients = /* @__PURE__ */ new Map();
24757
24833
  pendingPairs = /* @__PURE__ */ new Map();
24758
24834
  conversations = /* @__PURE__ */ new Map();
24835
+ /** 按 hostId 一机一枚(外加 `UNBOUND_HOST_TOKEN_KEY` 那个未绑定槽位)。 */
24836
+ hostTokens = /* @__PURE__ */ new Map();
24759
24837
  /** 测试可注入固定时钟;默认用真实时间。 */
24760
24838
  clock;
24761
24839
  maxPendingPairsValue;
@@ -25120,12 +25198,56 @@ var RelayState = class {
25120
25198
  return dropped;
25121
25199
  }
25122
25200
  /** 测试与诊断:把内部计数摊平成 `/healthz` 需要的形状。 */
25201
+ // ── host token(按 hostId 一机一枚,2026-10-11 起)────────────────────
25202
+ /**
25203
+ * 签发/覆盖 `hostId` 的令牌。**只动这一条**,别人那枚不受影响。
25204
+ *
25205
+ * `lastUsedAt` 归零:这一枚刚签出来还没被用来鉴权过。它只影响诊断与裁剪顺序
25206
+ * (见 `pruneHostTokens`),不参与鉴权判定——鉴权只看 `token` 本身。
25207
+ */
25208
+ issueHostToken(hostId, token, now) {
25209
+ const entry = { token, issuedAt: now, lastUsedAt: 0 };
25210
+ this.hostTokens.set(hostId, entry);
25211
+ return entry;
25212
+ }
25213
+ /** 记下"这枚令牌刚被用来**成功**鉴权"。没这一条时裁剪顺序就退化成"签得早的先丢"。 */
25214
+ markHostTokenUsed(hostId, now) {
25215
+ const entry = this.hostTokens.get(hostId);
25216
+ if (entry) entry.lastUsedAt = now;
25217
+ }
25218
+ /**
25219
+ * 令牌表有界:超出 `max` 时,按 `lastUsedAt || issuedAt` **从旧到新**丢。
25220
+ *
25221
+ * 为什么不是"按签发时刻丢最老":一台一直在用的机器(签得早、但一直在用)会被
25222
+ * 优先丢掉,而它**不会自愈**——下次它连上来只会拿到 `bad_token`,而它盘上有令牌
25223
+ * 就不会重新认领。所以分两档:**没用过的先丢**,同一档里再按最近活动从旧到新。
25224
+ *
25225
+ * 返回丢掉几条(调用方去记一条 warn)。
25226
+ */
25227
+ pruneHostTokens(max) {
25228
+ if (!Number.isFinite(max) || max <= 0) return 0;
25229
+ if (this.hostTokens.size <= max) return 0;
25230
+ const order = [...this.hostTokens.entries()].sort((a, b) => {
25231
+ const usedA = a[1].lastUsedAt > 0 ? 1 : 0;
25232
+ const usedB = b[1].lastUsedAt > 0 ? 1 : 0;
25233
+ if (usedA !== usedB) return usedA - usedB;
25234
+ return (a[1].lastUsedAt || a[1].issuedAt) - (b[1].lastUsedAt || b[1].issuedAt);
25235
+ });
25236
+ let dropped = 0;
25237
+ for (const [key] of order) {
25238
+ if (this.hostTokens.size <= max) break;
25239
+ this.hostTokens.delete(key);
25240
+ dropped += 1;
25241
+ }
25242
+ return dropped;
25243
+ }
25123
25244
  counts() {
25124
25245
  return {
25125
25246
  hosts: this.hosts.size,
25126
25247
  clients: this.clients.size,
25127
25248
  conversations: this.conversations.size,
25128
- pendingPairs: this.pendingPairs.size
25249
+ pendingPairs: this.pendingPairs.size,
25250
+ hostTokens: this.hostTokens.size
25129
25251
  };
25130
25252
  }
25131
25253
  /** 全量清空(优雅停机或测试重置)。 */
@@ -25134,6 +25256,7 @@ var RelayState = class {
25134
25256
  this.clients.clear();
25135
25257
  this.pendingPairs.clear();
25136
25258
  this.conversations.clear();
25259
+ this.hostTokens.clear();
25137
25260
  }
25138
25261
  };
25139
25262
 
@@ -25147,8 +25270,9 @@ function tokenMatches(expected, got) {
25147
25270
  if (a.length !== b.length) return false;
25148
25271
  return timingSafeEqual(a, b);
25149
25272
  }
25150
- var CLAIM_MIN_INTERVAL_MS = 4e3;
25151
25273
  var CLAIM_TRACK_MAX = 1e4;
25274
+ var MAX_HOST_ID_LEN2 = 200;
25275
+ var HOST_ID_RE = /^[A-Za-z0-9._:-]+$/;
25152
25276
  function createRelay(config2) {
25153
25277
  const log = new Log(config2.logLevel);
25154
25278
  const state = new RelayState({ maxPendingPairs: config2.maxPendingPairs });
@@ -25157,23 +25281,38 @@ function createRelay(config2) {
25157
25281
  const pairStatusBudget = new Budget(config2.pairStatusBudgetPerSec);
25158
25282
  const peers = /* @__PURE__ */ new WeakMap();
25159
25283
  let shuttingDown = false;
25160
- let currentHostToken = config2.hostToken;
25161
25284
  const lastClaimAt = /* @__PURE__ */ new Map();
25162
25285
  let sweepTimer;
25163
25286
  let pingTimer;
25164
25287
  let lastPingAt = 0;
25165
25288
  let lastCountersAt = 0;
25166
- function rotateHostToken() {
25167
- currentHostToken = randomBytes2(24).toString("hex");
25168
- if (lastClaimAt.size >= CLAIM_TRACK_MAX) {
25169
- const drop = lastClaimAt.size - CLAIM_TRACK_MAX + 1;
25170
- let i = 0;
25171
- for (const key of lastClaimAt.keys()) {
25172
- lastClaimAt.delete(key);
25173
- if (++i >= drop) break;
25174
- }
25289
+ function newHostToken() {
25290
+ return randomBytes2(24).toString("hex");
25291
+ }
25292
+ function rememberClaim(ip, now) {
25293
+ lastClaimAt.set(ip, now);
25294
+ if (lastClaimAt.size <= CLAIM_TRACK_MAX) return;
25295
+ const drop = lastClaimAt.size - CLAIM_TRACK_MAX;
25296
+ let i = 0;
25297
+ for (const key of lastClaimAt.keys()) {
25298
+ lastClaimAt.delete(key);
25299
+ if (++i >= drop) break;
25175
25300
  }
25176
- return currentHostToken;
25301
+ }
25302
+ function hostTokenAccepted(hostId, got) {
25303
+ if (!got) return false;
25304
+ const now = Date.now();
25305
+ const perHost = state.hostTokens.get(hostId);
25306
+ if (perHost && tokenMatches(perHost.token, got)) {
25307
+ state.markHostTokenUsed(hostId, now);
25308
+ return true;
25309
+ }
25310
+ const unbound = state.hostTokens.get(UNBOUND_HOST_TOKEN_KEY);
25311
+ if (unbound && tokenMatches(unbound.token, got)) {
25312
+ state.markHostTokenUsed(UNBOUND_HOST_TOKEN_KEY, now);
25313
+ return true;
25314
+ }
25315
+ return config2.hostToken !== "" && tokenMatches(config2.hostToken, got);
25177
25316
  }
25178
25317
  const persistence = { enabled: config2.stateFile !== "", restored: 0, savedAt: 0, writes: 0, failures: 0 };
25179
25318
  if (persistence.enabled) {
@@ -25300,19 +25439,19 @@ function createRelay(config2) {
25300
25439
  }
25301
25440
  peer.protocol = frame.protocol;
25302
25441
  if (frame.role === "host") {
25303
- if (!currentHostToken) {
25442
+ const hostId = frame.hostId?.trim() || newHostId();
25443
+ if (!config2.hostToken && state.hostTokens.size === 0) {
25304
25444
  sendError(peer, "internal", "relay has no host token configured");
25305
25445
  peer.ws.close(1011);
25306
25446
  return;
25307
25447
  }
25308
- if (!frame.token || !tokenMatches(currentHostToken, frame.token)) {
25448
+ if (!hostTokenAccepted(hostId, frame.token)) {
25309
25449
  peer.authAttempts += 1;
25310
25450
  log.warn("host auth failed", { attempts: peer.authAttempts });
25311
25451
  sendError(peer, "bad_token");
25312
25452
  if (peer.authAttempts >= config2.maxHostAuthAttempts) peer.ws.close(4001, "too_many_auth_attempts");
25313
25453
  return;
25314
25454
  }
25315
- const hostId = frame.hostId?.trim() || newHostId();
25316
25455
  releaseIdentityFor(peer, { role: "host", id: hostId });
25317
25456
  const { replaced } = state.attachHost(hostId, peer.ws, frame.label ?? "");
25318
25457
  peer.role = "host";
@@ -25772,6 +25911,76 @@ function createRelay(config2) {
25772
25911
  peersNoProtocol: peersWithoutProtocol
25773
25912
  };
25774
25913
  }
25914
+ function readBody(req, limit) {
25915
+ return new Promise((resolve, reject) => {
25916
+ const chunks = [];
25917
+ let size = 0;
25918
+ req.on("data", (chunk) => {
25919
+ size += chunk.length;
25920
+ if (size > limit) {
25921
+ reject(new Error("body_too_large"));
25922
+ req.destroy();
25923
+ return;
25924
+ }
25925
+ chunks.push(chunk);
25926
+ });
25927
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
25928
+ req.on("error", reject);
25929
+ });
25930
+ }
25931
+ const CLAIM_BODY_MAX_BYTES = 1024;
25932
+ async function handleClaimToken(req, res) {
25933
+ if (req.method !== "POST") {
25934
+ res.statusCode = 405;
25935
+ res.end(JSON.stringify({ error: "use POST" }));
25936
+ return;
25937
+ }
25938
+ if (shuttingDown) {
25939
+ res.statusCode = 503;
25940
+ res.end(JSON.stringify({ error: "shutting_down" }));
25941
+ return;
25942
+ }
25943
+ const now = Date.now();
25944
+ const ip = req.socket.remoteAddress ?? "unknown";
25945
+ const last = lastClaimAt.get(ip) ?? 0;
25946
+ if (now - last < config2.claimMinIntervalMs) {
25947
+ res.statusCode = 429;
25948
+ res.end(JSON.stringify({ error: "too_soon", retryAfterMs: config2.claimMinIntervalMs - (now - last) }));
25949
+ return;
25950
+ }
25951
+ rememberClaim(ip, now);
25952
+ let body = {};
25953
+ const raw = await readBody(req, CLAIM_BODY_MAX_BYTES);
25954
+ if (raw.trim() !== "") {
25955
+ try {
25956
+ const parsed = JSON.parse(raw);
25957
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
25958
+ body = parsed;
25959
+ }
25960
+ } catch {
25961
+ }
25962
+ }
25963
+ const rawHostId = typeof body.hostId === "string" ? body.hostId.trim() : "";
25964
+ if (rawHostId !== "" && (rawHostId.length > MAX_HOST_ID_LEN2 || !HOST_ID_RE.test(rawHostId))) {
25965
+ res.statusCode = 400;
25966
+ res.end(
25967
+ JSON.stringify({ error: "bad_host_id", hint: `hostId \u6700\u591A ${MAX_HOST_ID_LEN2} \u5B57\u7B26\uFF0C\u53EA\u5141\u8BB8\u5B57\u6BCD\u6570\u5B57\u4E0E ._:-` })
25968
+ );
25969
+ return;
25970
+ }
25971
+ const slot = rawHostId === "" ? UNBOUND_HOST_TOKEN_KEY : rawHostId;
25972
+ const token = newHostToken();
25973
+ state.issueHostToken(slot, token, now);
25974
+ const pruned = state.pruneHostTokens(config2.maxHostTokens);
25975
+ if (pruned > 0) log.warn("host tokens pruned", { dropped: pruned, kept: state.hostTokens.size });
25976
+ markStateChanged();
25977
+ log.info(rawHostId === "" ? "host token claimed (unbound)" : "host token claimed", {
25978
+ ip,
25979
+ hostId: rawHostId,
25980
+ hostTokens: state.hostTokens.size
25981
+ });
25982
+ res.end(JSON.stringify({ token, hostId: rawHostId, rotatedAt: new Date(now).toISOString() }));
25983
+ }
25775
25984
  function httpHandler(req, res) {
25776
25985
  try {
25777
25986
  const url2 = new URL(req.url ?? "/", "http://relay.invalid");
@@ -25782,28 +25991,13 @@ function createRelay(config2) {
25782
25991
  return;
25783
25992
  }
25784
25993
  if (url2.pathname === "/api/claim-token") {
25785
- if (req.method !== "POST") {
25786
- res.statusCode = 405;
25787
- res.end(JSON.stringify({ error: "use POST" }));
25788
- return;
25789
- }
25790
- if (shuttingDown) {
25791
- res.statusCode = 503;
25792
- res.end(JSON.stringify({ error: "shutting_down" }));
25793
- return;
25794
- }
25795
- const now = Date.now();
25796
- const ip = req.socket.remoteAddress ?? "unknown";
25797
- const last = lastClaimAt.get(ip) ?? 0;
25798
- if (now - last < CLAIM_MIN_INTERVAL_MS) {
25799
- res.statusCode = 429;
25800
- res.end(JSON.stringify({ error: "too_soon", retryAfterMs: CLAIM_MIN_INTERVAL_MS - (now - last) }));
25801
- return;
25802
- }
25803
- lastClaimAt.set(ip, now);
25804
- const token = rotateHostToken();
25805
- log.info("host token claimed", { ip });
25806
- res.end(JSON.stringify({ token, rotatedAt: new Date(now).toISOString() }));
25994
+ void handleClaimToken(req, res).catch((e) => {
25995
+ log.warn("claim handler failed", { error: String(e) });
25996
+ if (!res.headersSent) {
25997
+ res.statusCode = 500;
25998
+ res.end(JSON.stringify({ error: "internal" }));
25999
+ }
26000
+ });
25807
26001
  return;
25808
26002
  }
25809
26003
  if (url2.pathname === "/api/info") {
@@ -25992,7 +26186,7 @@ function registerSignalHandlers(shutdown) {
25992
26186
 
25993
26187
  // src/main.ts
25994
26188
  function readVersion() {
25995
- if ("3.1.2") return "3.1.2";
26189
+ if ("3.1.3") return "3.1.3";
25996
26190
  try {
25997
26191
  const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
25998
26192
  if (typeof pkg.version === "string") return pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-remote/relay",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "description": "DSH Remote Control 的零知识 WebSocket 中继:只做路由,不碰载荷明文。可自托管的单文件产物。",
5
5
  "type": "module",
6
6
  "license": "MIT",