@xcanwin/manyoyo 8.3.3 → 8.4.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.
@@ -0,0 +1,450 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const net = require('net');
5
+ const state_ = require('../container-state');
6
+ const { resolveContainerId } = require('../container-exec');
7
+ const { normalizePolicy } = require('../network-policy');
8
+ const { parseEnvEntry } = require('../runtime-normalizers');
9
+ const { envUrlDomains } = require('../network-endpoints');
10
+
11
+ // 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
12
+ // 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
13
+ const LEGACY_MESSAGE = '该容器创建于旧版本,重建后可用';
14
+
15
+ function isLoopbackBind(bind) {
16
+ return bind === '127.0.0.1' || bind === '::1';
17
+ }
18
+
19
+ // 把 `wide` 当作放开限制:整个 /8 以上的网段(含 0.0.0.0/0)、公共后缀通配(*.co.uk 之类),效果接近 open
20
+ const PUBLIC_SUFFIX_WILDCARD_RE = /^\*\.(co|com|net|org|gov|edu|ac|or|ne|go)\.[a-z]{2}$/;
21
+
22
+ function isWideRule(cidr) {
23
+ const m = /\/(\d+)$/.exec(cidr);
24
+ const prefix = m ? Number(m[1]) : 32;
25
+ return cidr.includes(':') ? prefix <= 16 : prefix <= 8;
26
+ }
27
+
28
+ function wideKeys(policy) {
29
+ return [
30
+ ...policy.egress.rules.filter(rule => isWideRule(rule.cidr)).map(rule => `rule:${rule.cidr}|${rule.ports}|${rule.proto}`),
31
+ ...policy.egress.domains.filter(domain => PUBLIC_SUFFIX_WILDCARD_RE.test(domain)).map(domain => `domain:${domain}`)
32
+ ];
33
+ }
34
+
35
+ // 需要二次确认的风险:切到 open(等于不加规则)、新增非 loopback 的端口暴露、新增范围极宽的出站规则
36
+ function pendingRisks(before, after) {
37
+ const risks = [];
38
+ if (after.preset === 'open' && before.preset !== 'open') risks.push('open');
39
+ const hadWide = new Set(wideKeys(before));
40
+ if (after.preset !== 'open' && wideKeys(after).some(key => !hadWide.has(key))) risks.push('wide');
41
+ const had = new Set(before.expose.filter(e => !isLoopbackBind(e.bind)).map(e => `${e.bind}:${e.hostPort}`));
42
+ if (after.expose.some(e => !isLoopbackBind(e.bind) && !had.has(`${e.bind}:${e.hostPort}`))) risks.push('publicBind');
43
+ return risks;
44
+ }
45
+
46
+ function parseTail(value, fallback = 16 * 1024) {
47
+ const n = Number(value);
48
+ if (!Number.isInteger(n) || n < 1) return fallback;
49
+ return Math.min(n, 64 * 1024);
50
+ }
51
+
52
+ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
53
+ const { sendJson, readJsonBody, decodeSessionName, getNetworkManager, getPortForwarder, ensureWebContainer, execCommandInWebContainer, rememberContainerName } = deps;
54
+ const homeDir = ctx.homeDir || os.homedir();
55
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
56
+
57
+ // 解析容器:名字合法、存在、有 manyoyo.id 且状态目录在。失败时已回复,返回 null。
58
+ function resolveTarget(encodedName, { write = false } = {}) {
59
+ const name = decodeSessionName(encodedName);
60
+ if (!ctx.isValidContainerName(name)) {
61
+ sendJson(res, 400, { error: `containerName 非法: ${name}` });
62
+ return null;
63
+ }
64
+ if (!ctx.containerExists(name)) {
65
+ sendJson(res, 404, { error: `容器不存在: ${name}` });
66
+ return null;
67
+ }
68
+ const id = resolveContainerId(ctx.dockerExecArgs, name);
69
+ if (!id || !state_.stateExists(homeDir, id)) {
70
+ if (write) sendJson(res, 409, { error: LEGACY_MESSAGE, legacy: true });
71
+ else sendJson(res, 200, { legacy: true, message: LEGACY_MESSAGE });
72
+ return null;
73
+ }
74
+ rememberContainerName(id, name);
75
+ return { name, id };
76
+ }
77
+
78
+ function readPolicy(id) {
79
+ return normalizePolicy(state_.readNetworkRaw(homeDir, id) || undefined);
80
+ }
81
+
82
+ function writePolicy(id, policy) {
83
+ state_.writeNetworkRaw(homeDir, id, policy);
84
+ }
85
+
86
+ function isRunning(name) {
87
+ try {
88
+ return ctx.getContainerStatus(name) === 'running';
89
+ } catch (e) {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ // 环境变量文件的状态(不回传值,只给路径、能不能读、有几个变量、哪些行被跳过)
95
+ function envFilesPayload(id) {
96
+ return state_.readEnvFiles(homeDir, id).map(file => ({
97
+ path: file.path,
98
+ exists: file.exists,
99
+ error: file.error,
100
+ count: file.entries.length,
101
+ invalid: file.invalid
102
+ }));
103
+ }
104
+
105
+ function envPayload(id) {
106
+ const env = state_.readEnv(homeDir, id);
107
+ return {
108
+ legacy: false,
109
+ id,
110
+ text: env.text,
111
+ entries: env.entries,
112
+ invalid: env.invalid,
113
+ files: envFilesPayload(id),
114
+ etag: env.etag,
115
+ mtime: env.mtimeMs ? new Date(env.mtimeMs).toISOString() : null,
116
+ warning: env.unsafe ? `容器内的 /run/manyoyo/env ${env.unsafe},已忽略;保存会用新文件替换它` : ''
117
+ };
118
+ }
119
+
120
+ async function networkPayload(target, extra = {}) {
121
+ const policy = readPolicy(target.id);
122
+ const manager = getNetworkManager();
123
+ let peers = [];
124
+ try {
125
+ peers = (await manager.listManaged()).filter(item => item.id !== target.id).map(item => ({ id: item.id, name: item.name, running: item.running }));
126
+ } catch (e) {
127
+ peers = [];
128
+ }
129
+ // 建议放行的域名:来自当前环境变量(含容器里改过的)里的 URL。只是建议,要用户自己点才会加进策略
130
+ // (容器可写的 env 不能直接决定防火墙)
131
+ const envLines = [
132
+ ...state_.readEnv(homeDir, target.id).entries.map(entry => `${entry.key}=${entry.value}`),
133
+ ...state_.envFileEntries(homeDir, target.id).map(entry => `${entry.key}=${entry.value}`)
134
+ ];
135
+ const suggestedDomains = envUrlDomains(envLines).filter(domain => !policy.egress.domains.includes(domain));
136
+ return {
137
+ legacy: false,
138
+ id: target.id,
139
+ running: isRunning(target.name),
140
+ suggestedDomains,
141
+ policy,
142
+ status: state_.readNetStatus(homeDir, target.id),
143
+ forwards: getPortForwarder().list(target.id),
144
+ peers,
145
+ ...extra
146
+ };
147
+ }
148
+
149
+ // 保存策略后:下发规则(容器在运行时)、同步端口转发、重算有 peers 关系的容器
150
+ async function applyPolicy(target, policy) {
151
+ const out = { applyError: '', forwardFailures: [] };
152
+ const running = isRunning(target.name);
153
+ if (running) {
154
+ const manager = getNetworkManager();
155
+ try {
156
+ await manager.apply(target.name, { expectId: target.id });
157
+ for (const related of await manager.relatedContainers(target.name)) {
158
+ await manager.apply(related).catch(e => ctx.logger.warn('related container network apply failed', { related, message: e && e.message }));
159
+ }
160
+ } catch (e) {
161
+ out.applyError = e.message;
162
+ }
163
+ }
164
+ out.forwardFailures = await getPortForwarder().sync(target.id, policy.expose);
165
+ return out;
166
+ }
167
+
168
+ async function putNetwork(target, body) {
169
+ if (!body.policy || typeof body.policy !== 'object' || Array.isArray(body.policy)) {
170
+ sendJson(res, 400, { error: 'policy 必须是对象' });
171
+ return;
172
+ }
173
+ const before = readPolicy(target.id);
174
+ const next = normalizePolicy({ ...body.policy, autostartOnServe: body.policy && body.policy.autostartOnServe !== undefined ? body.policy.autostartOnServe : before.autostartOnServe });
175
+ for (const entry of next.peers.inbound) {
176
+ if (entry.from === target.id || !state_.stateExists(homeDir, entry.from)) {
177
+ sendJson(res, 400, { error: `peers.inbound 里的容器不存在: ${entry.from}` });
178
+ return;
179
+ }
180
+ }
181
+ const risks = pendingRisks(before, next);
182
+ if (risks.length && body.confirmRisk !== true) {
183
+ sendJson(res, 400, { error: '该改动会放开网络限制,需要确认', needsConfirm: true, risks });
184
+ return;
185
+ }
186
+ writePolicy(target.id, next);
187
+ state_.setNetRequired(homeDir, target.id, next.preset !== 'open');
188
+ const result = await applyPolicy(target, next);
189
+ const payload = await networkPayload(target, { applyError: result.applyError, forwardFailures: result.forwardFailures });
190
+ // 策略已保存;规则下发失败(502)或端口暴露没起来(409,如端口被占)都要明确报错
191
+ if (result.applyError) sendJson(res, 502, { error: result.applyError, ...payload });
192
+ else if (result.forwardFailures.length) sendJson(res, 409, { error: result.forwardFailures[0], ...payload });
193
+ else sendJson(res, 200, payload);
194
+ }
195
+
196
+ async function postExpose(target, body) {
197
+ const policy = readPolicy(target.id);
198
+ const entry = { bind: body.bind, hostPort: body.hostPort, port: body.port };
199
+ const next = normalizePolicy({ ...policy, expose: [...policy.expose, entry] });
200
+ const risks = pendingRisks(policy, next);
201
+ if (risks.length && body.confirmRisk !== true) {
202
+ sendJson(res, 400, { error: '绑定非本机地址会让局域网 / 公网可见,需要确认', needsConfirm: true, risks });
203
+ return;
204
+ }
205
+ const added = next.expose[next.expose.length - 1];
206
+ const failures = await getPortForwarder().sync(target.id, [...policy.expose, added]);
207
+ if (failures.length) {
208
+ sendJson(res, 409, { error: failures[0] });
209
+ return;
210
+ }
211
+ writePolicy(target.id, next);
212
+ sendJson(res, 200, await networkPayload(target));
213
+ }
214
+
215
+ async function deleteExpose(target, body) {
216
+ const policy = readPolicy(target.id);
217
+ const bind = String(body.bind || '').trim();
218
+ const hostPort = Number(body.hostPort);
219
+ const rest = policy.expose.filter(e => !(e.bind === bind && e.hostPort === hostPort));
220
+ if (rest.length === policy.expose.length) {
221
+ sendJson(res, 404, { error: '没有这条端口暴露' });
222
+ return;
223
+ }
224
+ writePolicy(target.id, { ...policy, expose: rest });
225
+ await getPortForwarder().sync(target.id, rest);
226
+ sendJson(res, 200, await networkPayload(target));
227
+ }
228
+
229
+ // 状态目录先于容器创建(docker run 之前),刚建的目录不能当孤儿:5 分钟内的一律跳过
230
+ function isYoung(id) {
231
+ const created = Date.parse(state_.readMeta(homeDir, id).createdAt || '');
232
+ return Number.isFinite(created) && Date.now() - created < 5 * 60 * 1000;
233
+ }
234
+
235
+ async function listOrphans() {
236
+ let live = [];
237
+ try {
238
+ live = (await getNetworkManager().listManaged()).map(item => item.id);
239
+ } catch (e) {
240
+ // 运行时不可用时不给“可删”列表,避免误删
241
+ sendJson(res, 503, { error: '容器运行时不可用,无法判断孤儿状态目录' });
242
+ return;
243
+ }
244
+ const orphans = state_.findOrphans(homeDir, live).filter(id => !isYoung(id)).map(id => {
245
+ const meta = state_.readMeta(homeDir, id);
246
+ return { id, name: meta.name || '', createdAt: meta.createdAt || '' };
247
+ });
248
+ sendJson(res, 200, { orphans });
249
+ }
250
+
251
+ return [
252
+ {
253
+ method: 'GET',
254
+ match: p => (p === '/api/containers/orphans' ? [] : null),
255
+ handler: async () => listOrphans()
256
+ },
257
+ {
258
+ method: 'DELETE',
259
+ match: p => p.match(/^\/api\/containers\/orphans\/([0-9a-f]{16})$/),
260
+ handler: async match => {
261
+ const id = match[1];
262
+ let live;
263
+ try {
264
+ live = (await getNetworkManager().listManaged()).map(item => item.id);
265
+ } catch (e) {
266
+ sendJson(res, 503, { error: '容器运行时不可用,无法判断孤儿状态目录' });
267
+ return;
268
+ }
269
+ if (live.includes(id)) {
270
+ sendJson(res, 409, { error: '该状态目录仍属于一个现有容器' });
271
+ return;
272
+ }
273
+ if (!state_.stateExists(homeDir, id)) {
274
+ sendJson(res, 404, { error: '状态目录不存在' });
275
+ return;
276
+ }
277
+ if (isYoung(id)) {
278
+ sendJson(res, 409, { error: '这个状态目录刚创建,可能对应一个正在创建的容器,请稍后再试' });
279
+ return;
280
+ }
281
+ getPortForwarder().closeFor(id);
282
+ state_.removeState(homeDir, id);
283
+ sendJson(res, 200, { removed: id });
284
+ }
285
+ },
286
+ {
287
+ method: 'GET',
288
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/env$/),
289
+ handler: async match => {
290
+ const target = resolveTarget(match[1]);
291
+ if (target) sendJson(res, 200, envPayload(target.id));
292
+ }
293
+ },
294
+ {
295
+ method: 'PUT',
296
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/env$/),
297
+ handler: async match => {
298
+ const target = resolveTarget(match[1], { write: true });
299
+ if (!target) return;
300
+ const body = await readJsonBody(req);
301
+ if (typeof body.text !== 'string') {
302
+ sendJson(res, 400, { error: 'text 必须是字符串' });
303
+ return;
304
+ }
305
+ const ifMatch = req.headers['if-match'];
306
+ if (ifMatch === undefined) {
307
+ sendJson(res, 428, { error: '需要 If-Match(先 GET 取得 etag),避免覆盖容器内的修改' });
308
+ return;
309
+ }
310
+ try {
311
+ if (body.files !== undefined) state_.normalizeEnvFileList(body.files);
312
+ state_.writeEnv(homeDir, target.id, body.text, { ifMatch: String(ifMatch).replace(/"/g, ''), parseEnvEntry });
313
+ if (body.files !== undefined) state_.writeEnvFileList(homeDir, target.id, body.files);
314
+ else state_.syncFilesEnv(homeDir, target.id);
315
+ } catch (e) {
316
+ if (e.code === 'CONFLICT') {
317
+ sendJson(res, 409, { error: e.message, conflict: true, ...envPayload(target.id) });
318
+ return;
319
+ }
320
+ sendJson(res, 400, { error: e.message });
321
+ return;
322
+ }
323
+ sendJson(res, 200, envPayload(target.id));
324
+ }
325
+ },
326
+ {
327
+ method: 'GET',
328
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart$/),
329
+ handler: async match => {
330
+ const target = resolveTarget(match[1]);
331
+ if (!target) return;
332
+ sendJson(res, 200, {
333
+ legacy: false,
334
+ id: target.id,
335
+ script: state_.readAutostart(homeDir, target.id),
336
+ autostartOnServe: readPolicy(target.id).autostartOnServe
337
+ });
338
+ }
339
+ },
340
+ {
341
+ method: 'PUT',
342
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart$/),
343
+ handler: async match => {
344
+ const target = resolveTarget(match[1], { write: true });
345
+ if (!target) return;
346
+ const body = await readJsonBody(req);
347
+ if (body.script !== undefined && typeof body.script !== 'string') {
348
+ sendJson(res, 400, { error: 'script 必须是字符串' });
349
+ return;
350
+ }
351
+ try {
352
+ if (body.script !== undefined) state_.writeAutostart(homeDir, target.id, body.script);
353
+ } catch (e) {
354
+ sendJson(res, 400, { error: e.message });
355
+ return;
356
+ }
357
+ if (body.autostartOnServe !== undefined) {
358
+ const policy = readPolicy(target.id);
359
+ writePolicy(target.id, { ...policy, autostartOnServe: body.autostartOnServe === true });
360
+ }
361
+ sendJson(res, 200, {
362
+ legacy: false,
363
+ id: target.id,
364
+ script: state_.readAutostart(homeDir, target.id),
365
+ autostartOnServe: readPolicy(target.id).autostartOnServe
366
+ });
367
+ }
368
+ },
369
+ {
370
+ method: 'POST',
371
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart\/run$/),
372
+ handler: async match => {
373
+ const target = resolveTarget(match[1], { write: true });
374
+ if (!target) return;
375
+ // 经门闩检查:网络规则没就绪不会运行(下发失败直接报错)
376
+ await ensureWebContainer(ctx, state, target.name);
377
+ const result = await execCommandInWebContainer(
378
+ ctx,
379
+ target.name,
380
+ 'nohup setsid /bin/bash /run/manyoyo/autostart.sh >> /run/manyoyo/autostart.log 2>&1 < /dev/null & echo started'
381
+ );
382
+ if (result.exitCode !== 0) {
383
+ sendJson(res, 500, { error: result.output || '启动失败' });
384
+ return;
385
+ }
386
+ sendJson(res, 200, { started: true });
387
+ }
388
+ },
389
+ {
390
+ method: 'GET',
391
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart\/log$/),
392
+ handler: async match => {
393
+ const target = resolveTarget(match[1]);
394
+ if (!target) return;
395
+ const log = state_.readAutostartLogTail(homeDir, target.id, parseTail(requestUrl.searchParams.get('tail')));
396
+ sendJson(res, 200, { log });
397
+ }
398
+ },
399
+ {
400
+ method: 'GET',
401
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/network$/),
402
+ handler: async match => {
403
+ const target = resolveTarget(match[1]);
404
+ if (target) sendJson(res, 200, await networkPayload(target));
405
+ }
406
+ },
407
+ {
408
+ method: 'PUT',
409
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/network$/),
410
+ handler: async match => {
411
+ const target = resolveTarget(match[1], { write: true });
412
+ if (!target) return;
413
+ const body = await readJsonBody(req);
414
+ try {
415
+ await putNetwork(target, body);
416
+ } catch (e) {
417
+ if (res.headersSent) throw e;
418
+ sendJson(res, 400, { error: e.message });
419
+ }
420
+ }
421
+ },
422
+ {
423
+ method: 'POST',
424
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/expose$/),
425
+ handler: async match => {
426
+ const target = resolveTarget(match[1], { write: true });
427
+ if (!target) return;
428
+ const body = await readJsonBody(req);
429
+ try {
430
+ await postExpose(target, body);
431
+ } catch (e) {
432
+ if (res.headersSent) throw e;
433
+ sendJson(res, 400, { error: e.message });
434
+ }
435
+ }
436
+ },
437
+ {
438
+ method: 'DELETE',
439
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/expose$/),
440
+ handler: async match => {
441
+ const target = resolveTarget(match[1], { write: true });
442
+ if (!target) return;
443
+ const body = await readJsonBody(req);
444
+ await deleteExpose(target, body);
445
+ }
446
+ }
447
+ ];
448
+ }
449
+
450
+ module.exports = { createContainerManageRoutes, pendingRisks, isLoopbackBind };