@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 +6 -6
- package/dist/bundle/main.js +250 -56
- package/package.json +1 -1
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` |
|
|
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 就是没在写。
|
package/dist/bundle/main.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
23743
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
24668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
25786
|
-
|
|
25787
|
-
res.
|
|
25788
|
-
|
|
25789
|
-
|
|
25790
|
-
|
|
25791
|
-
|
|
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.
|
|
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;
|