@hyzyn/dsh-docker 0.8.0 → 0.9.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +12 -0
- package/README.md +25 -1
- package/client-src/docker.css +95 -1
- package/client-src/index.js +1883 -534
- package/client-src/log-stream.js +7 -1
- package/client.js +119 -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 +303 -162
- 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,136 +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
|
-
function hasSameOriginProof(req) {
|
|
251
|
-
const site = req.headers['sec-fetch-site'];
|
|
252
|
-
if (typeof site === 'string' && site === 'same-origin')
|
|
253
|
-
return true;
|
|
254
|
-
const origin = req.headers.origin;
|
|
255
|
-
if (typeof origin !== 'string' || origin === '')
|
|
256
|
-
return false;
|
|
257
|
-
const host = req.headers.host;
|
|
258
|
-
if (typeof host !== 'string')
|
|
259
|
-
return false;
|
|
260
|
-
try {
|
|
261
|
-
return new URL(origin).host === host;
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
151
|
function writeJson(res, status, body) {
|
|
268
152
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
269
153
|
res.end(JSON.stringify(body));
|
|
@@ -433,6 +317,38 @@ export function mergeTargetSecrets(prev, incoming) {
|
|
|
433
317
|
return next;
|
|
434
318
|
});
|
|
435
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
|
+
}
|
|
436
352
|
/** 把一份任意来源的配置归一成 LiveConfig。 */
|
|
437
353
|
export function normalizeConfig(section) {
|
|
438
354
|
const targets = sanitizeTargets(section.targets);
|
|
@@ -441,8 +357,14 @@ export function normalizeConfig(section) {
|
|
|
441
357
|
enabled: section.enabled !== false,
|
|
442
358
|
announceToAgent: section.announceToAgent !== false,
|
|
443
359
|
dockerBin: assertBin(section.dockerBin),
|
|
444
|
-
|
|
445
|
-
|
|
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),
|
|
446
368
|
execTimeoutSec: clampInt(section.execTimeoutSec, 1, 120, 30),
|
|
447
369
|
pollIntervalSec: clampInt(section.pollIntervalSec, 1, 60, 5),
|
|
448
370
|
logTailDefault: clampInt(section.logTailDefault, 1, 5000, 200),
|
|
@@ -483,7 +405,11 @@ export function formatBytes(value) {
|
|
|
483
405
|
return `${text} ${units[unit] ?? 'B'}`;
|
|
484
406
|
}
|
|
485
407
|
/** 从 tty 的 entry settings 读取连接簿(只读;tty 未安装时为空表)。 */
|
|
486
|
-
|
|
408
|
+
/**
|
|
409
|
+
* 读 tty 连接簿 → 本包的连接规格。**导出仅供单测**(连接簿 → 规格这一跳是「一处配置、两处
|
|
410
|
+
* 生效」的落地处:漏带 jump / proxyCommand 就是「tty 能连、docker 连不上」那种半吊子状态)。
|
|
411
|
+
*/
|
|
412
|
+
export function readTtyBooks(settings) {
|
|
487
413
|
const out = new Map();
|
|
488
414
|
if (settings === undefined)
|
|
489
415
|
return out;
|
|
@@ -508,7 +434,7 @@ function readTtyBooks(settings) {
|
|
|
508
434
|
const username = typeof item.username === 'string' ? item.username.trim() : '';
|
|
509
435
|
if (name === '' || host === '' || username === '')
|
|
510
436
|
continue;
|
|
511
|
-
|
|
437
|
+
const spec = {
|
|
512
438
|
host,
|
|
513
439
|
port: typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22,
|
|
514
440
|
username,
|
|
@@ -517,10 +443,47 @@ function readTtyBooks(settings) {
|
|
|
517
443
|
password: typeof item.password === 'string' ? item.password : '',
|
|
518
444
|
passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
|
|
519
445
|
agentForward: item.agentForward === true,
|
|
520
|
-
}
|
|
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);
|
|
521
463
|
}
|
|
522
464
|
return out;
|
|
523
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
|
+
}
|
|
524
487
|
/** 从 tty 的 hostKeys 读取已钉扎指纹(作为本插件 TOFU 的种子)。 */
|
|
525
488
|
function readTtyHostKeys(settings) {
|
|
526
489
|
if (settings === undefined)
|
|
@@ -549,8 +512,13 @@ export function resolveTarget(target, books) {
|
|
|
549
512
|
* 但那张卡片管的是 tty 自己的连接簿条目,改不了 docker 目标引用的名字:用户照着找
|
|
550
513
|
* 只会扑空(实测:目标引用 HS-248、连接簿里只有 HS_248_ADMIN,进 tty 卡片什么也改不了)。
|
|
551
514
|
* 真正要改的字段是**本卡片这条目标的「连接簿」下拉**,所以先把这里说清楚,再说备选。
|
|
515
|
+
*
|
|
516
|
+
* 末句是给「只能经跳板机 / 代理命令访问」那类情况留的出口(项目级 ROADMAP 第 2 项):
|
|
517
|
+
* tty 的 ~/.ssh/config 导入会**自动带上 ProxyJump**、但**永不带上 ProxyCommand**,所以
|
|
518
|
+
* 后者的条目要么不存在、要么存在却没配代理命令——用户看到「条目不存在」时的第一反应
|
|
519
|
+
* 是「我明明配过」。
|
|
552
520
|
*/
|
|
553
|
-
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 项)` };
|
|
554
522
|
}
|
|
555
523
|
return { resolved: { name: target.name, kind: 'ssh', spec } };
|
|
556
524
|
}
|
|
@@ -577,8 +545,47 @@ export function resolveTarget(target, books) {
|
|
|
577
545
|
const plugin = definePlugin({
|
|
578
546
|
name: 'docker',
|
|
579
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]');
|
|
580
571
|
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
581
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
|
+
};
|
|
582
589
|
let live = normalizeConfig(config);
|
|
583
590
|
if (!live.enabled)
|
|
584
591
|
return;
|
|
@@ -643,7 +650,12 @@ const plugin = definePlugin({
|
|
|
643
650
|
persistHostKeys(next);
|
|
644
651
|
},
|
|
645
652
|
};
|
|
646
|
-
|
|
653
|
+
/*
|
|
654
|
+
* ProxyCommand 闸门:**每次拨号现读** tty settings(回调只做一次对象属性读取,无 IO)。
|
|
655
|
+
* 不缓存成布尔值:用户随时可能关掉它,缓存意味着「关了还能用」——这一档最不能出的错。
|
|
656
|
+
* settingsApi 在 settings 子上下文就绪前是 undefined,那时恒 false(= 关,安全方向)。
|
|
657
|
+
*/
|
|
658
|
+
const remote = new RemoteExec(logger, hostKeyStore, { proxyCommandAllowed: () => readTtyProxyCommandAllowed(settingsApi) });
|
|
647
659
|
/**
|
|
648
660
|
* 当前生效的 targets:**以 settings 解析值为准**。
|
|
649
661
|
* 为什么不能只读内存里的 live:settings 解析是异步的(服务就绪后才读一次),存在一个
|
|
@@ -800,6 +812,29 @@ const plugin = definePlugin({
|
|
|
800
812
|
dockerBin: live.dockerBin,
|
|
801
813
|
allowMutations: live.allowMutations,
|
|
802
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,
|
|
803
838
|
execTimeoutSec: live.execTimeoutSec,
|
|
804
839
|
pollIntervalSec: live.pollIntervalSec,
|
|
805
840
|
logTailDefault: live.logTailDefault,
|
|
@@ -1021,8 +1056,14 @@ const plugin = definePlugin({
|
|
|
1021
1056
|
};
|
|
1022
1057
|
const applySection = (section, options) => {
|
|
1023
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;
|
|
1024
1064
|
// hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
|
|
1025
|
-
|
|
1065
|
+
// 能力两项用**配置值**覆盖折叠后的 live:否则重算的输入就是折叠后的 false(见 configuredCapabilities)
|
|
1066
|
+
const merged = { ...live, ...section, ...configuredCapabilities };
|
|
1026
1067
|
if (section.hostKeys === undefined)
|
|
1027
1068
|
merged.hostKeys = live.hostKeys;
|
|
1028
1069
|
live = normalizeConfig(merged);
|
|
@@ -1053,6 +1094,29 @@ const plugin = definePlugin({
|
|
|
1053
1094
|
}
|
|
1054
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)})`);
|
|
1055
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');
|
|
1056
1120
|
/* ---------- agent 工具 ---------- */
|
|
1057
1121
|
let toolsApi;
|
|
1058
1122
|
let toolDisposers = [];
|
|
@@ -1265,6 +1329,8 @@ const plugin = definePlugin({
|
|
|
1265
1329
|
};
|
|
1266
1330
|
add('docker_targets', defineTool({
|
|
1267
1331
|
name: 'docker_targets',
|
|
1332
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1333
|
+
isConcurrencySafe: () => true,
|
|
1268
1334
|
description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达。其他 docker_* 工具的 target 参数取自这里。',
|
|
1269
1335
|
parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
|
|
1270
1336
|
output: {
|
|
@@ -1331,6 +1397,8 @@ const plugin = definePlugin({
|
|
|
1331
1397
|
}));
|
|
1332
1398
|
add('docker_ps', defineTool({
|
|
1333
1399
|
name: 'docker_ps',
|
|
1400
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1401
|
+
isConcurrencySafe: () => true,
|
|
1334
1402
|
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
|
|
1335
1403
|
parameters: {
|
|
1336
1404
|
target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
|
|
@@ -1446,6 +1514,8 @@ const plugin = definePlugin({
|
|
|
1446
1514
|
}));
|
|
1447
1515
|
add('docker_attention', defineTool({
|
|
1448
1516
|
name: 'docker_attention',
|
|
1517
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1518
|
+
isConcurrencySafe: () => true,
|
|
1449
1519
|
description: '列出「需要关注」的容器:不健康(unhealthy)/ 反复重启 / 被 OOM 杀 / 非零退出 / 僵死。target 传 `*` 时**跨所有目标聚合**(单目标不可达不影响其他目标)。排障入口:不确定从哪台机器看起时先调它。',
|
|
1450
1520
|
parameters: {
|
|
1451
1521
|
target: { type: 'string', description: '目标名;传 `*` 表示全部目标(docker_targets 列出)' },
|
|
@@ -1588,6 +1658,8 @@ const plugin = definePlugin({
|
|
|
1588
1658
|
}));
|
|
1589
1659
|
add('docker_inspect', defineTool({
|
|
1590
1660
|
name: 'docker_inspect',
|
|
1661
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1662
|
+
isConcurrencySafe: () => true,
|
|
1591
1663
|
description: '读取某个容器的权威详情(docker inspect):状态/健康检查/退出码/重启次数/端口映射/挂载/网络/启动命令。',
|
|
1592
1664
|
parameters: { target: targetParam, id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' } },
|
|
1593
1665
|
output: {
|
|
@@ -1642,6 +1714,8 @@ const plugin = definePlugin({
|
|
|
1642
1714
|
}));
|
|
1643
1715
|
add('docker_logs', defineTool({
|
|
1644
1716
|
name: 'docker_logs',
|
|
1717
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1718
|
+
isConcurrencySafe: () => true,
|
|
1645
1719
|
description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
|
|
1646
1720
|
parameters: {
|
|
1647
1721
|
target: targetParam,
|
|
@@ -1690,6 +1764,8 @@ const plugin = definePlugin({
|
|
|
1690
1764
|
}));
|
|
1691
1765
|
add('docker_stats', defineTool({
|
|
1692
1766
|
name: 'docker_stats',
|
|
1767
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1768
|
+
isConcurrencySafe: () => true,
|
|
1693
1769
|
description: '读取容器实时资源占用(docker stats --no-stream):CPU%、内存用量/上限、网络与磁盘 IO、PIDs。不传 ids 时返回该目标上全部运行中容器。',
|
|
1694
1770
|
parameters: {
|
|
1695
1771
|
target: targetParam,
|
|
@@ -1760,6 +1836,8 @@ const plugin = definePlugin({
|
|
|
1760
1836
|
}));
|
|
1761
1837
|
add('docker_events', defineTool({
|
|
1762
1838
|
name: 'docker_events',
|
|
1839
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1840
|
+
isConcurrencySafe: () => true,
|
|
1763
1841
|
description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
|
|
1764
1842
|
parameters: {
|
|
1765
1843
|
target: targetParam,
|
|
@@ -1823,6 +1901,8 @@ const plugin = definePlugin({
|
|
|
1823
1901
|
}));
|
|
1824
1902
|
add('docker_images', defineTool({
|
|
1825
1903
|
name: 'docker_images',
|
|
1904
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1905
|
+
isConcurrencySafe: () => true,
|
|
1826
1906
|
description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
|
|
1827
1907
|
parameters: { target: targetParam },
|
|
1828
1908
|
output: {
|
|
@@ -1874,6 +1954,8 @@ const plugin = definePlugin({
|
|
|
1874
1954
|
}));
|
|
1875
1955
|
add('docker_image_inspect', defineTool({
|
|
1876
1956
|
name: 'docker_image_inspect',
|
|
1957
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1958
|
+
isConcurrencySafe: () => true,
|
|
1877
1959
|
description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
|
|
1878
1960
|
parameters: {
|
|
1879
1961
|
target: targetParam,
|
|
@@ -1910,6 +1992,8 @@ const plugin = definePlugin({
|
|
|
1910
1992
|
}));
|
|
1911
1993
|
add('docker_networks', defineTool({
|
|
1912
1994
|
name: 'docker_networks',
|
|
1995
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
1996
|
+
isConcurrencySafe: () => true,
|
|
1913
1997
|
description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
|
|
1914
1998
|
parameters: { target: targetParam },
|
|
1915
1999
|
output: {
|
|
@@ -1963,6 +2047,8 @@ const plugin = definePlugin({
|
|
|
1963
2047
|
}));
|
|
1964
2048
|
add('docker_volumes', defineTool({
|
|
1965
2049
|
name: 'docker_volumes',
|
|
2050
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
2051
|
+
isConcurrencySafe: () => true,
|
|
1966
2052
|
description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
|
|
1967
2053
|
parameters: { target: targetParam },
|
|
1968
2054
|
output: {
|
|
@@ -2041,7 +2127,7 @@ const plugin = definePlugin({
|
|
|
2041
2127
|
},
|
|
2042
2128
|
async execute(args) {
|
|
2043
2129
|
if (!live.allowMutations)
|
|
2044
|
-
throw new Error(
|
|
2130
|
+
throw new Error(mutationsOffMessage());
|
|
2045
2131
|
const input = (args ?? {});
|
|
2046
2132
|
const picked = pickTarget(input.target);
|
|
2047
2133
|
if (picked.name === undefined)
|
|
@@ -2083,7 +2169,7 @@ const plugin = definePlugin({
|
|
|
2083
2169
|
},
|
|
2084
2170
|
async execute(args) {
|
|
2085
2171
|
if (!live.allowMutations)
|
|
2086
|
-
throw new Error(
|
|
2172
|
+
throw new Error(mutationsOffMessage());
|
|
2087
2173
|
const input = (args ?? {});
|
|
2088
2174
|
const picked = pickTarget(input.target);
|
|
2089
2175
|
if (picked.name === undefined)
|
|
@@ -2117,7 +2203,7 @@ const plugin = definePlugin({
|
|
|
2117
2203
|
},
|
|
2118
2204
|
async execute(args) {
|
|
2119
2205
|
if (!live.allowMutations)
|
|
2120
|
-
throw new Error(
|
|
2206
|
+
throw new Error(mutationsOffMessage());
|
|
2121
2207
|
const input = (args ?? {});
|
|
2122
2208
|
const picked = pickTarget(input.target);
|
|
2123
2209
|
if (picked.name === undefined)
|
|
@@ -2157,7 +2243,7 @@ const plugin = definePlugin({
|
|
|
2157
2243
|
},
|
|
2158
2244
|
async execute(args) {
|
|
2159
2245
|
if (!live.allowMutations)
|
|
2160
|
-
throw new Error(
|
|
2246
|
+
throw new Error(mutationsOffMessage());
|
|
2161
2247
|
const input = (args ?? {});
|
|
2162
2248
|
const picked = pickTarget(input.target);
|
|
2163
2249
|
if (picked.name === undefined)
|
|
@@ -2216,7 +2302,7 @@ const plugin = definePlugin({
|
|
|
2216
2302
|
},
|
|
2217
2303
|
async execute(args) {
|
|
2218
2304
|
if (!live.allowExec)
|
|
2219
|
-
throw new Error(
|
|
2305
|
+
throw new Error(execOffMessage());
|
|
2220
2306
|
const input = (args ?? {});
|
|
2221
2307
|
const picked = pickTarget(input.target);
|
|
2222
2308
|
if (picked.name === undefined)
|
|
@@ -2556,7 +2642,7 @@ const plugin = definePlugin({
|
|
|
2556
2642
|
*/
|
|
2557
2643
|
const servePullStream = async (req, res, params) => {
|
|
2558
2644
|
if (!live.allowMutations) {
|
|
2559
|
-
writeJson(res, 403, { error:
|
|
2645
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2560
2646
|
return;
|
|
2561
2647
|
}
|
|
2562
2648
|
const picked = pickTarget(params.get('target'));
|
|
@@ -2604,7 +2690,7 @@ const plugin = definePlugin({
|
|
|
2604
2690
|
kind: 'prefix',
|
|
2605
2691
|
path: ROUTE_PREFIX,
|
|
2606
2692
|
handler: async (req, res) => {
|
|
2607
|
-
const loopback =
|
|
2693
|
+
const loopback = isLoopbackRequestStrict(req);
|
|
2608
2694
|
// 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
|
|
2609
2695
|
if (loopback instanceof Promise) {
|
|
2610
2696
|
if (!(await loopback)) {
|
|
@@ -2617,10 +2703,11 @@ const plugin = definePlugin({
|
|
|
2617
2703
|
return;
|
|
2618
2704
|
}
|
|
2619
2705
|
const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice(ROUTE_PREFIX.length);
|
|
2620
|
-
// 插件禁用时只保留 /config
|
|
2621
|
-
// UI
|
|
2622
|
-
// 403——
|
|
2623
|
-
|
|
2706
|
+
// 插件禁用时只保留 /config 读写与 /elevate 族:设置卡片靠前者渲染,也是重新启用插件的
|
|
2707
|
+
// 唯一 UI 入口(不能一并关掉,否则卡片消失就没有恢复路径了);授权的宿主级操作也不该
|
|
2708
|
+
// 因为插件被停用而失效(用户先授权再启用插件同样合理)。其余数据路由一律 403——
|
|
2709
|
+
// agent 工具已由 refreshTools 同步清空,这里只管 HTTP 半体。
|
|
2710
|
+
if (!live.enabled && sub !== '/config' && !sub.startsWith('/elevate')) {
|
|
2624
2711
|
writeJson(res, 403, { error: '插件已禁用(插件配置 → Docker 容器面板 → 启用插件)' });
|
|
2625
2712
|
return;
|
|
2626
2713
|
}
|
|
@@ -2649,6 +2736,18 @@ const plugin = definePlugin({
|
|
|
2649
2736
|
continue;
|
|
2650
2737
|
patch[key] = body[key];
|
|
2651
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
|
+
}
|
|
2652
2751
|
// 空 targets 只在显式 clearTargets 时才允许清空:卡片若因启动竞态拿到
|
|
2653
2752
|
// 空列表,保存不会再把已配置的目标抹掉(防数据丢失)
|
|
2654
2753
|
if (Array.isArray(patch.targets) && patch.targets.length === 0 && body.clearTargets !== true) {
|
|
@@ -2794,8 +2893,10 @@ const plugin = definePlugin({
|
|
|
2794
2893
|
}
|
|
2795
2894
|
// 四条 SSE 都要有「同源证明」(D32):无 Origin 且无 Sec-Fetch-Site 的
|
|
2796
2895
|
// 请求(旧 Safari / 部分 WebView / 裸 curl)在长流端点上拒绝——浏览器
|
|
2797
|
-
// 的 EventSource / fetch
|
|
2896
|
+
// 的 EventSource / fetch 同源请求都会带其中之一;桌面壳的转发链只带
|
|
2897
|
+
// Cookie,由 hasSameOriginProof 内的例外放行(D139)
|
|
2798
2898
|
if (!hasSameOriginProof(req)) {
|
|
2899
|
+
ctx.logger.warn(`dsh-docker: 拒绝无同源证明的实时流请求 ${sub}(${originProofHint(req)})`);
|
|
2799
2900
|
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):实时流端点拒绝无来源请求' });
|
|
2800
2901
|
return;
|
|
2801
2902
|
}
|
|
@@ -2806,6 +2907,7 @@ const plugin = definePlugin({
|
|
|
2806
2907
|
// 变更类端点(写操作)同样要求同源证明(D32);/config 刻意不在名单里:
|
|
2807
2908
|
// 它是禁用状态下的唯一恢复入口,跨站 POST 已由 loopback + Origin 比对拦住
|
|
2808
2909
|
if (req.method === 'POST' && MUTATION_SUBROUTES.has(sub) && !hasSameOriginProof(req)) {
|
|
2910
|
+
ctx.logger.warn(`dsh-docker: 拒绝无同源证明的变更请求 ${sub}(${originProofHint(req)})`);
|
|
2809
2911
|
writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
|
|
2810
2912
|
return;
|
|
2811
2913
|
}
|
|
@@ -2818,6 +2920,42 @@ const plugin = definePlugin({
|
|
|
2818
2920
|
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
2819
2921
|
return;
|
|
2820
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
|
+
}
|
|
2821
2959
|
// 跨目标聚合(0.15.0):target='*' 不是目标名,必须在 pickTarget 之前分流,
|
|
2822
2960
|
// 否则会被当成「未知目标」直接 400
|
|
2823
2961
|
const wantsAllTargets = typeof body.target === 'string' && body.target.trim() === '*';
|
|
@@ -2941,7 +3079,7 @@ const plugin = definePlugin({
|
|
|
2941
3079
|
}
|
|
2942
3080
|
case '/images/remove': {
|
|
2943
3081
|
if (!live.allowMutations) {
|
|
2944
|
-
writeJson(res, 403, { error:
|
|
3082
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2945
3083
|
return;
|
|
2946
3084
|
}
|
|
2947
3085
|
if (typeof body.ref !== 'string' || body.ref.trim() === '') {
|
|
@@ -2953,7 +3091,7 @@ const plugin = definePlugin({
|
|
|
2953
3091
|
}
|
|
2954
3092
|
case '/images/prune': {
|
|
2955
3093
|
if (!live.allowMutations) {
|
|
2956
|
-
writeJson(res, 403, { error:
|
|
3094
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2957
3095
|
return;
|
|
2958
3096
|
}
|
|
2959
3097
|
writeJson(res, 200, { ok: true, result: await api.imagePrune() });
|
|
@@ -2973,7 +3111,7 @@ const plugin = definePlugin({
|
|
|
2973
3111
|
}
|
|
2974
3112
|
case '/networks/remove': {
|
|
2975
3113
|
if (!live.allowMutations) {
|
|
2976
|
-
writeJson(res, 403, { error:
|
|
3114
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2977
3115
|
return;
|
|
2978
3116
|
}
|
|
2979
3117
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
@@ -2985,7 +3123,7 @@ const plugin = definePlugin({
|
|
|
2985
3123
|
}
|
|
2986
3124
|
case '/networks/prune': {
|
|
2987
3125
|
if (!live.allowMutations) {
|
|
2988
|
-
writeJson(res, 403, { error:
|
|
3126
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
2989
3127
|
return;
|
|
2990
3128
|
}
|
|
2991
3129
|
writeJson(res, 200, { ok: true, result: await api.networkPrune() });
|
|
@@ -3005,7 +3143,7 @@ const plugin = definePlugin({
|
|
|
3005
3143
|
}
|
|
3006
3144
|
case '/volumes/remove': {
|
|
3007
3145
|
if (!live.allowMutations) {
|
|
3008
|
-
writeJson(res, 403, { error:
|
|
3146
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3009
3147
|
return;
|
|
3010
3148
|
}
|
|
3011
3149
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
@@ -3017,7 +3155,7 @@ const plugin = definePlugin({
|
|
|
3017
3155
|
}
|
|
3018
3156
|
case '/volumes/prune': {
|
|
3019
3157
|
if (!live.allowMutations) {
|
|
3020
|
-
writeJson(res, 403, { error:
|
|
3158
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3021
3159
|
return;
|
|
3022
3160
|
}
|
|
3023
3161
|
writeJson(res, 200, { ok: true, result: await api.volumePrune() });
|
|
@@ -3025,7 +3163,7 @@ const plugin = definePlugin({
|
|
|
3025
3163
|
}
|
|
3026
3164
|
case '/action': {
|
|
3027
3165
|
if (!live.allowMutations) {
|
|
3028
|
-
writeJson(res, 403, { error:
|
|
3166
|
+
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3029
3167
|
return;
|
|
3030
3168
|
}
|
|
3031
3169
|
if (typeof body.id !== 'string') {
|
|
@@ -3042,7 +3180,7 @@ const plugin = definePlugin({
|
|
|
3042
3180
|
}
|
|
3043
3181
|
case '/exec': {
|
|
3044
3182
|
if (!live.allowExec) {
|
|
3045
|
-
writeJson(res, 403, { error:
|
|
3183
|
+
writeJson(res, 403, { error: execOffMessage() });
|
|
3046
3184
|
return;
|
|
3047
3185
|
}
|
|
3048
3186
|
if (typeof body.id !== 'string' || typeof body.command !== 'string') {
|
|
@@ -3087,10 +3225,13 @@ const plugin = definePlugin({
|
|
|
3087
3225
|
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
3088
3226
|
settingsScope = scope;
|
|
3089
3227
|
settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
|
|
3090
|
-
// 立刻读一次 resolved 值(schema 默认值 ← composition base ←
|
|
3228
|
+
// 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
|
|
3229
|
+
// 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
|
|
3230
|
+
// configuredCapabilities),否则此后的就地提权重算会拿折叠后的 false 当输入。
|
|
3091
3231
|
const resolved = scope.get();
|
|
3092
|
-
if (typeof resolved === 'object' && resolved !== null)
|
|
3093
|
-
|
|
3232
|
+
if (typeof resolved === 'object' && resolved !== null) {
|
|
3233
|
+
applySection(resolved, { forceRefreshTools: true });
|
|
3234
|
+
}
|
|
3094
3235
|
const diag = (resolved ?? {});
|
|
3095
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'})`);
|
|
3096
3237
|
refreshTools();
|