@hyzyn/dsh-docker 0.6.3 → 0.7.0
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 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +606 -80
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
|
-
import
|
|
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
6
|
import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
6
7
|
const TARGET_SCHEMA = z.object({
|
|
7
8
|
name: z.string().required(),
|
|
@@ -21,7 +22,10 @@ const TARGET_SCHEMA = z.object({
|
|
|
21
22
|
const HOST_KEY_SCHEMA = z.object({
|
|
22
23
|
host: z.string().required(),
|
|
23
24
|
port: z.natural().max(65535).default(22),
|
|
24
|
-
|
|
25
|
+
/** 同一 host:port 的全部主机密钥指纹(rsa / ed25519 等各一条)。 */
|
|
26
|
+
fingerprints: z.array(z.string()).default([]),
|
|
27
|
+
/** 旧版单指纹字段:仅作迁移输入(sanitizeHostKeys 会并进 fingerprints)。 */
|
|
28
|
+
fingerprint: z.string().default(''),
|
|
25
29
|
});
|
|
26
30
|
const DOCKER_SETTINGS_SCHEMA = z.object({
|
|
27
31
|
enabled: z.boolean().default(true),
|
|
@@ -51,12 +55,25 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
51
55
|
'hostKeys',
|
|
52
56
|
/** 显式清空全部目标的确认位(见 POST /config 的空数组防丢保护)。 */
|
|
53
57
|
'clearTargets',
|
|
58
|
+
/** 显式删除 SSH 主机密钥记录:[{host, port}](hostKeys 是并集合并,删除必须显式)。 */
|
|
59
|
+
'hostKeysRemove',
|
|
54
60
|
]);
|
|
55
61
|
/* ------------------------------------------------------------------ *
|
|
56
62
|
* 常量
|
|
57
63
|
* ------------------------------------------------------------------ */
|
|
58
64
|
const ROUTE_PREFIX = '/api/dsh-docker';
|
|
59
65
|
const BODY_LIMIT = 1024 * 1024;
|
|
66
|
+
/** 变更类子路由(D32):要求同源证明(Origin 或 Sec-Fetch-Site: same-origin)。 */
|
|
67
|
+
const MUTATION_SUBROUTES = new Set([
|
|
68
|
+
'/action',
|
|
69
|
+
'/images/remove',
|
|
70
|
+
'/images/prune',
|
|
71
|
+
'/networks/remove',
|
|
72
|
+
'/networks/prune',
|
|
73
|
+
'/volumes/remove',
|
|
74
|
+
'/volumes/prune',
|
|
75
|
+
'/exec',
|
|
76
|
+
]);
|
|
60
77
|
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**)、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 权限,不要在用户未明确要求时执行变更操作。';
|
|
61
78
|
/**
|
|
62
79
|
* SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
|
|
@@ -68,9 +85,81 @@ export function sseFrame(event, data) {
|
|
|
68
85
|
/** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
|
|
69
86
|
const SSE_HEARTBEAT_MS = 15_000;
|
|
70
87
|
/** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
|
|
88
|
+
/*
|
|
89
|
+
* 环回地址判定(D31):接受 127/8 全段(BSD/Linux 惯例——整个 127.0.0.0/8 都是
|
|
90
|
+
* 环回,此前只认 127.0.0.1 一个字面量)与 IPv6 等价形式(::1、::ffff: 映射)。
|
|
91
|
+
*/
|
|
92
|
+
function isLoopbackAddress(address) {
|
|
93
|
+
if (address === undefined || address === '')
|
|
94
|
+
return false;
|
|
95
|
+
let text = address.toLowerCase();
|
|
96
|
+
if (text.startsWith('::ffff:'))
|
|
97
|
+
text = text.slice(7);
|
|
98
|
+
if (text === '::1')
|
|
99
|
+
return true;
|
|
100
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
|
101
|
+
return v4 !== null && v4[1] === '127';
|
|
102
|
+
}
|
|
103
|
+
/** 别名 Host 解析结果的缓存与超时(D110):请求路径里的 DNS 不该每请求都打一次,也不该无限等。 */
|
|
104
|
+
const HOST_LOOPBACK_TTL_MS = 60_000;
|
|
105
|
+
const HOST_LOOPBACK_CACHE_MAX = 64;
|
|
106
|
+
const HOST_LOOPBACK_TIMEOUT_MS = 500;
|
|
107
|
+
const hostLoopbackCache = new Map();
|
|
108
|
+
/**
|
|
109
|
+
* 解析一个主机名是否指向本机(D110)。带 500ms 超时与 60s 的 LRU(别名部署下每个请求都要过一次)。
|
|
110
|
+
* **失败与超时不缓存**:解析器恢复后要立刻生效,而不是把一次抖动钉 60 秒。
|
|
111
|
+
*/
|
|
112
|
+
async function lookupHostLoopback(host) {
|
|
113
|
+
const cached = hostLoopbackCache.get(host);
|
|
114
|
+
if (cached !== undefined && Date.now() - cached.at <= HOST_LOOPBACK_TTL_MS)
|
|
115
|
+
return cached.loopback;
|
|
116
|
+
let timer = null;
|
|
117
|
+
try {
|
|
118
|
+
const records = await Promise.race([
|
|
119
|
+
dns.promises.lookup(host, { all: true }),
|
|
120
|
+
new Promise((_resolve, reject) => {
|
|
121
|
+
timer = setTimeout(() => reject(new Error(`DNS 解析超时(>${String(HOST_LOOPBACK_TIMEOUT_MS)}ms)`)), HOST_LOOPBACK_TIMEOUT_MS);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
const loopback = records.some((record) => isLoopbackAddress(record.address));
|
|
126
|
+
if (hostLoopbackCache.size >= HOST_LOOPBACK_CACHE_MAX) {
|
|
127
|
+
const oldest = hostLoopbackCache.keys().next().value;
|
|
128
|
+
if (oldest !== undefined)
|
|
129
|
+
hostLoopbackCache.delete(oldest);
|
|
130
|
+
}
|
|
131
|
+
hostLoopbackCache.set(host, { at: Date.now(), loopback });
|
|
132
|
+
return loopback;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (timer !== null)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Host 是否指向本机(D31):字面量环回直接判;主机名 / /etc/hosts 别名走一次带超时的
|
|
144
|
+
* DNS 解析。判不出来就拒绝——围栏宁可误拦一个怪别名,不能放行一个能解析到公网的 Host。
|
|
145
|
+
*/
|
|
146
|
+
async function hostResolvesToLoopback(hostname) {
|
|
147
|
+
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
148
|
+
if (host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host))
|
|
149
|
+
return true;
|
|
150
|
+
return await lookupHostLoopback(host);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* loopback 信任围栏(D31):字面量环回(绝大多数请求)**同步**判定——保持
|
|
154
|
+
* 「请求进来即建流」的原有时序(SSE 测试与 EventSource 都依赖第一拍就写头);
|
|
155
|
+
* 只有主机名 / /etc/hosts 别名才走异步 DNS 确认。
|
|
156
|
+
*
|
|
157
|
+
* **来源检查必须在解析 Host 之前**(D80):别名主机名(`127.0.0.1.nip.io`、`/etc/hosts` 里
|
|
158
|
+
* 的别名)走的是异步分支,若在那里提前 return,`Sec-Fetch-Site` 与 Origin 两段检查会被
|
|
159
|
+
* 整段跳过 —— 围栏等于没设,跨站页面就能写 `/config`(它不要求同源证明)。
|
|
160
|
+
*/
|
|
71
161
|
function isLoopbackHttp(req) {
|
|
72
|
-
|
|
73
|
-
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
162
|
+
if (!isLoopbackAddress(req.socket.remoteAddress))
|
|
74
163
|
return false;
|
|
75
164
|
const host = req.headers.host;
|
|
76
165
|
if (typeof host !== 'string')
|
|
@@ -82,15 +171,44 @@ function isLoopbackHttp(req) {
|
|
|
82
171
|
catch {
|
|
83
172
|
return false;
|
|
84
173
|
}
|
|
85
|
-
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
|
|
86
|
-
return false;
|
|
87
174
|
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
88
175
|
return false;
|
|
89
176
|
const origin = req.headers.origin;
|
|
90
|
-
if (origin
|
|
177
|
+
if (origin !== undefined) {
|
|
178
|
+
let sameOrigin = false;
|
|
179
|
+
try {
|
|
180
|
+
sameOrigin = new URL(origin).host === hostUrl.host;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
sameOrigin = false;
|
|
184
|
+
}
|
|
185
|
+
if (!sameOrigin)
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const hostname = hostUrl.hostname.toLowerCase().replace(/\.$/, '');
|
|
189
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || isLoopbackAddress(hostname))
|
|
91
190
|
return true;
|
|
191
|
+
return hostResolvesToLoopback(hostname);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 「同源证明」(D32):变更类与长流端点要求请求带 Origin(浏览器 fetch 对
|
|
195
|
+
* cross-site 一定带)或 Sec-Fetch-Site: same-origin 之一。恶意页面可以用
|
|
196
|
+
* `<img src="GET /images/pull/stream?...">` 触发副作用 / 拉起 docker 子进程,
|
|
197
|
+
* 而旧 Safari / 部分 WebView 既不发 Origin 也不发 Sec-Fetch-Site——这两类端点
|
|
198
|
+
* 对「无来源证明」的请求拒绝;只读端点维持 loopback-only 的原信任模型。
|
|
199
|
+
*/
|
|
200
|
+
function hasSameOriginProof(req) {
|
|
201
|
+
const site = req.headers['sec-fetch-site'];
|
|
202
|
+
if (typeof site === 'string' && site === 'same-origin')
|
|
203
|
+
return true;
|
|
204
|
+
const origin = req.headers.origin;
|
|
205
|
+
if (typeof origin !== 'string' || origin === '')
|
|
206
|
+
return false;
|
|
207
|
+
const host = req.headers.host;
|
|
208
|
+
if (typeof host !== 'string')
|
|
209
|
+
return false;
|
|
92
210
|
try {
|
|
93
|
-
return new URL(origin).host ===
|
|
211
|
+
return new URL(origin).host === host;
|
|
94
212
|
}
|
|
95
213
|
catch {
|
|
96
214
|
return false;
|
|
@@ -155,38 +273,106 @@ export function sanitizeTargets(input) {
|
|
|
155
273
|
}
|
|
156
274
|
return out;
|
|
157
275
|
}
|
|
158
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* 清洗一份 hostKeys 输入(settings 存储 / 热更新 / 种子复制共用)。
|
|
278
|
+
*
|
|
279
|
+
* 同时接受两种形状(D03):
|
|
280
|
+
* - tty 0.19.0+ 的 `{host, port, fingerprints: [...]}`(多指纹集合);
|
|
281
|
+
* - docker 0.6.x 自己落盘的 `{host, port, fingerprint}`(迁移输入)。
|
|
282
|
+
* host 统一 trim + 小写(与 tty 的落盘口径一致,否则种子命中与否取决于大小写),
|
|
283
|
+
* 同一 host:port 的多条记录合并成一组指纹。
|
|
284
|
+
*/
|
|
159
285
|
export function sanitizeHostKeys(input) {
|
|
160
286
|
if (!Array.isArray(input))
|
|
161
287
|
return undefined;
|
|
162
|
-
const
|
|
288
|
+
const byKey = new Map();
|
|
163
289
|
for (const raw of input) {
|
|
164
290
|
if (typeof raw !== 'object' || raw === null)
|
|
165
291
|
continue;
|
|
166
292
|
const item = raw;
|
|
167
|
-
const host = typeof item.host === 'string' ? item.host.trim() : '';
|
|
168
|
-
const fingerprint = typeof item.fingerprint === 'string' ? item.fingerprint.trim() : '';
|
|
169
|
-
if (host === '' || fingerprint === '')
|
|
170
|
-
continue;
|
|
293
|
+
const host = typeof item.host === 'string' ? item.host.trim().toLowerCase() : '';
|
|
171
294
|
const port = typeof item.port === 'number' && Number.isInteger(item.port) && item.port >= 1 && item.port <= 65535 ? item.port : 22;
|
|
172
|
-
|
|
295
|
+
const list = Array.isArray(item.fingerprints) ? item.fingerprints : [];
|
|
296
|
+
const single = typeof item.fingerprint === 'string' && item.fingerprint.trim() !== '' ? [item.fingerprint.trim()] : [];
|
|
297
|
+
const fingerprints = [...new Set([
|
|
298
|
+
...list.filter((fp) => typeof fp === 'string' && fp.trim() !== '').map((fp) => fp.trim()),
|
|
299
|
+
...single,
|
|
300
|
+
])];
|
|
301
|
+
if (host === '' || fingerprints.length === 0)
|
|
302
|
+
continue;
|
|
303
|
+
const key = `${host}:${String(port)}`;
|
|
304
|
+
const existing = byKey.get(key);
|
|
305
|
+
if (existing !== undefined) {
|
|
306
|
+
existing.fingerprints = [...new Set([...existing.fingerprints, ...fingerprints])];
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
byKey.set(key, { host, port, fingerprints });
|
|
173
310
|
}
|
|
174
|
-
return
|
|
311
|
+
return [...byKey.values()];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* hostKeys **并集**合并(D10):客户端表单快照回传的表不得整表覆盖 TOFU 运行期
|
|
315
|
+
* 新增的记录——面板一次无关保存就把钉扎回退掉,指纹变更检测随之失效。按
|
|
316
|
+
* host:port 合并指纹集合;删除某条记录走显式的 `hostKeysRemove`。
|
|
317
|
+
*/
|
|
318
|
+
export function mergeHostKeys(base, incoming) {
|
|
319
|
+
const byKey = new Map();
|
|
320
|
+
for (const record of base) {
|
|
321
|
+
byKey.set(`${record.host}:${String(record.port)}`, { host: record.host, port: record.port, fingerprints: [...record.fingerprints] });
|
|
322
|
+
}
|
|
323
|
+
for (const record of sanitizeHostKeys(incoming) ?? []) {
|
|
324
|
+
const key = `${record.host}:${String(record.port)}`;
|
|
325
|
+
const existing = byKey.get(key);
|
|
326
|
+
if (existing === undefined) {
|
|
327
|
+
byKey.set(key, { host: record.host, port: record.port, fingerprints: [...record.fingerprints] });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
existing.fingerprints = [...new Set([...existing.fingerprints, ...record.fingerprints])];
|
|
331
|
+
}
|
|
332
|
+
return [...byKey.values()];
|
|
175
333
|
}
|
|
176
334
|
/**
|
|
177
335
|
* 合并凭证:配置卡片从不回显密码 / 口令(只回 passwordSet),因此浏览器提交的
|
|
178
336
|
* targets 里往往**没有** password/passphrase 字段。按目标名把已有值补回来,
|
|
179
337
|
* 避免「改个名字就把密码清了」。(显式传空字符串仍然按清空处理。)
|
|
338
|
+
*
|
|
339
|
+
* 改名的目标按**连接身份**(book / host / port / username / auth / keyPath)认领
|
|
340
|
+
* 旧凭证(D15):只按名字找的话,改名 = 凭证凭空消失。身份对不上就不继承——
|
|
341
|
+
* 「删一个目标、另建一个无关目标」不应该串密码,宁缺勿错。
|
|
180
342
|
*/
|
|
181
343
|
export function mergeTargetSecrets(prev, incoming) {
|
|
182
344
|
if (!Array.isArray(incoming))
|
|
183
345
|
return incoming;
|
|
346
|
+
const incomingNames = new Set();
|
|
347
|
+
for (const raw of incoming) {
|
|
348
|
+
if (typeof raw !== 'object' || raw === null)
|
|
349
|
+
continue;
|
|
350
|
+
const name = raw.name;
|
|
351
|
+
if (typeof name === 'string' && name.trim() !== '')
|
|
352
|
+
incomingNames.add(name.trim());
|
|
353
|
+
}
|
|
354
|
+
// 名字没出现在新表里的旧目标 = 改名 / 删除的候选
|
|
355
|
+
const renamed = prev.filter((target) => !incomingNames.has(target.name));
|
|
184
356
|
return incoming.map((raw) => {
|
|
185
357
|
if (typeof raw !== 'object' || raw === null)
|
|
186
358
|
return raw;
|
|
187
359
|
const item = raw;
|
|
188
360
|
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
189
|
-
|
|
361
|
+
let before = prev.find((target) => target.name === name);
|
|
362
|
+
if (before === undefined && renamed.length > 0) {
|
|
363
|
+
const book = typeof item.book === 'string' ? item.book.trim() : '';
|
|
364
|
+
const host = typeof item.host === 'string' ? item.host.trim() : '';
|
|
365
|
+
const username = typeof item.username === 'string' ? item.username.trim() : '';
|
|
366
|
+
const keyPath = typeof item.keyPath === 'string' ? item.keyPath.trim() : '';
|
|
367
|
+
const auth = item.auth === 'key' || item.auth === 'password' ? item.auth : 'agent';
|
|
368
|
+
const port = typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22;
|
|
369
|
+
before = renamed.find((target) => (target.book ?? '') === book
|
|
370
|
+
&& target.host === host
|
|
371
|
+
&& (target.port ?? 22) === port
|
|
372
|
+
&& target.username === username
|
|
373
|
+
&& (target.auth ?? 'agent') === auth
|
|
374
|
+
&& target.keyPath === keyPath);
|
|
375
|
+
}
|
|
190
376
|
if (before === undefined)
|
|
191
377
|
return item;
|
|
192
378
|
const next = { ...item };
|
|
@@ -348,7 +534,7 @@ const plugin = definePlugin({
|
|
|
348
534
|
const ttySeed = () => {
|
|
349
535
|
const map = new Map();
|
|
350
536
|
for (const record of readTtyHostKeys(settingsApi))
|
|
351
|
-
map.set(`${record.host}:${String(record.port)}`, record.
|
|
537
|
+
map.set(`${record.host}:${String(record.port)}`, record.fingerprints);
|
|
352
538
|
return map;
|
|
353
539
|
};
|
|
354
540
|
const persistHostKeys = (records) => {
|
|
@@ -361,21 +547,35 @@ const plugin = definePlugin({
|
|
|
361
547
|
};
|
|
362
548
|
const hostKeyStore = {
|
|
363
549
|
get(host, port) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
550
|
+
// host 键统一 trim + 小写(D03):tty 落盘时把 host 小写化,比较口径必须一致
|
|
551
|
+
const key = host.trim().toLowerCase();
|
|
552
|
+
const own = live.hostKeys.find((record) => record.host === key && record.port === port);
|
|
553
|
+
if (own !== undefined && own.fingerprints.length > 0)
|
|
554
|
+
return own.fingerprints;
|
|
555
|
+
// tty 已确认过的主机不再重复确认:种子命中即视为可信,并**整组**复制进本插件的
|
|
556
|
+
// 记录(D03)——此前只认单数 fingerprint 字段,tty 0.19.0 改存 fingerprints[] 后
|
|
557
|
+
// 种子恒为空,对 tty 钉扎过的主机会静默重新 TOFU(指纹变更不再拒绝)。
|
|
558
|
+
const seeded = ttySeed().get(`${key}:${String(port)}`);
|
|
559
|
+
if (seeded !== undefined && seeded.length > 0) {
|
|
560
|
+
live.hostKeys = [...live.hostKeys, { host: key, port, fingerprints: [...seeded] }];
|
|
371
561
|
persistHostKeys(live.hostKeys);
|
|
372
562
|
return seeded;
|
|
373
563
|
}
|
|
374
|
-
return
|
|
564
|
+
return own?.fingerprints;
|
|
375
565
|
},
|
|
376
566
|
record(host, port, fingerprint) {
|
|
377
|
-
const
|
|
378
|
-
|
|
567
|
+
const key = host.trim().toLowerCase();
|
|
568
|
+
const existing = live.hostKeys.find((record) => record.host === key && record.port === port);
|
|
569
|
+
if (existing !== undefined) {
|
|
570
|
+
// 同一主机的第二把主机密钥(rsa + ed25519):并入集合而不是覆盖(D03)
|
|
571
|
+
if (!existing.fingerprints.includes(fingerprint)) {
|
|
572
|
+
existing.fingerprints = [...existing.fingerprints, fingerprint];
|
|
573
|
+
persistHostKeys(live.hostKeys);
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const next = live.hostKeys.filter((record) => !(record.host === key && record.port === port));
|
|
578
|
+
next.push({ host: key, port, fingerprints: [fingerprint] });
|
|
379
579
|
live.hostKeys = next;
|
|
380
580
|
persistHostKeys(next);
|
|
381
581
|
},
|
|
@@ -434,8 +634,19 @@ const plugin = definePlugin({
|
|
|
434
634
|
};
|
|
435
635
|
/** 解析工具/路由里的 target 参数。 */
|
|
436
636
|
const pickTarget = (input) => {
|
|
437
|
-
if (typeof input === 'string' && input.trim() !== '')
|
|
438
|
-
|
|
637
|
+
if (typeof input === 'string' && input.trim() !== '') {
|
|
638
|
+
const name = input.trim();
|
|
639
|
+
// `*` 聚合只有 docker_ps / docker_attention 支持(D46):到这里说明是单目标
|
|
640
|
+
// 工具传了 `*`——给专门文案,而不是一句「未知目标:*」
|
|
641
|
+
if (name === '*')
|
|
642
|
+
return { error: '`*`(全部目标)只有 docker_ps / docker_attention 支持;请传具体目标名(docker_targets 列出)' };
|
|
643
|
+
return { name };
|
|
644
|
+
}
|
|
645
|
+
// 类型不对(数字 / 数组 / 对象)必须报错(D34):静默回落到默认目标会让
|
|
646
|
+
// 破坏性操作打错主机的最后一道防线失效
|
|
647
|
+
if (input !== undefined && input !== null && typeof input !== 'string') {
|
|
648
|
+
return { error: 'target 必须是字符串(目标名见 docker_targets)' };
|
|
649
|
+
}
|
|
439
650
|
const fallback = defaultTargetName();
|
|
440
651
|
if (fallback !== undefined)
|
|
441
652
|
return { name: fallback };
|
|
@@ -534,6 +745,17 @@ const plugin = definePlugin({
|
|
|
534
745
|
hostKeys: live.hostKeys,
|
|
535
746
|
// 只读复用 tty 连接簿:卡片用它渲染「从连接簿选择」下拉
|
|
536
747
|
ttyBooks: [...books.keys()],
|
|
748
|
+
/*
|
|
749
|
+
* 连接簿条目 → 解析后的 host:port(**只 name/host/port,凭据永不出宿主**)。
|
|
750
|
+
*
|
|
751
|
+
* 客户端「当前会话主机 ↔ 目标」的匹配要在**连接之前**就能判定:从连接簿打开的
|
|
752
|
+
* SSH 标签,spec 里只有条目名;宿主回显的 `tab.target` 只在**连接成功**后才
|
|
753
|
+
* 有值,而连不上(握手超时 / 主机没开机)恰恰是最需要面板的时候。少了这张表,
|
|
754
|
+
* 面板只能判成「该主机没配目标」,然后沿用上一次选的目标——把另一台主机的容器
|
|
755
|
+
* 显示出来。有了它,按主机配的目标(如目标绑 HS-248 条目、会话走
|
|
756
|
+
* 192.168.80.248 条目)在连接失败时也能对上。
|
|
757
|
+
*/
|
|
758
|
+
ttyBookHosts: [...books.entries()].map(([name, spec]) => ({ name, host: spec.host, port: spec.port })),
|
|
537
759
|
ttyAvailable: books.size > 0,
|
|
538
760
|
toolsRegistered: registeredNames,
|
|
539
761
|
};
|
|
@@ -592,6 +814,17 @@ const plugin = definePlugin({
|
|
|
592
814
|
const controller = new AbortController();
|
|
593
815
|
let done = false;
|
|
594
816
|
let heartbeat = null;
|
|
817
|
+
/*
|
|
818
|
+
* 背压(D04):write() 返回 false = socket 写缓冲已满。无视返回值继续写,
|
|
819
|
+
* 慢客户端(后台标签 / 慢链路)+ 话痨容器会让宿主侧缓冲无界增长直至 OOM。
|
|
820
|
+
* 处理:缓冲已满时把帧暂存进内存队列、等 drain 再续写;队列超过上限视为
|
|
821
|
+
* 客户端事实上已死(消费速度跟不上产出),主动收尾——宿主内存上限从
|
|
822
|
+
* 「无界」变成「每条流 ≤ MAX_PENDING_BYTES」。上游(docker logs -f 的
|
|
823
|
+
* stdout)由 finish/clientGone 里的 abort 停掉,不需要逐帧 pause。
|
|
824
|
+
*/
|
|
825
|
+
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
826
|
+
let pendingFrames = [];
|
|
827
|
+
let pendingBytes = 0;
|
|
595
828
|
const stopHeartbeat = () => {
|
|
596
829
|
if (heartbeat === null)
|
|
597
830
|
return;
|
|
@@ -604,6 +837,8 @@ const plugin = definePlugin({
|
|
|
604
837
|
return;
|
|
605
838
|
done = true;
|
|
606
839
|
stopHeartbeat();
|
|
840
|
+
pendingFrames = [];
|
|
841
|
+
pendingBytes = 0;
|
|
607
842
|
activeStreams.delete(handle);
|
|
608
843
|
controller.abort();
|
|
609
844
|
};
|
|
@@ -615,28 +850,59 @@ const plugin = definePlugin({
|
|
|
615
850
|
stopHeartbeat();
|
|
616
851
|
activeStreams.delete(handle);
|
|
617
852
|
controller.abort();
|
|
853
|
+
/*
|
|
854
|
+
* 收尾前把没写完的帧交给 res.end 落地(D83):直接清空队列会把已经入队、还没进
|
|
855
|
+
* socket 的帧(含 end / error 帧)一起丢掉。客户端凭「连接关了但没有 end 帧」
|
|
856
|
+
* 判定异常 → EventSource 自动重连 → **拉取流会把 docker pull 再跑一遍**。
|
|
857
|
+
* 队列本身有 8MB 上限,这里一次 append 不会放大内存。
|
|
858
|
+
*/
|
|
859
|
+
const tail = pendingFrames.join('');
|
|
860
|
+
pendingFrames = [];
|
|
861
|
+
pendingBytes = 0;
|
|
618
862
|
try {
|
|
619
|
-
res.end();
|
|
863
|
+
res.end(tail === '' ? undefined : tail);
|
|
620
864
|
}
|
|
621
865
|
catch {
|
|
622
866
|
/* 连接已断开 */
|
|
623
867
|
}
|
|
624
868
|
};
|
|
625
|
-
const
|
|
626
|
-
if (done)
|
|
627
|
-
return;
|
|
869
|
+
const writeFrame = (frame) => {
|
|
628
870
|
try {
|
|
629
|
-
write(frame);
|
|
871
|
+
return write(frame) !== false;
|
|
630
872
|
}
|
|
631
873
|
catch {
|
|
632
874
|
// 写失败 = 连接已断:与 res close 同一收尾路径
|
|
633
875
|
clientGone();
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
/** drain 后续写暂存的帧;中途再遇 false 就停手等下一次 drain。 */
|
|
880
|
+
const flushPending = () => {
|
|
881
|
+
while (!done && pendingFrames.length > 0) {
|
|
882
|
+
const frame = pendingFrames[0];
|
|
883
|
+
if (!writeFrame(frame))
|
|
884
|
+
return;
|
|
885
|
+
pendingBytes -= frame.length;
|
|
886
|
+
pendingFrames.shift();
|
|
634
887
|
}
|
|
635
888
|
};
|
|
889
|
+
const send = (frame) => {
|
|
890
|
+
if (done)
|
|
891
|
+
return;
|
|
892
|
+
if (pendingFrames.length === 0 && writeFrame(frame))
|
|
893
|
+
return;
|
|
894
|
+
if (done)
|
|
895
|
+
return;
|
|
896
|
+
pendingFrames.push(frame);
|
|
897
|
+
pendingBytes += frame.length;
|
|
898
|
+
if (pendingBytes > MAX_PENDING_BYTES)
|
|
899
|
+
finish();
|
|
900
|
+
};
|
|
636
901
|
const sendEvent = (event, data) => send(sseFrame(event, data));
|
|
637
902
|
const handle = { end: finish };
|
|
638
903
|
activeStreams.add(handle);
|
|
639
904
|
res.on?.('close', clientGone);
|
|
905
|
+
res.on?.('drain', flushPending);
|
|
640
906
|
heartbeat = setInterval(() => send(': ping\n\n'), SSE_HEARTBEAT_MS);
|
|
641
907
|
heartbeat.unref?.();
|
|
642
908
|
try {
|
|
@@ -654,17 +920,63 @@ const plugin = definePlugin({
|
|
|
654
920
|
}
|
|
655
921
|
};
|
|
656
922
|
/* ---------- 配置热应用 ---------- */
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
923
|
+
/**
|
|
924
|
+
* 逐字段比较目标列表(含凭证):凭证变了,已开的流按旧凭据在跑,也要收尾。
|
|
925
|
+
* **顺序无关**(D108):面板上重排一次目标不该被当成「目标变了」而收流 —— 那会把
|
|
926
|
+
* 在途的 docker pull 一起 abort 掉(正是 D09 要消除的「一次白拉」)。
|
|
927
|
+
*/
|
|
928
|
+
const sameTargets = (a, b) => {
|
|
929
|
+
if (a.length !== b.length)
|
|
930
|
+
return false;
|
|
931
|
+
const byName = new Map(b.map((item) => [item.name, item]));
|
|
932
|
+
return a.every((item) => {
|
|
933
|
+
const other = byName.get(item.name);
|
|
934
|
+
if (other === undefined)
|
|
935
|
+
return false;
|
|
936
|
+
return item.kind === other.kind
|
|
937
|
+
&& item.book === other.book
|
|
938
|
+
&& item.host === other.host
|
|
939
|
+
&& item.port === other.port
|
|
940
|
+
&& item.username === other.username
|
|
941
|
+
&& item.auth === other.auth
|
|
942
|
+
&& item.keyPath === other.keyPath
|
|
943
|
+
&& item.password === other.password
|
|
944
|
+
&& item.passphrase === other.passphrase
|
|
945
|
+
&& item.agentForward === other.agentForward;
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
const applySection = (section, options) => {
|
|
949
|
+
const before = live;
|
|
661
950
|
// hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
|
|
662
951
|
const merged = { ...live, ...section };
|
|
663
952
|
if (section.hostKeys === undefined)
|
|
664
953
|
merged.hostKeys = live.hostKeys;
|
|
665
954
|
live = normalizeConfig(merged);
|
|
666
|
-
|
|
667
|
-
|
|
955
|
+
/*
|
|
956
|
+
* 执行通道相关的键**变了**才收流(D09):TOFU 落盘会走到这里(hostKeys 变化、
|
|
957
|
+
* 其余不变),此前无差别 closeAllStreams 会把刚开的日志 FOLLOW 掐断、把在途的
|
|
958
|
+
* docker pull abort 掉——一次白拉。浏览器侧 EventSource 虽会自动重连,但拉取
|
|
959
|
+
* 不会自己重来。
|
|
960
|
+
*/
|
|
961
|
+
// 能力开关被**撤销**时也必须收流(D84):在途的 /images/pull/stream 是写操作,
|
|
962
|
+
// 关掉「允许变更操作」就该立刻停手(旧代码无条件收流,D09 收窄条件时漏了这条)。
|
|
963
|
+
const mutationsRevoked = before.allowMutations && !live.allowMutations;
|
|
964
|
+
if (before.enabled !== live.enabled || before.dockerBin !== live.dockerBin || !sameTargets(before.targets, live.targets) || mutationsRevoked) {
|
|
965
|
+
closeAllStreams();
|
|
966
|
+
}
|
|
967
|
+
// 工具注册面只受启用态 / 能力开关影响;dockerBin 不改变工具清单但影响执行,
|
|
968
|
+
// 一起重注册是幂等的,保守带上。forceRefreshTools 给「保存路径」用:上一次注册
|
|
969
|
+
// 可能只成功了一部分,重存同一份配置也要能重试(D107)。
|
|
970
|
+
if (options?.forceRefreshTools === true
|
|
971
|
+
|| before.enabled !== live.enabled
|
|
972
|
+
|| before.dockerBin !== live.dockerBin
|
|
973
|
+
|| before.allowMutations !== live.allowMutations
|
|
974
|
+
|| before.allowExec !== live.allowExec) {
|
|
975
|
+
refreshTools();
|
|
976
|
+
}
|
|
977
|
+
if (before.enabled !== live.enabled || before.announceToAgent !== live.announceToAgent) {
|
|
978
|
+
refreshAnnouncement();
|
|
979
|
+
}
|
|
668
980
|
console.log(`[dsh-docker] config applied (enabled=${String(live.enabled)}, bin=${live.dockerBin}, targets=${String(live.targets.length)}, allowMutations=${String(live.allowMutations)}, allowExec=${String(live.allowExec)})`);
|
|
669
981
|
};
|
|
670
982
|
/* ---------- agent 工具 ---------- */
|
|
@@ -676,7 +988,8 @@ const plugin = definePlugin({
|
|
|
676
988
|
return '尚未配置任何 Docker 目标(插件配置 → Docker 容器面板 → 目标)。';
|
|
677
989
|
return 'Docker 目标:' + rows.map((row) => {
|
|
678
990
|
const state = row.ok === undefined ? '' : row.ok ? ' [可达]' : ` [不可用:${row.error ?? '未知'}]`;
|
|
679
|
-
|
|
991
|
+
const version = row.serverVersion === undefined ? '' : ` docker ${row.serverVersion}`;
|
|
992
|
+
return `\n- ${row.name} (${row.kind}) ${row.label}${version}${state}`;
|
|
680
993
|
}).join('');
|
|
681
994
|
};
|
|
682
995
|
/**
|
|
@@ -717,16 +1030,28 @@ const plugin = definePlugin({
|
|
|
717
1030
|
};
|
|
718
1031
|
/** 需关注列表渲染(单目标 / 跨目标共用)。入参同样是**工具扁平形状**,不是 AttentionItem。 */
|
|
719
1032
|
const renderAttention = (groups) => {
|
|
1033
|
+
// 零目标不是「一切正常」(D53):排障入口给出假阴性比报错更糟——与 docker_ps
|
|
1034
|
+
// 的「尚未配置任何 Docker 目标」兜底同款
|
|
1035
|
+
if (groups.length === 0)
|
|
1036
|
+
return '尚未配置任何 Docker 目标(插件配置 → Docker 容器面板)。';
|
|
720
1037
|
const total = groups.reduce((sum, group) => sum + (group.items?.length ?? 0), 0);
|
|
721
1038
|
if (total === 0 && groups.every((group) => group.ok))
|
|
722
|
-
return '所有目标上没有需要关注的容器(无 unhealthy /
|
|
1039
|
+
return '所有目标上没有需要关注的容器(无 unhealthy / 反复重启 / OOM / 非零退出 / 僵死)。';
|
|
723
1040
|
return `需关注容器共 ${String(total)} 个:` + groups.map((group) => {
|
|
724
1041
|
if (!group.ok)
|
|
725
1042
|
return `\n\n■ ${group.target}(${group.label})— 不可用:${group.error ?? '未知错误'}`;
|
|
726
1043
|
const items = group.items ?? [];
|
|
727
1044
|
if (items.length === 0)
|
|
728
1045
|
return `\n\n■ ${group.target}(${group.label})— 无异常`;
|
|
729
|
-
|
|
1046
|
+
// 降级信号的措辞要覆盖全部三种成因(D42/D85):inspect 整体失败、候选超出检查预算、
|
|
1047
|
+
// 补捞预算被吃满 —— 都是「权威字段按摘要口径」,而不是单纯的 inspect 失败
|
|
1048
|
+
const warn = group.degraded === true ? '(部分条目未取到权威详情:inspect 失败或候选超出检查预算,OOM / 重启次数等按摘要口径)' : '';
|
|
1049
|
+
// 截断信号(D12):名额按严重度排序后切,超出的不静默
|
|
1050
|
+
const cut = group.truncated === true && group.total !== undefined && group.total > items.length
|
|
1051
|
+
? `(该目标实际共 ${String(group.total)} 条,已按严重度截断为 ${String(items.length)} 条,可传更大的 limit)`
|
|
1052
|
+
: '';
|
|
1053
|
+
const notes = [warn, cut].filter((part) => part !== '').join(' ');
|
|
1054
|
+
return `\n\n■ ${group.target}(${group.label})${notes === '' ? '' : '\n' + notes}` + items.map((item) => {
|
|
730
1055
|
const reasons = item.reasons.map((reason) => ATTENTION_LABEL[reason] ?? reason).join(' + ');
|
|
731
1056
|
const extra = [
|
|
732
1057
|
item.exitCode === undefined ? '' : `exit=${String(item.exitCode)}`,
|
|
@@ -845,10 +1170,24 @@ const plugin = definePlugin({
|
|
|
845
1170
|
// 插件禁用(设置卡片关掉「启用插件」)时不注册任何工具:运行期关掉也要立刻生效
|
|
846
1171
|
if (!live.enabled)
|
|
847
1172
|
return;
|
|
848
|
-
|
|
1173
|
+
// 文案要跟语义一致(D46):`*` 聚合只有 docker_ps / docker_attention 真支持,
|
|
1174
|
+
// 其余 12 个单目标工具省略时只在「恰好一个目标」时回落、多目标则报 target 必填
|
|
1175
|
+
const targetParam = { type: 'string', description: '目标名(docker_targets 列出;只有一个目标时可省略。`*` 仅 docker_ps / docker_attention 支持,其他工具请传具体目标名)' };
|
|
1176
|
+
/*
|
|
1177
|
+
* 单个工具注册失败不该把整批带下去(D107):以前 add 直接抛,调用方(applySection)
|
|
1178
|
+
* 也就抛了,结果是「旧工具已全拆 + 新工具只注册了一半」,而重存同一份配置又因为
|
|
1179
|
+
* 差异判定不再触发 refreshTools —— 半套状态一直缺到重启。现在逐条兜住并汇总,
|
|
1180
|
+
* 下次配置变更(或保存路径的 forceRefreshTools)会自然重试。
|
|
1181
|
+
*/
|
|
1182
|
+
const failures = [];
|
|
849
1183
|
const add = (toolName, definition) => {
|
|
850
|
-
|
|
851
|
-
|
|
1184
|
+
try {
|
|
1185
|
+
toolDisposers.push(tools.register(definition));
|
|
1186
|
+
registeredNames.push(toolName);
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
failures.push(`${toolName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1190
|
+
}
|
|
852
1191
|
};
|
|
853
1192
|
add('docker_targets', defineTool({
|
|
854
1193
|
name: 'docker_targets',
|
|
@@ -871,6 +1210,7 @@ const plugin = definePlugin({
|
|
|
871
1210
|
label: { type: 'string', required: true },
|
|
872
1211
|
ok: { type: 'boolean' },
|
|
873
1212
|
error: { type: 'string' },
|
|
1213
|
+
serverVersion: { type: 'string' },
|
|
874
1214
|
},
|
|
875
1215
|
},
|
|
876
1216
|
},
|
|
@@ -902,7 +1242,15 @@ const plugin = definePlugin({
|
|
|
902
1242
|
continue;
|
|
903
1243
|
}
|
|
904
1244
|
const probe = await api.probe();
|
|
905
|
-
|
|
1245
|
+
// serverVersion 随 probe 回传(D48):README 承诺「探测 docker 版本」,
|
|
1246
|
+
// probe() 本来就返回了它,只是这里被丢掉了
|
|
1247
|
+
rows.push({
|
|
1248
|
+
name: target.name,
|
|
1249
|
+
kind: target.kind,
|
|
1250
|
+
label,
|
|
1251
|
+
ok: probe.ok,
|
|
1252
|
+
...(probe.ok ? { ...(probe.serverVersion === null ? {} : { serverVersion: probe.serverVersion }) } : { error: probe.error ?? '未知错误' }),
|
|
1253
|
+
});
|
|
906
1254
|
}
|
|
907
1255
|
return { targets: rows };
|
|
908
1256
|
},
|
|
@@ -982,7 +1330,9 @@ const plugin = definePlugin({
|
|
|
982
1330
|
const input = (args ?? {});
|
|
983
1331
|
const isAll = typeof input.target === 'string' && input.target.trim() === '*';
|
|
984
1332
|
const toRow = (row) => ({
|
|
985
|
-
|
|
1333
|
+
// 短 ID(D47):docker_attention 已是 shortId,同一字段跨工具宽度要一致
|
|
1334
|
+
//(ps 带 --no-trunc,完整 64 位既与 README 的「短 ID」矛盾也多烧 token)
|
|
1335
|
+
id: row.shortId,
|
|
986
1336
|
name: row.name,
|
|
987
1337
|
image: row.image,
|
|
988
1338
|
state: row.state,
|
|
@@ -1028,6 +1378,9 @@ const plugin = definePlugin({
|
|
|
1028
1378
|
additionalProperties: false,
|
|
1029
1379
|
properties: {
|
|
1030
1380
|
target: { type: 'string' },
|
|
1381
|
+
total: { type: 'number' },
|
|
1382
|
+
truncated: { type: 'boolean' },
|
|
1383
|
+
degraded: { type: 'boolean' },
|
|
1031
1384
|
items: {
|
|
1032
1385
|
type: 'array',
|
|
1033
1386
|
items: {
|
|
@@ -1056,6 +1409,9 @@ const plugin = definePlugin({
|
|
|
1056
1409
|
label: { type: 'string', required: true },
|
|
1057
1410
|
ok: { type: 'boolean', required: true },
|
|
1058
1411
|
error: { type: 'string' },
|
|
1412
|
+
total: { type: 'number' },
|
|
1413
|
+
truncated: { type: 'boolean' },
|
|
1414
|
+
degraded: { type: 'boolean' },
|
|
1059
1415
|
items: {
|
|
1060
1416
|
type: 'array',
|
|
1061
1417
|
items: {
|
|
@@ -1083,7 +1439,23 @@ const plugin = definePlugin({
|
|
|
1083
1439
|
const v = value;
|
|
1084
1440
|
if (Array.isArray(v.groups))
|
|
1085
1441
|
return [{ type: 'text', text: renderAttention(v.groups) }];
|
|
1086
|
-
|
|
1442
|
+
/*
|
|
1443
|
+
* 单目标分支也要把 total / truncated / degraded 带上(D100):renderAttention 的
|
|
1444
|
+
* 「实际共 N 条、已截断」与「inspect 降级」两段提示读的就是这三个字段,漏传时
|
|
1445
|
+
* 最常用的单目标调用仍然静默截断 —— D12/D42 想消除的假阴性就还在。
|
|
1446
|
+
*/
|
|
1447
|
+
return [{
|
|
1448
|
+
type: 'text',
|
|
1449
|
+
text: renderAttention([{
|
|
1450
|
+
target: v.target ?? '?',
|
|
1451
|
+
label: v.target ?? '?',
|
|
1452
|
+
ok: true,
|
|
1453
|
+
items: v.items ?? [],
|
|
1454
|
+
...(v.total === undefined ? {} : { total: v.total }),
|
|
1455
|
+
...(v.truncated === true ? { truncated: true } : {}),
|
|
1456
|
+
...(v.degraded === true ? { degraded: true } : {}),
|
|
1457
|
+
}]),
|
|
1458
|
+
}];
|
|
1087
1459
|
},
|
|
1088
1460
|
},
|
|
1089
1461
|
async execute(args) {
|
|
@@ -1110,7 +1482,12 @@ const plugin = definePlugin({
|
|
|
1110
1482
|
label: group.label,
|
|
1111
1483
|
ok: group.ok,
|
|
1112
1484
|
...(group.error === undefined ? {} : { error: group.error }),
|
|
1113
|
-
...(group.data === undefined ? {} : {
|
|
1485
|
+
...(group.data === undefined ? {} : {
|
|
1486
|
+
items: group.data.items.map(toRow),
|
|
1487
|
+
total: group.data.total,
|
|
1488
|
+
...(group.data.truncated ? { truncated: true } : {}),
|
|
1489
|
+
...(group.data.degraded ? { degraded: true } : {}),
|
|
1490
|
+
}),
|
|
1114
1491
|
})),
|
|
1115
1492
|
};
|
|
1116
1493
|
}
|
|
@@ -1120,8 +1497,14 @@ const plugin = definePlugin({
|
|
|
1120
1497
|
const { api } = apiFor(picked.name);
|
|
1121
1498
|
if (api === undefined)
|
|
1122
1499
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
1123
|
-
const
|
|
1124
|
-
return {
|
|
1500
|
+
const attention = await api.attention(limit === undefined ? undefined : { limit });
|
|
1501
|
+
return {
|
|
1502
|
+
target: picked.name,
|
|
1503
|
+
items: attention.items.map(toRow),
|
|
1504
|
+
total: attention.total,
|
|
1505
|
+
...(attention.truncated ? { truncated: true } : {}),
|
|
1506
|
+
...(attention.degraded ? { degraded: true } : {}),
|
|
1507
|
+
};
|
|
1125
1508
|
},
|
|
1126
1509
|
}));
|
|
1127
1510
|
add('docker_inspect', defineTool({
|
|
@@ -1161,11 +1544,11 @@ const plugin = definePlugin({
|
|
|
1161
1544
|
}));
|
|
1162
1545
|
add('docker_logs', defineTool({
|
|
1163
1546
|
name: 'docker_logs',
|
|
1164
|
-
description: '读取某个容器的日志尾部(docker logs --tail
|
|
1547
|
+
description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
|
|
1165
1548
|
parameters: {
|
|
1166
1549
|
target: targetParam,
|
|
1167
1550
|
id: { type: 'string', required: true, description: '容器名或 ID' },
|
|
1168
|
-
tail: { type: 'number', description: '尾部行数(1~5000
|
|
1551
|
+
tail: { type: 'number', description: '尾部行数(1~5000,默认取配置 logTailDefault;越界值会被静默夹紧到边界)' },
|
|
1169
1552
|
timestamps: { type: 'boolean', description: 'true 时每行带时间戳' },
|
|
1170
1553
|
since: { type: 'string', description: '起始时间(docker --since 语法,如 10m、2026-09-09T10:00:00)' },
|
|
1171
1554
|
},
|
|
@@ -1199,7 +1582,10 @@ const plugin = definePlugin({
|
|
|
1199
1582
|
const result = await api.logs(input.id, {
|
|
1200
1583
|
tail: typeof input.tail === 'number' && Number.isInteger(input.tail) ? input.tail : live.logTailDefault,
|
|
1201
1584
|
timestamps: input.timestamps === true,
|
|
1202
|
-
|
|
1585
|
+
// since 统一口径(D45):与 docker_events 同一个 assertSince——此前 logs
|
|
1586
|
+
// 完全不校验,docker 的参数错误会变成一句不可读的报错。空/纯空白按「未传」处理(D98):
|
|
1587
|
+
// 否则模型传个空串会拿到「since 必填」这种与事实相反的提示。
|
|
1588
|
+
...(typeof input.since === 'string' && input.since.trim() !== '' ? { since: assertSince(input.since) } : {}),
|
|
1203
1589
|
});
|
|
1204
1590
|
return { target: picked.name, id: result.id, text: result.text, truncated: result.truncated };
|
|
1205
1591
|
},
|
|
@@ -1250,6 +1636,11 @@ const plugin = definePlugin({
|
|
|
1250
1636
|
const ids = typeof input.ids === 'string'
|
|
1251
1637
|
? input.ids.split(',').map((id) => id.trim()).filter((id) => id !== '')
|
|
1252
1638
|
: [];
|
|
1639
|
+
// 传了 ids 但解析为空(空串 / 纯空白 / 全是逗号)必须报错(D52):静默变成
|
|
1640
|
+
// 「全部容器」会让模型把别的容器数据当成目标容器的
|
|
1641
|
+
if (typeof input.ids === 'string' && input.ids.trim() !== '' && ids.length === 0) {
|
|
1642
|
+
throw new Error('ids 只包含空白:请传容器名/ID(逗号分隔),或干脆省略 ids 表示全部运行中容器');
|
|
1643
|
+
}
|
|
1253
1644
|
const { api } = apiFor(picked.name);
|
|
1254
1645
|
if (api === undefined)
|
|
1255
1646
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
@@ -1271,7 +1662,7 @@ const plugin = definePlugin({
|
|
|
1271
1662
|
}));
|
|
1272
1663
|
add('docker_events', defineTool({
|
|
1273
1664
|
name: 'docker_events',
|
|
1274
|
-
description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update
|
|
1665
|
+
description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
|
|
1275
1666
|
parameters: {
|
|
1276
1667
|
target: targetParam,
|
|
1277
1668
|
since: { type: 'string', description: '起始时间(docker --since 语法,如 30m、2h;默认 10m)' },
|
|
@@ -1311,12 +1702,9 @@ const plugin = definePlugin({
|
|
|
1311
1702
|
const picked = pickTarget(input.target);
|
|
1312
1703
|
if (picked.name === undefined)
|
|
1313
1704
|
throw new Error(picked.error ?? '无效的 target');
|
|
1314
|
-
// since
|
|
1315
|
-
//
|
|
1316
|
-
const since = typeof input.since === 'string' && input.since.trim() !== '' ? input.since
|
|
1317
|
-
if (!/^[0-9]+(ns|us|ms|s|m|h)?$/.test(since) && !/^[0-9]{4}-[0-9]{2}-[0-9]{2}([T ][0-9:.]+(Z|[+-][0-9:]{2,5})?)?$/.test(since)) {
|
|
1318
|
-
throw new Error('since 只支持时长(如 30m、2h)或时间戳(如 2026-09-13T10:00:00)');
|
|
1319
|
-
}
|
|
1705
|
+
// since 统一口径(D45):assertSince 接受复合 duration(1h30m)——旧白名单
|
|
1706
|
+
// 会拒掉 docker 明明支持的写法
|
|
1707
|
+
const since = assertSince(typeof input.since === 'string' && input.since.trim() !== '' ? input.since : '10m');
|
|
1320
1708
|
const { api } = apiFor(picked.name);
|
|
1321
1709
|
if (api === undefined)
|
|
1322
1710
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
@@ -1532,7 +1920,9 @@ const plugin = definePlugin({
|
|
|
1532
1920
|
description: '对容器执行生命周期操作:start / stop / restart / remove。**破坏性**:remove 会删除容器(数据卷不在其中,但容器配置与可写层丢失),执行前必须向用户确认目标容器。仅当用户在设置里打开「允许变更操作」时可用。',
|
|
1533
1921
|
parameters: {
|
|
1534
1922
|
target: targetParam,
|
|
1535
|
-
|
|
1923
|
+
// enum 把「四选一」前移到派发前(D51 残留):实测 dsh-tools 的参数 DSL 支持 enum
|
|
1924
|
+
// (不支持的是 minimum/maximum),此前只在执行期拒绝,模型会先浪费一次往返
|
|
1925
|
+
action: { type: 'string', enum: ['start', 'stop', 'restart', 'remove'], required: true, description: 'start | stop | restart | remove(四选一)' },
|
|
1536
1926
|
id: { type: 'string', required: true, description: '容器名或 ID' },
|
|
1537
1927
|
},
|
|
1538
1928
|
output: {
|
|
@@ -1647,7 +2037,7 @@ const plugin = definePlugin({
|
|
|
1647
2037
|
parameters: {
|
|
1648
2038
|
target: targetParam,
|
|
1649
2039
|
ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或 digest' },
|
|
1650
|
-
timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600
|
|
2040
|
+
timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600;越界值会被静默夹紧)' },
|
|
1651
2041
|
},
|
|
1652
2042
|
output: {
|
|
1653
2043
|
schema: {
|
|
@@ -1699,7 +2089,7 @@ const plugin = definePlugin({
|
|
|
1699
2089
|
target: targetParam,
|
|
1700
2090
|
id: { type: 'string', required: true, description: '容器名或 ID' },
|
|
1701
2091
|
command: { type: 'string', required: true, description: '要执行的命令(经容器内 sh -c 执行)' },
|
|
1702
|
-
timeoutSec: { type: 'number', description: '超时秒数(1~120
|
|
2092
|
+
timeoutSec: { type: 'number', description: '超时秒数(1~120,默认取插件配置 execTimeoutSec;越界值会被静默夹紧)' },
|
|
1703
2093
|
},
|
|
1704
2094
|
output: {
|
|
1705
2095
|
schema: {
|
|
@@ -1753,6 +2143,9 @@ const plugin = definePlugin({
|
|
|
1753
2143
|
},
|
|
1754
2144
|
}));
|
|
1755
2145
|
}
|
|
2146
|
+
if (failures.length > 0) {
|
|
2147
|
+
console.warn(`[dsh-docker] ${String(failures.length)} 个 agent 工具注册失败(下次配置变更会重试):${failures.join(';')}`);
|
|
2148
|
+
}
|
|
1756
2149
|
};
|
|
1757
2150
|
// tools 服务(可选):拿到后注册一次,能力开关变化时 refreshTools 重注册
|
|
1758
2151
|
/*
|
|
@@ -1851,7 +2244,18 @@ const plugin = definePlugin({
|
|
|
1851
2244
|
const tailParam = params.get('tail');
|
|
1852
2245
|
const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
|
|
1853
2246
|
const timestampsParam = params.get('timestamps');
|
|
1854
|
-
const
|
|
2247
|
+
const sinceParam = params.get('since');
|
|
2248
|
+
// SSE 与快照同一套 since 校验(D45);非法直接 400(此处不在 POST 的 try 内)
|
|
2249
|
+
let since;
|
|
2250
|
+
if (sinceParam !== null && sinceParam.trim() !== '') {
|
|
2251
|
+
try {
|
|
2252
|
+
since = assertSince(sinceParam);
|
|
2253
|
+
}
|
|
2254
|
+
catch (error) {
|
|
2255
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
1855
2259
|
await openSseStream(res, {
|
|
1856
2260
|
reason: 'container-exit',
|
|
1857
2261
|
run: async (sendEvent, signal) => {
|
|
@@ -1864,7 +2268,7 @@ const plugin = definePlugin({
|
|
|
1864
2268
|
// 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
|
|
1865
2269
|
tail: Number.isInteger(tail) ? tail : live.logTailDefault,
|
|
1866
2270
|
timestamps: timestampsParam === '1' || timestampsParam === 'true',
|
|
1867
|
-
...(since !==
|
|
2271
|
+
...(since !== undefined ? { since } : {}),
|
|
1868
2272
|
}, handlers, signal);
|
|
1869
2273
|
return result.code;
|
|
1870
2274
|
},
|
|
@@ -1923,10 +2327,10 @@ const plugin = definePlugin({
|
|
|
1923
2327
|
* 跨 chunk 去重:实测 docker stats 会把**同一个采样渲染两次**(约 500ms 一轮,
|
|
1924
2328
|
* 0.04 / 0.04 / 0.16 / 0.16 …),两次是逐字节相同的 JSON 且可能落在不同 chunk 里。
|
|
1925
2329
|
* 不去重的话,客户端 60 点环形缓冲会被重复点占掉一半窗口(实际只剩 ~30 秒)。
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
2330
|
+
* 去重键按**容器**记(D33):此前只与紧邻上一条比较——单容器序列 A A 能去重,
|
|
2331
|
+
* 多容器交错 A B A B 就漏了;按 Name 各记上次值,交错情形同样覆盖。
|
|
1928
2332
|
*/
|
|
1929
|
-
|
|
2333
|
+
const lastRawByName = new Map();
|
|
1930
2334
|
const handlers = {
|
|
1931
2335
|
onStdout: (chunk) => {
|
|
1932
2336
|
pending += chunk;
|
|
@@ -1945,10 +2349,14 @@ const plugin = definePlugin({
|
|
|
1945
2349
|
if (pending.length > 64 * 1024)
|
|
1946
2350
|
pending = pending.slice(-4096);
|
|
1947
2351
|
for (const raw of found) {
|
|
1948
|
-
|
|
2352
|
+
const rows = parseStatsJson(raw);
|
|
2353
|
+
if (rows.length === 0)
|
|
1949
2354
|
continue;
|
|
1950
|
-
|
|
1951
|
-
|
|
2355
|
+
const key = rows[0]?.name === undefined || rows[0].name === '' ? raw : rows[0].name;
|
|
2356
|
+
if (lastRawByName.get(key) === raw)
|
|
2357
|
+
continue;
|
|
2358
|
+
lastRawByName.set(key, raw);
|
|
2359
|
+
for (const row of rows)
|
|
1952
2360
|
sendEvent('stats', row);
|
|
1953
2361
|
}
|
|
1954
2362
|
},
|
|
@@ -2089,7 +2497,15 @@ const plugin = definePlugin({
|
|
|
2089
2497
|
kind: 'prefix',
|
|
2090
2498
|
path: ROUTE_PREFIX,
|
|
2091
2499
|
handler: async (req, res) => {
|
|
2092
|
-
|
|
2500
|
+
const loopback = isLoopbackHttp(req);
|
|
2501
|
+
// 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
|
|
2502
|
+
if (loopback instanceof Promise) {
|
|
2503
|
+
if (!(await loopback)) {
|
|
2504
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
else if (!loopback) {
|
|
2093
2509
|
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
2094
2510
|
return;
|
|
2095
2511
|
}
|
|
@@ -2122,7 +2538,7 @@ const plugin = definePlugin({
|
|
|
2122
2538
|
writeJson(res, 400, { error: '未知配置项: ' + key });
|
|
2123
2539
|
return;
|
|
2124
2540
|
}
|
|
2125
|
-
if (key === 'clearTargets')
|
|
2541
|
+
if (key === 'clearTargets' || key === 'hostKeysRemove')
|
|
2126
2542
|
continue;
|
|
2127
2543
|
patch[key] = body[key];
|
|
2128
2544
|
}
|
|
@@ -2136,8 +2552,60 @@ const plugin = definePlugin({
|
|
|
2136
2552
|
}
|
|
2137
2553
|
}
|
|
2138
2554
|
// 凭证补全必须在写盘之前:否则提交的 targets 会把密码清空
|
|
2139
|
-
if (patch.targets !== undefined)
|
|
2555
|
+
if (patch.targets !== undefined) {
|
|
2556
|
+
// 读路径会丢弃的条目(重名 / 空名 / 超 64 字符)要有信号(D35):否则
|
|
2557
|
+
// 面板只看到留下来的那条,下一次保存把丢弃结果固化,另一台主机凭空消失
|
|
2558
|
+
const rawTargets = Array.isArray(patch.targets) ? patch.targets.length : 0;
|
|
2140
2559
|
patch.targets = mergeTargetSecrets(targetsNow(), patch.targets);
|
|
2560
|
+
/*
|
|
2561
|
+
* 计数必须取**真正会丢弃**的那一步(D89):mergeTargetSecrets 是 1:1 的
|
|
2562
|
+
* map(只补凭证、从不删条目),丢弃发生在 sanitizeTargets(读路径与
|
|
2563
|
+
* normalizeConfig 都用它)—— 原来的判据恒为 false,warning 是死代码。
|
|
2564
|
+
*/
|
|
2565
|
+
const keptTargets = sanitizeTargets(patch.targets)?.length ?? 0;
|
|
2566
|
+
if (rawTargets > keptTargets) {
|
|
2567
|
+
warning = [warning, `目标列表中有 ${String(rawTargets - keptTargets)} 条无效条目(重名 / 名称为空 / 超过 64 字符)已被丢弃。`].filter((part) => part !== undefined && part !== '').join(' ');
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
/*
|
|
2571
|
+
* hostKeys 采用「并集合并 + 显式删除」(D10):客户端表单快照里的
|
|
2572
|
+
* hostKeys 落后于运行期(打开卡片期间可能刚记录了新指纹),整表覆盖
|
|
2573
|
+
* 会把 TOFU 钉扎回退掉。传上来的记录按 host:port 并入现有记录;
|
|
2574
|
+
* 删除某条记录走显式的 hostKeysRemove: [{host, port}]。
|
|
2575
|
+
*/
|
|
2576
|
+
if (patch.hostKeys !== undefined || body.hostKeysRemove !== undefined) {
|
|
2577
|
+
let removeRequested = false;
|
|
2578
|
+
const removeKeys = new Set();
|
|
2579
|
+
if (Array.isArray(body.hostKeysRemove)) {
|
|
2580
|
+
for (const raw of body.hostKeysRemove) {
|
|
2581
|
+
if (typeof raw !== 'object' || raw === null)
|
|
2582
|
+
continue;
|
|
2583
|
+
const item = raw;
|
|
2584
|
+
const host = typeof item.host === 'string' ? item.host.trim().toLowerCase() : '';
|
|
2585
|
+
const port = typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22;
|
|
2586
|
+
if (host !== '')
|
|
2587
|
+
removeKeys.add(`${host}:${String(port)}`);
|
|
2588
|
+
}
|
|
2589
|
+
removeRequested = true;
|
|
2590
|
+
}
|
|
2591
|
+
else if (body.hostKeysRemove !== undefined) {
|
|
2592
|
+
// 形状非法不能静默忽略(D109):用户点了「删除」却什么都没发生,且没有任何反馈
|
|
2593
|
+
warning = [warning, 'hostKeysRemove 必须是 [{host, port}] 数组,本条已忽略。'].filter((part) => part !== undefined && part !== '').join(' ');
|
|
2594
|
+
}
|
|
2595
|
+
patch.hostKeys = mergeHostKeys(live.hostKeys, patch.hostKeys ?? []);
|
|
2596
|
+
/*
|
|
2597
|
+
* 删除在并集**之后**生效(D109):同一请求既带 hostKeys 又带 hostKeysRemove 时,
|
|
2598
|
+
* 「删除」必须赢 —— 否则手写请求 / 旧客户端会把刚删掉的指纹又并回来,同样没有提示。
|
|
2599
|
+
*/
|
|
2600
|
+
if (removeKeys.size > 0) {
|
|
2601
|
+
const merged = patch.hostKeys;
|
|
2602
|
+
const kept = merged.filter((record) => !removeKeys.has(`${record.host}:${String(record.port)}`));
|
|
2603
|
+
patch.hostKeys = kept;
|
|
2604
|
+
if (removeRequested && kept.length === merged.length) {
|
|
2605
|
+
warning = [warning, '要删除的主机指纹记录不存在(可能已被别处删除)。'].filter((part) => part !== undefined && part !== '').join(' ');
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2141
2609
|
// 校验必须在**落盘之前**。原先只有 applySection 会做校验,而它跑在
|
|
2142
2610
|
// scope.update 之后:值不合法的 patch 已经被写进 settings.yaml,随后
|
|
2143
2611
|
// applySection 抛出,异常穿到宿主 HTTP 层 → 用户收到一个**没有正文的
|
|
@@ -2167,7 +2635,17 @@ const plugin = definePlugin({
|
|
|
2167
2635
|
}
|
|
2168
2636
|
// settings/updated 会触发 applySection;无事件时也应用一次(幂等)
|
|
2169
2637
|
}
|
|
2170
|
-
applySection
|
|
2638
|
+
// applySection 也可能抛(tools.register / systemPrompt.section,D36):
|
|
2639
|
+
// 此时配置已落盘,必须把原因带回给用户,而不是一个空 400/500。
|
|
2640
|
+
// forceRefreshTools:保存路径无条件重注册一次(幂等),这样「上一次只注册了
|
|
2641
|
+
// 一半」的状态能被用户的**重试**修复,而不是必须等到下一次真实配置变化(D107)。
|
|
2642
|
+
try {
|
|
2643
|
+
applySection(patch, { forceRefreshTools: true });
|
|
2644
|
+
}
|
|
2645
|
+
catch (error) {
|
|
2646
|
+
writeJson(res, 500, { error: '配置已保存但应用失败:' + (error instanceof Error ? error.message : String(error)) });
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2171
2649
|
writeJson(res, 200, { ok: true, config: snapshot(), ...(warning === undefined ? {} : { warning }) });
|
|
2172
2650
|
return;
|
|
2173
2651
|
}
|
|
@@ -2207,10 +2685,23 @@ const plugin = definePlugin({
|
|
|
2207
2685
|
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
2208
2686
|
return;
|
|
2209
2687
|
}
|
|
2688
|
+
// 四条 SSE 都要有「同源证明」(D32):无 Origin 且无 Sec-Fetch-Site 的
|
|
2689
|
+
// 请求(旧 Safari / 部分 WebView / 裸 curl)在长流端点上拒绝——浏览器
|
|
2690
|
+
// 的 EventSource / fetch 同源请求都会带其中之一
|
|
2691
|
+
if (!hasSameOriginProof(req)) {
|
|
2692
|
+
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):实时流端点拒绝无来源请求' });
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2210
2695
|
const params = new URL(req.url ?? '/', 'http://loopback').searchParams;
|
|
2211
2696
|
await serveStream(req, res, params);
|
|
2212
2697
|
return;
|
|
2213
2698
|
}
|
|
2699
|
+
// 变更类端点(写操作)同样要求同源证明(D32);/config 刻意不在名单里:
|
|
2700
|
+
// 它是禁用状态下的唯一恢复入口,跨站 POST 已由 loopback + Origin 比对拦住
|
|
2701
|
+
if (req.method === 'POST' && MUTATION_SUBROUTES.has(sub) && !hasSameOriginProof(req)) {
|
|
2702
|
+
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2214
2705
|
if (req.method !== 'POST') {
|
|
2215
2706
|
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
2216
2707
|
return;
|
|
@@ -2250,6 +2741,33 @@ const plugin = definePlugin({
|
|
|
2250
2741
|
return;
|
|
2251
2742
|
}
|
|
2252
2743
|
const api = built.api;
|
|
2744
|
+
// 引用白名单在路由层先跑一次(D37):非法引用是**客户端**错误,直接 400
|
|
2745
|
+
// 带原因——落到 DockerApi 里才抛的话会被外层 catch 统一写成 500。
|
|
2746
|
+
// D97:`/action` 的 id、`/stats` 的 ids[] 与 `/exec` 的空 command 原先漏在外,
|
|
2747
|
+
// 于是同一类错误在有的路由是 400、有的是 500。
|
|
2748
|
+
try {
|
|
2749
|
+
if ((sub === '/inspect' || sub === '/logs' || sub === '/exec' || sub === '/action') && typeof body.id === 'string')
|
|
2750
|
+
assertRef(body.id, 'container');
|
|
2751
|
+
if (sub === '/stats' && Array.isArray(body.ids)) {
|
|
2752
|
+
for (const id of body.ids)
|
|
2753
|
+
if (typeof id === 'string')
|
|
2754
|
+
assertRef(id, 'container');
|
|
2755
|
+
}
|
|
2756
|
+
if ((sub === '/images/inspect' || sub === '/images/remove') && typeof body.ref === 'string')
|
|
2757
|
+
assertImageRef(body.ref, 'image');
|
|
2758
|
+
if ((sub === '/networks/inspect' || sub === '/networks/remove') && typeof body.name === 'string')
|
|
2759
|
+
assertName(body.name, 'network');
|
|
2760
|
+
if ((sub === '/volumes/inspect' || sub === '/volumes/remove') && typeof body.name === 'string')
|
|
2761
|
+
assertName(body.name, 'volume');
|
|
2762
|
+
if (sub === '/exec' && typeof body.command === 'string' && body.command.trim() === '') {
|
|
2763
|
+
writeJson(res, 400, { error: 'command 不能为空' });
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
catch (error) {
|
|
2768
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2253
2771
|
try {
|
|
2254
2772
|
switch (sub) {
|
|
2255
2773
|
case '/probe': {
|
|
@@ -2261,7 +2779,13 @@ const plugin = definePlugin({
|
|
|
2261
2779
|
return;
|
|
2262
2780
|
}
|
|
2263
2781
|
case '/attention': {
|
|
2264
|
-
|
|
2782
|
+
// limit 可由调用方给(D101):面板想一次拿全量时不必被隐式钉在默认 100
|
|
2783
|
+
const limitRaw = body.limit;
|
|
2784
|
+
const limit = typeof limitRaw === 'number' && Number.isInteger(limitRaw)
|
|
2785
|
+
? Math.min(Math.max(limitRaw, 1), 500)
|
|
2786
|
+
: undefined;
|
|
2787
|
+
const attention = await api.attention(limit === undefined ? undefined : { limit });
|
|
2788
|
+
writeJson(res, 200, { ok: true, items: attention.items, total: attention.total, truncated: attention.truncated, degraded: attention.degraded });
|
|
2265
2789
|
return;
|
|
2266
2790
|
}
|
|
2267
2791
|
case '/inspect': {
|
|
@@ -2288,7 +2812,9 @@ const plugin = definePlugin({
|
|
|
2288
2812
|
logs: await api.logs(body.id, {
|
|
2289
2813
|
tail,
|
|
2290
2814
|
timestamps: body.timestamps === true,
|
|
2291
|
-
|
|
2815
|
+
// `/logs` 此前裸透传(D98):同一参数在 `/logs/stream` 与 agent 工具上是 400/抛错,
|
|
2816
|
+
// 在快照路由上却变成 docker 的参数错误再被写成 500。空/纯空白 = 未传。
|
|
2817
|
+
...(typeof body.since === 'string' && body.since.trim() !== '' ? { since: assertSince(body.since) } : {}),
|
|
2292
2818
|
}),
|
|
2293
2819
|
});
|
|
2294
2820
|
return;
|