mstool-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,12 @@
1
+ # Agent Node 环境变量
2
+
3
+ # Platform WebSocket 地址
4
+ PLATFORM_WS_URL=ws://localhost:3000/ws/agent
5
+
6
+ # 首次注册时设置(注册成功后可删除)
7
+ REGISTRATION_TOKEN=
8
+
9
+ # 注册成功后自动保存到 .node-credentials.json
10
+ # 也可以手动设置(重连已注册节点时使用)
11
+ # NODE_ID=
12
+ # NODE_SECRET=
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ require('tsx/cjs');
3
+ require('../src/index.ts');
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "mstool-agent",
3
+ "version": "1.0.0",
4
+ "description": "MSTool Agent Node - AI Agent 运维节点",
5
+ "main": "src/index.ts",
6
+ "bin": {
7
+ "mstool-agent": "./bin/mstool-agent.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/mstool-agent.js"
11
+ },
12
+ "dependencies": {
13
+ "mstool-shared": "^1.0.0",
14
+ "ws": "^8.18.0",
15
+ "ssh2": "^1.15.0",
16
+ "oracledb": "^6.5.1",
17
+ "mysql2": "^3.10.1",
18
+ "pg": "^8.12.0",
19
+ "uuid": "^10.0.0",
20
+ "node-cron": "^3.0.3",
21
+ "dotenv": "^16.4.5",
22
+ "zod": "^3.23.8",
23
+ "tsx": "^4.19.0"
24
+ },
25
+ "keywords": ["mstool", "agent", "ops", "ai"],
26
+ "license": "MIT",
27
+ "repository": { "type": "git", "url": "https://github.com/scaleflower/mstools" }
28
+ }
package/src/client.ts ADDED
@@ -0,0 +1,186 @@
1
+ import WebSocket from 'ws';
2
+ import { randomUUID } from 'crypto';
3
+ import os from 'os';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import {
7
+ WsMessage, WsMessageType, WsRegisterPayload, WsRegisterAckPayload,
8
+ WsToolRequest, WsToolResult, WsError,
9
+ } from 'mstool-shared';
10
+ import { toolRegistry } from './tools';
11
+
12
+ interface AgentNodeClientOptions {
13
+ platformUrl: string;
14
+ registrationToken?: string;
15
+ nodeId?: string;
16
+ nodeSecret?: string;
17
+ }
18
+
19
+ const CREDENTIALS_FILE = path.join(process.cwd(), '.node-credentials.json');
20
+ const VERSION = '1.0.0';
21
+
22
+ export class AgentNodeClient {
23
+ private ws: WebSocket | null = null;
24
+ private reconnectTimer: NodeJS.Timeout | null = null;
25
+ private nodeId?: string;
26
+ private nodeSecret?: string;
27
+ private registrationToken?: string;
28
+ private platformUrl: string;
29
+
30
+ constructor(opts: AgentNodeClientOptions) {
31
+ this.platformUrl = opts.platformUrl;
32
+ this.registrationToken = opts.registrationToken;
33
+ this.nodeId = opts.nodeId;
34
+ this.nodeSecret = opts.nodeSecret;
35
+
36
+ if (!this.nodeId && fs.existsSync(CREDENTIALS_FILE)) {
37
+ const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
38
+ this.nodeId = creds.nodeId;
39
+ this.nodeSecret = creds.nodeSecret;
40
+ }
41
+ }
42
+
43
+ connect() {
44
+ console.log(`连接到 Platform: ${this.platformUrl}`);
45
+ this.ws = new WebSocket(this.platformUrl);
46
+
47
+ this.ws.on('open', () => {
48
+ console.log('WebSocket 连接已建立');
49
+ if (this.registrationToken && !this.nodeId) {
50
+ this.register();
51
+ } else if (this.nodeId && this.nodeSecret) {
52
+ this.authenticate();
53
+ } else {
54
+ console.error('缺少注册 Token 或节点凭据');
55
+ this.ws?.close();
56
+ }
57
+ });
58
+
59
+ this.ws.on('message', (raw) => {
60
+ try {
61
+ const msg: WsMessage = JSON.parse(raw.toString());
62
+ this.handleMessage(msg);
63
+ } catch (e) {
64
+ console.error('消息解析失败', e);
65
+ }
66
+ });
67
+
68
+ this.ws.on('close', (code, reason) => {
69
+ console.log(`连接断开 [${code}]: ${reason?.toString()}`);
70
+ this.scheduleReconnect();
71
+ });
72
+
73
+ this.ws.on('error', (err) => {
74
+ console.error('WebSocket 错误', err.message);
75
+ });
76
+ }
77
+
78
+ disconnect() {
79
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
80
+ this.ws?.close();
81
+ }
82
+
83
+ private register() {
84
+ const payload: WsRegisterPayload = {
85
+ token: this.registrationToken!,
86
+ version: VERSION,
87
+ hostname: os.hostname(),
88
+ };
89
+ const msg: WsMessage<WsRegisterPayload> = {
90
+ task_id: randomUUID(),
91
+ type: WsMessageType.REGISTER,
92
+ payload,
93
+ };
94
+ this.ws!.send(JSON.stringify(msg));
95
+ }
96
+
97
+ private authenticate() {
98
+ const msg: WsMessage = {
99
+ task_id: randomUUID(),
100
+ type: WsMessageType.PING,
101
+ payload: { nodeId: this.nodeId, nodeSecret: this.nodeSecret, version: VERSION },
102
+ };
103
+ this.ws!.send(JSON.stringify(msg));
104
+ }
105
+
106
+ private handleMessage(msg: WsMessage) {
107
+ switch (msg.type) {
108
+ case WsMessageType.REGISTER_ACK:
109
+ this.handleRegisterAck(msg as WsMessage<WsRegisterAckPayload>);
110
+ break;
111
+ case WsMessageType.REQUEST:
112
+ this.handleToolRequest(msg as WsMessage<WsToolRequest>);
113
+ break;
114
+ case WsMessageType.PING:
115
+ this.send({ task_id: msg.task_id, type: WsMessageType.PONG, payload: {} });
116
+ break;
117
+ default:
118
+ console.warn('未知消息类型', msg.type);
119
+ }
120
+ }
121
+
122
+ private handleRegisterAck(msg: WsMessage<WsRegisterAckPayload>) {
123
+ const { nodeId, nodeSecret } = msg.payload;
124
+ this.nodeId = nodeId;
125
+ this.nodeSecret = nodeSecret;
126
+ this.registrationToken = undefined;
127
+
128
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ nodeId, nodeSecret }, null, 2));
129
+ console.log(`注册成功,Node ID: ${nodeId}`);
130
+ console.log('凭据已保存到', CREDENTIALS_FILE);
131
+ }
132
+
133
+ private async handleToolRequest(msg: WsMessage<WsToolRequest>) {
134
+ const { tool, args } = msg.payload;
135
+ console.log(`执行工具: ${tool}`, JSON.stringify(args).slice(0, 100));
136
+
137
+ const toolFn = toolRegistry[tool];
138
+ if (!toolFn) {
139
+ this.send({
140
+ task_id: msg.task_id,
141
+ type: WsMessageType.ERROR,
142
+ payload: { code: 'UNKNOWN_TOOL', message: `未知工具: ${tool}` } satisfies WsError,
143
+ });
144
+ return;
145
+ }
146
+
147
+ try {
148
+ const result = await toolFn(args, (chunk: string) => {
149
+ this.send({
150
+ task_id: msg.task_id,
151
+ type: WsMessageType.STREAM_CHUNK,
152
+ payload: { chunk },
153
+ });
154
+ });
155
+
156
+ this.send({
157
+ task_id: msg.task_id,
158
+ type: WsMessageType.RESULT,
159
+ payload: { output: result } satisfies WsToolResult,
160
+ });
161
+ } catch (e: unknown) {
162
+ const message = e instanceof Error ? e.message : String(e);
163
+ this.send({
164
+ task_id: msg.task_id,
165
+ type: WsMessageType.ERROR,
166
+ payload: { code: 'TOOL_ERROR', message } satisfies WsError,
167
+ });
168
+ }
169
+ }
170
+
171
+ private send(msg: WsMessage) {
172
+ if (this.ws?.readyState === WebSocket.OPEN) {
173
+ this.ws.send(JSON.stringify(msg));
174
+ }
175
+ }
176
+
177
+ private scheduleReconnect() {
178
+ if (this.reconnectTimer) return;
179
+ const delay = 5000;
180
+ console.log(`${delay / 1000}s 后重连...`);
181
+ this.reconnectTimer = setTimeout(() => {
182
+ this.reconnectTimer = null;
183
+ this.connect();
184
+ }, delay);
185
+ }
186
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const STORE_FILE = path.join(process.cwd(), '.db-credentials.json');
5
+
6
+ interface DbCredential {
7
+ username: string;
8
+ password: string;
9
+ connectString: string;
10
+ }
11
+
12
+ class CredentialStore {
13
+ private store: Record<string, DbCredential> = {};
14
+
15
+ constructor() {
16
+ if (fs.existsSync(STORE_FILE)) {
17
+ try {
18
+ this.store = JSON.parse(fs.readFileSync(STORE_FILE, 'utf8'));
19
+ } catch {
20
+ this.store = {};
21
+ }
22
+ }
23
+ }
24
+
25
+ getDbCredential(connectionId: string): DbCredential | undefined {
26
+ return this.store[connectionId];
27
+ }
28
+
29
+ setDbCredential(connectionId: string, cred: DbCredential) {
30
+ this.store[connectionId] = cred;
31
+ fs.writeFileSync(STORE_FILE, JSON.stringify(this.store, null, 2), { mode: 0o600 });
32
+ }
33
+
34
+ removeDbCredential(connectionId: string) {
35
+ delete this.store[connectionId];
36
+ fs.writeFileSync(STORE_FILE, JSON.stringify(this.store, null, 2), { mode: 0o600 });
37
+ }
38
+ }
39
+
40
+ export const NodeCredentialStore = new CredentialStore();
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import 'dotenv/config';
2
+ import { AgentNodeClient } from './client';
3
+
4
+ const PLATFORM_URL = process.env.PLATFORM_WS_URL || 'ws://localhost:3000/ws/agent';
5
+ const REGISTRATION_TOKEN = process.env.REGISTRATION_TOKEN;
6
+ const NODE_ID = process.env.NODE_ID;
7
+ const NODE_SECRET = process.env.NODE_SECRET;
8
+
9
+ if (!REGISTRATION_TOKEN && (!NODE_ID || !NODE_SECRET)) {
10
+ console.error('需要设置 REGISTRATION_TOKEN(首次注册)或 NODE_ID + NODE_SECRET(重连)');
11
+ process.exit(1);
12
+ }
13
+
14
+ const client = new AgentNodeClient({
15
+ platformUrl: PLATFORM_URL,
16
+ registrationToken: REGISTRATION_TOKEN,
17
+ nodeId: NODE_ID,
18
+ nodeSecret: NODE_SECRET,
19
+ });
20
+
21
+ client.connect();
22
+
23
+ process.on('SIGINT', () => { client.disconnect(); process.exit(0); });
24
+ process.on('SIGTERM', () => { client.disconnect(); process.exit(0); });
@@ -0,0 +1,52 @@
1
+ interface AwrGenerateOptions {
2
+ connectionId: string;
3
+ dbId?: string;
4
+ instanceNum?: number;
5
+ beginSnapId: number;
6
+ endSnapId: number;
7
+ }
8
+
9
+ export async function awrGenerate(opts: AwrGenerateOptions, onChunk?: (chunk: string) => void): Promise<string> {
10
+ let oracledb: any;
11
+ try {
12
+ oracledb = require('oracledb');
13
+ } catch {
14
+ throw new Error('oracledb 模块未安装,无法生成 AWR 报告');
15
+ }
16
+
17
+ const { NodeCredentialStore } = require('../credential-store');
18
+ const cred = NodeCredentialStore.getDbCredential(opts.connectionId);
19
+ if (!cred) throw new Error(`未找到连接 ${opts.connectionId} 的凭据`);
20
+
21
+ oracledb.fetchAsString = [oracledb.CLOB];
22
+
23
+ const connection = await oracledb.getConnection({
24
+ user: cred.username,
25
+ password: cred.password,
26
+ connectString: cred.connectString,
27
+ });
28
+
29
+ onChunk?.('正在生成 AWR 报告...\n');
30
+
31
+ try {
32
+ const dbIdParam = opts.dbId ? `l_db_id => '${opts.dbId}',` : '';
33
+ const instanceParam = opts.instanceNum != null ? `l_inst_num => ${opts.instanceNum},` : '';
34
+
35
+ const result = await connection.execute(
36
+ `SELECT DBMS_WORKLOAD_REPOSITORY.AWR_REPORT_HTML(
37
+ ${dbIdParam}
38
+ ${instanceParam}
39
+ l_snap_id_set => SYS.AWRRPT_SEQ_TYPE(${opts.beginSnapId}, ${opts.endSnapId}),
40
+ l_global => 0
41
+ ) FROM DUAL`,
42
+ [],
43
+ { fetchInfo: { 'DBMS_WORKLOAD_REPOSITORY.AWR_REPORT_HTML': { type: oracledb.STRING } } },
44
+ );
45
+
46
+ const html = (result.rows as any[])[0][0];
47
+ onChunk?.('AWR 报告生成完成\n');
48
+ return html;
49
+ } finally {
50
+ await connection.close();
51
+ }
52
+ }
@@ -0,0 +1,67 @@
1
+ import { sshExec } from './ssh';
2
+
3
+ interface SshConnectionBase {
4
+ host: string;
5
+ port: number;
6
+ username: string;
7
+ authType: 'key' | 'password';
8
+ password?: string;
9
+ jumpHosts: Array<{ host: string; port: number; username: string; authType: 'key' | 'password' }>;
10
+ }
11
+
12
+ interface CollectMetricsOptions extends SshConnectionBase {}
13
+
14
+ interface CollectProcessesOptions extends SshConnectionBase {
15
+ sortBy: 'cpu' | 'memory';
16
+ limit: number;
17
+ }
18
+
19
+ const METRICS_SCRIPT = `
20
+ python3 -c "
21
+ import json, os, time
22
+ with open('/proc/meminfo') as f:
23
+ meminfo = {k.strip(): int(v.split()[0]) for line in f for k, _, v in [line.partition(':')]}
24
+ with open('/proc/stat') as f:
25
+ cpu_line = f.readline().split()
26
+ idle1 = int(cpu_line[4])
27
+ total1 = sum(int(x) for x in cpu_line[1:])
28
+ time.sleep(0.5)
29
+ with open('/proc/stat') as f:
30
+ cpu_line = f.readline().split()
31
+ idle2 = int(cpu_line[4])
32
+ total2 = sum(int(x) for x in cpu_line[1:])
33
+ cpu_pct = round((1 - (idle2-idle1)/(total2-total1))*100, 2)
34
+ mem_total = meminfo.get('MemTotal', 0) * 1024
35
+ mem_free = (meminfo.get('MemFree', 0) + meminfo.get('Buffers', 0) + meminfo.get('Cached', 0)) * 1024
36
+ mem_used = mem_total - mem_free
37
+ disk = os.statvfs('/')
38
+ disk_total = disk.f_blocks * disk.f_frsize
39
+ disk_used = (disk.f_blocks - disk.f_bfree) * disk.f_frsize
40
+ print(json.dumps({'cpu_percent': cpu_pct, 'memory_used_bytes': mem_used, 'memory_total_bytes': mem_total, 'memory_percent': round(mem_used/mem_total*100, 2) if mem_total else 0, 'disk_used_bytes': disk_used, 'disk_total_bytes': disk_total, 'network_rx_bytes_per_sec': 0, 'network_tx_bytes_per_sec': 0}))
41
+ " 2>/dev/null || echo '{"error":"python3 not available"}'
42
+ `.trim();
43
+
44
+ export async function collectMetrics(opts: CollectMetricsOptions) {
45
+ const output = await sshExec({ ...opts, jumpHosts: opts.jumpHosts, command: METRICS_SCRIPT });
46
+ try {
47
+ return JSON.parse(output.trim());
48
+ } catch {
49
+ return { raw: output };
50
+ }
51
+ }
52
+
53
+ export async function collectProcesses(opts: CollectProcessesOptions) {
54
+ const sortField = opts.sortBy === 'cpu' ? '%cpu' : '%mem';
55
+ const cmd = `ps aux --sort=-${sortField} | head -n ${opts.limit + 1} | awk 'NR>1 {print $2,$3,$4,$11}' | head -n ${opts.limit}`;
56
+ const output = await sshExec({ ...opts, jumpHosts: opts.jumpHosts, command: cmd });
57
+ const processes = output.trim().split('\n').filter(Boolean).map((line) => {
58
+ const [pid, cpu, memory, ...nameParts] = line.split(' ');
59
+ return {
60
+ pid: parseInt(pid, 10),
61
+ cpuPercent: parseFloat(cpu),
62
+ memoryPercent: parseFloat(memory),
63
+ name: nameParts.join(' '),
64
+ };
65
+ });
66
+ return { processes, collectedAt: new Date().toISOString() };
67
+ }
@@ -0,0 +1,114 @@
1
+ interface DbQueryOptions {
2
+ connectionId: string;
3
+ dbType: 'oracle' | 'mysql' | 'postgresql';
4
+ host: string;
5
+ port: number;
6
+ serviceName: string;
7
+ username: string;
8
+ password: string;
9
+ sql: string;
10
+ maxRows: number;
11
+ }
12
+
13
+ export async function dbQuery(opts: DbQueryOptions): Promise<unknown> {
14
+ switch (opts.dbType) {
15
+ case 'oracle':
16
+ return queryOracle(opts);
17
+ case 'mysql':
18
+ return queryMysql(opts);
19
+ case 'postgresql':
20
+ return queryPostgres(opts);
21
+ default:
22
+ throw new Error(`不支持的数据库类型: ${opts.dbType}`);
23
+ }
24
+ }
25
+
26
+ async function queryOracle(opts: DbQueryOptions) {
27
+ let oracledb: any;
28
+ try {
29
+ oracledb = require('oracledb');
30
+ } catch {
31
+ throw new Error('oracledb 模块未安装');
32
+ }
33
+
34
+ oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
35
+ oracledb.fetchAsString = [oracledb.CLOB];
36
+
37
+ const connection = await oracledb.getConnection({
38
+ user: opts.username,
39
+ password: opts.password,
40
+ connectString: `${opts.host}:${opts.port}/${opts.serviceName}`,
41
+ });
42
+
43
+ try {
44
+ const limitedSql = wrapWithRowLimit(opts.sql, opts.maxRows, 'oracle');
45
+ const result = await connection.execute(limitedSql, [], { maxRows: opts.maxRows });
46
+ return {
47
+ columns: result.metaData?.map((c: any) => c.name) ?? [],
48
+ rows: result.rows ?? [],
49
+ rowCount: result.rows?.length ?? 0,
50
+ };
51
+ } finally {
52
+ await connection.close();
53
+ }
54
+ }
55
+
56
+ async function queryMysql(opts: DbQueryOptions) {
57
+ const mysql2 = require('mysql2/promise');
58
+ const connection = await mysql2.createConnection({
59
+ host: opts.host,
60
+ port: opts.port,
61
+ user: opts.username,
62
+ password: opts.password,
63
+ database: opts.serviceName,
64
+ });
65
+
66
+ try {
67
+ const limitedSql = wrapWithRowLimit(opts.sql, opts.maxRows, 'mysql');
68
+ const [rows, fields] = await connection.execute(limitedSql);
69
+ return {
70
+ columns: (fields as any[]).map((f) => f.name),
71
+ rows,
72
+ rowCount: (rows as unknown[]).length,
73
+ };
74
+ } finally {
75
+ await connection.end();
76
+ }
77
+ }
78
+
79
+ async function queryPostgres(opts: DbQueryOptions) {
80
+ const { Pool } = require('pg');
81
+ const pool = new Pool({
82
+ host: opts.host,
83
+ port: opts.port,
84
+ user: opts.username,
85
+ password: opts.password,
86
+ database: opts.serviceName,
87
+ });
88
+
89
+ try {
90
+ const limitedSql = wrapWithRowLimit(opts.sql, opts.maxRows, 'postgresql');
91
+ const result = await pool.query(limitedSql);
92
+ return {
93
+ columns: result.fields.map((f: any) => f.name),
94
+ rows: result.rows,
95
+ rowCount: result.rowCount,
96
+ };
97
+ } finally {
98
+ await pool.end();
99
+ }
100
+ }
101
+
102
+ function wrapWithRowLimit(sql: string, maxRows: number, dbType: string): string {
103
+ const trimmed = sql.trim().replace(/;\s*$/, '');
104
+ switch (dbType) {
105
+ case 'oracle':
106
+ return `SELECT * FROM (${trimmed}) WHERE ROWNUM <= ${maxRows}`;
107
+ case 'mysql':
108
+ return `${trimmed} LIMIT ${maxRows}`;
109
+ case 'postgresql':
110
+ return `${trimmed} LIMIT ${maxRows}`;
111
+ default:
112
+ return trimmed;
113
+ }
114
+ }
@@ -0,0 +1,68 @@
1
+ import { sshExec } from './ssh';
2
+ import { dbQuery } from './database';
3
+ import { collectMetrics, collectProcesses } from './collection';
4
+ import { awrGenerate } from './awr';
5
+
6
+ type ToolFn = (args: Record<string, unknown>, onChunk?: (chunk: string) => void) => Promise<unknown>;
7
+
8
+ export const toolRegistry: Record<string, ToolFn> = {
9
+ ssh_exec: async (args, onChunk) => {
10
+ return sshExec({
11
+ host: args.host as string,
12
+ port: (args.port as number) || 22,
13
+ username: args.username as string,
14
+ authType: args.authType as 'key' | 'password',
15
+ password: args.password as string | undefined,
16
+ jumpHosts: (args.jumpHosts as any[]) || [],
17
+ command: args.command as string,
18
+ }, onChunk);
19
+ },
20
+
21
+ collect_metrics: async (args) => {
22
+ return collectMetrics({
23
+ host: args.host as string,
24
+ port: (args.port as number) || 22,
25
+ username: args.username as string,
26
+ authType: args.authType as 'key' | 'password',
27
+ password: args.password as string | undefined,
28
+ jumpHosts: (args.jumpHosts as any[]) || [],
29
+ });
30
+ },
31
+
32
+ collect_processes: async (args) => {
33
+ return collectProcesses({
34
+ host: args.host as string,
35
+ port: (args.port as number) || 22,
36
+ username: args.username as string,
37
+ authType: args.authType as 'key' | 'password',
38
+ password: args.password as string | undefined,
39
+ jumpHosts: (args.jumpHosts as any[]) || [],
40
+ sortBy: (args.sortBy as 'cpu' | 'memory') || 'cpu',
41
+ limit: (args.limit as number) || 20,
42
+ });
43
+ },
44
+
45
+ db_query: async (args) => {
46
+ return dbQuery({
47
+ connectionId: args.connectionId as string,
48
+ dbType: args.dbType as 'oracle' | 'mysql' | 'postgresql',
49
+ host: args.host as string,
50
+ port: args.port as number,
51
+ serviceName: args.serviceName as string,
52
+ username: args.username as string,
53
+ password: args.password as string,
54
+ sql: args.sql as string,
55
+ maxRows: (args.maxRows as number) || 100,
56
+ });
57
+ },
58
+
59
+ awr_generate: async (args, onChunk) => {
60
+ return awrGenerate({
61
+ connectionId: args.connectionId as string,
62
+ dbId: args.dbId as string | undefined,
63
+ instanceNum: args.instanceNum as number | undefined,
64
+ beginSnapId: args.beginSnapId as number,
65
+ endSnapId: args.endSnapId as number,
66
+ }, onChunk);
67
+ },
68
+ };
@@ -0,0 +1,122 @@
1
+ import { Client, ConnectConfig } from 'ssh2';
2
+
3
+ interface SshConfig {
4
+ host: string;
5
+ port: number;
6
+ username: string;
7
+ authType: 'key' | 'password';
8
+ password?: string;
9
+ privateKeyPath?: string;
10
+ jumpHosts: Array<{ host: string; port: number; username: string; authType: 'key' | 'password' }>;
11
+ command: string;
12
+ }
13
+
14
+ function getConnectConfig(cfg: Omit<SshConfig, 'command' | 'jumpHosts'>): ConnectConfig {
15
+ const base: ConnectConfig = {
16
+ host: cfg.host,
17
+ port: cfg.port,
18
+ username: cfg.username,
19
+ readyTimeout: 15000,
20
+ };
21
+
22
+ if (cfg.authType === 'password' && cfg.password) {
23
+ base.password = cfg.password;
24
+ } else {
25
+ const keyPath = cfg.privateKeyPath || `${process.env.HOME}/.ssh/id_rsa`;
26
+ try {
27
+ const fs = require('fs');
28
+ base.privateKey = fs.readFileSync(keyPath);
29
+ } catch {
30
+ throw new Error(`无法读取 SSH 私钥: ${keyPath}`);
31
+ }
32
+ }
33
+
34
+ return base;
35
+ }
36
+
37
+ export function sshExec(cfg: SshConfig, onChunk?: (chunk: string) => void): Promise<string> {
38
+ return new Promise((resolve, reject) => {
39
+ const output: string[] = [];
40
+
41
+ if (cfg.jumpHosts.length === 0) {
42
+ executeDirectly(cfg, output, onChunk, resolve, reject);
43
+ } else {
44
+ executeViaJump(cfg, output, onChunk, resolve, reject);
45
+ }
46
+ });
47
+ }
48
+
49
+ function executeDirectly(
50
+ cfg: SshConfig,
51
+ output: string[],
52
+ onChunk: ((c: string) => void) | undefined,
53
+ resolve: (v: string) => void,
54
+ reject: (e: Error) => void,
55
+ ) {
56
+ const conn = new Client();
57
+ conn.on('ready', () => {
58
+ conn.exec(cfg.command, (err, stream) => {
59
+ if (err) { conn.end(); return reject(err); }
60
+ stream.on('data', (data: Buffer) => {
61
+ const chunk = data.toString();
62
+ output.push(chunk);
63
+ onChunk?.(chunk);
64
+ });
65
+ stream.stderr.on('data', (data: Buffer) => {
66
+ const chunk = `[stderr] ${data.toString()}`;
67
+ output.push(chunk);
68
+ onChunk?.(chunk);
69
+ });
70
+ stream.on('close', () => { conn.end(); resolve(output.join('')); });
71
+ });
72
+ });
73
+ conn.on('error', reject);
74
+ conn.connect(getConnectConfig(cfg));
75
+ }
76
+
77
+ function executeViaJump(
78
+ cfg: SshConfig,
79
+ output: string[],
80
+ onChunk: ((c: string) => void) | undefined,
81
+ resolve: (v: string) => void,
82
+ reject: (e: Error) => void,
83
+ ) {
84
+ const jump = cfg.jumpHosts[0];
85
+ const jumpConn = new Client();
86
+
87
+ jumpConn.on('ready', () => {
88
+ jumpConn.forwardOut('127.0.0.1', 0, cfg.host, cfg.port, (err, stream) => {
89
+ if (err) { jumpConn.end(); return reject(err); }
90
+
91
+ const targetConn = new Client();
92
+ const targetCfg = { ...cfg, jumpHosts: cfg.jumpHosts.slice(1) };
93
+
94
+ targetConn.on('ready', () => {
95
+ if (targetCfg.jumpHosts.length === 0) {
96
+ targetConn.exec(cfg.command, (execErr, execStream) => {
97
+ if (execErr) { targetConn.end(); jumpConn.end(); return reject(execErr); }
98
+ execStream.on('data', (data: Buffer) => {
99
+ const chunk = data.toString();
100
+ output.push(chunk);
101
+ onChunk?.(chunk);
102
+ });
103
+ execStream.stderr.on('data', (data: Buffer) => {
104
+ const chunk = `[stderr] ${data.toString()}`;
105
+ output.push(chunk);
106
+ onChunk?.(chunk);
107
+ });
108
+ execStream.on('close', () => {
109
+ targetConn.end();
110
+ jumpConn.end();
111
+ resolve(output.join(''));
112
+ });
113
+ });
114
+ }
115
+ });
116
+ targetConn.on('error', (e) => { jumpConn.end(); reject(e); });
117
+ targetConn.connect({ ...getConnectConfig(cfg), sock: stream });
118
+ });
119
+ });
120
+ jumpConn.on('error', reject);
121
+ jumpConn.connect(getConnectConfig({ ...jump, privateKeyPath: undefined }));
122
+ }