@hyzyn/dsh-docker 0.8.1 → 0.9.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +15 -1
- package/client-src/docker.css +119 -1
- package/client-src/index.js +1892 -534
- package/client-src/log-stream.js +7 -1
- package/client.js +143 -25
- package/lib/docker.d.ts +9 -2
- package/lib/docker.js +8 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +18 -0
- package/lib/index.js +299 -195
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +142 -2
- package/lib/ssh-exec.js +549 -12
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +8 -2
- package/scripts/route-smoke.mjs +15 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import z from '@deepseek-ai/schemastery';
|
|
2
|
-
import { definePlugin, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
3
|
+
import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
3
4
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
|
-
import * as dns from 'node:dns';
|
|
5
5
|
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
|
|
6
|
-
import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
6
|
+
import { RemoteExec, sanitizeJumpSpec, sanitizeProxyCommand, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
7
|
+
import { auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
|
|
7
8
|
const TARGET_SCHEMA = z.object({
|
|
8
9
|
name: z.string().required(),
|
|
9
10
|
kind: z.union([z.const('local'), z.const('ssh')]).default('local'),
|
|
@@ -81,6 +82,13 @@ const MUTATION_SUBROUTES = new Set([
|
|
|
81
82
|
'/volumes/remove',
|
|
82
83
|
'/volumes/prune',
|
|
83
84
|
'/exec',
|
|
85
|
+
/*
|
|
86
|
+
* 就地提权三条(判定条件是**精确子路径** + POST,所以必须逐条列;只写 '/elevate' 的话
|
|
87
|
+
* /elevate/status 与 /elevate/revoke 就是裸奔的——跨站页面能撤销授权、能对着确认码试错)。
|
|
88
|
+
*/
|
|
89
|
+
'/elevate',
|
|
90
|
+
'/elevate/status',
|
|
91
|
+
'/elevate/revoke',
|
|
84
92
|
]);
|
|
85
93
|
/** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
|
|
86
94
|
const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
|
|
@@ -134,170 +142,12 @@ export function sseFrame(event, data) {
|
|
|
134
142
|
}
|
|
135
143
|
/** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
|
|
136
144
|
const SSE_HEARTBEAT_MS = 15_000;
|
|
137
|
-
/** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
|
|
138
145
|
/*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
146
|
+
* 回环围栏与同源证明:**实现已收敛到 `@hyzyn/dsh-kit`**(2026-09-25,项目级 ROADMAP
|
|
147
|
+
* 第 1 项)。本包是那段加固档的来源,行为一字未改;D31 / D32 / D80 / D110 / D139 五条
|
|
148
|
+
* 的成因、时序约束与桌面版例外随实现一起搬到了 `packages/kit/src/http.ts`——**那边的
|
|
149
|
+
* 注释是唯一归宿**,这里只留指针(否则又是「三处各写一份必然漂」)。
|
|
141
150
|
*/
|
|
142
|
-
function isLoopbackAddress(address) {
|
|
143
|
-
if (address === undefined || address === '')
|
|
144
|
-
return false;
|
|
145
|
-
let text = address.toLowerCase();
|
|
146
|
-
if (text.startsWith('::ffff:'))
|
|
147
|
-
text = text.slice(7);
|
|
148
|
-
if (text === '::1')
|
|
149
|
-
return true;
|
|
150
|
-
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
|
151
|
-
return v4 !== null && v4[1] === '127';
|
|
152
|
-
}
|
|
153
|
-
/** 别名 Host 解析结果的缓存与超时(D110):请求路径里的 DNS 不该每请求都打一次,也不该无限等。 */
|
|
154
|
-
const HOST_LOOPBACK_TTL_MS = 60_000;
|
|
155
|
-
const HOST_LOOPBACK_CACHE_MAX = 64;
|
|
156
|
-
const HOST_LOOPBACK_TIMEOUT_MS = 500;
|
|
157
|
-
const hostLoopbackCache = new Map();
|
|
158
|
-
/**
|
|
159
|
-
* 解析一个主机名是否指向本机(D110)。带 500ms 超时与 60s 的 LRU(别名部署下每个请求都要过一次)。
|
|
160
|
-
* **失败与超时不缓存**:解析器恢复后要立刻生效,而不是把一次抖动钉 60 秒。
|
|
161
|
-
*/
|
|
162
|
-
async function lookupHostLoopback(host) {
|
|
163
|
-
const cached = hostLoopbackCache.get(host);
|
|
164
|
-
if (cached !== undefined && Date.now() - cached.at <= HOST_LOOPBACK_TTL_MS)
|
|
165
|
-
return cached.loopback;
|
|
166
|
-
let timer = null;
|
|
167
|
-
try {
|
|
168
|
-
const records = await Promise.race([
|
|
169
|
-
dns.promises.lookup(host, { all: true }),
|
|
170
|
-
new Promise((_resolve, reject) => {
|
|
171
|
-
timer = setTimeout(() => reject(new Error(`DNS 解析超时(>${String(HOST_LOOPBACK_TIMEOUT_MS)}ms)`)), HOST_LOOPBACK_TIMEOUT_MS);
|
|
172
|
-
timer.unref?.();
|
|
173
|
-
}),
|
|
174
|
-
]);
|
|
175
|
-
const loopback = records.some((record) => isLoopbackAddress(record.address));
|
|
176
|
-
if (hostLoopbackCache.size >= HOST_LOOPBACK_CACHE_MAX) {
|
|
177
|
-
const oldest = hostLoopbackCache.keys().next().value;
|
|
178
|
-
if (oldest !== undefined)
|
|
179
|
-
hostLoopbackCache.delete(oldest);
|
|
180
|
-
}
|
|
181
|
-
hostLoopbackCache.set(host, { at: Date.now(), loopback });
|
|
182
|
-
return loopback;
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
finally {
|
|
188
|
-
if (timer !== null)
|
|
189
|
-
clearTimeout(timer);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Host 是否指向本机(D31):字面量环回直接判;主机名 / /etc/hosts 别名走一次带超时的
|
|
194
|
-
* DNS 解析。判不出来就拒绝——围栏宁可误拦一个怪别名,不能放行一个能解析到公网的 Host。
|
|
195
|
-
*/
|
|
196
|
-
async function hostResolvesToLoopback(hostname) {
|
|
197
|
-
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
198
|
-
if (host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host))
|
|
199
|
-
return true;
|
|
200
|
-
return await lookupHostLoopback(host);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* loopback 信任围栏(D31):字面量环回(绝大多数请求)**同步**判定——保持
|
|
204
|
-
* 「请求进来即建流」的原有时序(SSE 测试与 EventSource 都依赖第一拍就写头);
|
|
205
|
-
* 只有主机名 / /etc/hosts 别名才走异步 DNS 确认。
|
|
206
|
-
*
|
|
207
|
-
* **来源检查必须在解析 Host 之前**(D80):别名主机名(`127.0.0.1.nip.io`、`/etc/hosts` 里
|
|
208
|
-
* 的别名)走的是异步分支,若在那里提前 return,`Sec-Fetch-Site` 与 Origin 两段检查会被
|
|
209
|
-
* 整段跳过 —— 围栏等于没设,跨站页面就能写 `/config`(它不要求同源证明)。
|
|
210
|
-
*/
|
|
211
|
-
function isLoopbackHttp(req) {
|
|
212
|
-
if (!isLoopbackAddress(req.socket.remoteAddress))
|
|
213
|
-
return false;
|
|
214
|
-
const host = req.headers.host;
|
|
215
|
-
if (typeof host !== 'string')
|
|
216
|
-
return false;
|
|
217
|
-
let hostUrl;
|
|
218
|
-
try {
|
|
219
|
-
hostUrl = new URL('http://' + host);
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
225
|
-
return false;
|
|
226
|
-
const origin = req.headers.origin;
|
|
227
|
-
if (origin !== undefined) {
|
|
228
|
-
let sameOrigin = false;
|
|
229
|
-
try {
|
|
230
|
-
sameOrigin = new URL(origin).host === hostUrl.host;
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
sameOrigin = false;
|
|
234
|
-
}
|
|
235
|
-
if (!sameOrigin)
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
const hostname = hostUrl.hostname.toLowerCase().replace(/\.$/, '');
|
|
239
|
-
if (hostname === 'localhost' || hostname.endsWith('.localhost') || isLoopbackAddress(hostname))
|
|
240
|
-
return true;
|
|
241
|
-
return hostResolvesToLoopback(hostname);
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* 「同源证明」(D32):变更类与长流端点要求请求带 Origin(浏览器 fetch 对
|
|
245
|
-
* cross-site 一定带)或 Sec-Fetch-Site: same-origin 之一。恶意页面可以用
|
|
246
|
-
* `<img src="GET /images/pull/stream?...">` 触发副作用 / 拉起 docker 子进程,
|
|
247
|
-
* 而旧 Safari / 部分 WebView 既不发 Origin 也不发 Sec-Fetch-Site——这两类端点
|
|
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
|
-
* 侧最不可伪造的那一件。
|
|
265
|
-
*/
|
|
266
|
-
function hasSameOriginProof(req) {
|
|
267
|
-
const site = req.headers['sec-fetch-site'];
|
|
268
|
-
if (typeof site === 'string' && site === 'same-origin')
|
|
269
|
-
return true;
|
|
270
|
-
const origin = req.headers.origin;
|
|
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
|
-
}
|
|
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')}`;
|
|
300
|
-
}
|
|
301
151
|
function writeJson(res, status, body) {
|
|
302
152
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
303
153
|
res.end(JSON.stringify(body));
|
|
@@ -467,6 +317,38 @@ export function mergeTargetSecrets(prev, incoming) {
|
|
|
467
317
|
return next;
|
|
468
318
|
});
|
|
469
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* 两条能力开关的宿主侧授权(见 kit 的 capability.js 与 docs/architecture.md)。
|
|
322
|
+
*
|
|
323
|
+
* 两条提权通道:**启动环境变量**(最严,见 kit 的 capability.ts)与**就地提权**(页内点开关 →
|
|
324
|
+
* 在宿主上落地一个随机名文件 → 免重启生效,见 kit 的 elevation.ts)。HTTP 侧不能凭空打开它们——
|
|
325
|
+
* 回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`,
|
|
326
|
+
* 那是请求头不是凭据),而 docker socket 等价目标主机 root。
|
|
327
|
+
*/
|
|
328
|
+
const CAP_MUTATIONS = { env: 'DSH_DOCKER_ALLOW_MUTATIONS', label: '变更操作' };
|
|
329
|
+
const CAP_EXEC = { env: 'DSH_DOCKER_ALLOW_EXEC', label: 'exec' };
|
|
330
|
+
/**
|
|
331
|
+
* 客户端/HTTP 用的能力名 → 授权说明。**白名单**:不在表里的一律 400。
|
|
332
|
+
*
|
|
333
|
+
* 为什么要这张表:路由收到的 `capability` 是外部字符串,直接拿它当环境变量名去查授权存储,等于让
|
|
334
|
+
* 请求方决定「查哪个键」——今天查不出问题(存储里只有这两个键),明天就是一类越权。
|
|
335
|
+
*/
|
|
336
|
+
const CAPABILITIES = new Map([
|
|
337
|
+
['allowMutations', CAP_MUTATIONS],
|
|
338
|
+
['allowExec', CAP_EXEC],
|
|
339
|
+
]);
|
|
340
|
+
/** 未启用时的文案:没授权要说清「怎么授权」,授权了只是没开就说「去开开关」。 */
|
|
341
|
+
function mutationsOffMessage() {
|
|
342
|
+
return capabilityGranted(CAP_MUTATIONS)
|
|
343
|
+
? '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)'
|
|
344
|
+
: capabilityDeniedMessage(CAP_MUTATIONS, { inPlace: true });
|
|
345
|
+
}
|
|
346
|
+
/** `exec` 那一档的同款文案。 */
|
|
347
|
+
function execOffMessage() {
|
|
348
|
+
return capabilityGranted(CAP_EXEC)
|
|
349
|
+
? 'exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)'
|
|
350
|
+
: capabilityDeniedMessage(CAP_EXEC, { inPlace: true });
|
|
351
|
+
}
|
|
470
352
|
/** 把一份任意来源的配置归一成 LiveConfig。 */
|
|
471
353
|
export function normalizeConfig(section) {
|
|
472
354
|
const targets = sanitizeTargets(section.targets);
|
|
@@ -475,8 +357,14 @@ export function normalizeConfig(section) {
|
|
|
475
357
|
enabled: section.enabled !== false,
|
|
476
358
|
announceToAgent: section.announceToAgent !== false,
|
|
477
359
|
dockerBin: assertBin(section.dockerBin),
|
|
478
|
-
|
|
479
|
-
|
|
360
|
+
/*
|
|
361
|
+
* **有效值 = 配置值 && 宿主授权**。折叠在这一处(而不是每个使用点各判一次)的理由:本包
|
|
362
|
+
* 有二十多处按 `live.allowMutations` 决定「工具注册 / 路由放行 / 面板渲染」,散着判必然
|
|
363
|
+
* 漏一处——漏掉的那处就是「没授权也能执行」。配置里的 true 不算授权:它与 HTTP 写进去的
|
|
364
|
+
* 值存在同一个存储里,分不出来源,只认环境变量才是能说清的规则(升级影响见 README)。
|
|
365
|
+
*/
|
|
366
|
+
allowMutations: section.allowMutations === true && capabilityGranted(CAP_MUTATIONS),
|
|
367
|
+
allowExec: section.allowExec === true && capabilityGranted(CAP_EXEC),
|
|
480
368
|
execTimeoutSec: clampInt(section.execTimeoutSec, 1, 120, 30),
|
|
481
369
|
pollIntervalSec: clampInt(section.pollIntervalSec, 1, 60, 5),
|
|
482
370
|
logTailDefault: clampInt(section.logTailDefault, 1, 5000, 200),
|
|
@@ -517,7 +405,11 @@ export function formatBytes(value) {
|
|
|
517
405
|
return `${text} ${units[unit] ?? 'B'}`;
|
|
518
406
|
}
|
|
519
407
|
/** 从 tty 的 entry settings 读取连接簿(只读;tty 未安装时为空表)。 */
|
|
520
|
-
|
|
408
|
+
/**
|
|
409
|
+
* 读 tty 连接簿 → 本包的连接规格。**导出仅供单测**(连接簿 → 规格这一跳是「一处配置、两处
|
|
410
|
+
* 生效」的落地处:漏带 jump / proxyCommand 就是「tty 能连、docker 连不上」那种半吊子状态)。
|
|
411
|
+
*/
|
|
412
|
+
export function readTtyBooks(settings) {
|
|
521
413
|
const out = new Map();
|
|
522
414
|
if (settings === undefined)
|
|
523
415
|
return out;
|
|
@@ -542,7 +434,7 @@ function readTtyBooks(settings) {
|
|
|
542
434
|
const username = typeof item.username === 'string' ? item.username.trim() : '';
|
|
543
435
|
if (name === '' || host === '' || username === '')
|
|
544
436
|
continue;
|
|
545
|
-
|
|
437
|
+
const spec = {
|
|
546
438
|
host,
|
|
547
439
|
port: typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22,
|
|
548
440
|
username,
|
|
@@ -551,10 +443,47 @@ function readTtyBooks(settings) {
|
|
|
551
443
|
password: typeof item.password === 'string' ? item.password : '',
|
|
552
444
|
passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
|
|
553
445
|
agentForward: item.agentForward === true,
|
|
554
|
-
}
|
|
446
|
+
};
|
|
447
|
+
/*
|
|
448
|
+
* 跳板机跟着连接簿一起走(本包**不自建跳板机界面**:目标是「一处配置、两处生效」)。
|
|
449
|
+
* 漏了这一段就是「终端能连、docker 目标连不上」——本项立项时点名的半吊子状态。
|
|
450
|
+
*/
|
|
451
|
+
const jump = sanitizeJumpSpec(item.jump);
|
|
452
|
+
if (jump !== undefined)
|
|
453
|
+
spec.jump = jump;
|
|
454
|
+
/*
|
|
455
|
+
* 代理命令同理跟着连接簿走(本包**不自建界面**)。闸门不在这里判:它归 tty settings 的
|
|
456
|
+
* `allowProxyCommand`,由 `readTtyProxyCommandAllowed()` 现读、在**每次拨号**时求值
|
|
457
|
+
* (见 RemoteExec 的 options.proxyCommandAllowed)——这样用户一关开关就立刻生效。
|
|
458
|
+
*/
|
|
459
|
+
const proxyCommand = sanitizeProxyCommand(item.proxyCommand);
|
|
460
|
+
if (proxyCommand !== undefined)
|
|
461
|
+
spec.proxyCommand = proxyCommand;
|
|
462
|
+
out.set(name, spec);
|
|
555
463
|
}
|
|
556
464
|
return out;
|
|
557
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 读 tty settings 里的 ProxyCommand 闸门(`allowProxyCommand`,默认关)。
|
|
468
|
+
*
|
|
469
|
+
* 为什么本包要用 **tty 的**开关而不是自己再加一个:连接簿只有一处(tty),代理命令也只有
|
|
470
|
+
* 一处能填;两个开关会让「连接簿配了、这个面板不认」变成说不清的状态。代价是本包多依赖一个
|
|
471
|
+
* 只读 settings 字段——settings 句柄缺失(启动早期)时恒 false,即**关**(失败方向安全)。
|
|
472
|
+
*/
|
|
473
|
+
export function readTtyProxyCommandAllowed(settings) {
|
|
474
|
+
if (settings === undefined)
|
|
475
|
+
return false;
|
|
476
|
+
let raw;
|
|
477
|
+
try {
|
|
478
|
+
raw = settings.get('tty');
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
if (typeof raw !== 'object' || raw === null)
|
|
484
|
+
return false;
|
|
485
|
+
return raw.allowProxyCommand === true;
|
|
486
|
+
}
|
|
558
487
|
/** 从 tty 的 hostKeys 读取已钉扎指纹(作为本插件 TOFU 的种子)。 */
|
|
559
488
|
function readTtyHostKeys(settings) {
|
|
560
489
|
if (settings === undefined)
|
|
@@ -583,8 +512,13 @@ export function resolveTarget(target, books) {
|
|
|
583
512
|
* 但那张卡片管的是 tty 自己的连接簿条目,改不了 docker 目标引用的名字:用户照着找
|
|
584
513
|
* 只会扑空(实测:目标引用 HS-248、连接簿里只有 HS_248_ADMIN,进 tty 卡片什么也改不了)。
|
|
585
514
|
* 真正要改的字段是**本卡片这条目标的「连接簿」下拉**,所以先把这里说清楚,再说备选。
|
|
515
|
+
*
|
|
516
|
+
* 末句是给「只能经跳板机 / 代理命令访问」那类情况留的出口(项目级 ROADMAP 第 2 项):
|
|
517
|
+
* tty 的 ~/.ssh/config 导入会**自动带上 ProxyJump**、但**永不带上 ProxyCommand**,所以
|
|
518
|
+
* 后者的条目要么不存在、要么存在却没配代理命令——用户看到「条目不存在」时的第一反应
|
|
519
|
+
* 是「我明明配过」。
|
|
586
520
|
*/
|
|
587
|
-
return { error: `目标「${target.name}」引用的连接簿条目不存在:${target.book}(在本卡片这条目标的「连接簿」下拉里改选一个已有条目;或把 tty 终端面板的连接簿补一个同名条目;也可清空下拉改为手填 host/username
|
|
521
|
+
return { error: `目标「${target.name}」引用的连接簿条目不存在:${target.book}(在本卡片这条目标的「连接簿」下拉里改选一个已有条目;或把 tty 终端面板的连接簿补一个同名条目;也可清空下拉改为手填 host/username。若该主机在 ~/.ssh/config 里只配了 ProxyCommand,导入会跳过它——那种条目要在 tty 连接簿里手动建,并打开「允许 ProxyCommand」,见项目级 ROADMAP 第 2 项)` };
|
|
588
522
|
}
|
|
589
523
|
return { resolved: { name: target.name, kind: 'ssh', spec } };
|
|
590
524
|
}
|
|
@@ -611,8 +545,47 @@ export function resolveTarget(target, books) {
|
|
|
611
545
|
const plugin = definePlugin({
|
|
612
546
|
name: 'docker',
|
|
613
547
|
apply(ctx, rawConfig) {
|
|
548
|
+
/*
|
|
549
|
+
* 授权来源必须在**第一次 capabilityGranted 之前**绑定(下面的 normalizeConfig 就会查它):
|
|
550
|
+
* 环境变量那一半取宿主的启动快照,就地提权那一半取带外授权存储。详见 kit 的 capability.ts /
|
|
551
|
+
* grant-store.ts。存储每次 apply 新建一个实例 —— 这就是「宿主重启后重新读盘」的语义。
|
|
552
|
+
*
|
|
553
|
+
* 落点全部来自 kit 的 `capabilityPaths`(`<DSH home>/dsh-kit/`):路径只在那里拼一次,
|
|
554
|
+
* 插件不再自己 join(kit D08:平铺在 DSH 主目录的 `capability-grants.json` / `grant-confirm/`
|
|
555
|
+
* 不带归属,容易与官方或别的插件撞名)。
|
|
556
|
+
*/
|
|
557
|
+
const paths = capabilityPaths(dshHome());
|
|
558
|
+
// **共享实例**(kit D11):docker 与 tty 同装时各 new 一个会各自缓存一份文件快照,
|
|
559
|
+
// 后绑定的那个看不到另一个后来写进去的授权(实测:tty 授权成功后自己的快照仍是 false)
|
|
560
|
+
const grantStore = sharedGrantStore(paths.dir);
|
|
561
|
+
bindCapabilitySources(ctx, grantStore);
|
|
562
|
+
/*
|
|
563
|
+
* 启动期审计:盘上已有的带外授权是**持久**的(重启后直接生效、不再有任何一次确认),所以那次
|
|
564
|
+
* 「静默继承」必须在日志里留下痕迹——否则「三周前授权的能力今天一开机就开着」无可追溯(kit D09)。
|
|
565
|
+
* 只报本插件的两条能力;环境变量通道由启动环境本身表达,不在这里重复。
|
|
566
|
+
*
|
|
567
|
+
* 刻意放在 `enabled` 判定**之前**:授权是宿主级的、与插件这次是否启用无关;插件这次是禁用态时
|
|
568
|
+
* 反而更该说一句(「盘上有授权,但插件没开」与「点了没反应」是两回事)。
|
|
569
|
+
*/
|
|
570
|
+
auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-docker]');
|
|
614
571
|
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
615
572
|
const config = plainConfig((rawConfig ?? {}));
|
|
573
|
+
/*
|
|
574
|
+
* 配置里**写着的**能力开关值(**没和授权折叠**)。
|
|
575
|
+
*
|
|
576
|
+
* 为什么必须单独留一份:`live.allowMutations` 是「配置值 && 已授权」折叠后的**有效值**,一旦
|
|
577
|
+
* 用户在没授权时打开过开关,它就成了 false——此后「重算」再拿它当输入,永远回不到 true
|
|
578
|
+
* (授权到了、工具却不注册,就是那个半个状态)。重算必须从**配置值**出发:
|
|
579
|
+
* - `applySection` 把这一份塞回合并结果里(见那里的注释);
|
|
580
|
+
* - 快照里的 `*Configured` 字段也用它,界面靠它把开关画成「开着但未生效」。
|
|
581
|
+
*
|
|
582
|
+
* (D141/D142:折叠后的有效值当输入 = 授权到了也回不来;快照缺配置值/来源 = 界面说不出
|
|
583
|
+
* 「配置开着但没授权」。)
|
|
584
|
+
*/
|
|
585
|
+
let configuredCapabilities = {
|
|
586
|
+
allowMutations: config.allowMutations === true,
|
|
587
|
+
allowExec: config.allowExec === true,
|
|
588
|
+
};
|
|
616
589
|
let live = normalizeConfig(config);
|
|
617
590
|
if (!live.enabled)
|
|
618
591
|
return;
|
|
@@ -677,7 +650,12 @@ const plugin = definePlugin({
|
|
|
677
650
|
persistHostKeys(next);
|
|
678
651
|
},
|
|
679
652
|
};
|
|
680
|
-
|
|
653
|
+
/*
|
|
654
|
+
* ProxyCommand 闸门:**每次拨号现读** tty settings(回调只做一次对象属性读取,无 IO)。
|
|
655
|
+
* 不缓存成布尔值:用户随时可能关掉它,缓存意味着「关了还能用」——这一档最不能出的错。
|
|
656
|
+
* settingsApi 在 settings 子上下文就绪前是 undefined,那时恒 false(= 关,安全方向)。
|
|
657
|
+
*/
|
|
658
|
+
const remote = new RemoteExec(logger, hostKeyStore, { proxyCommandAllowed: () => readTtyProxyCommandAllowed(settingsApi) });
|
|
681
659
|
/**
|
|
682
660
|
* 当前生效的 targets:**以 settings 解析值为准**。
|
|
683
661
|
* 为什么不能只读内存里的 live:settings 解析是异步的(服务就绪后才读一次),存在一个
|
|
@@ -834,6 +812,29 @@ const plugin = definePlugin({
|
|
|
834
812
|
dockerBin: live.dockerBin,
|
|
835
813
|
allowMutations: live.allowMutations,
|
|
836
814
|
allowExec: live.allowExec,
|
|
815
|
+
/*
|
|
816
|
+
* 配置里**写着的**值(未与授权折叠):开关的视觉状态用它,有效值用上面那两个。
|
|
817
|
+
* 两张都要给客户端,否则「配置开着但没授权」这个状态在界面上无法表达——那正是本仓最忌讳的
|
|
818
|
+
* 「配了没反应」:开关看着是开的、什么都不会发生,却没有任何东西说明原因。
|
|
819
|
+
*/
|
|
820
|
+
allowMutationsConfigured: configuredCapabilities.allowMutations,
|
|
821
|
+
allowExecConfigured: configuredCapabilities.allowExec,
|
|
822
|
+
// 宿主侧授权(只读):客户端用它把开关渲染成「点不动 + 说明怎么开」
|
|
823
|
+
allowMutationsGranted: capabilityGranted(CAP_MUTATIONS),
|
|
824
|
+
allowExecGranted: capabilityGranted(CAP_EXEC),
|
|
825
|
+
/*
|
|
826
|
+
* 授权来源:'env' = 启动环境变量(界面不给「撤销」按钮,因为它只能靠改启动环境撤销)、
|
|
827
|
+
* 'file' = 就地确认写下的带外授权(界面可撤销)、null = 没授权。
|
|
828
|
+
* 客户端据此决定显示「撤销宿主授权」还是「去启动环境里去掉它」,两者完全不同。
|
|
829
|
+
*/
|
|
830
|
+
allowMutationsGrantSource: capabilityGrantVia(CAP_MUTATIONS) ?? null,
|
|
831
|
+
allowExecGrantSource: capabilityGrantVia(CAP_EXEC) ?? null,
|
|
832
|
+
/*
|
|
833
|
+
* 授权时刻(Unix 秒;只有 file 通道有值,环境变量通道与未授权都是 null)。授权是**持久**
|
|
834
|
+
* 的:重启后它直接生效、不再确认,所以界面至少要能说出「它是什么时候来的」(D09)。
|
|
835
|
+
*/
|
|
836
|
+
allowMutationsGrantedAt: capabilityGrantAt(CAP_MUTATIONS) ?? null,
|
|
837
|
+
allowExecGrantedAt: capabilityGrantAt(CAP_EXEC) ?? null,
|
|
837
838
|
execTimeoutSec: live.execTimeoutSec,
|
|
838
839
|
pollIntervalSec: live.pollIntervalSec,
|
|
839
840
|
logTailDefault: live.logTailDefault,
|
|
@@ -1055,8 +1056,14 @@ const plugin = definePlugin({
|
|
|
1055
1056
|
};
|
|
1056
1057
|
const applySection = (section, options) => {
|
|
1057
1058
|
const before = live;
|
|
1059
|
+
// 能力开关的**配置值**单独记(D141):section 里没提到它就保持原样(`{}` = 只重算、不改配置)
|
|
1060
|
+
if (section.allowMutations !== undefined)
|
|
1061
|
+
configuredCapabilities.allowMutations = section.allowMutations === true;
|
|
1062
|
+
if (section.allowExec !== undefined)
|
|
1063
|
+
configuredCapabilities.allowExec = section.allowExec === true;
|
|
1058
1064
|
// hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
|
|
1059
|
-
|
|
1065
|
+
// 能力两项用**配置值**覆盖折叠后的 live:否则重算的输入就是折叠后的 false(见 configuredCapabilities)
|
|
1066
|
+
const merged = { ...live, ...section, ...configuredCapabilities };
|
|
1060
1067
|
if (section.hostKeys === undefined)
|
|
1061
1068
|
merged.hostKeys = live.hostKeys;
|
|
1062
1069
|
live = normalizeConfig(merged);
|
|
@@ -1087,6 +1094,29 @@ const plugin = definePlugin({
|
|
|
1087
1094
|
}
|
|
1088
1095
|
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)})`);
|
|
1089
1096
|
};
|
|
1097
|
+
/*
|
|
1098
|
+
* 就地提权(见 kit 的 elevation.ts):页内点开关 → 在宿主上落地一个随机名确认文件 → 授权。
|
|
1099
|
+
*
|
|
1100
|
+
* `onGrantChange` 是这一整段里**最容易漏、也最要紧**的一下:授权到达与撤销都必须重算 live。
|
|
1101
|
+
* - 授权侧:配置开关可能**早就**是 true(用户先打开过、当时没授权,于是 live.allowMutations
|
|
1102
|
+
* 被折叠成 false),授权到了却不重算,症状就是「开关亮着、工具却没注册」;
|
|
1103
|
+
* - 撤销侧:不重算会留下「记录没了、工具还开着」,还要收掉在途的写流(D84)。
|
|
1104
|
+
* `applySection({})` 用当前 live 兜底再归一化一次即可(normalizeConfig 会重新问 capabilityGranted)。
|
|
1105
|
+
*/
|
|
1106
|
+
const elevation = createElevationManager({
|
|
1107
|
+
confirmDir: paths.confirmDir,
|
|
1108
|
+
store: grantStore,
|
|
1109
|
+
logger,
|
|
1110
|
+
logPrefix: '[dsh-docker]',
|
|
1111
|
+
onGrantChange: () => {
|
|
1112
|
+
applySection({}, { forceRefreshTools: true });
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
ctx.effect(() => {
|
|
1116
|
+
return () => {
|
|
1117
|
+
elevation.dispose();
|
|
1118
|
+
};
|
|
1119
|
+
}, 'dsh-docker: elevation cleanup');
|
|
1090
1120
|
/* ---------- agent 工具 ---------- */
|
|
1091
1121
|
let toolsApi;
|
|
1092
1122
|
let toolDisposers = [];
|
|
@@ -1299,6 +1329,8 @@ const plugin = definePlugin({
|
|
|
1299
1329
|
};
|
|
1300
1330
|
add('docker_targets', defineTool({
|
|
1301
1331
|
name: 'docker_targets',
|
|
1332
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1333
|
+
isConcurrencySafe: () => true,
|
|
1302
1334
|
description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达。其他 docker_* 工具的 target 参数取自这里。',
|
|
1303
1335
|
parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
|
|
1304
1336
|
output: {
|
|
@@ -1365,6 +1397,8 @@ const plugin = definePlugin({
|
|
|
1365
1397
|
}));
|
|
1366
1398
|
add('docker_ps', defineTool({
|
|
1367
1399
|
name: 'docker_ps',
|
|
1400
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1401
|
+
isConcurrencySafe: () => true,
|
|
1368
1402
|
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
|
|
1369
1403
|
parameters: {
|
|
1370
1404
|
target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
|
|
@@ -1480,6 +1514,8 @@ const plugin = definePlugin({
|
|
|
1480
1514
|
}));
|
|
1481
1515
|
add('docker_attention', defineTool({
|
|
1482
1516
|
name: 'docker_attention',
|
|
1517
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1518
|
+
isConcurrencySafe: () => true,
|
|
1483
1519
|
description: '列出「需要关注」的容器:不健康(unhealthy)/ 反复重启 / 被 OOM 杀 / 非零退出 / 僵死。target 传 `*` 时**跨所有目标聚合**(单目标不可达不影响其他目标)。排障入口:不确定从哪台机器看起时先调它。',
|
|
1484
1520
|
parameters: {
|
|
1485
1521
|
target: { type: 'string', description: '目标名;传 `*` 表示全部目标(docker_targets 列出)' },
|
|
@@ -1622,6 +1658,8 @@ const plugin = definePlugin({
|
|
|
1622
1658
|
}));
|
|
1623
1659
|
add('docker_inspect', defineTool({
|
|
1624
1660
|
name: 'docker_inspect',
|
|
1661
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1662
|
+
isConcurrencySafe: () => true,
|
|
1625
1663
|
description: '读取某个容器的权威详情(docker inspect):状态/健康检查/退出码/重启次数/端口映射/挂载/网络/启动命令。',
|
|
1626
1664
|
parameters: { target: targetParam, id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' } },
|
|
1627
1665
|
output: {
|
|
@@ -1676,6 +1714,8 @@ const plugin = definePlugin({
|
|
|
1676
1714
|
}));
|
|
1677
1715
|
add('docker_logs', defineTool({
|
|
1678
1716
|
name: 'docker_logs',
|
|
1717
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1718
|
+
isConcurrencySafe: () => true,
|
|
1679
1719
|
description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
|
|
1680
1720
|
parameters: {
|
|
1681
1721
|
target: targetParam,
|
|
@@ -1724,6 +1764,8 @@ const plugin = definePlugin({
|
|
|
1724
1764
|
}));
|
|
1725
1765
|
add('docker_stats', defineTool({
|
|
1726
1766
|
name: 'docker_stats',
|
|
1767
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1768
|
+
isConcurrencySafe: () => true,
|
|
1727
1769
|
description: '读取容器实时资源占用(docker stats --no-stream):CPU%、内存用量/上限、网络与磁盘 IO、PIDs。不传 ids 时返回该目标上全部运行中容器。',
|
|
1728
1770
|
parameters: {
|
|
1729
1771
|
target: targetParam,
|
|
@@ -1794,6 +1836,8 @@ const plugin = definePlugin({
|
|
|
1794
1836
|
}));
|
|
1795
1837
|
add('docker_events', defineTool({
|
|
1796
1838
|
name: 'docker_events',
|
|
1839
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1840
|
+
isConcurrencySafe: () => true,
|
|
1797
1841
|
description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
|
|
1798
1842
|
parameters: {
|
|
1799
1843
|
target: targetParam,
|
|
@@ -1857,6 +1901,8 @@ const plugin = definePlugin({
|
|
|
1857
1901
|
}));
|
|
1858
1902
|
add('docker_images', defineTool({
|
|
1859
1903
|
name: 'docker_images',
|
|
1904
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1905
|
+
isConcurrencySafe: () => true,
|
|
1860
1906
|
description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
|
|
1861
1907
|
parameters: { target: targetParam },
|
|
1862
1908
|
output: {
|
|
@@ -1908,6 +1954,8 @@ const plugin = definePlugin({
|
|
|
1908
1954
|
}));
|
|
1909
1955
|
add('docker_image_inspect', defineTool({
|
|
1910
1956
|
name: 'docker_image_inspect',
|
|
1957
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1958
|
+
isConcurrencySafe: () => true,
|
|
1911
1959
|
description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
|
|
1912
1960
|
parameters: {
|
|
1913
1961
|
target: targetParam,
|
|
@@ -1944,6 +1992,8 @@ const plugin = definePlugin({
|
|
|
1944
1992
|
}));
|
|
1945
1993
|
add('docker_networks', defineTool({
|
|
1946
1994
|
name: 'docker_networks',
|
|
1995
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1996
|
+
isConcurrencySafe: () => true,
|
|
1947
1997
|
description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
|
|
1948
1998
|
parameters: { target: targetParam },
|
|
1949
1999
|
output: {
|
|
@@ -1997,6 +2047,8 @@ const plugin = definePlugin({
|
|
|
1997
2047
|
}));
|
|
1998
2048
|
add('docker_volumes', defineTool({
|
|
1999
2049
|
name: 'docker_volumes',
|
|
2050
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
2051
|
+
isConcurrencySafe: () => true,
|
|
2000
2052
|
description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
|
|
2001
2053
|
parameters: { target: targetParam },
|
|
2002
2054
|
output: {
|
|
@@ -2075,7 +2127,7 @@ const plugin = definePlugin({
|
|
|
2075
2127
|
},
|
|
2076
2128
|
async execute(args) {
|
|
2077
2129
|
if (!live.allowMutations)
|
|
2078
|
-
throw new Error(
|
|
2130
|
+
throw new Error(mutationsOffMessage());
|
|
2079
2131
|
const input = (args ?? {});
|
|
2080
2132
|
const picked = pickTarget(input.target);
|
|
2081
2133
|
if (picked.name === undefined)
|
|
@@ -2117,7 +2169,7 @@ const plugin = definePlugin({
|
|
|
2117
2169
|
},
|
|
2118
2170
|
async execute(args) {
|
|
2119
2171
|
if (!live.allowMutations)
|
|
2120
|
-
throw new Error(
|
|
2172
|
+
throw new Error(mutationsOffMessage());
|
|
2121
2173
|
const input = (args ?? {});
|
|
2122
2174
|
const picked = pickTarget(input.target);
|
|
2123
2175
|
if (picked.name === undefined)
|
|
@@ -2151,7 +2203,7 @@ const plugin = definePlugin({
|
|
|
2151
2203
|
},
|
|
2152
2204
|
async execute(args) {
|
|
2153
2205
|
if (!live.allowMutations)
|
|
2154
|
-
throw new Error(
|
|
2206
|
+
throw new Error(mutationsOffMessage());
|
|
2155
2207
|
const input = (args ?? {});
|
|
2156
2208
|
const picked = pickTarget(input.target);
|
|
2157
2209
|
if (picked.name === undefined)
|
|
@@ -2191,7 +2243,7 @@ const plugin = definePlugin({
|
|
|
2191
2243
|
},
|
|
2192
2244
|
async execute(args) {
|
|
2193
2245
|
if (!live.allowMutations)
|
|
2194
|
-
throw new Error(
|
|
2246
|
+
throw new Error(mutationsOffMessage());
|
|
2195
2247
|
const input = (args ?? {});
|
|
2196
2248
|
const picked = pickTarget(input.target);
|
|
2197
2249
|
if (picked.name === undefined)
|
|
@@ -2250,7 +2302,7 @@ const plugin = definePlugin({
|
|
|
2250
2302
|
},
|
|
2251
2303
|
async execute(args) {
|
|
2252
2304
|
if (!live.allowExec)
|
|
2253
|
-
throw new Error(
|
|
2305
|
+
throw new Error(execOffMessage());
|
|
2254
2306
|
const input = (args ?? {});
|
|
2255
2307
|
const picked = pickTarget(input.target);
|
|
2256
2308
|
if (picked.name === undefined)
|
|
@@ -2590,7 +2642,7 @@ const plugin = definePlugin({
|
|
|
2590
2642
|
*/
|
|
2591
2643
|
const servePullStream = async (req, res, params) => {
|
|
2592
2644
|
if (!live.allowMutations) {
|
|
2593
|
-
writeJson(res, 403, { error:
|
|
2645
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2594
2646
|
return;
|
|
2595
2647
|
}
|
|
2596
2648
|
const picked = pickTarget(params.get('target'));
|
|
@@ -2638,7 +2690,7 @@ const plugin = definePlugin({
|
|
|
2638
2690
|
kind: 'prefix',
|
|
2639
2691
|
path: ROUTE_PREFIX,
|
|
2640
2692
|
handler: async (req, res) => {
|
|
2641
|
-
const loopback =
|
|
2693
|
+
const loopback = isLoopbackRequestStrict(req);
|
|
2642
2694
|
// 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
|
|
2643
2695
|
if (loopback instanceof Promise) {
|
|
2644
2696
|
if (!(await loopback)) {
|
|
@@ -2651,10 +2703,11 @@ const plugin = definePlugin({
|
|
|
2651
2703
|
return;
|
|
2652
2704
|
}
|
|
2653
2705
|
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice(ROUTE_PREFIX.length);
|
|
2654
|
-
// 插件禁用时只保留 /config
|
|
2655
|
-
// UI
|
|
2656
|
-
// 403——
|
|
2657
|
-
|
|
2706
|
+
// 插件禁用时只保留 /config 读写与 /elevate 族:设置卡片靠前者渲染,也是重新启用插件的
|
|
2707
|
+
// 唯一 UI 入口(不能一并关掉,否则卡片消失就没有恢复路径了);授权的宿主级操作也不该
|
|
2708
|
+
// 因为插件被停用而失效(用户先授权再启用插件同样合理)。其余数据路由一律 403——
|
|
2709
|
+
// agent 工具已由 refreshTools 同步清空,这里只管 HTTP 半体。
|
|
2710
|
+
if (!live.enabled && sub !== '/config' && !sub.startsWith('/elevate')) {
|
|
2658
2711
|
writeJson(res, 403, { error: '插件已禁用(插件配置 → Docker 容器面板 → 启用插件)' });
|
|
2659
2712
|
return;
|
|
2660
2713
|
}
|
|
@@ -2683,6 +2736,18 @@ const plugin = definePlugin({
|
|
|
2683
2736
|
continue;
|
|
2684
2737
|
patch[key] = body[key];
|
|
2685
2738
|
}
|
|
2739
|
+
/*
|
|
2740
|
+
* **能力开关不能凭空升**:提权只认宿主侧来源(启动环境变量,或带外确认写下的授权)。
|
|
2741
|
+
* HTTP 侧给 true 而没有授权一律驳回并说清两条路。这不是「输入不合法」而是「没获授权」——
|
|
2742
|
+
* 文案必须给出**怎么授权**,否则用户会对着一个点不动的开关反复点。
|
|
2743
|
+
* 校验放在**落盘之前**(与下面那条 normalizeConfig 干跑同一口径):错误路径不留脏配置。
|
|
2744
|
+
*/
|
|
2745
|
+
for (const [key, spec] of [['allowMutations', CAP_MUTATIONS], ['allowExec', CAP_EXEC]]) {
|
|
2746
|
+
if (patch[key] === true && !capabilityGranted(spec)) {
|
|
2747
|
+
writeJson(res, 400, { error: capabilityDeniedMessage(spec, { inPlace: true }) });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2686
2751
|
// 空 targets 只在显式 clearTargets 时才允许清空:卡片若因启动竞态拿到
|
|
2687
2752
|
// 空列表,保存不会再把已配置的目标抹掉(防数据丢失)
|
|
2688
2753
|
if (Array.isArray(patch.targets) && patch.targets.length === 0 && body.clearTargets !== true) {
|
|
@@ -2855,6 +2920,42 @@ const plugin = definePlugin({
|
|
|
2855
2920
|
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2856
2921
|
return;
|
|
2857
2922
|
}
|
|
2923
|
+
/*
|
|
2924
|
+
* 就地提权(三条子路由)。放在这里而不是 `/config` 旁边是有原因的:**同源证明由上面那段
|
|
2925
|
+
* MUTATION_SUBROUTES 判据提供**,而它只覆盖它后面这段分发——写在它前面就是裸的了
|
|
2926
|
+
* (跨站页面能撤销授权、能反复试确认)。三条都做成 POST 也是为了复用那一条判据
|
|
2927
|
+
* (它只认 POST;`status` 做成 GET 会绕过)。
|
|
2928
|
+
*/
|
|
2929
|
+
if (sub === '/elevate' || sub === '/elevate/status' || sub === '/elevate/revoke') {
|
|
2930
|
+
const capability = typeof body.capability === 'string' ? body.capability : '';
|
|
2931
|
+
const spec = CAPABILITIES.get(capability);
|
|
2932
|
+
if (spec === undefined) {
|
|
2933
|
+
writeJson(res, 400, { error: `未知能力开关: ${capability === '' ? '(空)' : capability}(可用:${[...CAPABILITIES.keys()].join(' / ')})` });
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
if (sub === '/elevate/status') {
|
|
2937
|
+
writeJson(res, 200, elevation.status(spec.env));
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
if (sub === '/elevate/revoke') {
|
|
2941
|
+
// 降权零门槛:不要求任何确认(紧急刹车不等重启)。撤销带来的重算由 elevation 的
|
|
2942
|
+
// onGrantChange 回调负责(不变量 6),这里不再补一刀。
|
|
2943
|
+
const revoked = elevation.revoke(spec.env);
|
|
2944
|
+
writeJson(res, 200, { ok: true, revoked, config: snapshot() });
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
const result = elevation.begin(spec.env);
|
|
2948
|
+
if (result.status === 'rate-limited') {
|
|
2949
|
+
writeJson(res, 429, { ...result, error: '短时间内发起太多次,请稍后再试' });
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
if (result.status === 'error') {
|
|
2953
|
+
writeJson(res, 500, result);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
writeJson(res, 200, result);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2858
2959
|
// 跨目标聚合(0.15.0):target='*' 不是目标名,必须在 pickTarget 之前分流,
|
|
2859
2960
|
// 否则会被当成「未知目标」直接 400
|
|
2860
2961
|
const wantsAllTargets = typeof body.target === 'string' && body.target.trim() === '*';
|
|
@@ -2978,7 +3079,7 @@ const plugin = definePlugin({
|
|
|
2978
3079
|
}
|
|
2979
3080
|
case '/images/remove': {
|
|
2980
3081
|
if (!live.allowMutations) {
|
|
2981
|
-
writeJson(res, 403, { error:
|
|
3082
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2982
3083
|
return;
|
|
2983
3084
|
}
|
|
2984
3085
|
if (typeof body.ref !== 'string' || body.ref.trim() === '') {
|
|
@@ -2990,7 +3091,7 @@ const plugin = definePlugin({
|
|
|
2990
3091
|
}
|
|
2991
3092
|
case '/images/prune': {
|
|
2992
3093
|
if (!live.allowMutations) {
|
|
2993
|
-
writeJson(res, 403, { error:
|
|
3094
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2994
3095
|
return;
|
|
2995
3096
|
}
|
|
2996
3097
|
writeJson(res, 200, { ok: true, result: await api.imagePrune() });
|
|
@@ -3010,7 +3111,7 @@ const plugin = definePlugin({
|
|
|
3010
3111
|
}
|
|
3011
3112
|
case '/networks/remove': {
|
|
3012
3113
|
if (!live.allowMutations) {
|
|
3013
|
-
writeJson(res, 403, { error:
|
|
3114
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3014
3115
|
return;
|
|
3015
3116
|
}
|
|
3016
3117
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
@@ -3022,7 +3123,7 @@ const plugin = definePlugin({
|
|
|
3022
3123
|
}
|
|
3023
3124
|
case '/networks/prune': {
|
|
3024
3125
|
if (!live.allowMutations) {
|
|
3025
|
-
writeJson(res, 403, { error:
|
|
3126
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3026
3127
|
return;
|
|
3027
3128
|
}
|
|
3028
3129
|
writeJson(res, 200, { ok: true, result: await api.networkPrune() });
|
|
@@ -3042,7 +3143,7 @@ const plugin = definePlugin({
|
|
|
3042
3143
|
}
|
|
3043
3144
|
case '/volumes/remove': {
|
|
3044
3145
|
if (!live.allowMutations) {
|
|
3045
|
-
writeJson(res, 403, { error:
|
|
3146
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3046
3147
|
return;
|
|
3047
3148
|
}
|
|
3048
3149
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
@@ -3054,7 +3155,7 @@ const plugin = definePlugin({
|
|
|
3054
3155
|
}
|
|
3055
3156
|
case '/volumes/prune': {
|
|
3056
3157
|
if (!live.allowMutations) {
|
|
3057
|
-
writeJson(res, 403, { error:
|
|
3158
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3058
3159
|
return;
|
|
3059
3160
|
}
|
|
3060
3161
|
writeJson(res, 200, { ok: true, result: await api.volumePrune() });
|
|
@@ -3062,7 +3163,7 @@ const plugin = definePlugin({
|
|
|
3062
3163
|
}
|
|
3063
3164
|
case '/action': {
|
|
3064
3165
|
if (!live.allowMutations) {
|
|
3065
|
-
writeJson(res, 403, { error:
|
|
3166
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3066
3167
|
return;
|
|
3067
3168
|
}
|
|
3068
3169
|
if (typeof body.id !== 'string') {
|
|
@@ -3079,7 +3180,7 @@ const plugin = definePlugin({
|
|
|
3079
3180
|
}
|
|
3080
3181
|
case '/exec': {
|
|
3081
3182
|
if (!live.allowExec) {
|
|
3082
|
-
writeJson(res, 403, { error:
|
|
3183
|
+
writeJson(res, 403, { error: execOffMessage() });
|
|
3083
3184
|
return;
|
|
3084
3185
|
}
|
|
3085
3186
|
if (typeof body.id !== 'string' || typeof body.command !== 'string') {
|
|
@@ -3124,10 +3225,13 @@ const plugin = definePlugin({
|
|
|
3124
3225
|
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
3125
3226
|
settingsScope = scope;
|
|
3126
3227
|
settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
|
|
3127
|
-
// 立刻读一次 resolved 值(schema 默认值 ← composition base ←
|
|
3228
|
+
// 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
|
|
3229
|
+
// 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
|
|
3230
|
+
// configuredCapabilities),否则此后的就地提权重算会拿折叠后的 false 当输入。
|
|
3128
3231
|
const resolved = scope.get();
|
|
3129
|
-
if (typeof resolved === 'object' && resolved !== null)
|
|
3130
|
-
|
|
3232
|
+
if (typeof resolved === 'object' && resolved !== null) {
|
|
3233
|
+
applySection(resolved, { forceRefreshTools: true });
|
|
3234
|
+
}
|
|
3131
3235
|
const diag = (resolved ?? {});
|
|
3132
3236
|
console.log(`[dsh-docker] settings resolved (keys=${Object.keys(diag).join('|')}, targets=${Array.isArray(diag.targets) ? String(diag.targets.length) : 'not-array'}, hostKeys=${Array.isArray(diag.hostKeys) ? String(diag.hostKeys.length) : 'not-array'})`);
|
|
3133
3237
|
refreshTools();
|