dsh-remote-bridge 2.4.3 → 2.5.1
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 +31 -10
- package/README.md +28 -10
- package/cordis.patch.yml +14 -10
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +22 -3
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +33 -0
- package/lib/client.cjs +12 -0
- package/lib/index.d.ts +107 -8
- package/lib/index.js +837 -450
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import path, { join } from "node:path";
|
|
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
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -44,6 +44,261 @@ 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
|
+
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
56
|
+
/** settings 表里的键名。 */
|
|
57
|
+
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
58
|
+
/**
|
|
59
|
+
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
60
|
+
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
61
|
+
*/
|
|
62
|
+
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
63
|
+
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
64
|
+
const MAX_SUBDIR_CHARS = 64;
|
|
65
|
+
/**
|
|
66
|
+
* 把用户填的目录名收成一个安全值。
|
|
67
|
+
*
|
|
68
|
+
* 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
|
|
69
|
+
* 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
|
|
70
|
+
* 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
|
|
71
|
+
*/
|
|
72
|
+
function normalizeHandoffSubdir(value) {
|
|
73
|
+
if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
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.`);
|
|
76
|
+
if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
|
|
77
|
+
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.");
|
|
78
|
+
return trimmed;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 读出当前生效的目录名。
|
|
82
|
+
*
|
|
83
|
+
* 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
|
|
84
|
+
* 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
|
|
85
|
+
*/
|
|
86
|
+
function readHandoffSubdir(database) {
|
|
87
|
+
const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
|
|
88
|
+
if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
|
|
89
|
+
try {
|
|
90
|
+
return normalizeHandoffSubdir(stored);
|
|
91
|
+
} catch {
|
|
92
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
|
|
93
|
+
return DEFAULT_HANDOFF_SUBDIR;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 校验并保存,返回落库后的值。 */
|
|
97
|
+
function saveHandoffSubdir(database, value) {
|
|
98
|
+
const normalized = normalizeHandoffSubdir(value);
|
|
99
|
+
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|
|
102
|
+
/** 设置面板要的那三个值。 */
|
|
103
|
+
function imageHandoffView(database, fallbackDir) {
|
|
104
|
+
return {
|
|
105
|
+
subdir: readHandoffSubdir(database),
|
|
106
|
+
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
107
|
+
fallbackDir
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
|
|
111
|
+
const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
112
|
+
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
113
|
+
const HANDOFF_MEDIA_TYPES = {
|
|
114
|
+
png: "image/png",
|
|
115
|
+
jpg: "image/jpeg",
|
|
116
|
+
jpeg: "image/jpeg",
|
|
117
|
+
webp: "image/webp",
|
|
118
|
+
gif: "image/gif",
|
|
119
|
+
heic: "image/heic",
|
|
120
|
+
heif: "image/heif",
|
|
121
|
+
bmp: "image/bmp",
|
|
122
|
+
avif: "image/avif"
|
|
123
|
+
};
|
|
124
|
+
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
125
|
+
const MAX_BYTES = 33554432;
|
|
126
|
+
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
127
|
+
const MAX_NAME_CHARS = 4096;
|
|
128
|
+
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
129
|
+
function declaredMediaType(name) {
|
|
130
|
+
const cut = name.lastIndexOf(".");
|
|
131
|
+
if (cut === -1) return void 0;
|
|
132
|
+
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 「扩展名声明」与「字节自证」是否属于同一图片家族。
|
|
136
|
+
*
|
|
137
|
+
* 为什么需要这一层而不是直接 `declared === sniffed`:**HEIC 与 HEIF 在字节层面无法区分**。
|
|
138
|
+
* 两者都是 ISO-BMFF,靠 `ftyp` 后面的 brand 认家族,而 `heic`/`heif`/`mif1`/`hevc`
|
|
139
|
+
* 都在同一套 `hei*` / `hev*` / `mif*` 前缀里 —— 嗅探器只能诚实地报出「这是 HEI 家族」,
|
|
140
|
+
* 也就是 `image/heic`;它**不该**为了迁就某条扩展名而返回一个自己无法自证的值。
|
|
141
|
+
*
|
|
142
|
+
* 于是把「等价」这件事放在一致性校验这一侧:
|
|
143
|
+
* - 写盘表(`dsh-0.2-bridge.ts`)按内核给的 mediaType 原样产 `<hash>.heif`;
|
|
144
|
+
* - 读取表把 `heif` 声明成 `image/heif`;
|
|
145
|
+
* - 嗅探器对同一份字节返回 `image/heic`;
|
|
146
|
+
* - 两者同族 → 放行。
|
|
147
|
+
*
|
|
148
|
+
* 这不是把闸门拆了:**跨家族的错配仍然全部 415**(png 字节配 `.jpg` 名、
|
|
149
|
+
* `image/png` 配 `.avif` 名、heic 字节配 `.avif` 名)。只放开这一对无法在字节层
|
|
150
|
+
* 区分的「同一家族两个名字」。
|
|
151
|
+
*/
|
|
152
|
+
function sameImageFamily(declared, sniffed) {
|
|
153
|
+
if (declared === sniffed) return true;
|
|
154
|
+
const heifFamily = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
|
|
155
|
+
return heifFamily.has(declared) && heifFamily.has(sniffed);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
|
|
159
|
+
* @param head - 文件前至少 12 个字节。
|
|
160
|
+
*/
|
|
161
|
+
function sniffMediaType(head) {
|
|
162
|
+
const ascii = (offset, text) => {
|
|
163
|
+
if (head.length < offset + text.length) return false;
|
|
164
|
+
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
165
|
+
return true;
|
|
166
|
+
};
|
|
167
|
+
const bytes = (...expected) => {
|
|
168
|
+
if (head.length < expected.length) return false;
|
|
169
|
+
return expected.every((byte, index) => head[index] === byte);
|
|
170
|
+
};
|
|
171
|
+
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
172
|
+
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
173
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
174
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
175
|
+
if (bytes(66, 77)) return "image/bmp";
|
|
176
|
+
if (ascii(4, "ftyp")) {
|
|
177
|
+
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
178
|
+
if (brand.startsWith("avi")) return "image/avif";
|
|
179
|
+
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */ function loopbackHost(req) {
|
|
183
|
+
const host = req.headers.host;
|
|
184
|
+
if (typeof host !== "string" || host === "") return false;
|
|
185
|
+
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
186
|
+
const name = host.split(":")[0];
|
|
187
|
+
return name === "127.0.0.1" || name === "localhost";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 请求是否来自某个插件自己服务的页面。
|
|
191
|
+
*
|
|
192
|
+
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
193
|
+
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
194
|
+
*/
|
|
195
|
+
function pluginPageReferer(req) {
|
|
196
|
+
const referer = req.headers.referer;
|
|
197
|
+
if (typeof referer !== "string" || referer === "") return false;
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL(referer);
|
|
200
|
+
if (url.host !== req.headers.host) return false;
|
|
201
|
+
return url.pathname.startsWith("/plugin/");
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 请求是否可以被回答。
|
|
208
|
+
*
|
|
209
|
+
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
210
|
+
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
211
|
+
*/
|
|
212
|
+
function sameOrigin(req) {
|
|
213
|
+
if (!loopbackHost(req)) return false;
|
|
214
|
+
if (pluginPageReferer(req)) return true;
|
|
215
|
+
const site = req.headers["sec-fetch-site"];
|
|
216
|
+
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
217
|
+
const origin = req.headers.origin;
|
|
218
|
+
if (typeof origin !== "string" || origin === "") return true;
|
|
219
|
+
try {
|
|
220
|
+
return new URL(origin).host === req.headers.host;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
226
|
+
function refuse(res, status, reason) {
|
|
227
|
+
const body = `${reason}\n`;
|
|
228
|
+
res.writeHead(status, {
|
|
229
|
+
"content-type": "text/plain; charset=utf-8",
|
|
230
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
231
|
+
"cache-control": "no-store",
|
|
232
|
+
"x-content-type-options": "nosniff"
|
|
233
|
+
});
|
|
234
|
+
res.end(body);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 这条路由的处理器工厂。
|
|
238
|
+
* @param options - 取图实现。
|
|
239
|
+
* @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
|
|
240
|
+
*/
|
|
241
|
+
function createImageRouteHandler(options) {
|
|
242
|
+
return async function serveImage(req, res) {
|
|
243
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
244
|
+
refuse(res, 405, "method not allowed");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!sameOrigin(req)) {
|
|
248
|
+
refuse(res, 403, "cross-site request refused");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
252
|
+
const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
|
|
253
|
+
const rawName = url.searchParams.get("name") ?? "";
|
|
254
|
+
if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
|
|
255
|
+
refuse(res, 400, "sessionId and name are required");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const name = rawName.split(/[/\\]/).pop() ?? rawName;
|
|
259
|
+
if (HANDOFF_FILE_NAME.exec(name) === null) {
|
|
260
|
+
refuse(res, 404, "no such handoff image");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const declared = declaredMediaType(name);
|
|
264
|
+
let image;
|
|
265
|
+
try {
|
|
266
|
+
image = await options.read(sessionId, name);
|
|
267
|
+
} catch {
|
|
268
|
+
refuse(res, 404, "no such handoff image");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (image.bytes.byteLength === 0) {
|
|
272
|
+
refuse(res, 404, "empty file");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (image.bytes.byteLength > MAX_BYTES) {
|
|
276
|
+
refuse(res, 413, "image is too large to display");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
280
|
+
if (sniffed === void 0) {
|
|
281
|
+
refuse(res, 415, "content is not a supported image");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (declared !== void 0 && !sameImageFamily(declared, sniffed)) {
|
|
285
|
+
refuse(res, 415, "content does not match the file extension");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
res.writeHead(200, {
|
|
289
|
+
"content-type": sniffed,
|
|
290
|
+
"content-length": String(image.bytes.byteLength),
|
|
291
|
+
"cache-control": "private, max-age=300",
|
|
292
|
+
"x-content-type-options": "nosniff"
|
|
293
|
+
});
|
|
294
|
+
if (req.method === "HEAD") {
|
|
295
|
+
res.end();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
res.end(Buffer.from(image.bytes.buffer, image.bytes.byteOffset, image.bytes.byteLength));
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
47
302
|
//#region src/host/rate-limit.ts
|
|
48
303
|
var FixedWindowLimiter = class {
|
|
49
304
|
limit;
|
|
@@ -74,19 +329,142 @@ var FixedWindowLimiter = class {
|
|
|
74
329
|
};
|
|
75
330
|
//#endregion
|
|
76
331
|
//#region src/host/auth.ts
|
|
332
|
+
/**
|
|
333
|
+
* 判定「这是不是一次真正的本机管理请求」。
|
|
334
|
+
*
|
|
335
|
+
* ## 为什么不能只看 `remoteAddress`
|
|
336
|
+
*
|
|
337
|
+
* 修复前 `requireAdmin` 的全部判据是「TCP 来源是回环 + Origin 是回环」。这条判据在
|
|
338
|
+
* **任何把外部请求转到本机回环的拓扑下都恒真**:插件自带的 cloudflared Quick Tunnel
|
|
339
|
+
* 用 `--url http://127.0.0.1:PORT` 转发(`cloudflared.ts`),公网请求进到 bridge 时
|
|
340
|
+
* `req.socket.remoteAddress` 就是 `127.0.0.1`。于是 `https://xxx.trycloudflare.com/manage/roots`
|
|
341
|
+
* 一个凭据都不带也能读到全部授权根的绝对路径(v2.5.0 实测 200/201)。
|
|
342
|
+
* 同一类拓扑还包括 ngrok、frp、Caddy/Nginx `proxy_pass 127.0.0.1`、SSH 端口转发、
|
|
343
|
+
* Tailscale Serve。见 `docs/project/PITFALLS.md` §隧道让回环判据失效。
|
|
344
|
+
*
|
|
345
|
+
* ## 四层判据(层层收紧,`undefined` = 放行)
|
|
346
|
+
*
|
|
347
|
+
* 0. **隧道运行中一律拒绝**(`tunnelActive === true`)—— 100% 可靠的一条:隧道是我们
|
|
348
|
+
* 自己拉的进程,状态握在自己手里,不依赖任何可伪造的请求头。返回
|
|
349
|
+
* `TUNNEL_MODE_ADMIN_DISABLED`。
|
|
350
|
+
* 1. **出现任何转发头即视为经代理** —— `Forwarded` / `X-Forwarded-For` /
|
|
351
|
+
* `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP`。真正的本机浏览器与
|
|
352
|
+
* `curl 127.0.0.1` 都不会发这些头;发了就说明前面有一层我们不认识的代理。
|
|
353
|
+
* 返回 `ADMIN_PROXY_FORWARDED`。
|
|
354
|
+
* 2. **`Host` 必须是回环**(复用 `image-handoff.ts` 的 `loopbackHost`,它接受
|
|
355
|
+
* `127.0.0.1` / `localhost` / `[::1]`)。缺失 `Host` 也拒。这一条挡两类攻击:
|
|
356
|
+
* 隧道/反代转发进来的公网域名(`Host: xxx.trycloudflare.com`),以及 **DNS 重绑定**
|
|
357
|
+
* (攻击者域名解析到 127.0.0.1,浏览器照样发回环 `Origin`,只有 `Host` 露馅)。
|
|
358
|
+
* 返回 `ADMIN_HOST_NOT_LOOPBACK`。
|
|
359
|
+
* 3. **socket 来源必须是回环,`Origin` 若存在必须是回环**。`Origin` 缺失**仍然放行** ——
|
|
360
|
+
* 这是刻意的取舍,理由见下。
|
|
361
|
+
*
|
|
362
|
+
* ## 为什么 `Origin` 缺失还放行(刻意的取舍,别「顺手修掉」)
|
|
363
|
+
*
|
|
364
|
+
* 翻成「必须带回环 Origin」能多挡一点,但会**同时打死 `curl` 与脚本调试**:它们在
|
|
365
|
+
* 本机跑、天然不带 `Origin`,而本机进程本来就有权读这些文件(它们能直接 `cat`)。
|
|
366
|
+
* 真正的防线不是「请求必须像浏览器」,而是**拓扑与头**:判据 0 管住了我们自己拉的隧道,
|
|
367
|
+
* 判据 1 管住了任何代理,判据 2 管住了公网域名与 DNS 重绑定。隧道场景里公网请求即使
|
|
368
|
+
* 不带 `Origin` 也会先撞上判据 0/1/2 —— 这正是 §3.1 那条攻击链的断点。
|
|
369
|
+
*
|
|
370
|
+
* 换句话说:**允许缺 `Origin` 不再是「可信」,而是「不再有决定权」**。修复前它是有
|
|
371
|
+
* 决定权的(`isLoopbackOrigin(undefined) === true` 直接就给了管理员身份)。
|
|
372
|
+
*
|
|
373
|
+
* @param req - 待判定的请求。
|
|
374
|
+
* @param options.tunnelActive - 是否有我们自管的隧道在运行;缺省视为没有。
|
|
375
|
+
* @param options.allowTunnelMode - 仅用于「隧道状态」这类**只读、无副作用**的端点:
|
|
376
|
+
* 让它们豁免判据 0(否则隧道一开,面板连「隧道开着」这件事都读不到),
|
|
377
|
+
* 判据 1/2/3 仍然全部生效。**其他任何 `/manage` 端点都不许传这个选项。**
|
|
378
|
+
*/
|
|
379
|
+
function adminRefusal(req, options = {}) {
|
|
380
|
+
if (options.tunnelActive === true && options.allowTunnelMode !== true) return {
|
|
381
|
+
code: "TUNNEL_MODE_ADMIN_DISABLED",
|
|
382
|
+
message: "Management endpoints are disabled while the Cloudflare tunnel is running. Use the local WebUI, or stop the tunnel first."
|
|
383
|
+
};
|
|
384
|
+
for (const name of FORWARDING_HEADERS) if (headerPresent(req, name)) return {
|
|
385
|
+
code: "ADMIN_PROXY_FORWARDED",
|
|
386
|
+
message: "Management endpoints are available only from the local machine, not through a proxy or tunnel."
|
|
387
|
+
};
|
|
388
|
+
if (!loopbackHost(req)) return {
|
|
389
|
+
code: "ADMIN_HOST_NOT_LOOPBACK",
|
|
390
|
+
message: "Management endpoints are available only under a loopback host name (127.0.0.1, localhost or [::1])."
|
|
391
|
+
};
|
|
392
|
+
if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) return {
|
|
393
|
+
code: "ADMIN_LOOPBACK_REQUIRED",
|
|
394
|
+
message: "This operation is available only from the local machine."
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 「这个请求不是本机直连」的**全部**证据头。
|
|
399
|
+
*
|
|
400
|
+
* 分两组,理由不同:
|
|
401
|
+
*
|
|
402
|
+
* 1. **转发头**(`Forwarded` / `X-Forwarded-*` / `X-Real-IP`):本机浏览器与
|
|
403
|
+
* `curl 127.0.0.1` 都不会发。**存在即拒**,不看值 —— `X-Forwarded-For: 127.0.0.1`
|
|
404
|
+
* 正是最典型的洗白写法(攻击者从隧道进来,塞一个声称自己是本机的头)。
|
|
405
|
+
* 2. **Cloudflare 边缘头**(`Cf-Connecting-IP` / `Cf-Ray` / `Cf-IpCountry` / `Cf-Visitor`):
|
|
406
|
+
* 这一组是**真实隧道场景的兜底**。cloudflared 的 `httpHostHeader` 默认为空,会把
|
|
407
|
+
* `Host` 改写成源站地址(`127.0.0.1:PORT`),于是判据 2 在真实隧道下挡不住;
|
|
408
|
+
* 而 Cloudflare 边缘一定会注入这组头(且请求方无法伪造成「我没有经过边缘」)。
|
|
409
|
+
* 实测(`.local-backup/security-audit/verify-fix-tunnel-admin.mjs`):
|
|
410
|
+
* 只把 Host 改成回环地址就能拿到 200,加上这组头才被挡住。
|
|
411
|
+
*
|
|
412
|
+
* 这两组都不参与「判据 0 的豁免」—— 隧道状态端点仍要求它们为空。
|
|
413
|
+
*/
|
|
414
|
+
const FORWARDING_HEADERS = [
|
|
415
|
+
"forwarded",
|
|
416
|
+
"x-forwarded-for",
|
|
417
|
+
"x-forwarded-host",
|
|
418
|
+
"x-forwarded-proto",
|
|
419
|
+
"x-real-ip",
|
|
420
|
+
"cf-connecting-ip",
|
|
421
|
+
"cf-ray",
|
|
422
|
+
"cf-ipcountry",
|
|
423
|
+
"cf-visitor"
|
|
424
|
+
];
|
|
425
|
+
/** 头存在且非空才算「出现」:空串是某些代理库的产物,不构成声明。 */
|
|
426
|
+
function headerPresent(req, name) {
|
|
427
|
+
const raw = req.headers[name];
|
|
428
|
+
if (raw === void 0) return false;
|
|
429
|
+
if (Array.isArray(raw)) return raw.some((value) => value.trim() !== "");
|
|
430
|
+
return raw.trim() !== "";
|
|
431
|
+
}
|
|
77
432
|
var AuthService = class {
|
|
78
433
|
database;
|
|
434
|
+
options;
|
|
79
435
|
authFailures = new FixedWindowLimiter(20, 3e5);
|
|
80
436
|
pairingAttempts = new FixedWindowLimiter(10, 6e5);
|
|
81
437
|
requests = new FixedWindowLimiter(120, 6e4);
|
|
82
|
-
constructor(database) {
|
|
438
|
+
constructor(database, options = {}) {
|
|
83
439
|
this.database = database;
|
|
440
|
+
this.options = options;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* 隧道是否在跑。探针抛错时**按「在跑」处理**(fail closed):探针读的是我们自己
|
|
444
|
+
* 对象上的 getter,它抛错意味着状态不可知;状态不可知时放行管理面正是这次修复要
|
|
445
|
+
* 消灭的那类决定。
|
|
446
|
+
*/
|
|
447
|
+
tunnelRunning() {
|
|
448
|
+
const probe = this.options.tunnelActive;
|
|
449
|
+
if (probe === void 0) return false;
|
|
450
|
+
try {
|
|
451
|
+
return probe() === true;
|
|
452
|
+
} catch {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
84
455
|
}
|
|
85
456
|
principal(req, options = {}) {
|
|
86
457
|
const remote = requestAddress(req);
|
|
87
458
|
this.requests.consume(remote);
|
|
88
|
-
if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1"
|
|
89
|
-
|
|
459
|
+
if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1") {
|
|
460
|
+
const refusal = adminRefusal(req, { tunnelActive: this.tunnelRunning() });
|
|
461
|
+
if (refusal !== void 0) {
|
|
462
|
+
this.authFailures.consume(remote);
|
|
463
|
+
throw new ApiError(403, refusal.code, refusal.message);
|
|
464
|
+
}
|
|
465
|
+
return ADMIN_PRINCIPAL;
|
|
466
|
+
}
|
|
467
|
+
const authorization = req.headers.authorization;
|
|
90
468
|
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
91
469
|
this.authFailures.consume(remote);
|
|
92
470
|
throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
|
|
@@ -105,8 +483,18 @@ var AuthService = class {
|
|
|
105
483
|
rootIds: new Set(device.rootIds)
|
|
106
484
|
};
|
|
107
485
|
}
|
|
108
|
-
|
|
109
|
-
|
|
486
|
+
/**
|
|
487
|
+
* `/manage/**` 的守门人。
|
|
488
|
+
*
|
|
489
|
+
* @param options.allowTunnelMode - 只有「隧道状态下读/停隧道」这几个端点会传 true。
|
|
490
|
+
* 见 `adminRefusal` 的同名说明与 `router.ts` 的 `TUNNEL_MODE_EXEMPT_PATHS`。
|
|
491
|
+
*/
|
|
492
|
+
requireAdmin(req, options = {}) {
|
|
493
|
+
const refusal = adminRefusal(req, {
|
|
494
|
+
tunnelActive: this.tunnelRunning(),
|
|
495
|
+
...options.allowTunnelMode === true ? { allowTunnelMode: true } : {}
|
|
496
|
+
});
|
|
497
|
+
if (refusal !== void 0) throw new ApiError(403, refusal.code, refusal.message);
|
|
110
498
|
return ADMIN_PRINCIPAL;
|
|
111
499
|
}
|
|
112
500
|
requireScope(principal, scope) {
|
|
@@ -123,6 +511,13 @@ function isLoopbackAddress(address) {
|
|
|
123
511
|
const normalized = address.toLowerCase().split("%")[0];
|
|
124
512
|
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
|
125
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* `Origin` 是否是回环。
|
|
516
|
+
*
|
|
517
|
+
* `undefined` 返回 `true` 是**刻意保留**的(理由见 `adminRefusal` 的注释):它不再
|
|
518
|
+
* 单独构成「可信」,判据 0/1/2 已经承担了决定性判断。改这一行会让 `curl` 调试
|
|
519
|
+
* 无处可去,而本机进程本来就能直接读这些文件。
|
|
520
|
+
*/
|
|
126
521
|
function isLoopbackOrigin(origin) {
|
|
127
522
|
if (origin === void 0) return true;
|
|
128
523
|
try {
|
|
@@ -134,10 +529,22 @@ function isLoopbackOrigin(origin) {
|
|
|
134
529
|
}
|
|
135
530
|
//#endregion
|
|
136
531
|
//#region src/host/cloudflared.ts
|
|
137
|
-
/**
|
|
138
|
-
|
|
532
|
+
/**
|
|
533
|
+
* 平台/架构 -> cloudflared 归档后缀。
|
|
534
|
+
*
|
|
535
|
+
* **架构必须能单独传**,不能读 `process.arch`:
|
|
536
|
+
* 2026-10-20 CI 实锤 —— 旧实现里 platform 是参数、arch 却写死 `process.arch`,
|
|
537
|
+
* 于是 `archSuffix('linux')` 在 macOS runner(arm64)上返回 `linux-arm64`,
|
|
538
|
+
* 而调用方要的是「linux 平台该用哪个官方归档」。同一函数里 platform 可控、
|
|
539
|
+
* arch 不可控,是**自相矛盾的设计**;测试在 macOS 上红、Linux 上绿,全平台矩阵
|
|
540
|
+
* (ubuntu/windows/macos × 两个 Node 版本)里只有 Linux 那两格能过。
|
|
541
|
+
*
|
|
542
|
+
* 两个参数都带默认值时保持旧行为(本机平台 + 本机架构);显式传入时,
|
|
543
|
+
* 结果只由入参决定 —— 这才让「算一个别的平台的后缀」成为可能。
|
|
544
|
+
*/
|
|
545
|
+
function archSuffix(platform = process.platform, arch = process.arch) {
|
|
139
546
|
if (platform !== "linux") throw new Error(`cloudflared 尚不支持 ${platform} 平台。`);
|
|
140
|
-
switch (
|
|
547
|
+
switch (arch) {
|
|
141
548
|
case "x64": return "linux-amd64";
|
|
142
549
|
case "arm64": return "linux-arm64";
|
|
143
550
|
default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
|
|
@@ -197,8 +604,20 @@ function isDebSource() {
|
|
|
197
604
|
return mirrorBase().startsWith("deb+");
|
|
198
605
|
}
|
|
199
606
|
/** 返回要下载的源 URL:deb 源返回完整 .deb 地址;裸镜像基址拼架构后缀;否则官方 GitHub。 */
|
|
200
|
-
|
|
201
|
-
|
|
607
|
+
/**
|
|
608
|
+
* cloudflared 官方归档的下载地址。
|
|
609
|
+
*
|
|
610
|
+
* **platform/arch 都能显式传入**(2026-10-20 CI 实锤后加):旧实现里
|
|
611
|
+
* `downloadUrlFor()` 走 `archSuffix()` 的默认参数 —— 也就是**本机** platform/arch。
|
|
612
|
+
* 于是这个函数在 macOS runner 上直接抛「尚不支持 darwin 平台」,
|
|
613
|
+
* 而它要回答的问题其实是「linux 的 cloudflared 该从哪下」。
|
|
614
|
+
*
|
|
615
|
+
* 默认值保持本机(生产调用点 `ensureCloudflared` 就在 linux 上跑,行为不变),
|
|
616
|
+
* 但显式传入后结果只由入参决定 —— 这才让「算别的平台的 URL」成为可能,
|
|
617
|
+
* 也让测试不依赖跑在什么机器上。
|
|
618
|
+
*/
|
|
619
|
+
function downloadUrlFor(platform = process.platform, arch = process.arch) {
|
|
620
|
+
const suffix = archSuffix(platform, arch);
|
|
202
621
|
const mirror = mirrorBase();
|
|
203
622
|
if (mirror === "") return `${OFFICIAL_BASE}${suffix}`;
|
|
204
623
|
if (mirror.startsWith("deb+")) return mirror.slice(4);
|
|
@@ -279,406 +698,186 @@ async function ensureCloudflared(dataDir, report) {
|
|
|
279
698
|
return writeFromDeb(dataDir, res.body, url);
|
|
280
699
|
}
|
|
281
700
|
report?.({
|
|
282
|
-
stage: "unpacking",
|
|
283
|
-
label: "写入二进制并赋予执行权限…",
|
|
284
|
-
percent: null
|
|
285
|
-
});
|
|
286
|
-
const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
|
|
287
|
-
await writeFile(tmp, res.body);
|
|
288
|
-
await chmod(tmp, 493);
|
|
289
|
-
await rename(tmp, existing);
|
|
290
|
-
return existing;
|
|
291
|
-
} catch (cause) {
|
|
292
|
-
errors.push(cause instanceof Error ? cause.message : String(cause));
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
const tried = candidates.map((c) => c.url).join("\n ");
|
|
296
|
-
throw new Error(`下载 cloudflared 失败:\n ${tried}\n原因:${errors.join(";")}`);
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
|
|
300
|
-
* 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
|
|
301
|
-
*/
|
|
302
|
-
async function writeFromDeb(dataDir, debBuffer, url) {
|
|
303
|
-
await mkdir(dataDir, { recursive: true });
|
|
304
|
-
const debPath = path.join(dataDir, `.cloudflared.deb-${process.pid}`);
|
|
305
|
-
const extractDir = path.join(dataDir, `.cloudflared-extract-${process.pid}`);
|
|
306
|
-
await writeFile(debPath, debBuffer);
|
|
307
|
-
await mkdir(extractDir, { recursive: true });
|
|
308
|
-
try {
|
|
309
|
-
await new Promise((resolve, reject) => {
|
|
310
|
-
execFile("dpkg-deb", [
|
|
311
|
-
"-x",
|
|
312
|
-
debPath,
|
|
313
|
-
extractDir
|
|
314
|
-
], (err) => err ? reject(err) : resolve());
|
|
315
|
-
});
|
|
316
|
-
const inner = path.join(extractDir, "usr", "bin", "cloudflared");
|
|
317
|
-
const out = path.join(dataDir, "cloudflared");
|
|
318
|
-
const bin = await readFile(inner);
|
|
319
|
-
await writeFile(out, bin);
|
|
320
|
-
await chmod(out, 493);
|
|
321
|
-
return out;
|
|
322
|
-
} finally {
|
|
323
|
-
await rm(debPath, { force: true }).catch(() => {});
|
|
324
|
-
await rm(extractDir, {
|
|
325
|
-
recursive: true,
|
|
326
|
-
force: true
|
|
327
|
-
}).catch(() => {});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
/** 一次 cloudflared 子进程生命周期 + 对外状态机。 */
|
|
331
|
-
var CloudflaredTunnelManager = class {
|
|
332
|
-
process = null;
|
|
333
|
-
url = null;
|
|
334
|
-
channel = null;
|
|
335
|
-
errorText = void 0;
|
|
336
|
-
listeners = [];
|
|
337
|
-
activity = {
|
|
338
|
-
stage: "idle",
|
|
339
|
-
label: "",
|
|
340
|
-
percent: null
|
|
341
|
-
};
|
|
342
|
-
/** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
|
|
343
|
-
get activityState() {
|
|
344
|
-
return this.activity;
|
|
345
|
-
}
|
|
346
|
-
/** 上报一条阶段进度(前端轮询 /cloudflare/activity 可见)。 */
|
|
347
|
-
reportActivity(a) {
|
|
348
|
-
this.activity = a;
|
|
349
|
-
this.emit();
|
|
350
|
-
}
|
|
351
|
-
/** 任务结束(成功或失败)时清空进行中进度。 */
|
|
352
|
-
clearActivity() {
|
|
353
|
-
this.activity = {
|
|
354
|
-
stage: "idle",
|
|
355
|
-
label: "",
|
|
356
|
-
percent: null
|
|
357
|
-
};
|
|
358
|
-
this.emit();
|
|
359
|
-
}
|
|
360
|
-
get publicState() {
|
|
361
|
-
return {
|
|
362
|
-
channel: this.channel,
|
|
363
|
-
url: this.url,
|
|
364
|
-
running: this.process !== null && !this.process.killed,
|
|
365
|
-
...this.errorText === void 0 ? {} : { error: this.errorText }
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
/** 订阅状态变化;返回取消函数。 */
|
|
369
|
-
onStatus(cb) {
|
|
370
|
-
this.listeners.push(cb);
|
|
371
|
-
return () => {
|
|
372
|
-
this.listeners = this.listeners.filter((fn) => fn !== cb);
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
emit() {
|
|
376
|
-
for (const fn of this.listeners) fn();
|
|
377
|
-
}
|
|
378
|
-
setError(message) {
|
|
379
|
-
this.errorText = message;
|
|
380
|
-
this.emit();
|
|
381
|
-
}
|
|
382
|
-
/**
|
|
383
|
-
* 启动一次 Quick Tunnel。
|
|
384
|
-
* @param exe cloudflared 可执行文件路径(来自 [ensureCloudflared])
|
|
385
|
-
* @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
|
|
386
|
-
*/
|
|
387
|
-
async startQuick(exe, port) {
|
|
388
|
-
if (this.process !== null && !this.process.killed) throw new Error("隧道已经在运行中,请先停止。");
|
|
389
|
-
this.errorText = void 0;
|
|
390
|
-
this.reportActivity({
|
|
391
|
-
stage: "starting",
|
|
392
|
-
label: "正在启动 cloudflared 隧道…",
|
|
393
|
-
percent: null
|
|
394
|
-
});
|
|
395
|
-
const child = spawn(exe, [
|
|
396
|
-
"tunnel",
|
|
397
|
-
"--url",
|
|
398
|
-
`http://127.0.0.1:${port}`,
|
|
399
|
-
"--no-autoupdate"
|
|
400
|
-
], { stdio: [
|
|
401
|
-
"ignore",
|
|
402
|
-
"pipe",
|
|
403
|
-
"pipe"
|
|
404
|
-
] });
|
|
405
|
-
this.process = child;
|
|
406
|
-
this.channel = "quick";
|
|
407
|
-
this.emit();
|
|
408
|
-
const onData = (buf) => {
|
|
409
|
-
const found = extractQuickTunnelUrl(buf.toString("utf8"));
|
|
410
|
-
if (found !== null && this.url === null) {
|
|
411
|
-
this.url = found;
|
|
412
|
-
this.clearActivity();
|
|
413
|
-
this.emit();
|
|
414
|
-
}
|
|
415
|
-
};
|
|
416
|
-
child.stdout?.on("data", onData);
|
|
417
|
-
child.stderr?.on("data", onData);
|
|
418
|
-
child.on("exit", (code, signal) => {
|
|
419
|
-
const wasThis = this.process === child;
|
|
420
|
-
this.process = null;
|
|
421
|
-
this.channel = null;
|
|
422
|
-
this.emit();
|
|
423
|
-
if (wasThis) {
|
|
424
|
-
if (this.url === null) this.setError(`cloudflared 退出(code=${code ?? "?"}, signal=${signal ?? "?"}),未拿到隧道地址。`);
|
|
425
|
-
this.url = null;
|
|
426
|
-
if (this.channel === null) this.clearActivity();
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
return this.publicState;
|
|
430
|
-
}
|
|
431
|
-
/** 停止当前隧道。 */
|
|
432
|
-
stop() {
|
|
433
|
-
const child = this.process;
|
|
434
|
-
if (child !== null && !child.killed) child.kill("SIGTERM");
|
|
435
|
-
this.process = null;
|
|
436
|
-
this.channel = null;
|
|
437
|
-
this.url = null;
|
|
438
|
-
this.emit();
|
|
439
|
-
return this.publicState;
|
|
440
|
-
}
|
|
441
|
-
/** 注入已抓到的地址 + 通道(命名隧道用)。 */
|
|
442
|
-
setNamed(url) {
|
|
443
|
-
this.url = url;
|
|
444
|
-
this.channel = "named";
|
|
445
|
-
this.emit();
|
|
446
|
-
return this.publicState;
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
//#endregion
|
|
450
|
-
//#region src/host/image-handoff.ts
|
|
451
|
-
/**
|
|
452
|
-
* 交接目录的**出厂默认**。
|
|
453
|
-
*
|
|
454
|
-
* 设置面板里「当前默认」那一栏展示的就是这个字面量,且是只读的 —— 它是这份代码的
|
|
455
|
-
* 契约(历史交接消息、识图工具、手机端都按它写过),不是一个可以随风改的偏好项。
|
|
456
|
-
* 用户可以改的是设置的**当前值**(存在数据库里),默认值本身不跟着走。
|
|
457
|
-
*/
|
|
458
|
-
const DEFAULT_HANDOFF_SUBDIR = ".dsh-images";
|
|
459
|
-
/** settings 表里的键名。 */
|
|
460
|
-
const IMAGE_SUBDIR_SETTING = "image_handoff_subdir";
|
|
461
|
-
/**
|
|
462
|
-
* 只读图片路由。刻意放在 `/plugin/` 下而不是版本化 REST 前缀里:
|
|
463
|
-
* 它不参与 REST 契约、不需要设备 token,将来 REST 升版时这条也不该跟着动。
|
|
464
|
-
*/
|
|
465
|
-
const IMAGE_ROUTE = "/plugin/dsh-remote-bridge/raw";
|
|
466
|
-
/** 目录名上限:它要拼进路径,太长的名字只是拿来坑自己的。 */
|
|
467
|
-
const MAX_SUBDIR_CHARS = 64;
|
|
468
|
-
/**
|
|
469
|
-
* 把用户填的目录名收成一个安全值。
|
|
470
|
-
*
|
|
471
|
-
* 只收**单段**目录名:`/`、`\`、NUL 一律拒绝(否则它就不是「目录名」而是「路径」,
|
|
472
|
-
* 会把落盘位置带出工作目录);`.` 与 `..` 拒绝(前者是当前目录、后者是往上爬);
|
|
473
|
-
* 允许点开头 —— 默认值 `.dsh-images` 自己就是点目录。
|
|
474
|
-
*/
|
|
475
|
-
function normalizeHandoffSubdir(value) {
|
|
476
|
-
if (typeof value !== "string") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name must be a string.");
|
|
477
|
-
const trimmed = value.trim();
|
|
478
|
-
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.`);
|
|
479
|
-
if (trimmed === "." || trimmed === "..") throw new ApiError(400, "IMAGE_SUBDIR_INVALID", "The image directory name cannot be \".\" or \"..\".");
|
|
480
|
-
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.");
|
|
481
|
-
return trimmed;
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* 读出当前生效的目录名。
|
|
485
|
-
*
|
|
486
|
-
* 数据库里那条坏掉(手改过 / 旧版本写过)就退回默认并**顺手修掉**:一个坏值不该让
|
|
487
|
-
* 「发图」这个动作从此失败,而每次读都只回退不修正会把坏值永远留着。
|
|
488
|
-
*/
|
|
489
|
-
function readHandoffSubdir(database) {
|
|
490
|
-
const stored = database.getSetting(IMAGE_SUBDIR_SETTING);
|
|
491
|
-
if (stored === void 0) return DEFAULT_HANDOFF_SUBDIR;
|
|
492
|
-
try {
|
|
493
|
-
return normalizeHandoffSubdir(stored);
|
|
494
|
-
} catch {
|
|
495
|
-
database.setSetting(IMAGE_SUBDIR_SETTING, DEFAULT_HANDOFF_SUBDIR);
|
|
496
|
-
return DEFAULT_HANDOFF_SUBDIR;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
/** 校验并保存,返回落库后的值。 */
|
|
500
|
-
function saveHandoffSubdir(database, value) {
|
|
501
|
-
const normalized = normalizeHandoffSubdir(value);
|
|
502
|
-
database.setSetting(IMAGE_SUBDIR_SETTING, normalized);
|
|
503
|
-
return normalized;
|
|
504
|
-
}
|
|
505
|
-
/** 设置面板要的那三个值。 */
|
|
506
|
-
function imageHandoffView(database, fallbackDir) {
|
|
507
|
-
return {
|
|
508
|
-
subdir: readHandoffSubdir(database),
|
|
509
|
-
defaultSubdir: DEFAULT_HANDOFF_SUBDIR,
|
|
510
|
-
fallbackDir
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
/** 交接文件名的白名单:`<sha256>.<图片扩展名>`,路径穿越在名字这一层就死掉。 */
|
|
514
|
-
const HANDOFF_FILE_NAME = /^[0-9a-f]{64}\.(png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
|
|
515
|
-
/** 扩展名 → 该扩展名必须用内容自证的身份。 */
|
|
516
|
-
const HANDOFF_MEDIA_TYPES = {
|
|
517
|
-
png: "image/png",
|
|
518
|
-
jpg: "image/jpeg",
|
|
519
|
-
jpeg: "image/jpeg",
|
|
520
|
-
webp: "image/webp",
|
|
521
|
-
gif: "image/gif",
|
|
522
|
-
heic: "image/heic",
|
|
523
|
-
heif: "image/heif",
|
|
524
|
-
bmp: "image/bmp",
|
|
525
|
-
avif: "image/avif"
|
|
526
|
-
};
|
|
527
|
-
/** 浏览器只会显示预览,超过这个就别端过去了。 */
|
|
528
|
-
const MAX_BYTES = 33554432;
|
|
529
|
-
/** 病态长的入参在碰文件系统之前就退掉。 */
|
|
530
|
-
const MAX_NAME_CHARS = 4096;
|
|
531
|
-
/** 按扩展名取声明的媒体类型;不支持的扩展名返回 undefined。 */
|
|
532
|
-
function declaredMediaType(name) {
|
|
533
|
-
const cut = name.lastIndexOf(".");
|
|
534
|
-
if (cut === -1) return void 0;
|
|
535
|
-
return HANDOFF_MEDIA_TYPES[name.slice(cut + 1).toLowerCase()];
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* 从文件头认出真实格式。扩展名只是**声明**,这里才是内容自证。
|
|
539
|
-
* @param head - 文件前至少 12 个字节。
|
|
540
|
-
*/
|
|
541
|
-
function sniffMediaType(head) {
|
|
542
|
-
const ascii = (offset, text) => {
|
|
543
|
-
if (head.length < offset + text.length) return false;
|
|
544
|
-
for (let index = 0; index < text.length; index += 1) if (head[offset + index] !== text.charCodeAt(index)) return false;
|
|
545
|
-
return true;
|
|
546
|
-
};
|
|
547
|
-
const bytes = (...expected) => {
|
|
548
|
-
if (head.length < expected.length) return false;
|
|
549
|
-
return expected.every((byte, index) => head[index] === byte);
|
|
550
|
-
};
|
|
551
|
-
if (bytes(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
552
|
-
if (bytes(255, 216, 255)) return "image/jpeg";
|
|
553
|
-
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
554
|
-
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
555
|
-
if (bytes(66, 77)) return "image/bmp";
|
|
556
|
-
if (ascii(4, "ftyp")) {
|
|
557
|
-
const brand = head.length >= 12 ? String.fromCharCode(...head.subarray(8, 12)) : "";
|
|
558
|
-
if (brand.startsWith("avi")) return "image/avif";
|
|
559
|
-
if (brand.startsWith("hei") || brand.startsWith("hev") || brand.startsWith("mif")) return "image/heic";
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
/** 请求的 `Host` 是否指向回环(DNS 重绑定防护)。 */
|
|
563
|
-
function loopbackHost(req) {
|
|
564
|
-
const host = req.headers.host;
|
|
565
|
-
if (typeof host !== "string" || host === "") return false;
|
|
566
|
-
if (host.startsWith("[")) return host.startsWith("[::1]");
|
|
567
|
-
const name = host.split(":")[0];
|
|
568
|
-
return name === "127.0.0.1" || name === "localhost";
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* 请求是否来自某个插件自己服务的页面。
|
|
572
|
-
*
|
|
573
|
-
* 沙箱化的画布跑在不透明 origin 里,它自己的请求不带可用的 origin,但会带来源页 URL
|
|
574
|
-
* —— 这是 HTML 产物也能显示工作区图片的原因。
|
|
575
|
-
*/
|
|
576
|
-
function pluginPageReferer(req) {
|
|
577
|
-
const referer = req.headers.referer;
|
|
578
|
-
if (typeof referer !== "string" || referer === "") return false;
|
|
579
|
-
try {
|
|
580
|
-
const url = new URL(referer);
|
|
581
|
-
if (url.host !== req.headers.host) return false;
|
|
582
|
-
return url.pathname.startsWith("/plugin/");
|
|
583
|
-
} catch {
|
|
584
|
-
return false;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* 请求是否可以被回答。
|
|
589
|
-
*
|
|
590
|
-
* 浏览器一定发 `Sec-Fetch-Site`,跨站一律拒绝;没有这个头的调用方不是浏览器页面
|
|
591
|
-
* (curl、测试),此时回环可达性本身就是访问模型 —— 与本地 GUI 其余部分假定的信任一致。
|
|
592
|
-
*/
|
|
593
|
-
function sameOrigin(req) {
|
|
594
|
-
if (!loopbackHost(req)) return false;
|
|
595
|
-
if (pluginPageReferer(req)) return true;
|
|
596
|
-
const site = req.headers["sec-fetch-site"];
|
|
597
|
-
if (typeof site === "string") return site === "same-origin" || site === "none";
|
|
598
|
-
const origin = req.headers.origin;
|
|
599
|
-
if (typeof origin !== "string" || origin === "") return true;
|
|
600
|
-
try {
|
|
601
|
-
return new URL(origin).host === req.headers.host;
|
|
602
|
-
} catch {
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
/** 用一段纯文本说出拒绝理由:不回显调用方给的路径,也不做任何目录列举。 */
|
|
607
|
-
function refuse(res, status, reason) {
|
|
608
|
-
const body = `${reason}\n`;
|
|
609
|
-
res.writeHead(status, {
|
|
610
|
-
"content-type": "text/plain; charset=utf-8",
|
|
611
|
-
"content-length": String(Buffer.byteLength(body)),
|
|
612
|
-
"cache-control": "no-store",
|
|
613
|
-
"x-content-type-options": "nosniff"
|
|
614
|
-
});
|
|
615
|
-
res.end(body);
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* 这条路由的处理器工厂。
|
|
619
|
-
* @param options - 取图实现。
|
|
620
|
-
* @returns 一个 `(req, res) => Promise<void>`,可直接交给 `webServer.register`。
|
|
621
|
-
*/
|
|
622
|
-
function createImageRouteHandler(options) {
|
|
623
|
-
return async function serveImage(req, res) {
|
|
624
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
625
|
-
refuse(res, 405, "method not allowed");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
if (!sameOrigin(req)) {
|
|
629
|
-
refuse(res, 403, "cross-site request refused");
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
633
|
-
const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
|
|
634
|
-
const rawName = url.searchParams.get("name") ?? "";
|
|
635
|
-
if (sessionId === "" || rawName === "" || rawName.length > MAX_NAME_CHARS) {
|
|
636
|
-
refuse(res, 400, "sessionId and name are required");
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const name = rawName.split(/[/\\]/).pop() ?? rawName;
|
|
640
|
-
if (HANDOFF_FILE_NAME.exec(name) === null) {
|
|
641
|
-
refuse(res, 404, "no such handoff image");
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
const declared = declaredMediaType(name);
|
|
645
|
-
let image;
|
|
646
|
-
try {
|
|
647
|
-
image = await options.read(sessionId, name);
|
|
648
|
-
} catch {
|
|
649
|
-
refuse(res, 404, "no such handoff image");
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (image.bytes.byteLength === 0) {
|
|
653
|
-
refuse(res, 404, "empty file");
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
if (image.bytes.byteLength > MAX_BYTES) {
|
|
657
|
-
refuse(res, 413, "image is too large to display");
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
const sniffed = sniffMediaType(image.bytes.subarray(0, 12));
|
|
661
|
-
if (sniffed === void 0) {
|
|
662
|
-
refuse(res, 415, "content is not a supported image");
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
if (declared !== void 0 && sniffed !== declared) {
|
|
666
|
-
refuse(res, 415, "content does not match the file extension");
|
|
667
|
-
return;
|
|
701
|
+
stage: "unpacking",
|
|
702
|
+
label: "写入二进制并赋予执行权限…",
|
|
703
|
+
percent: null
|
|
704
|
+
});
|
|
705
|
+
const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
|
|
706
|
+
await writeFile(tmp, res.body);
|
|
707
|
+
await chmod(tmp, 493);
|
|
708
|
+
await rename(tmp, existing);
|
|
709
|
+
return existing;
|
|
710
|
+
} catch (cause) {
|
|
711
|
+
errors.push(cause instanceof Error ? cause.message : String(cause));
|
|
668
712
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
713
|
+
}
|
|
714
|
+
const tried = candidates.map((c) => c.url).join("\n ");
|
|
715
|
+
throw new Error(`下载 cloudflared 失败:\n ${tried}\n原因:${errors.join(";")}`);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
|
|
719
|
+
* 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
|
|
720
|
+
*/
|
|
721
|
+
async function writeFromDeb(dataDir, debBuffer, url) {
|
|
722
|
+
await mkdir(dataDir, { recursive: true });
|
|
723
|
+
const debPath = path.join(dataDir, `.cloudflared.deb-${process.pid}`);
|
|
724
|
+
const extractDir = path.join(dataDir, `.cloudflared-extract-${process.pid}`);
|
|
725
|
+
await writeFile(debPath, debBuffer);
|
|
726
|
+
await mkdir(extractDir, { recursive: true });
|
|
727
|
+
try {
|
|
728
|
+
await new Promise((resolve, reject) => {
|
|
729
|
+
execFile("dpkg-deb", [
|
|
730
|
+
"-x",
|
|
731
|
+
debPath,
|
|
732
|
+
extractDir
|
|
733
|
+
], (err) => err ? reject(err) : resolve());
|
|
674
734
|
});
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
735
|
+
const inner = path.join(extractDir, "usr", "bin", "cloudflared");
|
|
736
|
+
const out = path.join(dataDir, "cloudflared");
|
|
737
|
+
const bin = await readFile(inner);
|
|
738
|
+
await writeFile(out, bin);
|
|
739
|
+
await chmod(out, 493);
|
|
740
|
+
return out;
|
|
741
|
+
} finally {
|
|
742
|
+
await rm(debPath, { force: true }).catch(() => {});
|
|
743
|
+
await rm(extractDir, {
|
|
744
|
+
recursive: true,
|
|
745
|
+
force: true
|
|
746
|
+
}).catch(() => {});
|
|
747
|
+
}
|
|
681
748
|
}
|
|
749
|
+
/** 一次 cloudflared 子进程生命周期 + 对外状态机。 */
|
|
750
|
+
var CloudflaredTunnelManager = class {
|
|
751
|
+
process = null;
|
|
752
|
+
url = null;
|
|
753
|
+
channel = null;
|
|
754
|
+
errorText = void 0;
|
|
755
|
+
listeners = [];
|
|
756
|
+
activity = {
|
|
757
|
+
stage: "idle",
|
|
758
|
+
label: "",
|
|
759
|
+
percent: null
|
|
760
|
+
};
|
|
761
|
+
/** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
|
|
762
|
+
get activityState() {
|
|
763
|
+
return this.activity;
|
|
764
|
+
}
|
|
765
|
+
/** 上报一条阶段进度(前端轮询 /cloudflare/activity 可见)。 */
|
|
766
|
+
reportActivity(a) {
|
|
767
|
+
this.activity = a;
|
|
768
|
+
this.emit();
|
|
769
|
+
}
|
|
770
|
+
/** 任务结束(成功或失败)时清空进行中进度。 */
|
|
771
|
+
clearActivity() {
|
|
772
|
+
this.activity = {
|
|
773
|
+
stage: "idle",
|
|
774
|
+
label: "",
|
|
775
|
+
percent: null
|
|
776
|
+
};
|
|
777
|
+
this.emit();
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* 是否有隧道在运行 —— 管理面鉴权的**判据 0**。
|
|
781
|
+
*
|
|
782
|
+
* 判据取自**进程持有状态**,不是请求头,所以它对「隧道把外部流量转进 127.0.0.1」
|
|
783
|
+
* 这条绕过路径 100% 可靠:不管转发进来的请求长什么样,只要隧道开着,管理面就关门。
|
|
784
|
+
* 见 `auth.ts` 的 `adminRefusal` 与 `PITFALLS.md` §隧道让回环判据失效。
|
|
785
|
+
*
|
|
786
|
+
* `running` 与 `publicState.running` 是同一个表达式(同一份真相),这里单独暴露是为了
|
|
787
|
+
* 让鉴权层不必构造一个含 url 的视图对象 —— 少一处把地址带进意外地方的机会。
|
|
788
|
+
*/
|
|
789
|
+
get tunnelActive() {
|
|
790
|
+
return this.process !== null && !this.process.killed;
|
|
791
|
+
}
|
|
792
|
+
get publicState() {
|
|
793
|
+
return {
|
|
794
|
+
channel: this.channel,
|
|
795
|
+
url: this.url,
|
|
796
|
+
running: this.tunnelActive,
|
|
797
|
+
...this.errorText === void 0 ? {} : { error: this.errorText }
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
/** 订阅状态变化;返回取消函数。 */
|
|
801
|
+
onStatus(cb) {
|
|
802
|
+
this.listeners.push(cb);
|
|
803
|
+
return () => {
|
|
804
|
+
this.listeners = this.listeners.filter((fn) => fn !== cb);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
emit() {
|
|
808
|
+
for (const fn of this.listeners) fn();
|
|
809
|
+
}
|
|
810
|
+
setError(message) {
|
|
811
|
+
this.errorText = message;
|
|
812
|
+
this.emit();
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* 启动一次 Quick Tunnel。
|
|
816
|
+
* @param exe cloudflared 可执行文件路径(来自 [ensureCloudflared])
|
|
817
|
+
* @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
|
|
818
|
+
*/
|
|
819
|
+
async startQuick(exe, port) {
|
|
820
|
+
if (this.process !== null && !this.process.killed) throw new Error("隧道已经在运行中,请先停止。");
|
|
821
|
+
this.errorText = void 0;
|
|
822
|
+
this.reportActivity({
|
|
823
|
+
stage: "starting",
|
|
824
|
+
label: "正在启动 cloudflared 隧道…",
|
|
825
|
+
percent: null
|
|
826
|
+
});
|
|
827
|
+
const child = spawn(exe, [
|
|
828
|
+
"tunnel",
|
|
829
|
+
"--url",
|
|
830
|
+
`http://127.0.0.1:${port}`,
|
|
831
|
+
"--no-autoupdate"
|
|
832
|
+
], { stdio: [
|
|
833
|
+
"ignore",
|
|
834
|
+
"pipe",
|
|
835
|
+
"pipe"
|
|
836
|
+
] });
|
|
837
|
+
this.process = child;
|
|
838
|
+
this.channel = "quick";
|
|
839
|
+
this.emit();
|
|
840
|
+
const onData = (buf) => {
|
|
841
|
+
const found = extractQuickTunnelUrl(buf.toString("utf8"));
|
|
842
|
+
if (found !== null && this.url === null) {
|
|
843
|
+
this.url = found;
|
|
844
|
+
this.clearActivity();
|
|
845
|
+
this.emit();
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
child.stdout?.on("data", onData);
|
|
849
|
+
child.stderr?.on("data", onData);
|
|
850
|
+
child.on("exit", (code, signal) => {
|
|
851
|
+
const wasThis = this.process === child;
|
|
852
|
+
this.process = null;
|
|
853
|
+
this.channel = null;
|
|
854
|
+
this.emit();
|
|
855
|
+
if (wasThis) {
|
|
856
|
+
if (this.url === null) this.setError(`cloudflared 退出(code=${code ?? "?"}, signal=${signal ?? "?"}),未拿到隧道地址。`);
|
|
857
|
+
this.url = null;
|
|
858
|
+
if (this.channel === null) this.clearActivity();
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
return this.publicState;
|
|
862
|
+
}
|
|
863
|
+
/** 停止当前隧道。 */
|
|
864
|
+
stop() {
|
|
865
|
+
const child = this.process;
|
|
866
|
+
if (child !== null && !child.killed) child.kill("SIGTERM");
|
|
867
|
+
this.process = null;
|
|
868
|
+
this.channel = null;
|
|
869
|
+
this.url = null;
|
|
870
|
+
this.emit();
|
|
871
|
+
return this.publicState;
|
|
872
|
+
}
|
|
873
|
+
/** 注入已抓到的地址 + 通道(命名隧道用)。 */
|
|
874
|
+
setNamed(url) {
|
|
875
|
+
this.url = url;
|
|
876
|
+
this.channel = "named";
|
|
877
|
+
this.emit();
|
|
878
|
+
return this.publicState;
|
|
879
|
+
}
|
|
880
|
+
};
|
|
682
881
|
//#endregion
|
|
683
882
|
//#region src/host/path-policy.ts
|
|
684
883
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
@@ -796,7 +995,33 @@ function parentWirePath(relativePath) {
|
|
|
796
995
|
* 调用方必须自己给出可读的错误(见 readAttachment),而不是让用户看到一个
|
|
797
996
|
* 语焉不详的 500。
|
|
798
997
|
*/
|
|
998
|
+
/**
|
|
999
|
+
* 读一个**可选**宿主服务:存在就返回,不存在返回 null。
|
|
1000
|
+
*
|
|
1001
|
+
* ## 为什么必须走 `ctx.reflect.get(name, false)`,不能直接读属性
|
|
1002
|
+
*
|
|
1003
|
+
* cordis 的 `inject` 是**硬依赖**(`registry.d.ts` 的 `Inject` 类型只有数组与映射两种
|
|
1004
|
+
* 形式,注释原文是 "required services"):inject 里任何一个服务缺失,`_refresh()`
|
|
1005
|
+
* 就把 epoch 置为 INACTIVE,**整个插件不激活**。
|
|
1006
|
+
*
|
|
1007
|
+
* 所以「可选服务」**不能**写进 inject —— 写了就变成「哪个内核版本缺它、插件在哪一版
|
|
1008
|
+
* 整个死掉」。而直接读属性(`host[name]`)也不行:不在 inject 里的属性会抛
|
|
1009
|
+
* `cannot get property "x" without inject`,与「服务不存在」**报同一个错**,
|
|
1010
|
+
* 于是这个函数会永远返回 null(这正是它此前的实际行为,
|
|
1011
|
+
* `attachments` / `fileUploads` 一直在走降级路径)。
|
|
1012
|
+
*
|
|
1013
|
+
* `ctx.reflect.get(name, false)` 是 cordis 给这件事准备的正路
|
|
1014
|
+
* (`cordis/lib/index.js` 的 ReflectService.get,注释原文:
|
|
1015
|
+
* "Read a service from the store **without the inject requirement**"):
|
|
1016
|
+
* 不要求 inject 声明,服务不存在时返回 `undefined` 而不是抛错。
|
|
1017
|
+
*/
|
|
799
1018
|
function optionalService(host, name) {
|
|
1019
|
+
const reflect = host.reflect;
|
|
1020
|
+
if (reflect !== void 0 && typeof reflect.get === "function") try {
|
|
1021
|
+
const value = reflect.get(name, false);
|
|
1022
|
+
if (value === void 0 || value === null) return null;
|
|
1023
|
+
return value;
|
|
1024
|
+
} catch {}
|
|
800
1025
|
try {
|
|
801
1026
|
return host[name];
|
|
802
1027
|
} catch {
|
|
@@ -1413,7 +1638,7 @@ function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
|
1413
1638
|
if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
|
|
1414
1639
|
return {
|
|
1415
1640
|
absolute,
|
|
1416
|
-
relative: workspace !== void 0 && absolute.startsWith(workspace +
|
|
1641
|
+
relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
|
|
1417
1642
|
};
|
|
1418
1643
|
}
|
|
1419
1644
|
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
@@ -2013,7 +2238,13 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2013
2238
|
settingsNs: entry.settingsNs,
|
|
2014
2239
|
settingsPath: [...entry.settingsPath],
|
|
2015
2240
|
active: live.has(entry.provider),
|
|
2016
|
-
...entry.declared === void 0 ? {} : { declared: entry.declared }
|
|
2241
|
+
...entry.declared === void 0 ? {} : { declared: entry.declared },
|
|
2242
|
+
...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
|
|
2243
|
+
...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: [...entry.baseURLEnv] },
|
|
2244
|
+
...entry.nativeAuth === void 0 ? {} : { nativeAuth: {
|
|
2245
|
+
...entry.nativeAuth,
|
|
2246
|
+
...entry.nativeAuth.env === void 0 ? {} : { env: [...entry.nativeAuth.env] }
|
|
2247
|
+
} }
|
|
2017
2248
|
})) };
|
|
2018
2249
|
}),
|
|
2019
2250
|
models: () => call(async () => {
|
|
@@ -2036,7 +2267,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2036
2267
|
}
|
|
2037
2268
|
//#endregion
|
|
2038
2269
|
//#region src/shared/version.ts
|
|
2039
|
-
const PLUGIN_VERSION = "2.
|
|
2270
|
+
const PLUGIN_VERSION = "2.5.1";
|
|
2040
2271
|
//#endregion
|
|
2041
2272
|
//#region src/host/capabilities.ts
|
|
2042
2273
|
/**
|
|
@@ -3235,10 +3466,51 @@ function redactApprovalText(value, maxLength = 1200) {
|
|
|
3235
3466
|
}
|
|
3236
3467
|
//#endregion
|
|
3237
3468
|
//#region src/host/database.ts
|
|
3469
|
+
/**
|
|
3470
|
+
* 本进程内打开的库(弱引用集合)。
|
|
3471
|
+
*
|
|
3472
|
+
* ## 为什么需要它
|
|
3473
|
+
*
|
|
3474
|
+
* `WorkspaceDatabase` 用 `PRAGMA journal_mode=WAL` 打开 sqlite,会留下
|
|
3475
|
+
* `workspace.sqlite` / `-wal` / `-shm` 三个文件,**且句柄不关就删不掉**。
|
|
3476
|
+
* Linux/macOS 上删一个仍被打开的文件是允许的(unlink 只是断链),所以问题
|
|
3477
|
+
* 在那里看不出来;**Windows 上直接 EBUSY**:
|
|
3478
|
+
*
|
|
3479
|
+
* ```
|
|
3480
|
+
* EBUSY: resource busy or locked, unlink 'C:\...\Temp\daw-xxx\state\workspace.sqlite'
|
|
3481
|
+
* ```
|
|
3482
|
+
*
|
|
3483
|
+
* 2026-10-20 CI 在 windows-latest 上实锤(macOS/Linux 全绿、Windows 全红)。
|
|
3484
|
+
*
|
|
3485
|
+
* ## 它的作用
|
|
3486
|
+
*
|
|
3487
|
+
* 进程退出时统一关闭所有还开着的库 —— 无论调用方记不记得 `close()`。
|
|
3488
|
+
* 这不是「代替 close」,而是**兜底**:正常路径仍应显式 close(早关早释放),
|
|
3489
|
+
* 但漏掉的那一个不会在 Windows 上变成 EBUSY 式的删除失败。
|
|
3490
|
+
*
|
|
3491
|
+
* 弱引用:不阻止 GC,也不阻止正常 close 后的移除(close 时会从集合里摘掉)。
|
|
3492
|
+
*/
|
|
3493
|
+
const openDatabases = /* @__PURE__ */ new Set();
|
|
3494
|
+
/** 进程退出时尽力关闭所有还开着的库。 */
|
|
3495
|
+
function closeAllOpenDatabases() {
|
|
3496
|
+
for (const ref of [...openDatabases]) {
|
|
3497
|
+
const db = ref.deref();
|
|
3498
|
+
if (db === void 0) {
|
|
3499
|
+
openDatabases.delete(ref);
|
|
3500
|
+
continue;
|
|
3501
|
+
}
|
|
3502
|
+
try {
|
|
3503
|
+
db.close();
|
|
3504
|
+
} catch {}
|
|
3505
|
+
}
|
|
3506
|
+
openDatabases.clear();
|
|
3507
|
+
}
|
|
3508
|
+
process.once("exit", closeAllOpenDatabases);
|
|
3238
3509
|
var WorkspaceDatabase = class {
|
|
3239
3510
|
dataDir;
|
|
3240
3511
|
trashDir;
|
|
3241
3512
|
db;
|
|
3513
|
+
closed = false;
|
|
3242
3514
|
constructor(dataDir) {
|
|
3243
3515
|
this.dataDir = path.resolve(dataDir);
|
|
3244
3516
|
this.trashDir = path.join(this.dataDir, "trash");
|
|
@@ -3246,8 +3518,13 @@ var WorkspaceDatabase = class {
|
|
|
3246
3518
|
this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
|
|
3247
3519
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
|
3248
3520
|
this.migrate();
|
|
3521
|
+
openDatabases.add(new WeakRef(this));
|
|
3249
3522
|
}
|
|
3523
|
+
/** 关闭数据库。**幂等** —— 重复调用不抛错。 */
|
|
3250
3524
|
close() {
|
|
3525
|
+
if (this.closed) return;
|
|
3526
|
+
this.closed = true;
|
|
3527
|
+
for (const ref of [...openDatabases]) if (ref.deref() === this) openDatabases.delete(ref);
|
|
3251
3528
|
this.db.close();
|
|
3252
3529
|
}
|
|
3253
3530
|
/**
|
|
@@ -4064,7 +4341,14 @@ function trashView(record) {
|
|
|
4064
4341
|
* 使用方式:`GET /dsh-workspace-api/api/v1/debug/host-services`
|
|
4065
4342
|
* (需要设备 token 或 loopback admin,与其它 REST 端点同一套鉴权)。
|
|
4066
4343
|
*/
|
|
4067
|
-
/**
|
|
4344
|
+
/**
|
|
4345
|
+
* 插件 `cordis.patch.yml` 与 `src/index.ts` 里 inject 声明的全部服务名
|
|
4346
|
+
* (**两份清单必须一致**,docs:check 有对齐门禁)。
|
|
4347
|
+
*
|
|
4348
|
+
* ⚠️ 只能放「两个支持版本(0.2.0-rc.2 与 0.2.1-rc.2)上都存在的服务」——
|
|
4349
|
+
* cordis 的 inject 是硬依赖,缺一个整个插件不激活(2026-10-20 在 0.2.0-rc.2 的
|
|
4350
|
+
* smoke-profile-install 上实锤过)。可选服务一律走 OPTIONAL_SERVICE_NAMES。
|
|
4351
|
+
*/
|
|
4068
4352
|
const INJECTED_SERVICE_NAMES = [
|
|
4069
4353
|
"sessionController",
|
|
4070
4354
|
"workspaceController",
|
|
@@ -4072,17 +4356,26 @@ const INJECTED_SERVICE_NAMES = [
|
|
|
4072
4356
|
"settingsController",
|
|
4073
4357
|
"credentialsController",
|
|
4074
4358
|
"agentPresets",
|
|
4075
|
-
"permissionPresets",
|
|
4076
4359
|
"llm",
|
|
4077
4360
|
"webServer",
|
|
4078
4361
|
"agents",
|
|
4079
|
-
"commands"
|
|
4362
|
+
"commands"
|
|
4363
|
+
];
|
|
4364
|
+
/**
|
|
4365
|
+
* **可选服务**:不写进 inject(inject 是硬依赖),由插件经
|
|
4366
|
+
* `ctx.reflect.get(name, false)` 运行时读取 —— 读到就走该能力,读不到按能力位降级。
|
|
4367
|
+
*
|
|
4368
|
+
* 探针用同一条 `reflect.get` 读取它们:这样报告的是「内核**真的**提供了什么」,
|
|
4369
|
+
* 而不是「我们 inject 了什么」。两者的差就是「可选能力在这台内核上可用与否」。
|
|
4370
|
+
*/
|
|
4371
|
+
const OPTIONAL_SERVICE_NAMES = [
|
|
4372
|
+
"permissionPresets",
|
|
4080
4373
|
"jobController",
|
|
4081
4374
|
"fileUploads",
|
|
4082
|
-
"attachments"
|
|
4375
|
+
"attachments",
|
|
4376
|
+
"workingDirectory",
|
|
4377
|
+
"workspaceFiles"
|
|
4083
4378
|
];
|
|
4084
|
-
/** 0.2.1 新增、插件尚未 inject 但已确认存在于 web profile 的服务。 */
|
|
4085
|
-
const OBSERVED_ONLY_SERVICE_NAMES = ["workingDirectory", "workspaceFiles"];
|
|
4086
4379
|
/**
|
|
4087
4380
|
* 在注入属性**仍可读**的时机(即 `apply` 执行期间)逐个试读服务。
|
|
4088
4381
|
*
|
|
@@ -4096,6 +4389,32 @@ const OBSERVED_ONLY_SERVICE_NAMES = ["workingDirectory", "workspaceFiles"];
|
|
|
4096
4389
|
* @returns 每个服务的可解析性报告。
|
|
4097
4390
|
*/
|
|
4098
4391
|
function probeHostServices(host, pluginVersion) {
|
|
4392
|
+
const reflect = host.reflect;
|
|
4393
|
+
const probeOptional = (name) => {
|
|
4394
|
+
if (reflect === void 0 || typeof reflect.get !== "function") return {
|
|
4395
|
+
name,
|
|
4396
|
+
available: false,
|
|
4397
|
+
resolvable: false,
|
|
4398
|
+
error: "ctx.reflect.get unavailable on this cordis"
|
|
4399
|
+
};
|
|
4400
|
+
try {
|
|
4401
|
+
const value = reflect.get(name, false);
|
|
4402
|
+
const resolvable = value !== null && value !== void 0;
|
|
4403
|
+
return {
|
|
4404
|
+
name,
|
|
4405
|
+
available: true,
|
|
4406
|
+
resolvable,
|
|
4407
|
+
...resolvable ? {} : { error: "not provided by this kernel/profile" }
|
|
4408
|
+
};
|
|
4409
|
+
} catch (caught) {
|
|
4410
|
+
return {
|
|
4411
|
+
name,
|
|
4412
|
+
available: false,
|
|
4413
|
+
resolvable: false,
|
|
4414
|
+
error: caught instanceof Error ? caught.message : String(caught)
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
};
|
|
4099
4418
|
const probeOne = (name) => {
|
|
4100
4419
|
let available = false;
|
|
4101
4420
|
let resolvable = false;
|
|
@@ -4116,7 +4435,7 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4116
4435
|
};
|
|
4117
4436
|
};
|
|
4118
4437
|
const injected = INJECTED_SERVICE_NAMES.map(probeOne);
|
|
4119
|
-
const observed =
|
|
4438
|
+
const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
|
|
4120
4439
|
return {
|
|
4121
4440
|
pluginVersion,
|
|
4122
4441
|
injected,
|
|
@@ -4125,7 +4444,7 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4125
4444
|
injectedTotal: injected.length,
|
|
4126
4445
|
injectedAvailable: injected.filter((service) => service.available).length,
|
|
4127
4446
|
injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
|
|
4128
|
-
observedAvailable: observed.filter((service) => service.
|
|
4447
|
+
observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
|
|
4129
4448
|
}
|
|
4130
4449
|
};
|
|
4131
4450
|
}
|
|
@@ -4787,20 +5106,36 @@ function timestamp() {
|
|
|
4787
5106
|
//#endregion
|
|
4788
5107
|
//#region src/host/root-resolver.ts
|
|
4789
5108
|
/**
|
|
4790
|
-
*
|
|
5109
|
+
* 与 [matchAuthorizedRoot] 相同,但**两侧都做 realpath 归一**再比较。
|
|
5110
|
+
*
|
|
5111
|
+
* ## 为什么需要它
|
|
5112
|
+
*
|
|
5113
|
+
* 授权根在入库时走了 `canonicalRoot()`(`database.ts` 的 addRoot),存的是**解析过
|
|
5114
|
+
* 符号链接**的路径;而客户端请求里的路径通常**没解析**。两者字面不同就匹配不上,
|
|
5115
|
+
* 结果是「明明在授权根内」却报 404 `PATH_OUTSIDE_ROOTS`。
|
|
4791
5116
|
*
|
|
4792
|
-
*
|
|
4793
|
-
*
|
|
5117
|
+
* 最常见的触发场景就是 **macOS 的临时目录**:`os.tmpdir()` 给的是
|
|
5118
|
+
* `/var/folders/...`,而它是指向 `/private/var/folders/...` 的**符号链接**。
|
|
5119
|
+
* 于是测试里 `mkdtemp(os.tmpdir())` 造出的根与请求路径字面不一致 → 404。
|
|
5120
|
+
* 2026-10-20 CI 在 macOS runner 上实锤(Linux 上 tmpdir 不带 symlink,所以只在 macOS 炸)。
|
|
4794
5121
|
*
|
|
4795
|
-
*
|
|
4796
|
-
*
|
|
5122
|
+
* 这不只是测试问题:用户给一个经 symlink 的路径加授权根、或客户端用 symlink 别名
|
|
5123
|
+
* 请求,都会踩到。**归一后两侧是同一条真实路径**,语义也就正确了。
|
|
5124
|
+
*
|
|
5125
|
+
* ## 安全边界不变
|
|
5126
|
+
*
|
|
5127
|
+
* 归一只解决「同一个目录的两种写法」,**不放宽**任何权限:
|
|
5128
|
+
* 真正的越权校验仍由 [inspectResolvedPath] → `resolveAuthorizedPath` 逐段做
|
|
5129
|
+
* (它会拒绝指向根外的符号链接)。这里只是把「找哪个根」这一步做准。
|
|
4797
5130
|
*/
|
|
4798
|
-
function
|
|
5131
|
+
async function matchAuthorizedRootResolved(absolutePath, roots) {
|
|
5132
|
+
const candidate = await canonicalOrSelf(absolutePath);
|
|
4799
5133
|
let best;
|
|
4800
5134
|
for (const root of roots) {
|
|
4801
|
-
const
|
|
5135
|
+
const canonicalRoot = await canonicalOrSelf(root.realPath);
|
|
5136
|
+
const relative = wireRelative(canonicalRoot, candidate);
|
|
4802
5137
|
if (relative === void 0) continue;
|
|
4803
|
-
if (best === void 0 ||
|
|
5138
|
+
if (best === void 0 || canonicalRoot.length > best.root.realPath.length) best = {
|
|
4804
5139
|
root,
|
|
4805
5140
|
relative
|
|
4806
5141
|
};
|
|
@@ -4810,6 +5145,14 @@ function matchAuthorizedRoot(absolutePath, roots) {
|
|
|
4810
5145
|
path: best.relative
|
|
4811
5146
|
};
|
|
4812
5147
|
}
|
|
5148
|
+
/** 取路径的真实形式;取不到(路径不存在、无权限等)就原样返回。 */
|
|
5149
|
+
async function canonicalOrSelf(target) {
|
|
5150
|
+
try {
|
|
5151
|
+
return await realpath(target);
|
|
5152
|
+
} catch {
|
|
5153
|
+
return target;
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
4813
5156
|
const CONTENT_TYPES = {
|
|
4814
5157
|
".md": "text/markdown",
|
|
4815
5158
|
".txt": "text/plain",
|
|
@@ -4979,6 +5322,23 @@ function matchJobRoute(pathname) {
|
|
|
4979
5322
|
};
|
|
4980
5323
|
return null;
|
|
4981
5324
|
}
|
|
5325
|
+
/**
|
|
5326
|
+
* 隧道运行期间**仍然可达**的管理端点(豁免判据 0)。
|
|
5327
|
+
*
|
|
5328
|
+
* 为什么存在这个集合:判据 0(隧道开着 → 管理面整体关门)会让「隧道状态」这个信号
|
|
5329
|
+
* 本体也 403,于是面板没法告诉用户「隧道开着所以管理面停用」—— 只剩一个没法解读的
|
|
5330
|
+
* 失败。这两个端点进来的目的就是解掉这个矛盾。
|
|
5331
|
+
*
|
|
5332
|
+
* 为什么是安全的:它们**只读、无副作用**(不写库、不发令牌、不改监听),返回的
|
|
5333
|
+
* `running`/`channel`/`error`/阶段进度都不构成攻击面。**唯一敏感的是 `url`** ——
|
|
5334
|
+
* Quick Tunnel 地址本身是一次对外入口。
|
|
5335
|
+
*
|
|
5336
|
+
* 豁免**只针对判据 0**。判据 1/2/3(转发头+Cloudflare 边缘头 / Host / Origin)对它们
|
|
5337
|
+
* 照旧全部生效,所以真实隧道来源的请求连这两个端点也是 403、拿不到隧道地址。
|
|
5338
|
+
* 「远程能读到 running 但读不到 url」这个方案被**否决**了:那需要在判据 1 上再开一个口子,
|
|
5339
|
+
* 而那正是本次修复要消灭的东西。`tunnelStateFor` 里还留了一层不变量检查作双保险。
|
|
5340
|
+
*/
|
|
5341
|
+
const TUNNEL_MODE_EXEMPT_PATHS = /* @__PURE__ */ new Set(["/manage/cloudflare/status", "/manage/cloudflare/activity"]);
|
|
4982
5342
|
var ApiRouter = class {
|
|
4983
5343
|
options;
|
|
4984
5344
|
constructor(options) {
|
|
@@ -5037,7 +5397,7 @@ var ApiRouter = class {
|
|
|
5037
5397
|
return;
|
|
5038
5398
|
}
|
|
5039
5399
|
if (pathname.startsWith("/manage")) {
|
|
5040
|
-
this.options.auth.requireAdmin(req);
|
|
5400
|
+
this.options.auth.requireAdmin(req, { ...TUNNEL_MODE_EXEMPT_PATHS.has(pathname) ? { allowTunnelMode: true } : {} });
|
|
5041
5401
|
await this.dispatchManagement(req, res, url, requestId);
|
|
5042
5402
|
return;
|
|
5043
5403
|
}
|
|
@@ -5071,7 +5431,7 @@ var ApiRouter = class {
|
|
|
5071
5431
|
const requested = url.searchParams.get("path")?.trim() ?? "";
|
|
5072
5432
|
if (requested === "") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
5073
5433
|
if (!path.isAbsolute(requested)) throw new ApiError(400, "PATH_NOT_ABSOLUTE", "path must be an absolute path.");
|
|
5074
|
-
const match =
|
|
5434
|
+
const match = await matchAuthorizedRootResolved(path.resolve(requested), this.authorizedRoots(principal));
|
|
5075
5435
|
if (match === void 0) throw new ApiError(404, "PATH_OUTSIDE_ROOTS", "The path is not inside any root this device may access.");
|
|
5076
5436
|
sendJson(res, 200, await inspectResolvedPath(this.requireRoot(principal, match.rootId), match.path));
|
|
5077
5437
|
return;
|
|
@@ -5656,7 +6016,7 @@ var ApiRouter = class {
|
|
|
5656
6016
|
}
|
|
5657
6017
|
if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/status") {
|
|
5658
6018
|
if (method === "GET" || method === "HEAD") {
|
|
5659
|
-
sendJson(res, 200, this.
|
|
6019
|
+
sendJson(res, 200, this.tunnelStateFor(req));
|
|
5660
6020
|
return;
|
|
5661
6021
|
}
|
|
5662
6022
|
}
|
|
@@ -5692,6 +6052,30 @@ var ApiRouter = class {
|
|
|
5692
6052
|
}
|
|
5693
6053
|
throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
5694
6054
|
}
|
|
6055
|
+
/**
|
|
6056
|
+
* 隧道状态视图。
|
|
6057
|
+
*
|
|
6058
|
+
* **这里不做脱敏,因为用不上**:判据 1 已经把 Cloudflare 边缘头(`Cf-Connecting-IP` /
|
|
6059
|
+
* `Cf-Ray` / …)算作「前面有代理」的证据,于是任何真实隧道来源的请求在
|
|
6060
|
+
* `requireAdmin` 就被 403 挡住了,根本到不了这个方法。能走到这里的只有真·本机请求
|
|
6061
|
+
* (判据 1/2/3 全过),所以 `url` 可以照常给它 —— 那是桌面端「用隧道地址生成配置文本」
|
|
6062
|
+
* 的依据。
|
|
6063
|
+
*
|
|
6064
|
+
* 为什么仍然保留这个方法而不是直接 `publicState`:脱敏的位置要留在一个**语义明确**的
|
|
6065
|
+
* 出口上。将来若有人为了「让远程客户端也能读到隧道是否开着」而放宽判据 1,
|
|
6066
|
+
* 必须在这里重新定义脱敏,而不是顺手把 `url` 一起放出去。
|
|
6067
|
+
* 评估结论见报告:本轮选择「远程一律 403」而不是「远程读得到但 `url` 为 null」,
|
|
6068
|
+
* 因为后者需要为状态端点在判据 1 上再开一个口子,而那正是这次修复要消灭的东西。
|
|
6069
|
+
*/
|
|
6070
|
+
tunnelStateFor(req) {
|
|
6071
|
+
const tunnels = this.options.tunnels;
|
|
6072
|
+
if (tunnels === void 0) throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
6073
|
+
const state = tunnels.publicState;
|
|
6074
|
+
return adminRefusal(req, { tunnelActive: false }) === void 0 ? state : {
|
|
6075
|
+
...state,
|
|
6076
|
+
url: null
|
|
6077
|
+
};
|
|
6078
|
+
}
|
|
5695
6079
|
async dispatchEntries(req, res, url, principal, root, requestId) {
|
|
5696
6080
|
const method = req.method ?? "GET";
|
|
5697
6081
|
if (method === "GET") {
|
|
@@ -6424,6 +6808,10 @@ var DshSettingsAdapter = class {
|
|
|
6424
6808
|
configurable: entry.settingsNs !== "",
|
|
6425
6809
|
configured,
|
|
6426
6810
|
removable,
|
|
6811
|
+
...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
|
|
6812
|
+
...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: entry.baseURLEnv },
|
|
6813
|
+
...entry.nativeAuth === void 0 ? {} : { nativeAuth: entry.nativeAuth },
|
|
6814
|
+
...entry.settingsNs === "" ? {} : { settingsNs: entry.settingsNs },
|
|
6427
6815
|
credential: {
|
|
6428
6816
|
ref,
|
|
6429
6817
|
...credential
|
|
@@ -6463,12 +6851,18 @@ var DshSettingsAdapter = class {
|
|
|
6463
6851
|
});
|
|
6464
6852
|
}
|
|
6465
6853
|
if (patch.models !== void 0) {
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
path: [...context.entry.settingsPath, "models"],
|
|
6470
|
-
value: patch.models
|
|
6854
|
+
if (patch.models === null) ops.push({
|
|
6855
|
+
op: "unset",
|
|
6856
|
+
path: [...context.entry.settingsPath, "models"]
|
|
6471
6857
|
});
|
|
6858
|
+
else {
|
|
6859
|
+
validateModels(patch.models);
|
|
6860
|
+
ops.push({
|
|
6861
|
+
op: "set",
|
|
6862
|
+
path: [...context.entry.settingsPath, "models"],
|
|
6863
|
+
value: patch.models
|
|
6864
|
+
});
|
|
6865
|
+
}
|
|
6472
6866
|
}
|
|
6473
6867
|
const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
|
|
6474
6868
|
if (context.entry.settingsPath.length > 0 && context.profile === void 0) ops.unshift({
|
|
@@ -6718,14 +7112,10 @@ const inject = [
|
|
|
6718
7112
|
"settingsController",
|
|
6719
7113
|
"credentialsController",
|
|
6720
7114
|
"agentPresets",
|
|
6721
|
-
"permissionPresets",
|
|
6722
7115
|
"llm",
|
|
6723
7116
|
"webServer",
|
|
6724
7117
|
"agents",
|
|
6725
|
-
"commands"
|
|
6726
|
-
"jobController",
|
|
6727
|
-
"fileUploads",
|
|
6728
|
-
"attachments"
|
|
7118
|
+
"commands"
|
|
6729
7119
|
];
|
|
6730
7120
|
async function apply(ctx, config = {}) {
|
|
6731
7121
|
const dataDir = path.resolve(config.dataDir ?? path.join(process.cwd(), ".dsh-workspace"));
|
|
@@ -6742,7 +7132,8 @@ async function apply(ctx, config = {}) {
|
|
|
6742
7132
|
}
|
|
6743
7133
|
await registerBuiltinWorkspaceRoot(ctx, database);
|
|
6744
7134
|
const events = new WorkspaceEventBus();
|
|
6745
|
-
const
|
|
7135
|
+
const tunnels = new CloudflaredTunnelManager();
|
|
7136
|
+
const auth = new AuthService(database, { tunnelActive: () => tunnels.tunnelActive });
|
|
6746
7137
|
const files = new FileService(database, (event) => events.emit(event));
|
|
6747
7138
|
const imageHandoffDir = path.join(dataDir, "image-handoff");
|
|
6748
7139
|
const handoffRootsOf = () => database.listRoots().map((root) => ({
|
|
@@ -6770,13 +7161,9 @@ async function apply(ctx, config = {}) {
|
|
|
6770
7161
|
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: host service probe failed: ${String(error)}`));
|
|
6771
7162
|
}
|
|
6772
7163
|
let router;
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
jobControllerAvailable = true;
|
|
6777
|
-
} catch {
|
|
6778
|
-
ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: ctx.jobController unavailable, background-job endpoints disabled"));
|
|
6779
|
-
}
|
|
7164
|
+
const reflect = ctx.reflect;
|
|
7165
|
+
const jobControllerAvailable = reflect?.get !== void 0 && reflect.get("jobController", false) !== void 0;
|
|
7166
|
+
if (!jobControllerAvailable) ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: jobController not provided by this kernel, background-job endpoints disabled"));
|
|
6780
7167
|
const remote = new RemoteApiServer({
|
|
6781
7168
|
port,
|
|
6782
7169
|
remoteHost,
|
|
@@ -6793,12 +7180,12 @@ async function apply(ctx, config = {}) {
|
|
|
6793
7180
|
chat,
|
|
6794
7181
|
settings,
|
|
6795
7182
|
remote,
|
|
6796
|
-
tunnels
|
|
7183
|
+
tunnels,
|
|
6797
7184
|
cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
|
|
6798
7185
|
maxUploadBytes,
|
|
6799
7186
|
imageFallbackDir: imageHandoffDir,
|
|
6800
7187
|
...hostServiceReport === void 0 ? {} : { hostServiceReport },
|
|
6801
|
-
...jobControllerAvailable ? { jobs: new JobAdapter({ jobController:
|
|
7188
|
+
...jobControllerAvailable && reflect?.get ? { jobs: new JobAdapter({ jobController: reflect.get("jobController", false) }) } : {}
|
|
6802
7189
|
});
|
|
6803
7190
|
try {
|
|
6804
7191
|
await remote.start();
|