@dsh-remote/relay 3.1.0 → 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 +260 -25
- 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
|
@@ -2279,7 +2279,7 @@ var require_websocket = __commonJS({
|
|
|
2279
2279
|
var http = __require("http");
|
|
2280
2280
|
var net = __require("net");
|
|
2281
2281
|
var tls = __require("tls");
|
|
2282
|
-
var { randomBytes:
|
|
2282
|
+
var { randomBytes: randomBytes3, createHash } = __require("crypto");
|
|
2283
2283
|
var { Duplex, Readable } = __require("stream");
|
|
2284
2284
|
var { URL: URL2 } = __require("url");
|
|
2285
2285
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
@@ -2830,7 +2830,7 @@ var require_websocket = __commonJS({
|
|
|
2830
2830
|
}
|
|
2831
2831
|
}
|
|
2832
2832
|
const defaultPort = isSecure ? 443 : 80;
|
|
2833
|
-
const key =
|
|
2833
|
+
const key = randomBytes3(16).toString("base64");
|
|
2834
2834
|
const request = isSecure ? https.request : http.request;
|
|
2835
2835
|
const protocolSet = /* @__PURE__ */ new Set();
|
|
2836
2836
|
let perMessageDeflate;
|
|
@@ -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 计数虚高、排障对不上。
|
|
@@ -23855,7 +23870,7 @@ function loadConfig(env, version2) {
|
|
|
23855
23870
|
|
|
23856
23871
|
// src/server.ts
|
|
23857
23872
|
import { createServer } from "node:http";
|
|
23858
|
-
import { randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
23873
|
+
import { randomBytes as randomBytes2, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
23859
23874
|
|
|
23860
23875
|
// node_modules/.pnpm/ws@8.22.0/node_modules/ws/wrapper.mjs
|
|
23861
23876
|
var import_stream = __toESM(require_stream(), 1);
|
|
@@ -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,6 +25270,9 @@ function tokenMatches(expected, got) {
|
|
|
25147
25270
|
if (a.length !== b.length) return false;
|
|
25148
25271
|
return timingSafeEqual(a, b);
|
|
25149
25272
|
}
|
|
25273
|
+
var CLAIM_TRACK_MAX = 1e4;
|
|
25274
|
+
var MAX_HOST_ID_LEN2 = 200;
|
|
25275
|
+
var HOST_ID_RE = /^[A-Za-z0-9._:-]+$/;
|
|
25150
25276
|
function createRelay(config2) {
|
|
25151
25277
|
const log = new Log(config2.logLevel);
|
|
25152
25278
|
const state = new RelayState({ maxPendingPairs: config2.maxPendingPairs });
|
|
@@ -25155,10 +25281,39 @@ function createRelay(config2) {
|
|
|
25155
25281
|
const pairStatusBudget = new Budget(config2.pairStatusBudgetPerSec);
|
|
25156
25282
|
const peers = /* @__PURE__ */ new WeakMap();
|
|
25157
25283
|
let shuttingDown = false;
|
|
25284
|
+
const lastClaimAt = /* @__PURE__ */ new Map();
|
|
25158
25285
|
let sweepTimer;
|
|
25159
25286
|
let pingTimer;
|
|
25160
25287
|
let lastPingAt = 0;
|
|
25161
25288
|
let lastCountersAt = 0;
|
|
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;
|
|
25300
|
+
}
|
|
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);
|
|
25316
|
+
}
|
|
25162
25317
|
const persistence = { enabled: config2.stateFile !== "", restored: 0, savedAt: 0, writes: 0, failures: 0 };
|
|
25163
25318
|
if (persistence.enabled) {
|
|
25164
25319
|
persistence.restored = restoreState(config2.stateFile, state, log, startedAt);
|
|
@@ -25284,19 +25439,19 @@ function createRelay(config2) {
|
|
|
25284
25439
|
}
|
|
25285
25440
|
peer.protocol = frame.protocol;
|
|
25286
25441
|
if (frame.role === "host") {
|
|
25287
|
-
|
|
25442
|
+
const hostId = frame.hostId?.trim() || newHostId();
|
|
25443
|
+
if (!config2.hostToken && state.hostTokens.size === 0) {
|
|
25288
25444
|
sendError(peer, "internal", "relay has no host token configured");
|
|
25289
25445
|
peer.ws.close(1011);
|
|
25290
25446
|
return;
|
|
25291
25447
|
}
|
|
25292
|
-
if (!
|
|
25448
|
+
if (!hostTokenAccepted(hostId, frame.token)) {
|
|
25293
25449
|
peer.authAttempts += 1;
|
|
25294
25450
|
log.warn("host auth failed", { attempts: peer.authAttempts });
|
|
25295
25451
|
sendError(peer, "bad_token");
|
|
25296
25452
|
if (peer.authAttempts >= config2.maxHostAuthAttempts) peer.ws.close(4001, "too_many_auth_attempts");
|
|
25297
25453
|
return;
|
|
25298
25454
|
}
|
|
25299
|
-
const hostId = frame.hostId?.trim() || newHostId();
|
|
25300
25455
|
releaseIdentityFor(peer, { role: "host", id: hostId });
|
|
25301
25456
|
const { replaced } = state.attachHost(hostId, peer.ws, frame.label ?? "");
|
|
25302
25457
|
peer.role = "host";
|
|
@@ -25756,6 +25911,76 @@ function createRelay(config2) {
|
|
|
25756
25911
|
peersNoProtocol: peersWithoutProtocol
|
|
25757
25912
|
};
|
|
25758
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
|
+
}
|
|
25759
25984
|
function httpHandler(req, res) {
|
|
25760
25985
|
try {
|
|
25761
25986
|
const url2 = new URL(req.url ?? "/", "http://relay.invalid");
|
|
@@ -25765,6 +25990,16 @@ function createRelay(config2) {
|
|
|
25765
25990
|
res.end(JSON.stringify(health()));
|
|
25766
25991
|
return;
|
|
25767
25992
|
}
|
|
25993
|
+
if (url2.pathname === "/api/claim-token") {
|
|
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
|
+
});
|
|
26001
|
+
return;
|
|
26002
|
+
}
|
|
25768
26003
|
if (url2.pathname === "/api/info") {
|
|
25769
26004
|
res.end(JSON.stringify({ publicUrl: config2.publicUrl, protocol: PROTOCOL_VERSION }));
|
|
25770
26005
|
return;
|
|
@@ -25951,7 +26186,7 @@ function registerSignalHandlers(shutdown) {
|
|
|
25951
26186
|
|
|
25952
26187
|
// src/main.ts
|
|
25953
26188
|
function readVersion() {
|
|
25954
|
-
if ("3.1.
|
|
26189
|
+
if ("3.1.3") return "3.1.3";
|
|
25955
26190
|
try {
|
|
25956
26191
|
const pkg = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8"));
|
|
25957
26192
|
if (typeof pkg.version === "string") return pkg.version;
|