@lzm04521/ssh-mcp-server 1.1.5

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.
Files changed (78) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +157 -0
  3. package/admin-web/dist/assets/index-DXbr5DVR.js +494 -0
  4. package/admin-web/dist/index.html +6 -0
  5. package/admin-web/dist/logo.png +0 -0
  6. package/build/cli/command-line-parser.js +715 -0
  7. package/build/cli/command-line-parser.js.map +1 -0
  8. package/build/cli/run-mode.js +44 -0
  9. package/build/cli/run-mode.js.map +1 -0
  10. package/build/cli/stdio-proxy.js +304 -0
  11. package/build/cli/stdio-proxy.js.map +1 -0
  12. package/build/config/index.js +5 -0
  13. package/build/config/index.js.map +1 -0
  14. package/build/config/server.js +16 -0
  15. package/build/config/server.js.map +1 -0
  16. package/build/core/mcp-http-server.js +14 -0
  17. package/build/core/mcp-http-server.js.map +1 -0
  18. package/build/core/mcp-server.js +95 -0
  19. package/build/core/mcp-server.js.map +1 -0
  20. package/build/index.js +98 -0
  21. package/build/index.js.map +1 -0
  22. package/build/models/admin-types.js +135 -0
  23. package/build/models/admin-types.js.map +1 -0
  24. package/build/models/types.js +2 -0
  25. package/build/models/types.js.map +1 -0
  26. package/build/server/index.js +128 -0
  27. package/build/server/index.js.map +1 -0
  28. package/build/server/routes/admin.js +929 -0
  29. package/build/server/routes/admin.js.map +1 -0
  30. package/build/server/routes/audit.js +9 -0
  31. package/build/server/routes/audit.js.map +1 -0
  32. package/build/server/routes/backups.js +11 -0
  33. package/build/server/routes/backups.js.map +1 -0
  34. package/build/server/routes/mcp.js +52 -0
  35. package/build/server/routes/mcp.js.map +1 -0
  36. package/build/server/routes/settings.js +49 -0
  37. package/build/server/routes/settings.js.map +1 -0
  38. package/build/server/routes/system.js +147 -0
  39. package/build/server/routes/system.js.map +1 -0
  40. package/build/services/audit-store.js +123 -0
  41. package/build/services/audit-store.js.map +1 -0
  42. package/build/services/autostart-service.js +55 -0
  43. package/build/services/autostart-service.js.map +1 -0
  44. package/build/services/backup-scheduler.js +90 -0
  45. package/build/services/backup-scheduler.js.map +1 -0
  46. package/build/services/backup-service.js +113 -0
  47. package/build/services/backup-service.js.map +1 -0
  48. package/build/services/config-store.js +163 -0
  49. package/build/services/config-store.js.map +1 -0
  50. package/build/services/defaults.js +35 -0
  51. package/build/services/defaults.js.map +1 -0
  52. package/build/services/restart-helper.js +43 -0
  53. package/build/services/restart-helper.js.map +1 -0
  54. package/build/services/ssh-connection-manager.js +1775 -0
  55. package/build/services/ssh-connection-manager.js.map +1 -0
  56. package/build/services/update-service.js +93 -0
  57. package/build/services/update-service.js.map +1 -0
  58. package/build/tools/download.js +41 -0
  59. package/build/tools/download.js.map +1 -0
  60. package/build/tools/execute-command.js +53 -0
  61. package/build/tools/execute-command.js.map +1 -0
  62. package/build/tools/index.js +17 -0
  63. package/build/tools/index.js.map +1 -0
  64. package/build/tools/list-directory.js +57 -0
  65. package/build/tools/list-directory.js.map +1 -0
  66. package/build/tools/list-servers.js +51 -0
  67. package/build/tools/list-servers.js.map +1 -0
  68. package/build/tools/upload.js +41 -0
  69. package/build/tools/upload.js.map +1 -0
  70. package/build/utils/logger.js +28 -0
  71. package/build/utils/logger.js.map +1 -0
  72. package/build/utils/ssh-config-parser.js +195 -0
  73. package/build/utils/ssh-config-parser.js.map +1 -0
  74. package/build/utils/status-collector.js +226 -0
  75. package/build/utils/status-collector.js.map +1 -0
  76. package/build/utils/tool-error.js +20 -0
  77. package/build/utils/tool-error.js.map +1 -0
  78. package/package.json +69 -0
@@ -0,0 +1,929 @@
1
+ import { getFlatHosts } from "../../services/config-store.js";
2
+ import { ConnectionSchema, GlobalConfigSchema } from "../../models/admin-types.js";
3
+ import { DEFAULT_ENVIRONMENTS, DEFAULT_COMMAND_BLACKLIST } from "../../services/defaults.js";
4
+ import { Client } from "ssh2";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ function isValidIpv4(s) {
8
+ const parts = s.split(".");
9
+ return parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
10
+ }
11
+ function isValidIpv6(s) {
12
+ if (s.indexOf("::") !== s.lastIndexOf("::"))
13
+ return false; // `::` 只能出现一次
14
+ if (!/^[0-9a-fA-F:]+$/.test(s) || /:{3,}/.test(s))
15
+ return false; // 仅 hex 与冒号,禁止连续 3+ 冒号
16
+ const groups = s.split("::").join(":").split(":").filter(Boolean);
17
+ return groups.length <= 8 && groups.every((g) => /^[0-9a-fA-F]{1,4}$/.test(g));
18
+ }
19
+ // 校验主机地址:IPv4/IPv6 需合法;主机名/SSH 别名仅拦明显非法字符
20
+ function isValidHostAddress(value) {
21
+ const v = value.trim();
22
+ if (!v)
23
+ return false;
24
+ if (v.includes(":"))
25
+ return isValidIpv6(v);
26
+ if (/^[\d.]+$/.test(v))
27
+ return isValidIpv4(v);
28
+ return !/[\s/\\]/.test(v);
29
+ }
30
+ // 按 order 数组重排(纯数字键会被 JS 强制升序,故不能依赖对象 key 顺序)
31
+ function orderByName(items, order) {
32
+ if (!Array.isArray(order) || order.length === 0)
33
+ return items;
34
+ const byName = {};
35
+ for (const it of items)
36
+ byName[it.name] = it;
37
+ const out = [];
38
+ const seen = new Set();
39
+ for (const n of order)
40
+ if (byName[n] && !seen.has(n)) {
41
+ out.push(byName[n]);
42
+ seen.add(n);
43
+ }
44
+ for (const it of items)
45
+ if (!seen.has(it.name))
46
+ out.push(it);
47
+ return out;
48
+ }
49
+ function maskHost(host) {
50
+ return host;
51
+ }
52
+ function maskHosts(hosts) {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(hosts || {})) {
55
+ out[k] = maskHost(v);
56
+ }
57
+ return out;
58
+ }
59
+ function maskProject(proj) {
60
+ if (!proj)
61
+ return proj;
62
+ const copy = { ...proj };
63
+ copy.environments = {};
64
+ for (const [eName, env] of Object.entries((proj.environments || {}))) {
65
+ const eCopy = { ...env };
66
+ eCopy.hosts = maskHosts(env.hosts);
67
+ copy.environments[eName] = eCopy;
68
+ }
69
+ return copy;
70
+ }
71
+ function maskEnvironment(env) {
72
+ if (!env)
73
+ return env;
74
+ const copy = { ...env };
75
+ copy.hosts = maskHosts(env.hosts);
76
+ return copy;
77
+ }
78
+ export function registerAdminRoutes(app, store) {
79
+ // GET /admin/api/projects index
80
+ app.get("/admin/api/projects", async () => {
81
+ const cfg = await store.load();
82
+ const projects = cfg.projects || {};
83
+ const list = Object.entries(projects).map(([name, proj]) => {
84
+ const envs = proj.environments || {};
85
+ const environmentCount = Object.keys(envs).length;
86
+ let hostCount = 0;
87
+ for (const env of Object.values(envs)) {
88
+ hostCount += Object.keys(env.hosts || {}).length;
89
+ }
90
+ return {
91
+ name,
92
+ displayName: proj.displayName,
93
+ defaultEnvironment: proj.defaultEnvironment,
94
+ environmentCount,
95
+ hostCount,
96
+ };
97
+ });
98
+ return orderByName(list, cfg.projectOrder);
99
+ });
100
+ // POST /admin/api/projects create/update + rename
101
+ app.post("/admin/api/projects", async (req, reply) => {
102
+ try {
103
+ const body = req.body || {};
104
+ const name = typeof body.name === "string" ? body.name.trim() : "";
105
+ const originalName = typeof body.originalName === "string" ? body.originalName.trim() : undefined;
106
+ const displayName = body.displayName;
107
+ const defaultEnvironment = body.defaultEnvironment !== undefined && body.defaultEnvironment !== null ? String(body.defaultEnvironment).trim() : undefined;
108
+ if (!name) {
109
+ reply.code(400);
110
+ return { ok: false, code: "INVALID_NAME", message: `项目名称不能为空: ${name}` };
111
+ }
112
+ if (originalName !== undefined && !originalName) {
113
+ reply.code(400);
114
+ return { ok: false, code: "INVALID_NAME", message: `项目原名称不能为空: ${originalName}` };
115
+ }
116
+ if (defaultEnvironment !== undefined && !defaultEnvironment) {
117
+ reply.code(400);
118
+ return { ok: false, code: "INVALID_NAME", message: `环境名称不能为空: ${defaultEnvironment}` };
119
+ }
120
+ const cfg = await store.load();
121
+ cfg.projects = cfg.projects || {};
122
+ // atomic rename check
123
+ if (originalName) {
124
+ const source = cfg.projects[originalName];
125
+ if (!source) {
126
+ reply.code(404);
127
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${originalName}` };
128
+ }
129
+ if (originalName !== name && cfg.projects[name]) {
130
+ reply.code(400);
131
+ return { ok: false, code: "HOST_EXISTS", message: `项目已存在: ${name}` };
132
+ }
133
+ // validate defaultEnvironment exists in source (or will exist after)
134
+ const targetDefault = defaultEnvironment !== undefined ? defaultEnvironment : source.defaultEnvironment;
135
+ if (targetDefault && !source.environments?.[targetDefault]) {
136
+ reply.code(400);
137
+ return { ok: false, code: "DEFAULT_ENV_NOT_FOUND", message: `默认环境不存在: ${targetDefault}` };
138
+ }
139
+ // apply updates to source
140
+ if (displayName !== undefined)
141
+ source.displayName = displayName;
142
+ if (defaultEnvironment !== undefined)
143
+ source.defaultEnvironment = defaultEnvironment;
144
+ else {
145
+ // 留空则默认为测试环境(若存在)
146
+ if (source.environments && source.environments["测试环境"]) {
147
+ source.defaultEnvironment = "测试环境";
148
+ }
149
+ else {
150
+ delete source.defaultEnvironment;
151
+ }
152
+ }
153
+ if (!source.environments)
154
+ source.environments = {};
155
+ if (originalName !== name) {
156
+ cfg.projects[name] = source;
157
+ delete cfg.projects[originalName];
158
+ // 同步 projectOrder,避免重命名后排序位置丢失(掉到列表末尾)
159
+ if (Array.isArray(cfg.projectOrder)) {
160
+ cfg.projectOrder = cfg.projectOrder.map((n) => (n === originalName ? name : n));
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ // 新建项目名称必须唯一(避免同名被当作更新/覆盖)
166
+ if (cfg.projects[name]) {
167
+ reply.code(400);
168
+ return { ok: false, code: "HOST_EXISTS", message: `项目已存在: ${name}` };
169
+ }
170
+ const target = { environments: {} };
171
+ // 自动创建默认环境(中文名),用户只需维护主机
172
+ for (const e of DEFAULT_ENVIRONMENTS) {
173
+ target.environments[e] = { displayName: e, hosts: {} };
174
+ }
175
+ if (displayName !== undefined)
176
+ target.displayName = displayName;
177
+ if (defaultEnvironment !== undefined)
178
+ target.defaultEnvironment = defaultEnvironment;
179
+ // 未指定则默认 测试环境
180
+ if (!target.defaultEnvironment)
181
+ target.defaultEnvironment = "测试环境";
182
+ // 若指定的默认环境不在预设 4 个内,自动补一个
183
+ if (target.defaultEnvironment && !target.environments[target.defaultEnvironment]) {
184
+ target.environments[target.defaultEnvironment] = { hosts: {} };
185
+ }
186
+ cfg.projects[name] = target;
187
+ }
188
+ // Validate whole config
189
+ try {
190
+ GlobalConfigSchema.parse(cfg);
191
+ }
192
+ catch (e) {
193
+ reply.code(400);
194
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
195
+ }
196
+ await store.save(cfg);
197
+ return { ok: true };
198
+ }
199
+ catch (e) {
200
+ reply.code(400);
201
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
202
+ }
203
+ });
204
+ // GET /admin/api/projects/:project (masked)
205
+ app.get("/admin/api/projects/:project", async (req, reply) => {
206
+ const cfg = await store.load();
207
+ const proj = cfg.projects?.[req.params.project];
208
+ if (!proj) {
209
+ reply.code(404);
210
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
211
+ }
212
+ return maskProject(proj);
213
+ });
214
+ // DELETE /admin/api/projects/:project
215
+ app.delete("/admin/api/projects/:project", async (req, reply) => {
216
+ const cfg = await store.load();
217
+ if (!cfg.projects?.[req.params.project]) {
218
+ reply.code(404);
219
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
220
+ }
221
+ delete cfg.projects[req.params.project];
222
+ await store.save(cfg);
223
+ return { ok: true };
224
+ });
225
+ // POST /admin/api/projects/:project/environments
226
+ app.post("/admin/api/projects/:project/environments", async (req, reply) => {
227
+ try {
228
+ const project = req.params.project;
229
+ const body = req.body || {};
230
+ const name = typeof body.name === "string" ? body.name.trim() : "";
231
+ const originalName = typeof body.originalName === "string" ? body.originalName.trim() : undefined;
232
+ const displayName = body.displayName;
233
+ if (!name) {
234
+ reply.code(400);
235
+ return { ok: false, code: "INVALID_NAME", message: `环境名称不能为空: ${name}` };
236
+ }
237
+ if (originalName !== undefined && !originalName) {
238
+ reply.code(400);
239
+ return { ok: false, code: "INVALID_NAME", message: `环境原名称不能为空: ${originalName}` };
240
+ }
241
+ const cfg = await store.load();
242
+ const proj = cfg.projects?.[project];
243
+ if (!proj) {
244
+ reply.code(404);
245
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${project}` };
246
+ }
247
+ proj.environments = proj.environments || {};
248
+ if (originalName) {
249
+ const source = proj.environments[originalName];
250
+ if (!source) {
251
+ reply.code(404);
252
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${originalName}` };
253
+ }
254
+ if (originalName !== name && proj.environments[name]) {
255
+ reply.code(400);
256
+ return { ok: false, code: "HOST_EXISTS", message: `环境已存在: ${name}` };
257
+ }
258
+ if (displayName !== undefined)
259
+ source.displayName = displayName;
260
+ if (originalName !== name) {
261
+ proj.environments[name] = source;
262
+ delete proj.environments[originalName];
263
+ // if defaultEnvironment pointed to old name, update it
264
+ if (proj.defaultEnvironment === originalName)
265
+ proj.defaultEnvironment = name;
266
+ }
267
+ }
268
+ else {
269
+ if (proj.environments[name]) {
270
+ reply.code(400);
271
+ return { ok: false, code: "HOST_EXISTS", message: `环境已存在: ${name}` };
272
+ }
273
+ const target = { hosts: {} };
274
+ if (displayName !== undefined)
275
+ target.displayName = displayName;
276
+ proj.environments[name] = target;
277
+ }
278
+ try {
279
+ GlobalConfigSchema.parse(cfg);
280
+ }
281
+ catch (e) {
282
+ reply.code(400);
283
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
284
+ }
285
+ await store.save(cfg);
286
+ return { ok: true };
287
+ }
288
+ catch (e) {
289
+ reply.code(400);
290
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
291
+ }
292
+ });
293
+ // GET /admin/api/projects/:project/environments/:env (masked)
294
+ app.get("/admin/api/projects/:project/environments/:env", async (req, reply) => {
295
+ const cfg = await store.load();
296
+ const proj = cfg.projects?.[req.params.project];
297
+ if (!proj) {
298
+ reply.code(404);
299
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
300
+ }
301
+ const env = proj.environments?.[req.params.env];
302
+ if (!env) {
303
+ reply.code(404);
304
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${req.params.env}` };
305
+ }
306
+ return maskEnvironment(env);
307
+ });
308
+ // DELETE /admin/api/projects/:project/environments/:env
309
+ app.delete("/admin/api/projects/:project/environments/:env", async (req, reply) => {
310
+ const cfg = await store.load();
311
+ const proj = cfg.projects?.[req.params.project];
312
+ if (!proj) {
313
+ reply.code(404);
314
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
315
+ }
316
+ if (!proj.environments?.[req.params.env]) {
317
+ reply.code(404);
318
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${req.params.env}` };
319
+ }
320
+ delete proj.environments[req.params.env];
321
+ if (proj.defaultEnvironment === req.params.env)
322
+ delete proj.defaultEnvironment;
323
+ await store.save(cfg);
324
+ return { ok: true };
325
+ });
326
+ // POST /admin/api/projects/:project/environments/:env/hosts
327
+ app.post("/admin/api/projects/:project/environments/:env/hosts", async (req, reply) => {
328
+ try {
329
+ const project = req.params.project;
330
+ const envName = req.params.env;
331
+ const body = req.body || {};
332
+ const rawOriginal = typeof body.originalName === "string" ? body.originalName.trim() : undefined;
333
+ const hostKey = typeof body.name === "string" ? body.name.trim() : "";
334
+ if (!hostKey) {
335
+ reply.code(400);
336
+ return { ok: false, code: "INVALID_NAME", message: `主机名称不能为空: ${hostKey}` };
337
+ }
338
+ if (rawOriginal !== undefined && !rawOriginal) {
339
+ reply.code(400);
340
+ return { ok: false, code: "INVALID_NAME", message: `主机原名称不能为空: ${rawOriginal}` };
341
+ }
342
+ const cfg = await store.load();
343
+ const proj = cfg.projects?.[project];
344
+ if (!proj) {
345
+ reply.code(404);
346
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${project}` };
347
+ }
348
+ const env = proj.environments?.[envName];
349
+ if (!env) {
350
+ reply.code(404);
351
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${envName}` };
352
+ }
353
+ env.hosts = env.hosts || {};
354
+ // 校验主机地址格式(IPv4/IPv6/主机名或 SSH 别名)
355
+ const hostValue = typeof body.host === "string" ? body.host.trim() : "";
356
+ if (!hostValue || !isValidHostAddress(hostValue)) {
357
+ reply.code(400);
358
+ return { ok: false, code: "INVALID_ADDRESS", message: "主机地址格式不正确" };
359
+ }
360
+ const lookupKey = rawOriginal || hostKey;
361
+ const existing = env.hosts[lookupKey];
362
+ // 新建或重命名到已存在的名称时拒绝,避免覆盖;自身原地保存放行
363
+ if (env.hosts[hostKey] && rawOriginal !== hostKey) {
364
+ reply.code(400);
365
+ return { ok: false, code: "HOST_EXISTS", message: `主机已存在: ${hostKey}` };
366
+ }
367
+ // prepare data for validation/normalization
368
+ const toParse = { ...body };
369
+ delete toParse.originalName;
370
+ toParse.name = hostKey;
371
+ let parsed;
372
+ try {
373
+ parsed = ConnectionSchema.passthrough().parse(toParse);
374
+ }
375
+ catch (e) {
376
+ reply.code(400);
377
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
378
+ }
379
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
380
+ const normalized = CommandLineParser.normalizeConfig(parsed);
381
+ normalized.name = hostKey;
382
+ // 修复 #3:清空后应跟随全局(undefined),而非固化默认值
383
+ if (parsed.transportMode === undefined)
384
+ delete normalized.transportMode;
385
+ if (parsed.shellReadyTimeoutMs === undefined)
386
+ delete normalized.shellReadyTimeoutMs;
387
+ // Handle password/passphrase retention: if frontend sent "***" treat as missing
388
+ if (normalized.password === "***")
389
+ delete normalized.password;
390
+ if (normalized.passphrase === "***")
391
+ delete normalized.passphrase;
392
+ if (!normalized.password && existing?.password)
393
+ normalized.password = existing.password;
394
+ if (!normalized.passphrase && existing?.passphrase)
395
+ normalized.passphrase = existing.passphrase;
396
+ env.hosts[hostKey] = normalized;
397
+ if (rawOriginal && rawOriginal !== hostKey) {
398
+ delete env.hosts[rawOriginal];
399
+ }
400
+ try {
401
+ GlobalConfigSchema.parse(cfg);
402
+ }
403
+ catch (e) {
404
+ reply.code(400);
405
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
406
+ }
407
+ await store.save(cfg);
408
+ return { ok: true };
409
+ }
410
+ catch (e) {
411
+ reply.code(400);
412
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
413
+ }
414
+ });
415
+ // DELETE /admin/api/projects/:project/environments/:env/hosts/:host
416
+ app.delete("/admin/api/projects/:project/environments/:env/hosts/:host", async (req, reply) => {
417
+ const cfg = await store.load();
418
+ const proj = cfg.projects?.[req.params.project];
419
+ if (!proj) {
420
+ reply.code(404);
421
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
422
+ }
423
+ const env = proj.environments?.[req.params.env];
424
+ if (!env) {
425
+ reply.code(404);
426
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${req.params.env}` };
427
+ }
428
+ if (!env.hosts?.[req.params.host]) {
429
+ reply.code(404);
430
+ return { ok: false, code: "HOST_NOT_FOUND", message: `主机不存在: ${req.params.host}` };
431
+ }
432
+ delete env.hosts[req.params.host];
433
+ await store.save(cfg);
434
+ return { ok: true };
435
+ });
436
+ // POST /admin/api/projects/reorder 项目拖拽排序(写入 projectOrder 数组持久化)
437
+ app.post("/admin/api/projects/reorder", async (req, reply) => {
438
+ try {
439
+ const order = req.body?.order;
440
+ if (!Array.isArray(order)) {
441
+ reply.code(400);
442
+ return { ok: false, code: "INVALID_ORDER", message: "order 必须是数组" };
443
+ }
444
+ const cfg = await store.load();
445
+ const projects = cfg.projects || {};
446
+ // 用显式 projectOrder 数组持久化顺序(对象 key 对纯数字名会重排,不能依赖)
447
+ const seen = [];
448
+ for (const name of order)
449
+ if (typeof name === "string" && projects[name] && !seen.includes(name))
450
+ seen.push(name);
451
+ for (const name of Object.keys(projects))
452
+ if (!seen.includes(name))
453
+ seen.push(name);
454
+ cfg.projectOrder = seen;
455
+ GlobalConfigSchema.parse(cfg);
456
+ await store.save(cfg);
457
+ return { ok: true };
458
+ }
459
+ catch (e) {
460
+ reply.code(400);
461
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
462
+ }
463
+ });
464
+ // POST /admin/api/projects/:project/environments/:env/hosts/reorder 主机拖拽排序(写入 hostOrder 数组持久化)
465
+ app.post("/admin/api/projects/:project/environments/:env/hosts/reorder", async (req, reply) => {
466
+ try {
467
+ const order = req.body?.order;
468
+ if (!Array.isArray(order)) {
469
+ reply.code(400);
470
+ return { ok: false, code: "INVALID_ORDER", message: "order 必须是数组" };
471
+ }
472
+ const cfg = await store.load();
473
+ const proj = cfg.projects?.[req.params.project];
474
+ if (!proj) {
475
+ reply.code(404);
476
+ return { ok: false, code: "PROJECT_NOT_FOUND", message: `项目不存在: ${req.params.project}` };
477
+ }
478
+ const env = proj.environments?.[req.params.env];
479
+ if (!env) {
480
+ reply.code(404);
481
+ return { ok: false, code: "ENVIRONMENT_NOT_FOUND", message: `环境不存在: ${req.params.env}` };
482
+ }
483
+ env.hosts = env.hosts || {};
484
+ // 用显式 hostOrder 数组持久化顺序
485
+ const seen = [];
486
+ for (const name of order)
487
+ if (typeof name === "string" && env.hosts[name] && !seen.includes(name))
488
+ seen.push(name);
489
+ for (const name of Object.keys(env.hosts))
490
+ if (!seen.includes(name))
491
+ seen.push(name);
492
+ env.hostOrder = seen;
493
+ GlobalConfigSchema.parse(cfg);
494
+ await store.save(cfg);
495
+ return { ok: true };
496
+ }
497
+ catch (e) {
498
+ reply.code(400);
499
+ return { ok: false, code: "VALIDATION_ERROR", message: String(e?.message || e) };
500
+ }
501
+ });
502
+ app.get("/admin/api/connections", async () => {
503
+ const cfg = await store.load();
504
+ const flat = getFlatHosts(cfg);
505
+ const masked = {};
506
+ for (const [flatName, { config }] of flat) {
507
+ masked[flatName] = maskHost(config);
508
+ }
509
+ return masked;
510
+ });
511
+ // GET /admin/api/config/export (unmasked full)
512
+ app.get("/admin/api/config/export", async () => {
513
+ const cfg = await store.load();
514
+ // return full config unmasked, include projects and legacy connections for compat
515
+ const flat = getFlatHosts(cfg);
516
+ const connections = {};
517
+ for (const [flatName, { config }] of flat) {
518
+ connections[flatName] = config;
519
+ }
520
+ return {
521
+ projects: cfg.projects || {},
522
+ connections,
523
+ port: cfg.port,
524
+ audit: cfg.audit,
525
+ backups: cfg.backups,
526
+ security: cfg.security,
527
+ preConnect: cfg.preConnect,
528
+ };
529
+ });
530
+ // POST /admin/api/config/import
531
+ app.post("/admin/api/config/import", async (req, reply) => {
532
+ try {
533
+ const body = req.body || {};
534
+ const cfg = await store.load();
535
+ cfg.projects = cfg.projects || {};
536
+ let addedProjects = 0;
537
+ let updatedProjects = 0;
538
+ let addedEnvironments = 0;
539
+ let addedHosts = 0;
540
+ const warnings = [];
541
+ const hasProjects = body.projects && typeof body.projects === "object" && Object.keys(body.projects).length > 0;
542
+ const hasConnections = body.connections !== undefined && body.connections !== null;
543
+ if (hasProjects) {
544
+ // merge projects
545
+ for (const [pName, pVal] of Object.entries(body.projects)) {
546
+ if (!pName || !pName.trim()) {
547
+ warnings.push(`跳过非法项目名: ${pName}`);
548
+ continue;
549
+ }
550
+ const isNew = !cfg.projects[pName];
551
+ if (isNew)
552
+ addedProjects++;
553
+ else
554
+ updatedProjects++;
555
+ const pValAny = pVal || {};
556
+ const newEnvs = pValAny.environments || {};
557
+ if (isNew) {
558
+ // normalize hosts for new project
559
+ const newProj = { ...pValAny, environments: {} };
560
+ for (const [eName, eVal] of Object.entries(newEnvs)) {
561
+ if (!eName || !eName.trim()) {
562
+ warnings.push(`跳过非法环境名: ${eName}`);
563
+ continue;
564
+ }
565
+ const eValAny = eVal || {};
566
+ const newHosts = eValAny.hosts || {};
567
+ const normalizedHosts = {};
568
+ for (const [hName, hVal] of Object.entries(newHosts)) {
569
+ if (!hName || !hName.trim()) {
570
+ warnings.push(`跳过非法主机名: ${hName}`);
571
+ continue;
572
+ }
573
+ try {
574
+ const parsed = ConnectionSchema.passthrough().parse({ ...hVal, name: hName });
575
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
576
+ const normalized = CommandLineParser.normalizeConfig(parsed);
577
+ normalized.name = hName;
578
+ normalizedHosts[hName] = normalized;
579
+ addedHosts++;
580
+ }
581
+ catch (e) {
582
+ warnings.push(`跳过非法主机 ${hName}: ${String(e?.message || e)}`);
583
+ }
584
+ }
585
+ newProj.environments[eName] = { ...eValAny, hosts: normalizedHosts };
586
+ addedEnvironments++;
587
+ }
588
+ cfg.projects[pName] = newProj;
589
+ }
590
+ else {
591
+ // merge into existing
592
+ const target = cfg.projects[pName];
593
+ if (pValAny.displayName !== undefined)
594
+ target.displayName = pValAny.displayName;
595
+ if (pValAny.defaultEnvironment !== undefined)
596
+ target.defaultEnvironment = pValAny.defaultEnvironment;
597
+ target.environments = target.environments || {};
598
+ for (const [eName, eVal] of Object.entries(newEnvs)) {
599
+ if (!eName || !eName.trim()) {
600
+ warnings.push(`跳过非法环境名: ${eName}`);
601
+ continue;
602
+ }
603
+ const eValAny = eVal || {};
604
+ const newHosts = eValAny.hosts || {};
605
+ if (!target.environments[eName]) {
606
+ // new environment
607
+ const normalizedHosts = {};
608
+ for (const [hName, hVal] of Object.entries(newHosts)) {
609
+ if (!hName || !hName.trim()) {
610
+ warnings.push(`跳过非法主机名: ${hName}`);
611
+ continue;
612
+ }
613
+ try {
614
+ const parsed = ConnectionSchema.passthrough().parse({ ...hVal, name: hName });
615
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
616
+ const normalized = CommandLineParser.normalizeConfig(parsed);
617
+ normalized.name = hName;
618
+ normalizedHosts[hName] = normalized;
619
+ addedHosts++;
620
+ }
621
+ catch (e) {
622
+ warnings.push(`跳过非法主机 ${hName}: ${String(e?.message || e)}`);
623
+ }
624
+ }
625
+ target.environments[eName] = { ...eValAny, hosts: normalizedHosts };
626
+ addedEnvironments++;
627
+ }
628
+ else {
629
+ const tEnv = target.environments[eName];
630
+ if (eValAny.displayName !== undefined)
631
+ tEnv.displayName = eValAny.displayName;
632
+ tEnv.hosts = tEnv.hosts || {};
633
+ for (const [hName, hVal] of Object.entries(newHosts)) {
634
+ if (!hName || !hName.trim()) {
635
+ warnings.push(`跳过非法主机名: ${hName}`);
636
+ continue;
637
+ }
638
+ const isNewHost = !tEnv.hosts[hName];
639
+ try {
640
+ const parsed = ConnectionSchema.passthrough().parse({ ...hVal, name: hName });
641
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
642
+ const normalized = CommandLineParser.normalizeConfig(parsed);
643
+ normalized.name = hName;
644
+ tEnv.hosts[hName] = normalized;
645
+ if (isNewHost)
646
+ addedHosts++;
647
+ }
648
+ catch (e) {
649
+ warnings.push(`跳过非法主机 ${hName}: ${String(e?.message || e)}`);
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ }
656
+ if (hasConnections) {
657
+ warnings.push("检测到旧格式 connections,已忽略以 projects 为准");
658
+ }
659
+ }
660
+ else if (hasConnections) {
661
+ warnings.push("检测到旧格式 connections,已自动归入 default/default");
662
+ if (!cfg.projects.default) {
663
+ cfg.projects.default = { displayName: "默认项目", environments: {} };
664
+ addedProjects++;
665
+ }
666
+ if (!cfg.projects.default.environments)
667
+ cfg.projects.default.environments = {};
668
+ if (!cfg.projects.default.environments.default) {
669
+ cfg.projects.default.environments.default = { displayName: "默认环境", hosts: {} };
670
+ addedEnvironments++;
671
+ }
672
+ const targetHosts = cfg.projects.default.environments.default.hosts;
673
+ const raw = body.connections;
674
+ let list = [];
675
+ if (Array.isArray(raw)) {
676
+ list = raw.map((v) => ({ val: v }));
677
+ }
678
+ else if (typeof raw === "object") {
679
+ list = Object.entries(raw).map(([k, v]) => ({ key: k, val: v }));
680
+ }
681
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
682
+ for (const { key, val } of list) {
683
+ const inferredName = (val && typeof val.name === "string" && val.name.trim()) ? val.name.trim() : key?.trim();
684
+ if (!inferredName) {
685
+ warnings.push(`跳过非法主机名: ${inferredName || "(empty)"}`);
686
+ continue;
687
+ }
688
+ try {
689
+ const parsed = ConnectionSchema.passthrough().parse({ ...val, name: inferredName });
690
+ const normalized = CommandLineParser.normalizeConfig(parsed);
691
+ normalized.name = inferredName;
692
+ if (!targetHosts[inferredName])
693
+ addedHosts++;
694
+ targetHosts[inferredName] = normalized;
695
+ }
696
+ catch (e) {
697
+ warnings.push(`跳过非法主机 ${inferredName}: ${String(e?.message || e)}`);
698
+ }
699
+ }
700
+ }
701
+ else {
702
+ // also support body itself being connections (legacy direct array/record)
703
+ // Check if body looks like connections: array of hosts or flat record with host/port
704
+ const looksLikeConnections = Array.isArray(body) || (typeof body === "object" && Object.values(body).some((v) => v && typeof v === "object" && "host" in v));
705
+ if (looksLikeConnections) {
706
+ warnings.push("检测到旧格式 connections,已自动归入 default/default");
707
+ if (!cfg.projects.default) {
708
+ cfg.projects.default = { displayName: "默认项目", environments: {} };
709
+ addedProjects++;
710
+ }
711
+ if (!cfg.projects.default.environments)
712
+ cfg.projects.default.environments = {};
713
+ if (!cfg.projects.default.environments.default) {
714
+ cfg.projects.default.environments.default = { displayName: "默认环境", hosts: {} };
715
+ addedEnvironments++;
716
+ }
717
+ const targetHosts = cfg.projects.default.environments.default.hosts;
718
+ const raw = body;
719
+ let list = [];
720
+ if (Array.isArray(raw))
721
+ list = raw.map((v) => ({ val: v }));
722
+ else
723
+ list = Object.entries(raw).map(([k, v]) => ({ key: k, val: v }));
724
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
725
+ for (const { key, val } of list) {
726
+ const inferredName = (val && typeof val.name === "string" && val.name.trim()) ? val.name.trim() : key?.trim();
727
+ if (!inferredName) {
728
+ warnings.push(`跳过非法主机名: ${inferredName || "(empty)"}`);
729
+ continue;
730
+ }
731
+ try {
732
+ const parsed = ConnectionSchema.passthrough().parse({ ...val, name: inferredName });
733
+ const normalized = CommandLineParser.normalizeConfig(parsed);
734
+ normalized.name = inferredName;
735
+ if (!targetHosts[inferredName])
736
+ addedHosts++;
737
+ targetHosts[inferredName] = normalized;
738
+ }
739
+ catch (e) {
740
+ warnings.push(`跳过非法主机 ${inferredName}: ${String(e?.message || e)}`);
741
+ }
742
+ }
743
+ }
744
+ else {
745
+ reply.code(400);
746
+ return { ok: false, code: "INVALID_IMPORT", message: "导入数据为空或格式不正确", warnings };
747
+ }
748
+ }
749
+ try {
750
+ GlobalConfigSchema.parse(cfg);
751
+ }
752
+ catch (e) {
753
+ reply.code(400);
754
+ return { ok: false, code: "INVALID_IMPORT", message: String(e?.message || e), warnings };
755
+ }
756
+ await store.save(cfg);
757
+ return { ok: true, addedProjects, updatedProjects, addedEnvironments, addedHosts, warnings, count: addedHosts };
758
+ }
759
+ catch (e) {
760
+ reply.code(400);
761
+ return { ok: false, code: "INVALID_IMPORT", message: String(e?.message || e) };
762
+ }
763
+ });
764
+ // legacy flat POST /admin/api/connections (keep for compat, but now operates on default/default)
765
+ app.post("/admin/api/connections", async (req, reply) => {
766
+ try {
767
+ const body = req.body;
768
+ const parsed = ConnectionSchema.passthrough().parse(body);
769
+ const { CommandLineParser } = await import("../../cli/command-line-parser.js");
770
+ const normalized = CommandLineParser.normalizeConfig(parsed);
771
+ const cfg = await store.load();
772
+ cfg.projects = cfg.projects || {};
773
+ if (!cfg.projects.default)
774
+ cfg.projects.default = { displayName: "默认项目", environments: {} };
775
+ if (!cfg.projects.default.environments)
776
+ cfg.projects.default.environments = {};
777
+ if (!cfg.projects.default.environments.default)
778
+ cfg.projects.default.environments.default = { displayName: "默认环境", hosts: {} };
779
+ const hosts = cfg.projects.default.environments.default.hosts;
780
+ const existing = hosts[normalized.name];
781
+ if (existing) {
782
+ if (!normalized.password && existing.password)
783
+ normalized.password = existing.password;
784
+ if (!normalized.passphrase && existing.passphrase)
785
+ normalized.passphrase = existing.passphrase;
786
+ }
787
+ hosts[normalized.name] = normalized;
788
+ await store.save(cfg);
789
+ return { ok: true };
790
+ }
791
+ catch (e) {
792
+ reply.code(400);
793
+ return { ok: false, code: "INVALID_CONNECTION", message: String(e?.message || e), retriable: false };
794
+ }
795
+ });
796
+ app.delete("/admin/api/connections/:name", async (req, reply) => {
797
+ const cfg = await store.load();
798
+ // try to find flat host by name across projects
799
+ const flat = getFlatHosts(cfg);
800
+ let targetFlat = null;
801
+ if (flat.has(req.params.name))
802
+ targetFlat = req.params.name;
803
+ else {
804
+ const candidates = [];
805
+ for (const [k, v] of flat)
806
+ if (v.host === req.params.name)
807
+ candidates.push(k);
808
+ if (candidates.length === 1)
809
+ targetFlat = candidates[0];
810
+ }
811
+ if (targetFlat) {
812
+ const [p, e, h] = targetFlat.split("/");
813
+ delete cfg.projects[p]?.environments?.[e]?.hosts?.[h];
814
+ await store.save(cfg);
815
+ return { ok: true };
816
+ }
817
+ // fallback: delete from default/default if exists
818
+ if (cfg.projects?.default?.environments?.default?.hosts?.[req.params.name]) {
819
+ delete cfg.projects.default.environments.default.hosts[req.params.name];
820
+ await store.save(cfg);
821
+ return { ok: true };
822
+ }
823
+ reply.code(404);
824
+ return { ok: false, code: "HOST_NOT_FOUND", message: `主机不存在: ${req.params.name}` };
825
+ });
826
+ app.get("/admin/api/security", async () => {
827
+ const cfg = await store.load();
828
+ return cfg.security || { commandWhitelist: [], commandBlacklist: [...DEFAULT_COMMAND_BLACKLIST], allowedLocalPaths: [], allowedRemotePaths: [] };
829
+ });
830
+ // 全局默认值(唯一来源 services/defaults.ts),供前端下拉选项/恢复默认等场景取用
831
+ app.get("/admin/api/defaults", async () => {
832
+ return {
833
+ defaultEnvironments: [...DEFAULT_ENVIRONMENTS],
834
+ defaultCommandBlacklist: [...DEFAULT_COMMAND_BLACKLIST],
835
+ };
836
+ });
837
+ app.post("/admin/api/security", async (req, reply) => {
838
+ const body = req.body || {};
839
+ const normalizeList = (arr) => {
840
+ if (!arr)
841
+ return [];
842
+ const list = Array.isArray(arr) ? arr : String(arr).split("\n");
843
+ return list.map((s) => s.trim()).filter(Boolean).map((s) => {
844
+ const p = s.startsWith("^") ? s : "^" + s;
845
+ try {
846
+ new RegExp(p);
847
+ }
848
+ catch {
849
+ throw new Error("正则格式不正确: " + s);
850
+ }
851
+ return p;
852
+ });
853
+ };
854
+ const parsePaths = (v) => {
855
+ if (!v)
856
+ return [];
857
+ if (Array.isArray(v))
858
+ return v.map((s) => String(s).trim()).filter(Boolean);
859
+ return String(v).split(",").map((s) => s.trim()).filter(Boolean);
860
+ };
861
+ try {
862
+ const security = {
863
+ commandWhitelist: normalizeList(body.commandWhitelist ?? body.whitelist),
864
+ commandBlacklist: normalizeList(body.commandBlacklist ?? body.blacklist),
865
+ allowedLocalPaths: parsePaths(body.allowedLocalPaths),
866
+ allowedRemotePaths: parsePaths(body.allowedRemotePaths),
867
+ };
868
+ const cfg = await store.load();
869
+ cfg.security = security;
870
+ await store.save(cfg);
871
+ return { ok: true, security };
872
+ }
873
+ catch (e) {
874
+ reply.code(400);
875
+ return { ok: false, code: "INVALID_SECURITY", message: String(e.message) };
876
+ }
877
+ });
878
+ app.post("/admin/api/test-connection", async (req) => {
879
+ const conf = req.body || {};
880
+ const start = Date.now();
881
+ // 支持密码 / 私钥(路径,~ 展开)/ agent 三种认证,与主机配置保持一致
882
+ const connectOpts = { host: conf.host, port: conf.port ?? 22, username: conf.username, readyTimeout: 7000 };
883
+ if (conf.password)
884
+ connectOpts.password = conf.password;
885
+ if (conf.privateKey) {
886
+ try {
887
+ const keyPath = conf.privateKey.replace(/^~(?=$|[\\/])/, os.homedir());
888
+ connectOpts.privateKey = await fs.readFile(keyPath, "utf-8");
889
+ if (conf.passphrase)
890
+ connectOpts.passphrase = conf.passphrase;
891
+ }
892
+ catch {
893
+ return { ok: false, code: "KEY_READ_FAILED", message: `无法读取私钥: ${conf.privateKey}`, retriable: false };
894
+ }
895
+ }
896
+ else if (conf.agent) {
897
+ connectOpts.agent = conf.agent;
898
+ }
899
+ return await new Promise((resolve) => {
900
+ const conn = new Client();
901
+ let done = false;
902
+ const timer = setTimeout(() => {
903
+ if (!done) {
904
+ done = true;
905
+ conn.end();
906
+ resolve({ ok: false, code: "TIMEOUT", message: "timeout", retriable: true });
907
+ }
908
+ }, 8000);
909
+ conn
910
+ .on("ready", () => {
911
+ if (done)
912
+ return;
913
+ done = true;
914
+ clearTimeout(timer);
915
+ conn.end();
916
+ resolve({ ok: true, latencyMs: Date.now() - start });
917
+ })
918
+ .on("error", (e) => {
919
+ if (done)
920
+ return;
921
+ done = true;
922
+ clearTimeout(timer);
923
+ resolve({ ok: false, code: "CONNECT_FAILED", message: String(e.message), retriable: true });
924
+ })
925
+ .connect(connectOpts);
926
+ });
927
+ });
928
+ }
929
+ //# sourceMappingURL=admin.js.map