mcp-aws-manager 0.3.4 → 0.3.6

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.
@@ -46,6 +46,147 @@ function parseCsv(raw) {
46
46
  return list.length ? list : null;
47
47
  }
48
48
 
49
+ function normalizeKey(value) {
50
+ return String(value == null ? "" : value)
51
+ .trim()
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]/g, "");
54
+ }
55
+
56
+ function normalizedLookup(raw) {
57
+ const out = {};
58
+ if (!raw || typeof raw !== "object") return out;
59
+ for (const [key, value] of Object.entries(raw)) {
60
+ const original = String(key || "");
61
+ out[original] = value;
62
+ const normalized = normalizeKey(original);
63
+ if (normalized && !Object.prototype.hasOwnProperty.call(out, normalized)) {
64
+ out[normalized] = value;
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function pickField(raw, keys) {
71
+ const lookup = normalizedLookup(raw);
72
+ for (const key of keys) {
73
+ const normalized = normalizeKey(key);
74
+ if (!normalized) continue;
75
+ if (!Object.prototype.hasOwnProperty.call(lookup, normalized)) continue;
76
+ const value = lookup[normalized];
77
+ if (value == null) continue;
78
+ if (typeof value === "string" && value.trim() === "") continue;
79
+ return value;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function asText(value) {
85
+ if (value == null) return null;
86
+ const text = String(value).trim();
87
+ return text || null;
88
+ }
89
+
90
+ function asBool(value) {
91
+ if (value == null || value === "") return null;
92
+ if (typeof value === "boolean") return value;
93
+ if (typeof value === "number") return value !== 0;
94
+ const text = String(value).trim().toLowerCase();
95
+ if (!text) return null;
96
+ if (["1", "true", "yes", "y", "on"].includes(text)) return true;
97
+ if (["0", "false", "no", "n", "off"].includes(text)) return false;
98
+ return null;
99
+ }
100
+
101
+ function asInt(value) {
102
+ if (value == null || value === "") return null;
103
+ const num = Number(value);
104
+ if (!Number.isFinite(num)) return null;
105
+ return Math.round(num);
106
+ }
107
+
108
+ function parseCsvLine(line) {
109
+ const fields = [];
110
+ let current = "";
111
+ let inQuotes = false;
112
+ const text = String(line || "");
113
+ for (let i = 0; i < text.length; i += 1) {
114
+ const ch = text[i];
115
+ if (ch === "\"") {
116
+ if (inQuotes && text[i + 1] === "\"") {
117
+ current += "\"";
118
+ i += 1;
119
+ } else {
120
+ inQuotes = !inQuotes;
121
+ }
122
+ continue;
123
+ }
124
+ if (ch === "," && !inQuotes) {
125
+ fields.push(current.trim());
126
+ current = "";
127
+ continue;
128
+ }
129
+ current += ch;
130
+ }
131
+ fields.push(current.trim());
132
+ return fields;
133
+ }
134
+
135
+ function parseCsvObjects(text) {
136
+ const lines = String(text || "")
137
+ .replace(/^\uFEFF/, "")
138
+ .split(/\r?\n/)
139
+ .map((line) => line.trim())
140
+ .filter((line) => line && !line.startsWith("#"));
141
+ if (!lines.length) return [];
142
+
143
+ const headers = parseCsvLine(lines[0]).map((h, i) => h || `col_${i + 1}`);
144
+ const out = [];
145
+ for (let i = 1; i < lines.length; i += 1) {
146
+ const values = parseCsvLine(lines[i]);
147
+ if (!values.some((v) => String(v || "").trim())) continue;
148
+ const row = {};
149
+ for (let j = 0; j < headers.length; j += 1) {
150
+ row[headers[j]] = values[j] != null ? values[j] : "";
151
+ }
152
+ out.push(row);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function loadManualServerEntries(filePath) {
158
+ const text = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
159
+ const ext = path.extname(filePath).toLowerCase();
160
+ if (ext === ".csv") {
161
+ return parseCsvObjects(text);
162
+ }
163
+
164
+ let parsed = null;
165
+ try {
166
+ parsed = JSON.parse(text);
167
+ } catch (error) {
168
+ if (ext === ".json") {
169
+ throw new Error(`Invalid JSON in manual server list: ${error.message || String(error)}`);
170
+ }
171
+ return parseCsvObjects(text);
172
+ }
173
+
174
+ if (Array.isArray(parsed)) return parsed;
175
+ if (parsed && typeof parsed === "object") {
176
+ if (Array.isArray(parsed.servers)) return parsed.servers;
177
+ if (Array.isArray(parsed.instances)) return parsed.instances;
178
+ if (Array.isArray(parsed.items)) return parsed.items;
179
+ if (Array.isArray(parsed.records)) return parsed.records;
180
+ }
181
+ throw new Error("manual server list JSON must be an array or an object containing servers/instances/items/records array.");
182
+ }
183
+
184
+ function resolvePathList(raw) {
185
+ const items = parseCsv(raw);
186
+ if (!items) return null;
187
+ return items.map((value) => path.resolve(expandHome(value)));
188
+ }
189
+
49
190
  function toPosNum(raw, name, fallback) {
50
191
  const value = Number(raw == null ? fallback : raw);
51
192
  if (!Number.isFinite(value) || value <= 0) {
@@ -120,6 +261,13 @@ function usageText() {
120
261
  " --snapshot-timeout <seconds> (default: 60)",
121
262
  " --snapshot-concurrency <n> (default: 3)",
122
263
  " --snapshot-max-kb <n> (default: 64)",
264
+ " --manual-server-list <path> (JSON/CSV server list for manual inventory mode)",
265
+ " --pem-paths <a,b,c> (optional PEM keys for SSH runtime snapshot)",
266
+ " --ssh-user <name> (default: ec2-user)",
267
+ " --ssh-port <port> (default: 22)",
268
+ " --ssh-connect-timeout <seconds> (default: 8)",
269
+ " --html-out <path> (optional interactive HTML report output path)",
270
+ " --open-html (try opening HTML report after write)",
123
271
  " --auto-sso-login / --no-auto-sso-login",
124
272
  " --format <json|csv> (default: json)",
125
273
  " --out <path>",
@@ -135,6 +283,9 @@ function usageText() {
135
283
  " MCP_AWS_ALLOW_REPLACE_PROFILE, MCP_AWS_REMEDIATION_WAIT",
136
284
  " MCP_AWS_RUNTIME_SNAPSHOT, MCP_AWS_SNAPSHOT_TIMEOUT, MCP_AWS_SNAPSHOT_CONCURRENCY",
137
285
  " MCP_AWS_SNAPSHOT_MAX_KB, MCP_AWS_AUTO_SSO_LOGIN",
286
+ " MCP_AWS_MANUAL_SERVER_LIST, MCP_AWS_PEM_PATHS, MCP_AWS_SSH_USER, MCP_AWS_SSH_PORT",
287
+ " MCP_AWS_SSH_CONNECT_TIMEOUT",
288
+ " MCP_AWS_HTML_OUT, MCP_AWS_OPEN_HTML",
138
289
  " MCP_AWS_FORMAT, MCP_AWS_OUT, MCP_AWS_NO_PROGRESS",
139
290
  ""
140
291
  ].join("\n");
@@ -287,6 +438,13 @@ function parseDiscoverArgs(argv) {
287
438
  snapshotTimeoutSec: null,
288
439
  snapshotConcurrency: null,
289
440
  snapshotMaxKb: null,
441
+ manualServerListPath: null,
442
+ pemPaths: null,
443
+ sshUser: null,
444
+ sshPort: null,
445
+ sshConnectTimeoutSec: null,
446
+ htmlOutPath: null,
447
+ openHtml: null,
290
448
  autoSsoLogin: null,
291
449
  format: null,
292
450
  outPath: null,
@@ -305,6 +463,12 @@ function parseDiscoverArgs(argv) {
305
463
  case "snapshot-timeout": options.snapshotTimeoutSec = value; break;
306
464
  case "snapshot-concurrency": options.snapshotConcurrency = value; break;
307
465
  case "snapshot-max-kb": options.snapshotMaxKb = value; break;
466
+ case "manual-server-list": options.manualServerListPath = value; break;
467
+ case "pem-paths": options.pemPaths = value; break;
468
+ case "ssh-user": options.sshUser = value; break;
469
+ case "ssh-port": options.sshPort = value; break;
470
+ case "ssh-connect-timeout": options.sshConnectTimeoutSec = value; break;
471
+ case "html-out": options.htmlOutPath = value; break;
308
472
  case "format": options.format = value; break;
309
473
  case "out": options.outPath = value; break;
310
474
  default: throw new Error(`Unknown discover option --${key}`);
@@ -337,6 +501,7 @@ function parseDiscoverArgs(argv) {
337
501
  if (arg === "--no-runtime-snapshot") { options.runtimeSnapshot = false; continue; }
338
502
  if (arg === "--auto-sso-login") { options.autoSsoLogin = true; continue; }
339
503
  if (arg === "--no-auto-sso-login") { options.autoSsoLogin = false; continue; }
504
+ if (arg === "--open-html") { options.openHtml = true; continue; }
340
505
  if (arg === "--progress") { options.noProgress = false; continue; }
341
506
  if (arg === "--no-progress") { options.noProgress = true; continue; }
342
507
  if (!arg.startsWith("--")) throw new Error(`Unexpected argument: ${arg}`);
@@ -387,6 +552,17 @@ function parseDiscoverArgs(argv) {
387
552
  snapshotTimeoutSec: Math.round(toPosNum(options.snapshotTimeoutSec || envText("MCP_AWS_SNAPSHOT_TIMEOUT") || "60", "--snapshot-timeout", 60)),
388
553
  snapshotConcurrency: Math.max(1, Math.round(toPosNum(options.snapshotConcurrency || envText("MCP_AWS_SNAPSHOT_CONCURRENCY") || DEFAULT_SNAPSHOT_CONCURRENCY, "--snapshot-concurrency", DEFAULT_SNAPSHOT_CONCURRENCY))),
389
554
  snapshotMaxBytes: Math.round(toPosNum(options.snapshotMaxKb || envText("MCP_AWS_SNAPSHOT_MAX_KB") || "64", "--snapshot-max-kb", 64)) * 1024,
555
+ manualServerListPath: options.manualServerListPath
556
+ ? path.resolve(expandHome(options.manualServerListPath))
557
+ : (envText("MCP_AWS_MANUAL_SERVER_LIST") ? path.resolve(expandHome(envText("MCP_AWS_MANUAL_SERVER_LIST"))) : null),
558
+ pemPaths: resolvePathList(options.pemPaths) || resolvePathList(envText("MCP_AWS_PEM_PATHS")),
559
+ sshUser: (options.sshUser || envText("MCP_AWS_SSH_USER") || "ec2-user").trim(),
560
+ sshPort: Math.round(toPosNum(options.sshPort || envText("MCP_AWS_SSH_PORT") || "22", "--ssh-port", 22)),
561
+ sshConnectTimeoutSec: Math.round(toPosNum(options.sshConnectTimeoutSec || envText("MCP_AWS_SSH_CONNECT_TIMEOUT") || "8", "--ssh-connect-timeout", 8)),
562
+ htmlOutPath: options.htmlOutPath
563
+ ? path.resolve(expandHome(options.htmlOutPath))
564
+ : (envText("MCP_AWS_HTML_OUT") ? path.resolve(expandHome(envText("MCP_AWS_HTML_OUT"))) : null),
565
+ openHtml: options.openHtml != null ? options.openHtml : envBool("MCP_AWS_OPEN_HTML"),
390
566
  autoSsoLogin: options.autoSsoLogin != null ? options.autoSsoLogin : (envText("MCP_AWS_AUTO_SSO_LOGIN") != null ? envBool("MCP_AWS_AUTO_SSO_LOGIN") : true),
391
567
  format,
392
568
  outPath: options.outPath ? path.resolve(expandHome(options.outPath)) : (envText("MCP_AWS_OUT") ? path.resolve(expandHome(envText("MCP_AWS_OUT"))) : null),
@@ -398,6 +574,52 @@ function validateConfig(config, warnings, requiredActions) {
398
574
  if (config.outPath) {
399
575
  fs.mkdirSync(path.dirname(config.outPath), { recursive: true });
400
576
  }
577
+ if (config.htmlOutPath) {
578
+ fs.mkdirSync(path.dirname(config.htmlOutPath), { recursive: true });
579
+ }
580
+
581
+ if (config.manualServerListPath) {
582
+ if (!fs.existsSync(config.manualServerListPath)) {
583
+ throw new Error(`--manual-server-list not found: ${config.manualServerListPath}`);
584
+ }
585
+
586
+ if (!config.includeEc2) {
587
+ warnings.push("manual-server-list mode forces EC2 records; enabling include-ec2.");
588
+ config.includeEc2 = true;
589
+ }
590
+
591
+ if (config.includeLambda || config.includeAlb || config.includeAsg || config.includeRds || config.includeElastiCache || config.includeRoute53) {
592
+ warnings.push("manual-server-list mode supports EC2-style server records only; non-EC2 inventory flags are ignored.");
593
+ config.includeLambda = false;
594
+ config.includeAlb = false;
595
+ config.includeAsg = false;
596
+ config.includeRds = false;
597
+ config.includeElastiCache = false;
598
+ config.includeRoute53 = false;
599
+ }
600
+
601
+ if (config.managedOnly) {
602
+ warnings.push("manual-server-list mode does not use SSM managed filters; disabling --managed-only.");
603
+ config.managedOnly = false;
604
+ }
605
+
606
+ if (config.autoRemediateSsm) {
607
+ warnings.push("manual-server-list mode does not support EC2 IAM profile remediation; disabling --auto-remediate-ssm.");
608
+ config.autoRemediateSsm = false;
609
+ }
610
+
611
+ if (Array.isArray(config.pemPaths) && config.pemPaths.length) {
612
+ for (const pemPath of config.pemPaths) {
613
+ if (fs.existsSync(pemPath)) continue;
614
+ pushRequiredAction(requiredActions, {
615
+ code: "PEM_KEY_NOT_FOUND",
616
+ message: `Configured PEM key not found: ${pemPath}`,
617
+ hint: "Fix --pem-paths or remove missing PEM paths."
618
+ });
619
+ }
620
+ }
621
+ }
622
+
401
623
  const includeAny = config.includeEc2
402
624
  || config.includeLambda
403
625
  || config.includeAlb
@@ -830,7 +1052,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
830
1052
  pushRequiredAction(requiredActions, {
831
1053
  code: "SSO_REAUTH_REQUIRED",
832
1054
  message: `Profile '${profileLabel}' still cannot authenticate after auto login.`,
833
- hint: `Run 'aws sso login --profile ${profileName}'. Error: ${retryErr.message || String(retryErr)}`
1055
+ hint: `Run 'aws sso login --profile ${profileName}'. If SSO/access key cannot be used, switch to manual fallback with --manual-server-list <path>. Error: ${retryErr.message || String(retryErr)}`
834
1056
  });
835
1057
  return { ok: false, accountId: null };
836
1058
  }
@@ -839,7 +1061,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
839
1061
  pushRequiredAction(requiredActions, {
840
1062
  code: "SSO_LOGIN_NEEDED",
841
1063
  message: `Profile '${profileLabel}' requires interactive SSO login.`,
842
- hint: `Run 'aws sso login --profile ${profileName}'. CLI: ${(login.stderr || "").trim() || "failed"}`
1064
+ hint: `Run 'aws sso login --profile ${profileName}'. If SSO/access key cannot be used, use manual fallback with --manual-server-list <path>. CLI: ${(login.stderr || "").trim() || "failed"}`
843
1065
  });
844
1066
  return { ok: false, accountId: null };
845
1067
  }
@@ -847,7 +1069,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
847
1069
  pushRequiredAction(requiredActions, {
848
1070
  code: "AWS_CREDENTIALS_REQUIRED",
849
1071
  message: `Profile '${profileLabel}' authentication failed.`,
850
- hint: firstError
1072
+ hint: `Configure SSO/access key, or use manual fallback with --manual-server-list <path>. Error: ${firstError}`
851
1073
  });
852
1074
  return { ok: false, accountId: null };
853
1075
  } finally {
@@ -902,6 +1124,15 @@ async function buildDiscoveryPlan(config, warnings, requiredActions) {
902
1124
  }
903
1125
 
904
1126
  function buildOperationPlan(config) {
1127
+ if (config.manualServerListPath) {
1128
+ const inventoryOps = ["inventory.manualServerList"];
1129
+ const runtimeOps = [];
1130
+ if (config.runtimeSnapshot) {
1131
+ runtimeOps.push("runtime.snapshot.sshPem");
1132
+ }
1133
+ return { inventoryOps, runtimeOps };
1134
+ }
1135
+
905
1136
  const inventoryOps = [];
906
1137
  if (config.includeEc2) {
907
1138
  inventoryOps.push("inventory.ec2");
@@ -1123,6 +1354,13 @@ function baseInventoryRecord(plan, region, resourceType, service, resourceId, na
1123
1354
  ssmPlatformName: null,
1124
1355
  ssmPlatformVersion: null,
1125
1356
  remediationStatus: null,
1357
+ manualInput: false,
1358
+ connectionMode: null,
1359
+ sshHost: null,
1360
+ sshUser: null,
1361
+ sshPort: null,
1362
+ sshPemPath: null,
1363
+ sshPemKeyName: null,
1126
1364
  runtimeSnapshot: null,
1127
1365
  lambdaFunctionName: null,
1128
1366
  lambdaFunctionArn: null,
@@ -1306,6 +1544,131 @@ function addRoute53ZoneRecord(records, plan, zone, recordSetCount) {
1306
1544
  records.push(record);
1307
1545
  }
1308
1546
 
1547
+ function normalizeManualRecord(config, entry, index) {
1548
+ const profile = asText(pickField(entry, ["profile", "awsProfile", "profileName"])) || "manual";
1549
+ const accountId = asText(pickField(entry, ["accountId", "account", "accountNumber"])) || "manual";
1550
+ const region = asText(pickField(entry, ["region", "awsRegion"])) || (config.regions && config.regions.length ? config.regions[0] : baseRegion());
1551
+
1552
+ const instanceId = asText(pickField(entry, ["instanceId", "instance", "id", "serverId"]));
1553
+ const publicIp = asText(pickField(entry, ["publicIp", "publicIpv4", "ip", "ipAddress"]));
1554
+ const privateIp = asText(pickField(entry, ["privateIp", "privateIpv4", "privateAddress"]));
1555
+ const publicDns = asText(pickField(entry, ["publicDns", "dns", "publicHostname"]));
1556
+ const host = asText(pickField(entry, ["host", "hostname", "address", "target", "endpoint", "sshHost"]))
1557
+ || publicIp
1558
+ || publicDns
1559
+ || privateIp;
1560
+ const name = asText(pickField(entry, ["name", "serverName", "displayName"])) || host || instanceId || `manual-${index + 1}`;
1561
+ const state = asText(pickField(entry, ["state", "status"])) || "unknown";
1562
+ const platform = asText(pickField(entry, ["platform", "os", "osType"])) || "Linux/UNIX";
1563
+ const iamInstanceProfileArn = asText(pickField(entry, ["iamInstanceProfileArn", "instanceProfileArn"]));
1564
+ const pemPathRaw = asText(pickField(entry, ["pemPath", "pemFile", "keyPath", "privateKeyPath"]));
1565
+ const pemKeyName = asText(pickField(entry, ["pemKeyName", "keyName"]));
1566
+ const sshUser = asText(pickField(entry, ["sshUser", "user", "username"])) || config.sshUser;
1567
+ const sshPort = asInt(pickField(entry, ["sshPort", "port"])) || config.sshPort;
1568
+ const ssmManaged = asBool(pickField(entry, ["ssmManaged", "isSsmManaged"]));
1569
+ const ssmOnline = asBool(pickField(entry, ["ssmOnline", "isSsmOnline", "online"]));
1570
+ const resourceId = instanceId || host || name || `manual-${index + 1}`;
1571
+
1572
+ const plan = { profileLabel: profile, accountId };
1573
+ const record = baseInventoryRecord(plan, region, "ec2", "ec2", resourceId, name, state, platform);
1574
+ record.instanceId = instanceId || resourceId;
1575
+ record.privateIp = privateIp;
1576
+ record.publicIp = publicIp;
1577
+ record.publicDns = publicDns;
1578
+ record.iamInstanceProfileArn = iamInstanceProfileArn;
1579
+ record.ssmManaged = ssmManaged == null ? false : ssmManaged;
1580
+ record.ssmOnline = ssmOnline == null ? false : ssmOnline;
1581
+ record.manualInput = true;
1582
+ record.connectionMode = "ssh-pem";
1583
+ record.sshHost = host;
1584
+ record.sshUser = sshUser;
1585
+ record.sshPort = sshPort;
1586
+ record.sshPemPath = pemPathRaw ? path.resolve(expandHome(pemPathRaw)) : null;
1587
+ record.sshPemKeyName = pemKeyName;
1588
+ return record;
1589
+ }
1590
+
1591
+ async function collectManualInventory(config, warnings, requiredActions) {
1592
+ const stats = {
1593
+ profiles: 0,
1594
+ regions: 0,
1595
+ regionErrors: 0,
1596
+ instancesScanned: 0,
1597
+ lambdaFunctionsScanned: 0,
1598
+ loadBalancersScanned: 0,
1599
+ targetGroupsScanned: 0,
1600
+ autoScalingGroupsScanned: 0,
1601
+ rdsInstancesScanned: 0,
1602
+ elasticacheClustersScanned: 0,
1603
+ route53ZonesScanned: 0,
1604
+ remediationAttempts: 0,
1605
+ remediationChanged: 0,
1606
+ manualServersScanned: 0,
1607
+ manualServersLoaded: 0
1608
+ };
1609
+ const records = [];
1610
+ const rows = loadManualServerEntries(config.manualServerListPath);
1611
+ stats.manualServersScanned = rows.length;
1612
+ const profileSet = new Set();
1613
+ const regionSet = new Set();
1614
+ const instanceFilter = config.instanceIds ? new Set(config.instanceIds) : null;
1615
+
1616
+ if (!rows.length) {
1617
+ pushRequiredAction(requiredActions, {
1618
+ code: "MANUAL_SERVER_LIST_EMPTY",
1619
+ message: "manual-server-list file is empty.",
1620
+ hint: "Provide JSON/CSV rows with at least host/publicIp/privateIp and optional pemPath."
1621
+ });
1622
+ return { records, stats };
1623
+ }
1624
+
1625
+ rows.forEach((entry, index) => {
1626
+ if (!entry || typeof entry !== "object") {
1627
+ warnings.push(`manual-server-list row ${index + 1} is not an object; skipped.`);
1628
+ return;
1629
+ }
1630
+
1631
+ stats.instancesScanned += 1;
1632
+ const record = normalizeManualRecord(config, entry, index);
1633
+
1634
+ if (instanceFilter && !instanceFilter.has(record.instanceId)) {
1635
+ return;
1636
+ }
1637
+ if (config.publicOnly && !record.publicIp) {
1638
+ return;
1639
+ }
1640
+
1641
+ if (!record.sshHost) {
1642
+ pushRequiredAction(requiredActions, {
1643
+ code: "MANUAL_SERVER_HOST_REQUIRED",
1644
+ message: `No SSH host could be resolved for manual record '${record.instanceId}'.`,
1645
+ hint: "Set host/publicIp/privateIp/publicDns in manual server list."
1646
+ });
1647
+ }
1648
+
1649
+ if (record.sshPemPath && !fs.existsSync(record.sshPemPath)) {
1650
+ pushRequiredAction(requiredActions, {
1651
+ code: "PEM_KEY_NOT_FOUND",
1652
+ message: `Configured PEM key not found: ${record.sshPemPath}`,
1653
+ hint: "Fix pemPath in manual server list."
1654
+ });
1655
+ }
1656
+
1657
+ profileSet.add(record.profile || "manual");
1658
+ regionSet.add(record.region || "unknown");
1659
+ records.push(record);
1660
+ });
1661
+
1662
+ stats.manualServersLoaded = records.length;
1663
+ stats.profiles = profileSet.size || 1;
1664
+ stats.regions = regionSet.size || 1;
1665
+
1666
+ if (!records.length) {
1667
+ warnings.push("manual-server-list produced zero records after filters.");
1668
+ }
1669
+ return { records, stats };
1670
+ }
1671
+
1309
1672
  async function collectInventory(config, plans, warnings, requiredActions) {
1310
1673
  const { ec2, ssm, lambda, elbv2, autoScaling, rds, elasticache, route53 } = loadAwsModules();
1311
1674
  const stats = {
@@ -1321,7 +1684,9 @@ async function collectInventory(config, plans, warnings, requiredActions) {
1321
1684
  elasticacheClustersScanned: 0,
1322
1685
  route53ZonesScanned: 0,
1323
1686
  remediationAttempts: 0,
1324
- remediationChanged: 0
1687
+ remediationChanged: 0,
1688
+ manualServersScanned: 0,
1689
+ manualServersLoaded: 0
1325
1690
  };
1326
1691
  const records = [];
1327
1692
 
@@ -1696,7 +2061,233 @@ async function waitInvocation(ssmClient, commandId, instanceId, timeoutMs) {
1696
2061
  }
1697
2062
 
1698
2063
  return { ok: false, error: new Error("Timed out waiting for command invocation") };
1699
- }
2064
+ }
2065
+
2066
+ function normalizePemName(value) {
2067
+ const base = path.basename(String(value || ""));
2068
+ return base.replace(/\.pem$/i, "").toLowerCase();
2069
+ }
2070
+
2071
+ function resolvePemForRecord(record, config) {
2072
+ if (record.sshPemPath) {
2073
+ const explicit = path.resolve(expandHome(record.sshPemPath));
2074
+ if (fs.existsSync(explicit)) {
2075
+ return explicit;
2076
+ }
2077
+ return null;
2078
+ }
2079
+
2080
+ const configured = Array.isArray(config.pemPaths) ? config.pemPaths : [];
2081
+ const existing = configured.filter((pemPath) => fs.existsSync(pemPath));
2082
+ if (!existing.length) return null;
2083
+ if (existing.length === 1) return existing[0];
2084
+
2085
+ if (record.sshPemKeyName) {
2086
+ const hint = normalizePemName(record.sshPemKeyName);
2087
+ const exact = existing.find((pemPath) => normalizePemName(pemPath) === hint);
2088
+ if (exact) return exact;
2089
+
2090
+ const partial = existing.find((pemPath) => {
2091
+ const base = normalizePemName(pemPath);
2092
+ return base.includes(hint) || hint.includes(base);
2093
+ });
2094
+ if (partial) return partial;
2095
+ }
2096
+
2097
+ return null;
2098
+ }
2099
+
2100
+ function runSshCommand(args, timeoutMs) {
2101
+ return new Promise((resolve) => {
2102
+ const child = spawn("ssh", args, {
2103
+ cwd: process.cwd(),
2104
+ env: process.env,
2105
+ stdio: ["ignore", "pipe", "pipe"]
2106
+ });
2107
+
2108
+ let stdout = "";
2109
+ let stderr = "";
2110
+ let done = false;
2111
+ let timedOut = false;
2112
+
2113
+ if (child.stdout) {
2114
+ child.stdout.setEncoding("utf8");
2115
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
2116
+ }
2117
+ if (child.stderr) {
2118
+ child.stderr.setEncoding("utf8");
2119
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
2120
+ }
2121
+
2122
+ const timer = setTimeout(() => {
2123
+ if (done) return;
2124
+ timedOut = true;
2125
+ done = true;
2126
+ try { child.kill("SIGTERM"); } catch {}
2127
+ resolve({ ok: false, code: null, stdout, stderr: stderr || "ssh timed out", timedOut, spawnError: null });
2128
+ }, timeoutMs);
2129
+ timer.unref();
2130
+
2131
+ child.on("error", (error) => {
2132
+ if (done) return;
2133
+ done = true;
2134
+ clearTimeout(timer);
2135
+ resolve({
2136
+ ok: false,
2137
+ code: null,
2138
+ stdout,
2139
+ stderr,
2140
+ timedOut,
2141
+ spawnError: error && error.message ? error.message : String(error)
2142
+ });
2143
+ });
2144
+
2145
+ child.on("close", (code) => {
2146
+ if (done) return;
2147
+ done = true;
2148
+ clearTimeout(timer);
2149
+ resolve({ ok: code === 0, code, stdout, stderr, timedOut, spawnError: null });
2150
+ });
2151
+ });
2152
+ }
2153
+
2154
+ function sshSnapshotCommand(record) {
2155
+ if (!isWindowsRecord(record)) {
2156
+ return linuxSnapshotCommands().join(" ; ");
2157
+ }
2158
+
2159
+ const script = windowsSnapshotCommands().join("; ").replace(/"/g, "\\\"");
2160
+ return `powershell -NoProfile -NonInteractive -Command "${script}"`;
2161
+ }
2162
+
2163
+ async function snapshotRecordViaSsh(record, config, warnings, requiredActions, sshAvailable) {
2164
+ const host = asText(record.sshHost) || asText(record.publicIp) || asText(record.publicDns) || asText(record.privateIp);
2165
+ const user = asText(record.sshUser) || config.sshUser || "ec2-user";
2166
+ const port = asInt(record.sshPort) || config.sshPort || 22;
2167
+ const collectedAt = new Date().toISOString();
2168
+
2169
+ if (!host) {
2170
+ record.runtimeSnapshot = {
2171
+ status: "skipped-no-host",
2172
+ collectedAt,
2173
+ output: "",
2174
+ errorOutput: "No host/publicIp/privateIp/publicDns"
2175
+ };
2176
+ pushRequiredAction(requiredActions, {
2177
+ code: "MANUAL_SERVER_HOST_REQUIRED",
2178
+ message: `No SSH host could be resolved for manual record '${record.instanceId}'.`,
2179
+ hint: "Set host/publicIp/privateIp/publicDns in manual server list."
2180
+ });
2181
+ return;
2182
+ }
2183
+
2184
+ if (!sshAvailable) {
2185
+ record.runtimeSnapshot = {
2186
+ status: "failed-no-ssh-client",
2187
+ collectedAt,
2188
+ output: "",
2189
+ errorOutput: "ssh command not found in PATH"
2190
+ };
2191
+ pushRequiredAction(requiredActions, {
2192
+ code: "SSH_CLIENT_NOT_FOUND",
2193
+ message: "OpenSSH client is not available in PATH.",
2194
+ hint: "Install OpenSSH client and verify 'ssh -V' works."
2195
+ });
2196
+ return;
2197
+ }
2198
+
2199
+ let pemPath = resolvePemForRecord(record, config);
2200
+ let explicitPemMissing = false;
2201
+ if (!pemPath && record.sshPemPath) {
2202
+ const explicit = path.resolve(expandHome(record.sshPemPath));
2203
+ if (!fs.existsSync(explicit)) {
2204
+ explicitPemMissing = true;
2205
+ pushRequiredAction(requiredActions, {
2206
+ code: "PEM_KEY_NOT_FOUND",
2207
+ message: `Configured PEM key not found: ${explicit}`,
2208
+ hint: "Fix pemPath in manual server list or --pem-paths."
2209
+ });
2210
+ }
2211
+ }
2212
+
2213
+ if (!pemPath) {
2214
+ record.runtimeSnapshot = {
2215
+ status: "skipped-no-pem",
2216
+ collectedAt,
2217
+ output: "",
2218
+ errorOutput: "No PEM mapping was resolved for this server."
2219
+ };
2220
+ if (!explicitPemMissing) {
2221
+ pushRequiredAction(requiredActions, {
2222
+ code: "PEM_MAPPING_REQUIRED",
2223
+ message: `No PEM key mapping for manual server '${record.instanceId}' (${host}).`,
2224
+ hint: "Set pemPath per server or pass --pem-paths (single key or keyName-matchable list)."
2225
+ });
2226
+ }
2227
+ return;
2228
+ }
2229
+
2230
+ record.sshPemPath = pemPath;
2231
+ const target = `${user}@${host}`;
2232
+ const args = [
2233
+ "-i", pemPath,
2234
+ "-o", "BatchMode=yes",
2235
+ "-o", "StrictHostKeyChecking=accept-new",
2236
+ "-o", `ConnectTimeout=${config.sshConnectTimeoutSec}`,
2237
+ "-p", String(port),
2238
+ target,
2239
+ sshSnapshotCommand(record)
2240
+ ];
2241
+
2242
+ const run = await runSshCommand(args, config.snapshotTimeoutSec * 1000 + 15000);
2243
+ if (run.spawnError) {
2244
+ record.runtimeSnapshot = {
2245
+ status: "failed-spawn",
2246
+ collectedAt,
2247
+ output: "",
2248
+ errorOutput: run.spawnError
2249
+ };
2250
+ pushRequiredAction(requiredActions, {
2251
+ code: "SSH_CLIENT_NOT_FOUND",
2252
+ message: "Failed to start ssh client.",
2253
+ hint: run.spawnError
2254
+ });
2255
+ return;
2256
+ }
2257
+
2258
+ if (run.ok) {
2259
+ record.runtimeSnapshot = {
2260
+ status: "Success",
2261
+ collectedAt,
2262
+ transport: "ssh-pem",
2263
+ host,
2264
+ user,
2265
+ port,
2266
+ output: truncateText(run.stdout || "", config.snapshotMaxBytes),
2267
+ errorOutput: truncateText(run.stderr || "", config.snapshotMaxBytes)
2268
+ };
2269
+ return;
2270
+ }
2271
+
2272
+ const status = run.timedOut ? "failed-timeout" : "failed-ssh";
2273
+ const detail = truncateText((run.stderr || run.stdout || "ssh command failed").trim(), config.snapshotMaxBytes);
2274
+ record.runtimeSnapshot = {
2275
+ status,
2276
+ collectedAt,
2277
+ transport: "ssh-pem",
2278
+ host,
2279
+ user,
2280
+ port,
2281
+ output: truncateText(run.stdout || "", config.snapshotMaxBytes),
2282
+ errorOutput: detail
2283
+ };
2284
+ warnings.push(`[manual/${record.region || "unknown"}/${record.instanceId}] SSH snapshot failed: ${detail}`);
2285
+ pushRequiredAction(requiredActions, {
2286
+ code: "SSH_AUTH_OR_CONNECT_FAILED",
2287
+ message: `SSH snapshot failed for ${record.instanceId} (${target}).`,
2288
+ hint: detail || "Check network/security group/user/pem permissions."
2289
+ });
2290
+ }
1700
2291
 
1701
2292
  async function snapshotRecord(record, planLookup, config, warnings, requiredActions) {
1702
2293
  const { ssm } = loadAwsModules();
@@ -1890,31 +2481,65 @@ async function collectRemediation(config, records, plans, warnings, requiredActi
1890
2481
  }
1891
2482
 
1892
2483
  async function collectSnapshots(config, records, plans, warnings, requiredActions) {
1893
- if (!config.runtimeSnapshot) return { attempted: 0, succeeded: 0 };
1894
- const targets = records.filter((r) => r.ssmOnline === true);
2484
+ if (!config.runtimeSnapshot) {
2485
+ return {
2486
+ attempted: 0,
2487
+ succeeded: 0,
2488
+ ssmAttempted: 0,
2489
+ ssmSucceeded: 0,
2490
+ sshAttempted: 0,
2491
+ sshSucceeded: 0
2492
+ };
2493
+ }
2494
+
2495
+ const ssmTargets = records.filter((r) => r.ssmOnline === true && r.manualInput !== true);
2496
+ const sshTargets = records.filter((r) => r.resourceType === "ec2" && r.manualInput === true);
1895
2497
  const planLookup = new Map(plans.map((p) => [`${p.profileLabel}::${p.accountId}`, p]));
1896
2498
  let attempted = 0;
1897
2499
  let succeeded = 0;
2500
+ let ssmAttempted = 0;
2501
+ let ssmSucceeded = 0;
2502
+ let sshAttempted = 0;
2503
+ let sshSucceeded = 0;
1898
2504
 
1899
- await mapWithConcurrency(targets, config.snapshotConcurrency || DEFAULT_SNAPSHOT_CONCURRENCY, async (record) => {
2505
+ await mapWithConcurrency(ssmTargets, config.snapshotConcurrency || DEFAULT_SNAPSHOT_CONCURRENCY, async (record) => {
1900
2506
  attempted += 1;
2507
+ ssmAttempted += 1;
1901
2508
  await snapshotRecord(record, planLookup, config, warnings, requiredActions);
1902
2509
  if (record.runtimeSnapshot && record.runtimeSnapshot.status === "Success") {
1903
2510
  succeeded += 1;
2511
+ ssmSucceeded += 1;
1904
2512
  }
1905
2513
  });
1906
2514
 
1907
- return { attempted, succeeded };
2515
+ const sshAvailable = sshTargets.length ? commandExists("ssh", ["-V"]) : true;
2516
+ await mapWithConcurrency(sshTargets, config.snapshotConcurrency || DEFAULT_SNAPSHOT_CONCURRENCY, async (record) => {
2517
+ attempted += 1;
2518
+ sshAttempted += 1;
2519
+ await snapshotRecordViaSsh(record, config, warnings, requiredActions, sshAvailable);
2520
+ if (record.runtimeSnapshot && record.runtimeSnapshot.status === "Success") {
2521
+ succeeded += 1;
2522
+ sshSucceeded += 1;
2523
+ }
2524
+ });
2525
+
2526
+ return { attempted, succeeded, ssmAttempted, ssmSucceeded, sshAttempted, sshSucceeded };
1908
2527
  }
1909
2528
 
1910
2529
  async function executeRuntimeOperations(config, records, plans, warnings, requiredActions) {
1911
- const remediation = await collectRemediation(config, records, plans, warnings, requiredActions);
2530
+ const remediation = config.manualServerListPath
2531
+ ? { attempted: 0, changed: 0 }
2532
+ : await collectRemediation(config, records, plans, warnings, requiredActions);
1912
2533
  const snapshots = await collectSnapshots(config, records, plans, warnings, requiredActions);
1913
2534
  return {
1914
2535
  remediationAttempts: remediation.attempted,
1915
2536
  remediationChanged: remediation.changed,
1916
2537
  snapshotAttempted: snapshots.attempted,
1917
- snapshotSucceeded: snapshots.succeeded
2538
+ snapshotSucceeded: snapshots.succeeded,
2539
+ snapshotSsmAttempted: snapshots.ssmAttempted,
2540
+ snapshotSsmSucceeded: snapshots.ssmSucceeded,
2541
+ snapshotSshAttempted: snapshots.sshAttempted,
2542
+ snapshotSshSucceeded: snapshots.sshSucceeded
1918
2543
  };
1919
2544
  }
1920
2545
 
@@ -1957,6 +2582,13 @@ function toCsvRow(record) {
1957
2582
  ssmPlatformName: record.ssmPlatformName,
1958
2583
  ssmPlatformVersion: record.ssmPlatformVersion,
1959
2584
  remediationStatus: record.remediationStatus,
2585
+ manualInput: record.manualInput,
2586
+ connectionMode: record.connectionMode,
2587
+ sshHost: record.sshHost,
2588
+ sshUser: record.sshUser,
2589
+ sshPort: record.sshPort,
2590
+ sshPemPath: record.sshPemPath,
2591
+ sshPemKeyName: record.sshPemKeyName,
1960
2592
  lambdaFunctionName: record.lambdaFunctionName,
1961
2593
  lambdaFunctionArn: record.lambdaFunctionArn,
1962
2594
  lambdaRuntime: record.lambdaRuntime,
@@ -2020,6 +2652,7 @@ function renderOutput(config, records) {
2020
2652
  "profile", "accountId", "region", "instanceId", "name", "state", "privateIp", "publicIp", "publicDns",
2021
2653
  "platform", "iamInstanceProfileArn", "ssmManaged", "ssmOnline", "ssmPingStatus", "ssmLastPingDateTime",
2022
2654
  "ssmAgentVersion", "ssmPlatformName", "ssmPlatformVersion", "remediationStatus",
2655
+ "manualInput", "connectionMode", "sshHost", "sshUser", "sshPort", "sshPemPath", "sshPemKeyName",
2023
2656
  "lambdaFunctionName", "lambdaFunctionArn", "lambdaRuntime", "lambdaHandler", "lambdaRole", "lambdaLastModified",
2024
2657
  "lambdaState", "lambdaPackageType", "lambdaTimeoutSec", "lambdaMemoryMb", "lambdaVpcConfigured",
2025
2658
  "albArn", "albType", "albScheme", "albDnsName", "albVpcId", "albListenerPorts",
@@ -2039,6 +2672,295 @@ function renderOutput(config, records) {
2039
2672
  return lines.join("\n");
2040
2673
  }
2041
2674
 
2675
+ function htmlSafeJson(value) {
2676
+ return JSON.stringify(value)
2677
+ .replace(/</g, "\\u003c")
2678
+ .replace(/>/g, "\\u003e")
2679
+ .replace(/&/g, "\\u0026");
2680
+ }
2681
+
2682
+ function renderHtmlReport(records) {
2683
+ const payload = htmlSafeJson(Array.isArray(records) ? records : []);
2684
+ return `<!doctype html>
2685
+ <html lang="en">
2686
+ <head>
2687
+ <meta charset="utf-8" />
2688
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2689
+ <title>AWS Inventory Report</title>
2690
+ <style>
2691
+ :root {
2692
+ --bg: #0b1220;
2693
+ --panel: #121a2b;
2694
+ --panel-2: #16233a;
2695
+ --text: #eef4ff;
2696
+ --muted: #a9bad8;
2697
+ --line: #27406b;
2698
+ --accent: #2dd4bf;
2699
+ --accent-2: #38bdf8;
2700
+ --warn: #f59e0b;
2701
+ }
2702
+ * { box-sizing: border-box; }
2703
+ body {
2704
+ margin: 0;
2705
+ color: var(--text);
2706
+ font-family: "Segoe UI", "Noto Sans KR", system-ui, sans-serif;
2707
+ background: radial-gradient(1200px 600px at 10% -20%, #1e3a8a 0%, transparent 70%), linear-gradient(160deg, #0b1220 0%, #07101d 100%);
2708
+ min-height: 100vh;
2709
+ }
2710
+ .wrap { max-width: 1280px; margin: 0 auto; padding: 24px; }
2711
+ .hero {
2712
+ background: linear-gradient(135deg, rgba(45, 212, 191, 0.15), rgba(56, 189, 248, 0.12));
2713
+ border: 1px solid var(--line);
2714
+ border-radius: 16px;
2715
+ padding: 20px;
2716
+ backdrop-filter: blur(6px);
2717
+ }
2718
+ .title { font-size: 28px; font-weight: 700; margin: 0; letter-spacing: 0.3px; }
2719
+ .sub { margin: 8px 0 0; color: var(--muted); font-size: 13px; }
2720
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 14px; }
2721
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
2722
+ .card .k { color: var(--muted); font-size: 12px; }
2723
+ .card .v { margin-top: 4px; font-size: 18px; font-weight: 700; }
2724
+ .tools {
2725
+ margin-top: 16px;
2726
+ display: grid;
2727
+ grid-template-columns: 1fr 180px 120px 140px;
2728
+ gap: 10px;
2729
+ }
2730
+ input, select, button {
2731
+ width: 100%;
2732
+ border-radius: 10px;
2733
+ border: 1px solid var(--line);
2734
+ background: var(--panel-2);
2735
+ color: var(--text);
2736
+ padding: 10px 12px;
2737
+ font-size: 14px;
2738
+ }
2739
+ button {
2740
+ cursor: pointer;
2741
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
2742
+ color: #042b33;
2743
+ font-weight: 700;
2744
+ border: none;
2745
+ }
2746
+ .table-wrap {
2747
+ margin-top: 14px;
2748
+ background: rgba(11, 18, 32, 0.65);
2749
+ border: 1px solid var(--line);
2750
+ border-radius: 14px;
2751
+ overflow: auto;
2752
+ max-height: calc(100vh - 320px);
2753
+ }
2754
+ table { width: 100%; border-collapse: collapse; min-width: 980px; }
2755
+ th, td { padding: 9px 10px; border-bottom: 1px solid rgba(39, 64, 107, 0.45); text-align: left; font-size: 13px; }
2756
+ th { position: sticky; top: 0; background: #0d1729; color: #d9e8ff; z-index: 1; }
2757
+ td.muted { color: var(--muted); }
2758
+ .tag {
2759
+ display: inline-block;
2760
+ border-radius: 99px;
2761
+ padding: 2px 8px;
2762
+ font-size: 12px;
2763
+ background: #1f2f4f;
2764
+ border: 1px solid var(--line);
2765
+ }
2766
+ .ok { color: #86efac; }
2767
+ .warn { color: var(--warn); }
2768
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
2769
+ @media (max-width: 900px) {
2770
+ .tools { grid-template-columns: 1fr; }
2771
+ .wrap { padding: 12px; }
2772
+ .title { font-size: 22px; }
2773
+ }
2774
+ </style>
2775
+ </head>
2776
+ <body>
2777
+ <div class="wrap">
2778
+ <section class="hero">
2779
+ <h1 class="title">AWS Inventory GUI</h1>
2780
+ <p class="sub">Interactive local report generated by mcp-aws-manager</p>
2781
+ <div class="grid" id="summary"></div>
2782
+ <div class="tools">
2783
+ <input id="q" type="text" placeholder="Search instance/resource id, name, IP, profile, region..." />
2784
+ <select id="serviceFilter"><option value="">All services</option></select>
2785
+ <button id="btnReset" type="button">Reset</button>
2786
+ <button id="btnCsv" type="button">Download CSV</button>
2787
+ </div>
2788
+ </section>
2789
+ <div class="table-wrap">
2790
+ <table>
2791
+ <thead>
2792
+ <tr>
2793
+ <th>service</th>
2794
+ <th>resourceType</th>
2795
+ <th>resourceId</th>
2796
+ <th>name</th>
2797
+ <th>profile</th>
2798
+ <th>region</th>
2799
+ <th>state</th>
2800
+ <th>privateIp</th>
2801
+ <th>publicIp</th>
2802
+ <th>ssm</th>
2803
+ <th>snapshot</th>
2804
+ </tr>
2805
+ </thead>
2806
+ <tbody id="rows"></tbody>
2807
+ </table>
2808
+ </div>
2809
+ </div>
2810
+ <script>
2811
+ const records = ${payload};
2812
+ const qEl = document.getElementById("q");
2813
+ const serviceEl = document.getElementById("serviceFilter");
2814
+ const rowsEl = document.getElementById("rows");
2815
+ const summaryEl = document.getElementById("summary");
2816
+ const btnReset = document.getElementById("btnReset");
2817
+ const btnCsv = document.getElementById("btnCsv");
2818
+
2819
+ const services = Array.from(new Set(records.map(r => r.service || ""))).filter(Boolean).sort();
2820
+ for (const s of services) {
2821
+ const op = document.createElement("option");
2822
+ op.value = s;
2823
+ op.textContent = s;
2824
+ serviceEl.appendChild(op);
2825
+ }
2826
+
2827
+ function esc(v) {
2828
+ const span = document.createElement("span");
2829
+ span.textContent = v == null ? "" : String(v);
2830
+ return span.innerHTML;
2831
+ }
2832
+
2833
+ function yesNo(v) {
2834
+ if (v === true) return '<span class="tag ok">yes</span>';
2835
+ if (v === false) return '<span class="tag warn">no</span>';
2836
+ return '<span class="tag">n/a</span>';
2837
+ }
2838
+
2839
+ function summarize(list) {
2840
+ const byService = {};
2841
+ for (const r of list) {
2842
+ const key = r.service || "unknown";
2843
+ byService[key] = (byService[key] || 0) + 1;
2844
+ }
2845
+ const cards = [];
2846
+ cards.push({ k: "Total Records", v: list.length });
2847
+ cards.push({ k: "EC2", v: list.filter(r => r.resourceType === "ec2").length });
2848
+ cards.push({ k: "Lambda", v: list.filter(r => r.resourceType === "lambda").length });
2849
+ cards.push({ k: "SSM Online", v: list.filter(r => r.ssmOnline === true).length });
2850
+ cards.push({ k: "With Public IP", v: list.filter(r => !!r.publicIp).length });
2851
+ for (const [k, v] of Object.entries(byService).sort((a, b) => a[0].localeCompare(b[0])).slice(0, 6)) {
2852
+ cards.push({ k: "svc:" + k, v });
2853
+ }
2854
+ return cards;
2855
+ }
2856
+
2857
+ function filterRows() {
2858
+ const q = qEl.value.trim().toLowerCase();
2859
+ const service = serviceEl.value;
2860
+ return records.filter((r) => {
2861
+ if (service && (r.service || "") !== service) return false;
2862
+ if (!q) return true;
2863
+ const bucket = [
2864
+ r.resourceId, r.instanceId, r.name, r.profile, r.region, r.state,
2865
+ r.privateIp, r.publicIp, r.publicDns, r.service, r.resourceType
2866
+ ].map(v => (v == null ? "" : String(v).toLowerCase())).join("|");
2867
+ return bucket.includes(q);
2868
+ });
2869
+ }
2870
+
2871
+ function render() {
2872
+ const list = filterRows();
2873
+ rowsEl.innerHTML = list.map((r) => \`
2874
+ <tr>
2875
+ <td><span class="tag">\${esc(r.service || "")}</span></td>
2876
+ <td>\${esc(r.resourceType || "")}</td>
2877
+ <td class="mono">\${esc(r.resourceId || "")}</td>
2878
+ <td>\${esc(r.name || "")}</td>
2879
+ <td>\${esc(r.profile || "")}</td>
2880
+ <td>\${esc(r.region || "")}</td>
2881
+ <td>\${esc(r.state || "")}</td>
2882
+ <td class="mono muted">\${esc(r.privateIp || "")}</td>
2883
+ <td class="mono">\${esc(r.publicIp || "")}</td>
2884
+ <td>\${yesNo(r.ssmOnline)}</td>
2885
+ <td>\${esc(r.runtimeSnapshot && r.runtimeSnapshot.status ? r.runtimeSnapshot.status : "")}</td>
2886
+ </tr>
2887
+ \`).join("");
2888
+
2889
+ const cards = summarize(list);
2890
+ summaryEl.innerHTML = cards.map(c => \`<div class="card"><div class="k">\${esc(c.k)}</div><div class="v">\${esc(c.v)}</div></div>\`).join("");
2891
+ window.__view = list;
2892
+ }
2893
+
2894
+ function toCsv(list) {
2895
+ const fields = [
2896
+ "resourceType","service","resourceId","instanceId","name","profile","region","state",
2897
+ "privateIp","publicIp","publicDns","ssmManaged","ssmOnline","runtimeSnapshot"
2898
+ ];
2899
+ const escCsv = (v) => {
2900
+ if (v == null) return "";
2901
+ const text = typeof v === "object" ? JSON.stringify(v) : String(v);
2902
+ return /[",\\r\\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
2903
+ };
2904
+ const lines = [fields.join(",")];
2905
+ for (const row of list) {
2906
+ lines.push(fields.map(f => escCsv(row[f])).join(","));
2907
+ }
2908
+ return lines.join("\\n");
2909
+ }
2910
+
2911
+ qEl.addEventListener("input", render);
2912
+ serviceEl.addEventListener("change", render);
2913
+ btnReset.addEventListener("click", () => {
2914
+ qEl.value = "";
2915
+ serviceEl.value = "";
2916
+ render();
2917
+ });
2918
+ btnCsv.addEventListener("click", () => {
2919
+ const csv = toCsv(window.__view || []);
2920
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
2921
+ const url = URL.createObjectURL(blob);
2922
+ const a = document.createElement("a");
2923
+ a.href = url;
2924
+ a.download = "inventory-view.csv";
2925
+ a.click();
2926
+ URL.revokeObjectURL(url);
2927
+ });
2928
+ render();
2929
+ </script>
2930
+ </body>
2931
+ </html>`;
2932
+ }
2933
+
2934
+ function writeHtmlReport(config, records) {
2935
+ if (!config.htmlOutPath) return null;
2936
+ const html = renderHtmlReport(records);
2937
+ fs.writeFileSync(config.htmlOutPath, html, "utf8");
2938
+ eprint(`Wrote HTML report to ${config.htmlOutPath}`);
2939
+ return config.htmlOutPath;
2940
+ }
2941
+
2942
+ function tryOpenFile(targetPath) {
2943
+ if (!targetPath) return { ok: false, detail: "empty path" };
2944
+ const fullPath = path.resolve(targetPath);
2945
+ try {
2946
+ if (process.platform === "win32") {
2947
+ const child = spawn("explorer.exe", [fullPath], { stdio: "ignore", detached: true, windowsHide: true });
2948
+ child.unref();
2949
+ return { ok: true, detail: "explorer.exe" };
2950
+ }
2951
+ if (process.platform === "darwin") {
2952
+ const child = spawn("open", [fullPath], { stdio: "ignore", detached: true });
2953
+ child.unref();
2954
+ return { ok: true, detail: "open" };
2955
+ }
2956
+ const child = spawn("xdg-open", [fullPath], { stdio: "ignore", detached: true });
2957
+ child.unref();
2958
+ return { ok: true, detail: "xdg-open" };
2959
+ } catch (error) {
2960
+ return { ok: false, detail: error && error.message ? error.message : String(error) };
2961
+ }
2962
+ }
2963
+
2042
2964
  function writeOutput(config, content) {
2043
2965
  if (config.outPath) {
2044
2966
  fs.writeFileSync(config.outPath, content, "utf8");
@@ -2054,7 +2976,7 @@ async function runWorkflow(config) {
2054
2976
  const requiredActions = [];
2055
2977
 
2056
2978
  progress(config, 1, "orchestrator: parse user request and operation scope");
2057
- eprint(`Inputs: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${config.profiles ? config.profiles.join(",") : "auto"}, regions=${config.regions ? config.regions.join(",") : "auto"}, include_ec2=${config.includeEc2 ? "on" : "off"}, include_lambda=${config.includeLambda ? "on" : "off"}, include_alb=${config.includeAlb ? "on" : "off"}, include_asg=${config.includeAsg ? "on" : "off"}, include_rds=${config.includeRds ? "on" : "off"}, include_elasticache=${config.includeElastiCache ? "on" : "off"}, include_route53=${config.includeRoute53 ? "on" : "off"}, public_only=${config.publicOnly ? "on" : "off"}, managed_only=${config.managedOnly ? "on" : "off"}, auto_remediate_ssm=${config.autoRemediateSsm ? "on" : "off"}, runtime_snapshot=${config.runtimeSnapshot ? "on" : "off"}, auto_sso_login=${config.autoSsoLogin ? "on" : "off"}`);
2979
+ eprint(`Inputs: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${config.profiles ? config.profiles.join(",") : "auto"}, regions=${config.regions ? config.regions.join(",") : "auto"}, include_ec2=${config.includeEc2 ? "on" : "off"}, include_lambda=${config.includeLambda ? "on" : "off"}, include_alb=${config.includeAlb ? "on" : "off"}, include_asg=${config.includeAsg ? "on" : "off"}, include_rds=${config.includeRds ? "on" : "off"}, include_elasticache=${config.includeElastiCache ? "on" : "off"}, include_route53=${config.includeRoute53 ? "on" : "off"}, public_only=${config.publicOnly ? "on" : "off"}, managed_only=${config.managedOnly ? "on" : "off"}, auto_remediate_ssm=${config.autoRemediateSsm ? "on" : "off"}, runtime_snapshot=${config.runtimeSnapshot ? "on" : "off"}, manual_server_list=${config.manualServerListPath || "off"}, pem_paths=${config.pemPaths && config.pemPaths.length ? config.pemPaths.length : 0}, ssh_user=${config.sshUser}, ssh_port=${config.sshPort}, html_out=${config.htmlOutPath || "off"}, open_html=${config.openHtml ? "on" : "off"}, auto_sso_login=${config.autoSsoLogin ? "on" : "off"}`);
2058
2980
 
2059
2981
  progress(config, 2, "config_validator: validate settings and output path");
2060
2982
  validateConfig(config, warnings, requiredActions);
@@ -2066,11 +2988,16 @@ async function runWorkflow(config) {
2066
2988
  const operationPlan = buildOperationPlan(config);
2067
2989
  eprint(`Operation plan: inventory=${operationPlan.inventoryOps.length ? operationPlan.inventoryOps.join(",") : "none"}; runtime=${operationPlan.runtimeOps.length ? operationPlan.runtimeOps.join(",") : "none"}`);
2068
2990
 
2069
- const plans = await buildDiscoveryPlan(config, warnings, requiredActions);
2070
- if (plans.length) {
2071
- eprint("Discovery plan: " + plans.map((p) => `${p.profileLabel}(${p.regions.length} regions)`).join(", "));
2991
+ let plans = [];
2992
+ if (config.manualServerListPath) {
2993
+ eprint(`Discovery plan: manual server list mode (${config.manualServerListPath})`);
2072
2994
  } else {
2073
- warnings.push("No usable AWS profiles were found after validation.");
2995
+ plans = await buildDiscoveryPlan(config, warnings, requiredActions);
2996
+ if (plans.length) {
2997
+ eprint("Discovery plan: " + plans.map((p) => `${p.profileLabel}(${p.regions.length} regions)`).join(", "));
2998
+ } else {
2999
+ warnings.push("No usable AWS profiles were found after validation.");
3000
+ }
2074
3001
  }
2075
3002
 
2076
3003
  if (config.autoRemediateSsm) {
@@ -2078,23 +3005,30 @@ async function runWorkflow(config) {
2078
3005
  }
2079
3006
 
2080
3007
  progress(config, 5, "resource_inventory_collector: collect multi-service inventory");
2081
- const inventoryResult = await collectInventory(config, plans, warnings, requiredActions);
3008
+ const inventoryResult = config.manualServerListPath
3009
+ ? await collectManualInventory(config, warnings, requiredActions)
3010
+ : await collectInventory(config, plans, warnings, requiredActions);
2082
3011
  const records = inventoryResult.records;
2083
3012
  const stats = inventoryResult.stats;
2084
3013
  eprint(`Collected inventory records: ${records.length}`);
2085
3014
 
2086
3015
  progress(config, 6, "runtime_operation_executor: execute runtime snapshot/remediation tasks");
2087
3016
  const ec2Records = records.filter((r) => r.resourceType === "ec2");
2088
- const unmanaged = ec2Records.filter((r) => !r.ssmManaged).length;
2089
- const offline = ec2Records.filter((r) => r.ssmManaged && !r.ssmOnline).length;
2090
- eprint(`Pre-runtime SSM state: unmanaged=${unmanaged}, managed_offline=${offline}`);
3017
+ if (config.manualServerListPath) {
3018
+ const manualTargets = ec2Records.filter((r) => r.manualInput === true).length;
3019
+ eprint(`Pre-runtime manual SSH state: targets=${manualTargets}`);
3020
+ } else {
3021
+ const unmanaged = ec2Records.filter((r) => !r.ssmManaged).length;
3022
+ const offline = ec2Records.filter((r) => r.ssmManaged && !r.ssmOnline).length;
3023
+ eprint(`Pre-runtime SSM state: unmanaged=${unmanaged}, managed_offline=${offline}`);
2091
3024
 
2092
- if (unmanaged > 0 && !config.autoRemediateSsm) {
2093
- pushRequiredAction(requiredActions, {
2094
- code: "SSM_ROLE_OR_AGENT_REQUIRED",
2095
- message: `${unmanaged} instances are not SSM managed.`,
2096
- hint: "Attach instance profile with AmazonSSMManagedInstanceCore and verify SSM agent/network."
2097
- });
3025
+ if (unmanaged > 0 && !config.autoRemediateSsm) {
3026
+ pushRequiredAction(requiredActions, {
3027
+ code: "SSM_ROLE_OR_AGENT_REQUIRED",
3028
+ message: `${unmanaged} instances are not SSM managed.`,
3029
+ hint: "Attach instance profile with AmazonSSMManagedInstanceCore and verify SSM agent/network."
3030
+ });
3031
+ }
2098
3032
  }
2099
3033
 
2100
3034
  const runtimeStats = await executeRuntimeOperations(config, records, plans, warnings, requiredActions);
@@ -2104,6 +3038,15 @@ async function runWorkflow(config) {
2104
3038
 
2105
3039
  progress(config, 8, "cli_output_formatter: render JSON/CSV and guidance payload");
2106
3040
  writeOutput(config, renderOutput(config, outputRecords));
3041
+ const htmlPath = writeHtmlReport(config, outputRecords);
3042
+ if (htmlPath && config.openHtml) {
3043
+ const opened = tryOpenFile(htmlPath);
3044
+ if (!opened.ok) {
3045
+ warnings.push(`Failed to auto-open HTML report: ${opened.detail}`);
3046
+ } else {
3047
+ eprint(`Opened HTML report via ${opened.detail}`);
3048
+ }
3049
+ }
2107
3050
 
2108
3051
  progress(config, 9, "END: emit execution summary and evidence metadata");
2109
3052
  const outputEc2 = outputRecords.filter((r) => r.resourceType === "ec2");
@@ -2117,7 +3060,7 @@ async function runWorkflow(config) {
2117
3060
  const ssmManaged = outputEc2.filter((r) => r.ssmManaged).length;
2118
3061
  const ssmOnline = outputEc2.filter((r) => r.ssmOnline).length;
2119
3062
  const publicCount = outputEc2.filter((r) => Boolean(r.publicIp)).length;
2120
- eprint(`Summary: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${stats.profiles}, regions_scanned=${stats.regions}, region_errors=${stats.regionErrors}, ec2_scanned=${stats.instancesScanned}, lambda_scanned=${stats.lambdaFunctionsScanned}, alb_scanned=${stats.loadBalancersScanned}, targetgroup_scanned=${stats.targetGroupsScanned}, asg_scanned=${stats.autoScalingGroupsScanned}, rds_scanned=${stats.rdsInstancesScanned}, elasticache_scanned=${stats.elasticacheClustersScanned}, route53_zone_scanned=${stats.route53ZonesScanned}, output_records=${outputRecords.length}, output_ec2=${outputEc2.length}, output_lambda=${outputLambda.length}, output_alb=${outputAlb.length}, output_target_group=${outputTargetGroups.length}, output_asg=${outputAsg.length}, output_rds=${outputRds.length}, output_elasticache=${outputElastiCache.length}, output_route53_zone=${outputRoute53.length}, ec2_public_ip_records=${publicCount}, ec2_ssm_managed=${ssmManaged}, ec2_ssm_online=${ssmOnline}, remediation_attempts=${runtimeStats.remediationAttempts}, remediation_changed=${runtimeStats.remediationChanged}, runtime_snapshot_attempted=${runtimeStats.snapshotAttempted}, runtime_snapshot_succeeded=${runtimeStats.snapshotSucceeded}, warnings=${warnings.length}, required_actions=${requiredActions.length}`);
3063
+ eprint(`Summary: execution_mode=${INTERNAL_BACKEND_ID}, manual_mode=${config.manualServerListPath ? "on" : "off"}, profiles=${stats.profiles}, regions_scanned=${stats.regions}, region_errors=${stats.regionErrors}, ec2_scanned=${stats.instancesScanned}, lambda_scanned=${stats.lambdaFunctionsScanned}, alb_scanned=${stats.loadBalancersScanned}, targetgroup_scanned=${stats.targetGroupsScanned}, asg_scanned=${stats.autoScalingGroupsScanned}, rds_scanned=${stats.rdsInstancesScanned}, elasticache_scanned=${stats.elasticacheClustersScanned}, route53_zone_scanned=${stats.route53ZonesScanned}, manual_servers_scanned=${stats.manualServersScanned || 0}, manual_servers_loaded=${stats.manualServersLoaded || 0}, output_records=${outputRecords.length}, output_ec2=${outputEc2.length}, output_lambda=${outputLambda.length}, output_alb=${outputAlb.length}, output_target_group=${outputTargetGroups.length}, output_asg=${outputAsg.length}, output_rds=${outputRds.length}, output_elasticache=${outputElastiCache.length}, output_route53_zone=${outputRoute53.length}, ec2_public_ip_records=${publicCount}, ec2_ssm_managed=${ssmManaged}, ec2_ssm_online=${ssmOnline}, remediation_attempts=${runtimeStats.remediationAttempts}, remediation_changed=${runtimeStats.remediationChanged}, runtime_snapshot_attempted=${runtimeStats.snapshotAttempted}, runtime_snapshot_succeeded=${runtimeStats.snapshotSucceeded}, runtime_snapshot_ssm_attempted=${runtimeStats.snapshotSsmAttempted}, runtime_snapshot_ssm_succeeded=${runtimeStats.snapshotSsmSucceeded}, runtime_snapshot_ssh_attempted=${runtimeStats.snapshotSshAttempted}, runtime_snapshot_ssh_succeeded=${runtimeStats.snapshotSshSucceeded}, warnings=${warnings.length}, required_actions=${requiredActions.length}`);
2121
3064
  eprint(`EvidenceMeta: workflow_id=b60f0f18-cc45-4af9-a7c7-217284457759; schema=gesia.orflow.contract.v2; step_count=${TOTAL_STEPS}; execution_mode=${INTERNAL_BACKEND_ID}; inventory_ops=${operationPlan.inventoryOps.length}; runtime_ops=${operationPlan.runtimeOps.length}`);
2122
3065
 
2123
3066
  for (const warning of warnings) eprint(`WARNING: ${warning}`);