rainskills 0.1.11 → 0.1.13

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.
@@ -4,10 +4,12 @@ const crypto = require("node:crypto");
4
4
  const fs = require("node:fs");
5
5
  const http = require("node:http");
6
6
  const https = require("node:https");
7
+ const net = require("node:net");
7
8
  const os = require("node:os");
8
9
  const path = require("node:path");
9
10
  const readline = require("node:readline/promises");
10
11
  const { spawn, spawnSync } = require("node:child_process");
12
+ const { TextDecoder } = require("node:util");
11
13
  const YAML = require("yaml");
12
14
 
13
15
  const POLICY = require("../references/installation-policy.json");
@@ -19,10 +21,14 @@ const REQUIRED_ROLES = ["etcd", "master", "worker", "rbd-gateway", "rbd-chaos"];
19
21
  const ALL_ROLES = [...REQUIRED_ROLES, "nfs-server"];
20
22
  const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential)/i;
21
23
  const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
22
- const SAFE_ADDRESS = /^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|(?:[0-9a-fA-F]*:){2,}[0-9a-fA-F:.]+)$/;
23
24
  const SAFE_REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
24
25
  const CRITICAL_WORKLOADS = ["rbd-api", "rbd-gateway", "rbd-app-ui"];
25
26
  const MAX_CHILD_OUTPUT_BYTES = 4 * 1024 * 1024;
27
+ const MAX_SERVER_INPUT_BYTES = 1024 * 1024;
28
+ const MAX_SERVER_INPUT_HOSTS = 100;
29
+ const MAX_SERVER_INPUT_ISSUES = 200;
30
+ const SERVER_INPUT_FIELDS = ["public_ip", "private_ip", "ssh_port", "password"];
31
+ const SERVER_INPUT_VALIDATION_ERROR = "servers.txt 解析结果无效";
26
32
  const CHILD_OUTPUT_LIMIT_ERROR = "子进程输出超过安全上限";
27
33
  const TEMPLATE_ADDRESSES = new Map([
28
34
  ["192.0.2.101", "node1"],
@@ -68,6 +74,63 @@ function renderHostClusterConfigPrompt({ configPath, platform = process.platform
68
74
  ].join("\n");
69
75
  }
70
76
 
77
+ function createHostServerInputTemplate() {
78
+ const heading = [
79
+ "# Rainbond 多节点服务器信息",
80
+ "# 密码只写入受保护的本地和远端安装文件,不会写入聊天、日志或状态;不要填写私钥或 Token。",
81
+ "# 超过三台服务器时,复制完整的 [server-N] 区块并按顺序编号。",
82
+ ];
83
+ const sections = Array.from({ length: 3 }, (_, index) => [
84
+ `[server-${index + 1}]`,
85
+ "# 公网 IP:控制端通过 SSH 访问该服务器的地址;没有独立公网 IP 时可与内网 IP 相同",
86
+ "public_ip=",
87
+ "# 内网 IP:集群节点之间通信使用的地址",
88
+ "private_ip=",
89
+ "# SSH 端口:按服务器实际端口填写,不要求为 22",
90
+ "ssh_port=22",
91
+ "# root 密码:保留所有特殊字符;不要添加引号",
92
+ "password=",
93
+ ].join("\n"));
94
+ return Buffer.from(`${heading.join("\n")}\n\n${sections.join("\n\n")}\n`, "utf8");
95
+ }
96
+
97
+ function hostServerInputOpenCommand(inputPath, platform = process.platform) {
98
+ const filePath = String(inputPath);
99
+ if (!filePath || /[\0\r\n]/.test(filePath)) throw new Error("servers.txt 路径无效");
100
+ if (platform === "darwin") return `open ${quotePosixArgument(filePath)}`;
101
+ if (platform === "linux") return `xdg-open ${quotePosixArgument(filePath)}`;
102
+ if (platform === "win32") return `explorer.exe "${filePath.replace(/"/g, '""')}"`;
103
+ throw new Error("不支持的控制端平台");
104
+ }
105
+
106
+ function renderHostServerInputPrompt({ inputPath, platform = process.platform, issues = [] }) {
107
+ const normalizedPath = String(inputPath).replace(/\\/g, "/");
108
+ const linkTarget = encodeURI(normalizedPath).replace(/#/g, "%23");
109
+ const heading = issues.length > 0
110
+ ? ["服务器信息还需要调整:", ...issues.map((issue, index) => `${index + 1}. ${issue}`)]
111
+ : ["服务器信息文件已生成。"];
112
+ return [
113
+ ...heading,
114
+ "",
115
+ `[点击打开 servers.txt](<${linkTarget}>)`,
116
+ "",
117
+ "如果链接无法打开,请在系统终端执行:",
118
+ "",
119
+ "```sh",
120
+ hostServerInputOpenCommand(inputPath, platform),
121
+ "```",
122
+ "",
123
+ "请为每台服务器填写 public_ip、private_ip、ssh_port 和 password。",
124
+ "- 公网 IP:控制端 SSH 地址;没有独立公网 IP 时可与内网 IP 相同。",
125
+ "- 内网 IP:节点通信地址。",
126
+ "- SSH 端口:填写每台服务器的实际端口,不强制为 22。",
127
+ "- root 密码:只写入受保护的本地和远端安装文件,不得发送到聊天。",
128
+ "密码只写入受保护的本地和远端安装文件,不会写入聊天、日志或状态;不要填写私钥或 Token。",
129
+ "",
130
+ "编辑完成后回复“已完成”,我会检查全部服务器信息。",
131
+ ].join("\n");
132
+ }
133
+
71
134
  function clusterConfigCommandArgument(configPath, platform = process.platform) {
72
135
  const value = String(configPath || "");
73
136
  if (!value || /[\0\r\n]/.test(value)) throw new Error("cluster.yaml 路径无效");
@@ -237,6 +300,178 @@ function deepClone(value) {
237
300
  return JSON.parse(JSON.stringify(value));
238
301
  }
239
302
 
303
+ function normalizeServerInputAddress(value) {
304
+ if (typeof value !== "string" || value.includes("%")) return null;
305
+ const ipVersion = net.isIP(value);
306
+ if (ipVersion === 4) return value;
307
+ if (ipVersion === 6) {
308
+ try {
309
+ return new URL(`http://[${value}]/`).hostname.slice(1, -1);
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+ if (value.length > 253) return null;
315
+ const labels = value.split(".");
316
+ if (labels.some((label) => (
317
+ label.length < 1
318
+ || label.length > 63
319
+ || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label)
320
+ ))) return null;
321
+ return value.toLowerCase();
322
+ }
323
+
324
+ function parseHostServerInput(input) {
325
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
326
+ const issues = [];
327
+ const issueSet = new Set();
328
+ let diagnosticsTruncated = false;
329
+ const add = (message) => {
330
+ if (!message || issueSet.has(message)) return;
331
+ if (issues.length >= MAX_SERVER_INPUT_ISSUES) {
332
+ diagnosticsTruncated = true;
333
+ return;
334
+ }
335
+ issueSet.add(message);
336
+ issues.push(message);
337
+ };
338
+ const result = (hosts) => {
339
+ if (diagnosticsTruncated) issues.push("servers.txt 问题过多,诊断已截断");
340
+ return { hosts, issues };
341
+ };
342
+ if (bytes.length > MAX_SERVER_INPUT_BYTES) {
343
+ return { hosts: [], issues: ["servers.txt 大小不能超过 1 MiB"] };
344
+ }
345
+
346
+ let text;
347
+ try {
348
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
349
+ } catch {
350
+ return { hosts: [], issues: ["servers.txt 必须使用有效的 UTF-8 编码"] };
351
+ }
352
+ if (/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(text)
353
+ || /(?:^|[^\r])\r(?!\n)/.test(text)) {
354
+ add("servers.txt 不能包含 NUL 或其他控制字符");
355
+ text = text
356
+ .replace(/[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
357
+ .replace(/\r(?!\n)/g, "");
358
+ }
359
+
360
+ const sections = [];
361
+ let current = null;
362
+ let ignoreRemainingInput = false;
363
+ const lines = text.split("\n");
364
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
365
+ if (ignoreRemainingInput) continue;
366
+ const lineNumber = lineIndex + 1;
367
+ const line = lines[lineIndex].endsWith("\r") ? lines[lineIndex].slice(0, -1) : lines[lineIndex];
368
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
369
+
370
+ const sectionMatch = /^\[server-(\d+)\]$/.exec(line);
371
+ if (sectionMatch) {
372
+ if (sections.length >= MAX_SERVER_INPUT_HOSTS) {
373
+ add("servers.txt 最多允许 100 个节点区块");
374
+ current = null;
375
+ ignoreRemainingInput = true;
376
+ continue;
377
+ }
378
+ current = {
379
+ number: Number(sectionMatch[1]),
380
+ numberText: sectionMatch[1],
381
+ line: lineNumber,
382
+ fields: new Map(),
383
+ };
384
+ sections.push(current);
385
+ continue;
386
+ }
387
+ if (line.startsWith("[") && line.endsWith("]")) {
388
+ add(`第 ${lineNumber} 行是未知区块`);
389
+ current = null;
390
+ continue;
391
+ }
392
+
393
+ const separator = line.indexOf("=");
394
+ if (separator < 0) {
395
+ add(`第 ${lineNumber} 行字段格式无效`);
396
+ continue;
397
+ }
398
+ if (!current) {
399
+ add(`第 ${lineNumber} 行字段不属于任何 server 区块`);
400
+ continue;
401
+ }
402
+ const field = line.slice(0, separator);
403
+ if (!SERVER_INPUT_FIELDS.includes(field)) {
404
+ add(`第 ${lineNumber} 行包含未知字段`);
405
+ continue;
406
+ }
407
+ if (current.fields.has(field)) {
408
+ add(`第 ${lineNumber} 行的 ${field} 字段重复`);
409
+ continue;
410
+ }
411
+ const rawValue = line.slice(separator + 1);
412
+ current.fields.set(field, field === "password" ? rawValue : rawValue.trim());
413
+ }
414
+
415
+ if (sections.length < 3) add("servers.txt 至少需要 3 个节点区块");
416
+ const sectionNumbers = new Set();
417
+ for (let index = 0; index < sections.length; index += 1) {
418
+ const section = sections[index];
419
+ const label = `server-${section.number}`;
420
+ if (sectionNumbers.has(section.number)) add(`第 ${section.line} 行的 ${label} 区块重复`);
421
+ sectionNumbers.add(section.number);
422
+ if (section.numberText !== String(index + 1)) add(`第 ${section.line} 行的区块编号必须从 server-1 开始连续`);
423
+ for (const field of SERVER_INPUT_FIELDS) {
424
+ if (!section.fields.has(field)) {
425
+ add(`${label} 的 ${field} 字段缺失`);
426
+ } else if (field === "password" && section.fields.get(field).trim() === "") {
427
+ add(`${label} 的 password 未填写或只有空白`);
428
+ } else if (field !== "password" && section.fields.get(field) === "") {
429
+ add(`${label} 的 ${field} 字段未填写`);
430
+ }
431
+ }
432
+ }
433
+
434
+ const hosts = sections.map((section) => ({
435
+ publicIp: section.fields.get("public_ip") || "",
436
+ privateIp: section.fields.get("private_ip") || "",
437
+ sshPort: Number(section.fields.get("ssh_port")),
438
+ password: section.fields.get("password") || "",
439
+ }));
440
+ const sshEndpoints = new Set();
441
+ const privateAddresses = new Set();
442
+ for (let index = 0; index < sections.length; index += 1) {
443
+ const section = sections[index];
444
+ const host = hosts[index];
445
+ const label = `server-${section.number}`;
446
+ const publicIp = section.fields.get("public_ip");
447
+ const privateIp = section.fields.get("private_ip");
448
+ const portText = section.fields.get("ssh_port");
449
+ const publicNormalized = typeof publicIp === "string" && publicIp !== ""
450
+ ? normalizeServerInputAddress(publicIp)
451
+ : null;
452
+ const privateNormalized = typeof privateIp === "string" && privateIp !== ""
453
+ ? normalizeServerInputAddress(privateIp)
454
+ : null;
455
+ const publicValid = publicNormalized !== null;
456
+ const privateValid = privateNormalized !== null;
457
+ const portValid = typeof portText === "string" && /^\d+$/.test(portText)
458
+ && Number.isInteger(host.sshPort) && host.sshPort >= 1 && host.sshPort <= 65535;
459
+ if (typeof publicIp === "string" && publicIp !== "" && !publicValid) add(`${label} 的 public_ip 地址无效`);
460
+ if (typeof privateIp === "string" && privateIp !== "" && !privateValid) add(`${label} 的 private_ip 地址无效`);
461
+ if (typeof portText === "string" && portText !== "" && !portValid) add(`${label} 的 ssh_port 必须是 1 到 65535 的整数`);
462
+ if (publicValid && portValid) {
463
+ const endpoint = `${publicNormalized}\0${host.sshPort}`;
464
+ if (sshEndpoints.has(endpoint)) add(`${label} 的 SSH endpoint 与前面的区块重复`);
465
+ sshEndpoints.add(endpoint);
466
+ }
467
+ if (privateValid) {
468
+ if (privateAddresses.has(privateNormalized)) add(`${label} 的 private_ip 与前面的区块重复`);
469
+ privateAddresses.add(privateNormalized);
470
+ }
471
+ }
472
+ return result(hosts);
473
+ }
474
+
240
475
  function parseClusterDocument(input) {
241
476
  const text = Buffer.isBuffer(input) ? input.toString("utf8") : String(input || "");
242
477
  if (text.includes("\0")) throw new Error("cluster.yaml 不能包含 NUL 字节");
@@ -301,6 +536,61 @@ function createHostClusterTemplate() {
301
536
  return Buffer.from(String(document), "utf8");
302
537
  }
303
538
 
539
+ function createClusterConfigFromServerInput(hosts) {
540
+ if (!Array.isArray(hosts) || hosts.length < 3 || hosts.length > MAX_SERVER_INPUT_HOSTS) {
541
+ throw new Error(SERVER_INPUT_VALIDATION_ERROR);
542
+ }
543
+ const nodeNames = Array.from({ length: hosts.length }, (_, index) => `node${index + 1}`);
544
+ const generatedHosts = [];
545
+ for (let index = 0; index < hosts.length; index += 1) {
546
+ if (!Object.prototype.hasOwnProperty.call(hosts, index)) {
547
+ throw new Error(SERVER_INPUT_VALIDATION_ERROR);
548
+ }
549
+ const item = hosts[index];
550
+ if (!item || typeof item !== "object" || Array.isArray(item)
551
+ || typeof item.publicIp !== "string" || normalizeServerInputAddress(item.publicIp) === null
552
+ || typeof item.privateIp !== "string" || normalizeServerInputAddress(item.privateIp) === null
553
+ || !Number.isInteger(item.sshPort) || item.sshPort < 1 || item.sshPort > 65535
554
+ || typeof item.password !== "string" || item.password.trim() === "") {
555
+ throw new Error(SERVER_INPUT_VALIDATION_ERROR);
556
+ }
557
+ generatedHosts.push({
558
+ name: nodeNames[index],
559
+ address: item.publicIp,
560
+ internalAddress: item.privateIp,
561
+ user: "root",
562
+ password: item.password,
563
+ port: item.sshPort,
564
+ ...(index === 0 ? { bootstrap: true } : {}),
565
+ });
566
+ }
567
+ const config = {
568
+ hosts: generatedHosts,
569
+ roleGroups: {
570
+ etcd: nodeNames.slice(0, 3),
571
+ master: nodeNames.slice(0, 3),
572
+ worker: nodeNames,
573
+ "rbd-gateway": nodeNames.slice(0, 2),
574
+ "rbd-chaos": nodeNames,
575
+ "nfs-server": ["node1"],
576
+ },
577
+ storage: {
578
+ nfs: { enabled: true, sharePath: "/nfs-data/k8s", storageClass: { enabled: true } },
579
+ existingStorageClass: { enabled: false },
580
+ },
581
+ registry: { external: { enabled: false } },
582
+ database: { mysql: { enabled: false }, custom: { enabled: false } },
583
+ };
584
+ try {
585
+ validateClusterTopology(config);
586
+ } catch {
587
+ throw new Error(SERVER_INPUT_VALIDATION_ERROR);
588
+ }
589
+ const document = new YAML.Document(config);
590
+ document.options.lineWidth = 0;
591
+ return Buffer.from(String(document), "utf8");
592
+ }
593
+
304
594
  function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
305
595
  let value;
306
596
  try {
@@ -317,7 +607,8 @@ function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
317
607
  const hosts = Array.isArray(value.hosts) ? value.hosts : [];
318
608
  if (!Array.isArray(value.hosts) || hosts.length === 0) add("hosts 至少需要配置一个节点");
319
609
  const names = new Set();
320
- const addresses = new Set();
610
+ const sshEndpoints = new Set();
611
+ const internalAddresses = new Set();
321
612
  const normalized = [];
322
613
  for (let index = 0; index < hosts.length; index += 1) {
323
614
  const raw = hosts[index];
@@ -329,15 +620,21 @@ function diagnoseClusterConfig(input, { source = "generated-template" } = {}) {
329
620
  const address = String(raw.address || "");
330
621
  const internalAddress = String(raw.internalAddress || raw.address || "");
331
622
  const port = raw.port === undefined ? 22 : Number(raw.port);
623
+ const normalizedAddress = normalizeServerInputAddress(address);
624
+ const normalizedInternalAddress = normalizeServerInputAddress(internalAddress);
332
625
  if (!SAFE_NAME.test(name)) add(`hosts[${index}].name 无效`);
333
626
  if (names.has(name) && name) add(`节点名称重复:${name}`);
334
627
  if (name) names.add(name);
335
- if (!SAFE_ADDRESS.test(address) || address.startsWith("-") || /\.\./.test(address)) add(`节点 ${name || index} 的 address 无效`);
336
- if (!SAFE_ADDRESS.test(internalAddress) || internalAddress.startsWith("-") || /\.\./.test(internalAddress)) add(`节点 ${name || index} 的 internalAddress 无效`);
337
- for (const candidate of new Set([address, internalAddress])) {
338
- if (!candidate) continue;
339
- if (addresses.has(candidate)) add(`节点地址重复:${candidate}`);
340
- addresses.add(candidate);
628
+ if (normalizedAddress === null) add(`节点 ${name || index} 的 address 无效`);
629
+ if (normalizedInternalAddress === null) add(`节点 ${name || index} 的 internalAddress 无效`);
630
+ if (normalizedAddress !== null && Number.isInteger(port) && port >= 1 && port <= 65535) {
631
+ const endpoint = `${normalizedAddress}\0${port}`;
632
+ if (sshEndpoints.has(endpoint)) add(`节点地址(SSH endpoint)重复:${address}:${port}`);
633
+ sshEndpoints.add(endpoint);
634
+ }
635
+ if (normalizedInternalAddress !== null) {
636
+ if (internalAddresses.has(normalizedInternalAddress)) add(`节点 internalAddress 重复:${internalAddress}`);
637
+ internalAddresses.add(normalizedInternalAddress);
341
638
  }
342
639
  if (String(raw.user || "root") !== "root") add(`节点 ${name || index} 只支持 root SSH 用户`);
343
640
  if (typeof raw.password !== "string" || raw.password.trim() === "") add(`节点 ${name || index} 的 password 未填写`);
@@ -416,10 +713,10 @@ function normalizeHost(raw, index) {
416
713
  const user = String(raw.user || "root");
417
714
  const port = raw.port === undefined ? 22 : Number(raw.port);
418
715
  if (!SAFE_NAME.test(name)) throw new Error(`节点名称无效:${name || `(index ${index})`}`);
419
- if (!SAFE_ADDRESS.test(address) || address.startsWith("-") || /\.\./.test(address)) {
716
+ if (normalizeServerInputAddress(address) === null) {
420
717
  throw new Error(`节点 address 无效:${name}`);
421
718
  }
422
- if (!SAFE_ADDRESS.test(internalAddress) || internalAddress.startsWith("-") || /\.\./.test(internalAddress)) {
719
+ if (normalizeServerInputAddress(internalAddress) === null) {
423
720
  throw new Error(`节点 internalAddress 无效:${name}`);
424
721
  }
425
722
  if (user !== "root") throw new Error(`ROI 主机安装只支持 root SSH 用户:${name}`);
@@ -449,14 +746,20 @@ function validateClusterTopology(config) {
449
746
  if (!Array.isArray(config.hosts) || config.hosts.length === 0) throw new Error("集群至少需要一个节点");
450
747
  const hosts = config.hosts.map(normalizeHost);
451
748
  const names = new Set();
452
- const addresses = new Set();
749
+ const sshEndpoints = new Set();
750
+ const internalAddresses = new Set();
453
751
  for (const item of hosts) {
454
752
  if (names.has(item.name)) throw new Error(`节点名称必须唯一:${item.name}`);
455
753
  names.add(item.name);
456
- for (const address of new Set([item.address, item.internalAddress])) {
457
- if (addresses.has(address)) throw new Error(`节点地址存在冲突:${address}`);
458
- addresses.add(address);
754
+ const normalizedAddress = normalizeServerInputAddress(item.address);
755
+ const endpoint = `${normalizedAddress}\0${item.port}`;
756
+ if (sshEndpoints.has(endpoint)) throw new Error(`节点地址(SSH endpoint)存在冲突:${item.address}:${item.port}`);
757
+ sshEndpoints.add(endpoint);
758
+ const normalizedInternalAddress = normalizeServerInputAddress(item.internalAddress);
759
+ if (internalAddresses.has(normalizedInternalAddress)) {
760
+ throw new Error(`节点 internalAddress 存在冲突:${item.internalAddress}`);
459
761
  }
762
+ internalAddresses.add(normalizedInternalAddress);
460
763
  }
461
764
  const roleGroups = config.roleGroups;
462
765
  if (!roleGroups || typeof roleGroups !== "object" || Array.isArray(roleGroups)) {
@@ -613,6 +916,92 @@ function readSafeClusterSource(filePath, {
613
916
  return { bytes, value: parsed };
614
917
  }
615
918
 
919
+ function readProtectedServerInput(filePath, {
920
+ platform = process.platform,
921
+ stateStore,
922
+ fsImpl = fs,
923
+ currentUid = typeof process.getuid === "function" ? process.getuid() : null,
924
+ } = {}) {
925
+ if (!stateStore || typeof stateStore.assertProtectedRegularFile !== "function") {
926
+ throw new Error("servers.txt 必须通过受保护文件检查");
927
+ }
928
+ const readOnce = () => {
929
+ let opened;
930
+ try {
931
+ const noFollow = platform === "win32" ? 0 : (fsImpl.constants.O_NOFOLLOW || 0);
932
+ opened = fsImpl.openSync(filePath, fsImpl.constants.O_RDONLY | noFollow);
933
+ } catch (error) {
934
+ if (["ELOOP", "EMLINK"].includes(error.code)) throw new Error("拒绝读取符号链接 servers.txt");
935
+ throw error;
936
+ }
937
+ try {
938
+ const info = fsImpl.fstatSync(opened);
939
+ if (!info.isFile() || info.isSymbolicLink?.()) throw new Error("servers.txt 不是普通文件");
940
+ if (currentUid !== null && info.uid !== currentUid) throw new Error("servers.txt 不属于当前用户");
941
+ if (platform !== "win32" && (info.mode & 0o777) !== 0o600) {
942
+ throw new Error("servers.txt 权限必须精确为 0600");
943
+ }
944
+ if (info.size > MAX_SERVER_INPUT_BYTES) throw new Error("servers.txt 大小不能超过 1 MiB");
945
+ const bytes = Buffer.alloc(info.size);
946
+ let offset = 0;
947
+ while (offset < bytes.length) {
948
+ const count = fsImpl.readSync(opened, bytes, offset, bytes.length - offset, offset);
949
+ if (count === 0) throw new Error("servers.txt 在读取期间发生变化,拒绝继续");
950
+ offset += count;
951
+ }
952
+ const extra = Buffer.alloc(1);
953
+ if (fsImpl.readSync(opened, extra, 0, 1, offset) !== 0) {
954
+ throw new Error("servers.txt 在读取期间增长或超过 1 MiB,拒绝继续");
955
+ }
956
+ const afterInfo = fsImpl.fstatSync(opened);
957
+ if (sourceIdentity(info) !== sourceIdentity(afterInfo)) {
958
+ throw new Error("servers.txt 在 descriptor 读取期间发生变化,拒绝继续");
959
+ }
960
+ return { bytes, info: afterInfo };
961
+ } finally {
962
+ fsImpl.closeSync(opened);
963
+ }
964
+ };
965
+
966
+ const inspectWindowsAcl = () => {
967
+ const protectedAcl = stateStore.assertProtectedRegularFile(filePath);
968
+ const acl = typeof stateStore.assertSafeExternalRegularFile === "function"
969
+ ? stateStore.assertSafeExternalRegularFile(filePath)
970
+ : protectedAcl;
971
+ if (!acl || typeof acl.fileIdentity !== "string" || acl.fileIdentity === "") {
972
+ throw new Error("Windows servers.txt ACL 检查缺少 file identity");
973
+ }
974
+ return acl;
975
+ };
976
+ const beforeAcl = platform === "win32"
977
+ ? inspectWindowsAcl()
978
+ : (stateStore.assertProtectedRegularFile(filePath), null);
979
+ const first = readOnce();
980
+ if (platform === "win32") {
981
+ if (beforeAcl.fileIdentity !== contentIdentity(first.bytes)) {
982
+ throw new Error("Windows servers.txt ACL identity 与首次 fd 字节不匹配");
983
+ }
984
+ const afterAcl = inspectWindowsAcl();
985
+ const second = readOnce();
986
+ if (afterAcl.fileIdentity !== contentIdentity(second.bytes)) {
987
+ throw new Error("Windows servers.txt ACL identity 与再次 fd 字节不匹配");
988
+ }
989
+ if (beforeAcl.fileIdentity !== afterAcl.fileIdentity
990
+ || sourceIdentity(first.info) !== sourceIdentity(second.info)
991
+ || sha256(first.bytes) !== sha256(second.bytes)) {
992
+ throw new Error("servers.txt 在 ACL 检查和读取期间发生变化,拒绝继续");
993
+ }
994
+ } else {
995
+ stateStore.assertProtectedRegularFile(filePath);
996
+ const second = readOnce();
997
+ if (sourceIdentity(first.info) !== sourceIdentity(second.info)
998
+ || sha256(first.bytes) !== sha256(second.bytes)) {
999
+ throw new Error("servers.txt 在两次受保护读取期间字节发生变化,拒绝继续");
1000
+ }
1001
+ }
1002
+ return first.bytes;
1003
+ }
1004
+
616
1005
  function atomicWriteProtectedBytes(destinationPath, bytes, { stateStore, mode = 0o600 } = {}) {
617
1006
  const target = path.resolve(destinationPath);
618
1007
  const directory = path.dirname(target);
@@ -921,9 +1310,33 @@ async function probeRemoteArchitecture(bootstrap, {
921
1310
  throw new Error("bootstrap 节点的 CPU 架构不受 ROI 支持");
922
1311
  }
923
1312
 
1313
+ function renderTopologyNodeLine(node) {
1314
+ return `- ${node.name}: ${node.roles.join(", ")}${node.bootstrap ? " (bootstrap)" : ""}`;
1315
+ }
1316
+
1317
+ function renderValidatedTopologyStatus(summary, configPath) {
1318
+ const etcdNodes = (summary.nodes || []).filter(({ roles }) => roles.includes("etcd"));
1319
+ const lines = [
1320
+ "",
1321
+ "[RAINSKILLS_STATUS:host_cluster_config_valid]",
1322
+ "集群配置检查通过:",
1323
+ `- 节点:${summary.hosts} 个`,
1324
+ `- etcd:${etcdNodes.length} 个`,
1325
+ `- bootstrap:${summary.bootstrap}`,
1326
+ `- 存储模式:${summary.storageMode}`,
1327
+ `受保护配置:${configPath}`,
1328
+ "",
1329
+ "自动角色分配:",
1330
+ ...(summary.nodes || []).map(renderTopologyNodeLine),
1331
+ "",
1332
+ "正在准备所有服务器的 SSH 连接。",
1333
+ ];
1334
+ return `${lines.join("\n")}\n`;
1335
+ }
1336
+
924
1337
  function renderConfirmationSummary(summary) {
925
1338
  const lines = ["\nROI 主机集群安装确认", "", `节点拓扑:${summary.hosts} 个节点`];
926
- for (const node of summary.nodes || []) lines.push(`- ${node.name}: ${node.roles.join(", ")}${node.bootstrap ? " (bootstrap)" : ""}`);
1339
+ for (const node of summary.nodes || []) lines.push(renderTopologyNodeLine(node));
927
1340
  lines.push("", "将发生的系统变更:安装 RKE2、Containerd、Rainbond 及所选存储组件;写入 /opt/rainbond 和 /var/lib/rancher。", `受保护配置:${summary.configPath}`, "");
928
1341
  return `${lines.join("\n")}\n`;
929
1342
  }
@@ -1284,35 +1697,59 @@ function reuseLockedRoiArtifact({
1284
1697
  };
1285
1698
  }
1286
1699
 
1287
- function createLineRedactor() {
1700
+ function normalizeSensitiveValues(sensitiveValues = []) {
1701
+ if (!Array.isArray(sensitiveValues)) return [];
1702
+ return [...new Set(sensitiveValues
1703
+ .filter((value) => typeof value === "string" && value.length > 0))]
1704
+ .sort((left, right) => right.length - left.length);
1705
+ }
1706
+
1707
+ function selectSafeRedactionMarker(sensitiveValues) {
1708
+ const candidates = ["[REDACTED]", "[FILTERED]", "[MASKED]", "***", ""];
1709
+ return candidates.find((candidate) => (
1710
+ sensitiveValues.every((sensitiveValue) => !candidate.includes(sensitiveValue))
1711
+ )) ?? "";
1712
+ }
1713
+
1714
+ function redactLiteralSensitiveValues(value, sensitiveValues, marker) {
1715
+ let safe = String(value || "");
1716
+ for (const sensitiveValue of sensitiveValues) {
1717
+ safe = safe.split(sensitiveValue).join(marker);
1718
+ }
1719
+ return safe;
1720
+ }
1721
+
1722
+ function createLineRedactor(sensitiveValues = []) {
1723
+ const literalValues = normalizeSensitiveValues(sensitiveValues);
1724
+ const marker = selectSafeRedactionMarker(literalValues);
1288
1725
  let sensitiveBlockIndent = null;
1289
1726
  let pemBlock = false;
1290
1727
  let structuredBlockIndent = null;
1291
1728
  return (value) => {
1292
- const line = String(value || "").replace(/\r$/, "");
1729
+ const line = redactLiteralSensitiveValues(String(value || "").replace(/\r$/, ""), literalValues, marker);
1293
1730
  const indent = line.match(/^\s*/)?.[0].length || 0;
1294
1731
  if (pemBlock) {
1295
1732
  if (/-----END [A-Z0-9 ]+-----/.test(line)) pemBlock = false;
1296
- return `${line.slice(0, indent)}[REDACTED]`;
1733
+ return `${line.slice(0, indent)}${marker}`;
1297
1734
  }
1298
1735
  const pemBegin = /-----BEGIN [A-Z0-9 ]+-----/.test(line);
1299
1736
  if (pemBegin) {
1300
1737
  if (!/-----END [A-Z0-9 ]+-----/.test(line)) pemBlock = true;
1301
- return `${line.slice(0, indent)}[REDACTED]`;
1738
+ return `${line.slice(0, indent)}${marker}`;
1302
1739
  }
1303
1740
  if (structuredBlockIndent !== null) {
1304
- if (!line.trim() || indent > structuredBlockIndent) return `${line.slice(0, indent)}[REDACTED]`;
1741
+ if (!line.trim() || indent > structuredBlockIndent) return `${line.slice(0, indent)}${marker}`;
1305
1742
  structuredBlockIndent = null;
1306
- if (/^[}\]],?$/.test(line.trim())) return `${line.slice(0, indent)}[REDACTED]`;
1743
+ if (/^[}\]],?$/.test(line.trim())) return `${line.slice(0, indent)}${marker}`;
1307
1744
  }
1308
1745
  if (sensitiveBlockIndent !== null) {
1309
- if (!line.trim() || indent > sensitiveBlockIndent) return `${line.slice(0, indent)}[REDACTED]`;
1746
+ if (!line.trim() || indent > sensitiveBlockIndent) return `${line.slice(0, indent)}${marker}`;
1310
1747
  sensitiveBlockIndent = null;
1311
1748
  }
1312
1749
  if (
1313
1750
  /\b(?:Bearer|Basic)\s+\S+/i.test(line)
1314
1751
  || /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/.test(line)
1315
- ) return `${line.slice(0, indent)}[REDACTED]`;
1752
+ ) return `${line.slice(0, indent)}${marker}`;
1316
1753
  const keyMatches = [...line.matchAll(/(?:password|passwd|token|secret|private.?key|credential|database|registry|authorization|proxy-authorization|set-cookie|cookie|api[-_ ]?key|apikey|grjwt|jwt)/ig)];
1317
1754
  if (keyMatches.length === 0) return line;
1318
1755
  const candidates = keyMatches.map((keyMatch) => {
@@ -1331,20 +1768,20 @@ function createLineRedactor() {
1331
1768
  !earliest || candidate.separator < earliest.separator ? candidate : earliest
1332
1769
  ), null);
1333
1770
  const separator = firstCandidate?.separator ?? -1;
1334
- if (separator < 0) return "[REDACTED]";
1771
+ if (separator < 0) return marker;
1335
1772
  const remainder = blockCandidate?.remainder ?? firstCandidate.remainder;
1336
1773
  if (/-----BEGIN [A-Z0-9 ]+-----/.test(remainder) && !/-----END [A-Z0-9 ]+-----/.test(remainder)) pemBlock = true;
1337
1774
  else if (/^[\[{]/.test(remainder) || /[\[{]\s*$/.test(line)) structuredBlockIndent = indent;
1338
1775
  else if (!remainder || /^[|>][+-]?$/.test(remainder)) sensitiveBlockIndent = indent;
1339
- return `${line.slice(0, separator + 1)} [REDACTED]`;
1776
+ return `${line.slice(0, separator + 1)} ${marker}`;
1340
1777
  };
1341
1778
  }
1342
1779
 
1343
- function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES) {
1780
+ function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES, sensitiveValues = []) {
1344
1781
  const outputLimit = boundedOutputLimit(maxBytes);
1345
1782
  const input = String(value || "");
1346
1783
  if (Buffer.byteLength(input) > outputLimit) throw childOutputLimitError();
1347
- const redactLine = createLineRedactor();
1784
+ const redactLine = createLineRedactor(sensitiveValues);
1348
1785
  const output = [];
1349
1786
  let pending = "";
1350
1787
  let outputBytes = 0;
@@ -1368,14 +1805,14 @@ function redactInstallLog(value, maxBytes = MAX_CHILD_OUTPUT_BYTES) {
1368
1805
  return output.join("");
1369
1806
  }
1370
1807
 
1371
- function protectedInstallLogBytes(execution) {
1808
+ function protectedInstallLogBytes(execution, sensitiveValues = []) {
1372
1809
  const rawStdout = String(execution?.stdout || "");
1373
1810
  const rawStderr = String(execution?.stderr || "");
1374
1811
  if (Buffer.byteLength(rawStdout) + Buffer.byteLength(rawStderr) > MAX_CHILD_OUTPUT_BYTES) {
1375
1812
  throw childOutputLimitError();
1376
1813
  }
1377
- const stdout = execution?.redacted === true ? rawStdout : redactInstallLog(rawStdout);
1378
- const stderr = execution?.redacted === true ? rawStderr : redactInstallLog(rawStderr);
1814
+ const stdout = redactInstallLog(rawStdout, MAX_CHILD_OUTPUT_BYTES, sensitiveValues);
1815
+ const stderr = redactInstallLog(rawStderr, MAX_CHILD_OUTPUT_BYTES, sensitiveValues);
1379
1816
  const stdoutLength = Buffer.byteLength(stdout);
1380
1817
  const stderrLength = Buffer.byteLength(stderr);
1381
1818
  const separatorLength = stdoutLength > 0 && stderrLength > 0 ? 1 : 0;
@@ -1392,6 +1829,17 @@ function assertSafeRemotePath(value) {
1392
1829
  return normalized;
1393
1830
  }
1394
1831
 
1832
+ const ROI_COMPLETION_RECEIPT_LINE = "RECEIPT_PHASE=completed";
1833
+
1834
+ function isExactRoiCompletionReceiptLine(value) {
1835
+ const line = String(value || "");
1836
+ return (line.endsWith("\r") ? line.slice(0, -1) : line) === ROI_COMPLETION_RECEIPT_LINE;
1837
+ }
1838
+
1839
+ function hasExactRoiCompletionReceipt(value) {
1840
+ return String(value || "").split("\n").some(isExactRoiCompletionReceiptLine);
1841
+ }
1842
+
1395
1843
  function spawnRedactedAttached(command, args, {
1396
1844
  spawnFn = spawn,
1397
1845
  registerChild = () => {},
@@ -1399,6 +1847,7 @@ function spawnRedactedAttached(command, args, {
1399
1847
  stderrWriter = process.stderr,
1400
1848
  maxOutputBytes,
1401
1849
  input,
1850
+ sensitiveValues = [],
1402
1851
  } = {}) {
1403
1852
  return new Promise((resolve, reject) => {
1404
1853
  const child = spawnFn(command, args, { stdio: [input === undefined ? "inherit" : "pipe", "pipe", "pipe"] });
@@ -1406,11 +1855,15 @@ function spawnRedactedAttached(command, args, {
1406
1855
  let cleared = false;
1407
1856
  let receivedBytes = 0;
1408
1857
  let emittedBytes = 0;
1858
+ let receiptCompleted = false;
1409
1859
  const outputLimit = boundedOutputLimit(maxOutputBytes);
1410
1860
  const collected = { stdout: [], stderr: [] };
1411
1861
  const pending = { stdout: "", stderr: "" };
1412
1862
  const buffers = { stdout: "", stderr: "" };
1413
- const redactors = { stdout: createLineRedactor(), stderr: createLineRedactor() };
1863
+ const redactors = {
1864
+ stdout: createLineRedactor(sensitiveValues),
1865
+ stderr: createLineRedactor(sensitiveValues),
1866
+ };
1414
1867
  const unregister = registerChild(child, false);
1415
1868
  const clear = () => {
1416
1869
  if (cleared) return;
@@ -1451,14 +1904,18 @@ function spawnRedactedAttached(command, args, {
1451
1904
  buffers[name] += String(chunk);
1452
1905
  let newline;
1453
1906
  while ((newline = buffers[name].indexOf("\n")) >= 0) {
1454
- const safe = `${redactors[name](buffers[name].slice(0, newline))}\n`;
1907
+ const rawLine = buffers[name].slice(0, newline);
1908
+ if (name === "stdout" && isExactRoiCompletionReceiptLine(rawLine)) receiptCompleted = true;
1909
+ const safe = `${redactors[name](rawLine)}\n`;
1455
1910
  buffers[name] = buffers[name].slice(newline + 1);
1456
1911
  if (!emitSafe(name, writer, safe)) return;
1457
1912
  }
1458
1913
  };
1459
1914
  const flush = (name, writer) => {
1460
1915
  if (!buffers[name]) return;
1461
- const safe = redactors[name](buffers[name]);
1916
+ const rawLine = buffers[name];
1917
+ if (name === "stdout" && isExactRoiCompletionReceiptLine(rawLine)) receiptCompleted = true;
1918
+ const safe = redactors[name](rawLine);
1462
1919
  buffers[name] = "";
1463
1920
  emitSafe(name, writer, safe);
1464
1921
  };
@@ -1481,7 +1938,14 @@ function spawnRedactedAttached(command, args, {
1481
1938
  for (const name of ["stdout", "stderr"]) {
1482
1939
  if (pending[name]) collected[name].push(pending[name]);
1483
1940
  }
1484
- resolve({ code, signal, stdout: collected.stdout.join(""), stderr: collected.stderr.join(""), redacted: true });
1941
+ resolve({
1942
+ code,
1943
+ signal,
1944
+ stdout: collected.stdout.join(""),
1945
+ stderr: collected.stderr.join(""),
1946
+ redacted: true,
1947
+ receiptCompleted,
1948
+ });
1485
1949
  });
1486
1950
  if (input !== undefined) child.stdin.end(input);
1487
1951
  });
@@ -1576,6 +2040,7 @@ async function executeRoiInstall({
1576
2040
  sshSpawn,
1577
2041
  write = (value) => process.stderr.write(value),
1578
2042
  abortState,
2043
+ sensitiveValues = [],
1579
2044
  }) {
1580
2045
  assertOperationNotAborted(abortState);
1581
2046
  const item = normalizeHost(bootstrap, 0);
@@ -1610,8 +2075,11 @@ async function executeRoiInstall({
1610
2075
  registerChild,
1611
2076
  spawnFn: sshSpawn,
1612
2077
  input: REMOTE_ROI_LAUNCH_SCRIPT,
2078
+ sensitiveValues,
1613
2079
  });
1614
- atomicWriteProtectedBytes(logPath, protectedInstallLogBytes(execution), { stateStore });
2080
+ const receiptCompleted = execution.receiptCompleted === true
2081
+ || (execution.receiptCompleted === undefined && hasExactRoiCompletionReceipt(execution.stdout));
2082
+ atomicWriteProtectedBytes(logPath, protectedInstallLogBytes(execution, sensitiveValues), { stateStore });
1615
2083
  if (execution.signal || execution.code === 130) {
1616
2084
  persistState({ stage: "executing", status: "interrupted", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
1617
2085
  write(`\n安装已中断,状态已保留。继续时执行:\n ${resumeArgv.join(" ")}\n`);
@@ -1621,7 +2089,7 @@ async function executeRoiInstall({
1621
2089
  persistState({ stage: "executing", status: "failed", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
1622
2090
  throw new Error(`ROI 主机集群安装失败,退出码 ${execution.code}`);
1623
2091
  }
1624
- if (!/^RECEIPT_PHASE=completed$/m.test(String(execution.stdout || ""))) {
2092
+ if (!receiptCompleted) {
1625
2093
  persistState({ stage: "executing", status: "failed", configSha256: configDigest, artifactSha256: artifactDigest, resumeArgv });
1626
2094
  throw new Error("ROI 已返回成功,但远端 completed receipt 验证结果缺失");
1627
2095
  }
@@ -1810,6 +2278,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1810
2278
  const hostRoot = path.join(paths.root, "host-cluster");
1811
2279
  stateStore.ensurePrivateDirectory(hostRoot);
1812
2280
  const configPath = path.join(hostRoot, "cluster.yaml");
2281
+ const serverInputPath = path.join(hostRoot, "servers.txt");
1813
2282
  const driverStatePath = path.join(hostRoot, "state.json");
1814
2283
  const logPath = path.join(hostRoot, "roi.log");
1815
2284
  const loadedDriverState = loadDriverState(driverStatePath, stateStore);
@@ -1827,13 +2296,20 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1827
2296
  if (fs.existsSync(configPath)) {
1828
2297
  throw new Error("发现来源不明的受保护 cluster.yaml,已停止;请将其作为外部文件重新导入,或移走后重新生成模板");
1829
2298
  }
2299
+ if (fs.existsSync(serverInputPath)) {
2300
+ throw new Error("发现来源不明的受保护 servers.txt,已停止;请移走后重新开始");
2301
+ }
1830
2302
  persistDriverState({
1831
- config_source: options.clusterConfig ? "imported-file" : "generated-template",
2303
+ config_source: options.clusterConfig ? "imported-file" : "generated-server-input",
1832
2304
  ...(options.clusterConfig ? { import_source_path: path.resolve(options.clusterConfig) } : {}),
2305
+ ...(!options.clusterConfig ? { server_input_path: serverInputPath } : {}),
1833
2306
  });
1834
2307
  } else if (!driverState.config_source) {
1835
2308
  if (driverState.stage !== "configuration" && driverState.config_sha256) {
1836
- persistDriverState({ config_source: "imported-file" });
2309
+ persistDriverState({
2310
+ config_source: "imported-file",
2311
+ import_source_sha256: driverState.config_sha256,
2312
+ });
1837
2313
  } else {
1838
2314
  throw new Error("主机集群配置来源状态不完整,拒绝读取 cluster.yaml");
1839
2315
  }
@@ -1842,13 +2318,103 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1842
2318
  if (driverState.config_source === "generated-template" && options.clusterConfig) {
1843
2319
  throw new Error("当前流程已绑定自动生成的 cluster.yaml,不能切换为外部导入配置");
1844
2320
  }
2321
+ if (driverState.config_source === "generated-server-input" && options.clusterConfig) {
2322
+ throw new Error("当前流程已绑定自动生成的 servers.txt,不能切换为外部导入配置");
2323
+ }
1845
2324
  if (driverState.config_source === "imported-file" && options.clusterConfig
1846
2325
  && path.resolve(options.clusterConfig) !== driverState.import_source_path) {
1847
2326
  throw new Error("当前流程已绑定另一份外部 cluster.yaml,不能切换配置来源");
1848
2327
  }
1849
2328
 
1850
2329
  let config;
1851
- if (driverState.config_source === "generated-template") {
2330
+ if (driverState.config_source === "generated-server-input") {
2331
+ if (driverState.server_input_path !== serverInputPath) {
2332
+ throw new Error("主机集群 servers.txt 路径状态不匹配,拒绝继续");
2333
+ }
2334
+ if (!fs.existsSync(serverInputPath)) {
2335
+ if (fs.existsSync(configPath)) {
2336
+ throw new Error("servers.txt 缺失且发现无法验证来源的 cluster.yaml,拒绝继续");
2337
+ }
2338
+ const template = createHostServerInputTemplate();
2339
+ createProtectedBytesExclusive(serverInputPath, template, { stateStore });
2340
+ persistDriverState({
2341
+ stage: "configuration",
2342
+ status: "waiting_user",
2343
+ server_input_template_sha256: sha256(template),
2344
+ });
2345
+ write("\n[RAINSKILLS_USER_INPUT_REQUIRED:host_cluster_server_input]\n");
2346
+ writeUserMessage(write, "platform.host-cluster-server-input", renderHostServerInputPrompt({
2347
+ inputPath: serverInputPath,
2348
+ platform: dependencies.platform || process.platform,
2349
+ }));
2350
+ return { waiting: true, waitingStage: "waiting-host-cluster-server-input", inputPath: serverInputPath };
2351
+ }
2352
+
2353
+ const inputBytes = readProtectedServerInput(serverInputPath, {
2354
+ platform: dependencies.platform || process.platform,
2355
+ stateStore,
2356
+ });
2357
+ const inputSha256 = sha256(inputBytes);
2358
+ if (driverState.server_input_sha256 && driverState.server_input_sha256 !== inputSha256) {
2359
+ throw new Error("恢复时 servers.txt 字节发生变化,拒绝继续");
2360
+ }
2361
+
2362
+ if (driverState.server_input_sha256) {
2363
+ if (!driverState.generated_config_sha256 || !driverState.config_sha256) {
2364
+ throw new Error("自动生成的 cluster.yaml 摘要状态不完整,拒绝继续");
2365
+ }
2366
+ if (!fs.existsSync(configPath)) throw new Error("已锁定的 cluster.yaml 缺失,拒绝恢复");
2367
+ const locked = readSafeClusterSource(configPath, {
2368
+ platform: dependencies.platform || process.platform,
2369
+ sourceStateStore: stateStore,
2370
+ });
2371
+ const lockedSha256 = sha256(locked.bytes);
2372
+ if (lockedSha256 !== driverState.generated_config_sha256
2373
+ || lockedSha256 !== driverState.config_sha256) {
2374
+ throw new Error("恢复时 cluster.yaml 字节发生变化,拒绝继续");
2375
+ }
2376
+ config = locked.value;
2377
+ } else {
2378
+ const parsed = parseHostServerInput(inputBytes);
2379
+ if (parsed.issues.length > 0) {
2380
+ if (fs.existsSync(configPath)) {
2381
+ throw new Error("servers.txt 无效且发现无法验证的 cluster.yaml,拒绝继续");
2382
+ }
2383
+ persistDriverState({ stage: "configuration", status: "waiting_user" });
2384
+ write("\n[RAINSKILLS_USER_INPUT_REQUIRED:host_cluster_server_input]\n");
2385
+ writeUserMessage(write, "platform.host-cluster-server-input", renderHostServerInputPrompt({
2386
+ inputPath: serverInputPath,
2387
+ platform: dependencies.platform || process.platform,
2388
+ issues: parsed.issues,
2389
+ }));
2390
+ return {
2391
+ waiting: true,
2392
+ waitingStage: "waiting-host-cluster-server-input",
2393
+ inputPath: serverInputPath,
2394
+ issues: parsed.issues,
2395
+ };
2396
+ }
2397
+
2398
+ const expectedConfigBytes = createClusterConfigFromServerInput(parsed.hosts);
2399
+ if (!fs.existsSync(configPath)) {
2400
+ createProtectedBytesExclusive(configPath, expectedConfigBytes, { stateStore });
2401
+ }
2402
+ const generated = readSafeClusterSource(configPath, {
2403
+ platform: dependencies.platform || process.platform,
2404
+ sourceStateStore: stateStore,
2405
+ });
2406
+ if (!generated.bytes.equals(expectedConfigBytes)) {
2407
+ throw new Error("已有 cluster.yaml 与 servers.txt 自动生成结果不匹配,拒绝采用");
2408
+ }
2409
+ const generatedSha256 = sha256(generated.bytes);
2410
+ persistDriverState({
2411
+ server_input_sha256: inputSha256,
2412
+ generated_config_sha256: generatedSha256,
2413
+ config_sha256: generatedSha256,
2414
+ });
2415
+ config = generated.value;
2416
+ }
2417
+ } else if (driverState.config_source === "generated-template") {
1852
2418
  if (!fs.existsSync(configPath)) {
1853
2419
  const template = createHostClusterTemplate();
1854
2420
  createProtectedBytesExclusive(configPath, template, { stateStore });
@@ -1882,7 +2448,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1882
2448
  }
1883
2449
  config = diagnostic.value;
1884
2450
  } else if (driverState.config_source === "imported-file") {
1885
- if (!fs.existsSync(configPath)) {
2451
+ const readImportSource = () => {
1886
2452
  const requestedSource = String(options.clusterConfig || driverState.import_source_path || "").trim();
1887
2453
  if (!requestedSource) throw new Error("外部 cluster.yaml 来源缺失,无法继续导入");
1888
2454
  const sourcePath = path.resolve(requestedSource);
@@ -1892,18 +2458,55 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1892
2458
  sourceStateStore: stateStore,
1893
2459
  });
1894
2460
  validateClusterTopology(source.value);
1895
- const sourceSha256 = sha256(source.bytes);
1896
- if (driverState.import_source_sha256 && driverState.import_source_sha256 !== sourceSha256) {
2461
+ return { ...source, path: sourcePath, sha256: sha256(source.bytes) };
2462
+ };
2463
+
2464
+ let protectedCopy;
2465
+ if (!fs.existsSync(configPath)) {
2466
+ const source = readImportSource();
2467
+ if (driverState.import_source_sha256 && driverState.import_source_sha256 !== source.sha256) {
1897
2468
  throw new Error("外部 cluster.yaml 在导入恢复期间发生变化,拒绝继续");
1898
2469
  }
1899
- if (!driverState.import_source_sha256) persistDriverState({ import_source_path: sourcePath, import_source_sha256: sourceSha256 });
1900
2470
  createProtectedBytesExclusive(configPath, source.bytes, { stateStore });
2471
+ protectedCopy = readSafeClusterSource(configPath, {
2472
+ platform: dependencies.platform || process.platform,
2473
+ sourceStateStore: stateStore,
2474
+ });
2475
+ if (!protectedCopy.bytes.equals(source.bytes)) {
2476
+ throw new Error("受保护的导入 cluster.yaml 副本与外部 source 字节不匹配,拒绝继续");
2477
+ }
2478
+ persistDriverState({
2479
+ import_source_path: source.path,
2480
+ import_source_sha256: source.sha256,
2481
+ config_sha256: source.sha256,
2482
+ });
2483
+ } else {
2484
+ stateStore.assertProtectedRegularFile(configPath);
2485
+ protectedCopy = readSafeClusterSource(configPath, {
2486
+ platform: dependencies.platform || process.platform,
2487
+ sourceStateStore: stateStore,
2488
+ });
2489
+ const protectedSha256 = sha256(protectedCopy.bytes);
2490
+ if (driverState.import_source_sha256) {
2491
+ if (driverState.import_source_sha256 !== protectedSha256) {
2492
+ throw new Error("受保护的导入 cluster.yaml 副本与已锁定 source 摘要不匹配,拒绝继续");
2493
+ }
2494
+ } else {
2495
+ if (!driverState.import_source_path) {
2496
+ throw new Error("导入来源状态不完整,缺少受信任的外部 cluster.yaml 路径");
2497
+ }
2498
+ const source = readImportSource();
2499
+ if (!protectedCopy.bytes.equals(source.bytes)) {
2500
+ throw new Error("受保护的导入 cluster.yaml 副本与外部 source 字节不匹配,拒绝继续");
2501
+ }
2502
+ persistDriverState({
2503
+ import_source_path: source.path,
2504
+ import_source_sha256: source.sha256,
2505
+ config_sha256: source.sha256,
2506
+ });
2507
+ }
1901
2508
  }
1902
- stateStore.assertProtectedRegularFile(configPath);
1903
- config = readSafeClusterSource(configPath, {
1904
- platform: dependencies.platform || process.platform,
1905
- sourceStateStore: stateStore,
1906
- }).value;
2509
+ config = protectedCopy.value;
1907
2510
  } else {
1908
2511
  throw new Error("不支持的主机集群配置来源");
1909
2512
  }
@@ -1920,7 +2523,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1920
2523
  if (driverState.config_sha256 && driverState.config_sha256 !== configSha256) throw new Error("恢复时 cluster.yaml 字节发生变化,拒绝继续");
1921
2524
  if (!driverState.config_sha256) persistDriverState({ config_sha256: configSha256 });
1922
2525
  if (driverState.stage === "configuration") {
1923
- write(`\n[RAINSKILLS_STATUS:host_cluster_config_valid]\n集群配置检查通过:\n- 节点:${topology.hosts.length} 个\n- etcd:${topology.roleGroups.etcd.length} 个\n- bootstrap:${topology.bootstrap.name}\n- 存储模式:${topology.storageMode}\n\n正在准备所有服务器的 SSH 连接。\n`);
2526
+ write(renderValidatedTopologyStatus(summarizeTopology(config), configPath));
1924
2527
  }
1925
2528
  const prepared = await prepareHostSshSessions(topology, { ...dependencies, configPath });
1926
2529
  const closeSessions = () => {
@@ -1983,6 +2586,7 @@ async function installHostCluster({ onboarding, state, paths, options }, depende
1983
2586
  resumeArgv,
1984
2587
  write: dependencies.write,
1985
2588
  abortState: dependencies.abortState,
2589
+ sensitiveValues: topology.hosts.map(({ password }) => password),
1986
2590
  });
1987
2591
  } catch (error) {
1988
2592
  if (error.code !== "RAINSKILLS_HOST_CLUSTER_INTERRUPTED") throw error;
@@ -2111,7 +2715,9 @@ module.exports = {
2111
2715
  acquireRoiArtifact,
2112
2716
  atomicWriteProtectedBytes,
2113
2717
  confirmRoiInstall,
2718
+ createClusterConfigFromServerInput,
2114
2719
  createHostClusterTemplate,
2720
+ createHostServerInputTemplate,
2115
2721
  createProtectedBytesExclusive,
2116
2722
  defaultSshRunner,
2117
2723
  defaultTransfer,
@@ -2122,14 +2728,17 @@ module.exports = {
2122
2728
  inspectRemoteCluster,
2123
2729
  installHostCluster,
2124
2730
  parseClusterDocument,
2731
+ parseHostServerInput,
2125
2732
  probeRemoteArchitecture,
2126
2733
  probeRemoteRoiVersion,
2127
2734
  prepareHostSshSessions,
2128
2735
  probeConsole,
2736
+ readProtectedServerInput,
2129
2737
  readSafeClusterSource,
2130
2738
  reconcileHostExecution,
2131
2739
  redactInstallLog,
2132
2740
  renderHostClusterConfigPrompt,
2741
+ renderHostServerInputPrompt,
2133
2742
  renderHostClusterSshAuthenticationPrompt,
2134
2743
  renderConfirmationSummary,
2135
2744
  runClusterWizard,