dsh-remote-bridge 2.5.0 → 2.5.2
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.en.md +22 -7
- package/README.md +20 -7
- package/docs/api/asyncapi.yaml +51 -2
- package/docs/api/openapi.yaml +187 -4
- package/lib/client.cjs +1113 -509
- package/lib/index.d.ts +462 -15
- package/lib/index.js +2431 -505
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path, { join, sep } from "node:path";
|
|
2
2
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { execFile, spawn } from "node:child_process";
|
|
4
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
5
|
import os, { homedir, hostname, networkInterfaces } from "node:os";
|
|
6
|
+
import { createReadStream, mkdirSync } from "node:fs";
|
|
7
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import { isIP } from "node:net";
|
|
@@ -44,6 +44,293 @@ function asApiError(error) {
|
|
|
44
44
|
return new ApiError(500, "INTERNAL_ERROR", "An internal server error occurred.");
|
|
45
45
|
}
|
|
46
46
|
//#endregion
|
|
47
|
+
//#region src/host/image-handoff.ts
|
|
48
|
+
/**
|
|
49
|
+
* 图片交接的「识图路径」:目录名从哪来、图片怎么被安全地端到浏览器面前。
|
|
50
|
+
*
|
|
51
|
+
* 两件事合在一个模块里,因为它们是同一个概念的里外两面:
|
|
52
|
+
*
|
|
53
|
+
* 1. **设置**(`识图路径`):纯文本模型发图时,图片落在「会话工作目录 + <识图路径>」下。
|
|
54
|
+
* 出厂默认是 [DEFAULT_HANDOFF_SUBDIR],设置面板里把这一栏**只读**展示(用户要求:
|
|
55
|
+
* 「当前默认(不能改)」),真正可编辑的是那个目录名本身,存在 settings 表里。
|
|
56
|
+
* 2. **只读路由**(从 dsh-image-paths 0.3.1 并进来的那半边):浏览器 `<img>` 不能带
|
|
57
|
+
* Authorization 头,所以走不了版本化 REST 的鉴权通道。这条路由改用「回环 + 同源 +
|
|
58
|
+
* 裸文件名白名单 + 字节自证」的模型;**目录不由客户端给**,而是按会话工作目录由宿主
|
|
59
|
+
* 自己推出(与写盘用的是同一个解析),于是「指一个路径去读本地文件」这件事在参数
|
|
60
|
+
* 层面就不成立 —— 这是比 image-paths 原版更紧的一步。
|
|
61
|
+
*
|
|
62
|
+
* 威胁模型与 image-paths 一致:webserver 绑回环,跨站请求拒绝,返回的每个字节都先过了
|
|
63
|
+
* 「文件名白名单 + 扩展名声明 + 内容自证 + 体积上限」四道闸。
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* 交接目录的**出厂默认**。
|
|
67
|
+
*
|
|
68
|
+
* 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
|
|
69
|
+
* 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
|
|
70
|
+
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
73
|
+
/** settings 表里的键名。 */
|
|
74
|
+
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
75
|
+
/**
|
|
76
|
+
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
77
|
+
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
78
|
+
*/
|
|
79
|
+
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
80
|
+
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
81
|
+
const MAX_SUBDIR_CHARS = 64;
|
|
82
|
+
/**
|
|
83
|
+
* 把用户填的目录名收成一个安全值。
|
|
84
|
+
*
|
|
85
|
+
* 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
|
|
86
|
+
* 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
|
|
87
|
+
* 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
|
|
88
|
+
*/
|
|
89
|
+
function normalizeHandoffSubdir(value) {
|
|
90
|
+
if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (trimmed === "" || trimmed.length > MAX_SUBDIR_CHARS) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", `The image directory name must be 1-${MAX_SUBDIR_CHARS} characters.`);
|
|
93
|
+
if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
|
|
94
|
+
if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name may only contain letters, digits, dots, underscores, and hyphens.");
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 读出当前生效的目录名。
|
|
99
|
+
*
|
|
100
|
+
* 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
|
|
101
|
+
* 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
|
|
102
|
+
*/
|
|
103
|
+
function readHandoffSubdir(database) {
|
|
104
|
+
const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
|
|
105
|
+
if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
|
|
106
|
+
try {
|
|
107
|
+
return normalizeHandoffSubdir(stored);
|
|
108
|
+
} catch {
|
|
109
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
|
|
110
|
+
return DEFAULT_HANDOFF_SUBDIR;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** 校验并保存,返回落库后的值。 */
|
|
114
|
+
function saveHandoffSubdir(database, value) {
|
|
115
|
+
const normalized = normalizeHandoffSubdir(value);
|
|
116
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
/** 设置面板要的那三个值。 */
|
|
120
|
+
function imageHandoffView(database, fallbackDir) {
|
|
121
|
+
return {
|
|
122
|
+
subdir: readHandoffSubdir(database),
|
|
123
|
+
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
124
|
+
fallbackDir
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
|
|
128
|
+
const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
129
|
+
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
130
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
131
|
+
png: "image/png",
|
|
132
|
+
jpg: "image/jpeg",
|
|
133
|
+
jpeg: "image/jpeg",
|
|
134
|
+
webp: "image/webp",
|
|
135
|
+
gif: "image/gif",
|
|
136
|
+
heic: "image/heic",
|
|
137
|
+
heif: "image/heif",
|
|
138
|
+
bmp: "image/bmp",
|
|
139
|
+
avif: "image/avif"
|
|
140
|
+
};
|
|
141
|
+
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
142
|
+
const MAX_BYTES = 33554432;
|
|
143
|
+
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
144
|
+
const MAX_NAME_CHARS = 4096;
|
|
145
|
+
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
146
|
+
function declaredMediaType(name) {
|
|
147
|
+
const cut = name.lastIndexOf(".");
|
|
148
|
+
if (cut === -1) return void 0;
|
|
149
|
+
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 「扩展名声明」与「字节自证」是否属于同一图片家族。
|
|
153
|
+
*
|
|
154
|
+
* 为什么需要这一层而不是直接 `declared === sniffed`:**HEIC 与 HEIF 在字节层面无法区分**。
|
|
155
|
+
* 两者都是 ISO-BMFF,靠 `ftyp` 后面的 brand 认家族,而 `heic`/`heif`/`mif1`/`hevc`
|
|
156
|
+
* 都在同一套 `hei*` / `hev*` / `mif*` 前缀里 —— 嗅探器只能诚实地报出「这是 HEI 家族」,
|
|
157
|
+
* 也就是 `image/heic`;它**不该**为了迁就某条扩展名而返回一个自己无法自证的值。
|
|
158
|
+
*
|
|
159
|
+
* 于是把「等价」这件事放在一致性校验这一侧:
|
|
160
|
+
* - 写盘表(`dsh-0.2-bridge.ts`)按内核给的 mediaType 原样产 `<hash>.heif`;
|
|
161
|
+
* - 读取表把 `heif` 声明成 `image/heif`;
|
|
162
|
+
* - 嗅探器对同一份字节返回 `image/heic`;
|
|
163
|
+
* - 两者同族 → 放行。
|
|
164
|
+
*
|
|
165
|
+
* 这不是把闸门拆了:**跨家族的错配仍然全部 415**(png 字节配 `.jpg` 名、
|
|
166
|
+
* `image/png` 配 `.avif` 名、heic 字节配 `.avif` 名)。只放开这一对无法在字节层
|
|
167
|
+
* 区分的「同一家族两个名字」。
|
|
168
|
+
*/
|
|
169
|
+
function sameImageFamily(declared, sniffed) {
|
|
170
|
+
if (declared === sniffed) return true;
|
|
171
|
+
const heifFamily = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
172
|
+
return heifFamily.has(declared) && heifFamily.has(sniffed);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
|
|
176
|
+
* @param head - 文件前至少 12 个字节。
|
|
177
|
+
*/
|
|
178
|
+
function sniffMediaType(head) {
|
|
179
|
+
const ascii = (offset, text) => {
|
|
180
|
+
if (head.length < offset + text.length) return false;
|
|
181
|
+
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
const bytes = (...expected) => {
|
|
185
|
+
if (head.length < expected.length) return false;
|
|
186
|
+
return expected.every((byte, index) => head[index] === byte);
|
|
187
|
+
};
|
|
188
|
+
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
189
|
+
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
190
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
191
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
192
|
+
if (bytes(66, 77)) return "image/bmp";
|
|
193
|
+
if (ascii(4, "ftyp")) {
|
|
194
|
+
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
195
|
+
if (brand.startsWith("avi")) return "image/avif";
|
|
196
|
+
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */ function loopbackHost(req) {
|
|
200
|
+
const host = req.headers.host;
|
|
201
|
+
if (typeof host !== "string" || host === "") return false;
|
|
202
|
+
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
203
|
+
const name = host.split(":")[0];
|
|
204
|
+
return name === "127.0.0.1" || name === "localhost";
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 请求是否来自某个插件自己服务的页面。
|
|
208
|
+
*
|
|
209
|
+
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
210
|
+
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
211
|
+
*/
|
|
212
|
+
function pluginPageReferer(req) {
|
|
213
|
+
const referer = req.headers.referer;
|
|
214
|
+
if (typeof referer !== "string" || referer === "") return false;
|
|
215
|
+
try {
|
|
216
|
+
const url = new URL(referer);
|
|
217
|
+
if (url.host !== req.headers.host) return false;
|
|
218
|
+
return url.pathname.startsWith("/plugin/");
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 请求是否可以被回答。
|
|
225
|
+
*
|
|
226
|
+
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
227
|
+
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
228
|
+
*/
|
|
229
|
+
function sameOrigin(req) {
|
|
230
|
+
if (!loopbackHost(req)) return false;
|
|
231
|
+
if (pluginPageReferer(req)) return true;
|
|
232
|
+
const site = req.headers["sec-fetch-site"];
|
|
233
|
+
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
234
|
+
const origin = req.headers.origin;
|
|
235
|
+
if (typeof origin !== "string" || origin === "") return true;
|
|
236
|
+
try {
|
|
237
|
+
return new URL(origin).host === req.headers.host;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 内容是够与文件名里的哈希一致。
|
|
244
|
+
*
|
|
245
|
+
* 文件名形如 `<64 hex>.<ext>`(`HANDOFF_FILE_NAME` 已保证),取前 64 字符当期望值。
|
|
246
|
+
* `message` 前缀(`sha256:`)不需要处理 —— 白名单正则里没有冒号。
|
|
247
|
+
*/
|
|
248
|
+
function contentMatchesName(bytes, name) {
|
|
249
|
+
const expected = name.slice(0, 64).toLowerCase();
|
|
250
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) return false;
|
|
251
|
+
return createHash("sha256").update(bytes).digest("hex") === expected;
|
|
252
|
+
}
|
|
253
|
+
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
254
|
+
function refuse(res, status, reason) {
|
|
255
|
+
const body = `${reason}\n`;
|
|
256
|
+
res.writeHead(status, {
|
|
257
|
+
"content-type": "text/plain; charset=utf-8",
|
|
258
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
259
|
+
"cache-control": "no-store",
|
|
260
|
+
"x-content-type-options": "nosniff"
|
|
261
|
+
});
|
|
262
|
+
res.end(body);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 这条路由的处理器工厂。
|
|
266
|
+
* @param options - 取图实现。
|
|
267
|
+
* @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
|
|
268
|
+
*/
|
|
269
|
+
function createImageRouteHandler(options) {
|
|
270
|
+
return async function serveImage(req, res) {
|
|
271
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
272
|
+
refuse(res, 405, "method not allowed");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!sameOrigin(req)) {
|
|
276
|
+
refuse(res, 403, "cross-site request refused");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
280
|
+
const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
|
|
281
|
+
const rawName = url.searchParams.get("name") ?? "";
|
|
282
|
+
if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
|
|
283
|
+
refuse(res, 400, "sessionId and name are required");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const name = rawName.split(/[/\\]/).pop() ?? rawName;
|
|
287
|
+
if (HANDOFF_FILE_NAME.exec(name) === null) {
|
|
288
|
+
refuse(res, 404, "no such handoff image");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const declared = declaredMediaType(name);
|
|
292
|
+
let image;
|
|
293
|
+
try {
|
|
294
|
+
image = await options.read(sessionId, name);
|
|
295
|
+
} catch {
|
|
296
|
+
refuse(res, 404, "no such handoff image");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (image.bytes.byteLength === 0) {
|
|
300
|
+
refuse(res, 404, "empty file");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (image.bytes.byteLength > MAX_BYTES) {
|
|
304
|
+
refuse(res, 413, "image is too large to display");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
308
|
+
if (sniffed === void 0) {
|
|
309
|
+
refuse(res, 415, "content is not a supported image");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (declared !== void 0 && !sameImageFamily(declared, sniffed)) {
|
|
313
|
+
refuse(res, 415, "content does not match the file extension");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!contentMatchesName(image.bytes, name)) {
|
|
317
|
+
refuse(res, 404, "no such handoff image");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
res.writeHead(200, {
|
|
321
|
+
"content-type": sniffed,
|
|
322
|
+
"content-length": String(image.bytes.byteLength),
|
|
323
|
+
"cache-control": "private, max-age=300",
|
|
324
|
+
"x-content-type-options": "nosniff"
|
|
325
|
+
});
|
|
326
|
+
if (req.method === "HEAD") {
|
|
327
|
+
res.end();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
47
334
|
//#region src/host/rate-limit.ts
|
|
48
335
|
var FixedWindowLimiter = class {
|
|
49
336
|
limit;
|
|
@@ -74,27 +361,206 @@ var FixedWindowLimiter = class {
|
|
|
74
361
|
};
|
|
75
362
|
//#endregion
|
|
76
363
|
//#region src/host/auth.ts
|
|
364
|
+
/**
|
|
365
|
+
* 判定「这是不是一次真正的本机管理请求」。
|
|
366
|
+
*
|
|
367
|
+
* ## 为什么不能只看 `remoteAddress`
|
|
368
|
+
*
|
|
369
|
+
* 修复前 `requireAdmin` 的全部判据是「TCP 来源是回环 + Origin 是回环」。这条判据在
|
|
370
|
+
* **任何把外部请求转到本机回环的拓扑下都恒真**:插件自带的 cloudflared Quick Tunnel
|
|
371
|
+
* 用 `--url http://127.0.0.1:PORT` 转发(`cloudflared.ts`),公网请求进到 bridge 时
|
|
372
|
+
* `req.socket.remoteAddress` 就是 `127.0.0.1`。于是 `https://xxx.trycloudflare.com/manage/roots`
|
|
373
|
+
* 一个凭据都不带也能读到全部授权根的绝对路径(v2.5.0 实测 200/201)。
|
|
374
|
+
* 同一类拓扑还包括 ngrok、frp、Caddy/Nginx `proxy_pass 127.0.0.1`、SSH 端口转发、
|
|
375
|
+
* Tailscale Serve。见 `docs/project/PITFALLS.md` §隧道让回环判据失效。
|
|
376
|
+
*
|
|
377
|
+
* ## 四层判据(层层收紧,`undefined` = 放行)
|
|
378
|
+
*
|
|
379
|
+
* 0. **隧道运行中一律拒绝**(`tunnelActive === true`)—— 100% 可靠的一条:隧道是我们
|
|
380
|
+
* 自己拉的进程,状态握在自己手里,不依赖任何可伪造的请求头。返回
|
|
381
|
+
* `TUNNEL_MODE_ADMIN_DISABLED`。
|
|
382
|
+
* 1. **出现任何转发头即视为经代理** —— `Forwarded` / `X-Forwarded-For` /
|
|
383
|
+
* `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP`。真正的本机浏览器与
|
|
384
|
+
* `curl 127.0.0.1` 都不会发这些头;发了就说明前面有一层我们不认识的代理。
|
|
385
|
+
* 返回 `ADMIN_PROXY_FORWARDED`。
|
|
386
|
+
* 2. **`Host` 必须是回环**(复用 `image-handoff.ts` 的 `loopbackHost`,它接受
|
|
387
|
+
* `127.0.0.1` / `localhost` / `[::1]`)。缺失 `Host` 也拒。这一条挡两类攻击:
|
|
388
|
+
* 隧道/反代转发进来的公网域名(`Host: xxx.trycloudflare.com`),以及 **DNS 重绑定**
|
|
389
|
+
* (攻击者域名解析到 127.0.0.1,浏览器照样发回环 `Origin`,只有 `Host` 露馅)。
|
|
390
|
+
* 返回 `ADMIN_HOST_NOT_LOOPBACK`。
|
|
391
|
+
* 3. **socket 来源必须是回环,`Origin` 若存在必须是回环**。`Origin` 缺失**仍然放行** ——
|
|
392
|
+
* 这是刻意的取舍,理由见下。
|
|
393
|
+
*
|
|
394
|
+
* ## 为什么 `Origin` 缺失还放行(刻意的取舍,别「顺手修掉」)
|
|
395
|
+
*
|
|
396
|
+
* 翻成「必须带回环 Origin」能多挡一点,但会**同时打死 `curl` 与脚本调试**:它们在
|
|
397
|
+
* 本机跑、天然不带 `Origin`,而本机进程本来就有权读这些文件(它们能直接 `cat`)。
|
|
398
|
+
* 真正的防线不是「请求必须像浏览器」,而是**拓扑与头**:判据 0 管住了我们自己拉的隧道,
|
|
399
|
+
* 判据 1 管住了任何代理,判据 2 管住了公网域名与 DNS 重绑定。隧道场景里公网请求即使
|
|
400
|
+
* 不带 `Origin` 也会先撞上判据 0/1/2 —— 这正是 §3.1 那条攻击链的断点。
|
|
401
|
+
*
|
|
402
|
+
* 换句话说:**允许缺 `Origin` 不再是「可信」,而是「不再有决定权」**。修复前它是有
|
|
403
|
+
* 决定权的(`isLoopbackOrigin(undefined) === true` 直接就给了管理员身份)。
|
|
404
|
+
*
|
|
405
|
+
* @param req - 待判定的请求。
|
|
406
|
+
* @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
|
|
407
|
+
* @param options.allowTunnelMode - 仅用于「隧道状态」与「停止隧道」端点:让它们豁免判据 0
|
|
408
|
+
* (否则隧道一开,面板连「隧道开着」都读不到,也无法体面地把隧道停掉),判据 1/2/3
|
|
409
|
+
* 仍然全部生效。其中 `cloudflare/stop` 是**唯一**豁免的写操作 —— 它的唯一效果就是把
|
|
410
|
+
* 管理面重新锁上(重新拉回判据 0),且判据 1 仍挡住任何经隧道/代理的调用,所以对真实
|
|
411
|
+
* 隧道来源(必带 cf-* 头)不可利用;放行它不构成任何提权。**其他任何 `/manage` 端点
|
|
412
|
+
* (尤其 config/text)都不许传这个选项。**
|
|
413
|
+
*/
|
|
414
|
+
function adminRefusal(req, options = {}) {
|
|
415
|
+
if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
|
|
416
|
+
code: "TUNNEL_MODE_ADMIN_DISABLED",
|
|
417
|
+
message: "Management endpoints are disabled while the Cloudflare tunnel is running. Use the local WebUI, or stop the tunnel first."
|
|
418
|
+
};
|
|
419
|
+
for (const name of FORWARDING_HEADERS) if (headerPresent(req, name)) return {
|
|
420
|
+
code: "ADMIN_PROXY_FORWARDED",
|
|
421
|
+
message: "Management endpoints are available only from the local machine, not through a proxy or tunnel."
|
|
422
|
+
};
|
|
423
|
+
if (!loopbackHost(req)) return {
|
|
424
|
+
code: "ADMIN_HOST_NOT_LOOPBACK",
|
|
425
|
+
message: "Management endpoints are available only under a loopback host name (127.0.0.1, localhost or [::1])."
|
|
426
|
+
};
|
|
427
|
+
if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) return {
|
|
428
|
+
code: "ADMIN_LOOPBACK_REQUIRED",
|
|
429
|
+
message: "This operation is available only from the local machine."
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** 是否存在任何转发头(`adminRefusal` 判据 1 的公开形态,供测试与文档断言复用)。 */
|
|
433
|
+
function hasForwardingHeader(req) {
|
|
434
|
+
return FORWARDING_HEADERS.some((name) => headerPresent(req, name));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* 「这个请求不是本机直连」的**全部**证据头。
|
|
438
|
+
*
|
|
439
|
+
* 分两组,理由不同:
|
|
440
|
+
*
|
|
441
|
+
* 1. **转发头**(`Forwarded` / `X-Forwarded-*` / `X-Real-IP`):本机浏览器与
|
|
442
|
+
* `curl 127.0.0.1` 都不会发。**存在即拒**,不看值 —— `X-Forwarded-For: 127.0.0.1`
|
|
443
|
+
* 正是最典型的洗白写法(攻击者从隧道进来,塞一个声称自己是本机的头)。
|
|
444
|
+
* 2. **Cloudflare 边缘头**(`Cf-Connecting-IP` / `Cf-Ray` / `Cf-IpCountry` / `Cf-Visitor`):
|
|
445
|
+
* 这一组是**真实隧道场景的兜底**。cloudflared 的 `httpHostHeader` 默认为空,会把
|
|
446
|
+
* `Host` 改写成源站地址(`127.0.0.1:PORT`),于是判据 2 在真实隧道下挡不住;
|
|
447
|
+
* 而 Cloudflare 边缘一定会注入这组头(且请求方无法伪造成「我没有经过边缘」)。
|
|
448
|
+
* 实测(`.local-backup/security-audit/verify-fix-tunnel-admin.mjs`):
|
|
449
|
+
* 只把 Host 改成回环地址就能拿到 200,加上这组头才被挡住。
|
|
450
|
+
*
|
|
451
|
+
* 这两组都不参与「判据 0 的豁免」—— 隧道状态端点仍要求它们为空。
|
|
452
|
+
*/
|
|
453
|
+
const FORWARDING_HEADERS = [
|
|
454
|
+
"forwarded",
|
|
455
|
+
"x-forwarded-for",
|
|
456
|
+
"x-forwarded-host",
|
|
457
|
+
"x-forwarded-proto",
|
|
458
|
+
"x-real-ip",
|
|
459
|
+
"cf-connecting-ip",
|
|
460
|
+
"cf-ray",
|
|
461
|
+
"cf-ipcountry",
|
|
462
|
+
"cf-visitor"
|
|
463
|
+
];
|
|
464
|
+
/** 头存在且非空才算「出现」:空串是某些代理库的产物,不构成声明。 */
|
|
465
|
+
function headerPresent(req, name) {
|
|
466
|
+
const raw = req.headers[name];
|
|
467
|
+
if (raw === void 0) return false;
|
|
468
|
+
if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
|
|
469
|
+
return raw.trim() !== "";
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* 判定一条请求属于哪一类对端。纯函数(除 `tunnelActive` 探针外不碰任何状态),
|
|
473
|
+
* 供限流分桶与测试直接调用。
|
|
474
|
+
*
|
|
475
|
+
* @param req - 待分类的请求。
|
|
476
|
+
* @param options.tunnelActive - 隧道是否在跑;缺省视为没有。
|
|
477
|
+
*/
|
|
478
|
+
function classifyPeer(req) {
|
|
479
|
+
if (hasForwardingHeader(req)) return "tunnelled";
|
|
480
|
+
return "local";
|
|
481
|
+
}
|
|
77
482
|
var AuthService = class {
|
|
78
483
|
database;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
484
|
+
options;
|
|
485
|
+
/**
|
|
486
|
+
* 两个「按对端分类」分开计的桶(键 = `<class>:<remoteAddress>`)。
|
|
487
|
+
*
|
|
488
|
+
* 阈值刻意**两边一致**:分桶的目的是「互不连坐」,不是「给本机开后门」。
|
|
489
|
+
* 如果本机侧更宽,那它就成了一条「怎么把自己伪装成本机」的动机 —— 而伪装是免费的
|
|
490
|
+
* (不发头即可),于是更宽的那一侧等于被取消。
|
|
491
|
+
* 阈值相等时,攻击者伪装成 local 只会消耗 local 配额,**没有额外收益**。
|
|
492
|
+
*
|
|
493
|
+
* `requests`:120/60s。`authFailures`:20/5min。
|
|
494
|
+
*/
|
|
495
|
+
authFailuresByPeer = {
|
|
496
|
+
local: new FixedWindowLimiter(20, 3e5),
|
|
497
|
+
tunnelled: new FixedWindowLimiter(20, 3e5)
|
|
498
|
+
};
|
|
499
|
+
/** 配对兑换:本机与隧道分开。隧道侧公网暴力尝试不该锁死本机的配对。 */
|
|
500
|
+
pairingAttemptsByPeer = {
|
|
501
|
+
local: new FixedWindowLimiter(10, 6e5),
|
|
502
|
+
tunnelled: new FixedWindowLimiter(10, 6e5)
|
|
503
|
+
};
|
|
504
|
+
requestsByPeer = {
|
|
505
|
+
local: new FixedWindowLimiter(120, 6e4),
|
|
506
|
+
tunnelled: new FixedWindowLimiter(120, 6e4)
|
|
507
|
+
};
|
|
508
|
+
constructor(database, options = {}) {
|
|
83
509
|
this.database = database;
|
|
510
|
+
this.options = options;
|
|
511
|
+
}
|
|
512
|
+
/** 这套服务当前的对端分类(`tunnelActive` 走 fail-closed 探针)。 */
|
|
513
|
+
peerOf(req) {
|
|
514
|
+
return classifyPeer(req);
|
|
515
|
+
}
|
|
516
|
+
/** 按对端取请求配额桶。 */
|
|
517
|
+
requestLimiter(req) {
|
|
518
|
+
return this.requestsByPeer[this.peerOf(req)];
|
|
519
|
+
}
|
|
520
|
+
/** 按对端取认证失败桶。 */
|
|
521
|
+
failureLimiter(req) {
|
|
522
|
+
return this.authFailuresByPeer[this.peerOf(req)];
|
|
523
|
+
}
|
|
524
|
+
/** 按对端取配对尝试桶(`router.ts` 的 exchange 端点用)。 */
|
|
525
|
+
pairingLimiter(req) {
|
|
526
|
+
return this.pairingAttemptsByPeer[this.peerOf(req)];
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
|
|
530
|
+
* 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
|
|
531
|
+
* 消灭的那类决定。
|
|
532
|
+
*/
|
|
533
|
+
tunnelRunning() {
|
|
534
|
+
const probe = this.options.tunnelActive;
|
|
535
|
+
if (probe === void 0) return false;
|
|
536
|
+
try {
|
|
537
|
+
return probe() === true;
|
|
538
|
+
} catch {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
84
541
|
}
|
|
85
542
|
principal(req, options = {}) {
|
|
86
543
|
const remote = requestAddress(req);
|
|
87
|
-
this.
|
|
88
|
-
|
|
544
|
+
const peer = this.peerOf(req);
|
|
545
|
+
const key = `${peer}:${remote}`;
|
|
546
|
+
this.requestsByPeer[peer].consume(key);
|
|
547
|
+
if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
|
|
548
|
+
const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
|
|
549
|
+
if (refusal !== void 0) {
|
|
550
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
551
|
+
throw new ApiError(403, refusal.code, refusal.message);
|
|
552
|
+
}
|
|
553
|
+
return ADMIN_PRINCIPAL;
|
|
554
|
+
}
|
|
89
555
|
const authorization = req.headers.authorization;
|
|
90
556
|
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
91
|
-
this.
|
|
557
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
92
558
|
throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
|
|
93
559
|
}
|
|
94
560
|
const token = authorization.slice(7).trim();
|
|
95
561
|
const device = token === "" ? void 0 : this.database.authenticate(token);
|
|
96
562
|
if (device === void 0) {
|
|
97
|
-
this.
|
|
563
|
+
this.authFailuresByPeer[peer].consume(key);
|
|
98
564
|
throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
|
|
99
565
|
}
|
|
100
566
|
return {
|
|
@@ -105,8 +571,18 @@ var AuthService = class {
|
|
|
105
571
|
rootIds: new Set(device.rootIds)
|
|
106
572
|
};
|
|
107
573
|
}
|
|
108
|
-
|
|
109
|
-
|
|
574
|
+
/**
|
|
575
|
+
* `/manage/**` 的守门人。
|
|
576
|
+
*
|
|
577
|
+
* @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
|
|
578
|
+
* 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
|
|
579
|
+
*/
|
|
580
|
+
requireAdmin(req, options = {}) {
|
|
581
|
+
const refusal = adminRefusal(req, {
|
|
582
|
+
tunnelActive: this.tunnelRunning(),
|
|
583
|
+
...options.allowTunnelMode === true ? { allowTunnelMode: true } : {}
|
|
584
|
+
});
|
|
585
|
+
if (refusal !== void 0) throw new ApiError(403, refusal.code, refusal.message);
|
|
110
586
|
return ADMIN_PRINCIPAL;
|
|
111
587
|
}
|
|
112
588
|
requireScope(principal, scope) {
|
|
@@ -123,6 +599,13 @@ function isLoopbackAddress(address) {
|
|
|
123
599
|
const normalized = address.toLowerCase().split("%")[0];
|
|
124
600
|
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
|
125
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* `Origin` 是否是回环。
|
|
604
|
+
*
|
|
605
|
+
* `undefined` 返回 `true` 是**刻意保留**的(理由见 `adminRefusal` 的注释):它不再
|
|
606
|
+
* 单独构成「可信」,判据 0/1/2 已经承担了决定性判断。改这一行会让 `curl` 调试
|
|
607
|
+
* 无处可去,而本机进程本来就能直接读这些文件。
|
|
608
|
+
*/
|
|
126
609
|
function isLoopbackOrigin(origin) {
|
|
127
610
|
if (origin === void 0) return true;
|
|
128
611
|
try {
|
|
@@ -155,14 +638,27 @@ function archSuffix(platform = process.platform, arch = process.arch) {
|
|
|
155
638
|
default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
|
|
156
639
|
}
|
|
157
640
|
}
|
|
158
|
-
|
|
641
|
+
/**
|
|
642
|
+
* cloudflared 的**唯一**版本号(导出给测试与配置文本用)。
|
|
643
|
+
*
|
|
644
|
+
* ⚠️ 「钉版本」必须只有一处事实来源:2.5.1 及之前 GitHub 基线钉 `2024.11.1`,
|
|
645
|
+
* 而 deb 兜底 URL 里写死了另一个版本 `2026.10.1` —— 同一个函数对「装哪个版本」
|
|
646
|
+
* 给出两个不同答案,等于**没有钉住**。现在 deb URL 也从这里派生。
|
|
647
|
+
*/
|
|
648
|
+
const CLOUDFLARED_VERSION = "2024.11.1";
|
|
649
|
+
const OFFICIAL_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-`;
|
|
159
650
|
/**
|
|
160
651
|
* 官方 deb 兜底仓库(GitHub 被墙时回退)。
|
|
652
|
+
*
|
|
161
653
|
* `pkg.cloudflare.com` 是 Cloudflare 官方仓库,实测在 GitHub 直连被墙的机器上可达可用
|
|
162
|
-
* (见 PITFALLS §国内下载 cloudflared
|
|
163
|
-
*
|
|
654
|
+
* (见 PITFALLS §国内下载 cloudflared 必须择源)。仅在官方 GitHub 失败时兜底,
|
|
655
|
+
* 不改变「默认先 GitHub」的语义。
|
|
656
|
+
*
|
|
657
|
+
* 版本**从 `CLOUDFLARED_VERSION` 派生**,不再写死第二个版本号。
|
|
164
658
|
*/
|
|
165
|
-
|
|
659
|
+
function debFallbackUrl(debArch) {
|
|
660
|
+
return `https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_${CLOUDFLARED_VERSION}_${debArch}.deb`;
|
|
661
|
+
}
|
|
166
662
|
async function downloadHttps(url, ms, onProgress) {
|
|
167
663
|
const mod = url.startsWith("http:") ? await import("node:http") : await import("node:https");
|
|
168
664
|
return await new Promise((resolve, reject) => {
|
|
@@ -196,6 +692,60 @@ async function downloadHttps(url, ms, onProgress) {
|
|
|
196
692
|
function statusOk(code) {
|
|
197
693
|
return typeof code === "number" && code >= 200 && code < 300;
|
|
198
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* 校验下载到的字节是否匹配期望的 SHA-256。
|
|
697
|
+
*
|
|
698
|
+
* 大小写不敏感、允许 `sha256:` 前缀(常见发行格式)。**长度不对一律 false** ——
|
|
699
|
+
* 不做「宽松接受」:一个 3 字符的期望值只可能是配置错误,接受它就是放弃校验。
|
|
700
|
+
*/
|
|
701
|
+
function verifySha256(bytes, expectedHex) {
|
|
702
|
+
const normalized = expectedHex.trim().replace(/^sha256:/i, "").toLowerCase();
|
|
703
|
+
if (!/^[0-9a-f]{64}$/.test(normalized)) return false;
|
|
704
|
+
return createHash("sha256").update(bytes).digest("hex") === normalized;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* 期望的校验和:读 `CLOUDFLARED_SHA256` 环境变量。
|
|
708
|
+
*
|
|
709
|
+
* 空串/纯空白视为**没有声明**(返回 undefined)—— 否则「随手 export 一个空值」
|
|
710
|
+
* 会让所有下载都被判成不匹配。
|
|
711
|
+
*/
|
|
712
|
+
function expectedSha256From() {
|
|
713
|
+
const raw = process.env.CLOUDFLARED_SHA256?.trim() ?? "";
|
|
714
|
+
return raw === "" ? void 0 : raw;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* 把下载/解包错误**脱敏**成可以对外(与本机日志)说的一行字。
|
|
718
|
+
*
|
|
719
|
+
* ## 为什么需要它
|
|
720
|
+
*
|
|
721
|
+
* 底层错误有三类敏感残留:
|
|
722
|
+
* 1. **完整 URL** —— `downloadHttps` 的 `` `下载失败:${url}(${cause.message})` ``,
|
|
723
|
+
* 而镜像 URL 可能带 token(`https://mirror/token/...`);
|
|
724
|
+
* 2. **绝对路径** —— `execFile('dpkg-deb', ...)` 的错误形如
|
|
725
|
+
* `Command failed: dpkg-deb -x /home/user/.../x.deb /home/user/.../dir`;
|
|
726
|
+
* 3. 两者都可能被 `router.ts` 原样塞进 502 响应体。
|
|
727
|
+
*
|
|
728
|
+
* ## 取舍
|
|
729
|
+
*
|
|
730
|
+
* 保留**可诊断的线索**(host 名、文件名),去掉**不该外传的部分**(完整 URL、目录)。
|
|
731
|
+
* 一句话:能说「哪个源、哪个文件」;不能说「你家目录长什么样、URL 里那个 token 是什么」。
|
|
732
|
+
*/
|
|
733
|
+
function sanitizeDownloadError(message) {
|
|
734
|
+
let safe = message;
|
|
735
|
+
safe = safe.replace(/https?:\/\/[^\s))(("']+/gi, (raw) => {
|
|
736
|
+
try {
|
|
737
|
+
return `host:${new URL(raw).host}`;
|
|
738
|
+
} catch {
|
|
739
|
+
return "host:unknown";
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
safe = safe.replace(/(?:[A-Za-z]:)?(?:\/[^\s::/()()]+)+\/?/g, (raw) => {
|
|
743
|
+
const trimmed = raw.replace(/[\/]+$/, "");
|
|
744
|
+
const base = trimmed.split(/[\\/]/).pop() ?? trimmed;
|
|
745
|
+
return base === "" ? trimmed : base;
|
|
746
|
+
});
|
|
747
|
+
return safe;
|
|
748
|
+
}
|
|
199
749
|
/** 环境变量注入的下载源配置;空则回退官方。 */
|
|
200
750
|
function mirrorBase() {
|
|
201
751
|
return process.env.CLOUDFLARED_MIRROR?.trim() ?? "";
|
|
@@ -246,14 +796,14 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
246
796
|
const existing = path.join(dataDir, "cloudflared");
|
|
247
797
|
report?.({
|
|
248
798
|
stage: "locating",
|
|
249
|
-
|
|
799
|
+
labelKey: "tunnelStageLocating",
|
|
250
800
|
percent: null
|
|
251
801
|
});
|
|
252
802
|
try {
|
|
253
803
|
if ((await stat(existing)).isFile()) {
|
|
254
804
|
report?.({
|
|
255
805
|
stage: "locating",
|
|
256
|
-
|
|
806
|
+
labelKey: "tunnelStageBinaryFound",
|
|
257
807
|
percent: 100
|
|
258
808
|
});
|
|
259
809
|
return existing;
|
|
@@ -267,14 +817,15 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
267
817
|
url: downloadUrlFor(),
|
|
268
818
|
deb: false
|
|
269
819
|
}, {
|
|
270
|
-
url:
|
|
820
|
+
url: debFallbackUrl("amd64"),
|
|
271
821
|
deb: true
|
|
272
822
|
}];
|
|
823
|
+
const expected = expectedSha256From();
|
|
273
824
|
const errors = [];
|
|
274
825
|
for (const { url, deb } of candidates) {
|
|
275
826
|
report?.({
|
|
276
827
|
stage: "downloading",
|
|
277
|
-
|
|
828
|
+
labelKey: deb ? "tunnelStageDownloadDeb" : "tunnelStageDownloadGithub",
|
|
278
829
|
percent: null
|
|
279
830
|
});
|
|
280
831
|
try {
|
|
@@ -285,7 +836,11 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
285
836
|
const all = (total / 1024 / 1024).toFixed(1);
|
|
286
837
|
report?.({
|
|
287
838
|
stage: "downloading",
|
|
288
|
-
|
|
839
|
+
labelKey: "tunnelStageDownloadProgress",
|
|
840
|
+
labelParams: {
|
|
841
|
+
mb,
|
|
842
|
+
all
|
|
843
|
+
},
|
|
289
844
|
percent
|
|
290
845
|
});
|
|
291
846
|
}
|
|
@@ -294,17 +849,26 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
294
849
|
errors.push(`HTTP ${res.status}`);
|
|
295
850
|
continue;
|
|
296
851
|
}
|
|
852
|
+
if (expected !== void 0 && !verifySha256(res.body, expected)) {
|
|
853
|
+
errors.push("SHA-256 mismatch");
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (expected === void 0) report?.({
|
|
857
|
+
stage: "downloading",
|
|
858
|
+
labelKey: "tunnelStageDownloadUnverified",
|
|
859
|
+
percent: 100
|
|
860
|
+
});
|
|
297
861
|
if (deb) {
|
|
298
862
|
report?.({
|
|
299
863
|
stage: "unpacking",
|
|
300
|
-
|
|
864
|
+
labelKey: "tunnelStageUnpackDeb",
|
|
301
865
|
percent: null
|
|
302
866
|
});
|
|
303
867
|
return writeFromDeb(dataDir, res.body, url);
|
|
304
868
|
}
|
|
305
869
|
report?.({
|
|
306
870
|
stage: "unpacking",
|
|
307
|
-
|
|
871
|
+
labelKey: "tunnelStageWriteBinary",
|
|
308
872
|
percent: null
|
|
309
873
|
});
|
|
310
874
|
const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
|
|
@@ -313,15 +877,24 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
313
877
|
await rename(tmp, existing);
|
|
314
878
|
return existing;
|
|
315
879
|
} catch (cause) {
|
|
316
|
-
errors.push(cause instanceof Error ? cause.message : String(cause));
|
|
880
|
+
errors.push(sanitizeDownloadError(cause instanceof Error ? cause.message : String(cause)));
|
|
317
881
|
}
|
|
318
882
|
}
|
|
319
|
-
const
|
|
320
|
-
|
|
883
|
+
const triedHosts = [...new Set(candidates.map((c) => {
|
|
884
|
+
try {
|
|
885
|
+
return new URL(c.url).host;
|
|
886
|
+
} catch {
|
|
887
|
+
return "unknown";
|
|
888
|
+
}
|
|
889
|
+
}))].join(", ");
|
|
890
|
+
throw new Error(`下载 cloudflared 失败(已尝试 ${candidates.length} 个源:${triedHosts})` + (errors.length === 0 ? "" : `;原因:${errors.join(";")}`));
|
|
321
891
|
}
|
|
322
892
|
/**
|
|
323
893
|
* 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
|
|
324
894
|
* 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
|
|
895
|
+
*
|
|
896
|
+
* 失败时抛出的错误**已脱敏**:`execFile` 的原始错误含两个绝对路径
|
|
897
|
+
* (deb 与解包目录),直接外传会泄露本机目录结构。
|
|
325
898
|
*/
|
|
326
899
|
async function writeFromDeb(dataDir, debBuffer, url) {
|
|
327
900
|
await mkdir(dataDir, { recursive: true });
|
|
@@ -335,11 +908,18 @@ async function writeFromDeb(dataDir, debBuffer, url) {
|
|
|
335
908
|
"-x",
|
|
336
909
|
debPath,
|
|
337
910
|
extractDir
|
|
338
|
-
], (err) =>
|
|
911
|
+
], (err) => {
|
|
912
|
+
if (err === null) {
|
|
913
|
+
resolve();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
reject(new Error(sanitizeDownloadError(`dpkg-deb 解包失败:${err.message}`)));
|
|
917
|
+
});
|
|
339
918
|
});
|
|
340
919
|
const inner = path.join(extractDir, "usr", "bin", "cloudflared");
|
|
341
920
|
const out = path.join(dataDir, "cloudflared");
|
|
342
921
|
const bin = await readFile(inner);
|
|
922
|
+
if (bin.byteLength === 0) throw new Error("解包后的 cloudflared 为空文件");
|
|
343
923
|
await writeFile(out, bin);
|
|
344
924
|
await chmod(out, 493);
|
|
345
925
|
return out;
|
|
@@ -357,10 +937,12 @@ var CloudflaredTunnelManager = class {
|
|
|
357
937
|
url = null;
|
|
358
938
|
channel = null;
|
|
359
939
|
errorText = void 0;
|
|
940
|
+
errorKey = void 0;
|
|
941
|
+
errorParams = void 0;
|
|
360
942
|
listeners = [];
|
|
361
943
|
activity = {
|
|
362
944
|
stage: "idle",
|
|
363
|
-
|
|
945
|
+
labelKey: "",
|
|
364
946
|
percent: null
|
|
365
947
|
};
|
|
366
948
|
/** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
|
|
@@ -376,31 +958,54 @@ var CloudflaredTunnelManager = class {
|
|
|
376
958
|
clearActivity() {
|
|
377
959
|
this.activity = {
|
|
378
960
|
stage: "idle",
|
|
379
|
-
|
|
961
|
+
labelKey: "",
|
|
380
962
|
percent: null
|
|
381
963
|
};
|
|
382
964
|
this.emit();
|
|
383
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
|
|
968
|
+
*
|
|
969
|
+
* 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
|
|
970
|
+
* 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
|
|
971
|
+
* 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
|
|
972
|
+
*
|
|
973
|
+
* `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
|
|
974
|
+
* 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
|
|
975
|
+
*/
|
|
976
|
+
get tunnelActive() {
|
|
977
|
+
return this.process !== null && !this.process.killed;
|
|
978
|
+
}
|
|
384
979
|
get publicState() {
|
|
385
980
|
return {
|
|
386
981
|
channel: this.channel,
|
|
387
982
|
url: this.url,
|
|
388
|
-
running: this.
|
|
389
|
-
...this.errorText === void 0 ? {} : { error: this.errorText }
|
|
983
|
+
running: this.tunnelActive,
|
|
984
|
+
...this.errorText === void 0 ? {} : { error: this.errorText },
|
|
985
|
+
...this.errorKey === void 0 ? {} : { errorKey: this.errorKey },
|
|
986
|
+
...this.errorParams === void 0 ? {} : { errorParams: this.errorParams }
|
|
390
987
|
};
|
|
391
988
|
}
|
|
392
989
|
/** 订阅状态变化;返回取消函数。 */
|
|
393
990
|
onStatus(cb) {
|
|
394
991
|
this.listeners.push(cb);
|
|
395
|
-
return () =>
|
|
396
|
-
|
|
397
|
-
|
|
992
|
+
return () => this.offStatus(cb);
|
|
993
|
+
}
|
|
994
|
+
/** 取消一个状态订阅(onStatus 返回的函数与 waitForUrl 共用)。 */
|
|
995
|
+
offStatus(cb) {
|
|
996
|
+
this.listeners = this.listeners.filter((fn) => fn !== cb);
|
|
398
997
|
}
|
|
399
998
|
emit() {
|
|
400
999
|
for (const fn of this.listeners) fn();
|
|
401
1000
|
}
|
|
402
|
-
|
|
1001
|
+
/**
|
|
1002
|
+
* 记录一次错误。`error` 保留脱敏后的短消息(兜底/日志),
|
|
1003
|
+
* `errorKey` + `errorParams` 供客户端本地化(优先级 errorKey > error)。
|
|
1004
|
+
*/
|
|
1005
|
+
setError(errorKey, message, errorParams) {
|
|
403
1006
|
this.errorText = message;
|
|
1007
|
+
this.errorKey = errorKey;
|
|
1008
|
+
this.errorParams = errorParams;
|
|
404
1009
|
this.emit();
|
|
405
1010
|
}
|
|
406
1011
|
/**
|
|
@@ -409,11 +1014,13 @@ var CloudflaredTunnelManager = class {
|
|
|
409
1014
|
* @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
|
|
410
1015
|
*/
|
|
411
1016
|
async startQuick(exe, port) {
|
|
412
|
-
if (this.process !== null && !this.process.killed) throw new Error("
|
|
1017
|
+
if (this.process !== null && !this.process.killed) throw new Error("Tunnel is already running; stop it first.");
|
|
413
1018
|
this.errorText = void 0;
|
|
1019
|
+
this.errorKey = void 0;
|
|
1020
|
+
this.errorParams = void 0;
|
|
414
1021
|
this.reportActivity({
|
|
415
1022
|
stage: "starting",
|
|
416
|
-
|
|
1023
|
+
labelKey: "tunnelStageStarting",
|
|
417
1024
|
percent: null
|
|
418
1025
|
});
|
|
419
1026
|
const child = spawn(exe, [
|
|
@@ -445,13 +1052,52 @@ var CloudflaredTunnelManager = class {
|
|
|
445
1052
|
this.channel = null;
|
|
446
1053
|
this.emit();
|
|
447
1054
|
if (wasThis) {
|
|
448
|
-
if (this.url === null) this.setError(`cloudflared
|
|
1055
|
+
if (this.url === null) this.setError("tunnelErrorExited", `cloudflared exited (code=${code ?? "?"}, signal=${signal ?? "?"}) without a tunnel URL.`, {
|
|
1056
|
+
code: String(code ?? "?"),
|
|
1057
|
+
signal: String(signal ?? "?")
|
|
1058
|
+
});
|
|
449
1059
|
this.url = null;
|
|
450
1060
|
if (this.channel === null) this.clearActivity();
|
|
451
1061
|
}
|
|
452
1062
|
});
|
|
453
1063
|
return this.publicState;
|
|
454
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* 等待子进程把随机域名打印到 stdout(spawn 后 `url` 通常还要几秒才有)。
|
|
1067
|
+
*
|
|
1068
|
+
* 存在的理由(2.5.2):`POST /manage/cloudflare/create` 发生在隧道**起来之前**,
|
|
1069
|
+
* 此刻管理面还没被判据 0 关门;一旦在这次请求里等出地址并顺带出令牌配置文本,
|
|
1070
|
+
* 客户端就不必在隧道运行后再打一次 `/manage/config/text`(那时必然 403,
|
|
1071
|
+
* 这正是「一键开隧道+出令牌」失效的根因)。
|
|
1072
|
+
*
|
|
1073
|
+
* 任一条件满足即结算:地址出现 → resolve(url);进程退出/超时 → resolve(null)
|
|
1074
|
+
* (不抛,调用方据 null 决定要不要让客户端走轮询补一步)。调用方传 AbortSignal 可提前走人。
|
|
1075
|
+
*/
|
|
1076
|
+
async waitForUrl(timeoutMs = 12e3, signal) {
|
|
1077
|
+
if (this.url !== null) return this.url;
|
|
1078
|
+
if (this.process === null || this.process.killed) return null;
|
|
1079
|
+
return await new Promise((resolve) => {
|
|
1080
|
+
let settled = false;
|
|
1081
|
+
const finish = (value) => {
|
|
1082
|
+
if (settled) return;
|
|
1083
|
+
settled = true;
|
|
1084
|
+
clearTimeout(timer);
|
|
1085
|
+
this.offStatus(observer);
|
|
1086
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1087
|
+
resolve(value);
|
|
1088
|
+
};
|
|
1089
|
+
const observer = () => {
|
|
1090
|
+
if (this.url !== null) finish(this.url);
|
|
1091
|
+
else if (this.process === null || this.process.killed) finish(null);
|
|
1092
|
+
};
|
|
1093
|
+
const onAbort = () => finish(null);
|
|
1094
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
1095
|
+
timer.unref?.();
|
|
1096
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1097
|
+
this.onStatus(observer);
|
|
1098
|
+
observer();
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
455
1101
|
/** 停止当前隧道。 */
|
|
456
1102
|
stop() {
|
|
457
1103
|
const child = this.process;
|
|
@@ -471,236 +1117,139 @@ var CloudflaredTunnelManager = class {
|
|
|
471
1117
|
}
|
|
472
1118
|
};
|
|
473
1119
|
//#endregion
|
|
474
|
-
//#region src/host/
|
|
1120
|
+
//#region src/host/metrics.ts
|
|
475
1121
|
/**
|
|
476
|
-
*
|
|
1122
|
+
* 进程内**薄指标收集器**。
|
|
477
1123
|
*
|
|
478
|
-
*
|
|
479
|
-
* 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
|
|
480
|
-
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
481
|
-
*/
|
|
482
|
-
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
483
|
-
/** settings 表里的键名。 */
|
|
484
|
-
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
485
|
-
/**
|
|
486
|
-
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
487
|
-
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
488
|
-
*/
|
|
489
|
-
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
490
|
-
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
491
|
-
const MAX_SUBDIR_CHARS = 64;
|
|
492
|
-
/**
|
|
493
|
-
* 把用户填的目录名收成一个安全值。
|
|
1124
|
+
* ## 为什么需要它
|
|
494
1125
|
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
1126
|
+
* 本插件此前只有 9 处启动期日志(`index.ts`),运行期**一条都没有**,也没有任何耗时统计。
|
|
1127
|
+
* 于是「广播是不是串行卡住了」「内核 RPC 是不是超时了」「事件是不是被丢了」这三个问题
|
|
1128
|
+
* 在运行数据里**完全不可见** —— 只能靠读代码推断。这份模块就是让它们变得可观测的最小代价版本。
|
|
1129
|
+
*
|
|
1130
|
+
* ## 为什么是「进程内、无依赖、固定样本环」
|
|
1131
|
+
*
|
|
1132
|
+
* - **不引入 prometheus / otel**:这插件跑在用户的 DSH 进程里,加一个重依赖会拖慢启动、
|
|
1133
|
+
* 也会把「插件」变成「服务」;这里要的只是「出问题时能看一眼」。
|
|
1134
|
+
* - **固定样本环(256)**:P50/P95 需要排序,样本无界增长就是内存泄漏。256 个数字足以
|
|
1135
|
+
* 看出「大多数很快、尾部很慢」,而且分配是一次性的。
|
|
1136
|
+
* - **全部计数都是整数累加**:热路径(每个事件、每个 RPC)调用它只做 `+= 1` 与几次比较,
|
|
1137
|
+
* 不会自己变成瓶颈。
|
|
1138
|
+
*
|
|
1139
|
+
* ## 如何被读到
|
|
1140
|
+
*
|
|
1141
|
+
* `getMetricsSnapshot()` 是**纯读**。挂载点在 `GET /api/v1/debug/host-services` 的响应里
|
|
1142
|
+
* (由 router 负责,本模块不碰 HTTP)—— 那条端点本就是「只读诊断」,且已有同一套鉴权。
|
|
1143
|
+
*
|
|
1144
|
+
* 注意:这些数字**不参与任何业务判断**。它们只用于观测,坏了最坏是「数字不对」,
|
|
1145
|
+
* 不会让功能变化 —— 这是刻意划的边界。
|
|
498
1146
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1147
|
+
/** 保留多少个 RPC 耗时样本用于算分位数。 */
|
|
1148
|
+
const SAMPLE_CAPACITY = 256;
|
|
1149
|
+
const rpcBuckets = /* @__PURE__ */ new Map();
|
|
1150
|
+
/** WebSocket 与广播面的计数。 */
|
|
1151
|
+
const counters = {
|
|
1152
|
+
wsConnections: 0,
|
|
1153
|
+
wsRejected: 0,
|
|
1154
|
+
wsTerminated: 0,
|
|
1155
|
+
broadcastEvents: 0,
|
|
1156
|
+
/** 所有连接队列里最长的那条有多长(背压信号)。 */
|
|
1157
|
+
broadcastQueueDepth: 0,
|
|
1158
|
+
/** 因队列满/背压被丢掉的事件数(任何连接)。 */
|
|
1159
|
+
droppedEvents: 0,
|
|
1160
|
+
/** FrameHub 因队列满丢掉的事件数(由 dsh-0.2-bridge 上报)。 */
|
|
1161
|
+
frameHubDropped: 0
|
|
1162
|
+
};
|
|
507
1163
|
/**
|
|
508
|
-
*
|
|
1164
|
+
* 记录一次 RPC 耗时。
|
|
509
1165
|
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
1166
|
+
* @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把参数拼进去,
|
|
1167
|
+
* 否则基数会炸(每个 sessionId 一个桶)。
|
|
1168
|
+
* @param ms - 毫秒耗时。
|
|
1169
|
+
* @param ok - 是否成功;失败单独计数,因为「慢」与「错」是两回事。
|
|
512
1170
|
*/
|
|
513
|
-
function
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1171
|
+
function recordRpc(name, ms, ok = true) {
|
|
1172
|
+
let bucket = rpcBuckets.get(name);
|
|
1173
|
+
if (bucket === void 0) {
|
|
1174
|
+
bucket = {
|
|
1175
|
+
count: 0,
|
|
1176
|
+
failures: 0,
|
|
1177
|
+
totalMs: 0,
|
|
1178
|
+
maxMs: 0,
|
|
1179
|
+
samples: [],
|
|
1180
|
+
index: 0,
|
|
1181
|
+
filled: 0
|
|
1182
|
+
};
|
|
1183
|
+
rpcBuckets.set(name, bucket);
|
|
521
1184
|
}
|
|
1185
|
+
bucket.count += 1;
|
|
1186
|
+
if (!ok) bucket.failures += 1;
|
|
1187
|
+
if (!Number.isFinite(ms) || ms < 0) return;
|
|
1188
|
+
bucket.totalMs += ms;
|
|
1189
|
+
if (ms > bucket.maxMs) bucket.maxMs = ms;
|
|
1190
|
+
bucket.samples[bucket.index] = ms;
|
|
1191
|
+
bucket.index = (bucket.index + 1) % SAMPLE_CAPACITY;
|
|
1192
|
+
if (bucket.filled < SAMPLE_CAPACITY) bucket.filled += 1;
|
|
522
1193
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const normalized = normalizeHandoffSubdir(value);
|
|
526
|
-
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
527
|
-
return normalized;
|
|
1194
|
+
function setWsConnections(value) {
|
|
1195
|
+
counters.wsConnections = value < 0 ? 0 : value;
|
|
528
1196
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
return {
|
|
532
|
-
subdir: readHandoffSubdir(database),
|
|
533
|
-
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
534
|
-
fallbackDir
|
|
535
|
-
};
|
|
1197
|
+
function recordWsRejected() {
|
|
1198
|
+
counters.wsRejected += 1;
|
|
536
1199
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
540
|
-
const HANDOFF_MEDIA_TYPES = {
|
|
541
|
-
png: "image/png",
|
|
542
|
-
jpg: "image/jpeg",
|
|
543
|
-
jpeg: "image/jpeg",
|
|
544
|
-
webp: "image/webp",
|
|
545
|
-
gif: "image/gif",
|
|
546
|
-
heic: "image/heic",
|
|
547
|
-
heif: "image/heif",
|
|
548
|
-
bmp: "image/bmp",
|
|
549
|
-
avif: "image/avif"
|
|
550
|
-
};
|
|
551
|
-
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
552
|
-
const MAX_BYTES = 33554432;
|
|
553
|
-
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
554
|
-
const MAX_NAME_CHARS = 4096;
|
|
555
|
-
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
556
|
-
function declaredMediaType(name) {
|
|
557
|
-
const cut = name.lastIndexOf(".");
|
|
558
|
-
if (cut === -1) return void 0;
|
|
559
|
-
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
1200
|
+
function recordWsTerminated() {
|
|
1201
|
+
counters.wsTerminated += 1;
|
|
560
1202
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
* @param head - 文件前至少 12 个字节。
|
|
564
|
-
*/
|
|
565
|
-
function sniffMediaType(head) {
|
|
566
|
-
const ascii = (offset, text) => {
|
|
567
|
-
if (head.length < offset + text.length) return false;
|
|
568
|
-
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
569
|
-
return true;
|
|
570
|
-
};
|
|
571
|
-
const bytes = (...expected) => {
|
|
572
|
-
if (head.length < expected.length) return false;
|
|
573
|
-
return expected.every((byte, index) => head[index] === byte);
|
|
574
|
-
};
|
|
575
|
-
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
576
|
-
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
577
|
-
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
578
|
-
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
579
|
-
if (bytes(66, 77)) return "image/bmp";
|
|
580
|
-
if (ascii(4, "ftyp")) {
|
|
581
|
-
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
582
|
-
if (brand.startsWith("avi")) return "image/avif";
|
|
583
|
-
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
584
|
-
}
|
|
1203
|
+
function recordBroadcastEvent() {
|
|
1204
|
+
counters.broadcastEvents += 1;
|
|
585
1205
|
}
|
|
586
|
-
/**
|
|
587
|
-
function
|
|
588
|
-
|
|
589
|
-
if (typeof host !== "string" || host === "") return false;
|
|
590
|
-
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
591
|
-
const name = host.split(":")[0];
|
|
592
|
-
return name === "127.0.0.1" || name === "localhost";
|
|
1206
|
+
/** 更新「最长连接队列」;传入当前所有连接队列长度的最大值。 */
|
|
1207
|
+
function setBroadcastQueueDepth(value) {
|
|
1208
|
+
counters.broadcastQueueDepth = value < 0 ? 0 : value;
|
|
593
1209
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
*
|
|
597
|
-
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
598
|
-
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
599
|
-
*/
|
|
600
|
-
function pluginPageReferer(req) {
|
|
601
|
-
const referer = req.headers.referer;
|
|
602
|
-
if (typeof referer !== "string" || referer === "") return false;
|
|
603
|
-
try {
|
|
604
|
-
const url = new URL(referer);
|
|
605
|
-
if (url.host !== req.headers.host) return false;
|
|
606
|
-
return url.pathname.startsWith("/plugin/");
|
|
607
|
-
} catch {
|
|
608
|
-
return false;
|
|
609
|
-
}
|
|
1210
|
+
function recordDroppedEvent(count = 1) {
|
|
1211
|
+
counters.droppedEvents += count;
|
|
610
1212
|
}
|
|
611
|
-
/**
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
615
|
-
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
616
|
-
*/
|
|
617
|
-
function sameOrigin(req) {
|
|
618
|
-
if (!loopbackHost(req)) return false;
|
|
619
|
-
if (pluginPageReferer(req)) return true;
|
|
620
|
-
const site = req.headers["sec-fetch-site"];
|
|
621
|
-
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
622
|
-
const origin = req.headers.origin;
|
|
623
|
-
if (typeof origin !== "string" || origin === "") return true;
|
|
624
|
-
try {
|
|
625
|
-
return new URL(origin).host === req.headers.host;
|
|
626
|
-
} catch {
|
|
627
|
-
return false;
|
|
628
|
-
}
|
|
1213
|
+
/** 由 `FrameHub` 上报:它自己丢了几个事件(与 socket 队列无关的另一处丢弃点)。 */
|
|
1214
|
+
function recordFrameHubDropped(count = 1) {
|
|
1215
|
+
counters.frameHubDropped += count;
|
|
629
1216
|
}
|
|
630
|
-
/**
|
|
631
|
-
function
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
"content-length": String(Buffer.byteLength(body)),
|
|
636
|
-
"cache-control": "no-store",
|
|
637
|
-
"x-content-type-options": "nosniff"
|
|
638
|
-
});
|
|
639
|
-
res.end(body);
|
|
1217
|
+
/** 从环形样本里问分位数。空样本返回 0(而不是 NaN —— NaN 会让 JSON 变 `null`)。 */
|
|
1218
|
+
function quantile(bucket, f) {
|
|
1219
|
+
if (bucket.filled === 0) return 0;
|
|
1220
|
+
const live = bucket.samples.slice(0, bucket.filled).sort((left, right) => left - right);
|
|
1221
|
+
return live[Math.min(live.length - 1, Math.max(0, Math.floor((live.length - 1) * f)))] ?? 0;
|
|
640
1222
|
}
|
|
641
1223
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
1224
|
+
* 读一份快照。**纯读**:不重置任何计数(重置会让「谁先读谁清空」变成新的坑)。
|
|
1225
|
+
*
|
|
1226
|
+
* 返回的是**新对象**,调用方改它不会污染内部状态。
|
|
645
1227
|
*/
|
|
646
|
-
function
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
image = await options.read(sessionId, name);
|
|
672
|
-
} catch {
|
|
673
|
-
refuse(res, 404, "no such handoff image");
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
if (image.bytes.byteLength === 0) {
|
|
677
|
-
refuse(res, 404, "empty file");
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
if (image.bytes.byteLength > MAX_BYTES) {
|
|
681
|
-
refuse(res, 413, "image is too large to display");
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
685
|
-
if (sniffed === void 0) {
|
|
686
|
-
refuse(res, 415, "content is not a supported image");
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
if (declared !== void 0 && sniffed !== declared) {
|
|
690
|
-
refuse(res, 415, "content does not match the file extension");
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
res.writeHead(200, {
|
|
694
|
-
"content-type": sniffed,
|
|
695
|
-
"content-length": String(image.bytes.byteLength),
|
|
696
|
-
"cache-control": "private, max-age=300",
|
|
697
|
-
"x-content-type-options": "nosniff"
|
|
698
|
-
});
|
|
699
|
-
if (req.method === "HEAD") {
|
|
700
|
-
res.end();
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
|
|
1228
|
+
function getMetricsSnapshot() {
|
|
1229
|
+
const rpc = [];
|
|
1230
|
+
for (const [name, bucket] of rpcBuckets) rpc.push({
|
|
1231
|
+
name,
|
|
1232
|
+
count: bucket.count,
|
|
1233
|
+
failures: bucket.failures,
|
|
1234
|
+
averageMs: bucket.count === 0 ? 0 : Math.round(bucket.totalMs / Math.max(1, bucket.count) * 100) / 100,
|
|
1235
|
+
p50Ms: quantile(bucket, .5),
|
|
1236
|
+
p95Ms: quantile(bucket, .95),
|
|
1237
|
+
maxMs: bucket.maxMs
|
|
1238
|
+
});
|
|
1239
|
+
rpc.sort((left, right) => right.p95Ms - left.p95Ms);
|
|
1240
|
+
return {
|
|
1241
|
+
ws: {
|
|
1242
|
+
connections: counters.wsConnections,
|
|
1243
|
+
rejected: counters.wsRejected,
|
|
1244
|
+
terminated: counters.wsTerminated
|
|
1245
|
+
},
|
|
1246
|
+
broadcast: {
|
|
1247
|
+
events: counters.broadcastEvents,
|
|
1248
|
+
queueDepth: counters.broadcastQueueDepth,
|
|
1249
|
+
droppedEvents: counters.droppedEvents
|
|
1250
|
+
},
|
|
1251
|
+
frameHub: { dropped: counters.frameHubDropped },
|
|
1252
|
+
rpc
|
|
704
1253
|
};
|
|
705
1254
|
}
|
|
706
1255
|
//#endregion
|
|
@@ -945,16 +1494,108 @@ function toRpcError(error) {
|
|
|
945
1494
|
};
|
|
946
1495
|
}
|
|
947
1496
|
/** Run one host call and wrap its outcome or failure in the old envelope. */
|
|
948
|
-
|
|
1497
|
+
/**
|
|
1498
|
+
* 一次内核 RPC 的**统一入口**:耗时统计 + 慢调用/失败日志。
|
|
1499
|
+
*
|
|
1500
|
+
* ## 为什么所有 RPC 都经过这一个函数
|
|
1501
|
+
*
|
|
1502
|
+
* 适配层有二十来条 RPC(sessions.list / history / prompt / models …),逐条埋点
|
|
1503
|
+
* 必然漂(有人加新方法时会忘)。这里是**唯一单点**:加一行就全覆盖。
|
|
1504
|
+
*
|
|
1505
|
+
* ## 为什么必须有一层超时
|
|
1506
|
+
*
|
|
1507
|
+
* 修复前 `call(run)` 不做任何超时,而 `run` 里的每次内核调用都用
|
|
1508
|
+
* `new AbortController().signal`(**永不 abort**)。于是内核侧一旦卡住:
|
|
1509
|
+
*
|
|
1510
|
+
* - 广播路径上那条 `sessions.list` 会把 `broadcastQueue` 永久堵住
|
|
1511
|
+
* (整站事件停摆,`stop()` 也跟着挂);
|
|
1512
|
+
* - 请求路径上那条会让 HTTP 连接一直挂着,客户端先超时、服务端资源不释放。
|
|
1513
|
+
*
|
|
1514
|
+
* 现在统一 10 秒上限。**注意语义**:超时被当作一次 RPC 失败(`code: 'rpc/timeout'`),
|
|
1515
|
+
* 由调用方决定怎么处理 —— 广播路径拿到 false 就「少推一条」,请求路径拿到 504。
|
|
1516
|
+
* 这比「无限等待」在任何情况下都更接近正确。
|
|
1517
|
+
*
|
|
1518
|
+
* @param name - 稳定的 RPC 名(如 `sessions.history`)。**不要**把 sessionId 拼进来,
|
|
1519
|
+
* 否则指标的基数会按会话数增长。
|
|
1520
|
+
* @param run - 真正的调用。它收到的 `signal` 已经带超时,应当传给宿主调用。
|
|
1521
|
+
* @param options.timeoutMs - 覆盖默认上限(流式/长任务可放宽,但**不能**设成无限)。
|
|
1522
|
+
*/
|
|
1523
|
+
async function call(name, run, options = {}) {
|
|
1524
|
+
const startedAt = Date.now();
|
|
1525
|
+
const timeoutMs = options.timeoutMs ?? RPC_TIMEOUT_MS;
|
|
1526
|
+
const controller = new AbortController();
|
|
1527
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`RPC ${name} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
1528
|
+
timer.unref?.();
|
|
949
1529
|
try {
|
|
950
|
-
|
|
1530
|
+
const value = await run(controller.signal);
|
|
1531
|
+
const elapsed = Date.now() - startedAt;
|
|
1532
|
+
recordRpc(name, elapsed, true);
|
|
1533
|
+
logSlowRpc(name, elapsed, true);
|
|
1534
|
+
return ok(value);
|
|
951
1535
|
} catch (error) {
|
|
1536
|
+
const elapsed = Date.now() - startedAt;
|
|
1537
|
+
recordRpc(name, elapsed, false);
|
|
1538
|
+
logSlowRpc(name, elapsed, false);
|
|
1539
|
+
if (controller.signal.aborted && isAbortLike(error)) return { result: {
|
|
1540
|
+
ok: false,
|
|
1541
|
+
error: {
|
|
1542
|
+
code: "rpc/timeout",
|
|
1543
|
+
message: `The host did not answer ${name} within ${timeoutMs}ms.`
|
|
1544
|
+
}
|
|
1545
|
+
} };
|
|
952
1546
|
return { result: {
|
|
953
1547
|
ok: false,
|
|
954
1548
|
error: toRpcError(error)
|
|
955
1549
|
} };
|
|
1550
|
+
} finally {
|
|
1551
|
+
clearTimeout(timer);
|
|
956
1552
|
}
|
|
957
1553
|
}
|
|
1554
|
+
/** 默认 RPC 上限。10 秒:超过它基本可以断定内核卡了,而不是「慢」。 */
|
|
1555
|
+
const RPC_TIMEOUT_MS = 1e4;
|
|
1556
|
+
/**
|
|
1557
|
+
* 「查一下这个会话的工作目录 / 投影」这类**短查询**的上限。
|
|
1558
|
+
*
|
|
1559
|
+
* 比 [RPC_TIMEOUT_MS] 更紧,因为它们在 prompt 的同步前置里:用户按下发送之后
|
|
1560
|
+
* 要等它们回来(图才落盘、模型判定才完成)。让一个辅助查询拖 10 秒,
|
|
1561
|
+
* 用户已经认定「发不出去」了。
|
|
1562
|
+
*/
|
|
1563
|
+
const SESSION_DIR_LOOKUP_TIMEOUT_MS = 3e3;
|
|
1564
|
+
/**
|
|
1565
|
+
* 造一个**会自己到期**的 signal(并让它的定时器随 abort 一起清掉)。
|
|
1566
|
+
*
|
|
1567
|
+
* 为什么不在调用点写 `AbortSignal.timeout(ms)`:两者的过期行为一致,但这里需要
|
|
1568
|
+
* 「定时器不拖住进程退出」—— `AbortSignal.timeout` 内部没有 `unref`,
|
|
1569
|
+
* 在只剩它一个活跃句柄时会让进程多等三秒。这在本仓是已经踩过的纪律
|
|
1570
|
+
* (见 `server.ts` 的 `unref` 注释)。
|
|
1571
|
+
*/
|
|
1572
|
+
function timeoutSignal(ms) {
|
|
1573
|
+
const controller = new AbortController();
|
|
1574
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
|
|
1575
|
+
timer.unref?.();
|
|
1576
|
+
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
1577
|
+
return controller.signal;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* 慢调用 / 失败时打一条日志。
|
|
1581
|
+
*
|
|
1582
|
+
* 阈值 5 秒是刻意的:**正常**的内核调用都在毫秒级,5 秒说明真的出问题了;
|
|
1583
|
+
* 而每次都打日志会让热路径的日志量变成噪声(那反而更难看出问题)。
|
|
1584
|
+
*/
|
|
1585
|
+
function logSlowRpc(name, elapsedMs, okFlag) {
|
|
1586
|
+
if (okFlag && elapsedMs < SLOW_RPC_LOG_MS) return;
|
|
1587
|
+
const message = `dsh-0.2-bridge: rpc ${name} ${okFlag ? "slow" : "failed"}: ${elapsedMs}ms`;
|
|
1588
|
+
console.warn(message);
|
|
1589
|
+
}
|
|
1590
|
+
const SLOW_RPC_LOG_MS = 5e3;
|
|
1591
|
+
/** 判断一个异常是不是「因为我们 abort 才抛」的(不同实现的 message 不一样)。 */
|
|
1592
|
+
function isAbortLike(error) {
|
|
1593
|
+
if (error === null || typeof error !== "object") return false;
|
|
1594
|
+
const candidate = error;
|
|
1595
|
+
if (candidate.name === "AbortError") return true;
|
|
1596
|
+
if (candidate.code === "ABORT_ERR") return true;
|
|
1597
|
+
return typeof candidate.message === "string" && /abort/i.test(candidate.message);
|
|
1598
|
+
}
|
|
958
1599
|
/**
|
|
959
1600
|
* Replay-free fan-out of host facts into the `{ rpcId, payload }` frames the
|
|
960
1601
|
* old `apiProxy` streams produced. Subscribers only observe frames emitted
|
|
@@ -975,8 +1616,23 @@ var FrameHub = class {
|
|
|
975
1616
|
const queue = [];
|
|
976
1617
|
let wake;
|
|
977
1618
|
let closed = false;
|
|
1619
|
+
/** 因队列满被丢掉的帧数(丢过就要告知消费方「事件流不连续」)。 */
|
|
1620
|
+
let dropped = 0;
|
|
978
1621
|
const onFrame = (frame) => {
|
|
979
1622
|
if (closed) return;
|
|
1623
|
+
if (queue.length >= FRAME_HUB_QUEUE_LIMIT) {
|
|
1624
|
+
if (isRebuildableFrame(frame)) {
|
|
1625
|
+
dropped += 1;
|
|
1626
|
+
recordFrameHubDropped();
|
|
1627
|
+
wake?.();
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const victim = queue.findIndex(isRebuildableFrame);
|
|
1631
|
+
if (victim >= 0) queue.splice(victim, 1);
|
|
1632
|
+
else queue.shift();
|
|
1633
|
+
dropped += 1;
|
|
1634
|
+
recordFrameHubDropped();
|
|
1635
|
+
}
|
|
980
1636
|
queue.push(frame);
|
|
981
1637
|
wake?.();
|
|
982
1638
|
};
|
|
@@ -991,6 +1647,18 @@ var FrameHub = class {
|
|
|
991
1647
|
return { async *[Symbol.asyncIterator]() {
|
|
992
1648
|
try {
|
|
993
1649
|
while (!closed && !signal.aborted) {
|
|
1650
|
+
if (dropped > 0) {
|
|
1651
|
+
const count = dropped;
|
|
1652
|
+
dropped = 0;
|
|
1653
|
+
yield {
|
|
1654
|
+
rpcId: randomUUID(),
|
|
1655
|
+
payload: {
|
|
1656
|
+
type: "bridge/resync-required",
|
|
1657
|
+
dropped: count,
|
|
1658
|
+
reason: "backpressure"
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
994
1662
|
if (queue.length === 0) {
|
|
995
1663
|
await new Promise((resolve) => {
|
|
996
1664
|
wake = resolve;
|
|
@@ -1007,6 +1675,24 @@ var FrameHub = class {
|
|
|
1007
1675
|
}
|
|
1008
1676
|
};
|
|
1009
1677
|
/**
|
|
1678
|
+
* `FrameHub` 每个消费者的队列上限。
|
|
1679
|
+
*
|
|
1680
|
+
* 与 `server.ts` 的 `DEFAULT_SOCKET_QUEUE_LIMIT` 同量级但**不必相等**:
|
|
1681
|
+
* 这一层是「内核帧 → 插件帧」的缓冲,消费方是本插件的 `startEvents` 循环;
|
|
1682
|
+
* 那一层是「插件事件 → WebSocket 帧」,消费方是网络。两层各有各的慢法。
|
|
1683
|
+
*/
|
|
1684
|
+
const FRAME_HUB_QUEUE_LIMIT = 1e3;
|
|
1685
|
+
/**
|
|
1686
|
+
* 这一帧丢了能不能重建。
|
|
1687
|
+
*
|
|
1688
|
+
* 只有流式文本增量可以(客户端重拉历史即得)。判据看**归一化后的 payload.type**:
|
|
1689
|
+
* `assistant/chunk` 会经 `normalizeDshEvent` 变成 `chat.message.delta`,
|
|
1690
|
+
* 而 `FrameHub` 处在归一化**之前**,所以两边都要认。
|
|
1691
|
+
*/
|
|
1692
|
+
function isRebuildableFrame(frame) {
|
|
1693
|
+
return frame.payload["type"] === "assistant/chunk";
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1010
1696
|
* Relays live session frames into the old `session/event` shape.
|
|
1011
1697
|
*
|
|
1012
1698
|
* 0.2.x has no process-wide session event stream: both durable events and the
|
|
@@ -1020,16 +1706,48 @@ var SessionEventRelay = class {
|
|
|
1020
1706
|
host;
|
|
1021
1707
|
mux;
|
|
1022
1708
|
limit;
|
|
1709
|
+
warn;
|
|
1710
|
+
/**
|
|
1711
|
+
* 活跃订阅。**Map 的插入顺序就是 LRU 顺序**:命中/新建都先 delete 再 set,
|
|
1712
|
+
* 于是最旧(最久没动过)的会话永远在队首 —— 淘汰时取第一个即可,不用另建链表。
|
|
1713
|
+
*/
|
|
1023
1714
|
active = /* @__PURE__ */ new Map();
|
|
1024
|
-
constructor(host, mux, limit = 8) {
|
|
1715
|
+
constructor(host, mux, limit = 8, warn = () => void 0) {
|
|
1025
1716
|
this.host = host;
|
|
1026
1717
|
this.mux = mux;
|
|
1027
1718
|
this.limit = limit;
|
|
1719
|
+
this.warn = warn;
|
|
1028
1720
|
}
|
|
1029
|
-
/**
|
|
1721
|
+
/**
|
|
1722
|
+
* Start following one session; idempotent per session.
|
|
1723
|
+
*
|
|
1724
|
+
* ## 到上限时的行为(修复点)
|
|
1725
|
+
*
|
|
1726
|
+
* 此前是 `if (this.active.size >= this.limit) return` —— **静默**不订阅。
|
|
1727
|
+
* 后果:第 9 个「正在跑」的会话**没有流式事件**,而两侧日志一句话都没有;
|
|
1728
|
+
* 用户看到的现象是「这个会话不实时了」,与网络问题完全无法区分。
|
|
1729
|
+
*
|
|
1730
|
+
* 现在改为 **LRU 淘汰 + warn**:踢掉最久没被触碰的那条订阅,给新会话腾位置。
|
|
1731
|
+
* 这比「拒绝新会话」更接近用户预期 —— 用户切到的那个会话正是他此刻在看的那条,
|
|
1732
|
+
* 而被淘汰的那条通常已经不在屏幕上了(它的下一次 `attach` 会重新订阅,
|
|
1733
|
+
* 代价只是「那段时间的增量要重拉历史」,而不是永久看不到)。
|
|
1734
|
+
*
|
|
1735
|
+
* 淘汰必须**有日志**:不然「我的旧会话为什么突然不流式了」永远查不出来。
|
|
1736
|
+
*/
|
|
1030
1737
|
attach(sessionId) {
|
|
1031
|
-
|
|
1032
|
-
if (
|
|
1738
|
+
const existing = this.active.get(sessionId);
|
|
1739
|
+
if (existing !== void 0) {
|
|
1740
|
+
this.active.delete(sessionId);
|
|
1741
|
+
this.active.set(sessionId, existing);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (this.active.size >= this.limit) {
|
|
1745
|
+
const oldest = this.active.keys().next().value;
|
|
1746
|
+
if (oldest !== void 0) {
|
|
1747
|
+
this.warn(`dsh-0.2-bridge: session event relay is at its limit (${this.limit}); evicting the least recently used subscription (${oldest}) to follow ${sessionId}`);
|
|
1748
|
+
this.detach(oldest);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1033
1751
|
const controller = new AbortController();
|
|
1034
1752
|
this.active.set(sessionId, controller);
|
|
1035
1753
|
this.pump(sessionId, controller.signal).catch(() => {
|
|
@@ -1138,13 +1856,26 @@ var ApprovalBridge = class {
|
|
|
1138
1856
|
const settle = (value) => {
|
|
1139
1857
|
if (done) return;
|
|
1140
1858
|
done = true;
|
|
1859
|
+
clearTimeout(entry.timer);
|
|
1141
1860
|
resolve(value);
|
|
1142
1861
|
};
|
|
1143
|
-
|
|
1862
|
+
const entry = {
|
|
1863
|
+
settle,
|
|
1864
|
+
timer: setTimeout(() => void 0, 0),
|
|
1865
|
+
requestedAt: Date.now()
|
|
1866
|
+
};
|
|
1867
|
+
clearTimeout(entry.timer);
|
|
1868
|
+
entry.timer = setTimeout(() => {
|
|
1869
|
+
this.pending.delete(rpcId);
|
|
1870
|
+
settle("delegate");
|
|
1871
|
+
}, APPROVAL_PENDING_TIMEOUT_MS);
|
|
1872
|
+
entry.timer.unref?.();
|
|
1873
|
+
this.pending.set(rpcId, entry);
|
|
1144
1874
|
req.signal?.addEventListener("abort", () => {
|
|
1145
1875
|
this.pending.delete(rpcId);
|
|
1146
1876
|
if (done) return;
|
|
1147
1877
|
done = true;
|
|
1878
|
+
clearTimeout(entry.timer);
|
|
1148
1879
|
resolve("delegate");
|
|
1149
1880
|
}, { once: true });
|
|
1150
1881
|
this.mux.push({
|
|
@@ -1190,6 +1921,39 @@ var ApprovalBridge = class {
|
|
|
1190
1921
|
*/
|
|
1191
1922
|
const MAX_PENDING_QUESTIONS = 32;
|
|
1192
1923
|
/**
|
|
1924
|
+
* 同时跟踪多少个「正在跑」的会话的事件流。
|
|
1925
|
+
*
|
|
1926
|
+
* 每一条都是一个 `follow()` 订阅,占一个文件描述符与一段内存;无上限的话,
|
|
1927
|
+
* 一个「同时跑 50 个会话」的机器会把 fd 耗光。8 是既有取值,本轮不调 ——
|
|
1928
|
+
* 本轮只把「到顶就静默丢弃」改成「LRU 淘汰 + 日志」。
|
|
1929
|
+
*/
|
|
1930
|
+
const SESSION_RELAY_LIMIT = 8;
|
|
1931
|
+
/**
|
|
1932
|
+
* 一条审批**最多挂起多久**(10 分钟)。
|
|
1933
|
+
*
|
|
1934
|
+
* 为什么必须有这个上限:`approval/request` 是一个等待承诺 —— 客户端通过
|
|
1935
|
+
* `respond()` 结算,宿主通过 `req.signal` 的 abort 结算。**两条都不发生时,
|
|
1936
|
+
* 这个 Promise 永不结算**,内核那一轮就永久阻塞,`pending` 条目也永远留着。
|
|
1937
|
+
*
|
|
1938
|
+
* 而「客户端永远不回答」在真实场景里并不罕见:手机被放进兜里、App 被杀、
|
|
1939
|
+
* 通知被划掉。修复前遇到这种情况,用户看到的是「agent 卡住不动了」,
|
|
1940
|
+
* 除了重启 dsh 没有别的出路。
|
|
1941
|
+
*
|
|
1942
|
+
* 为什么超时是 `delegate` 而**不是** `allowed-once`:把「没人回答」解释成
|
|
1943
|
+
* 「默认批准」等于让安全判据失效于一个定时器。`delegate` 让内核回到下游
|
|
1944
|
+
* (官方 WebUI)继续等,同时推一条 `approval/resolved` 让陈旧卡片消失 ——
|
|
1945
|
+
* 功能上「回到没有这个插件时的行为」,这才是正确的降级方向。
|
|
1946
|
+
*/
|
|
1947
|
+
const APPROVAL_PENDING_TIMEOUT_MS = 6e5;
|
|
1948
|
+
/**
|
|
1949
|
+
* 一条前台提问**最多挂起多久**(10 分钟,与审批同口径)。
|
|
1950
|
+
*
|
|
1951
|
+
* 提问不阻塞内核(Agent 已经继续跑),但它一样会吊着一个 Promise 与一条挂起表条目。
|
|
1952
|
+
* 超时按 `dismissed` 结算:推 `userQuestions/dismissed` 让手机端那张卡片消失,
|
|
1953
|
+
* 同时把 `delegate` 交给调用方 —— 与「用户点了电脑端」同一条收尾路径。
|
|
1954
|
+
*/
|
|
1955
|
+
const QUESTION_PENDING_TIMEOUT_MS = 6e5;
|
|
1956
|
+
/**
|
|
1193
1957
|
* `user-questions/request` 的接管者 —— 形状照 [ApprovalBridge]。
|
|
1194
1958
|
*
|
|
1195
1959
|
* 为什么非接不可:`ask_user_question` 只有在**参数 schema 声明了 `timeout`** 时才走
|
|
@@ -1247,14 +2011,24 @@ var QuestionBridge = class {
|
|
|
1247
2011
|
const settle = (value) => {
|
|
1248
2012
|
if (done) return;
|
|
1249
2013
|
done = true;
|
|
2014
|
+
clearTimeout(entry.timer);
|
|
1250
2015
|
resolve(value);
|
|
1251
2016
|
};
|
|
1252
|
-
|
|
2017
|
+
const entry = {
|
|
1253
2018
|
sessionId,
|
|
1254
2019
|
callId,
|
|
1255
2020
|
questions,
|
|
1256
|
-
settle
|
|
1257
|
-
|
|
2021
|
+
settle,
|
|
2022
|
+
timer: setTimeout(() => void 0, 0),
|
|
2023
|
+
requestedAt: Date.now()
|
|
2024
|
+
};
|
|
2025
|
+
clearTimeout(entry.timer);
|
|
2026
|
+
entry.timer = setTimeout(() => {
|
|
2027
|
+
bridge.pending.delete(rpcId);
|
|
2028
|
+
settle({ kind: "dismissed" });
|
|
2029
|
+
}, QUESTION_PENDING_TIMEOUT_MS);
|
|
2030
|
+
entry.timer.unref?.();
|
|
2031
|
+
bridge.pending.set(rpcId, entry);
|
|
1258
2032
|
request?.signal?.addEventListener("abort", () => {
|
|
1259
2033
|
bridge.pending.delete(rpcId);
|
|
1260
2034
|
settle({ kind: "dismissed" });
|
|
@@ -1314,6 +2088,30 @@ var QuestionBridge = class {
|
|
|
1314
2088
|
};
|
|
1315
2089
|
}
|
|
1316
2090
|
/**
|
|
2091
|
+
* 清扫**已经超期**的挂起项。
|
|
2092
|
+
*
|
|
2093
|
+
* 为什么还需要这个「批量清扫」,既然每条都有自己的定时器:
|
|
2094
|
+
* 定时器是**正常**路径;清扫是**兜底**。两者都存在的理由与 `pending` 表的最小不变式
|
|
2095
|
+
* 有关 —— 表里的每一条都必须「要么会被用户结算、要么会被超时结算、要么会被这里扫掉」。
|
|
2096
|
+
* 少任何一条都会留下永久条目(那正是修复前的状态:只有用户结算这一条路)。
|
|
2097
|
+
*
|
|
2098
|
+
* 它按 `requestedAt` 判断而不是再挂一个计时器:调用方(适配层)可以在任意时机
|
|
2099
|
+
* 顺手调一次,代价是 O(表中条目数),而这个表被 `MAX_PENDING_QUESTIONS` 封在 32。
|
|
2100
|
+
*
|
|
2101
|
+
* @returns 被清掉多少条(供日志/测试断言)。
|
|
2102
|
+
*/
|
|
2103
|
+
sweepExpired(now = Date.now()) {
|
|
2104
|
+
let swept = 0;
|
|
2105
|
+
for (const [rpcId, entry] of this.pending) {
|
|
2106
|
+
if (now - entry.requestedAt < QUESTION_PENDING_TIMEOUT_MS) continue;
|
|
2107
|
+
this.pending.delete(rpcId);
|
|
2108
|
+
clearTimeout(entry.timer);
|
|
2109
|
+
entry.settle({ kind: "dismissed" });
|
|
2110
|
+
swept += 1;
|
|
2111
|
+
}
|
|
2112
|
+
return swept;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
1317
2115
|
* 某会话当前挂起中的提问(供 `GET /questions` 合并)。
|
|
1318
2116
|
*
|
|
1319
2117
|
* `state` 刻意报 `'continued'`:客户端(dsh-companion 的 HarnessViewModel:1314)
|
|
@@ -1423,6 +2221,14 @@ function sessionIdOf(summary) {
|
|
|
1423
2221
|
* 宁可落到私有目录也不能让「发消息」本身失败。
|
|
1424
2222
|
*/
|
|
1425
2223
|
const DEFAULT_IMAGE_HANDOFF_DIR = join(homedir(), ".dsh", "image-handoff");
|
|
2224
|
+
/**
|
|
2225
|
+
* 交接图片读取的体积上限(32 MiB)。
|
|
2226
|
+
*
|
|
2227
|
+
* 与只读图片路由里的 `MAX_BYTES`(`image-handoff.ts`)**同值但不同职责**:
|
|
2228
|
+
* 那边是「浏览器要不要显示」,这边是「服务端要不要把字节读进内存」。
|
|
2229
|
+
* 这里必须**在读之前**判 —— 先读完再判等于闸门形同虚设。
|
|
2230
|
+
*/
|
|
2231
|
+
const MAX_HANDOFF_BYTES = 33554432;
|
|
1426
2232
|
const IMAGE_EXTENSIONS = {
|
|
1427
2233
|
"image/png": "png",
|
|
1428
2234
|
"image/jpeg": "jpg",
|
|
@@ -1451,16 +2257,23 @@ function handoffExtension(mediaType, bytes) {
|
|
|
1451
2257
|
* `relative` 是相对会话工作目录的短路径,给人看;`absolute` 给读图工具用。
|
|
1452
2258
|
* 目录 0700、文件 0600:这些字节可能包含用户隐私。
|
|
1453
2259
|
*/
|
|
1454
|
-
function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
2260
|
+
async function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
1455
2261
|
const bytes = Buffer.from(data, "base64");
|
|
1456
2262
|
if (bytes.length === 0) throw new Error("EMPTY_IMAGE");
|
|
1457
|
-
|
|
2263
|
+
await mkdir(dir, {
|
|
1458
2264
|
recursive: true,
|
|
1459
2265
|
mode: 448
|
|
1460
2266
|
});
|
|
1461
2267
|
const fileName = `${createHash("sha256").update(bytes).digest("hex")}.${handoffExtension(mediaType, bytes)}`;
|
|
1462
2268
|
const absolute = join(dir, fileName);
|
|
1463
|
-
|
|
2269
|
+
try {
|
|
2270
|
+
await writeFile(absolute, bytes, {
|
|
2271
|
+
mode: 384,
|
|
2272
|
+
flag: "wx"
|
|
2273
|
+
});
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
if (error.code !== "EEXIST") throw error;
|
|
2276
|
+
}
|
|
1464
2277
|
return {
|
|
1465
2278
|
absolute,
|
|
1466
2279
|
relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
|
|
@@ -1490,10 +2303,21 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
|
|
|
1490
2303
|
const ext = (match[1] ?? "").toLowerCase();
|
|
1491
2304
|
const mediaType = HANDOFF_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
1492
2305
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1493
|
-
const
|
|
2306
|
+
const candidates = [...workspace !== void 0 ? [join(workspace, subdir, fileName)] : [], join(fallbackDir, fileName)];
|
|
2307
|
+
let target;
|
|
2308
|
+
let size = 0;
|
|
2309
|
+
for (const candidate of candidates) try {
|
|
2310
|
+
const info = await stat(candidate);
|
|
2311
|
+
if (!info.isFile()) continue;
|
|
2312
|
+
target = candidate;
|
|
2313
|
+
size = info.size;
|
|
2314
|
+
break;
|
|
2315
|
+
} catch {}
|
|
1494
2316
|
if (target === void 0) throw new Error("HANDOFF_IMAGE_NOT_FOUND");
|
|
2317
|
+
if (size === 0) throw new Error("HANDOFF_IMAGE_EMPTY");
|
|
2318
|
+
if (size > MAX_HANDOFF_BYTES) throw new Error("HANDOFF_IMAGE_TOO_LARGE");
|
|
1495
2319
|
return {
|
|
1496
|
-
bytes: new Uint8Array(
|
|
2320
|
+
bytes: new Uint8Array(await readFile(target)),
|
|
1497
2321
|
mediaType
|
|
1498
2322
|
};
|
|
1499
2323
|
}
|
|
@@ -1506,7 +2330,7 @@ async function readHandoffImageFile(host, roots, fallbackDir, subdir, sessionId,
|
|
|
1506
2330
|
*/
|
|
1507
2331
|
async function sessionWorkspaceDir(host, roots, sessionId) {
|
|
1508
2332
|
if (roots.length === 0) return void 0;
|
|
1509
|
-
const item = (await host.sessionController.list({},
|
|
2333
|
+
const item = (await host.sessionController.list({}, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS))).items?.find((entry) => entry.sessionId === sessionId);
|
|
1510
2334
|
const cwd = typeof item?.cwd === "string" ? item.cwd : void 0;
|
|
1511
2335
|
if (cwd === void 0) return void 0;
|
|
1512
2336
|
for (const root of roots) {
|
|
@@ -1637,7 +2461,7 @@ async function readSessionProjections(host, sessionId, signal) {
|
|
|
1637
2461
|
}
|
|
1638
2462
|
/** 会话当前生效的模型(pending > lastUsed > 目录默认),取不到返回 undefined。 */
|
|
1639
2463
|
async function currentModelSelection(host, sessionId) {
|
|
1640
|
-
const selection = (await host.sessionController.projections({ sessionId },
|
|
2464
|
+
const selection = (await host.sessionController.projections({ sessionId }, timeoutSignal(SESSION_DIR_LOOKUP_TIMEOUT_MS)))?.values?.["modelSelection"];
|
|
1641
2465
|
const catalog = await host.sessionController.modelCatalog();
|
|
1642
2466
|
const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
|
|
1643
2467
|
const provider = current?.provider;
|
|
@@ -1691,16 +2515,22 @@ async function handoffImagesForTextOnlyModel(host, roots, fallbackDir, subdir, l
|
|
|
1691
2515
|
const workspace = await sessionWorkspaceDir(host, roots, sessionId);
|
|
1692
2516
|
const dir = workspace !== void 0 ? join(workspace, subdir) : fallbackDir;
|
|
1693
2517
|
let index = 0;
|
|
1694
|
-
|
|
1695
|
-
|
|
2518
|
+
const mapped = [];
|
|
2519
|
+
for (const part of content) {
|
|
2520
|
+
if (part.type !== "image") {
|
|
2521
|
+
mapped.push(part);
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
1696
2524
|
index += 1;
|
|
1697
|
-
const { absolute, relative } = writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
2525
|
+
const { absolute, relative } = await writeHandoffFile(dir, workspace, part.mediaType, part.data);
|
|
1698
2526
|
const name = typeof part.name === "string" && part.name.trim() !== "" ? part.name.trim() : locale === "en" ? `image ${index}` : `图片 ${index}`;
|
|
1699
|
-
|
|
2527
|
+
const text = [handoffLine(index, name, relative, locale), `[Image: source: ${absolute}]`].join("\n");
|
|
2528
|
+
mapped.push({
|
|
1700
2529
|
type: "text",
|
|
1701
|
-
text
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
2530
|
+
text
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
return mapped;
|
|
1704
2534
|
} catch {
|
|
1705
2535
|
return content;
|
|
1706
2536
|
}
|
|
@@ -1746,7 +2576,9 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1746
2576
|
approvals.attach();
|
|
1747
2577
|
const questions = new QuestionBridge(host, mux);
|
|
1748
2578
|
questions.attach();
|
|
1749
|
-
const relay = new SessionEventRelay(host, mux)
|
|
2579
|
+
const relay = new SessionEventRelay(host, mux, SESSION_RELAY_LIMIT, (message) => {
|
|
2580
|
+
console.warn(message);
|
|
2581
|
+
});
|
|
1750
2582
|
host.on("api-session/added", (summary) => {
|
|
1751
2583
|
const sessionId = sessionIdOf(summary);
|
|
1752
2584
|
if (sessionId === void 0) return;
|
|
@@ -1808,18 +2640,20 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1808
2640
|
return {
|
|
1809
2641
|
respond: (message) => approvals.respond(message),
|
|
1810
2642
|
sessions: {
|
|
1811
|
-
list: (request) => call(async () => {
|
|
1812
|
-
const page = await host.sessionController.list(request.payload,
|
|
2643
|
+
list: (request) => call("sessions.list", async (signal) => {
|
|
2644
|
+
const page = await host.sessionController.list(request.payload, signal);
|
|
1813
2645
|
const items = Array.isArray(page.items) ? page.items.filter((item) => item.origin !== "subagent") : page.items;
|
|
1814
2646
|
return {
|
|
1815
2647
|
...page,
|
|
1816
2648
|
items
|
|
1817
2649
|
};
|
|
1818
2650
|
}),
|
|
1819
|
-
create: (request) => call(() => host.sessionController.create(request.payload)),
|
|
1820
|
-
history: (request) => call(async () => {
|
|
2651
|
+
create: (request) => call("sessions.create", (signal) => host.sessionController.create(request.payload)),
|
|
2652
|
+
history: (request) => call("sessions.history", async (signal) => {
|
|
1821
2653
|
const { sessionId, maxMessages, beforeSeq } = request.payload;
|
|
1822
2654
|
const controller = new AbortController();
|
|
2655
|
+
if (signal.aborted) controller.abort();
|
|
2656
|
+
else signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1823
2657
|
try {
|
|
1824
2658
|
const snapshot = (await host.sessionController.follow({
|
|
1825
2659
|
address: {
|
|
@@ -1858,7 +2692,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1858
2692
|
controller.abort();
|
|
1859
2693
|
}
|
|
1860
2694
|
}),
|
|
1861
|
-
prompt: (request) => call(async () => {
|
|
2695
|
+
prompt: (request) => call("sessions.prompt", async (signal) => {
|
|
1862
2696
|
const requestId = randomUUID();
|
|
1863
2697
|
const { locale, ...kernelPayload } = request.payload;
|
|
1864
2698
|
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
@@ -1871,22 +2705,22 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1871
2705
|
requestId,
|
|
1872
2706
|
...kernelPayload,
|
|
1873
2707
|
content
|
|
1874
|
-
},
|
|
2708
|
+
}, signal);
|
|
1875
2709
|
binding?.commit();
|
|
1876
2710
|
return { accepted: true };
|
|
1877
2711
|
}),
|
|
1878
|
-
uploadAttachment: (request) => call(async () => {
|
|
2712
|
+
uploadAttachment: (request) => call("sessions.uploadAttachment", async (signal) => {
|
|
1879
2713
|
const { sessionId, data, name } = request.payload;
|
|
1880
2714
|
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
1881
2715
|
return {
|
|
1882
2716
|
receiptId: (await fileUploadService.upload(await agentFor(sessionId), name === void 0 || name === "" ? { data } : {
|
|
1883
2717
|
data,
|
|
1884
2718
|
name
|
|
1885
|
-
},
|
|
2719
|
+
}, signal)).receiptId,
|
|
1886
2720
|
name: name ?? null
|
|
1887
2721
|
};
|
|
1888
2722
|
}),
|
|
1889
|
-
readAttachment: (request) => call(async () => {
|
|
2723
|
+
readAttachment: (request) => call("sessions.readAttachment", async (signal) => {
|
|
1890
2724
|
const { sessionId, attachmentId } = request.payload;
|
|
1891
2725
|
const value = await host.sessionController.attachment({
|
|
1892
2726
|
sessionId,
|
|
@@ -1897,20 +2731,20 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1897
2731
|
mediaType: value.attachment.mediaType
|
|
1898
2732
|
};
|
|
1899
2733
|
}),
|
|
1900
|
-
readHandoffImage: (request) => call(async () => {
|
|
2734
|
+
readHandoffImage: (request) => call("sessions.readHandoffImage", async (signal) => {
|
|
1901
2735
|
const { sessionId, name } = request.payload;
|
|
1902
2736
|
return readHandoffImageFile(host, handoffRootsOf(), imageHandoffDir, handoffSubdirOf(), sessionId, name);
|
|
1903
2737
|
}),
|
|
1904
|
-
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
1905
|
-
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
1906
|
-
fork: (request) => call(() => host.sessionController.fork(request.payload)),
|
|
1907
|
-
models: (request) => call(async () => {
|
|
2738
|
+
cancel: (request) => call("sessions.cancel", (signal) => host.sessionController.cancel(request.payload)),
|
|
2739
|
+
rename: (request) => call("sessions.rename", (signal) => host.sessionController.rename(request.payload)),
|
|
2740
|
+
fork: (request) => call("sessions.fork", (signal) => host.sessionController.fork(request.payload)),
|
|
2741
|
+
models: (request) => call("sessions.models", async (signal) => {
|
|
1908
2742
|
const { sessionId } = request.payload;
|
|
1909
2743
|
try {
|
|
1910
2744
|
await host.sessionController.resolveAgent(sessionId);
|
|
1911
2745
|
} catch {}
|
|
1912
2746
|
const catalog = await host.sessionController.modelCatalog();
|
|
1913
|
-
const selection = (await host.sessionController.projections({ sessionId },
|
|
2747
|
+
const selection = (await host.sessionController.projections({ sessionId }, signal))?.values?.["modelSelection"];
|
|
1914
2748
|
return {
|
|
1915
2749
|
current: selection?.next ?? selection?.lastUsed ?? catalog.default,
|
|
1916
2750
|
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
@@ -1921,10 +2755,10 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1921
2755
|
})) : []
|
|
1922
2756
|
};
|
|
1923
2757
|
}),
|
|
1924
|
-
selectModel: (request) => call(() => host.sessionController.selectModel(request.payload))
|
|
2758
|
+
selectModel: (request) => call("sessions.selectModel", (signal) => host.sessionController.selectModel(request.payload))
|
|
1925
2759
|
},
|
|
1926
2760
|
workspace: {
|
|
1927
|
-
list: () => call(async () => {
|
|
2761
|
+
list: () => call("workspace.list", async (signal) => {
|
|
1928
2762
|
return {
|
|
1929
2763
|
items: host.workspaceRegistry.list().map((row) => ({
|
|
1930
2764
|
workspaceId: row.id,
|
|
@@ -1937,16 +2771,16 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1937
2771
|
archivedSessionIds: readArchivedSessionIds(host.workspaceRegistry)
|
|
1938
2772
|
};
|
|
1939
2773
|
}),
|
|
1940
|
-
create: (request) => call(() => host.workspaceController.create(request.payload)),
|
|
1941
|
-
rename: (request) => call(() => host.workspaceController.rename(request.payload)),
|
|
1942
|
-
delete: (request) => call(async () => {
|
|
2774
|
+
create: (request) => call("workspace.create", (signal) => host.workspaceController.create(request.payload)),
|
|
2775
|
+
rename: (request) => call("workspace.rename", (signal) => host.workspaceController.rename(request.payload)),
|
|
2776
|
+
delete: (request) => call("workspace.delete", async (signal) => {
|
|
1943
2777
|
await host.workspaceController.delete(request.payload);
|
|
1944
2778
|
return { deleted: true };
|
|
1945
2779
|
}),
|
|
1946
|
-
archiveSession: (request) => call(() => host.workspaceController.archiveSession(request.payload))
|
|
2780
|
+
archiveSession: (request) => call("workspace.archiveSession", (signal) => host.workspaceController.archiveSession(request.payload))
|
|
1947
2781
|
},
|
|
1948
2782
|
agentPresets: {
|
|
1949
|
-
list: () => call(async () => {
|
|
2783
|
+
list: () => call("agentPresets.list", async (signal) => {
|
|
1950
2784
|
const withTrust = (rows) => rows.map((row) => ({
|
|
1951
2785
|
...row,
|
|
1952
2786
|
trust: row.trust ?? "system"
|
|
@@ -1966,19 +2800,19 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1966
2800
|
hasDocument: false
|
|
1967
2801
|
};
|
|
1968
2802
|
}),
|
|
1969
|
-
select: (request) => call(async () => {
|
|
2803
|
+
select: (request) => call("agentPresets.select", async (signal) => {
|
|
1970
2804
|
const agent = await agentFor(request.payload.sessionId);
|
|
1971
2805
|
return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
|
|
1972
2806
|
})
|
|
1973
2807
|
},
|
|
1974
|
-
permissionPresets: { catalog: () => call(async () => {
|
|
2808
|
+
permissionPresets: { catalog: () => call("permissionPresets.catalog", async (signal) => {
|
|
1975
2809
|
const service = optionalService(host, "permissionPresets");
|
|
1976
2810
|
if (service === null || typeof service.catalog !== "function") return FALLBACK_PERMISSION_CATALOG;
|
|
1977
2811
|
return normalizePermissionCatalog(await service.catalog());
|
|
1978
2812
|
}) },
|
|
1979
2813
|
userQuestions: {
|
|
1980
2814
|
pendingAll: () => questions.pendingAll(),
|
|
1981
|
-
list: (request) => call(async () => {
|
|
2815
|
+
list: (request) => call("userQuestions.list", async (signal) => {
|
|
1982
2816
|
const { sessionId } = request.payload;
|
|
1983
2817
|
const bridged = questions.pendingFor(sessionId);
|
|
1984
2818
|
const bridgedIds = new Set(bridged.map((item) => item.callId));
|
|
@@ -1990,7 +2824,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1990
2824
|
const continued = Array.isArray(rows) ? rows.map(toUserQuestionView).filter((item) => item !== void 0) : [];
|
|
1991
2825
|
return { items: [...bridged, ...continued.filter((item) => !bridgedIds.has(item.callId))] };
|
|
1992
2826
|
}),
|
|
1993
|
-
answer: (request) => call(async () => {
|
|
2827
|
+
answer: (request) => call("userQuestions.answer", async (signal) => {
|
|
1994
2828
|
const { sessionId, callId, answer } = request.payload;
|
|
1995
2829
|
const agent = await agentForQuestions(sessionId);
|
|
1996
2830
|
let kernelCouldNotAnswer = false;
|
|
@@ -2037,24 +2871,24 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2037
2871
|
})
|
|
2038
2872
|
},
|
|
2039
2873
|
settings: {
|
|
2040
|
-
describe: () => call(() => host.settingsController.describe()),
|
|
2041
|
-
mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
2874
|
+
describe: () => call("settings.describe", (signal) => host.settingsController.describe()),
|
|
2875
|
+
mutate: (request) => call("settings.mutate", (signal) => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
2042
2876
|
},
|
|
2043
2877
|
credentials: {
|
|
2044
|
-
describe: (request) => call(async () => {
|
|
2878
|
+
describe: (request) => call("credentials.describe", async (signal) => {
|
|
2045
2879
|
return { credentials: await host.credentialsController.describe(request.payload.refs) };
|
|
2046
2880
|
}),
|
|
2047
|
-
set: (request) => call(async () => {
|
|
2881
|
+
set: (request) => call("credentials.set", async (signal) => {
|
|
2048
2882
|
await host.credentialsController.set(request.payload.ref, request.payload.value);
|
|
2049
2883
|
return {};
|
|
2050
2884
|
}),
|
|
2051
|
-
unset: (request) => call(async () => {
|
|
2885
|
+
unset: (request) => call("credentials.unset", async (signal) => {
|
|
2052
2886
|
await host.credentialsController.unset(request.payload.ref);
|
|
2053
2887
|
return {};
|
|
2054
2888
|
})
|
|
2055
2889
|
},
|
|
2056
2890
|
llm: {
|
|
2057
|
-
providers: () => call(async () => {
|
|
2891
|
+
providers: () => call("llm.providers", async (signal) => {
|
|
2058
2892
|
const directory = host.llm.listConfigurableProviders();
|
|
2059
2893
|
const live = new Set(host.llm.listProviders().map((entry) => entry.id));
|
|
2060
2894
|
return { providers: directory.map((entry) => ({
|
|
@@ -2072,14 +2906,14 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2072
2906
|
} }
|
|
2073
2907
|
})) };
|
|
2074
2908
|
}),
|
|
2075
|
-
models: () => call(async () => {
|
|
2909
|
+
models: () => call("llm.models", async (signal) => {
|
|
2076
2910
|
const view = await host.sessionController.modelCatalog();
|
|
2077
2911
|
return {
|
|
2078
2912
|
groups: Array.isArray(view.groups) ? view.groups : [],
|
|
2079
2913
|
failures: Array.isArray(view.failures) ? view.failures : []
|
|
2080
2914
|
};
|
|
2081
2915
|
}),
|
|
2082
|
-
discoverModels: (request) => call(async () => {
|
|
2916
|
+
discoverModels: (request) => call("llm.discoverModels", async (signal) => {
|
|
2083
2917
|
const { settingsNs, ...rest } = request.payload;
|
|
2084
2918
|
return { models: [...await host.llm.discoverModels(settingsNs, rest)] };
|
|
2085
2919
|
})
|
|
@@ -2092,7 +2926,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2092
2926
|
}
|
|
2093
2927
|
//#endregion
|
|
2094
2928
|
//#region src/shared/version.ts
|
|
2095
|
-
const PLUGIN_VERSION = "2.5.
|
|
2929
|
+
const PLUGIN_VERSION = "2.5.2";
|
|
2096
2930
|
//#endregion
|
|
2097
2931
|
//#region src/host/capabilities.ts
|
|
2098
2932
|
/**
|
|
@@ -2123,7 +2957,8 @@ const PLUGIN_VERSION = "2.5.0";
|
|
|
2123
2957
|
* `approvals-v1` / `questions-v1`(`dsh-companion` 的 `REQUIRED_CAPABILITIES`:
|
|
2124
2958
|
* 缺了就明说「插件过旧、卡片不会出现」)与 `session-archive-list-v1`
|
|
2125
2959
|
* (决定会话面板是否显示「已归档」入口)。
|
|
2126
|
-
* 2. **对外声明** ——
|
|
2960
|
+
* 2. **对外声明** —— 其余所有能力位(数量以本文件数组为准,勿在注释里写数字,
|
|
2961
|
+
* 加一个位就要改注释的纪律只会让注释过期)。它们回答的是「这台服务端能做什么」,
|
|
2127
2962
|
* **服务的是任何客户端(包括第三方)**,而不是某一处分支。
|
|
2128
2963
|
*
|
|
2129
2964
|
* 所以:**「我方客户端没用到」不等于「没人用」**。删掉第 2 类会破坏对外承诺
|
|
@@ -2176,15 +3011,50 @@ const BRIDGE_CAPABILITIES = [
|
|
|
2176
3011
|
"job-kill-v1"
|
|
2177
3012
|
];
|
|
2178
3013
|
/**
|
|
3014
|
+
* 运行时**不可用**的能力位:服务端此刻做不了这些事,就不该声明。
|
|
3015
|
+
*
|
|
3016
|
+
* ## 为什么必须过滤(这是本节最重要的一条)
|
|
3017
|
+
*
|
|
3018
|
+
* 能力位的全部意义是让客户端把「服务端不支持」与「本来就没有」分开
|
|
3019
|
+
* (见文件头的说明)。而 `jobController` 是**可选宿主服务**:0.2.0-rc.2 上没有它,
|
|
3020
|
+
* 三个 `/jobs` 端点在那套内核上根本不会被挂载(`index.ts` 直接不下发,路由返回 404)。
|
|
3021
|
+
* 若 `connection.ready` 仍然宣告 `job-list-v1`,客户端按位探测后就会去打一个必然
|
|
3022
|
+
* 404 的端点 —— 这恰好是这份清单本身想消灭的那种「静默少一个功能」。
|
|
3023
|
+
*
|
|
3024
|
+
* ## 为什么按「能力位 → 依赖的服务」映射,而不是在调用点手写过滤
|
|
3025
|
+
*
|
|
3026
|
+
* 声明与端点必须**同源**。把「哪个位依赖哪个可选服务」写在一处,将来再加一个
|
|
3027
|
+
* 可选服务只需要在这里补一行;散在调用点就会漂。
|
|
3028
|
+
*/
|
|
3029
|
+
const CAPABILITY_REQUIREMENTS = {
|
|
3030
|
+
"job-list-v1": "jobs",
|
|
3031
|
+
"job-output-v1": "jobs",
|
|
3032
|
+
"job-kill-v1": "jobs"
|
|
3033
|
+
};
|
|
3034
|
+
/**
|
|
3035
|
+
* 这台进程此刻真的能提供的能力位。
|
|
3036
|
+
*
|
|
3037
|
+
* @param flags - 运行时可用性;**缺省当作「全都可用」**。
|
|
3038
|
+
* 缺省必须保守地全给:测试替身与老调用点(不传参)拿到的仍是完整清单,
|
|
3039
|
+
* 行为与加参数之前一字不差 —— 否则这个改动会悄悄改掉所有既有测试的期望。
|
|
3040
|
+
*/
|
|
3041
|
+
function availableCapabilities(flags = {}) {
|
|
3042
|
+
return BRIDGE_CAPABILITIES.filter((capability) => {
|
|
3043
|
+
const requirement = CAPABILITY_REQUIREMENTS[capability];
|
|
3044
|
+
if (requirement === void 0) return true;
|
|
3045
|
+
return flags[requirement] !== false;
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
2179
3049
|
* `connection.ready` 事件的载荷。
|
|
2180
3050
|
*
|
|
2181
3051
|
* 每次调用返回**新的副本**:调用方(或测试)改它不该影响常量,
|
|
2182
3052
|
* 否则一处手滑就会让整个进程后续所有连接都收到被改过的能力清单。
|
|
2183
3053
|
*/
|
|
2184
|
-
function connectionReadyData() {
|
|
3054
|
+
function connectionReadyData(flags = {}) {
|
|
2185
3055
|
return {
|
|
2186
3056
|
bridgeVersion: PLUGIN_VERSION,
|
|
2187
|
-
capabilities:
|
|
3057
|
+
capabilities: availableCapabilities(flags)
|
|
2188
3058
|
};
|
|
2189
3059
|
}
|
|
2190
3060
|
//#endregion
|
|
@@ -2302,25 +3172,39 @@ function clampStrings(value, limit, seen) {
|
|
|
2302
3172
|
*/
|
|
2303
3173
|
function clampHistoryEvents(events, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
|
|
2304
3174
|
let clampedStrings = 0;
|
|
2305
|
-
const
|
|
2306
|
-
|
|
3175
|
+
const sizes = new Array(events.length);
|
|
3176
|
+
const prepared = new Array(events.length);
|
|
3177
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
3178
|
+
const entry = events[index];
|
|
3179
|
+
const size = byteLength(entry);
|
|
3180
|
+
if (size <= perEventBudget) {
|
|
3181
|
+
sizes[index] = size;
|
|
3182
|
+
prepared[index] = entry;
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
2307
3185
|
const result = clampStrings(entry, perEventBudget, /* @__PURE__ */ new WeakSet());
|
|
2308
|
-
if (result.clamped === 0)
|
|
3186
|
+
if (result.clamped === 0) {
|
|
3187
|
+
sizes[index] = size;
|
|
3188
|
+
prepared[index] = entry;
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
2309
3191
|
clampedStrings += result.clamped;
|
|
2310
3192
|
const marker = { truncated: true };
|
|
2311
3193
|
const clampedValue = result.value;
|
|
2312
|
-
|
|
3194
|
+
const marked = clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
|
|
2313
3195
|
...clampedValue,
|
|
2314
3196
|
bridgeTruncated: marker
|
|
2315
3197
|
} : {
|
|
2316
3198
|
value: clampedValue,
|
|
2317
3199
|
bridgeTruncated: marker
|
|
2318
3200
|
};
|
|
2319
|
-
|
|
3201
|
+
prepared[index] = marked;
|
|
3202
|
+
sizes[index] = byteLength(marked);
|
|
3203
|
+
}
|
|
2320
3204
|
const kept = [];
|
|
2321
3205
|
let bytes = 0;
|
|
2322
3206
|
for (let index = prepared.length - 1; index >= 0; index -= 1) {
|
|
2323
|
-
const size =
|
|
3207
|
+
const size = sizes[index] ?? 0;
|
|
2324
3208
|
if (kept.length > 0 && bytes + size > byteBudget) break;
|
|
2325
3209
|
bytes += size;
|
|
2326
3210
|
kept.push(prepared[index]);
|
|
@@ -2356,10 +3240,49 @@ function applyHistoryBudget(raw, byteBudget = HISTORY_BYTE_BUDGET, perEventBudge
|
|
|
2356
3240
|
}
|
|
2357
3241
|
//#endregion
|
|
2358
3242
|
//#region src/host/chat-adapter.ts
|
|
3243
|
+
/** 会话可见性缓存的 TTL(见 `DshChatAdapter.sessionVisibility` 的说明)。 */
|
|
3244
|
+
const SESSION_VISIBILITY_TTL_MS = 2e3;
|
|
3245
|
+
/** 可见性缓存条目上限;到顶就按 TTL 清一遍(见 `rememberVisibility`)。 */
|
|
3246
|
+
const SESSION_VISIBILITY_CACHE_LIMIT = 2048;
|
|
3247
|
+
/**
|
|
3248
|
+
* 把 principal 收敛成一个稳定的缓存键。
|
|
3249
|
+
*
|
|
3250
|
+
* 为什么不用 `JSON.stringify(principal)`:`rootIds` 是 `Set`(序列化出来是 `{}`,
|
|
3251
|
+
* 所有设备会撞成同一个键 —— 那就是越权)。这里只用**身份**部分,
|
|
3252
|
+
* 授权范围的变化由 TTL 与 `sessionRootId` 那条独立缓存兜住。
|
|
3253
|
+
*/
|
|
3254
|
+
function principalKey(principal) {
|
|
3255
|
+
return principal.kind === "admin" ? "admin" : `device:${principal.id}`;
|
|
3256
|
+
}
|
|
2359
3257
|
var DshChatAdapter = class {
|
|
2360
3258
|
api;
|
|
2361
3259
|
database;
|
|
2362
3260
|
commandServices;
|
|
3261
|
+
/**
|
|
3262
|
+
* 会话 → 可见性快照的短 TTL 缓存。
|
|
3263
|
+
*
|
|
3264
|
+
* ## 为什么需要(这是本文件最热的一处浪费)
|
|
3265
|
+
*
|
|
3266
|
+
* `requireSession()` 会在**几乎每个** API 调用里跑一遍 `listSessions()`,
|
|
3267
|
+
* 而 `listSessions` 一次要打两次内核 RPC(`sessions.list` + `workspace.list`)
|
|
3268
|
+
* 加一次 `database.listRoots()`。也就是说 history / commands / approvals /
|
|
3269
|
+
* models / questions / prompt 每条请求都白花两次内核往返 —— 只为回答
|
|
3270
|
+
* 「这个 session 能不能被这台设备看见」。
|
|
3271
|
+
*
|
|
3272
|
+
* ## 为什么 TTL 是 2 秒(短到近乎无感,长到足以省掉重复)
|
|
3273
|
+
*
|
|
3274
|
+
* 客户端在打开一个会话时会**连续**打四五个接口(历史、审批、提问、命令、模型),
|
|
3275
|
+
* 2 秒足以把这一串并成一两次真实查询。再长就会让「刚建的会话看不到」
|
|
3276
|
+
* 变成一个可感受到的延迟 —— 而那正是我们要避免的另一类 bug。
|
|
3277
|
+
*
|
|
3278
|
+
* ## 缓存的是什么(关键:缓存的是**授权结论**,不是会话内容)
|
|
3279
|
+
*
|
|
3280
|
+
* 只缓存「这个 sessionId 对当前设备可见吗」这一个布尔结论,且按 `principal.id`
|
|
3281
|
+
* 分桶。**不缓存会话正文**:正文会被 `rename`/`archive`/新消息改掉,
|
|
3282
|
+
* 缓存它等于制造陈旧数据;而可见性只随「根授权变没变」与「会话删没删」变,
|
|
3283
|
+
* 两者都是低频且由用户操作驱动的。
|
|
3284
|
+
*/
|
|
3285
|
+
sessionVisibility = /* @__PURE__ */ new Map();
|
|
2363
3286
|
sessionRootIds = /* @__PURE__ */ new Map();
|
|
2364
3287
|
sessionRootLookups = /* @__PURE__ */ new Map();
|
|
2365
3288
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
@@ -2853,8 +3776,13 @@ var DshChatAdapter = class {
|
|
|
2853
3776
|
}
|
|
2854
3777
|
}
|
|
2855
3778
|
async isSessionAuthorized(principal, sessionId) {
|
|
3779
|
+
const key = `${principalKey(principal)}\u0000${sessionId}`;
|
|
3780
|
+
const cached = this.sessionVisibility.get(key);
|
|
3781
|
+
if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) return cached.visible;
|
|
2856
3782
|
const rootId = await this.sessionRootId(sessionId);
|
|
2857
|
-
|
|
3783
|
+
const allowed = rootId !== void 0 && (principal.rootIds === "all" || principal.rootIds.has(rootId));
|
|
3784
|
+
this.rememberVisibility(key, allowed);
|
|
3785
|
+
return allowed;
|
|
2858
3786
|
}
|
|
2859
3787
|
startEvents(signal, emit) {
|
|
2860
3788
|
const run = async (kind) => {
|
|
@@ -2917,10 +3845,37 @@ var DshChatAdapter = class {
|
|
|
2917
3845
|
return services.agents.get(sessionId);
|
|
2918
3846
|
}
|
|
2919
3847
|
async requireSession(principal, sessionId) {
|
|
3848
|
+
const key = `${principalKey(principal)}\u0000${sessionId}`;
|
|
3849
|
+
const cached = this.sessionVisibility.get(key);
|
|
3850
|
+
if (cached !== void 0 && Date.now() - cached.visibleAt < SESSION_VISIBILITY_TTL_MS) {
|
|
3851
|
+
if (cached.visible) {
|
|
3852
|
+
const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
|
|
3853
|
+
if (session !== void 0) return session;
|
|
3854
|
+
this.sessionVisibility.delete(key);
|
|
3855
|
+
} else throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
|
|
3856
|
+
}
|
|
2920
3857
|
const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
|
|
3858
|
+
this.rememberVisibility(key, session !== void 0);
|
|
2921
3859
|
if (session === void 0) throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
|
|
2922
3860
|
return session;
|
|
2923
3861
|
}
|
|
3862
|
+
/**
|
|
3863
|
+
* 记一条可见性结论,并顺手把过期条目清掉。
|
|
3864
|
+
*
|
|
3865
|
+
* 为什么在这里做清扫而不是定时器:缓存条目数与「设备数 × 会话数」同阶,
|
|
3866
|
+
* 对单机部署来说是千级 —— 每次写入顺手扫一遍完全够,而且**没有定时器泄漏**。
|
|
3867
|
+
* (本仓的纪律:服务端不引入 `setInterval`,见 `server.ts` 的心跳是唯一的例外,
|
|
3868
|
+
* 且它 `unref` 过。)
|
|
3869
|
+
*/
|
|
3870
|
+
rememberVisibility(key, visible) {
|
|
3871
|
+
const now = Date.now();
|
|
3872
|
+
this.sessionVisibility.set(key, {
|
|
3873
|
+
visibleAt: now,
|
|
3874
|
+
visible
|
|
3875
|
+
});
|
|
3876
|
+
if (this.sessionVisibility.size < SESSION_VISIBILITY_CACHE_LIMIT) return;
|
|
3877
|
+
for (const [entryKey, entry] of this.sessionVisibility) if (now - entry.visibleAt >= SESSION_VISIBILITY_TTL_MS) this.sessionVisibility.delete(entryKey);
|
|
3878
|
+
}
|
|
2924
3879
|
async requireWorkspace(principal, workspaceId) {
|
|
2925
3880
|
const workspace = (await this.listWorkspaces(principal)).find((item) => item.id === workspaceId);
|
|
2926
3881
|
if (workspace === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
|
|
@@ -3336,6 +4291,29 @@ var WorkspaceDatabase = class {
|
|
|
3336
4291
|
trashDir;
|
|
3337
4292
|
db;
|
|
3338
4293
|
closed = false;
|
|
4294
|
+
/**
|
|
4295
|
+
* 「设备集合或设备权限发生过写」的进程内单调计数。
|
|
4296
|
+
*
|
|
4297
|
+
* ## 为什么需要它
|
|
4298
|
+
*
|
|
4299
|
+
* WebSocket 广播的每一条事件都要确认「这条连接背后的设备还有效吗、scope/root 变了吗」
|
|
4300
|
+
* —— 旧实现是**每条事件查一次库**(`getActiveDevice`)。多条连接 × 持续事件
|
|
4301
|
+
* 就是成百次纯浪费的 SQLite 查询。
|
|
4302
|
+
*
|
|
4303
|
+
* 有了这个计数,`server.ts` 可以只在**它变了**的时候重新水化设备
|
|
4304
|
+
* (特性探测:getter 不存在就退回逐事件查库,所以迟到或缺席都不会更差)。
|
|
4305
|
+
*
|
|
4306
|
+
* ## 语义(与 perf-reader 的 PERF-1 契约一致)
|
|
4307
|
+
*
|
|
4308
|
+
* 「任何改变**设备是否存在 / scope / rootIds** 的写」都要 +1。当前四处:
|
|
4309
|
+
* `exchangePairing` / `updateDevice` / `revokeDevice` / `purgeRevokedDevices`。
|
|
4310
|
+
*
|
|
4311
|
+
* 根(roots)的增删**不** +1:设备记录本身没变;`/roots` 那条路径本来就是实时查库。
|
|
4312
|
+
*
|
|
4313
|
+
* 保守性:宁可多 +1(多一次缓存失效,代价是一次查询),不可漏 +1
|
|
4314
|
+
* (漏了就是「撤销后仍能收事件」—— 那正是 `device-revocation` 要防的事)。
|
|
4315
|
+
*/
|
|
4316
|
+
devicesRevisionCounter = 0;
|
|
3339
4317
|
constructor(dataDir) {
|
|
3340
4318
|
this.dataDir = path.resolve(dataDir);
|
|
3341
4319
|
this.trashDir = path.join(this.dataDir, "trash");
|
|
@@ -3345,6 +4323,10 @@ var WorkspaceDatabase = class {
|
|
|
3345
4323
|
this.migrate();
|
|
3346
4324
|
openDatabases.add(new WeakRef(this));
|
|
3347
4325
|
}
|
|
4326
|
+
/** 当前设备状态版本号。用途见字段注释(`server.ts` 的广播缓存失效)。 */
|
|
4327
|
+
get devicesRevision() {
|
|
4328
|
+
return this.devicesRevisionCounter;
|
|
4329
|
+
}
|
|
3348
4330
|
/** 关闭数据库。**幂等** —— 重复调用不抛错。 */
|
|
3349
4331
|
close() {
|
|
3350
4332
|
if (this.closed) return;
|
|
@@ -3368,6 +4350,13 @@ var WorkspaceDatabase = class {
|
|
|
3368
4350
|
setSetting(key, value) {
|
|
3369
4351
|
this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
3370
4352
|
}
|
|
4353
|
+
/**
|
|
4354
|
+
* 登记一条授权根。
|
|
4355
|
+
*
|
|
4356
|
+
* @param options.requestId - 发起这次登记的那条 HTTP 请求的 id。
|
|
4357
|
+
* 审计的用途不只是「记下发生了什么」,还要能回答「**哪一次请求**干的」;
|
|
4358
|
+
* 不传(插件启动期登记内置根)时审计行的 `request_id` 为 null。
|
|
4359
|
+
*/
|
|
3371
4360
|
async addRoot(inputPath, label, options) {
|
|
3372
4361
|
const realPath = await canonicalRoot(inputPath);
|
|
3373
4362
|
if (options?.allowDuplicate !== true) {
|
|
@@ -3378,7 +4367,7 @@ var WorkspaceDatabase = class {
|
|
|
3378
4367
|
const createdAt = Date.now();
|
|
3379
4368
|
const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
|
|
3380
4369
|
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at,builtin) VALUES(?,?,?,?,0)").run(id, resolvedLabel, realPath, createdAt);
|
|
3381
|
-
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
|
|
4370
|
+
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel }, options?.requestId);
|
|
3382
4371
|
return {
|
|
3383
4372
|
id,
|
|
3384
4373
|
label: resolvedLabel,
|
|
@@ -3436,7 +4425,12 @@ var WorkspaceDatabase = class {
|
|
|
3436
4425
|
if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
3437
4426
|
this.audit("admin", "loopback-admin", "root.remove", id);
|
|
3438
4427
|
}
|
|
3439
|
-
|
|
4428
|
+
/**
|
|
4429
|
+
* 生成一枚配对码。
|
|
4430
|
+
*
|
|
4431
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4432
|
+
*/
|
|
4433
|
+
createPairing(rootIds, scopes, ttlMs = 6e5, options = {}) {
|
|
3440
4434
|
assertScopes(scopes);
|
|
3441
4435
|
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
3442
4436
|
const raw = randomBytes(5).toString("hex").toUpperCase();
|
|
@@ -3447,13 +4441,20 @@ var WorkspaceDatabase = class {
|
|
|
3447
4441
|
rootIds,
|
|
3448
4442
|
scopes,
|
|
3449
4443
|
expiresAt
|
|
3450
|
-
});
|
|
4444
|
+
}, options.requestId);
|
|
3451
4445
|
return {
|
|
3452
4446
|
code,
|
|
3453
4447
|
expiresAt
|
|
3454
4448
|
};
|
|
3455
4449
|
}
|
|
3456
|
-
|
|
4450
|
+
/**
|
|
4451
|
+
* 用配对码换一枚设备令牌。
|
|
4452
|
+
*
|
|
4453
|
+
* 审计**在事务内**写(FUNC-5):它是「这台设备被授权了」的唯一留痕,
|
|
4454
|
+
* 与设备行同生共死。审计写不进去 → 整个兑换回滚 → 客户端重试,
|
|
4455
|
+
* 而不是「拿到令牌但库里没有这笔授权的记录」。
|
|
4456
|
+
*/
|
|
4457
|
+
exchangePairing(code, deviceName, options = {}) {
|
|
3457
4458
|
const normalized = normalizePairingCode(code);
|
|
3458
4459
|
const now = Date.now();
|
|
3459
4460
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -3469,18 +4470,18 @@ var WorkspaceDatabase = class {
|
|
|
3469
4470
|
this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now);
|
|
3470
4471
|
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
3471
4472
|
for (const rootId of rootIds) grant.run(id, rootId);
|
|
4473
|
+
this.audit("device", id, "pairing.exchange", void 0, void 0, { name }, options.requestId);
|
|
3472
4474
|
this.db.exec("COMMIT");
|
|
3473
|
-
|
|
3474
|
-
id,
|
|
3475
|
-
name,
|
|
3476
|
-
scopes,
|
|
3477
|
-
rootIds,
|
|
3478
|
-
createdAt: now
|
|
3479
|
-
};
|
|
3480
|
-
this.audit("device", id, "pairing.exchange", void 0, void 0, { name });
|
|
4475
|
+
this.devicesRevisionCounter += 1;
|
|
3481
4476
|
return {
|
|
3482
4477
|
token,
|
|
3483
|
-
device
|
|
4478
|
+
device: {
|
|
4479
|
+
id,
|
|
4480
|
+
name,
|
|
4481
|
+
scopes,
|
|
4482
|
+
rootIds,
|
|
4483
|
+
createdAt: now
|
|
4484
|
+
}
|
|
3484
4485
|
};
|
|
3485
4486
|
} catch (error) {
|
|
3486
4487
|
this.db.exec("ROLLBACK");
|
|
@@ -3526,7 +4527,16 @@ var WorkspaceDatabase = class {
|
|
|
3526
4527
|
isDeviceActive(id) {
|
|
3527
4528
|
return this.db.prepare("SELECT 1 AS active FROM devices WHERE id = ? AND revoked_at IS NULL").get(id) !== void 0;
|
|
3528
4529
|
}
|
|
3529
|
-
|
|
4530
|
+
/**
|
|
4531
|
+
* 改一台设备的 scope 与 root 授权。
|
|
4532
|
+
*
|
|
4533
|
+
* 审计**在事务内**写(FUNC-5):它是「谁的权限被扩大了」的唯一留痕。
|
|
4534
|
+
* 审计失败 → 整个更新回滚。宁可让用户重试,也不能出现「权限已经变宽、
|
|
4535
|
+
* 但没有任何记录说是谁在什么时候改的」—— 那是不可审计的提权。
|
|
4536
|
+
*
|
|
4537
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4538
|
+
*/
|
|
4539
|
+
updateDevice(id, scopes, rootIds, options = {}) {
|
|
3530
4540
|
assertScopes(scopes);
|
|
3531
4541
|
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
3532
4542
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -3535,16 +4545,17 @@ var WorkspaceDatabase = class {
|
|
|
3535
4545
|
this.db.prepare("DELETE FROM device_roots WHERE device_id = ?").run(id);
|
|
3536
4546
|
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
3537
4547
|
for (const rootId of rootIds) grant.run(id, rootId);
|
|
4548
|
+
this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
|
|
4549
|
+
deviceId: id,
|
|
4550
|
+
scopes,
|
|
4551
|
+
rootIds
|
|
4552
|
+
}, options.requestId);
|
|
3538
4553
|
this.db.exec("COMMIT");
|
|
3539
4554
|
} catch (error) {
|
|
3540
4555
|
this.db.exec("ROLLBACK");
|
|
3541
4556
|
throw error;
|
|
3542
4557
|
}
|
|
3543
|
-
this.
|
|
3544
|
-
deviceId: id,
|
|
3545
|
-
scopes,
|
|
3546
|
-
rootIds
|
|
3547
|
-
});
|
|
4558
|
+
this.devicesRevisionCounter += 1;
|
|
3548
4559
|
const device = this.listDevices().find((item) => item.id === id);
|
|
3549
4560
|
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
|
|
3550
4561
|
return device;
|
|
@@ -3562,14 +4573,31 @@ var WorkspaceDatabase = class {
|
|
|
3562
4573
|
* - `authenticate` 仍保留 `revoked_at IS NULL` 作为兜底,万一有别处留下的标记行,
|
|
3563
4574
|
* 它们依然登不进来 —— 两层保险不冲突。
|
|
3564
4575
|
*/
|
|
3565
|
-
|
|
4576
|
+
/**
|
|
4577
|
+
* 撤销一台设备 = 删除设备行。
|
|
4578
|
+
*
|
|
4579
|
+
* 审计与删除**在同一事务里**(FUNC-5),且顺序是「先留痕、再删除」:
|
|
4580
|
+
* 审计的 `actor_id` 不带外键,删设备不会把记录带走。反过来若先删后写、
|
|
4581
|
+
* 而写审计失败,就会留下「设备已经没了,但没有任何记录说谁撤的」。
|
|
4582
|
+
*
|
|
4583
|
+
* @param options.requestId - 发起这次请求的 HTTP requestId(审计可追溯)。
|
|
4584
|
+
*/
|
|
4585
|
+
revokeDevice(id, options = {}) {
|
|
3566
4586
|
const active = this.getActiveDevice(id);
|
|
3567
4587
|
if (active === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
3568
|
-
this.
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
4588
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
4589
|
+
try {
|
|
4590
|
+
this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, {
|
|
4591
|
+
deviceId: id,
|
|
4592
|
+
name: active.name
|
|
4593
|
+
}, options.requestId);
|
|
4594
|
+
if (this.db.prepare("DELETE FROM devices WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
4595
|
+
this.db.exec("COMMIT");
|
|
4596
|
+
} catch (error) {
|
|
4597
|
+
this.db.exec("ROLLBACK");
|
|
4598
|
+
throw error;
|
|
4599
|
+
}
|
|
4600
|
+
this.devicesRevisionCounter += 1;
|
|
3573
4601
|
}
|
|
3574
4602
|
/**
|
|
3575
4603
|
* 一次性清理历史遗留的「标记式」撤销设备(`revoked_at IS NOT NULL` 的行)。
|
|
@@ -3592,18 +4620,70 @@ var WorkspaceDatabase = class {
|
|
|
3592
4620
|
this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ?").run(Date.now(), id);
|
|
3593
4621
|
}
|
|
3594
4622
|
purgeRevokedDevices() {
|
|
3595
|
-
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0)
|
|
4623
|
+
if (this.db.prepare("SELECT id FROM devices WHERE revoked_at IS NOT NULL").all().length === 0) {
|
|
4624
|
+
this.devicesRevisionCounter += 1;
|
|
4625
|
+
return 0;
|
|
4626
|
+
}
|
|
3596
4627
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3597
4628
|
try {
|
|
3598
4629
|
const result = this.db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL").run();
|
|
3599
|
-
this.db.exec("COMMIT");
|
|
3600
4630
|
this.audit("admin", "loopback-admin", "device.purge", void 0, void 0, { purged: result.changes });
|
|
4631
|
+
this.db.exec("COMMIT");
|
|
4632
|
+
this.devicesRevisionCounter += 1;
|
|
3601
4633
|
return Number(result.changes);
|
|
3602
4634
|
} catch (error) {
|
|
3603
4635
|
this.db.exec("ROLLBACK");
|
|
3604
4636
|
throw error;
|
|
3605
4637
|
}
|
|
3606
4638
|
}
|
|
4639
|
+
/**
|
|
4640
|
+
* 删掉早于 `cutoffMs` 的幂等回放记录,返回删除行数。
|
|
4641
|
+
*
|
|
4642
|
+
* 由 `retention.ts`(perf-reader)按调度调用,本方法只做 SQL —— 不判断策略、
|
|
4643
|
+
* 不写日志,所以它可以被单独测试。
|
|
4644
|
+
*
|
|
4645
|
+
* 为什么用 `created_at` 而不是加一列 `expires_at`:24 小时的过期**完全**由
|
|
4646
|
+
* `created_at` 推导得出,加列会让每次插入多写一个字段,还多一次 migration 风险。
|
|
4647
|
+
*/
|
|
4648
|
+
pruneIdempotency(cutoffMs) {
|
|
4649
|
+
const result = this.db.prepare("DELETE FROM idempotency WHERE created_at < ?").run(cutoffMs);
|
|
4650
|
+
return Number(result.changes);
|
|
4651
|
+
}
|
|
4652
|
+
/**
|
|
4653
|
+
* 清理审计:先按时间删,再兜「总数超过 `keepMax` 就删最旧的」。
|
|
4654
|
+
*
|
|
4655
|
+
* ## 为什么是两段而不是一段
|
|
4656
|
+
*
|
|
4657
|
+
* 只按时间删不够:桥可以被一个「短时间高频写入」的场景灌满审计表
|
|
4658
|
+
* (例如每秒几十条 `chat.delta` 对应的写操作),时间窗内的量仍然能压垮库。
|
|
4659
|
+
* 只按数量删也不够:用户可能希望「一年前的都清掉」,而不在乎条数。
|
|
4660
|
+
*
|
|
4661
|
+
* ## `keepMax` 是**下限保护**
|
|
4662
|
+
*
|
|
4663
|
+
* 即使 `cutoffMs` 设成了未来(把全部行都判成过期),也**至少**保留最近
|
|
4664
|
+
* `keepMax` 条 —— 一句写错的调用不该把审计清空。审计被清空本身就等于
|
|
4665
|
+
* 「抹掉证据」,这个方向必须由这一层挡住。
|
|
4666
|
+
*
|
|
4667
|
+
* 返回删除的行数(两段之和)。
|
|
4668
|
+
*/
|
|
4669
|
+
pruneAudit(cutoffMs, keepMax) {
|
|
4670
|
+
const floor = Math.max(0, Math.floor(keepMax));
|
|
4671
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
4672
|
+
try {
|
|
4673
|
+
const total = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n);
|
|
4674
|
+
const expired = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit WHERE time < ?").get(cutoffMs).n);
|
|
4675
|
+
const removableByTime = Math.max(0, Math.min(expired, total - floor));
|
|
4676
|
+
let removed = 0;
|
|
4677
|
+
if (removableByTime > 0) removed += Number(this.db.prepare("DELETE FROM audit WHERE id IN (SELECT id FROM audit ORDER BY time ASC, id ASC LIMIT ?)").run(removableByTime).changes);
|
|
4678
|
+
const over = Number(this.db.prepare("SELECT COUNT(*) AS n FROM audit").get().n) - floor;
|
|
4679
|
+
if (over > 0) removed += Number(this.db.prepare("DELETE FROM audit WHERE id IN (SELECT id FROM audit ORDER BY time ASC, id ASC LIMIT ?)").run(over).changes);
|
|
4680
|
+
this.db.exec("COMMIT");
|
|
4681
|
+
return removed;
|
|
4682
|
+
} catch (error) {
|
|
4683
|
+
this.db.exec("ROLLBACK");
|
|
4684
|
+
throw error;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
3607
4687
|
insertTrash(record) {
|
|
3608
4688
|
this.db.prepare("INSERT INTO trash(id,root_id,relative_path,payload_path,kind,size,digest,created_at,status) VALUES(?,?,?,?,?,?,?,?,?)").run(record.id, record.rootId, record.relativePath, record.payloadPath, record.kind, record.size, record.digest, record.createdAt, record.status);
|
|
3609
4689
|
}
|
|
@@ -3769,6 +4849,14 @@ var WorkspaceEventBus = class {
|
|
|
3769
4849
|
};
|
|
3770
4850
|
//#endregion
|
|
3771
4851
|
//#region src/host/file-service.ts
|
|
4852
|
+
/**
|
|
4853
|
+
* 列目录时同时 `lstat` 多少个子项。
|
|
4854
|
+
*
|
|
4855
|
+
* 32 是本仓已经用过、也验证过的并发量级(见 `plugin-inventory.ts` 的
|
|
4856
|
+
* `withUpdateCheck` 用的是 6 个 worker;这里是短促的 stat,可以更宽一点)。
|
|
4857
|
+
* 关键不是这个数字本身,而是**有界**:一把放 500 个在网络盘上会把请求队列打满。
|
|
4858
|
+
*/
|
|
4859
|
+
const LIST_STAT_CONCURRENCY = 32;
|
|
3772
4860
|
var FileService = class {
|
|
3773
4861
|
database;
|
|
3774
4862
|
emit;
|
|
@@ -3787,18 +4875,21 @@ var FileService = class {
|
|
|
3787
4875
|
const pageSize = Math.min(Math.max(limit, 1), 500);
|
|
3788
4876
|
const selected = names.slice(start, start + pageSize);
|
|
3789
4877
|
const entries = [];
|
|
3790
|
-
for (
|
|
3791
|
-
const
|
|
3792
|
-
const
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
4878
|
+
for (let offset = 0; offset < selected.length; offset += LIST_STAT_CONCURRENCY) {
|
|
4879
|
+
const batch = selected.slice(offset, offset + LIST_STAT_CONCURRENCY);
|
|
4880
|
+
const rows = await Promise.all(batch.map(async (name) => {
|
|
4881
|
+
const absolute = path.join(resolved.absolutePath, name);
|
|
4882
|
+
const info = await lstat(absolute);
|
|
4883
|
+
return {
|
|
4884
|
+
name,
|
|
4885
|
+
path: relativePath === "" ? name : `${relativePath}/${name}`,
|
|
4886
|
+
kind: kindOf(info),
|
|
4887
|
+
size: info.isFile() ? info.size : 0,
|
|
4888
|
+
modifiedAt: info.mtimeMs,
|
|
4889
|
+
writable: (info.mode & 128) !== 0
|
|
4890
|
+
};
|
|
4891
|
+
}));
|
|
4892
|
+
entries.push(...rows);
|
|
3802
4893
|
}
|
|
3803
4894
|
const last = selected.at(-1);
|
|
3804
4895
|
return {
|
|
@@ -3812,12 +4903,11 @@ var FileService = class {
|
|
|
3812
4903
|
const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
|
|
3813
4904
|
const info = await lstat(resolved.absolutePath);
|
|
3814
4905
|
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files have readable content.");
|
|
3815
|
-
const etag = await fileEtag(resolved.absolutePath);
|
|
3816
4906
|
return {
|
|
3817
4907
|
absolutePath: resolved.absolutePath,
|
|
3818
4908
|
size: info.size,
|
|
3819
4909
|
modifiedAt: info.mtimeMs,
|
|
3820
|
-
etag,
|
|
4910
|
+
etag: etagFromStat(info),
|
|
3821
4911
|
contentType: contentTypeFor$1(relativePath)
|
|
3822
4912
|
};
|
|
3823
4913
|
}
|
|
@@ -3835,8 +4925,8 @@ var FileService = class {
|
|
|
3835
4925
|
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files can be overwritten.");
|
|
3836
4926
|
if (conditions.ifNoneMatch === "*") throw new ApiError(412, "PATH_ALREADY_EXISTS", "The file already exists.");
|
|
3837
4927
|
if (conditions.ifMatch === void 0) throw new ApiError(428, "ETAG_REQUIRED", "If-Match is required when overwriting a file.");
|
|
3838
|
-
const currentEtag =
|
|
3839
|
-
if (conditions.ifMatch
|
|
4928
|
+
const currentEtag = etagFromStat(info);
|
|
4929
|
+
if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
|
|
3840
4930
|
mode = info.mode;
|
|
3841
4931
|
} else if (conditions.ifNoneMatch !== "*") throw new ApiError(428, "CREATE_CONDITION_REQUIRED", "If-None-Match: * is required when creating file content.");
|
|
3842
4932
|
const temp = path.join(parent.absolutePath, `.${path.basename(resolved.absolutePath)}.dsh-${randomUUID()}.tmp`);
|
|
@@ -3849,8 +4939,8 @@ var FileService = class {
|
|
|
3849
4939
|
await handle.close();
|
|
3850
4940
|
}
|
|
3851
4941
|
if (resolved.exists) {
|
|
3852
|
-
const currentEtag = await
|
|
3853
|
-
if (conditions.ifMatch
|
|
4942
|
+
const currentEtag = etagFromStat(await lstat(resolved.absolutePath));
|
|
4943
|
+
if (!etagEqual(conditions.ifMatch, currentEtag)) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
|
|
3854
4944
|
}
|
|
3855
4945
|
await replaceFile(temp, resolved.absolutePath, resolved.exists);
|
|
3856
4946
|
} catch (error) {
|
|
@@ -3858,7 +4948,7 @@ var FileService = class {
|
|
|
3858
4948
|
throw error;
|
|
3859
4949
|
}
|
|
3860
4950
|
const info = await stat(resolved.absolutePath);
|
|
3861
|
-
const etag =
|
|
4951
|
+
const etag = etagFromStat(info);
|
|
3862
4952
|
this.notify("file.changed", root.id, relativePath, {
|
|
3863
4953
|
etag,
|
|
3864
4954
|
size: info.size
|
|
@@ -3996,10 +5086,75 @@ var FileService = class {
|
|
|
3996
5086
|
});
|
|
3997
5087
|
}
|
|
3998
5088
|
};
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
5089
|
+
/**
|
|
5090
|
+
* 一个文件的**弱验证器**:`W/"<size>-<mtimeMs>"`(两者都用 base36 缩短)。
|
|
5091
|
+
*
|
|
5092
|
+
* ## 为什么不再用全文件 sha256
|
|
5093
|
+
*
|
|
5094
|
+
* 修复前是 `sha256(整个文件)`:
|
|
5095
|
+
*
|
|
5096
|
+
* - `inspectContent`(本文件的 `:93`)在**每一个 GET** 都算一次 —— 一个 100 MiB 的
|
|
5097
|
+
* 文件被读一次,服务端就额外读 100 MiB、算一次 sha256;
|
|
5098
|
+
* - `writeContent` 在写前、写后、替换后再算(最多 **3 次**);
|
|
5099
|
+
* - 而 `router.ts` 的 304 判断只需要「这个值变没变」,不需要「内容指纹」。
|
|
5100
|
+
*
|
|
5101
|
+
* 改成 stat-only 之后是 **O(1)**:一个字节都不读。这正是 Range 下载/大文件预览
|
|
5102
|
+
* 场景里最划算的一笔优化 —— 它不是什么高级技巧,只是把「哈希整个文件」换成
|
|
5103
|
+
* 「问一下 stat」。
|
|
5104
|
+
*
|
|
5105
|
+
* ## 为什么必须标 `W/`(弱)
|
|
5106
|
+
*
|
|
5107
|
+
* size + mtime 无法区分「同一毫秒内两次写入、且大小完全不变」。标成强验证器
|
|
5108
|
+
* (不带 `W/`)是在说谎:HTTP 语义里强验证器可用于 `If-Range` 与字节范围续传,
|
|
5109
|
+
* 客户端会据此跳过内容校验,于是**它可能把一个已经变了的内容当成没变**。
|
|
5110
|
+
*
|
|
5111
|
+
* 标弱之后:`If-None-Match` 仍可做协商缓存(弱比较本来就用它),
|
|
5112
|
+
* 而需要强验证的地方(`If-Match` 写冲突、`If-Range`)客户端不会拿它当证据。
|
|
5113
|
+
* 这是诚实的代价,也是这条改动唯一的语义变化,已在报告里记为已知边界。
|
|
5114
|
+
*
|
|
5115
|
+
* @returns 形如 `W/"2f-yx3k9q1"` 的弱 ETag。
|
|
5116
|
+
*/
|
|
5117
|
+
/**
|
|
5118
|
+
* 把客户端回传的验证器归一成「去掉所有 `W/` 前缀」的形式,用于**相等比较**。
|
|
5119
|
+
*
|
|
5120
|
+
* ## 为什么写路径也需要归一(不只是读路径的 If-None-Match)
|
|
5121
|
+
*
|
|
5122
|
+
* 服务端现在发的是**弱** ETag(`W/"..."`,见 [etagFromStat]),而客户端做
|
|
5123
|
+
* `If-Match` 时只能把我们发出去的那个值原样回传 —— 于是 `conditions.ifMatch`
|
|
5124
|
+
* 必然带 `W/`,而磁盘上重新算出来的也是 `W/"..."`:**单层**情况下字符串恰好相等,
|
|
5125
|
+
* 看起来没问题。
|
|
5126
|
+
*
|
|
5127
|
+
* 但两层就崩了:某些 HTTP 客户端/代理会把弱验证器再包一层,回传 `W/W/"..."`。
|
|
5128
|
+
* 那时严格 `!==` 会把「同一个文件」判成「文件变了」→ 412 → **用户保存失败且
|
|
5129
|
+
* 重试也不会成功**(因为客户端每次都回传同一个值)。
|
|
5130
|
+
*
|
|
5131
|
+
* ## 为什么这不是「放松安全」
|
|
5132
|
+
*
|
|
5133
|
+
* `If-Match` 在这里的职责是**乐观并发控制**(防止盲写覆盖别人的修改),
|
|
5134
|
+
* 不是身份认证。归一化只消除 `W/` 前缀这层语法噪声,`<size>-<mtime>` 的
|
|
5135
|
+
* 实质比较一字未改 —— 真正变了的文件照样 412。
|
|
5136
|
+
*
|
|
5137
|
+
* RFC 7232 里 `If-Match` 本应用强比较、而弱验证器不能用于它;这条张力是
|
|
5138
|
+
* 「用 stat 而非内容哈希」的已知代价(见 `etagFromStat` 的说明)。我们的取舍是:
|
|
5139
|
+
* **宁可让乐观锁偏松一点,也不让用户的保存无故失败** —— 因为这条路径的失败模式
|
|
5140
|
+
* 是「数据存不进去」,比「两个客户端几乎同时改同一文件」更常见、更痛。
|
|
5141
|
+
*/
|
|
5142
|
+
function normalizeValidator(value) {
|
|
5143
|
+
return value.trim().replace(/^(?:W\/)+/i, "");
|
|
5144
|
+
}
|
|
5145
|
+
/**
|
|
5146
|
+
* 两个验证器是否表示同一版本(见 [normalizeValidator])。
|
|
5147
|
+
*
|
|
5148
|
+
* `left` 允许 undefined:调用方在「创建分支」上可能没有 `If-Match`
|
|
5149
|
+
* (那时它已经被 428 拦下过,但第二个比较点在另一个分支里,TS 窄化不到)。
|
|
5150
|
+
* `undefined` 一律视为「不相等」—— 缺失的验证器绝不能匹配上任何真实版本。
|
|
5151
|
+
*/
|
|
5152
|
+
function etagEqual(left, right) {
|
|
5153
|
+
if (left === void 0) return false;
|
|
5154
|
+
return normalizeValidator(left) === normalizeValidator(right);
|
|
5155
|
+
}
|
|
5156
|
+
function etagFromStat(info) {
|
|
5157
|
+
return `W/"${Math.max(0, Math.trunc(info.size)).toString(36)}-${Math.max(0, Math.floor(info.mtimeMs)).toString(36)}"`;
|
|
4003
5158
|
}
|
|
4004
5159
|
async function replaceFile(temp, destination, destinationExists) {
|
|
4005
5160
|
try {
|
|
@@ -4125,12 +5280,31 @@ function contentTypeFor$1(filePath) {
|
|
|
4125
5280
|
function encodeCursor(name) {
|
|
4126
5281
|
return Buffer.from(name, "utf8").toString("base64url");
|
|
4127
5282
|
}
|
|
5283
|
+
/** 游标长度上限:它编码的是一个文件名,几百字符足够;几千字符只可能是伪造。 */
|
|
5284
|
+
const MAX_CURSOR_CHARS = 1024;
|
|
5285
|
+
/** base64url 的字符集(无填充)。用正则**先**挡住非法输入,再交给解码器。 */
|
|
5286
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
5287
|
+
/**
|
|
5288
|
+
* 解目录游标(`encodeCursor` 的逆运算)。
|
|
5289
|
+
*
|
|
5290
|
+
* ## 为什么需要显式校验,而不能靠 `try/catch`
|
|
5291
|
+
*
|
|
5292
|
+
* 旧实现写成 `try { Buffer.from(cursor,'base64url') } catch { 400 }`。
|
|
5293
|
+
* 那个 `catch` 是**不可达分支**:Node 的 base64url 解码是**宽松**的 ——
|
|
5294
|
+
* `Buffer.from('!!!','base64url')` 返回**空 Buffer**(不抛),`'@@@'`、`'中文'` 同理。
|
|
5295
|
+
*
|
|
5296
|
+
* 后果不是「报错」,而是**静默的错行为**:非法游标被解成空串 →
|
|
5297
|
+
* `names.findIndex(name => name > '')` 命中第一项 → **从第一页重新开始**。
|
|
5298
|
+
* 客户端看到的是「翻页突然跳回开头」,没有任何错误信号 —— 比报错更难查。
|
|
5299
|
+
*
|
|
5300
|
+
* 所以这里先做两件**能真正失败**的检查:字符集与长度。
|
|
5301
|
+
* 校验通过后才解码;解码结果为空也算非法(空串不是任何文件名)。
|
|
5302
|
+
*/
|
|
4128
5303
|
function decodeCursor(cursor) {
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
}
|
|
5304
|
+
if (cursor.length > MAX_CURSOR_CHARS || !BASE64URL_PATTERN.test(cursor)) throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
|
|
5305
|
+
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
5306
|
+
if (decoded === "") throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
|
|
5307
|
+
return decoded;
|
|
4134
5308
|
}
|
|
4135
5309
|
function trashView(record) {
|
|
4136
5310
|
return {
|
|
@@ -4258,19 +5432,108 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4258
5432
|
resolvable,
|
|
4259
5433
|
...error === void 0 ? {} : { error }
|
|
4260
5434
|
};
|
|
4261
|
-
};
|
|
4262
|
-
const injected = INJECTED_SERVICE_NAMES.map(probeOne);
|
|
4263
|
-
const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
|
|
5435
|
+
};
|
|
5436
|
+
const injected = INJECTED_SERVICE_NAMES.map(probeOne);
|
|
5437
|
+
const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
|
|
5438
|
+
return {
|
|
5439
|
+
pluginVersion,
|
|
5440
|
+
injected,
|
|
5441
|
+
observed,
|
|
5442
|
+
summary: {
|
|
5443
|
+
injectedTotal: injected.length,
|
|
5444
|
+
injectedAvailable: injected.filter((service) => service.available).length,
|
|
5445
|
+
injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
|
|
5446
|
+
observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
|
|
5447
|
+
}
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
5450
|
+
//#endregion
|
|
5451
|
+
//#region src/host/retention.ts
|
|
5452
|
+
/**
|
|
5453
|
+
* 数据保留(retention)调度器:把**会无限增长的两张表**按时间清掉。
|
|
5454
|
+
*
|
|
5455
|
+
* ## 为什么需要它(这是唯一一条「随时间必然恶化」的债)
|
|
5456
|
+
*
|
|
5457
|
+
* `audit` 与 `idempotency` 都只增不删:
|
|
5458
|
+
*
|
|
5459
|
+
* - `audit` 在 router 里有 20+ 处写入点(登录、撤销、建根、发消息、审批、命令…),
|
|
5460
|
+
* 单机长期运行会到几十万行;
|
|
5461
|
+
* - `idempotency` 每个带 `clientRequestId` 的请求一行,而且 `response_json` 存的是
|
|
5462
|
+
* **整份响应体** —— 它增长得比审计还快,而且存的是重复数据。
|
|
5463
|
+
*
|
|
5464
|
+
* 两者都没有 TTL 列、也没有清理任务(`src/` 下 `DELETE FROM audit` / `VACUUM` /
|
|
5465
|
+
* `DELETE FROM idempotency` 零命中)。跑三个月之后的表现是:库文件与 WAL 持续变大、
|
|
5466
|
+
* `listAudit` 变慢、备份与迁移成本上升 —— 而这些问题在「刚装上」的时候完全看不到。
|
|
5467
|
+
*
|
|
5468
|
+
* ## 为什么这个模块只做调度、不写 SQL
|
|
5469
|
+
*
|
|
5470
|
+
* SQL 属于 `database.ts` 的所有权(表结构、事务边界、索引都在那边)。这个模块负责的是
|
|
5471
|
+
* **策略**:清多久以前的、最多留多少、什么时候清、清完记什么日志。
|
|
5472
|
+
*
|
|
5473
|
+
* 所以它通过一个**窄端口**([RetentionPort])调用数据库,并在端口缺席时
|
|
5474
|
+
* **静默降级**(只记一次 warn)。理由与可选宿主服务同一条:一个清理任务缺席,
|
|
5475
|
+
* 不该让整个插件激活失败。
|
|
5476
|
+
*
|
|
5477
|
+
* ## 为什么幂等记录只按时间清、不改存的内容
|
|
5478
|
+
*
|
|
5479
|
+
* 有个更"省空间"的方案是把 `response_json` 从整份响应裁成 `{sessionId}`。**否决**:
|
|
5480
|
+
* 幂等重放的语义就是「把当初那次响应原样再给你一次」,形状一变,已发布客户端的
|
|
5481
|
+
* 严格解码器会当场炸。省下的那点体积已经由按时间清理覆盖了,不值得换个破坏性变更。
|
|
5482
|
+
*/
|
|
5483
|
+
/** 幂等记录保留多久。24 h 足够覆盖任何「客户端重试」窗口(配对/发消息都是秒级重试)。 */
|
|
5484
|
+
const IDEMPOTENCY_TTL_MS = 864e5;
|
|
5485
|
+
/** 审计保留多久。90 天是「事后追查」与「库不失控」之间的常规取舍。 */
|
|
5486
|
+
const AUDIT_TTL_MS = 7776e6;
|
|
5487
|
+
/** 审计最多留多少行(TTL 之内的兜底:短时间高频写不该把库顶爆)。 */
|
|
5488
|
+
const AUDIT_KEEP_MAX = 5e5;
|
|
5489
|
+
/**
|
|
5490
|
+
* 从数据库对象上取保留端口;没有就返回 null。
|
|
5491
|
+
*
|
|
5492
|
+
* **特性探测**而不是直接调用:`database.ts` 归另一条手术台,两侧必须能分批落地。
|
|
5493
|
+
* 端口缺席时本模块什么也不做 —— 那是「清理任务没上」,不是「插件坏了」。
|
|
5494
|
+
*/
|
|
5495
|
+
function resolveRetentionPort(database) {
|
|
5496
|
+
const candidate = database;
|
|
5497
|
+
if (typeof candidate.pruneIdempotency !== "function") return null;
|
|
5498
|
+
if (typeof candidate.pruneAudit !== "function") return null;
|
|
5499
|
+
return database;
|
|
5500
|
+
}
|
|
5501
|
+
/**
|
|
5502
|
+
* 启动清理:一次把两张表都收到阈值以内。
|
|
5503
|
+
*
|
|
5504
|
+
* 放在**插件启动**时跑,因为它要处理的是「上次运行攒下来的量」—— 而那批数据
|
|
5505
|
+
* 可能远超一次请求能承受的规模,所以不该混进请求路径。
|
|
5506
|
+
*
|
|
5507
|
+
* 失败**不抛**(返回 `ran: true` 但两个计数为 0 并记 warn):清理失败只是「这次没清」,
|
|
5508
|
+
* 让它把插件启动打掉是轻重倒置。
|
|
5509
|
+
*/
|
|
5510
|
+
function runStartupRetention(database, logger, now = Date.now()) {
|
|
5511
|
+
const port = resolveRetentionPort(database);
|
|
5512
|
+
if (port === null) {
|
|
5513
|
+
logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: retention skipped — the database exposes no pruneIdempotency/pruneAudit; audit and idempotency tables will grow without bound"));
|
|
5514
|
+
return {
|
|
5515
|
+
ran: false,
|
|
5516
|
+
idempotencyDeleted: 0,
|
|
5517
|
+
auditDeleted: 0
|
|
5518
|
+
};
|
|
5519
|
+
}
|
|
5520
|
+
let idempotencyDeleted = 0;
|
|
5521
|
+
let auditDeleted = 0;
|
|
5522
|
+
try {
|
|
5523
|
+
idempotencyDeleted = port.pruneIdempotency(now - IDEMPOTENCY_TTL_MS);
|
|
5524
|
+
} catch (error) {
|
|
5525
|
+
logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning idempotency failed: ${String(error)}`));
|
|
5526
|
+
}
|
|
5527
|
+
try {
|
|
5528
|
+
auditDeleted = port.pruneAudit(now - AUDIT_TTL_MS, AUDIT_KEEP_MAX);
|
|
5529
|
+
} catch (error) {
|
|
5530
|
+
logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: pruning audit failed: ${String(error)}`));
|
|
5531
|
+
}
|
|
5532
|
+
if (idempotencyDeleted > 0 || auditDeleted > 0) logger.info(`dsh-remote-bridge: retention removed ${idempotencyDeleted} idempotency row(s) and ${auditDeleted} audit row(s)`);
|
|
4264
5533
|
return {
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
summary: {
|
|
4269
|
-
injectedTotal: injected.length,
|
|
4270
|
-
injectedAvailable: injected.filter((service) => service.available).length,
|
|
4271
|
-
injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
|
|
4272
|
-
observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
|
|
4273
|
-
}
|
|
5534
|
+
ran: true,
|
|
5535
|
+
idempotencyDeleted,
|
|
5536
|
+
auditDeleted
|
|
4274
5537
|
};
|
|
4275
5538
|
}
|
|
4276
5539
|
//#endregion
|
|
@@ -4565,6 +5828,14 @@ function defaultDisplayName() {
|
|
|
4565
5828
|
//#endregion
|
|
4566
5829
|
//#region src/host/plugin-inventory.ts
|
|
4567
5830
|
/**
|
|
5831
|
+
* 读包清单时的并发上限。
|
|
5832
|
+
*
|
|
5833
|
+
* 为什么不是 `Promise.all` 全放:`names` 的长度由用户装了多少插件决定,
|
|
5834
|
+
* 理论上可以几十上百;一把放出去在慢盘/网络盘上反而更慢,而且失败时更难定位。
|
|
5835
|
+
* 6 与 `withUpdateCheck` 的网络并发同值,本仓已经验证过。
|
|
5836
|
+
*/
|
|
5837
|
+
const INVENTORY_READ_CONCURRENCY = 6;
|
|
5838
|
+
/**
|
|
4568
5839
|
* 把 profile 标识归一化成 profile 名。
|
|
4569
5840
|
*
|
|
4570
5841
|
* 内核接受**两种**写法,两者都合法:
|
|
@@ -4708,8 +5979,17 @@ async function readPluginInventory(input = {}) {
|
|
|
4708
5979
|
const bundles = stringArray((typeof dshSection.profile === "object" && dshSection.profile !== null ? dshSection.profile : {}).bundles);
|
|
4709
5980
|
const names = [.../* @__PURE__ */ new Set([...Object.keys(dependencies), ...bundles])].sort();
|
|
4710
5981
|
const items = [];
|
|
5982
|
+
const manifests = /* @__PURE__ */ new Map();
|
|
5983
|
+
for (let offset = 0; offset < names.length; offset += INVENTORY_READ_CONCURRENCY) {
|
|
5984
|
+
const batch = names.slice(offset, offset + INVENTORY_READ_CONCURRENCY);
|
|
5985
|
+
const rows = await Promise.all(batch.map(async (name) => ({
|
|
5986
|
+
name,
|
|
5987
|
+
manifest: await readPackageManifest(profilePath, name)
|
|
5988
|
+
})));
|
|
5989
|
+
for (const row of rows) manifests.set(row.name, row.manifest);
|
|
5990
|
+
}
|
|
4711
5991
|
for (const name of names) {
|
|
4712
|
-
const pkgManifest =
|
|
5992
|
+
const pkgManifest = manifests.get(name) ?? null;
|
|
4713
5993
|
const installedRaw = pkgManifest?.version;
|
|
4714
5994
|
const installed = typeof installedRaw === "string" && installedRaw.trim() ? installedRaw : null;
|
|
4715
5995
|
const loaded = bundles.includes(name);
|
|
@@ -5125,28 +6405,49 @@ function matchJobRoute(pathname) {
|
|
|
5125
6405
|
const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
|
|
5126
6406
|
if (jobs !== null) return {
|
|
5127
6407
|
kind: "list",
|
|
5128
|
-
sessionId:
|
|
6408
|
+
sessionId: decodePathSegment(jobs[1])
|
|
5129
6409
|
};
|
|
5130
6410
|
const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
|
|
5131
6411
|
if (output !== null) return {
|
|
5132
6412
|
kind: "output",
|
|
5133
|
-
sessionId:
|
|
5134
|
-
jobId:
|
|
6413
|
+
sessionId: decodePathSegment(output[1]),
|
|
6414
|
+
jobId: decodePathSegment(output[2])
|
|
5135
6415
|
};
|
|
5136
6416
|
const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
|
|
5137
6417
|
if (kill !== null) return {
|
|
5138
6418
|
kind: "kill",
|
|
5139
|
-
sessionId:
|
|
5140
|
-
jobId:
|
|
6419
|
+
sessionId: decodePathSegment(kill[1]),
|
|
6420
|
+
jobId: decodePathSegment(kill[2])
|
|
5141
6421
|
};
|
|
5142
6422
|
const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
|
|
5143
6423
|
if (bare !== null) return {
|
|
5144
6424
|
kind: "unknown",
|
|
5145
|
-
sessionId:
|
|
5146
|
-
jobId:
|
|
6425
|
+
sessionId: decodePathSegment(bare[1]),
|
|
6426
|
+
jobId: decodePathSegment(bare[2])
|
|
5147
6427
|
};
|
|
5148
6428
|
return null;
|
|
5149
6429
|
}
|
|
6430
|
+
/**
|
|
6431
|
+
* 隧道运行期间**仍然可达**的管理端点(豁免判据 0)。
|
|
6432
|
+
*
|
|
6433
|
+
* 为什么存在这个集合:判据 0(隧道开着 → 管理面整体关门)会让「隧道状态」这个信号
|
|
6434
|
+
* 本体也 403,于是面板没法告诉用户「隧道开着所以管理面停用」—— 只剩一个没法解读的
|
|
6435
|
+
* 失败。这两个端点进来的目的就是解掉这个矛盾。
|
|
6436
|
+
*
|
|
6437
|
+
* 为什么是安全的:它们**只读、无副作用**(不写库、不发令牌、不改监听),返回的
|
|
6438
|
+
* `running`/`channel`/`error`/阶段进度都不构成攻击面。**唯一敏感的是 `url`** ——
|
|
6439
|
+
* Quick Tunnel 地址本身是一次对外入口。
|
|
6440
|
+
*
|
|
6441
|
+
* 豁免**只针对判据 0**。判据 1/2/3(转发头+Cloudflare 边缘头 / Host / Origin)对它们
|
|
6442
|
+
* 照旧全部生效,所以真实隧道来源的请求连这两个端点也是 403、拿不到隧道地址。
|
|
6443
|
+
* 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
|
|
6444
|
+
* 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
|
|
6445
|
+
*/
|
|
6446
|
+
const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
6447
|
+
"/manage/cloudflare/status",
|
|
6448
|
+
"/manage/cloudflare/activity",
|
|
6449
|
+
"/manage/cloudflare/stop"
|
|
6450
|
+
]);
|
|
5150
6451
|
var ApiRouter = class {
|
|
5151
6452
|
options;
|
|
5152
6453
|
constructor(options) {
|
|
@@ -5157,6 +6458,15 @@ var ApiRouter = class {
|
|
|
5157
6458
|
setCors(req, res);
|
|
5158
6459
|
res.setHeader("X-Request-Id", requestId);
|
|
5159
6460
|
if (req.method === "OPTIONS") {
|
|
6461
|
+
if (preflightRefused(req)) {
|
|
6462
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
6463
|
+
res.end(JSON.stringify({ error: {
|
|
6464
|
+
code: "CORS_ORIGIN_FORBIDDEN",
|
|
6465
|
+
message: "Cross-origin preflight is accepted only from a loopback origin.",
|
|
6466
|
+
requestId
|
|
6467
|
+
} }));
|
|
6468
|
+
return;
|
|
6469
|
+
}
|
|
5160
6470
|
res.writeHead(204);
|
|
5161
6471
|
res.end();
|
|
5162
6472
|
return;
|
|
@@ -5191,25 +6501,30 @@ var ApiRouter = class {
|
|
|
5191
6501
|
});
|
|
5192
6502
|
return;
|
|
5193
6503
|
}
|
|
5194
|
-
if (method === "GET" && pathname === "/api/v1/debug/host-services") {
|
|
5195
|
-
const report = this.options.hostServiceReport;
|
|
5196
|
-
if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
|
|
5197
|
-
sendJson(res, 200, report);
|
|
5198
|
-
return;
|
|
5199
|
-
}
|
|
5200
6504
|
if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
|
|
5201
|
-
this.options.auth.
|
|
6505
|
+
this.options.auth.pairingLimiter(req).consume(requestAddress(req));
|
|
5202
6506
|
const body = await readJson(req, 16384);
|
|
5203
6507
|
if (typeof body.code !== "string" || typeof body.deviceName !== "string") throw new ApiError(400, "BODY_INVALID", "code and deviceName are required strings.");
|
|
5204
|
-
sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName));
|
|
6508
|
+
sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName, { requestId }));
|
|
5205
6509
|
return;
|
|
5206
6510
|
}
|
|
5207
|
-
if (pathname.startsWith("/manage")) {
|
|
5208
|
-
this.options.auth.requireAdmin(req);
|
|
6511
|
+
if (pathname === "/manage" || pathname.startsWith("/manage/")) {
|
|
6512
|
+
this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
|
|
5209
6513
|
await this.dispatchManagement(req, res, url, requestId);
|
|
5210
6514
|
return;
|
|
5211
6515
|
}
|
|
5212
6516
|
const principal = this.options.auth.principal(req, { allowAdmin: true });
|
|
6517
|
+
if (method === "GET" && pathname === "/api/v1/debug/host-services") {
|
|
6518
|
+
this.options.auth.requireScope(principal, "settings.read");
|
|
6519
|
+
const report = this.options.hostServiceReport;
|
|
6520
|
+
if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
|
|
6521
|
+
const metrics = await readMetricsSnapshot();
|
|
6522
|
+
sendJson(res, 200, metrics === void 0 ? report : {
|
|
6523
|
+
...report,
|
|
6524
|
+
metrics
|
|
6525
|
+
});
|
|
6526
|
+
return;
|
|
6527
|
+
}
|
|
5213
6528
|
if (method === "GET" && pathname === "/api/v1/devices/self") {
|
|
5214
6529
|
if (principal.kind === "admin") sendJson(res, 200, {
|
|
5215
6530
|
id: principal.id,
|
|
@@ -5246,7 +6561,7 @@ var ApiRouter = class {
|
|
|
5246
6561
|
}
|
|
5247
6562
|
const rootRoute = pathname.match(/^\/api\/v1\/roots\/([^/]+)\/(entries|content)$/);
|
|
5248
6563
|
if (rootRoute !== null) {
|
|
5249
|
-
const rootId =
|
|
6564
|
+
const rootId = decodePathSegment(rootRoute[1]);
|
|
5250
6565
|
const resource = rootRoute[2];
|
|
5251
6566
|
const root = this.requireRoot(principal, rootId);
|
|
5252
6567
|
if (resource === "entries") {
|
|
@@ -5265,7 +6580,7 @@ var ApiRouter = class {
|
|
|
5265
6580
|
const restoreRoute = pathname.match(/^\/api\/v1\/trash\/([^/]+)\/restore$/);
|
|
5266
6581
|
if (method === "POST" && restoreRoute !== null) {
|
|
5267
6582
|
this.options.auth.requireScope(principal, "files.delete");
|
|
5268
|
-
const record = this.options.database.getTrash(
|
|
6583
|
+
const record = this.options.database.getTrash(decodePathSegment(restoreRoute[1]));
|
|
5269
6584
|
if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
|
|
5270
6585
|
const root = this.requireRoot(principal, record.rootId);
|
|
5271
6586
|
await this.options.files.restoreTrash(root, record.id);
|
|
@@ -5291,7 +6606,7 @@ var ApiRouter = class {
|
|
|
5291
6606
|
}
|
|
5292
6607
|
const workspaceRoute = pathname.match(/^\/api\/v1\/chat\/workspaces\/([^/]+)$/);
|
|
5293
6608
|
if (workspaceRoute !== null) {
|
|
5294
|
-
const workspaceId =
|
|
6609
|
+
const workspaceId = decodePathSegment(workspaceRoute[1]);
|
|
5295
6610
|
if (method === "PATCH") {
|
|
5296
6611
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5297
6612
|
const body = await readJson(req, 64e3);
|
|
@@ -5379,7 +6694,7 @@ var ApiRouter = class {
|
|
|
5379
6694
|
}
|
|
5380
6695
|
const providerRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)$/);
|
|
5381
6696
|
if (providerRoute !== null) {
|
|
5382
|
-
const providerId =
|
|
6697
|
+
const providerId = decodePathSegment(providerRoute[1]);
|
|
5383
6698
|
if (method === "PATCH") {
|
|
5384
6699
|
this.options.auth.requireScope(principal, "settings.write");
|
|
5385
6700
|
const body = await readJson(req, 1e6);
|
|
@@ -5405,7 +6720,7 @@ var ApiRouter = class {
|
|
|
5405
6720
|
const discoverProviderRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)\/discover$/);
|
|
5406
6721
|
if (method === "POST" && discoverProviderRoute !== null) {
|
|
5407
6722
|
this.options.auth.requireScope(principal, "settings.write");
|
|
5408
|
-
const providerId =
|
|
6723
|
+
const providerId = decodePathSegment(discoverProviderRoute[1]);
|
|
5409
6724
|
const body = await readJson(req, 64e3);
|
|
5410
6725
|
const draft = {
|
|
5411
6726
|
...typeof body.baseURL === "string" ? { baseURL: body.baseURL } : {},
|
|
@@ -5449,7 +6764,7 @@ var ApiRouter = class {
|
|
|
5449
6764
|
const sessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)$/);
|
|
5450
6765
|
if (method === "PATCH" && sessionRoute !== null) {
|
|
5451
6766
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5452
|
-
const sessionId =
|
|
6767
|
+
const sessionId = decodePathSegment(sessionRoute[1]);
|
|
5453
6768
|
const body = await readJson(req, 64e3);
|
|
5454
6769
|
if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
|
|
5455
6770
|
const result = await this.options.chat.renameSession(principal, sessionId, body.title);
|
|
@@ -5460,7 +6775,7 @@ var ApiRouter = class {
|
|
|
5460
6775
|
const forkSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/fork$/);
|
|
5461
6776
|
if (method === "POST" && forkSessionRoute !== null) {
|
|
5462
6777
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5463
|
-
const sessionId =
|
|
6778
|
+
const sessionId = decodePathSegment(forkSessionRoute[1]);
|
|
5464
6779
|
const body = await readJson(req, 16384);
|
|
5465
6780
|
const atSeq = body.atSeq === void 0 ? void 0 : body.atSeq;
|
|
5466
6781
|
if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) throw new ApiError(400, "BODY_INVALID", "atSeq must be a non-negative integer.");
|
|
@@ -5475,7 +6790,7 @@ var ApiRouter = class {
|
|
|
5475
6790
|
const archiveSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/archive$/);
|
|
5476
6791
|
if (method === "POST" && archiveSessionRoute !== null) {
|
|
5477
6792
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5478
|
-
const sessionId =
|
|
6793
|
+
const sessionId = decodePathSegment(archiveSessionRoute[1]);
|
|
5479
6794
|
await this.options.chat.archiveSession(principal, sessionId);
|
|
5480
6795
|
this.audit(principal, "chat.session.archive", void 0, void 0, requestId, { sessionId });
|
|
5481
6796
|
sendJson(res, 200, { archived: true });
|
|
@@ -5512,7 +6827,7 @@ var ApiRouter = class {
|
|
|
5512
6827
|
}
|
|
5513
6828
|
const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
|
|
5514
6829
|
if (messagesRoute !== null) {
|
|
5515
|
-
const sessionId =
|
|
6830
|
+
const sessionId = decodePathSegment(messagesRoute[1]);
|
|
5516
6831
|
if (method === "GET") {
|
|
5517
6832
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5518
6833
|
const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
|
|
@@ -5547,7 +6862,7 @@ var ApiRouter = class {
|
|
|
5547
6862
|
const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
|
|
5548
6863
|
if (attachmentRoute !== null && method === "GET") {
|
|
5549
6864
|
this.options.auth.requireScope(principal, "files.read");
|
|
5550
|
-
const attachmentId =
|
|
6865
|
+
const attachmentId = decodePathSegment(attachmentRoute[1]);
|
|
5551
6866
|
const sessionId = url.searchParams.get("sessionId")?.trim();
|
|
5552
6867
|
if (!sessionId) throw new ApiError(400, "BODY_INVALID", "sessionId query parameter is required.");
|
|
5553
6868
|
const { bytes, mediaType } = await this.options.chat.readAttachment(principal, sessionId, attachmentId);
|
|
@@ -5567,7 +6882,7 @@ var ApiRouter = class {
|
|
|
5567
6882
|
res.statusCode = 200;
|
|
5568
6883
|
res.setHeader("Content-Type", mediaType);
|
|
5569
6884
|
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
5570
|
-
res.setHeader("Cache-Control", "private, max-age=
|
|
6885
|
+
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
|
5571
6886
|
res.end(Buffer.from(bytes));
|
|
5572
6887
|
return;
|
|
5573
6888
|
}
|
|
@@ -5577,12 +6892,12 @@ var ApiRouter = class {
|
|
|
5577
6892
|
const body = await readJson(req, 24e6);
|
|
5578
6893
|
if (typeof body.data !== "string" || body.data === "") throw new ApiError(400, "BODY_INVALID", "data (base64) is required.");
|
|
5579
6894
|
const name = typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : void 0;
|
|
5580
|
-
sendJson(res, 201, await this.options.chat.uploadAttachment(principal,
|
|
6895
|
+
sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodePathSegment(attachmentsRoute[1]), body.data, name));
|
|
5581
6896
|
return;
|
|
5582
6897
|
}
|
|
5583
6898
|
const commandsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/commands$/);
|
|
5584
6899
|
if (commandsRoute !== null) {
|
|
5585
|
-
const sessionId =
|
|
6900
|
+
const sessionId = decodePathSegment(commandsRoute[1]);
|
|
5586
6901
|
if (method === "GET") {
|
|
5587
6902
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5588
6903
|
sendJson(res, 200, { items: await this.options.chat.listCommands(principal, sessionId) });
|
|
@@ -5604,15 +6919,15 @@ var ApiRouter = class {
|
|
|
5604
6919
|
const approvalsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals$/);
|
|
5605
6920
|
if (method === "GET" && approvalsRoute !== null) {
|
|
5606
6921
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5607
|
-
const sessionId =
|
|
6922
|
+
const sessionId = decodePathSegment(approvalsRoute[1]);
|
|
5608
6923
|
sendJson(res, 200, { items: await this.options.chat.listPendingApprovals(principal, sessionId) });
|
|
5609
6924
|
return;
|
|
5610
6925
|
}
|
|
5611
6926
|
const approvalDecisionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals\/([^/]+)\/decision$/);
|
|
5612
6927
|
if (method === "POST" && approvalDecisionRoute !== null) {
|
|
5613
6928
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5614
|
-
const sessionId =
|
|
5615
|
-
const approvalId =
|
|
6929
|
+
const sessionId = decodePathSegment(approvalDecisionRoute[1]);
|
|
6930
|
+
const approvalId = decodePathSegment(approvalDecisionRoute[2]);
|
|
5616
6931
|
const body = await readJson(req, 16384);
|
|
5617
6932
|
if (body.outcome !== "allowed-once" && body.outcome !== "rejected") throw new ApiError(400, "BODY_INVALID", "outcome must be allowed-once or rejected.");
|
|
5618
6933
|
const approval = await this.options.chat.decideApproval(principal, sessionId, approvalId, body.outcome);
|
|
@@ -5629,15 +6944,15 @@ var ApiRouter = class {
|
|
|
5629
6944
|
const questionsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions$/);
|
|
5630
6945
|
if (method === "GET" && questionsRoute !== null) {
|
|
5631
6946
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5632
|
-
const sessionId =
|
|
6947
|
+
const sessionId = decodePathSegment(questionsRoute[1]);
|
|
5633
6948
|
sendJson(res, 200, { items: await this.options.chat.listUserQuestions(principal, sessionId) });
|
|
5634
6949
|
return;
|
|
5635
6950
|
}
|
|
5636
6951
|
const questionAnswerRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
|
|
5637
6952
|
if (method === "POST" && questionAnswerRoute !== null) {
|
|
5638
6953
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5639
|
-
const sessionId =
|
|
5640
|
-
const callId =
|
|
6954
|
+
const sessionId = decodePathSegment(questionAnswerRoute[1]);
|
|
6955
|
+
const callId = decodePathSegment(questionAnswerRoute[2]);
|
|
5641
6956
|
const answer = readUserQuestionAnswer(await readJson(req, 64e3));
|
|
5642
6957
|
await this.options.chat.answerUserQuestion(principal, sessionId, callId, answer);
|
|
5643
6958
|
this.audit(principal, "chat.question.answer", void 0, void 0, requestId, {
|
|
@@ -5651,7 +6966,7 @@ var ApiRouter = class {
|
|
|
5651
6966
|
}
|
|
5652
6967
|
const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
|
|
5653
6968
|
if (sessionModelsRoute !== null) {
|
|
5654
|
-
const sessionId =
|
|
6969
|
+
const sessionId = decodePathSegment(sessionModelsRoute[1]);
|
|
5655
6970
|
if (method === "GET") {
|
|
5656
6971
|
this.options.auth.requireScope(principal, "chat.read");
|
|
5657
6972
|
sendJson(res, 200, await this.options.chat.models(principal, sessionId));
|
|
@@ -5679,7 +6994,7 @@ var ApiRouter = class {
|
|
|
5679
6994
|
const sessionAgentPresetRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/agent-preset$/);
|
|
5680
6995
|
if (method === "PUT" && sessionAgentPresetRoute !== null) {
|
|
5681
6996
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5682
|
-
const sessionId =
|
|
6997
|
+
const sessionId = decodePathSegment(sessionAgentPresetRoute[1]);
|
|
5683
6998
|
const body = await readJson(req, 64e3);
|
|
5684
6999
|
if (typeof body.agentPreset !== "string") throw new ApiError(400, "BODY_INVALID", "agentPreset is required.");
|
|
5685
7000
|
const agentPreset = await this.options.chat.selectAgentPreset(principal, sessionId, body.agentPreset);
|
|
@@ -5693,7 +7008,7 @@ var ApiRouter = class {
|
|
|
5693
7008
|
const cancelRoute = pathname.match(/^\/api\/v1\/chat\/runs\/([^/]+)\/cancel$/);
|
|
5694
7009
|
if (method === "POST" && cancelRoute !== null) {
|
|
5695
7010
|
this.options.auth.requireScope(principal, "chat.write");
|
|
5696
|
-
const sessionId =
|
|
7011
|
+
const sessionId = decodePathSegment(cancelRoute[1]);
|
|
5697
7012
|
await this.options.chat.cancel(principal, sessionId);
|
|
5698
7013
|
this.audit(principal, "chat.run.cancel", void 0, void 0, requestId, { sessionId });
|
|
5699
7014
|
sendJson(res, 202, { accepted: true });
|
|
@@ -5720,12 +7035,12 @@ var ApiRouter = class {
|
|
|
5720
7035
|
if (method === "POST" && pathname === "/manage/roots") {
|
|
5721
7036
|
const body = await readJson(req, 64e3);
|
|
5722
7037
|
if (typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
5723
|
-
sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0));
|
|
7038
|
+
sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0, { requestId }));
|
|
5724
7039
|
return;
|
|
5725
7040
|
}
|
|
5726
7041
|
const rootRoute = pathname.match(/^\/manage\/roots\/([^/]+)$/);
|
|
5727
7042
|
if (method === "DELETE" && rootRoute !== null) {
|
|
5728
|
-
this.options.database.removeRoot(
|
|
7043
|
+
this.options.database.removeRoot(decodePathSegment(rootRoute[1]));
|
|
5729
7044
|
res.writeHead(204);
|
|
5730
7045
|
res.end();
|
|
5731
7046
|
return;
|
|
@@ -5739,44 +7054,23 @@ var ApiRouter = class {
|
|
|
5739
7054
|
const body = await readJson(req, 64e3);
|
|
5740
7055
|
const scopes = validateScopes(body.scopes);
|
|
5741
7056
|
const rootIds = validateStringArray(body.rootIds, "rootIds");
|
|
5742
|
-
sendJson(res, 200, this.options.database.updateDevice(
|
|
7057
|
+
sendJson(res, 200, this.options.database.updateDevice(decodePathSegment(deviceRoute[1]), scopes, rootIds, { requestId }));
|
|
5743
7058
|
return;
|
|
5744
7059
|
}
|
|
5745
7060
|
if (deviceRoute !== null && method === "DELETE") {
|
|
5746
|
-
this.options.database.revokeDevice(
|
|
7061
|
+
this.options.database.revokeDevice(decodePathSegment(deviceRoute[1]), { requestId });
|
|
5747
7062
|
res.writeHead(204);
|
|
5748
7063
|
res.end();
|
|
5749
7064
|
return;
|
|
5750
7065
|
}
|
|
5751
7066
|
if (method === "POST" && pathname === "/manage/pairings") {
|
|
5752
7067
|
const body = await readJson(req, 64e3);
|
|
5753
|
-
sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes)));
|
|
7068
|
+
sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes), void 0, { requestId }));
|
|
5754
7069
|
return;
|
|
5755
7070
|
}
|
|
5756
7071
|
if (method === "POST" && pathname === "/manage/config/text") {
|
|
5757
7072
|
const body = await readJson(req, 64e3);
|
|
5758
|
-
|
|
5759
|
-
const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
|
|
5760
|
-
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
5761
|
-
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
5762
|
-
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
5763
|
-
const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
|
|
5764
|
-
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
5765
|
-
const pairing = this.options.database.createPairing(rootIds, scopes);
|
|
5766
|
-
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName);
|
|
5767
|
-
sendJson(res, 201, {
|
|
5768
|
-
text: encodeConfigText({
|
|
5769
|
-
displayName,
|
|
5770
|
-
endpoints,
|
|
5771
|
-
token: exchanged.token,
|
|
5772
|
-
deviceName,
|
|
5773
|
-
scopes
|
|
5774
|
-
}),
|
|
5775
|
-
displayName,
|
|
5776
|
-
deviceName,
|
|
5777
|
-
endpoints,
|
|
5778
|
-
deviceId: exchanged.device.id
|
|
5779
|
-
});
|
|
7073
|
+
sendJson(res, 201, this.buildConfigText(body, requestId));
|
|
5780
7074
|
return;
|
|
5781
7075
|
}
|
|
5782
7076
|
if (method === "POST" && pathname === "/manage/remote/enable") {
|
|
@@ -5816,7 +7110,7 @@ var ApiRouter = class {
|
|
|
5816
7110
|
}
|
|
5817
7111
|
const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
|
|
5818
7112
|
if (method === "DELETE" && purgeRoute !== null) {
|
|
5819
|
-
await this.options.files.purgeTrash(
|
|
7113
|
+
await this.options.files.purgeTrash(decodePathSegment(purgeRoute[1]));
|
|
5820
7114
|
this.options.database.audit("admin", "loopback-admin", "trash.purge", void 0, void 0, {}, requestId);
|
|
5821
7115
|
res.writeHead(204);
|
|
5822
7116
|
res.end();
|
|
@@ -5824,7 +7118,7 @@ var ApiRouter = class {
|
|
|
5824
7118
|
}
|
|
5825
7119
|
if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/status") {
|
|
5826
7120
|
if (method === "GET" || method === "HEAD") {
|
|
5827
|
-
sendJson(res, 200, this.
|
|
7121
|
+
sendJson(res, 200, this.tunnelStateFor(req));
|
|
5828
7122
|
return;
|
|
5829
7123
|
}
|
|
5830
7124
|
}
|
|
@@ -5835,6 +7129,8 @@ var ApiRouter = class {
|
|
|
5835
7129
|
}
|
|
5836
7130
|
}
|
|
5837
7131
|
if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
|
|
7132
|
+
const createBody = await readJson(req, 64e3).catch(() => ({}));
|
|
7133
|
+
const wantConfig = createBody.configText === true;
|
|
5838
7134
|
const binDir = this.options.cloudflaredBinDir;
|
|
5839
7135
|
let exe;
|
|
5840
7136
|
try {
|
|
@@ -5843,13 +7139,37 @@ var ApiRouter = class {
|
|
|
5843
7139
|
});
|
|
5844
7140
|
} catch (cause) {
|
|
5845
7141
|
this.options.tunnels.clearActivity();
|
|
5846
|
-
throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED",
|
|
7142
|
+
throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", "Failed to download or unpack cloudflared. Check the network and retry, or set CLOUDFLARED_MIRROR.", {
|
|
7143
|
+
errorKey: "tunnelErrorDownload",
|
|
7144
|
+
...cause instanceof Error ? { reason: sanitizeDownloadError(cause.message) } : {}
|
|
7145
|
+
});
|
|
5847
7146
|
}
|
|
5848
7147
|
const port = this.options.remote.status().configuredPort ?? 3090;
|
|
5849
7148
|
const state = await this.options.tunnels.startQuick(exe, port);
|
|
7149
|
+
let configText;
|
|
7150
|
+
let resolvedUrl = state.url;
|
|
7151
|
+
if (wantConfig) {
|
|
7152
|
+
const tunnelUrl = state.url ?? await this.options.tunnels.waitForUrl();
|
|
7153
|
+
resolvedUrl = tunnelUrl;
|
|
7154
|
+
if (tunnelUrl !== null) configText = this.buildConfigText({
|
|
7155
|
+
endpoints: [{
|
|
7156
|
+
label: readOptionalText(createBody.endpointLabel) ?? "云隧道",
|
|
7157
|
+
baseUrl: tunnelUrl
|
|
7158
|
+
}],
|
|
7159
|
+
...createBody.rootIds === void 0 ? {} : { rootIds: createBody.rootIds },
|
|
7160
|
+
...createBody.deviceName === void 0 ? {} : { deviceName: createBody.deviceName }
|
|
7161
|
+
}, requestId);
|
|
7162
|
+
}
|
|
5850
7163
|
this.options.tunnels.clearActivity();
|
|
5851
7164
|
this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
|
|
5852
|
-
|
|
7165
|
+
const responseState = resolvedUrl === state.url ? state : {
|
|
7166
|
+
...state,
|
|
7167
|
+
url: resolvedUrl
|
|
7168
|
+
};
|
|
7169
|
+
sendJson(res, 201, configText === void 0 ? responseState : {
|
|
7170
|
+
...responseState,
|
|
7171
|
+
configText
|
|
7172
|
+
});
|
|
5853
7173
|
return;
|
|
5854
7174
|
}
|
|
5855
7175
|
if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/stop") {
|
|
@@ -5860,6 +7180,66 @@ var ApiRouter = class {
|
|
|
5860
7180
|
}
|
|
5861
7181
|
throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
5862
7182
|
}
|
|
7183
|
+
/**
|
|
7184
|
+
* 隧道状态视图。
|
|
7185
|
+
*
|
|
7186
|
+
* **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
|
|
7187
|
+
* `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
|
|
7188
|
+
* `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
|
|
7189
|
+
* (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
|
|
7190
|
+
* 的依据。
|
|
7191
|
+
*
|
|
7192
|
+
* 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
|
|
7193
|
+
* 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
|
|
7194
|
+
* 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
|
|
7195
|
+
* 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
|
|
7196
|
+
* 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
|
|
7197
|
+
*/
|
|
7198
|
+
/**
|
|
7199
|
+
* 生成一行可粘贴的 `DSH1:` 配置文本(POST /manage/config/text 与「一键开隧道并出令牌」
|
|
7200
|
+
* 的 create 端点共用,避免两处各写一套配对/兑换/编码逻辑而漂移)。
|
|
7201
|
+
*
|
|
7202
|
+
* 入参字段与 /manage/config/text 的窄格式一致:
|
|
7203
|
+
* - endpoints:显式给出则不再做本机网卡探测(隧道场景必须只塞隧道地址,不能混入 LAN);
|
|
7204
|
+
* - rootIds / scopes / deviceName / displayName / port:全部可选,缺省沿用原端点口径;
|
|
7205
|
+
* - scopes 缺省是**五个非破坏性默认权限**(不含 files.delete / settings.write),
|
|
7206
|
+
* 调用方若显式传 DEVICE_SCOPES 会放大手机权限,create 一键路径不传。
|
|
7207
|
+
*
|
|
7208
|
+
* 刻意不返回 pairing.expiresAt:那是配对码的十分钟有效期,设备令牌本身不过期。
|
|
7209
|
+
*/
|
|
7210
|
+
buildConfigText(body, requestId) {
|
|
7211
|
+
const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
|
|
7212
|
+
const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
|
|
7213
|
+
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
7214
|
+
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
7215
|
+
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
7216
|
+
const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
|
|
7217
|
+
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
7218
|
+
const pairing = this.options.database.createPairing(rootIds, scopes, void 0, { requestId });
|
|
7219
|
+
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName, { requestId });
|
|
7220
|
+
return {
|
|
7221
|
+
text: encodeConfigText({
|
|
7222
|
+
displayName,
|
|
7223
|
+
endpoints,
|
|
7224
|
+
token: exchanged.token,
|
|
7225
|
+
deviceName,
|
|
7226
|
+
scopes
|
|
7227
|
+
}),
|
|
7228
|
+
displayName,
|
|
7229
|
+
deviceName,
|
|
7230
|
+
endpoints,
|
|
7231
|
+
deviceId: exchanged.device.id
|
|
7232
|
+
};
|
|
7233
|
+
}
|
|
7234
|
+
tunnelStateFor(req) {
|
|
7235
|
+
const tunnels = this.options.tunnels;
|
|
7236
|
+
if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
7237
|
+
const state = tunnels.publicState;
|
|
7238
|
+
return adminRefusal(req, { tunnelActive: false }) === void 0 ? state : {
|
|
7239
|
+
...state,
|
|
7240
|
+
url: null
|
|
7241
|
+
};
|
|
7242
|
+
}
|
|
5863
7243
|
async dispatchEntries(req, res, url, principal, root, requestId) {
|
|
5864
7244
|
const method = req.method ?? "GET";
|
|
5865
7245
|
if (method === "GET") {
|
|
@@ -5902,12 +7282,18 @@ var ApiRouter = class {
|
|
|
5902
7282
|
if (req.method === "GET") {
|
|
5903
7283
|
this.options.auth.requireScope(principal, "files.read");
|
|
5904
7284
|
const descriptor = await this.options.files.inspectContent(root, relativePath);
|
|
5905
|
-
|
|
7285
|
+
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
|
5906
7286
|
res.setHeader("Accept-Ranges", "bytes");
|
|
5907
7287
|
res.setHeader("ETag", descriptor.etag);
|
|
5908
7288
|
res.setHeader("Last-Modified", new Date(descriptor.modifiedAt).toUTCString());
|
|
5909
7289
|
res.setHeader("Content-Type", descriptor.contentType);
|
|
5910
7290
|
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(relativePath.split("/").at(-1) ?? "file")}`);
|
|
7291
|
+
const range = parseRange(req.headers.range, descriptor.size);
|
|
7292
|
+
if (range === void 0 && etagMatches(req.headers["if-none-match"], descriptor.etag)) {
|
|
7293
|
+
res.writeHead(304, { ETag: descriptor.etag });
|
|
7294
|
+
res.end();
|
|
7295
|
+
return;
|
|
7296
|
+
}
|
|
5911
7297
|
if (range === void 0) {
|
|
5912
7298
|
res.writeHead(200, { "Content-Length": descriptor.size });
|
|
5913
7299
|
this.options.files.streamContent(descriptor).pipe(res);
|
|
@@ -5992,17 +7378,71 @@ function sendJson(res, status, body) {
|
|
|
5992
7378
|
});
|
|
5993
7379
|
res.end(encoded);
|
|
5994
7380
|
}
|
|
7381
|
+
/**
|
|
7382
|
+
* 这个 `Origin` 是否指向回环(`127.0.0.1` / `localhost` / `[::1]`)。
|
|
7383
|
+
*
|
|
7384
|
+
* 与 `auth.ts` 的 `isLoopbackOrigin` **同一个判据**,只是这里需要「是否回环」这个
|
|
7385
|
+
* 布尔值本身(那边是「放不放行」)。两处若各写一套,就会出现「CORS 放行、
|
|
7386
|
+
* 鉴权拒绝」或反之的漂移 —— 2.5.1 的 CORS 缺陷正是这种漂移的一种。
|
|
7387
|
+
*/
|
|
7388
|
+
function isLoopbackOriginValue(origin) {
|
|
7389
|
+
try {
|
|
7390
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
7391
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
7392
|
+
} catch {
|
|
7393
|
+
return false;
|
|
7394
|
+
}
|
|
7395
|
+
}
|
|
7396
|
+
/**
|
|
7397
|
+
* 写 CORS 响应头。**只对回环 Origin 回显**(2.5.2 修复)。
|
|
7398
|
+
*
|
|
7399
|
+
* ## 修复前
|
|
7400
|
+
*
|
|
7401
|
+
* 只要请求带 `Origin` 就原样回显 `Access-Control-Allow-Origin: <它>`,
|
|
7402
|
+
* 且 `Allow-Headers` 里含 `X-Dsh-Workspace-Admin`。那等于在**协议层**对任意网站宣告:
|
|
7403
|
+
* 「你可以跨站调这个服务,连管理头都可以带」。
|
|
7404
|
+
*
|
|
7405
|
+
* 当时没有立刻变成提权,是因为 `adminRefusal` 的判据 3 会看 `Origin` 并 403 ——
|
|
7406
|
+
* **判据替 CORS 兜住了**。但纵深只剩一层:以后任何一次「为兼容放宽判据 3」的改动
|
|
7407
|
+
* (比如允许 `Origin: null`)都会立刻变成 CSRF 敞口。
|
|
7408
|
+
*
|
|
7409
|
+
* ## 修复后
|
|
7410
|
+
*
|
|
7411
|
+
* - 回环 Origin → 回显它(桌面 WebUI 在 `127.0.0.1:3080` 起,同源或跨端口都照常);
|
|
7412
|
+
* - 非回环 Origin → **不发** ACAO(浏览器自然拒绝跨站读取,不需要服务端 403 也算安全);
|
|
7413
|
+
* - 无 Origin → 不发(本机 `curl` 不需要 CORS);
|
|
7414
|
+
* - `Allow-Headers` **去掉** `X-Dsh-Workspace-Admin`:桌面面板是同源的,
|
|
7415
|
+
* 它带这个头根本不需要预检许可,把它写进协议只会误导后人。
|
|
7416
|
+
*
|
|
7417
|
+
* `Cache-Control: no-store` 仍在这里设默认值;少数可缓存的三条端点
|
|
7418
|
+
* (`/roots/:id/content`、`/attachments/:id`、`/image-handoff`)在各自的处理分支里覆盖。
|
|
7419
|
+
*/
|
|
5995
7420
|
function setCors(req, res) {
|
|
5996
7421
|
const origin = req.headers.origin;
|
|
5997
|
-
if (origin !== void 0) {
|
|
7422
|
+
if (origin !== void 0 && isLoopbackOriginValue(origin)) {
|
|
5998
7423
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
5999
7424
|
res.setHeader("Vary", "Origin");
|
|
6000
7425
|
}
|
|
6001
|
-
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id
|
|
7426
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id");
|
|
6002
7427
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
6003
7428
|
res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Range, X-Request-Id");
|
|
6004
7429
|
res.setHeader("Cache-Control", "no-store");
|
|
6005
7430
|
}
|
|
7431
|
+
/**
|
|
7432
|
+
* 预检(`OPTIONS`)是否该被回答。
|
|
7433
|
+
*
|
|
7434
|
+
* 浏览器预检一定带 `Origin`。带**非回环** Origin 的预检直接 403 ——
|
|
7435
|
+
* 与「不发 ACAO」相比这条更明确:让攻击者立刻知道跨站不被接受,
|
|
7436
|
+
* 而不是让它在一个「看起来成功但读不到内容」的响应里猜。
|
|
7437
|
+
*
|
|
7438
|
+
* 没有 `Origin` 的 `OPTIONS` 不是浏览器预检(`curl` 常这么探活),照旧 204 ——
|
|
7439
|
+
* 与 `adminRefusal` 放行「缺 Origin」是同一个口径(缺 Origin 不构成声明)。
|
|
7440
|
+
*/
|
|
7441
|
+
function preflightRefused(req) {
|
|
7442
|
+
const origin = req.headers.origin;
|
|
7443
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
7444
|
+
return !isLoopbackOriginValue(origin);
|
|
7445
|
+
}
|
|
6006
7446
|
function validateScopes(value) {
|
|
6007
7447
|
const values = validateStringArray(value, "scopes");
|
|
6008
7448
|
if (values.some((item) => !DEVICE_SCOPES.includes(item))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
|
|
@@ -6135,6 +7575,39 @@ function optionalInteger(value) {
|
|
|
6135
7575
|
function commandName(line) {
|
|
6136
7576
|
return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
|
|
6137
7577
|
}
|
|
7578
|
+
/**
|
|
7579
|
+
* 读一份运行期指标快照;读取本身抛错时返回 `undefined`(诊断端点绝不能反过来炸掉)。
|
|
7580
|
+
*
|
|
7581
|
+
* 历史:这里曾是动态 `import('./metrics.ts')`,因为 metrics.ts 与本文件由不同手术台并行落地,
|
|
7582
|
+
* 静态依赖会造成编译顺序耦合。metrics.ts 落地后该动态导入既不省包(server/bridge 已静态引用,
|
|
7583
|
+
* tsdown 会打 INEFFECTIVE_DYNAMIC_IMPORT 警告)也不提供容错,改为静态导入。
|
|
7584
|
+
*/
|
|
7585
|
+
async function readMetricsSnapshot() {
|
|
7586
|
+
try {
|
|
7587
|
+
return getMetricsSnapshot();
|
|
7588
|
+
} catch {
|
|
7589
|
+
return;
|
|
7590
|
+
}
|
|
7591
|
+
}
|
|
7592
|
+
/**
|
|
7593
|
+
* `If-None-Match` 是否命中当前 ETag。**带弱比较**(忽略 `W/` 前缀)。
|
|
7594
|
+
*
|
|
7595
|
+
* 为什么不用严格相等:ETag 的强弱前缀(`W/`)属于缓存语义的**修饰**,
|
|
7596
|
+
* 不改变「这份内容是不是同一份」。而两端(路由器 / `file-service`)谁加 `W/`
|
|
7597
|
+
* 都可能随实现变化(本仓的 ETag 格式正在被改成 stat-only 的弱校验器),
|
|
7598
|
+
* 写死严格相等会让「格式一改,304 静默失效」—— 那种失效没有任何报错,
|
|
7599
|
+
* 只是客户端永远拿不到 304,悄悄地退回每次重下整份。
|
|
7600
|
+
*
|
|
7601
|
+
* 支持逗号分隔的多值(`If-None-Match: W/"a", "b"`)与通配 `*`。
|
|
7602
|
+
*/
|
|
7603
|
+
function etagMatches(headerValue, etag) {
|
|
7604
|
+
const raw = Array.isArray(headerValue) ? headerValue.join(",") : headerValue;
|
|
7605
|
+
if (raw === void 0 || raw === "") return false;
|
|
7606
|
+
if (raw.trim() === "*") return true;
|
|
7607
|
+
const normalize = (value) => value.trim().replace(/^(?:W\/)+/i, "");
|
|
7608
|
+
const target = normalize(etag);
|
|
7609
|
+
return raw.split(",").some((candidate) => normalize(candidate) === target);
|
|
7610
|
+
}
|
|
6138
7611
|
function parseRange(value, size) {
|
|
6139
7612
|
if (value === void 0) return void 0;
|
|
6140
7613
|
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
@@ -6160,8 +7633,72 @@ function header(req, name) {
|
|
|
6160
7633
|
const value = req.headers[name];
|
|
6161
7634
|
return Array.isArray(value) ? value[0] : value;
|
|
6162
7635
|
}
|
|
7636
|
+
/**
|
|
7637
|
+
* 解码一个 URL **路径段**(不是 query 参数)。
|
|
7638
|
+
*
|
|
7639
|
+
* ## 为什么要收口成一个函数
|
|
7640
|
+
*
|
|
7641
|
+
* `decodeURIComponent` 对非法百分号(`%ZZ`、`%E0%A4%A`、孤零零的 `%`)抛 `URIError`。
|
|
7642
|
+
* 而 `new URL()` **不会**提前拒绝它们(实测 `pathname` 保留字面 `%ZZ`),
|
|
7643
|
+
* 于是请求真的会走到解码这一步 —— 抛出后一路冒到 `router.handle` 的兜底 `catch`,
|
|
7644
|
+
* 被 `asApiError` 包成 **500 `INTERNAL_ERROR`**。
|
|
7645
|
+
*
|
|
7646
|
+
* 那是**错误的分类**:客户端把 URL 打错,不是桥内部炸了。两层代价:
|
|
7647
|
+
* 1. 排障时把「你 URL 写错了」读成「服务端故障」,方向全错;
|
|
7648
|
+
* 2. 污染 `INTERNAL_ERROR` 这个码 —— 真出 500 时没人能再靠码区分。
|
|
7649
|
+
*
|
|
7650
|
+
* 所以这里把「客户端输入非法」固定成 **400 `PATH_INVALID`**,
|
|
7651
|
+
* 与 `path-policy.ts` 里其它非法路径的码保持一致(客户端只需认一个码)。
|
|
7652
|
+
*
|
|
7653
|
+
* ## 为什么不用 `URLSearchParams` 或 `decodeURI`
|
|
7654
|
+
*
|
|
7655
|
+
* - `URLSearchParams.get()` 会把 `+` 解成空格(那是 **query** 的
|
|
7656
|
+
* `application/x-www-form-urlencoded` 语义)。路径段里的 `+` 是字面量 ——
|
|
7657
|
+
* 用它会把 `a+b` 这类合法 id 静默改成 `a b`。
|
|
7658
|
+
* - `decodeURI` 不处理保留字符(`%3A` 之类解不出来),语义不对。
|
|
7659
|
+
*
|
|
7660
|
+
* 所以:`decodeURIComponent` + 严格 catch,什么都不额外做。
|
|
7661
|
+
*/
|
|
7662
|
+
function decodePathSegment(value) {
|
|
7663
|
+
try {
|
|
7664
|
+
return decodeURIComponent(value);
|
|
7665
|
+
} catch {
|
|
7666
|
+
throw new ApiError(400, "PATH_INVALID", "A path segment contains malformed percent-encoding.");
|
|
7667
|
+
}
|
|
7668
|
+
}
|
|
6163
7669
|
//#endregion
|
|
6164
7670
|
//#region src/host/server.ts
|
|
7671
|
+
/** 默认同时连接数。比手机端可能的重连风暴宽,又不至于让一个坏客户端吃满。 */
|
|
7672
|
+
const DEFAULT_MAX_CONNECTIONS = 32;
|
|
7673
|
+
/** 默认单帧上限(`ws` 的 `maxPayload`)。 */
|
|
7674
|
+
const DEFAULT_MAX_PAYLOAD_BYTES = 1048576;
|
|
7675
|
+
/** 默认心跳间隔。 */
|
|
7676
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
7677
|
+
/** 连接内事件队列上限。 */
|
|
7678
|
+
const DEFAULT_SOCKET_QUEUE_LIMIT = 512;
|
|
7679
|
+
/** 发送缓冲高水位:`ws` 的 `bufferedAmount` 越过它说明对端读不动。 */
|
|
7680
|
+
const DEFAULT_HIGH_WATER_MARK_BYTES = 4194304;
|
|
7681
|
+
/** 卸载时等广播排空的硬上限(不让插件卡住进程退出)。 */
|
|
7682
|
+
const STOP_DRAIN_TIMEOUT_MS = 1e3;
|
|
7683
|
+
/** 背压复查间隔:越线后等这么久再看一眼缓冲降没降。 */
|
|
7684
|
+
const HIGH_WATER_RECHECK_MS = 250;
|
|
7685
|
+
/**
|
|
7686
|
+
* 关闭连接时的宽限期。
|
|
7687
|
+
*
|
|
7688
|
+
* 升级过的 WebSocket 不受 `closeAllConnections()` 管理,所以「一条读不动的连接」
|
|
7689
|
+
* 能让 `server.close()` 的回调永远不来。宽限期后一律 `terminate()`。
|
|
7690
|
+
*/
|
|
7691
|
+
const CLOSE_GRACE_MS = 500;
|
|
7692
|
+
/**
|
|
7693
|
+
* 这条事件「丢了能不能重建」。
|
|
7694
|
+
*
|
|
7695
|
+
* 只有流式文本增量是可重建的(客户端重拉历史即可)。状态类帧(`turn.*` /
|
|
7696
|
+
* `approval.*` / `deliverables.*` / `connection.ready`)丢了会让客户端停在错误状态,
|
|
7697
|
+
* 所以溢出时**优先牺牲增量**。
|
|
7698
|
+
*/
|
|
7699
|
+
function isRebuildable(event) {
|
|
7700
|
+
return event.type === "chat.message.delta";
|
|
7701
|
+
}
|
|
6165
7702
|
var RemoteApiServer = class {
|
|
6166
7703
|
options;
|
|
6167
7704
|
server;
|
|
@@ -6172,14 +7709,18 @@ var RemoteApiServer = class {
|
|
|
6172
7709
|
remoteEnabled = false;
|
|
6173
7710
|
operations = /* @__PURE__ */ new Map();
|
|
6174
7711
|
sockets = /* @__PURE__ */ new Map();
|
|
6175
|
-
webSockets
|
|
6176
|
-
noServer: true,
|
|
6177
|
-
clientTracking: false
|
|
6178
|
-
});
|
|
7712
|
+
webSockets;
|
|
6179
7713
|
eventsAbort = new AbortController();
|
|
6180
7714
|
unsubscribeEvents;
|
|
6181
|
-
|
|
7715
|
+
/** 所有连接的 drain 完成信号;`Promise.all` 它们即可等排空。 */
|
|
7716
|
+
pendingDrains = /* @__PURE__ */ new Set();
|
|
7717
|
+
heartbeatTimer;
|
|
7718
|
+
changeTimer;
|
|
6182
7719
|
changing = false;
|
|
7720
|
+
maxConnections;
|
|
7721
|
+
socketQueueLimit;
|
|
7722
|
+
highWaterMarkBytes;
|
|
7723
|
+
heartbeatIntervalMs;
|
|
6183
7724
|
constructor(options) {
|
|
6184
7725
|
this.options = options;
|
|
6185
7726
|
const fallbackHost = normalizeListenerHost(options.remoteHost ?? "0.0.0.0");
|
|
@@ -6187,6 +7728,15 @@ var RemoteApiServer = class {
|
|
|
6187
7728
|
this.configuredHost = readStoredHost(options.database, fallbackHost);
|
|
6188
7729
|
this.configuredPort = readStoredPort(options.database, fallbackPort);
|
|
6189
7730
|
this.actualPort = this.configuredPort;
|
|
7731
|
+
this.maxConnections = Math.max(1, options.maxConnections ?? DEFAULT_MAX_CONNECTIONS);
|
|
7732
|
+
this.socketQueueLimit = Math.max(1, options.socketQueueLimit ?? DEFAULT_SOCKET_QUEUE_LIMIT);
|
|
7733
|
+
this.highWaterMarkBytes = Math.max(1, options.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES);
|
|
7734
|
+
this.heartbeatIntervalMs = Math.max(1, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
7735
|
+
this.webSockets = new WebSocketServer({
|
|
7736
|
+
noServer: true,
|
|
7737
|
+
clientTracking: false,
|
|
7738
|
+
maxPayload: Math.max(1, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES)
|
|
7739
|
+
});
|
|
6190
7740
|
}
|
|
6191
7741
|
async start() {
|
|
6192
7742
|
this.remoteEnabled = this.options.database.getSetting("remote_enabled") === "true";
|
|
@@ -6200,8 +7750,9 @@ var RemoteApiServer = class {
|
|
|
6200
7750
|
await this.bind(loopbackFor(this.configuredHost), this.configuredPort);
|
|
6201
7751
|
}
|
|
6202
7752
|
this.unsubscribeEvents = this.options.events.subscribe((event) => {
|
|
6203
|
-
this.
|
|
7753
|
+
this.enqueueBroadcast(event);
|
|
6204
7754
|
});
|
|
7755
|
+
this.startHeartbeat();
|
|
6205
7756
|
this.options.chat.startEvents(this.eventsAbort.signal, (event) => {
|
|
6206
7757
|
this.options.events.emit(event);
|
|
6207
7758
|
});
|
|
@@ -6210,9 +7761,15 @@ var RemoteApiServer = class {
|
|
|
6210
7761
|
this.eventsAbort.abort();
|
|
6211
7762
|
this.unsubscribeEvents?.();
|
|
6212
7763
|
this.unsubscribeEvents = void 0;
|
|
6213
|
-
|
|
7764
|
+
this.clearChangeTimer();
|
|
7765
|
+
if (this.heartbeatTimer !== void 0) {
|
|
7766
|
+
clearInterval(this.heartbeatTimer);
|
|
7767
|
+
this.heartbeatTimer = void 0;
|
|
7768
|
+
}
|
|
7769
|
+
await this.drainAll(STOP_DRAIN_TIMEOUT_MS);
|
|
6214
7770
|
await this.closeCurrent();
|
|
6215
7771
|
this.webSockets.close();
|
|
7772
|
+
setWsConnections(0);
|
|
6216
7773
|
}
|
|
6217
7774
|
status(operationId) {
|
|
6218
7775
|
const operation = operationId === void 0 ? void 0 : this.operations.get(operationId);
|
|
@@ -6265,11 +7822,21 @@ var RemoteApiServer = class {
|
|
|
6265
7822
|
};
|
|
6266
7823
|
this.operations.set(operation.id, operation);
|
|
6267
7824
|
this.changing = true;
|
|
6268
|
-
|
|
7825
|
+
this.clearChangeTimer();
|
|
7826
|
+
this.changeTimer = setTimeout(() => {
|
|
7827
|
+
this.changeTimer = void 0;
|
|
6269
7828
|
this.performChange(operation);
|
|
6270
7829
|
}, 75);
|
|
7830
|
+
this.changeTimer.unref?.();
|
|
6271
7831
|
return publicOperation(operation);
|
|
6272
7832
|
}
|
|
7833
|
+
/** 取消还没触发的监听变更。幂等:没定时器就什么都不做。 */
|
|
7834
|
+
clearChangeTimer() {
|
|
7835
|
+
if (this.changeTimer === void 0) return;
|
|
7836
|
+
clearTimeout(this.changeTimer);
|
|
7837
|
+
this.changeTimer = void 0;
|
|
7838
|
+
this.changing = false;
|
|
7839
|
+
}
|
|
6273
7840
|
async performChange(operation) {
|
|
6274
7841
|
const previousHost = this.host;
|
|
6275
7842
|
const previousPort = this.actualPort;
|
|
@@ -6327,7 +7894,7 @@ var RemoteApiServer = class {
|
|
|
6327
7894
|
}
|
|
6328
7895
|
async createBoundServer(host, port) {
|
|
6329
7896
|
const server = createServer((req, res) => {
|
|
6330
|
-
this.options.handle(req, res).catch((error) => failResponse(res, error));
|
|
7897
|
+
Promise.resolve().then(() => this.options.handle(req, res)).catch((error) => failResponse(res, error));
|
|
6331
7898
|
});
|
|
6332
7899
|
server.requestTimeout = 3e4;
|
|
6333
7900
|
server.headersTimeout = 1e4;
|
|
@@ -6349,14 +7916,42 @@ var RemoteApiServer = class {
|
|
|
6349
7916
|
port: server.address().port
|
|
6350
7917
|
};
|
|
6351
7918
|
}
|
|
7919
|
+
/**
|
|
7920
|
+
* 关掉当前监听与全部连接,**有界**。
|
|
7921
|
+
*
|
|
7922
|
+
* `server.close()` 的回调要等所有连接结束;而**升级过的 WebSocket 不在
|
|
7923
|
+
* `closeAllConnections()` 的管理范围里**(HTTP 层已经不跟踪它了)。于是只要有一条
|
|
7924
|
+
* 连接读不动(暂停读 / 半开),`close()` 就永远等不到回调 —— 插件卸载会挂死。
|
|
7925
|
+
*
|
|
7926
|
+
* 所以这里的顺序是:先礼后兵 —— 发 1012(客户端会看到「服务端重绑」而不是异常断开),
|
|
7927
|
+
* 等一小段宽限期;宽限期到就 `terminate()`,同时让 `close()` 的等待一起收尾。
|
|
7928
|
+
*/
|
|
6352
7929
|
async closeCurrent() {
|
|
6353
7930
|
const server = this.server;
|
|
6354
7931
|
if (server === void 0) return;
|
|
6355
7932
|
this.server = void 0;
|
|
6356
|
-
|
|
7933
|
+
const pending = [...this.sockets.keys()];
|
|
7934
|
+
for (const socket of pending) try {
|
|
7935
|
+
socket.close(1012, "Listener rebinding");
|
|
7936
|
+
} catch {}
|
|
6357
7937
|
this.sockets.clear();
|
|
7938
|
+
setWsConnections(0);
|
|
6358
7939
|
await new Promise((resolve) => {
|
|
6359
|
-
|
|
7940
|
+
let settled = false;
|
|
7941
|
+
const done = () => {
|
|
7942
|
+
if (settled) return;
|
|
7943
|
+
settled = true;
|
|
7944
|
+
clearTimeout(grace);
|
|
7945
|
+
resolve();
|
|
7946
|
+
};
|
|
7947
|
+
const grace = setTimeout(() => {
|
|
7948
|
+
for (const socket of pending) try {
|
|
7949
|
+
socket.terminate();
|
|
7950
|
+
} catch {}
|
|
7951
|
+
done();
|
|
7952
|
+
}, CLOSE_GRACE_MS);
|
|
7953
|
+
grace.unref?.();
|
|
7954
|
+
server.close(done);
|
|
6360
7955
|
server.closeAllConnections();
|
|
6361
7956
|
});
|
|
6362
7957
|
}
|
|
@@ -6369,27 +7964,84 @@ var RemoteApiServer = class {
|
|
|
6369
7964
|
const protocolToken = parseProtocolToken(req.headers["sec-websocket-protocol"]);
|
|
6370
7965
|
if (protocolToken !== void 0) req.headers.authorization = `Bearer ${protocolToken}`;
|
|
6371
7966
|
const principal = this.options.auth.principal(req, { allowAdmin: true });
|
|
7967
|
+
if (this.sockets.size >= this.maxConnections) {
|
|
7968
|
+
recordWsRejected();
|
|
7969
|
+
socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nRetry-After: 5\r\nContent-Length: 0\r\n\r\n");
|
|
7970
|
+
return;
|
|
7971
|
+
}
|
|
6372
7972
|
this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
|
|
6373
|
-
|
|
7973
|
+
const state = {
|
|
7974
|
+
principal,
|
|
7975
|
+
queue: [],
|
|
7976
|
+
draining: false,
|
|
7977
|
+
dropped: 0,
|
|
7978
|
+
resyncPending: false,
|
|
7979
|
+
isAlive: true,
|
|
7980
|
+
overHighWater: 0,
|
|
7981
|
+
devicesRevision: this.devicesRevisionOf(),
|
|
7982
|
+
recheckTimer: void 0
|
|
7983
|
+
};
|
|
7984
|
+
this.sockets.set(webSocket, state);
|
|
7985
|
+
setWsConnections(this.sockets.size);
|
|
7986
|
+
webSocket.on("pong", () => {
|
|
7987
|
+
state.isAlive = true;
|
|
7988
|
+
});
|
|
6374
7989
|
webSocket.on("close", () => {
|
|
7990
|
+
this.clearSocketTimers(state);
|
|
6375
7991
|
this.sockets.delete(webSocket);
|
|
7992
|
+
setWsConnections(this.sockets.size);
|
|
6376
7993
|
});
|
|
6377
7994
|
webSocket.on("error", () => {
|
|
7995
|
+
this.clearSocketTimers(state);
|
|
6378
7996
|
this.sockets.delete(webSocket);
|
|
7997
|
+
setWsConnections(this.sockets.size);
|
|
6379
7998
|
});
|
|
6380
7999
|
webSocket.send(stringifyWire({
|
|
6381
8000
|
id: randomUUID(),
|
|
6382
8001
|
type: "connection.ready",
|
|
6383
8002
|
time: Date.now(),
|
|
6384
|
-
data: connectionReadyData()
|
|
8003
|
+
data: connectionReadyData(this.options.capabilities ?? {})
|
|
6385
8004
|
}));
|
|
6386
|
-
this.replayPendingInteractions(webSocket,
|
|
8005
|
+
this.replayPendingInteractions(webSocket, state);
|
|
6387
8006
|
});
|
|
6388
8007
|
} catch {
|
|
6389
8008
|
socket.destroy();
|
|
6390
8009
|
}
|
|
6391
8010
|
}
|
|
6392
8011
|
/**
|
|
8012
|
+
* 心跳循环:每 `heartbeatIntervalMs` 检查一次存活标记。
|
|
8013
|
+
*
|
|
8014
|
+
* 时序要看清:`isAlive` **先读后置假**,并且只在本轮「活着」时才补一次 `ping`。
|
|
8015
|
+
* 于是:
|
|
8016
|
+
* - 本轮活着 → 标记为可疑,再 ping 一次;
|
|
8017
|
+
* - 本轮仍可疑(上一轮 ping 没换来 pong)→ `terminate()`。
|
|
8018
|
+
*
|
|
8019
|
+
* 结果是**连续错过两个周期**才踢人。偶发丢一个包不会误杀在线设备,真正断掉的
|
|
8020
|
+
* (手机切网 / 熄屏后 NAT 表项过期)最多两个周期就清掉 —— 而修复前它只能等
|
|
8021
|
+
* TCP 自己超时,等于「半开连接长期占着额度,还在每轮广播里被白查一次库」。
|
|
8022
|
+
*
|
|
8023
|
+
* `unref()` 必需:这个定时器永不结束,不 unref 会让进程退不掉。
|
|
8024
|
+
*/
|
|
8025
|
+
startHeartbeat() {
|
|
8026
|
+
if (this.heartbeatTimer !== void 0) return;
|
|
8027
|
+
this.heartbeatTimer = setInterval(() => {
|
|
8028
|
+
for (const [socket, state] of this.sockets) {
|
|
8029
|
+
if (!state.isAlive) {
|
|
8030
|
+
recordWsTerminated();
|
|
8031
|
+
this.sockets.delete(socket);
|
|
8032
|
+
setWsConnections(this.sockets.size);
|
|
8033
|
+
socket.terminate();
|
|
8034
|
+
continue;
|
|
8035
|
+
}
|
|
8036
|
+
state.isAlive = false;
|
|
8037
|
+
try {
|
|
8038
|
+
socket.ping();
|
|
8039
|
+
} catch {}
|
|
8040
|
+
}
|
|
8041
|
+
}, this.heartbeatIntervalMs);
|
|
8042
|
+
this.heartbeatTimer.unref?.();
|
|
8043
|
+
}
|
|
8044
|
+
/**
|
|
6393
8045
|
* 把待处理的**审批与提问**补推给刚建立的这条连接。
|
|
6394
8046
|
*
|
|
6395
8047
|
* 为什么要有这一步:「连接后要不要主动查一次 REST」是**每个客户端自己的选择**。
|
|
@@ -6398,43 +8050,213 @@ var RemoteApiServer = class {
|
|
|
6398
8050
|
* 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
|
|
6399
8051
|
* 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
|
|
6400
8052
|
*
|
|
6401
|
-
*
|
|
8053
|
+
* 四个刻意的取舍:
|
|
6402
8054
|
* - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
|
|
6403
8055
|
* - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
|
|
6404
8056
|
* - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
|
|
6405
|
-
*
|
|
8057
|
+
* 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖;
|
|
8058
|
+
* - **走连接自己的队列**:重放帧与随后的广播帧必须保序。直接 `socket.send`
|
|
8059
|
+
* 会让「重放的第 2 帧」插到「广播的第 1 帧」前面去。
|
|
6406
8060
|
*/
|
|
6407
|
-
async replayPendingInteractions(socket,
|
|
8061
|
+
async replayPendingInteractions(socket, state) {
|
|
6408
8062
|
try {
|
|
6409
|
-
const events = [...await this.options.chat.replayPendingApprovals(principal), ...await this.options.chat.replayPendingQuestions(principal)];
|
|
6410
|
-
for (const event of events)
|
|
6411
|
-
if (socket.readyState !== WebSocket.OPEN) return;
|
|
6412
|
-
socket.send(stringifyWire(event));
|
|
6413
|
-
}
|
|
8063
|
+
const events = [...await this.options.chat.replayPendingApprovals(state.principal), ...await this.options.chat.replayPendingQuestions(state.principal)];
|
|
8064
|
+
for (const event of events) this.enqueue(socket, state, event);
|
|
6414
8065
|
} catch {}
|
|
6415
8066
|
}
|
|
6416
|
-
|
|
6417
|
-
|
|
8067
|
+
/** 当前设备授权版本;`database` 还没提供就返回 undefined(退回逐事件查库的旧路径)。 */
|
|
8068
|
+
devicesRevisionOf() {
|
|
8069
|
+
const value = this.options.database.devicesRevision;
|
|
8070
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
8071
|
+
}
|
|
8072
|
+
/**
|
|
8073
|
+
* 广播入口:**只做快照 + 入队**,全程同步。
|
|
8074
|
+
*
|
|
8075
|
+
* 这里是宿主事件循环上的热路径 —— 订阅回调里的任何 `await`(查库、查内核、
|
|
8076
|
+
* 发网络)都会反过来把宿主的 `push` 拖住。修复前正是这样:一条全局 promise 链
|
|
8077
|
+
* 串起「事件 × 连接」,一个慢判定让所有事件、所有客户端一起排队,`stop()`
|
|
8078
|
+
* 还得 `await` 那条链。
|
|
8079
|
+
*/
|
|
8080
|
+
enqueueBroadcast(event) {
|
|
8081
|
+
recordBroadcastEvent();
|
|
8082
|
+
let deepest = 0;
|
|
8083
|
+
for (const [socket, state] of this.sockets) {
|
|
6418
8084
|
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
6419
|
-
|
|
6420
|
-
if (
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
8085
|
+
this.enqueue(socket, state, event);
|
|
8086
|
+
if (state.queue.length > deepest) deepest = state.queue.length;
|
|
8087
|
+
}
|
|
8088
|
+
setBroadcastQueueDepth(deepest);
|
|
8089
|
+
}
|
|
8090
|
+
/**
|
|
8091
|
+
* 单连接入队:溢出时按「先丢可重建帧」处理,并给客户端一个 resync 信号。
|
|
8092
|
+
*
|
|
8093
|
+
* 为什么溢出不断连:手机端解码慢一次不该被踢下线(那会触发一条重连风暴,
|
|
8094
|
+
* 而重连风暴又会把刚建好的连接再挤掉)。但「静默丢帧」更糟 —— 客户端会以为
|
|
8095
|
+
* 事件流还连续,于是它手里的状态永远停在丢帧之前。所以丢的时候记一笔,
|
|
8096
|
+
* 随后补发 `connection.resyncRequired`,把「静默」变成「告知」。
|
|
8097
|
+
*/
|
|
8098
|
+
enqueue(socket, state, event) {
|
|
8099
|
+
if (state.queue.length >= this.socketQueueLimit) {
|
|
8100
|
+
if (isRebuildable(event)) {
|
|
8101
|
+
state.dropped += 1;
|
|
8102
|
+
recordDroppedEvent();
|
|
8103
|
+
state.resyncPending = true;
|
|
8104
|
+
return;
|
|
8105
|
+
}
|
|
8106
|
+
const victimIndex = state.queue.findIndex(isRebuildable);
|
|
8107
|
+
if (victimIndex >= 0) {
|
|
8108
|
+
state.queue.splice(victimIndex, 1);
|
|
8109
|
+
state.dropped += 1;
|
|
8110
|
+
recordDroppedEvent();
|
|
8111
|
+
} else {
|
|
8112
|
+
state.queue.shift();
|
|
8113
|
+
state.dropped += 1;
|
|
8114
|
+
recordDroppedEvent();
|
|
8115
|
+
}
|
|
8116
|
+
state.resyncPending = true;
|
|
8117
|
+
}
|
|
8118
|
+
state.queue.push(event);
|
|
8119
|
+
this.startDrain(socket, state);
|
|
8120
|
+
}
|
|
8121
|
+
/** 保证一条连接只有一个 drain 循环(否则同一连接内的顺序会被打乱)。 */
|
|
8122
|
+
startDrain(socket, state) {
|
|
8123
|
+
if (state.draining) return;
|
|
8124
|
+
state.draining = true;
|
|
8125
|
+
const task = this.drain(socket, state).catch(() => void 0).finally(() => {
|
|
8126
|
+
state.draining = false;
|
|
8127
|
+
this.pendingDrains.delete(task);
|
|
8128
|
+
if (state.recheckTimer !== void 0) return;
|
|
8129
|
+
if (state.queue.length > 0 && this.sockets.get(socket) === state) this.startDrain(socket, state);
|
|
8130
|
+
});
|
|
8131
|
+
this.pendingDrains.add(task);
|
|
8132
|
+
}
|
|
8133
|
+
/**
|
|
8134
|
+
* 一条连接的发送循环。
|
|
8135
|
+
*
|
|
8136
|
+
* `await` 只发生在「这条连接自己的准备工作」上(刷新设备身份、判断会话授权)。
|
|
8137
|
+
* 于是慢连接只慢它自己 —— 这正是与修复前的根本区别。
|
|
8138
|
+
*/
|
|
8139
|
+
async drain(socket, state) {
|
|
8140
|
+
while (state.queue.length > 0) {
|
|
8141
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
8142
|
+
state.queue.length = 0;
|
|
8143
|
+
return;
|
|
8144
|
+
}
|
|
8145
|
+
if (socket.bufferedAmount > this.highWaterMarkBytes) {
|
|
8146
|
+
if (this.noteHighWater(socket, state)) return;
|
|
8147
|
+
return;
|
|
8148
|
+
}
|
|
8149
|
+
state.overHighWater = 0;
|
|
8150
|
+
if (state.resyncPending) {
|
|
8151
|
+
state.resyncPending = false;
|
|
8152
|
+
this.sendFrame(socket, {
|
|
8153
|
+
id: randomUUID(),
|
|
8154
|
+
type: "connection.resyncRequired",
|
|
8155
|
+
time: Date.now(),
|
|
8156
|
+
data: {
|
|
8157
|
+
dropped: state.dropped,
|
|
8158
|
+
reason: "backpressure"
|
|
8159
|
+
}
|
|
8160
|
+
});
|
|
8161
|
+
state.dropped = 0;
|
|
8162
|
+
continue;
|
|
8163
|
+
}
|
|
8164
|
+
const event = state.queue.shift();
|
|
8165
|
+
const principal = await this.refreshPrincipal(socket, state);
|
|
8166
|
+
if (principal === void 0) {
|
|
8167
|
+
state.queue.length = 0;
|
|
8168
|
+
socket.close(4001, "Device revoked");
|
|
8169
|
+
return;
|
|
8170
|
+
}
|
|
8171
|
+
if (!await this.eventAllowed(principal, event)) continue;
|
|
8172
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
8173
|
+
state.queue.length = 0;
|
|
8174
|
+
return;
|
|
6434
8175
|
}
|
|
6435
|
-
|
|
6436
|
-
socket.send(stringifyWire(event));
|
|
8176
|
+
this.sendFrame(socket, event);
|
|
6437
8177
|
}
|
|
8178
|
+
if (socket.readyState === WebSocket.OPEN && socket.bufferedAmount > this.highWaterMarkBytes) this.noteHighWater(socket, state);
|
|
8179
|
+
}
|
|
8180
|
+
/**
|
|
8181
|
+
* 记录一次「越过发送高水位」,并安排复查。
|
|
8182
|
+
*
|
|
8183
|
+
* @returns 这条连接是否已经被关掉(调用方据此决定还要不要继续 drain)。
|
|
8184
|
+
*
|
|
8185
|
+
* 两拍才关是刻意的:一次越线可能只是客户端的短暂抖动(切网、GC),
|
|
8186
|
+
* 立刻踢下线会制造重连风暴。但**必须有复查** —— 否则「第二拍」永远不会发生,
|
|
8187
|
+
* 连接就卡在「不发也不关」的僵尸态,比直接断掉更难排查。
|
|
8188
|
+
*/
|
|
8189
|
+
noteHighWater(socket, state) {
|
|
8190
|
+
state.overHighWater += 1;
|
|
8191
|
+
if (state.overHighWater >= 2) {
|
|
8192
|
+
this.closeBackpressured(socket, state);
|
|
8193
|
+
return true;
|
|
8194
|
+
}
|
|
8195
|
+
if (state.recheckTimer !== void 0) return false;
|
|
8196
|
+
state.recheckTimer = setTimeout(() => {
|
|
8197
|
+
state.recheckTimer = void 0;
|
|
8198
|
+
if (this.sockets.get(socket) !== state) return;
|
|
8199
|
+
if (socket.readyState === WebSocket.OPEN) this.startDrain(socket, state);
|
|
8200
|
+
}, HIGH_WATER_RECHECK_MS);
|
|
8201
|
+
return false;
|
|
8202
|
+
}
|
|
8203
|
+
sendFrame(socket, payload) {
|
|
8204
|
+
try {
|
|
8205
|
+
socket.send(stringifyWire(payload));
|
|
8206
|
+
} catch {}
|
|
8207
|
+
}
|
|
8208
|
+
/** 清掉这条连接自己的定时器。**必须**在连接被摘掉时调用,否则定时器会拖住进程。 */
|
|
8209
|
+
clearSocketTimers(state) {
|
|
8210
|
+
if (state.recheckTimer === void 0) return;
|
|
8211
|
+
clearTimeout(state.recheckTimer);
|
|
8212
|
+
state.recheckTimer = void 0;
|
|
8213
|
+
}
|
|
8214
|
+
closeBackpressured(socket, state) {
|
|
8215
|
+
this.clearSocketTimers(state);
|
|
8216
|
+
const dropped = state.dropped;
|
|
8217
|
+
state.queue.length = 0;
|
|
8218
|
+
state.dropped = 0;
|
|
8219
|
+
state.resyncPending = false;
|
|
8220
|
+
recordWsTerminated();
|
|
8221
|
+
this.sockets.delete(socket);
|
|
8222
|
+
setWsConnections(this.sockets.size);
|
|
8223
|
+
this.sendFrame(socket, {
|
|
8224
|
+
id: randomUUID(),
|
|
8225
|
+
type: "connection.resyncRequired",
|
|
8226
|
+
time: Date.now(),
|
|
8227
|
+
data: {
|
|
8228
|
+
dropped,
|
|
8229
|
+
reason: "backpressure"
|
|
8230
|
+
}
|
|
8231
|
+
});
|
|
8232
|
+
socket.close(1013, "Backpressure");
|
|
8233
|
+
}
|
|
8234
|
+
/**
|
|
8235
|
+
* 按需刷新这条连接的身份快照。
|
|
8236
|
+
*
|
|
8237
|
+
* 有 `devicesRevision` 时:只有版本变了才查库 —— 正常情况**一件事都不做**,
|
|
8238
|
+
* 而修复前是「每个事件、每条连接都查一次 `getActiveDevice`」。
|
|
8239
|
+
* 没有该 getter 时退回旧行为(逐事件查库):老数据库上功能不变,只是慢一点。
|
|
8240
|
+
* 这比「广播路径依赖一个可能不存在的 API」安全得多,也让两侧改动可以分批落地。
|
|
8241
|
+
*
|
|
8242
|
+
* @returns 最新身份;设备已被撤销时返回 undefined。
|
|
8243
|
+
*/
|
|
8244
|
+
async refreshPrincipal(socket, state) {
|
|
8245
|
+
if (state.principal.kind !== "device") return state.principal;
|
|
8246
|
+
const revision = this.devicesRevisionOf();
|
|
8247
|
+
if (revision !== void 0 && revision === state.devicesRevision) return state.principal;
|
|
8248
|
+
const device = this.options.database.getActiveDevice(state.principal.id);
|
|
8249
|
+
if (device === void 0) return void 0;
|
|
8250
|
+
const next = {
|
|
8251
|
+
kind: "device",
|
|
8252
|
+
id: device.id,
|
|
8253
|
+
name: device.name,
|
|
8254
|
+
scopes: new Set(device.scopes),
|
|
8255
|
+
rootIds: new Set(device.rootIds)
|
|
8256
|
+
};
|
|
8257
|
+
state.principal = next;
|
|
8258
|
+
state.devicesRevision = revision;
|
|
8259
|
+
return next;
|
|
6438
8260
|
}
|
|
6439
8261
|
async eventAllowed(principal, event) {
|
|
6440
8262
|
if (event.type.startsWith("file.") && !principal.scopes.has("files.read")) return false;
|
|
@@ -6445,6 +8267,20 @@ var RemoteApiServer = class {
|
|
|
6445
8267
|
if (sessionId !== void 0) return this.options.chat.isSessionAuthorized(principal, sessionId);
|
|
6446
8268
|
return rootId !== void 0 || principal.kind === "admin";
|
|
6447
8269
|
}
|
|
8270
|
+
/**
|
|
8271
|
+
* 等所有连接排空,**有上限**。
|
|
8272
|
+
*
|
|
8273
|
+
* `stop()` 不能无限等:一条读不动的 socket 会让插件卸载挂死,进而拖住进程退出。
|
|
8274
|
+
* 超时后照样会断开它们(`closeCurrent` 负责),只是不再尝试把队列里的东西送出去 ——
|
|
8275
|
+
* 卸载路径上「送达」本来就不是目标。
|
|
8276
|
+
*/
|
|
8277
|
+
async drainAll(timeoutMs) {
|
|
8278
|
+
if (this.pendingDrains.size === 0) return;
|
|
8279
|
+
const all = Promise.all([...this.pendingDrains]).then(() => void 0);
|
|
8280
|
+
await Promise.race([all, new Promise((resolve) => {
|
|
8281
|
+
setTimeout(resolve, timeoutMs).unref?.();
|
|
8282
|
+
})]);
|
|
8283
|
+
}
|
|
6448
8284
|
trimOperations() {
|
|
6449
8285
|
while (this.operations.size > 20) {
|
|
6450
8286
|
const first = this.operations.keys().next().value;
|
|
@@ -6454,6 +8290,7 @@ var RemoteApiServer = class {
|
|
|
6454
8290
|
}
|
|
6455
8291
|
};
|
|
6456
8292
|
function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
8293
|
+
assertExtraRoutePrefixes(extraRoutes);
|
|
6457
8294
|
const disposers = [webServer.register({
|
|
6458
8295
|
kind: "prefix",
|
|
6459
8296
|
path: "/dsh-workspace-api",
|
|
@@ -6463,6 +8300,29 @@ function attachEmbeddedRoutes(webServer, router, remote, extraRoutes = []) {
|
|
|
6463
8300
|
for (const dispose of disposers.reverse()) dispose();
|
|
6464
8301
|
};
|
|
6465
8302
|
}
|
|
8303
|
+
/**
|
|
8304
|
+
* `extraRoutes` 只许挂在 `/plugin/` 下 —— **启动即断言**,违例直接抛。
|
|
8305
|
+
*
|
|
8306
|
+
* ## 为什么这是一条启动断言,而不是一句注释
|
|
8307
|
+
*
|
|
8308
|
+
* `extraRoutes` 的全部理由是「浏览器 `<img src>` 带不了 `Authorization` 头」,
|
|
8309
|
+
* 所以那条路由不校验设备 token,改用「回环 + 同源 + 裸文件名白名单 + 字节自证」。
|
|
8310
|
+
* 那是一套**为只读图片量身定的**信任模型。
|
|
8311
|
+
*
|
|
8312
|
+
* 只要有人往这个座位里塞一条别的东西(比如一个能读文件的接口),那套模型就
|
|
8313
|
+
* 立刻成了漏洞:免令牌 + 只有回环判据,等于任何能从本机发起请求的页面都能用它。
|
|
8314
|
+
* 而这种事**不会在 dev 环境暴露** —— 本机回环一切正常,装到用户机器上才变成
|
|
8315
|
+
* 「谁都能读」。
|
|
8316
|
+
*
|
|
8317
|
+
* 所以把「只许 `/plugin/`」写成断言而不是注释:注释管不住下一个人,
|
|
8318
|
+
* 断言会在插件**启动时**立刻红,且在 CI 里红在测试而不是用户机器上。
|
|
8319
|
+
*
|
|
8320
|
+
* `/dsh-workspace-api` 这个**既有**前缀不受影响:它是上面那条固定路由,
|
|
8321
|
+
* 不经过 `extraRoutes`。
|
|
8322
|
+
*/
|
|
8323
|
+
function assertExtraRoutePrefixes(extraRoutes) {
|
|
8324
|
+
for (const route of extraRoutes) if (!route.path.startsWith("/plugin/")) throw new Error(`dsh-remote-bridge: extraRoutes may only be registered under /plugin/ (got "${route.path}"). Routes on this seat bypass the device-token check, so they are restricted to the read-only plugin surface.`);
|
|
8325
|
+
}
|
|
6466
8326
|
function publicOperation(operation) {
|
|
6467
8327
|
return {
|
|
6468
8328
|
id: operation.id,
|
|
@@ -6507,15 +8367,26 @@ function parseProtocolToken(value) {
|
|
|
6507
8367
|
if (joined === void 0) return void 0;
|
|
6508
8368
|
return joined.split(",").map((item) => item.trim()).find((item) => item.startsWith("bearer."))?.slice(7);
|
|
6509
8369
|
}
|
|
8370
|
+
/**
|
|
8371
|
+
* 500 兜底。
|
|
8372
|
+
*
|
|
8373
|
+
* `requestId` **必须**复用 router 已经写进 `X-Request-Id` 响应头的那个值:
|
|
8374
|
+
* 用户报障时给的是 body 里的 id,而运维翻日志靠的是头里的 id —— 两个不同的
|
|
8375
|
+
* UUID 等于「这条线索永远搜不到」。修复前这里**另生成一个** `randomUUID()`。
|
|
8376
|
+
*
|
|
8377
|
+
* 头不存在时(理论上只有 router 之外的调用方)才新生成,保证字段不为空。
|
|
8378
|
+
*/
|
|
6510
8379
|
function failResponse(res, error) {
|
|
6511
8380
|
if (res.headersSent) {
|
|
6512
8381
|
res.destroy(error instanceof Error ? error : void 0);
|
|
6513
8382
|
return;
|
|
6514
8383
|
}
|
|
8384
|
+
const headerValue = res.getHeader("x-request-id");
|
|
8385
|
+
const requestId = typeof headerValue === "string" && headerValue !== "" ? headerValue : randomUUID();
|
|
6515
8386
|
const body = Buffer.from(JSON.stringify({ error: {
|
|
6516
8387
|
code: "INTERNAL_ERROR",
|
|
6517
8388
|
message: "An internal server error occurred.",
|
|
6518
|
-
requestId
|
|
8389
|
+
requestId
|
|
6519
8390
|
} }));
|
|
6520
8391
|
res.writeHead(500, {
|
|
6521
8392
|
"Content-Type": "application/json",
|
|
@@ -6525,6 +8396,15 @@ function failResponse(res, error) {
|
|
|
6525
8396
|
}
|
|
6526
8397
|
//#endregion
|
|
6527
8398
|
//#region src/host/settings-adapter.ts
|
|
8399
|
+
/**
|
|
8400
|
+
* provider 上下文的复用窗口。
|
|
8401
|
+
*
|
|
8402
|
+
* 1 秒足以覆盖「同一个 PATCH 请求里的连续两次 context 查询」,又短到不会让
|
|
8403
|
+
* 用户可感知地读到旧值(真正的并发控制由 `expectedRevision` 负责)。
|
|
8404
|
+
*/
|
|
8405
|
+
const PROVIDER_CONTEXT_TTL_MS = 1e3;
|
|
8406
|
+
/** 缓存条目上限;到顶就整表清空(条目数与 provider 数量同阶,很小)。 */
|
|
8407
|
+
const PROVIDER_CONTEXT_CACHE_LIMIT = 128;
|
|
6528
8408
|
var DshSettingsAdapter = class {
|
|
6529
8409
|
api;
|
|
6530
8410
|
constructor(api) {
|
|
@@ -6675,6 +8555,7 @@ var DshSettingsAdapter = class {
|
|
|
6675
8555
|
}
|
|
6676
8556
|
}));
|
|
6677
8557
|
}
|
|
8558
|
+
this.invalidateProviderContext(providerId);
|
|
6678
8559
|
}
|
|
6679
8560
|
async createProvider(input) {
|
|
6680
8561
|
const id = input.id.trim();
|
|
@@ -6725,6 +8606,7 @@ var DshSettingsAdapter = class {
|
|
|
6725
8606
|
value: key
|
|
6726
8607
|
}
|
|
6727
8608
|
}));
|
|
8609
|
+
this.invalidateProviderContext(id);
|
|
6728
8610
|
}
|
|
6729
8611
|
async removeProvider(providerId, expectedRevision) {
|
|
6730
8612
|
const context = await this.providerContext(providerId);
|
|
@@ -6741,6 +8623,7 @@ var DshSettingsAdapter = class {
|
|
|
6741
8623
|
...expectedRevision === void 0 ? {} : { expectedRevision }
|
|
6742
8624
|
}
|
|
6743
8625
|
}));
|
|
8626
|
+
this.invalidateProviderContext(providerId);
|
|
6744
8627
|
}
|
|
6745
8628
|
async discover(providerId, draft) {
|
|
6746
8629
|
const payload = {
|
|
@@ -6755,7 +8638,47 @@ var DshSettingsAdapter = class {
|
|
|
6755
8638
|
payload
|
|
6756
8639
|
})).models;
|
|
6757
8640
|
}
|
|
8641
|
+
/**
|
|
8642
|
+
* 取一个 provider 的上下文(目录条目 + 设置命名空间 + 当前 profile)。
|
|
8643
|
+
*
|
|
8644
|
+
* ## 为什么加一层「单请求内复用」
|
|
8645
|
+
*
|
|
8646
|
+
* 修复前 `updateProvider()` 会**连续**调它两次(一次在开头做校验、一次在
|
|
8647
|
+
* `createProvider`/`removeProvider` 里),而每次 `providerContext` 都要打
|
|
8648
|
+
* **两次**内核 RPC(`llm.providers` + `settings.describe`)。于是一次 PATCH
|
|
8649
|
+
* 实际可能产生 4~5 次内核往返 —— 而它们问的是**同一份数据**。
|
|
8650
|
+
*
|
|
8651
|
+
* 这里用一个极短的 TTL(默认 1 秒,覆盖「同一请求内」的连续调用)缓存结果。
|
|
8652
|
+
* 为什么不用「请求作用域」的显式传参:那要改每个方法的签名,而调用点散在
|
|
8653
|
+
* router 与 adapter 两处 —— 收益远不抵这次改动。1 秒 TTL 的副作用是:
|
|
8654
|
+
* 同一秒内两次改同一个 provider 时,第二次读到的是第一次之前的快照 ——
|
|
8655
|
+
* 而 `settings.mutate` 有 `expectedRevision` 做真正的并发控制,所以不会写坏。
|
|
8656
|
+
*/
|
|
8657
|
+
providerContextCache = /* @__PURE__ */ new Map();
|
|
6758
8658
|
async providerContext(providerId) {
|
|
8659
|
+
const cached = this.providerContextCache.get(providerId);
|
|
8660
|
+
const now = Date.now();
|
|
8661
|
+
if (cached !== void 0 && now - cached.at < PROVIDER_CONTEXT_TTL_MS) return cached.value;
|
|
8662
|
+
const value = await this.loadProviderContext(providerId);
|
|
8663
|
+
if (this.providerContextCache.size >= PROVIDER_CONTEXT_CACHE_LIMIT) this.providerContextCache.clear();
|
|
8664
|
+
this.providerContextCache.set(providerId, {
|
|
8665
|
+
at: now,
|
|
8666
|
+
value
|
|
8667
|
+
});
|
|
8668
|
+
return value;
|
|
8669
|
+
}
|
|
8670
|
+
/**
|
|
8671
|
+
* 写操作之后**必须**让缓存失效。
|
|
8672
|
+
*
|
|
8673
|
+
* 为什么不能只靠 TTL:`updateProvider` 改完 baseURL 之后,同一个请求尾部的
|
|
8674
|
+
* `listProviders()` 要给出新值。靠 TTL 等 1 秒等于把一个「刚刚改成功」的
|
|
8675
|
+
* 界面回滚成旧值 —— 那会被用户当成「改了没生效」。
|
|
8676
|
+
*/
|
|
8677
|
+
invalidateProviderContext(providerId) {
|
|
8678
|
+
if (providerId === void 0) this.providerContextCache.clear();
|
|
8679
|
+
else this.providerContextCache.delete(providerId);
|
|
8680
|
+
}
|
|
8681
|
+
async loadProviderContext(providerId) {
|
|
6759
8682
|
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
6760
8683
|
rpcId: randomUUID(),
|
|
6761
8684
|
payload: {}
|
|
@@ -6914,9 +8837,11 @@ async function apply(ctx, config = {}) {
|
|
|
6914
8837
|
} catch (error) {
|
|
6915
8838
|
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: could not purge revoked devices: ${String(error)}`));
|
|
6916
8839
|
}
|
|
8840
|
+
runStartupRetention(database, ctx.logger);
|
|
6917
8841
|
await registerBuiltinWorkspaceRoot(ctx, database);
|
|
6918
8842
|
const events = new WorkspaceEventBus();
|
|
6919
|
-
const
|
|
8843
|
+
const tunnels = new CloudflaredTunnelManager();
|
|
8844
|
+
const auth = new AuthService(database, { tunnelActive: () => tunnels.tunnelActive });
|
|
6920
8845
|
const files = new FileService(database, (event) => events.emit(event));
|
|
6921
8846
|
const imageHandoffDir = path.join(dataDir, "image-handoff");
|
|
6922
8847
|
const handoffRootsOf = () => database.listRoots().map((root) => ({
|
|
@@ -6954,7 +8879,8 @@ async function apply(ctx, config = {}) {
|
|
|
6954
8879
|
auth,
|
|
6955
8880
|
chat,
|
|
6956
8881
|
events,
|
|
6957
|
-
handle: (req, res) => router.handle(req, res)
|
|
8882
|
+
handle: (req, res) => router.handle(req, res),
|
|
8883
|
+
capabilities: { jobs: jobControllerAvailable }
|
|
6958
8884
|
});
|
|
6959
8885
|
router = new ApiRouter({
|
|
6960
8886
|
database,
|
|
@@ -6963,7 +8889,7 @@ async function apply(ctx, config = {}) {
|
|
|
6963
8889
|
chat,
|
|
6964
8890
|
settings,
|
|
6965
8891
|
remote,
|
|
6966
|
-
tunnels
|
|
8892
|
+
tunnels,
|
|
6967
8893
|
cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
|
|
6968
8894
|
maxUploadBytes,
|
|
6969
8895
|
imageFallbackDir: imageHandoffDir,
|