koishi-plugin-docker-control 0.1.1 → 0.1.2

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.
@@ -133,18 +133,21 @@ function registerHelperCommands(ctx, getService, config) {
133
133
  const html = (0, render_1.generateNodeDetailHtml)(nodeData, version, systemInfo);
134
134
  return await (0, render_1.renderToImage)(ctx, html);
135
135
  }
136
- const memoryUsed = systemInfo?.MemTotal && systemInfo?.MemAvailable !== undefined
137
- ? `${Math.round((1 - systemInfo.MemAvailable / systemInfo.MemTotal) * 100)}%`
138
- : '-';
139
136
  const nodeName = node.config?.name || node.name || node.Name || 'Unknown';
140
137
  const nodeId = node.id || node.ID || node.Id || node.config?.id || '-';
138
+ // 格式化内存显示:总内存量
139
+ let memoryDisplay = '-';
140
+ if (systemInfo?.MemTotal) {
141
+ const memGB = (systemInfo.MemTotal / 1024 / 1024 / 1024).toFixed(2);
142
+ memoryDisplay = `${memGB} GB`;
143
+ }
141
144
  const lines = [
142
145
  `=== ${nodeName} ===`,
143
146
  `ID: ${nodeId}`,
144
147
  `状态: ${node.status || node.Status || 'unknown'}`,
145
148
  `标签: ${node.tags?.join(', ') || node.config?.tags?.join(', ') || '无'}`,
146
149
  `CPU: ${systemInfo?.NCPU || '-'} 核心`,
147
- `内存: ${memoryUsed} (可用: ${systemInfo?.MemAvailable ? Math.round(systemInfo.MemAvailable / 1024 / 1024) + ' MB' : '-'})`,
150
+ `内存: ${memoryDisplay}`,
148
151
  `容器: ${containerCount.running}/${containerCount.total} 运行中`,
149
152
  `镜像: ${imageCount} 个`,
150
153
  `Docker 版本: ${version.Version}`,
@@ -1,9 +1,18 @@
1
+ /**
2
+ * SSH 连接器 - 通过 SSH 执行 docker 命令
3
+ */
4
+ import { Client } from 'ssh2';
1
5
  import type { NodeConfig, DockerControlConfig } from '../types';
2
6
  export declare class DockerConnector {
3
7
  private config;
4
8
  private fullConfig;
5
9
  private sshClient;
6
10
  constructor(config: NodeConfig, fullConfig: DockerControlConfig);
11
+ /**
12
+ * 获取内部 SSH Client(用于连接复用)
13
+ * 如果尚未连接,会触发连接建立
14
+ */
15
+ getSshClient(): Promise<Client>;
7
16
  /**
8
17
  * 验证并修正配置
9
18
  */
@@ -17,6 +17,13 @@ class DockerConnector {
17
17
  // 立即验证并修正配置
18
18
  this.validateConfig();
19
19
  }
20
+ /**
21
+ * 获取内部 SSH Client(用于连接复用)
22
+ * 如果尚未连接,会触发连接建立
23
+ */
24
+ async getSshClient() {
25
+ return await this.getConnection();
26
+ }
20
27
  /**
21
28
  * 验证并修正配置
22
29
  */
@@ -55,7 +62,8 @@ class DockerConnector {
55
62
  const msg = err.message || '';
56
63
  // 如果是 SSH 通道打开失败,或者是连接已结束,则强制重连
57
64
  if (msg.includes('Channel open failure') || msg.includes('Client ended') || msg.includes('Socket ended')) {
58
- logger_1.connectorLogger.warn(`[${this.config.name}] SSH 连接异常 (${msg}),尝试重连...`);
65
+ logger_1.connectorLogger.warn(`[${this.config.name}] SSH连接异常: ${msg},尝试重连...`);
66
+ logger_1.connectorLogger.debug(`[${this.config.name}] 重连将产生新的SSH登录记录`);
59
67
  this.dispose(); // 强制销毁当前连接
60
68
  continue; // 重试
61
69
  }
@@ -67,11 +75,11 @@ class DockerConnector {
67
75
  }
68
76
  async execInternal(command) {
69
77
  const client = await this.getConnection();
70
- logger_1.connectorLogger.debug(`[${this.config.name}] 执行命令: ${command}`);
78
+ logger_1.connectorLogger.debug(`[${this.config.name}] 🔧 执行SSH命令: ${command}`);
71
79
  return new Promise((resolve, reject) => {
72
80
  client.exec(command, (err, stream) => {
73
81
  if (err) {
74
- logger_1.connectorLogger.debug(`[${this.config.name}] 命令执行错误: ${err.message}`);
82
+ logger_1.connectorLogger.warn(`[${this.config.name}] SSH命令执行失败: ${err.message}`);
75
83
  reject(err);
76
84
  return;
77
85
  }
@@ -196,7 +204,7 @@ class DockerConnector {
196
204
  reject(err);
197
205
  return;
198
206
  }
199
- logger_1.connectorLogger.info(`[${this.config.name}] Docker 事件流已连接`);
207
+ logger_1.connectorLogger.info(`[${this.config.name}] Docker 事件流已建立长连接 (docker events --format json --filter type=container)`);
200
208
  let buffer = '';
201
209
  let closed = false;
202
210
  const stop = () => {
@@ -211,13 +219,14 @@ class DockerConnector {
211
219
  catch (e) {
212
220
  // 可能已经关闭,忽略错误
213
221
  }
214
- logger_1.connectorLogger.debug(`[${this.config.name}] 主动停止事件流`);
222
+ logger_1.connectorLogger.info(`[${this.config.name}] 🔒 主动停止事件流`);
215
223
  }
216
224
  };
217
225
  stream.on('close', (code, signal) => {
218
226
  if (!closed) {
219
227
  closed = true;
220
- logger_1.connectorLogger.warn(`[${this.config.name}] 事件流意外断开 (Code: ${code}, Signal: ${signal})`);
228
+ logger_1.connectorLogger.error(`[${this.config.name}] 事件流意外断开!Code: ${code}, Signal: ${signal}`);
229
+ logger_1.connectorLogger.error(`[${this.config.name}] ⚠ 事件流断开后,node.ts 会自动重连 (将产生新的SSH登录记录)`);
221
230
  }
222
231
  });
223
232
  stream.on('data', (data) => {
@@ -251,6 +260,7 @@ class DockerConnector {
251
260
  */
252
261
  dispose() {
253
262
  if (this.sshClient) {
263
+ logger_1.connectorLogger.info(`[${this.config.name}] 主动销毁 SSH 连接`);
254
264
  this.sshClient.end();
255
265
  this.sshClient = null;
256
266
  }
@@ -272,29 +282,32 @@ class DockerConnector {
272
282
  if (!credential) {
273
283
  throw new Error(`凭证不存在: ${this.config.credentialId}`);
274
284
  }
275
- logger_1.connectorLogger.info(`[${this.config.name}] 正在连接到 ${this.config.host}:${this.config.port}`);
276
- logger_1.connectorLogger.debug(`[${this.config.name}] 用户名: ${credential.username}, 认证方式: ${credential.authType}`);
285
+ const port = typeof this.config.port === 'string'
286
+ ? parseInt(this.config.port, 10)
287
+ : (this.config.port || 22);
288
+ logger_1.connectorLogger.info(`[${this.config.name}] 🔗 建立新的SSH连接...`);
289
+ logger_1.connectorLogger.info(`[${this.config.name}] 目标: ${credential.username}@${this.config.host}:${port}`);
290
+ logger_1.connectorLogger.info(`[${this.config.name}] 认证方式: ${credential.authType}`);
277
291
  return new Promise((resolve, reject) => {
278
292
  const conn = new ssh2_1.Client();
279
293
  conn.on('ready', () => {
280
- logger_1.connectorLogger.info(`[${this.config.name}] SSH 连接成功`);
294
+ logger_1.connectorLogger.info(`[${this.config.name}] SSH连接成功 (user=${credential.username}, host=${this.config.host}, port=${port})`);
281
295
  resolve(conn);
282
296
  });
283
297
  conn.on('error', (err) => {
284
- logger_1.connectorLogger.error(`[${this.config.name}] SSH 连接失败: ${err.message}`);
298
+ logger_1.connectorLogger.error(`[${this.config.name}] SSH连接失败: ${err.message} (host=${this.config.host}, port=${port})`);
299
+ logger_1.connectorLogger.error(`[${this.config.name}] ⚠ 连接失败后将在片刻重试 (重试会产生新的SSH登录记录)`);
285
300
  conn.end();
286
301
  reject(err);
287
302
  });
288
303
  conn.on('close', () => {
289
- logger_1.connectorLogger.debug(`[${this.config.name}] SSH 连接关闭`);
304
+ const reason = this.connected ? 'SSH连接意外断开' : 'SSH连接已关闭';
305
+ logger_1.connectorLogger.warn(`[${this.config.name}] ${reason} (host=${this.config.host}, port=${this.config.port})`);
306
+ this.connected = false;
290
307
  });
291
308
  conn.on('banner', (msg) => {
292
309
  logger_1.connectorLogger.debug(`[${this.config.name}] SSH Banner: ${msg.trim()}`);
293
310
  });
294
- // 确保 port 是数字类型
295
- const port = typeof this.config.port === 'string'
296
- ? parseInt(this.config.port, 10)
297
- : (this.config.port || 22);
298
311
  const connectConfig = {
299
312
  host: this.config.host,
300
313
  port: port,
@@ -302,6 +315,9 @@ class DockerConnector {
302
315
  readyTimeout: constants_1.SSH_TIMEOUT,
303
316
  timeout: constants_1.SSH_TIMEOUT,
304
317
  tryKeyboard: true,
318
+ // === 保持连接活跃,防止被服务器踢掉 ===
319
+ keepaliveInterval: 15000, // 每15秒发送一次心跳
320
+ keepaliveCountMax: 3, // 失败3次认为断开
305
321
  ...this.buildAuthOptions(credential),
306
322
  };
307
323
  conn.connect(connectConfig);
@@ -10,8 +10,10 @@ export declare class DockerNode {
10
10
  status: NodeStatusType;
11
11
  /** Koishi Context (用于数据库操作) */
12
12
  private readonly ctx;
13
- /** SSH 连接器 */
13
+ /** SSH 连接器 (Fallback用) */
14
14
  private connector;
15
+ /** 持久化 SSH 客户端 (API用) */
16
+ private sshClient;
15
17
  /** Dockerode 实例 (用于 API 调用) */
16
18
  private dockerode;
17
19
  /** Docker API 是否可用 */
@@ -43,13 +45,17 @@ export declare class DockerNode {
43
45
  constructor(ctx: Context, config: NodeConfig, credential: CredentialConfig, debug?: boolean);
44
46
  /**
45
47
  * 连接到 Docker (带重试)
46
- * 3 次失败后每 1 分钟重试一次,直到成功
48
+ * 优化:优先尝试 API 连接,成功则不再建立多余的 SSH 命令行连接
47
49
  */
48
50
  connect(): Promise<void>;
49
51
  /**
50
52
  * 验证和清理配置
51
53
  */
52
54
  private validateAndCleanConfig;
55
+ /**
56
+ * 销毁 SSH 客户端
57
+ */
58
+ private disposeSshClient;
53
59
  /**
54
60
  * 断开连接
55
61
  */
@@ -79,6 +85,7 @@ export declare class DockerNode {
79
85
  }>;
80
86
  /**
81
87
  * 获取系统信息 (CPU、内存)
88
+ * 优先使用 Docker API,失败时降级到 SSH 命令
82
89
  */
83
90
  getSystemInfo(): Promise<{
84
91
  NCPU: number;
@@ -264,7 +271,7 @@ export declare class DockerNode {
264
271
  }>>;
265
272
  /**
266
273
  * 初始化 Dockerode
267
- * 根据配置决定连接本地 Socket 还是通过 SSH 连接远程
274
+ * 建立唯一的 SSH 连接,并通过 `docker system dial-stdio` 复用连接
268
275
  */
269
276
  private initDockerode;
270
277
  /**
@@ -329,6 +336,7 @@ export declare class DockerNode {
329
336
  private startMonitoring;
330
337
  /**
331
338
  * 启动 API 健康检查
339
+ * DPanel模式:信任底层 Keep-Alive,不主动 Ping,只在操作报错时重连
332
340
  */
333
341
  private startHealthCheck;
334
342
  /**
@@ -349,8 +357,13 @@ export declare class DockerNode {
349
357
  private pollContainerStates;
350
358
  /**
351
359
  * 启动 Docker 事件流监听
360
+ * 优先使用 Docker API (长连接且有心跳),失败降级到 SSH 命令
352
361
  */
353
362
  private startEventStream;
363
+ /**
364
+ * 重启事件流
365
+ */
366
+ private restartEventStream;
354
367
  /**
355
368
  * 处理事件流中的一行数据
356
369
  */
@@ -9,6 +9,8 @@ exports.DockerNode = void 0;
9
9
  */
10
10
  const koishi_1 = require("koishi");
11
11
  const dockerode_1 = __importDefault(require("dockerode"));
12
+ const http_1 = __importDefault(require("http"));
13
+ const ssh2_1 = require("ssh2");
12
14
  const constants_1 = require("../constants");
13
15
  const connector_1 = require("./connector");
14
16
  const logger_1 = require("../utils/logger");
@@ -18,8 +20,10 @@ class DockerNode {
18
20
  constructor(ctx, config, credential, debug = false) {
19
21
  /** 节点状态 */
20
22
  this.status = constants_1.NodeStatus.DISCONNECTED;
21
- /** SSH 连接器 */
23
+ /** SSH 连接器 (Fallback用) */
22
24
  this.connector = null;
25
+ /** 持久化 SSH 客户端 (API用) */
26
+ this.sshClient = null;
23
27
  /** Dockerode 实例 (用于 API 调用) */
24
28
  this.dockerode = null;
25
29
  /** Docker API 是否可用 */
@@ -67,7 +71,7 @@ class DockerNode {
67
71
  }
68
72
  /**
69
73
  * 连接到 Docker (带重试)
70
- * 3 次失败后每 1 分钟重试一次,直到成功
74
+ * 优化:优先尝试 API 连接,成功则不再建立多余的 SSH 命令行连接
71
75
  */
72
76
  async connect() {
73
77
  if (this.status === constants_1.NodeStatus.CONNECTING) {
@@ -91,21 +95,33 @@ class DockerNode {
91
95
  logger_1.nodeLogger.info(`[${this.name}] 连接尝试 ${attempt} (每 ${LONG_RETRY_INTERVAL / 1000} 秒重试)...`);
92
96
  }
93
97
  try {
94
- // 创建 connector(仅建立连接,不执行命令)
95
- const connector = new connector_1.DockerConnector(this.config, { credentials: [this.credential], nodes: [this.config] });
96
- this.connector = connector;
97
- // 尝试初始化 Dockerode (优先使用 API)
98
+ // === 优化策略:完全依赖 Docker API,不预创建 connector ===
99
+ // 只有在 API 真正失败时,才创建 connector 并建立 SSH 连接
100
+ // 1. 先尝试初始化 Docker API(不创建 connector
101
+ // 这可能会产生 1-2 SSH 连接(ping + getEvents)
98
102
  await this.initDockerode();
99
- // 如果 API 不可用,测试 SSH 连接和 docker 命令
103
+ // 2. 只有当 API 不可用时,才创建 connector 并降级到 SSH 命令
100
104
  if (!this.dockerApiAvailable) {
101
- logger_1.nodeLogger.debug(`[${this.name}] Docker API 不可用,测试 SSH docker 命令`);
105
+ logger_1.nodeLogger.warn(`[${this.name}] Docker API 不可用,创建 connector 并降级到 SSH 命令...`);
106
+ const connector = new connector_1.DockerConnector(this.config, { credentials: [this.credential], nodes: [this.config] });
107
+ this.connector = connector;
108
+ // 测试 SSH 命令(这会建立第 1 个 SSH 连接)
102
109
  await connector.exec('docker version --format "{{.Server.Version}}"');
110
+ logger_1.nodeLogger.info(`[${this.name}] ⚠ 已启用 SSH 命令模式`);
111
+ }
112
+ else {
113
+ // API 可用:创建一个懒加载的 connector(不立即连接)
114
+ // 只有当真正需要执行 SSH 命令时才建立连接
115
+ const connector = new connector_1.DockerConnector(this.config, { credentials: [this.credential], nodes: [this.config] });
116
+ this.connector = connector;
117
+ // 标记为 connected(但实际 SSH 连接尚未建立)
118
+ connector.setConnected(true);
119
+ logger_1.nodeLogger.info(`[${this.name}] ✅ Connector 已创建(懒加载模式,使用时才连接)`);
103
120
  }
104
- // 标记连接可用,允许事件流自动重连
105
- connector.setConnected(true);
106
121
  this.status = constants_1.NodeStatus.CONNECTED;
107
- logger_1.nodeLogger.info(`[${this.name}] 连接成功 (SSH + ${this.dockerApiAvailable ? 'Docker API' : 'SSH 命令模式'})`);
108
- // 启动监控
122
+ const mode = this.dockerApiAvailable ? 'Docker API (SSH隧道复用)' : 'SSH 命令模式';
123
+ logger_1.nodeLogger.info(`[${this.name}] 连接成功 [模式: ${mode}]`);
124
+ // 启动监控 (此时 API 已就绪,startEventStream 会复用 API 连接,不会产生新登录)
109
125
  this.startMonitoring();
110
126
  // 触发上线事件
111
127
  this.emitEvent({
@@ -122,8 +138,10 @@ class DockerNode {
122
138
  const lastError = error instanceof Error ? error : new Error(String(error));
123
139
  logger_1.nodeLogger.warn(`[${this.name}] 连接失败: ${lastError.message}`);
124
140
  // 清理连接
141
+ this.disposeSshClient();
125
142
  this.connector?.dispose();
126
143
  this.connector = null;
144
+ this.dockerode = null; // 确保清理
127
145
  // 等待后重试
128
146
  logger_1.nodeLogger.info(`[${this.name}] ${currentInterval / 1000} 秒后重试...`);
129
147
  await new Promise(resolve => setTimeout(resolve, currentInterval));
@@ -165,12 +183,28 @@ class DockerNode {
165
183
  logger_1.nodeLogger.info(`[${this.name}] 配置已修正: host="${this.config.host}", port=${cleanedPort}`);
166
184
  }
167
185
  }
186
+ /**
187
+ * 销毁 SSH 客户端
188
+ */
189
+ disposeSshClient() {
190
+ if (this.sshClient) {
191
+ try {
192
+ logger_1.nodeLogger.debug(`[${this.name}] 销毁 SSH 主连接`);
193
+ this.sshClient.end();
194
+ }
195
+ catch (e) {
196
+ // 忽略销毁错误
197
+ }
198
+ this.sshClient = null;
199
+ }
200
+ }
168
201
  /**
169
202
  * 断开连接
170
203
  */
171
204
  async disconnect() {
172
205
  this.stopMonitoring();
173
206
  this.clearTimers();
207
+ this.disposeSshClient();
174
208
  this.connector?.dispose();
175
209
  this.connector = null;
176
210
  this.dockerode = null;
@@ -286,28 +320,58 @@ class DockerNode {
286
320
  }
287
321
  /**
288
322
  * 获取系统信息 (CPU、内存)
323
+ * 优先使用 Docker API,失败时降级到 SSH 命令
289
324
  */
290
325
  async getSystemInfo() {
291
- if (!this.connector)
326
+ // 方式 1: 尝试使用 Docker API
327
+ logger_1.nodeLogger.debug(`[${this.name}] getSystemInfo 调用: dockerode=${!!this.dockerode}, apiAvailable=${this.dockerApiAvailable}`);
328
+ if (this.dockerode && this.dockerApiAvailable) {
329
+ try {
330
+ logger_1.nodeLogger.debug(`[${this.name}] 使用 Docker API 获取系统信息`);
331
+ const info = await this.dockerode.info();
332
+ logger_1.nodeLogger.debug(`[${this.name}] Docker API 返回: NCPU=${info.NCPU}, MemTotal=${info.MemTotal}, MemAvailable=${info.MemAvailable}`);
333
+ const result = {
334
+ NCPU: info.NCPU || 0,
335
+ MemTotal: info.MemTotal || 0,
336
+ MemAvailable: info.MemAvailable, // 可能不存在
337
+ };
338
+ logger_1.nodeLogger.debug(`[${this.name}] 返回系统信息: NCPU=${result.NCPU}, MemTotal=${result.MemTotal}`);
339
+ return result;
340
+ }
341
+ catch (e) {
342
+ logger_1.nodeLogger.warn(`[${this.name}] API getSystemInfo 失败,降级到 SSH: ${e.message}`);
343
+ }
344
+ }
345
+ // 方式 2: SSH 命令行回退
346
+ logger_1.nodeLogger.debug(`[${this.name}] 使用 SSH 命令获取系统信息`);
347
+ if (!this.connector) {
348
+ logger_1.nodeLogger.warn(`[${this.name}] connector 不存在,无法获取系统信息`);
292
349
  return null;
350
+ }
293
351
  try {
294
- // 使用 execWithExitCode 避免非零退出码抛出异常
295
- const result = await this.connector.execWithExitCode('docker info --format "{{.NCPU}} {{.MemTotal}} {{.MemAvailable}}"');
296
- logger_1.nodeLogger.debug(`[${this.name}] docker info 输出: "${result.output}", 退出码: ${result.exitCode}`);
297
- // docker info 可能返回退出码 1 但仍有输出(权限问题),只要有输出就解析
352
+ // 使用 JSON 格式获取完整信息,避免字段不存在导致的问题
353
+ const result = await this.connector.execWithExitCode('docker info --format "{{json .}}"');
354
+ logger_1.nodeLogger.debug(`[${this.name}] docker info 输出长度: ${result.output.length}, 退出码: ${result.exitCode}`);
298
355
  if (!result.output.trim()) {
299
356
  logger_1.nodeLogger.warn(`[${this.name}] docker info 输出为空`);
300
357
  return null;
301
358
  }
302
- const parts = result.output.trim().split(/\s+/);
303
- if (parts.length >= 2) {
304
- return {
305
- NCPU: parseInt(parts[0]) || 0,
306
- MemTotal: parseInt(parts[1]) || 0,
307
- MemAvailable: parts[2] ? parseInt(parts[2]) : undefined,
359
+ try {
360
+ const info = JSON.parse(result.output);
361
+ logger_1.nodeLogger.debug(`[${this.name}] SSH docker info 解析: NCPU=${info.NCPU}, MemTotal=${info.MemTotal}, MemAvailable=${info.MemAvailable}`);
362
+ const sshResult = {
363
+ NCPU: info.NCPU || 0,
364
+ MemTotal: info.MemTotal || 0,
365
+ MemAvailable: info.MemAvailable, // 可能不存在
308
366
  };
367
+ logger_1.nodeLogger.debug(`[${this.name}] SSH 返回系统信息: NCPU=${sshResult.NCPU}, MemTotal=${sshResult.MemTotal}`);
368
+ return sshResult;
369
+ }
370
+ catch (parseError) {
371
+ logger_1.nodeLogger.warn(`[${this.name}] 解析 docker info JSON 失败: ${parseError}`);
372
+ logger_1.nodeLogger.warn(`[${this.name}] 原始输出: ${result.output.substring(0, 200)}`);
373
+ return null;
309
374
  }
310
- return null;
311
375
  }
312
376
  catch (e) {
313
377
  logger_1.nodeLogger.warn(`[${this.name}] 获取系统信息异常: ${e}`);
@@ -1178,76 +1242,123 @@ class DockerNode {
1178
1242
  }
1179
1243
  /**
1180
1244
  * 初始化 Dockerode
1181
- * 根据配置决定连接本地 Socket 还是通过 SSH 连接远程
1245
+ * 建立唯一的 SSH 连接,并通过 `docker system dial-stdio` 复用连接
1182
1246
  */
1183
- async initDockerode() {
1247
+ async initDockerode(connector) {
1184
1248
  try {
1185
1249
  let dockerOptions;
1186
1250
  // 判断是否是本地节点
1187
1251
  const isLocal = this.config.host === '127.0.0.1' || this.config.host === 'localhost';
1188
1252
  if (isLocal) {
1189
- // 本地连接
1190
- dockerOptions = {
1191
- socketPath: '/var/run/docker.sock',
1192
- };
1253
+ // 本地连接:直接使用 Unix Socket
1254
+ this.dockerode = new dockerode_1.default({ socketPath: '/var/run/docker.sock' });
1255
+ await this.dockerode.ping();
1256
+ this.dockerApiAvailable = true;
1257
+ logger_1.nodeLogger.info(`[${this.name}] ✅ Docker API 连接成功 (Local Socket)`);
1258
+ return;
1193
1259
  }
1194
- else {
1195
- // === 远程 SSH 连接配置 ===
1196
- // 1. 确保端口是数字
1197
- let portNumber = 22;
1198
- if (typeof this.config.port === 'number') {
1199
- portNumber = this.config.port;
1260
+ // === 远程 SSH 连接配置 (单连接复用方案) ===
1261
+ // 1. 关闭旧连接
1262
+ this.disposeSshClient();
1263
+ // 2. 准备 SSH 配置
1264
+ let portNumber = 22;
1265
+ if (typeof this.config.port === 'number') {
1266
+ portNumber = this.config.port;
1267
+ }
1268
+ else if (typeof this.config.port === 'string') {
1269
+ const parsed = parseInt(this.config.port, 10);
1270
+ if (!isNaN(parsed) && parsed > 0) {
1271
+ portNumber = parsed;
1272
+ }
1273
+ }
1274
+ const sshConfig = {
1275
+ host: this.config.host,
1276
+ port: portNumber,
1277
+ username: this.credential.username,
1278
+ readyTimeout: 20000,
1279
+ keepaliveInterval: 10000, // 10秒心跳,防止被踢
1280
+ keepaliveCountMax: 3,
1281
+ };
1282
+ // 注入认证信息
1283
+ if (this.credential.authType === 'password' && this.credential.password) {
1284
+ sshConfig.password = this.credential.password;
1285
+ }
1286
+ else if (this.credential.privateKey) {
1287
+ sshConfig.privateKey = this.credential.privateKey.trim();
1288
+ if (this.credential.passphrase) {
1289
+ sshConfig.passphrase = this.credential.passphrase;
1200
1290
  }
1201
- else if (typeof this.config.port === 'string') {
1202
- const parsed = parseInt(this.config.port, 10);
1203
- if (!isNaN(parsed) && parsed > 0)
1204
- portNumber = parsed;
1291
+ }
1292
+ logger_1.nodeLogger.info(`[${this.name}] 正在建立 SSH 主连接...`);
1293
+ // 3. 建立 SSH 连接
1294
+ this.sshClient = new ssh2_1.Client();
1295
+ await new Promise((resolve, reject) => {
1296
+ if (!this.sshClient) {
1297
+ return reject(new Error('SSH client initialization failed'));
1205
1298
  }
1206
- // 2. 准备 SSH 认证选项
1207
- // 关键点:将 port 放在这里,而不是顶层
1208
- const sshOpts = {
1209
- port: portNumber,
1210
- readyTimeout: 20000,
1299
+ const onReady = () => {
1300
+ this.sshClient?.removeListener('error', onError);
1301
+ resolve();
1211
1302
  };
1212
- // 注入认证信息
1213
- if (this.credential.authType === 'password' && this.credential.password) {
1214
- sshOpts.password = this.credential.password;
1303
+ const onError = (err) => {
1304
+ this.sshClient?.removeListener('ready', onReady);
1305
+ reject(err);
1306
+ };
1307
+ this.sshClient.on('ready', onReady).on('error', onError).connect(sshConfig);
1308
+ });
1309
+ // 监听连接断开,触发重连逻辑
1310
+ this.sshClient.on('close', () => {
1311
+ if (this.status === constants_1.NodeStatus.CONNECTED) {
1312
+ logger_1.nodeLogger.warn(`[${this.name}] SSH 主连接已断开,触发重连`);
1313
+ // 不直接调用 disconnect(),避免状态混乱
1314
+ // 让上层监控逻辑处理重连
1215
1315
  }
1216
- else if (this.credential.privateKey) {
1217
- sshOpts.privateKey = this.credential.privateKey.trim();
1218
- if (this.credential.passphrase) {
1219
- sshOpts.passphrase = this.credential.passphrase;
1220
- }
1316
+ });
1317
+ logger_1.nodeLogger.info(`[${this.name}] SSH 主连接建立成功 (单次登录,复用所有API请求)`);
1318
+ // 4. 创建自定义 Agent,劫持 createConnection
1319
+ // 这允许 dockerode 的所有请求都复用这一个 SSH 连接
1320
+ const agent = new http_1.default.Agent();
1321
+ agent.createConnection = (options, cb) => {
1322
+ logger_1.nodeLogger.debug(`[${this.name}] 🔧 Agent.createConnection 被调用,复用 SSH 隧道`);
1323
+ // 使用 docker system dial-stdio 建立到 Docker Socket 的流
1324
+ // 这是官方 CLI 远程连接的标准方式,支持双向流
1325
+ if (!this.sshClient) {
1326
+ cb(new Error('SSH client not connected'), null);
1327
+ return null;
1221
1328
  }
1222
- // 3. 构建 Dockerode 配置
1223
- // 核心修复:
1224
- // - 保留 host: 让 dockerode 知道是远程连接(解决"只获取本地容器"问题)
1225
- // - 移除 port: 避免 docker-modem 拼接 URL 时出错(解决 "Invalid port" 问题)
1226
- // - sshOptions: 包含 port 和认证信息,供底层 ssh2 使用
1227
- dockerOptions = {
1228
- protocol: 'ssh',
1229
- host: this.config.host, // 必须保留
1230
- username: this.credential.username, // 必须保留
1231
- sshOptions: sshOpts, // 包含 port
1232
- };
1233
- logger_1.nodeLogger.info(`[${this.name}] 初始化 Docker API (SSH模式): host="${this.config.host}", port=${portNumber}`);
1234
- }
1329
+ this.sshClient.exec('docker system dial-stdio', (err, stream) => {
1330
+ if (err) {
1331
+ logger_1.nodeLogger.warn(`[${this.name}] SSH dial-stdio 失败: ${err.message}`);
1332
+ return cb(err, null);
1333
+ }
1334
+ // stream 是双工流,可以直接作为 socket 使用
1335
+ logger_1.nodeLogger.debug(`[${this.name}] ✅ SSH 隧道已建立`);
1336
+ cb(null, stream);
1337
+ });
1338
+ return null;
1339
+ };
1340
+ // 5. 初始化 Dockerode
1341
+ // 使用 'http' 协议欺骗 dockerode 使用我们的 agent
1342
+ dockerOptions = {
1343
+ protocol: 'http',
1344
+ host: '127.0.0.1', // 这里的 host/port 会被 agent 忽略
1345
+ port: 2375,
1346
+ agent: agent,
1347
+ };
1348
+ logger_1.nodeLogger.info(`[${this.name}] 🔨 创建 Dockerode 实例 (使用自定义 Agent)`);
1235
1349
  this.dockerode = new dockerode_1.default(dockerOptions);
1236
- // 测试连接
1237
- try {
1238
- await this.dockerode.ping();
1239
- this.dockerApiAvailable = true;
1240
- logger_1.nodeLogger.info(`[${this.name}] Docker API 连接成功 (${isLocal ? 'Local' : 'SSH'})`);
1241
- }
1242
- catch (e) {
1243
- this.dockerApiAvailable = false;
1244
- logger_1.nodeLogger.warn(`[${this.name}] Docker API 连接失败: ${e.message} (将降级使用 SSH 命令)`);
1245
- }
1350
+ // 测试 API
1351
+ logger_1.nodeLogger.info(`[${this.name}] 🔍 测试 Docker API 连接...`);
1352
+ await this.dockerode.ping();
1353
+ this.dockerApiAvailable = true;
1354
+ logger_1.nodeLogger.info(`[${this.name}] Docker API 隧道测试成功 (所有请求复用单条 SSH 连接)`);
1246
1355
  }
1247
1356
  catch (e) {
1357
+ this.disposeSshClient();
1248
1358
  this.dockerode = null;
1249
1359
  this.dockerApiAvailable = false;
1250
- logger_1.nodeLogger.debug(`[${this.name}] Dockerode 初始化异常: ${e}`);
1360
+ logger_1.nodeLogger.warn(`[${this.name}] Docker API 隧道建立失败: ${e.message}`);
1361
+ throw e; // 抛出错误让 connect 方法处理降级
1251
1362
  }
1252
1363
  }
1253
1364
  /**
@@ -1638,15 +1749,21 @@ class DockerNode {
1638
1749
  }
1639
1750
  /**
1640
1751
  * 启动 API 健康检查
1752
+ * DPanel模式:信任底层 Keep-Alive,不主动 Ping,只在操作报错时重连
1641
1753
  */
1642
1754
  startHealthCheck() {
1643
- // 立即检查一次
1755
+ // 方案:移除定时器,改为惰性检查
1756
+ // 底层 keepaliveInterval: 15s 的静默心跳已经足够防止断连
1757
+ // 主动 Ping 是产生日志的元凶,必须移除
1758
+ // 仅在启动时检查一次,确保 API 正常
1644
1759
  this.checkApiHealth();
1645
- // 定期检查API健康状态
1760
+ // 不再设置定时器,完全信任底层 TCP Keep-Alive
1761
+ /*
1646
1762
  this.healthCheckTimer = setInterval(async () => {
1647
- await this.checkApiHealth();
1648
- }, constants_1.API_HEALTH_CHECK_INTERVAL);
1649
- logger_1.nodeLogger.debug(`[${this.name}] API健康检查已启动 (间隔: ${constants_1.API_HEALTH_CHECK_INTERVAL / 1000}秒)`);
1763
+ await this.checkApiHealth()
1764
+ }, CHECK_INTERVAL)
1765
+ */
1766
+ logger_1.nodeLogger.debug(`[${this.name}] API健康检查策略: 仅启动时检查 (依赖底层 TCP Keep-Alive 保活,无定时Ping)`);
1650
1767
  }
1651
1768
  /**
1652
1769
  * 检查 Docker API 健康状态
@@ -1674,7 +1791,8 @@ class DockerNode {
1674
1791
  // API健康,无需操作
1675
1792
  }
1676
1793
  catch (e) {
1677
- logger_1.nodeLogger.warn(`[${this.name}] Docker API 健康检查失败: ${e.message},启动降级轮询`);
1794
+ logger_1.nodeLogger.error(`[${this.name}] Docker API 健康检查失败: ${e.message}`);
1795
+ logger_1.nodeLogger.warn(`[${this.name}] ⚠ API失败后将进入降级模式,每${constants_1.DEGRADED_POLL_INTERVAL / 1000}秒执行一次SSH命令`);
1678
1796
  this.dockerApiAvailable = false;
1679
1797
  this.startDegradedPolling();
1680
1798
  }
@@ -1700,7 +1818,8 @@ class DockerNode {
1700
1818
  this.degradedPollTimer = setInterval(async () => {
1701
1819
  await this.pollContainerStates();
1702
1820
  }, constants_1.DEGRADED_POLL_INTERVAL);
1703
- logger_1.nodeLogger.info(`[${this.name}] 降级轮询已启动 (间隔: ${constants_1.DEGRADED_POLL_INTERVAL / 1000})`);
1821
+ logger_1.nodeLogger.warn(`[${this.name}] 进入降级模式: 每${constants_1.DEGRADED_POLL_INTERVAL / 1000}秒执行一次SSH命令查询容器状态`);
1822
+ logger_1.nodeLogger.warn(`[${this.name}] ⚠ 这是产生频繁SSH登录记录的主要原因!建议修复Docker API连接以减少SSH使用`);
1704
1823
  }
1705
1824
  /**
1706
1825
  * 停止降级轮询
@@ -1714,7 +1833,7 @@ class DockerNode {
1714
1833
  clearInterval(this.degradedPollTimer);
1715
1834
  this.degradedPollTimer = null;
1716
1835
  }
1717
- logger_1.nodeLogger.info(`[${this.name}] 降级轮询已停止,恢复Docker API模式`);
1836
+ logger_1.nodeLogger.info(`[${this.name}] Docker API已恢复,停止降级轮询 (不再频繁执行SSH命令)`);
1718
1837
  }
1719
1838
  /**
1720
1839
  * 轮询容器状态 (用于降级模式)
@@ -1723,9 +1842,10 @@ class DockerNode {
1723
1842
  if (this.status !== constants_1.NodeStatus.CONNECTED)
1724
1843
  return;
1725
1844
  try {
1845
+ logger_1.nodeLogger.debug(`[${this.name}] 🔍 执行降级轮询: 使用SSH命令查询容器状态 (这会产生SSH登录记录)`);
1726
1846
  const containers = await this.listContainers(true);
1727
1847
  this.checkContainerStateChanges(containers);
1728
- logger_1.nodeLogger.debug(`[${this.name}] 降级轮询: 检查了 ${containers.length} 个容器`);
1848
+ logger_1.nodeLogger.debug(`[${this.name}] 降级轮询完成: 检查了 ${containers.length} 个容器`);
1729
1849
  }
1730
1850
  catch (e) {
1731
1851
  logger_1.nodeLogger.warn(`[${this.name}] 降级轮询失败: ${e}`);
@@ -1733,42 +1853,127 @@ class DockerNode {
1733
1853
  }
1734
1854
  /**
1735
1855
  * 启动 Docker 事件流监听
1856
+ * 优先使用 Docker API (长连接且有心跳),失败降级到 SSH 命令
1736
1857
  */
1737
- startEventStream() {
1738
- if (!this.connector)
1739
- return;
1740
- // 防止并发启动:使用 _startingStream 标志
1858
+ async startEventStream() {
1859
+ // 防止并发启动
1741
1860
  if (this._startingStream) {
1742
1861
  logger_1.nodeLogger.debug(`[${this.name}] 事件流正在启动中,跳过`);
1743
1862
  return;
1744
1863
  }
1745
1864
  ;
1746
1865
  this._startingStream = true;
1747
- // 检查是否已有活跃的流
1748
- if (this._activeStreamCount > 0) {
1749
- logger_1.nodeLogger.debug(`[${this.name}] 已有 ${this._activeStreamCount} 个活跃事件流,跳过启动`);
1866
+ // 清理旧的流
1867
+ if (this._eventStreamStop) {
1868
+ try {
1869
+ this._eventStreamStop();
1870
+ this._eventStreamStop = null;
1871
+ }
1872
+ catch (e) {
1873
+ // 忽略清理错误
1874
+ }
1875
+ }
1876
+ logger_1.nodeLogger.info(`[${this.name}] 🚀 启动事件流监听...`);
1877
+ // === 方案 1: 优先使用 Docker API (dockerode) ===
1878
+ // 优点: 复用已有的 Keep-Alive 连接,不会因为静默被防火墙切断
1879
+ if (this.dockerode && this.dockerApiAvailable) {
1880
+ try {
1881
+ logger_1.nodeLogger.info(`[${this.name}] 尝试使用 Docker API 获取事件流 (推荐模式,有心跳保护)`);
1882
+ logger_1.nodeLogger.info(`[${this.name}] 🔍 调用 dockerode.getEvents() (这可能会建立新的 SSH 连接)`);
1883
+ const stream = await this.dockerode.getEvents({
1884
+ filters: { type: ['container'] }
1885
+ });
1886
+ logger_1.nodeLogger.info(`[${this.name}] ✅ getEvents() 成功返回流对象`);
1887
+ // 处理数据流
1888
+ stream.on('data', (chunk) => {
1889
+ try {
1890
+ const lines = chunk.toString().split('\n').filter(Boolean);
1891
+ for (const line of lines) {
1892
+ this.handleEventLine(line);
1893
+ }
1894
+ }
1895
+ catch (e) {
1896
+ logger_1.nodeLogger.debug(`[${this.name}] 处理事件数据失败: ${e}`);
1897
+ }
1898
+ });
1899
+ // 处理错误和断开
1900
+ const onStreamError = (err) => {
1901
+ if (this._startingStream === false)
1902
+ return; // 已经手动停止
1903
+ logger_1.nodeLogger.warn(`[${this.name}] API 事件流异常: ${err.message || 'Stream ended'}`);
1904
+ this.restartEventStream();
1905
+ };
1906
+ stream.on('error', onStreamError);
1907
+ stream.on('end', () => onStreamError(new Error('Stream ended')));
1908
+ stream.on('close', () => onStreamError(new Error('Stream closed')));
1909
+ this._eventStreamStop = () => {
1910
+ try {
1911
+ stream.destroy?.();
1912
+ stream.off('error', onStreamError);
1913
+ stream.off('end', onStreamError);
1914
+ stream.off('close', onStreamError);
1915
+ stream.off('data', () => { });
1916
+ }
1917
+ catch (e) {
1918
+ // 忽略清理错误
1919
+ }
1920
+ };
1921
+ this._startingStream = false;
1922
+ logger_1.nodeLogger.info(`[${this.name}] ✅ API 事件流已连接 (享受心跳保护,不会超时)`);
1923
+ return;
1924
+ }
1925
+ catch (e) {
1926
+ logger_1.nodeLogger.warn(`[${this.name}] API 事件流启动失败: ${e.message},降级到 SSH 命令`);
1927
+ }
1928
+ }
1929
+ // === 方案 2: 降级使用 SSH 命令行 ===
1930
+ // 只有 API 不可用时才走这里(可能因静默超时而频繁重连)
1931
+ if (!this.connector) {
1932
+ ;
1750
1933
  this._startingStream = false;
1934
+ logger_1.nodeLogger.warn(`[${this.name}] 无可用连接器,跳过事件流监听`);
1751
1935
  return;
1752
1936
  }
1753
- ;
1754
- this._activeStreamCount = this._activeStreamCount || 0;
1755
- this._activeStreamCount++;
1756
- logger_1.nodeLogger.debug(`[${this.name}] 启动事件流 (活跃数: ${this._activeStreamCount})`);
1937
+ logger_1.nodeLogger.warn(`[${this.name}] 使用 SSH 命令模式监听事件流 (注意: 可能因长时间静默被防火墙切断)`);
1757
1938
  this.connector.startEventStream((line) => {
1758
1939
  this.handleEventLine(line);
1759
1940
  }).then((stop) => {
1760
1941
  ;
1761
1942
  this._eventStreamStop = stop;
1762
1943
  this._startingStream = false;
1763
- logger_1.nodeLogger.debug(`[${this.name}] 事件流回调已注册`);
1944
+ logger_1.nodeLogger.info(`[${this.name}] ✅ SSH 事件流已连接 (注意: SSH模式下可能因静默超时而频繁重连)`);
1764
1945
  }).catch((err) => {
1765
1946
  ;
1766
- this._activeStreamCount--;
1767
1947
  this._startingStream = false;
1768
- logger_1.nodeLogger.warn(`[${this.name}] 事件流启动失败: ${err.message},5秒后重试`);
1769
- setTimeout(() => this.startEventStream(), 5000);
1948
+ logger_1.nodeLogger.error(`[${this.name}] ❌ SSH 事件流启动失败: ${err.message}`);
1949
+ this.restartEventStream();
1770
1950
  });
1771
1951
  }
1952
+ /**
1953
+ * 重启事件流
1954
+ */
1955
+ restartEventStream() {
1956
+ // 清理旧的流
1957
+ if (this._eventStreamStop) {
1958
+ try {
1959
+ this._eventStreamStop();
1960
+ this._eventStreamStop = null;
1961
+ }
1962
+ catch (e) {
1963
+ // 忽略清理错误
1964
+ }
1965
+ }
1966
+ // 重置启动标志
1967
+ ;
1968
+ this._startingStream = false;
1969
+ // 5秒后重试
1970
+ setTimeout(() => {
1971
+ if (this.status === constants_1.NodeStatus.CONNECTED) {
1972
+ logger_1.nodeLogger.info(`[${this.name}] 重新启动事件流...`);
1973
+ this.startEventStream();
1974
+ }
1975
+ }, 5000);
1976
+ }
1772
1977
  /**
1773
1978
  * 处理事件流中的一行数据
1774
1979
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-docker-control",
3
3
  "description": "Koishi 插件 - 通过 SSH 控制 Docker 容器 (支持连接池、缓存、权限控制、审计日志)",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [