@hyzyn/dsh-docker 0.7.3 → 0.8.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 -5
- package/README.md +41 -13
- package/client-src/docker.css +13 -13
- package/client-src/index.js +466 -246
- package/client-src/log-buffer.js +205 -0
- package/client-src/log-stream.js +143 -0
- package/client.js +37 -36
- package/lib/docker.d.ts +18 -3
- package/lib/docker.js +54 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +154 -19
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/scripts/client-smoke.mjs +261 -0
- package/scripts/log-perf.mjs +206 -0
- package/scripts/route-smoke.mjs +21 -0
- package/scripts/smoke.mjs +59 -1
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import z from '@deepseek-ai/schemastery';
|
|
|
2
2
|
import { definePlugin, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import * as dns from 'node:dns';
|
|
5
|
-
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
|
|
5
|
+
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
|
|
6
6
|
import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
7
7
|
const TARGET_SCHEMA = z.object({
|
|
8
8
|
name: z.string().required(),
|
|
@@ -82,7 +82,49 @@ const MUTATION_SUBROUTES = new Set([
|
|
|
82
82
|
'/volumes/prune',
|
|
83
83
|
'/exec',
|
|
84
84
|
]);
|
|
85
|
-
|
|
85
|
+
/** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
|
|
86
|
+
const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
|
|
87
|
+
/**
|
|
88
|
+
* 给 `DockerApi` 包一层:**只有**从目标 API 抛出的错误才打上 `TARGET_FAILURE` 标记。
|
|
89
|
+
*
|
|
90
|
+
* 为什么需要这个标记:单目标数据路由原来是「api 抛什么,外层 catch 就统一回 500」。
|
|
91
|
+
* 而 SSH 连不上 / 私钥读不到 / docker 不在 PATH 这些都是**运维状况**,不是插件 bug——
|
|
92
|
+
* 多目标聚合路径对同一件事早就回 200 + `ok:false`(`aggregateAcrossTargets` 把 `run()`
|
|
93
|
+
* 的抛错收成每个目标一条 `ok:false`)。于是同一句 `EPERM: open '…id_ed25519'` 在
|
|
94
|
+
* `target='*'` 下是「可读原因」、在单目标下是「不透明 500」:面板点一个连不上的目标就长这样。
|
|
95
|
+
*
|
|
96
|
+
* 为什么不逐个调用点包 try/catch:那要改 20 处,而且**必然漏几个**——本仓「修到一半」
|
|
97
|
+
* 的老毛病(D80–D125 那批 29 条)。包一次就没有漏网之鱼。
|
|
98
|
+
*
|
|
99
|
+
* 为什么不是「把外层 catch 全改成 200 + ok:false」:那会把我们自己的 bug(比如某天
|
|
100
|
+
* 解析写错抛的 TypeError)也伪装成「目标不可达」,正是本仓最忌讳的**静默错误结果**。
|
|
101
|
+
* 打标记之后,没标记的错误仍旧 500。
|
|
102
|
+
*/
|
|
103
|
+
function guardTargetFailures(api) {
|
|
104
|
+
return new Proxy(api, {
|
|
105
|
+
get(target, prop, receiver) {
|
|
106
|
+
const value = Reflect.get(target, prop, receiver);
|
|
107
|
+
if (typeof value !== 'function')
|
|
108
|
+
return value;
|
|
109
|
+
return async (...args) => {
|
|
110
|
+
try {
|
|
111
|
+
return await value.apply(target, args);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error !== null && typeof error === 'object') {
|
|
115
|
+
Object.defineProperty(error, TARGET_FAILURE, { value: true, configurable: true });
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** 这条错误是不是从目标 API 抛出来的(见 guardTargetFailures)。 */
|
|
124
|
+
function isTargetFailure(error) {
|
|
125
|
+
return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
|
|
126
|
+
}
|
|
127
|
+
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
|
|
86
128
|
/**
|
|
87
129
|
* SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
|
|
88
130
|
* 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
|
|
@@ -204,23 +246,57 @@ function isLoopbackHttp(req) {
|
|
|
204
246
|
* `<img src="GET /images/pull/stream?...">` 触发副作用 / 拉起 docker 子进程,
|
|
205
247
|
* 而旧 Safari / 部分 WebView 既不发 Origin 也不发 Sec-Fetch-Site——这两类端点
|
|
206
248
|
* 对「无来源证明」的请求拒绝;只读端点维持 loopback-only 的原信任模型。
|
|
249
|
+
*
|
|
250
|
+
* **桌面版例外(D139)**:桌面壳把页面发往 `dsh-app://app/api/…` 的请求转给真实宿主时
|
|
251
|
+
* **会删掉 `origin` 与 `sec-fetch-site`**(`app.asar/lib/main.js` 的 `forwardWebRequest`,
|
|
252
|
+
* 只重写 `host` / `cookie`,其余头原样带过去),于是这四条 SSE 与八条变更路由在桌面版
|
|
253
|
+
* **全部** 403——症状是日志 / 统计 / 活动 / 拉取四条流无限「连接中断,正在自动重连…」,
|
|
254
|
+
* 而同一个面板的只读路由(`/containers`、`/inspect`…)照常可用(它们不要求证明)。
|
|
255
|
+
*
|
|
256
|
+
* 桌面壳在这条转发链上**必带宿主会话 Cookie**:`hostCookie` 由 `authenticateWebHost()`
|
|
257
|
+
* 拿 `set-cookie` 换来,取不到时 `forwardWebRequest` 整体 503、根本走不到这里。而浏览器
|
|
258
|
+
* 页面**伪造不了 Cookie 头**——跨站请求带不带它由 SameSite 决定,且现代浏览器一定同时带
|
|
259
|
+
* `sec-fetch-site: cross-site`(已被上一条 loopback 围栏拒掉)。
|
|
260
|
+
*
|
|
261
|
+
* 所以把「两条证明都缺省」收窄成「都缺省 **且** 带宿主 Cookie」:桌面版放行、旧 Safari /
|
|
262
|
+
* 裸 curl 仍然拒。这不是 D32 的松动——本函数从来没挡住本机进程(它们随时可以自带
|
|
263
|
+
* `Origin: http://127.0.0.1:<port>` 过闸),防的一直是**浏览器**,而 Cookie 恰恰是浏览器
|
|
264
|
+
* 侧最不可伪造的那一件。
|
|
207
265
|
*/
|
|
208
266
|
function hasSameOriginProof(req) {
|
|
209
267
|
const site = req.headers['sec-fetch-site'];
|
|
210
268
|
if (typeof site === 'string' && site === 'same-origin')
|
|
211
269
|
return true;
|
|
212
270
|
const origin = req.headers.origin;
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
271
|
+
if (origin !== undefined) {
|
|
272
|
+
// Origin 出现就一律以它为准:非字符串 / 空串 / 不同源都拒,**不回落**到 Cookie——
|
|
273
|
+
// 否则一个畸形 Origin 反倒成了绕过同源比对的入口
|
|
274
|
+
if (typeof origin !== 'string' || origin === '')
|
|
275
|
+
return false;
|
|
276
|
+
const host = req.headers.host;
|
|
277
|
+
if (typeof host !== 'string')
|
|
278
|
+
return false;
|
|
279
|
+
try {
|
|
280
|
+
return new URL(origin).host === host;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
223
285
|
}
|
|
286
|
+
const cookie = req.headers.cookie;
|
|
287
|
+
return typeof cookie === 'string' && cookie.trim() !== '';
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 403 的成因摘要(D139):这次桌面版四条流全断,宿主侧**一条日志都没有**,只能靠读客户端
|
|
291
|
+
* 源码 + 拆 `app.asar` 反推。以后同类问题第一眼就能定位:只报这三个头「有没有」,
|
|
292
|
+
* **绝不落 Cookie 的值**(它是宿主会话凭据)。
|
|
293
|
+
*/
|
|
294
|
+
function originProofHint(req) {
|
|
295
|
+
const has = (name) => {
|
|
296
|
+
const value = req.headers[name];
|
|
297
|
+
return typeof value === 'string' && value !== '' ? '有' : '无';
|
|
298
|
+
};
|
|
299
|
+
return `origin=${has('origin')} sec-fetch-site=${has('sec-fetch-site')} cookie=${has('cookie')}`;
|
|
224
300
|
}
|
|
225
301
|
function writeJson(res, status, body) {
|
|
226
302
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
@@ -842,6 +918,9 @@ const plugin = definePlugin({
|
|
|
842
918
|
* 客户端事实上已死(消费速度跟不上产出),主动收尾——宿主内存上限从
|
|
843
919
|
* 「无界」变成「每条流 ≤ MAX_PENDING_BYTES」。上游(docker logs -f 的
|
|
844
920
|
* stdout)由 finish/clientGone 里的 abort 停掉,不需要逐帧 pause。
|
|
921
|
+
*
|
|
922
|
+
* 溢出收尾会补一条 `end{reason:'output-limit'}`(D133):客户端据此提示
|
|
923
|
+
* 「主机侧积压」而不是当成正常结束。
|
|
845
924
|
*/
|
|
846
925
|
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
847
926
|
let pendingFrames = [];
|
|
@@ -916,8 +995,16 @@ const plugin = definePlugin({
|
|
|
916
995
|
return;
|
|
917
996
|
pendingFrames.push(frame);
|
|
918
997
|
pendingBytes += frame.length;
|
|
919
|
-
if (pendingBytes > MAX_PENDING_BYTES)
|
|
998
|
+
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
999
|
+
/*
|
|
1000
|
+
* 队列溢出 = 客户端消费速度跟不上产出(D133):收尾前补一条 end,
|
|
1001
|
+
* 让客户端知道「是主机侧积压」而不是把它当成正常结束。静默 res.end()
|
|
1002
|
+
* 会被 EventSource 判为流正常结束并自动重连——而自动重连复用带 tail
|
|
1003
|
+
* 的 URL,服务端就会把历史整段重推一遍(重复回填)。
|
|
1004
|
+
*/
|
|
1005
|
+
pendingFrames.push(sseFrame('end', { reason: 'output-limit', code: null }));
|
|
920
1006
|
finish();
|
|
1007
|
+
}
|
|
921
1008
|
};
|
|
922
1009
|
const sendEvent = (event, data) => send(sseFrame(event, data));
|
|
923
1010
|
const handle = { end: finish };
|
|
@@ -1278,7 +1365,7 @@ const plugin = definePlugin({
|
|
|
1278
1365
|
}));
|
|
1279
1366
|
add('docker_ps', defineTool({
|
|
1280
1367
|
name: 'docker_ps',
|
|
1281
|
-
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` =
|
|
1368
|
+
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
|
|
1282
1369
|
parameters: {
|
|
1283
1370
|
target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
|
|
1284
1371
|
all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' },
|
|
@@ -1302,6 +1389,7 @@ const plugin = definePlugin({
|
|
|
1302
1389
|
status: { type: 'string', required: true },
|
|
1303
1390
|
health: { type: 'string' },
|
|
1304
1391
|
ports: { type: 'string' },
|
|
1392
|
+
net: { type: 'string' },
|
|
1305
1393
|
composeProject: { type: 'string' },
|
|
1306
1394
|
composeService: { type: 'string' },
|
|
1307
1395
|
},
|
|
@@ -1330,6 +1418,7 @@ const plugin = definePlugin({
|
|
|
1330
1418
|
status: { type: 'string', required: true },
|
|
1331
1419
|
health: { type: 'string' },
|
|
1332
1420
|
ports: { type: 'string' },
|
|
1421
|
+
net: { type: 'string' },
|
|
1333
1422
|
composeProject: { type: 'string' },
|
|
1334
1423
|
composeService: { type: 'string' },
|
|
1335
1424
|
},
|
|
@@ -1360,6 +1449,9 @@ const plugin = definePlugin({
|
|
|
1360
1449
|
status: row.status,
|
|
1361
1450
|
...(row.health === null ? {} : { health: row.health }),
|
|
1362
1451
|
ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
|
|
1452
|
+
// 端口为空才补 net(D131):host 网络容器的端口即宿主机端口、ps 没有映射,
|
|
1453
|
+
// 空 ports 与「没暴露端口」否则无法区分。有映射时不加,避免每行都变长。
|
|
1454
|
+
...(row.ports.length === 0 && row.networks.length > 0 ? { net: row.networks.join(',') } : {}),
|
|
1363
1455
|
...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
|
|
1364
1456
|
...(row.composeService === null ? {} : { composeService: row.composeService }),
|
|
1365
1457
|
});
|
|
@@ -1556,7 +1648,26 @@ const plugin = definePlugin({
|
|
|
1556
1648
|
const { api } = apiFor(picked.name);
|
|
1557
1649
|
if (api === undefined)
|
|
1558
1650
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
1559
|
-
|
|
1651
|
+
let details;
|
|
1652
|
+
try {
|
|
1653
|
+
details = await api.inspect([input.id]);
|
|
1654
|
+
}
|
|
1655
|
+
catch (error) {
|
|
1656
|
+
// 未命中时给候选(D132):`No such object: rmqnamesrv` 只说明这个名字不存在,
|
|
1657
|
+
// 而 docker_ps 里可能就躺着 `607023340cbb_rmqnamesrv`(compose 建过的容器名)。
|
|
1658
|
+
// 只在失败路径多花一次 ps;候选查不到就静默——提示是锦上添花,不能变成新错误。
|
|
1659
|
+
let hint = '';
|
|
1660
|
+
try {
|
|
1661
|
+
const rows = await api.listContainers(true);
|
|
1662
|
+
const hits = suggestContainerNames(input.id, rows.map((row) => row.name));
|
|
1663
|
+
if (hits.length > 0)
|
|
1664
|
+
hint = `(是否想找:${hits.join('、')}?)`;
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
/* 候选获取失败不影响原错误 */
|
|
1668
|
+
}
|
|
1669
|
+
throw new Error((error instanceof Error ? error.message : String(error)) + hint);
|
|
1670
|
+
}
|
|
1560
1671
|
const detail = details[0];
|
|
1561
1672
|
if (detail === undefined)
|
|
1562
1673
|
throw new Error(`容器不存在:${input.id}`);
|
|
@@ -2229,9 +2340,13 @@ const plugin = definePlugin({
|
|
|
2229
2340
|
/**
|
|
2230
2341
|
* GET /logs/stream — 容器日志实时流(`docker logs --follow` → SSE)。
|
|
2231
2342
|
*
|
|
2343
|
+
* 参数:`tail`(0~5000,缺省取配置;**0 = 不补历史只跟随**,客户端断线重连用,
|
|
2344
|
+
* 见 D133)、`timestamps`、`since`。
|
|
2345
|
+
*
|
|
2232
2346
|
* 事件协议(每帧 `event:` + 单行 JSON `data:`):
|
|
2233
2347
|
* - `line` `{"d":"..."}` stdout 分片 / `{"e":"..."}` stderr 分片
|
|
2234
2348
|
* - `end` `{"reason":"container-exit","code":N}` 容器停止、docker logs -f 自然退出
|
|
2349
|
+
* - `end` `{"reason":"output-limit"}` 宿主侧背压队列溢出(客户端消费跟不上,D133)
|
|
2235
2350
|
* - `error` `{"message":"..."}` 后关闭(参数 / 执行失败)
|
|
2236
2351
|
* 心跳:每 15s 一帧 `: ping` 注释;客户端断开则静默中止执行器(SIGTERM 阶梯 /
|
|
2237
2352
|
* channel KILL),不写任何帧。安全语义与快照 /logs 一致:只读能力,不走
|
|
@@ -2262,8 +2377,13 @@ const plugin = definePlugin({
|
|
|
2262
2377
|
writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
|
|
2263
2378
|
return;
|
|
2264
2379
|
}
|
|
2380
|
+
/*
|
|
2381
|
+
* tail=0 合法(D133):客户端断线重连时不补历史、只要新行——否则服务端会把
|
|
2382
|
+
* 最后 tail 行当新行重推,客户端重复回填。空值/纯空白按「未传」处理(取默认),
|
|
2383
|
+
* 不要落进 Number('') === 0 的坑。
|
|
2384
|
+
*/
|
|
2265
2385
|
const tailParam = params.get('tail');
|
|
2266
|
-
const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
|
|
2386
|
+
const tail = tailParam === null || tailParam.trim() === '' ? live.logTailDefault : Number(tailParam);
|
|
2267
2387
|
const timestampsParam = params.get('timestamps');
|
|
2268
2388
|
const sinceParam = params.get('since');
|
|
2269
2389
|
// SSE 与快照同一套 since 校验(D45);非法直接 400(此处不在 POST 的 try 内)
|
|
@@ -2708,8 +2828,10 @@ const plugin = definePlugin({
|
|
|
2708
2828
|
}
|
|
2709
2829
|
// 四条 SSE 都要有「同源证明」(D32):无 Origin 且无 Sec-Fetch-Site 的
|
|
2710
2830
|
// 请求(旧 Safari / 部分 WebView / 裸 curl)在长流端点上拒绝——浏览器
|
|
2711
|
-
// 的 EventSource / fetch
|
|
2831
|
+
// 的 EventSource / fetch 同源请求都会带其中之一;桌面壳的转发链只带
|
|
2832
|
+
// Cookie,由 hasSameOriginProof 内的例外放行(D139)
|
|
2712
2833
|
if (!hasSameOriginProof(req)) {
|
|
2834
|
+
ctx.logger.warn(`dsh-docker: 拒绝无同源证明的实时流请求 ${sub}(${originProofHint(req)})`);
|
|
2713
2835
|
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):实时流端点拒绝无来源请求' });
|
|
2714
2836
|
return;
|
|
2715
2837
|
}
|
|
@@ -2720,6 +2842,7 @@ const plugin = definePlugin({
|
|
|
2720
2842
|
// 变更类端点(写操作)同样要求同源证明(D32);/config 刻意不在名单里:
|
|
2721
2843
|
// 它是禁用状态下的唯一恢复入口,跨站 POST 已由 loopback + Origin 比对拦住
|
|
2722
2844
|
if (req.method === 'POST' && MUTATION_SUBROUTES.has(sub) && !hasSameOriginProof(req)) {
|
|
2845
|
+
ctx.logger.warn(`dsh-docker: 拒绝无同源证明的变更请求 ${sub}(${originProofHint(req)})`);
|
|
2723
2846
|
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
|
|
2724
2847
|
return;
|
|
2725
2848
|
}
|
|
@@ -2761,7 +2884,8 @@ const plugin = definePlugin({
|
|
|
2761
2884
|
writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
|
|
2762
2885
|
return;
|
|
2763
2886
|
}
|
|
2764
|
-
|
|
2887
|
+
// 目标级抛错打标记,由外层 catch 分流成 200 + ok:false(见 guardTargetFailures)
|
|
2888
|
+
const api = guardTargetFailures(built.api);
|
|
2765
2889
|
// 引用白名单在路由层先跑一次(D37):非法引用是**客户端**错误,直接 400
|
|
2766
2890
|
// 带原因——落到 DockerApi 里才抛的话会被外层 catch 统一写成 500。
|
|
2767
2891
|
// D97:`/action` 的 id、`/stats` 的 ids[] 与 `/exec` 的空 command 原先漏在外,
|
|
@@ -2972,7 +3096,18 @@ const plugin = definePlugin({
|
|
|
2972
3096
|
}
|
|
2973
3097
|
}
|
|
2974
3098
|
catch (error) {
|
|
2975
|
-
|
|
3099
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3100
|
+
/*
|
|
3101
|
+
* 目标级失败(SSH 不可达 / 密钥读不到 / docker 不在)不是服务端 bug:
|
|
3102
|
+
* 按多目标聚合的同一口径回 200 + ok:false,客户端两条路都取 payload.error
|
|
3103
|
+
* (client-src 的 request():!response.ok 与 ok === false 都抛 error),
|
|
3104
|
+
* 面板显示的文案不变,但语义对了——500 该留给真正的服务端故障。
|
|
3105
|
+
*/
|
|
3106
|
+
if (isTargetFailure(error)) {
|
|
3107
|
+
writeJson(res, 200, { ok: false, target: picked.name, error: message });
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
writeJson(res, 500, { error: message });
|
|
2976
3111
|
}
|
|
2977
3112
|
},
|
|
2978
3113
|
});
|