@xcanwin/manyoyo 8.3.2 → 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,488 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const { writeConfigFileSecure } = require('./secure-file');
8
+ const { parseEnvEntry } = require('./runtime-normalizers');
9
+
10
+ // 每个容器一个状态目录,按随机 manyoyo.id 索引(不按容器名:删了重建同名容器不会继承旧文件)
11
+ // box/ 读写挂到 /run/manyoyo/ env、autostart.sh、autostart.log(容器可写)
12
+ // sys/ 只读挂到 /run/manyoyo-sys/ init.sh、managed.env、net-required
13
+ // network.json / meta.json 不挂进容器
14
+ const ID_RE = /^[0-9a-f]{16}$/;
15
+ const { ENV_KEY_RE, parseEnvText, serializeEnvEntries, formatEnvValue } = require('./env-text');
16
+ const MAX_ENV_BYTES = 256 * 1024;
17
+ const MAX_AUTOSTART_BYTES = 256 * 1024;
18
+ const INIT_TEMPLATE = path.join(__dirname, 'container-init.sh');
19
+
20
+ function isValidId(id) {
21
+ return typeof id === 'string' && ID_RE.test(id);
22
+ }
23
+
24
+ function newId() {
25
+ return crypto.randomBytes(8).toString('hex');
26
+ }
27
+
28
+ function containersRoot(homeDir = os.homedir()) {
29
+ return path.join(homeDir, '.manyoyo', 'containers');
30
+ }
31
+
32
+ function stateDir(homeDir, id) {
33
+ if (!isValidId(id)) throw new Error(`非法的容器 id: ${id}`);
34
+ return path.join(containersRoot(homeDir), id);
35
+ }
36
+
37
+ function paths(homeDir, id) {
38
+ const dir = stateDir(homeDir, id);
39
+ return {
40
+ dir,
41
+ box: path.join(dir, 'box'),
42
+ sys: path.join(dir, 'sys'),
43
+ env: path.join(dir, 'box', 'env'),
44
+ autostart: path.join(dir, 'box', 'autostart.sh'),
45
+ autostartLog: path.join(dir, 'box', 'autostart.log'),
46
+ init: path.join(dir, 'sys', 'init.sh'),
47
+ netRequired: path.join(dir, 'sys', 'net-required'),
48
+ managedEnv: path.join(dir, 'sys', 'managed.env'),
49
+ filesEnv: path.join(dir, 'sys', 'files.env'),
50
+ envFiles: path.join(dir, 'env-files.json'),
51
+ network: path.join(dir, 'network.json'),
52
+ netStatus: path.join(dir, 'net-status.json'),
53
+ egressToken: path.join(dir, 'egress-token'),
54
+ meta: path.join(dir, 'meta.json')
55
+ };
56
+ }
57
+
58
+ // 宿主机写入前的严格校验:沿用 parseEnvEntry 的字符限制与 key 规则
59
+ function validateEnvText(text, parseEnvEntry) {
60
+ const body = String(text || '');
61
+ if (Buffer.byteLength(body) > MAX_ENV_BYTES) throw new Error('环境变量内容过大');
62
+ const { entries, invalid } = parseEnvText(body);
63
+ if (invalid.length) throw new Error(`第 ${invalid[0].line} 行不是合法的 KEY=VALUE: ${invalid[0].reason}`);
64
+ entries.forEach(entry => parseEnvEntry(`${entry.key}=${entry.value}`));
65
+ return entries;
66
+ }
67
+
68
+ // NO_PROXY 由 Playwright 集成在原值上追加,必须留在容器级 env,不能被用户 env 文件覆盖
69
+ const CONTAINER_LEVEL_ENV_KEYS = new Set(['NO_PROXY', 'no_proxy']);
70
+
71
+ // ['--env', 'K=V', ...] → ['K=V', ...]
72
+ function envArgsToLines(flatArgs) {
73
+ const out = [];
74
+ for (let i = 0; i + 1 < (flatArgs || []).length; i += 2) {
75
+ if (flatArgs[i] === '--env') out.push(flatArgs[i + 1]);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function envLineKey(line) {
81
+ return String(line).split('=')[0];
82
+ }
83
+
84
+ // 从扁平 --env 参数里去掉指定 key(这些 key 改由 box/env 提供)
85
+ function stripEnvKeys(flatArgs, keys) {
86
+ const out = [];
87
+ for (let i = 0; i < (flatArgs || []).length; i += 2) {
88
+ if (flatArgs[i] === '--env' && keys.has(envLineKey(flatArgs[i + 1]))) continue;
89
+ out.push(flatArgs[i], flatArgs[i + 1]);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // 用户 env 拆成要进 box/env 的行(不含容器级 key)
95
+ function userEnvLines(flatArgs) {
96
+ return envArgsToLines(flatArgs).filter(line => !CONTAINER_LEVEL_ENV_KEYS.has(envLineKey(line)));
97
+ }
98
+
99
+ // box/ 是读写挂进容器的目录,里面的文件是**不可信输入**:容器里的进程可以把 env 换成 FIFO(宿主机读会永久阻塞)、
100
+ // 指向宿主机文件的符号链接(读会拿到别的文件、写会覆盖别的文件)或几 GB 的大文件。
101
+ // 所以 box/ 里的文件一律:O_NOFOLLOW|O_NONBLOCK 打开 → fstat 必须是普通文件 → 大小有上限;写入用同目录 tmp + rename
102
+ // (rename 替换的是目录项本身,不会跟随符号链接)。
103
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
104
+ const O_NONBLOCK = fs.constants.O_NONBLOCK || 0;
105
+
106
+ function readBoxText(file, maxBytes) {
107
+ let fd;
108
+ try {
109
+ fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
110
+ } catch (e) {
111
+ if (e.code === 'ENOENT') return { text: '' };
112
+ return { text: '', unsafe: e.code === 'ELOOP' ? '是符号链接' : `无法读取(${e.code})` };
113
+ }
114
+ try {
115
+ const stat = fs.fstatSync(fd);
116
+ if (!stat.isFile()) return { text: '', unsafe: '不是普通文件' };
117
+ if (stat.size > maxBytes) return { text: '', unsafe: '文件过大' };
118
+ const buffer = Buffer.alloc(stat.size);
119
+ const read = stat.size ? fs.readSync(fd, buffer, 0, stat.size, 0) : 0;
120
+ return { text: buffer.toString('utf-8', 0, read), mtimeMs: stat.mtimeMs };
121
+ } finally {
122
+ fs.closeSync(fd);
123
+ }
124
+ }
125
+
126
+ function writeBoxFile(file, data, mode = 0o600) {
127
+ try {
128
+ if (fs.lstatSync(file).isDirectory()) fs.rmSync(file, { recursive: true, force: true });
129
+ } catch (e) { /* 不存在 */ }
130
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
131
+ try {
132
+ fs.writeFileSync(tmp, data, { mode, flag: 'wx' });
133
+ fs.chmodSync(tmp, mode);
134
+ fs.renameSync(tmp, file);
135
+ } catch (error) {
136
+ try { fs.rmSync(tmp, { force: true }); } catch (e) { /* 尽力清理 */ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ function ensureDir(dir, mode) {
142
+ fs.mkdirSync(dir, { recursive: true, mode });
143
+ fs.chmodSync(dir, mode);
144
+ }
145
+
146
+ // 目录 0700 只限制宿主机其他用户;容器经 rootless 映射/rootful root 访问,不受影响
147
+ function writeFileAtomic(filePath, data, mode = 0o600) {
148
+ writeConfigFileSecure(filePath, data);
149
+ if (mode !== 0o600) fs.chmodSync(filePath, mode);
150
+ }
151
+
152
+ /**
153
+ * 创建状态目录与初始文件。
154
+ * @param {object} options
155
+ * @param {string} options.homeDir
156
+ * @param {string} [options.id]
157
+ * @param {string[]} [options.envLines] KEY=VALUE(已校验)
158
+ * @param {string} [options.autostart] 自启动脚本
159
+ * @param {object} [options.meta]
160
+ * @param {boolean} [options.netRequired]
161
+ * @param {object} [options.network] 已校验的网络策略(见 lib/network-policy.js)
162
+ */
163
+ function createState(options) {
164
+ const homeDir = options.homeDir || os.homedir();
165
+ const id = options.id || newId();
166
+ const p = paths(homeDir, id);
167
+ ensureDir(path.join(homeDir, '.manyoyo'), 0o700);
168
+ ensureDir(containersRoot(homeDir), 0o700);
169
+ ensureDir(p.dir, 0o700);
170
+ ensureDir(p.box, 0o700);
171
+ ensureDir(p.sys, 0o700);
172
+ // envLines 是成形的 KEY=VALUE(值是字面量):按统一语法写出(需要时加引号),解析时才能原样读回
173
+ const envText = serializeEnvEntries((options.envLines || []).map(line => {
174
+ const i = String(line).indexOf('=');
175
+ return { key: String(line).slice(0, i), value: String(line).slice(i + 1) };
176
+ }));
177
+ writeBoxFile(p.env, envText);
178
+ writeEnvFileList(homeDir, id, options.envFiles || []);
179
+ writeBoxFile(p.autostart, String(options.autostart || ''));
180
+ refreshInit(homeDir, id);
181
+ if (!fs.existsSync(p.managedEnv)) writeFileAtomic(p.managedEnv, '');
182
+ setNetRequired(homeDir, id, Boolean(options.netRequired));
183
+ if (options.network) writeNetworkRaw(homeDir, id, options.network);
184
+ writeMeta(homeDir, id, { id, createdAt: new Date().toISOString(), ...(options.meta || {}) });
185
+ return { id, ...p };
186
+ }
187
+
188
+ // 每次创建/启动前用仓库里的模板刷新,npm 升级后旧容器下次启动即用新 init
189
+ function refreshInit(homeDir, id) {
190
+ const p = paths(homeDir, id);
191
+ ensureDir(p.sys, 0o700);
192
+ writeFileAtomic(p.init, fs.readFileSync(INIT_TEMPLATE, 'utf-8'), 0o755);
193
+ }
194
+
195
+ // 防御:别的 manyoyo 实例的容器 / 已删容器的 id 不能因为一次写入就凭空建出状态目录
196
+ function assertStateExists(homeDir, id) {
197
+ if (!stateExists(homeDir, id)) throw new Error(`状态目录不存在: ${id}`);
198
+ }
199
+
200
+ function setNetRequired(homeDir, id, required) {
201
+ assertStateExists(homeDir, id);
202
+ const p = paths(homeDir, id);
203
+ if (required) {
204
+ writeFileAtomic(p.netRequired, '1\n');
205
+ } else {
206
+ fs.rmSync(p.netRequired, { force: true });
207
+ }
208
+ }
209
+
210
+ // ---- 环境变量文件:宿主机上的绝对路径列表,每次 exec 现读(所以文件改了下一条命令就生效) ----
211
+ const MAX_ENV_FILES = 20;
212
+
213
+ function normalizeEnvFileList(list) {
214
+ if (!Array.isArray(list)) throw new Error('环境变量文件必须是数组');
215
+ if (list.length > MAX_ENV_FILES) throw new Error(`环境变量文件最多 ${MAX_ENV_FILES} 个`);
216
+ const out = [];
217
+ list.forEach(item => {
218
+ const file = String(item === undefined || item === null ? '' : item).trim();
219
+ if (!file) return;
220
+ if (file.includes('\0') || !path.isAbsolute(file)) throw new Error(`环境变量文件只支持绝对路径: ${file.slice(0, 80)}`);
221
+ if (!out.includes(file)) out.push(file);
222
+ });
223
+ return out;
224
+ }
225
+
226
+ // 环境变量文件内容:统一语法解析,值里含 parseEnvEntry 禁止字符的行记为 invalid 并跳过(不再静默丢弃)
227
+ function parseEnvFileContent(text) {
228
+ const parsed = parseEnvText(text);
229
+ const entries = [];
230
+ const invalid = [...parsed.invalid];
231
+ parsed.entries.forEach(entry => {
232
+ try {
233
+ parseEnvEntry(`${entry.key}=${entry.value}`);
234
+ entries.push(entry);
235
+ } catch (e) {
236
+ invalid.push({ line: entry.line, text: `${entry.key}=…`, reason: e.message });
237
+ }
238
+ });
239
+ return { entries, invalid };
240
+ }
241
+
242
+ function readEnvFileList(homeDir, id) {
243
+ try {
244
+ const parsed = JSON.parse(fs.readFileSync(paths(homeDir, id).envFiles, 'utf-8'));
245
+ return Array.isArray(parsed.files) ? parsed.files.filter(f => typeof f === 'string') : [];
246
+ } catch (e) {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ function writeEnvFileList(homeDir, id, list) {
252
+ const files = normalizeEnvFileList(list);
253
+ writeFileAtomic(paths(homeDir, id).envFiles, `${JSON.stringify({ files }, null, 2)}\n`);
254
+ syncFilesEnv(homeDir, id);
255
+ return files;
256
+ }
257
+
258
+ /**
259
+ * 逐个读取环境变量文件并解析(语法同「环境变量」文本)。值里含 parseEnvEntry 禁止的字符的行记为 invalid 并跳过,
260
+ * 文件不存在 / 不是普通文件 / 太大记 error。读不到的文件不影响其他文件。
261
+ */
262
+ function readEnvFiles(homeDir, id) {
263
+ return readEnvFilePaths(readEnvFileList(homeDir, id));
264
+ }
265
+
266
+ // 按路径读取并解析一批环境变量文件(创建时推断端点、读取现有列表都用它)
267
+ function readEnvFilePaths(list) {
268
+ return list.map(file => {
269
+ const result = { path: file, exists: false, error: '', entries: [], invalid: [] };
270
+ let text;
271
+ try {
272
+ const stat = fs.statSync(file);
273
+ if (!stat.isFile()) { result.error = '不是普通文件'; return result; }
274
+ if (stat.size > MAX_ENV_BYTES) { result.error = '文件过大'; return result; }
275
+ text = fs.readFileSync(file, 'utf-8');
276
+ result.exists = true;
277
+ } catch (e) {
278
+ result.error = e.code === 'ENOENT' ? '文件不存在' : `无法读取(${e.code || e.message})`;
279
+ return result;
280
+ }
281
+ Object.assign(result, parseEnvFileContent(text));
282
+ return result;
283
+ });
284
+ }
285
+
286
+ function envFileEntries(homeDir, id) {
287
+ const entries = [];
288
+ readEnvFiles(homeDir, id).forEach(file => file.entries.forEach(entry => entries.push({ key: entry.key, value: entry.value })));
289
+ return entries;
290
+ }
291
+
292
+ // 容器内 init 读不到宿主机文件:把当前内容快照进只读挂载的 sys/files.env(创建、保存、每次下发规则时刷新),
293
+ // 供自启动脚本使用;exec 则始终现读文件。
294
+ function syncFilesEnv(homeDir, id) {
295
+ // 写成统一语法(需要时加引号),init 用同一套规则读回来
296
+ writeFileAtomic(paths(homeDir, id).filesEnv, serializeEnvEntries(envFileEntries(homeDir, id)));
297
+ }
298
+
299
+ function stateExists(homeDir, id) {
300
+ return isValidId(id) && fs.existsSync(paths(homeDir, id).dir);
301
+ }
302
+
303
+ function hashOf(buffer) {
304
+ return crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16);
305
+ }
306
+
307
+ function readEnv(homeDir, id) {
308
+ const read = readBoxText(paths(homeDir, id).env, MAX_ENV_BYTES);
309
+ return { text: read.text, ...parseEnvText(read.text), mtimeMs: read.mtimeMs || 0, etag: hashOf(read.text), unsafe: read.unsafe || '' };
310
+ }
311
+
312
+ /**
313
+ * 写 env 文本。ifMatch 与当前 etag 不一致说明容器内同时改过,抛 code=CONFLICT。
314
+ */
315
+ function writeEnv(homeDir, id, text, { ifMatch, parseEnvEntry } = {}) {
316
+ const current = readEnv(homeDir, id);
317
+ if (ifMatch !== undefined && ifMatch !== null && ifMatch !== current.etag) {
318
+ const error = new Error('容器内的环境变量已被修改');
319
+ error.code = 'CONFLICT';
320
+ throw error;
321
+ }
322
+ if (parseEnvEntry) validateEnvText(text, parseEnvEntry);
323
+ const body = String(text || '');
324
+ writeBoxFile(paths(homeDir, id).env, body.endsWith('\n') || !body ? body : `${body}\n`);
325
+ return readEnv(homeDir, id);
326
+ }
327
+
328
+ function readAutostart(homeDir, id) {
329
+ return readBoxText(paths(homeDir, id).autostart, MAX_AUTOSTART_BYTES).text;
330
+ }
331
+
332
+ function writeAutostart(homeDir, id, script) {
333
+ const body = String(script || '');
334
+ if (Buffer.byteLength(body) > MAX_AUTOSTART_BYTES) throw new Error('自启动脚本过大');
335
+ writeBoxFile(paths(homeDir, id).autostart, body);
336
+ }
337
+
338
+ // 倒序只读尾部,不全量读取
339
+ function readAutostartLogTail(homeDir, id, maxBytes = 16 * 1024) {
340
+ const file = paths(homeDir, id).autostartLog;
341
+ let fd;
342
+ try {
343
+ fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
344
+ } catch (e) {
345
+ return e.code === 'ENOENT' ? '' : `(日志文件不可读:${e.code === 'ELOOP' ? '是符号链接' : e.code})`;
346
+ }
347
+ try {
348
+ const stat = fs.fstatSync(fd);
349
+ if (!stat.isFile()) return '(日志文件不是普通文件)';
350
+ const length = Math.min(stat.size, maxBytes);
351
+ const buffer = Buffer.alloc(length);
352
+ fs.readSync(fd, buffer, 0, length, stat.size - length);
353
+ return buffer.toString('utf-8');
354
+ } finally {
355
+ fs.closeSync(fd);
356
+ }
357
+ }
358
+
359
+ function writeManagedEnv(homeDir, id, entries) {
360
+ assertStateExists(homeDir, id);
361
+ writeFileAtomic(paths(homeDir, id).managedEnv, entries.length ? `${entries.join('\n')}\n` : '');
362
+ }
363
+
364
+ function readMeta(homeDir, id) {
365
+ try {
366
+ return JSON.parse(fs.readFileSync(paths(homeDir, id).meta, 'utf-8'));
367
+ } catch (e) {
368
+ return {};
369
+ }
370
+ }
371
+
372
+ function writeMeta(homeDir, id, meta) {
373
+ writeFileAtomic(paths(homeDir, id).meta, `${JSON.stringify(meta, null, 2)}\n`);
374
+ }
375
+
376
+ // 网络策略原样存取(校验在 lib/network-policy.js);没有文件返回 null
377
+ function readNetworkRaw(homeDir, id) {
378
+ try {
379
+ return JSON.parse(fs.readFileSync(paths(homeDir, id).network, 'utf-8'));
380
+ } catch (e) {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ function writeNetworkRaw(homeDir, id, policy) {
386
+ writeFileAtomic(paths(homeDir, id).network, `${JSON.stringify(policy, null, 2)}\n`);
387
+ }
388
+
389
+ // 最近一次规则下发结果:{status: 'applied'|'error', message, at}
390
+ function readNetStatus(homeDir, id) {
391
+ try {
392
+ return JSON.parse(fs.readFileSync(paths(homeDir, id).netStatus, 'utf-8'));
393
+ } catch (e) {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ function writeNetStatus(homeDir, id, status) {
399
+ assertStateExists(homeDir, id);
400
+ writeFileAtomic(paths(homeDir, id).netStatus, `${JSON.stringify(status)}\n`);
401
+ }
402
+
403
+ // 过滤代理的容器凭据:256 bit 随机,只存宿主机侧(不挂进容器,容器从自己的 managed.env 拿到带凭据的代理地址)
404
+ function readEgressToken(homeDir, id) {
405
+ try {
406
+ return fs.readFileSync(paths(homeDir, id).egressToken, 'utf-8').trim() || null;
407
+ } catch (e) {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ function ensureEgressToken(homeDir, id) {
413
+ const existing = readEgressToken(homeDir, id);
414
+ if (existing) return existing;
415
+ const token = crypto.randomBytes(32).toString('hex');
416
+ writeFileAtomic(paths(homeDir, id).egressToken, `${token}\n`);
417
+ return token;
418
+ }
419
+
420
+ function removeState(homeDir, id) {
421
+ if (!isValidId(id)) return false;
422
+ fs.rmSync(stateDir(homeDir, id), { recursive: true, force: true });
423
+ return true;
424
+ }
425
+
426
+ function listStateIds(homeDir) {
427
+ try {
428
+ return fs.readdirSync(containersRoot(homeDir)).filter(isValidId);
429
+ } catch (e) {
430
+ return [];
431
+ }
432
+ }
433
+
434
+ // 状态目录里没有对应容器的 id(不自动删,避免切换运行时时误删)
435
+ function findOrphans(homeDir, liveIds) {
436
+ const live = new Set(liveIds);
437
+ return listStateIds(homeDir).filter(id => !live.has(id));
438
+ }
439
+
440
+ module.exports = {
441
+ ID_RE,
442
+ ENV_KEY_RE,
443
+ isValidId,
444
+ newId,
445
+ readBoxText,
446
+ writeBoxFile,
447
+ MAX_ENV_BYTES,
448
+ envArgsToLines,
449
+ envLineKey,
450
+ stripEnvKeys,
451
+ userEnvLines,
452
+ containersRoot,
453
+ stateDir,
454
+ paths,
455
+ parseEnvText,
456
+ serializeEnvEntries,
457
+ formatEnvValue,
458
+ validateEnvText,
459
+ createState,
460
+ refreshInit,
461
+ setNetRequired,
462
+ stateExists,
463
+ normalizeEnvFileList,
464
+ readEnvFileList,
465
+ writeEnvFileList,
466
+ readEnvFiles,
467
+ readEnvFilePaths,
468
+ parseEnvFileContent,
469
+ envFileEntries,
470
+ syncFilesEnv,
471
+ readEnv,
472
+ writeEnv,
473
+ readAutostart,
474
+ writeAutostart,
475
+ readAutostartLogTail,
476
+ writeManagedEnv,
477
+ readMeta,
478
+ writeMeta,
479
+ readNetworkRaw,
480
+ writeNetworkRaw,
481
+ readNetStatus,
482
+ writeNetStatus,
483
+ readEgressToken,
484
+ ensureEgressToken,
485
+ removeState,
486
+ listStateIds,
487
+ findOrphans
488
+ };