@sstar/embedlink_agent 0.1.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/README.md +107 -0
- package/dist/.platform +1 -0
- package/dist/board/docs.js +59 -0
- package/dist/board/notes.js +11 -0
- package/dist/board_uart/history.js +81 -0
- package/dist/board_uart/index.js +66 -0
- package/dist/board_uart/manager.js +313 -0
- package/dist/board_uart/resource.js +578 -0
- package/dist/board_uart/sessions.js +559 -0
- package/dist/config/index.js +341 -0
- package/dist/core/activity.js +7 -0
- package/dist/core/errors.js +45 -0
- package/dist/core/log_stream.js +26 -0
- package/dist/files/__tests__/files_manager.test.js +209 -0
- package/dist/files/artifact_manager.js +68 -0
- package/dist/files/file_operation_logger.js +271 -0
- package/dist/files/files_manager.js +511 -0
- package/dist/files/index.js +87 -0
- package/dist/files/types.js +5 -0
- package/dist/firmware/burn_recover.js +733 -0
- package/dist/firmware/prepare_images.js +184 -0
- package/dist/firmware/user_guide.js +43 -0
- package/dist/index.js +449 -0
- package/dist/logger.js +245 -0
- package/dist/macro/index.js +241 -0
- package/dist/macro/runner.js +168 -0
- package/dist/nfs/index.js +105 -0
- package/dist/plugins/loader.js +30 -0
- package/dist/proto/agent.proto +473 -0
- package/dist/resources/docs/board-interaction.md +115 -0
- package/dist/resources/docs/firmware-upgrade.md +404 -0
- package/dist/resources/docs/nfs-mount-guide.md +78 -0
- package/dist/resources/docs/tftp-transfer-guide.md +81 -0
- package/dist/secrets/index.js +9 -0
- package/dist/server/grpc.js +1069 -0
- package/dist/server/web.js +2284 -0
- package/dist/ssh/adapter.js +126 -0
- package/dist/ssh/candidates.js +85 -0
- package/dist/ssh/index.js +3 -0
- package/dist/ssh/paircheck.js +35 -0
- package/dist/ssh/tunnel.js +111 -0
- package/dist/tftp/client.js +345 -0
- package/dist/tftp/index.js +284 -0
- package/dist/tftp/server.js +731 -0
- package/dist/uboot/index.js +45 -0
- package/dist/ui/assets/index-BlnLVmbt.js +374 -0
- package/dist/ui/assets/index-xMbarYXA.css +32 -0
- package/dist/ui/index.html +21 -0
- package/dist/utils/network.js +150 -0
- package/dist/utils/platform.js +83 -0
- package/dist/utils/port-check.js +153 -0
- package/dist/utils/user-prompt.js +139 -0
- package/package.json +64 -0
|
@@ -0,0 +1,2284 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { boardUartWrite } from '../board_uart/index.js';
|
|
12
|
+
import { closeSession, listBoardUartSessions, openManualSession, startRecording, stopRecording, setDefaultBoardUartHint, buildSessionId, } from '../board_uart/sessions.js';
|
|
13
|
+
import { getBoardUartHistoryLines, setBoardUartHistoryMaxLines } from '../board_uart/history.js';
|
|
14
|
+
import { gatherSshCandidates } from '../ssh/candidates.js';
|
|
15
|
+
import { SshTunnel } from '../ssh/tunnel.js';
|
|
16
|
+
import { SSHAdapter } from '../ssh/adapter.js';
|
|
17
|
+
import { readPasswordFromEnv } from '../secrets/index.js';
|
|
18
|
+
import { loadConfig } from '../config/index.js';
|
|
19
|
+
import { downloadFromExternalTftp, getTftpServerStats, startTftpServer, stopTftpServer, uploadToExternalTftp, listTftpEntries, } from '../tftp/index.js';
|
|
20
|
+
import { ubootBreak, ubootRunCommand } from '../uboot/index.js';
|
|
21
|
+
import { ErrorCodes, DefaultTimeouts } from '../core/errors.js';
|
|
22
|
+
import { saveBoardNotes } from '../board/notes.js';
|
|
23
|
+
import { readDoc, writeDoc, resetDoc } from '../board/docs.js';
|
|
24
|
+
import { getBoardUartResourceManager } from '../board_uart/resource.js';
|
|
25
|
+
import { deleteMacroScript, getMacroScript, listMacroScripts, upsertMacroScript, } from '../macro/index.js';
|
|
26
|
+
import { getCurrentRun, runMacroScript, stopCurrentRun } from '../macro/runner.js';
|
|
27
|
+
import { getAgentLogger, LogLevel } from '../logger.js';
|
|
28
|
+
import { firmwarePrepareImages } from '../firmware/prepare_images.js';
|
|
29
|
+
import { firmwareBurnRecover } from '../firmware/burn_recover.js';
|
|
30
|
+
import { listFiles } from '../files/index.js';
|
|
31
|
+
import { nfsList } from '../nfs/index.js';
|
|
32
|
+
import { getGrpcLastActivity } from '../core/activity.js';
|
|
33
|
+
import { getPlatformInfo } from '../utils/platform.js';
|
|
34
|
+
import { getNetworkInfo } from '../utils/network.js';
|
|
35
|
+
import { getLogSnapshot, subscribeLogs } from '../core/log_stream.js';
|
|
36
|
+
export async function startWebServer(cfg) {
|
|
37
|
+
// 初始化Agent日志系统
|
|
38
|
+
const logger = getAgentLogger();
|
|
39
|
+
// 记录服务器启动信息
|
|
40
|
+
logger.write(LogLevel.INFO, 'server.starting', {
|
|
41
|
+
agentId: cfg.agentId,
|
|
42
|
+
version: cfg.version,
|
|
43
|
+
port: cfg.port,
|
|
44
|
+
grpcPort: cfg.grpcPort,
|
|
45
|
+
pid: process.pid,
|
|
46
|
+
});
|
|
47
|
+
const app = express();
|
|
48
|
+
app.use(express.json());
|
|
49
|
+
app.get('/health', (_req, res) => res.json({ status: 'ok', pid: process.pid, uptime: process.uptime() }));
|
|
50
|
+
app.get('/api/status', async (_req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const mod = await import('../board_uart/index.js');
|
|
53
|
+
const st = await (mod.boardUartStatus?.() || {});
|
|
54
|
+
const tools = [...(st.attachedTools || [])];
|
|
55
|
+
const lastGrpc = getGrpcLastActivity();
|
|
56
|
+
if (Date.now() - lastGrpc < 15000) {
|
|
57
|
+
if (!tools.includes('mcp-host')) {
|
|
58
|
+
tools.push('mcp-host');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
res.json({
|
|
62
|
+
id: cfg.agentId,
|
|
63
|
+
version: cfg.version,
|
|
64
|
+
uptime: process.uptime(),
|
|
65
|
+
attachedTools: tools,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
app.get('/api/network/interfaces', async (_req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const interfaces = await getNetworkInfo();
|
|
75
|
+
res.json({ interfaces });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (cfg.staticDir) {
|
|
82
|
+
const pagesDir = path.join(cfg.staticDir, 'pages');
|
|
83
|
+
if (fsSync.existsSync(pagesDir)) {
|
|
84
|
+
// 直接将 dist/pages 挂载到根,确保 / 与 /xxx.html 可用
|
|
85
|
+
app.use(express.static(pagesDir));
|
|
86
|
+
app.use('/pages', express.static(pagesDir));
|
|
87
|
+
}
|
|
88
|
+
app.use(express.static(cfg.staticDir));
|
|
89
|
+
const assetsDir = path.join(cfg.staticDir, 'assets');
|
|
90
|
+
app.use('/assets', express.static(assetsDir));
|
|
91
|
+
}
|
|
92
|
+
// Board UART ports listing
|
|
93
|
+
app.get('/api/board_uart/ports', async (_req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
// 尝试返回更详细的状态(有模块/被禁用/端口列表)
|
|
96
|
+
const mod = await import('../board_uart/index.js');
|
|
97
|
+
const st = await (mod.boardUartStatus?.() || {
|
|
98
|
+
disabled: false,
|
|
99
|
+
hasModule: false,
|
|
100
|
+
ports: [],
|
|
101
|
+
});
|
|
102
|
+
let reason;
|
|
103
|
+
if (st.disabled)
|
|
104
|
+
reason = 'disabled-by-env';
|
|
105
|
+
else if (!st.hasModule)
|
|
106
|
+
reason = 'module-missing';
|
|
107
|
+
else if (!st.ports?.length)
|
|
108
|
+
reason = 'no-device';
|
|
109
|
+
res.json({ ports: st.ports || [], reason });
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
// 兜底:即使发生异常也不要抛 500
|
|
113
|
+
res.json({ ports: [], reason: 'unknown' });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
app.get('/api/board_uart/default', async (_req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const conf = await ensureConfigLoaded();
|
|
119
|
+
res.json({ port: conf.boardUart.port || '', baud: conf.boardUart.baud || 115200 });
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// Board UART write (base64 data)
|
|
126
|
+
app.post('/api/board_uart/write', async (req, res) => {
|
|
127
|
+
try {
|
|
128
|
+
const { port, baud, data } = req.body || {};
|
|
129
|
+
if (!port || !baud || !data)
|
|
130
|
+
return res.status(400).json({ error: 'missing params' });
|
|
131
|
+
const bytes = await boardUartWrite({
|
|
132
|
+
port,
|
|
133
|
+
baud: Number(baud),
|
|
134
|
+
data: Buffer.from(String(data), 'base64'),
|
|
135
|
+
mock: false, // 明确禁用 mock
|
|
136
|
+
});
|
|
137
|
+
res.json({ ok: true, bytesWritten: bytes });
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// Board UART open test (no data, just open/close)
|
|
144
|
+
app.post('/api/board_uart/open', async (req, res) => {
|
|
145
|
+
try {
|
|
146
|
+
const { port, baud } = req.body || {};
|
|
147
|
+
if (!port || !baud)
|
|
148
|
+
return res.status(400).json({ error: 'missing params' });
|
|
149
|
+
await boardUartWrite({
|
|
150
|
+
port,
|
|
151
|
+
baud: Number(baud),
|
|
152
|
+
data: Buffer.alloc(0),
|
|
153
|
+
mock: false,
|
|
154
|
+
});
|
|
155
|
+
res.json({ ok: true });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
app.get('/api/board_uart/sessions', async (_req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
const sessions = await listBoardUartSessions();
|
|
164
|
+
res.json({ sessions });
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
app.post('/api/board_uart/session/open', async (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const { port, baud } = req.body || {};
|
|
173
|
+
const session = await openManualSession({ port, baud: Number(baud) });
|
|
174
|
+
res.json({ session });
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
app.post('/api/board_uart/session/close', async (req, res) => {
|
|
181
|
+
try {
|
|
182
|
+
const { sessionId } = req.body || {};
|
|
183
|
+
const closed = await closeSession(String(sessionId || ''));
|
|
184
|
+
res.json({ closed });
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
// SSH candidates from ssh config + netstat
|
|
191
|
+
app.get('/api/ssh/candidates', async (_req, res) => {
|
|
192
|
+
const hosts = await gatherSshCandidates();
|
|
193
|
+
res.json({ hosts });
|
|
194
|
+
});
|
|
195
|
+
// SSH defaults (username / key path)
|
|
196
|
+
app.get('/api/ssh/defaults', async (_req, res) => {
|
|
197
|
+
try {
|
|
198
|
+
const c = await loadConfig();
|
|
199
|
+
const keyPath = c.ssh?.defaultKeyPath;
|
|
200
|
+
let keyExists = false;
|
|
201
|
+
if (keyPath) {
|
|
202
|
+
try {
|
|
203
|
+
const st = await fs.stat(keyPath);
|
|
204
|
+
keyExists =
|
|
205
|
+
typeof st.isFile === 'function' ? st.isFile() : !st.isDirectory();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
keyExists = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const user = cfg.originUser || os.userInfo().username || 'user';
|
|
212
|
+
res.json({
|
|
213
|
+
user,
|
|
214
|
+
defaultKeyPath: keyPath,
|
|
215
|
+
keyExists,
|
|
216
|
+
enablePasswordFallback: !!c.ssh?.enablePasswordFallback,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// File listing for Custom Browser
|
|
224
|
+
app.post('/api/files/list', async (req, res) => {
|
|
225
|
+
try {
|
|
226
|
+
const { path } = req.body || {};
|
|
227
|
+
const result = await listFiles(path);
|
|
228
|
+
res.json(result);
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
res.status(500).json({ ok: false, error: e?.message || String(e) });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
// Path check for TFTP dir
|
|
235
|
+
app.get('/api/path/exists', async (req, res) => {
|
|
236
|
+
const dir = String(req.query.dir || '');
|
|
237
|
+
try {
|
|
238
|
+
const st = await fs.stat(dir);
|
|
239
|
+
res.json({ ok: st.isDirectory() });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
res.json({ ok: false });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
// Open directory in system file manager
|
|
246
|
+
app.post('/api/path/open', async (req, res) => {
|
|
247
|
+
const dir = String(req.body?.dir || '');
|
|
248
|
+
if (!dir)
|
|
249
|
+
return res.status(400).json({ error: 'missing dir' });
|
|
250
|
+
try {
|
|
251
|
+
await fs.mkdir(dir, { recursive: true });
|
|
252
|
+
const platform = process.platform;
|
|
253
|
+
let cmd;
|
|
254
|
+
let args;
|
|
255
|
+
if (platform === 'win32') {
|
|
256
|
+
cmd = 'cmd';
|
|
257
|
+
args = ['/c', 'start', '', dir];
|
|
258
|
+
}
|
|
259
|
+
else if (platform === 'darwin') {
|
|
260
|
+
cmd = 'open';
|
|
261
|
+
args = [dir];
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
cmd = 'xdg-open';
|
|
265
|
+
args = [dir];
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
269
|
+
child.unref();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// 打开失败不影响 HTTP 返回
|
|
273
|
+
}
|
|
274
|
+
res.json({ ok: true, dir });
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
async function pickDirectoryDialog() {
|
|
281
|
+
const platform = process.platform;
|
|
282
|
+
if (platform === 'win32') {
|
|
283
|
+
const script = '$ErrorActionPreference = "Stop";' +
|
|
284
|
+
'Add-Type -AssemblyName System.Windows.Forms;' +
|
|
285
|
+
'$f = New-Object System.Windows.Forms.FolderBrowserDialog;' +
|
|
286
|
+
'$f.Description = "选择目录";' +
|
|
287
|
+
'$f.ShowNewFolderButton = $true;' +
|
|
288
|
+
'if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }';
|
|
289
|
+
return await new Promise((resolve, reject) => {
|
|
290
|
+
const child = spawn('powershell', ['-NoProfile', '-Command', script], {
|
|
291
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
292
|
+
});
|
|
293
|
+
let out = '';
|
|
294
|
+
let err = '';
|
|
295
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
296
|
+
child.stderr.on('data', (d) => (err += d.toString()));
|
|
297
|
+
child.on('error', (e) => reject(e));
|
|
298
|
+
child.on('close', (code) => {
|
|
299
|
+
if (code === 0 && out.trim())
|
|
300
|
+
return resolve(out.trim());
|
|
301
|
+
if (code === 0)
|
|
302
|
+
return resolve(null);
|
|
303
|
+
reject(new Error(err.trim() || `powershell exited with code ${code}`));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (platform === 'darwin') {
|
|
308
|
+
return await new Promise((resolve, reject) => {
|
|
309
|
+
const script = 'try\n' +
|
|
310
|
+
' set theFolder to choose folder with prompt "选择目录"\n' +
|
|
311
|
+
' POSIX path of theFolder\n' +
|
|
312
|
+
'on error number -128\n' +
|
|
313
|
+
' return ""\n' +
|
|
314
|
+
'end try';
|
|
315
|
+
const child = spawn('osascript', ['-e', script], {
|
|
316
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
317
|
+
});
|
|
318
|
+
let out = '';
|
|
319
|
+
let err = '';
|
|
320
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
321
|
+
child.stderr.on('data', (d) => (err += d.toString()));
|
|
322
|
+
child.on('error', (e) => reject(e));
|
|
323
|
+
child.on('close', (code) => {
|
|
324
|
+
if (code === 0) {
|
|
325
|
+
const p = out.trim();
|
|
326
|
+
return resolve(p || null);
|
|
327
|
+
}
|
|
328
|
+
reject(new Error(err.trim() || `osascript exited with code ${code}`));
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Linux / 其他 Unix:尝试使用 zenity
|
|
333
|
+
return await new Promise((resolve, reject) => {
|
|
334
|
+
const child = spawn('zenity', ['--file-selection', '--directory', '--title=选择目录'], {
|
|
335
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
336
|
+
});
|
|
337
|
+
let out = '';
|
|
338
|
+
let err = '';
|
|
339
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
340
|
+
child.stderr.on('data', (d) => (err += d.toString()));
|
|
341
|
+
child.on('error', (e) => reject(e));
|
|
342
|
+
child.on('close', (code) => {
|
|
343
|
+
if (code === 0) {
|
|
344
|
+
const p = out.trim();
|
|
345
|
+
return resolve(p || null);
|
|
346
|
+
}
|
|
347
|
+
if (code === 1) {
|
|
348
|
+
// 用户取消
|
|
349
|
+
return resolve(null);
|
|
350
|
+
}
|
|
351
|
+
reject(new Error(err.trim() || `zenity exited with code ${code}`));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Pick directory via native dialog
|
|
356
|
+
app.post('/api/path/pick', async (req, res) => {
|
|
357
|
+
try {
|
|
358
|
+
const dir = await pickDirectoryDialog();
|
|
359
|
+
if (!dir) {
|
|
360
|
+
return res.json({ ok: false, cancelled: true });
|
|
361
|
+
}
|
|
362
|
+
const st = await fs.stat(dir).catch(() => null);
|
|
363
|
+
if (!st || !st.isDirectory()) {
|
|
364
|
+
return res.status(400).json({ ok: false, error: 'selected path is not a directory' });
|
|
365
|
+
}
|
|
366
|
+
res.json({ ok: true, path: dir });
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
res.status(500).json({ ok: false, error: e?.message || String(e) });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
// Accept uploaded private key (base64)
|
|
373
|
+
let uploadedKeyMem;
|
|
374
|
+
app.post('/api/ssh/key', async (req, res) => {
|
|
375
|
+
const { keyBase64 } = req.body || {};
|
|
376
|
+
if (!keyBase64)
|
|
377
|
+
return res.status(400).json({ error: 'missing keyBase64' });
|
|
378
|
+
uploadedKeyMem = Buffer.from(String(keyBase64), 'base64');
|
|
379
|
+
res.json({ ok: true, size: uploadedKeyMem.byteLength });
|
|
380
|
+
});
|
|
381
|
+
// Accept private key from local path
|
|
382
|
+
app.post('/api/ssh/key/path', async (req, res) => {
|
|
383
|
+
const { keyPath } = req.body || {};
|
|
384
|
+
if (!keyPath)
|
|
385
|
+
return res.status(400).json({ error: 'missing keyPath' });
|
|
386
|
+
try {
|
|
387
|
+
const buf = await fs.readFile(String(keyPath));
|
|
388
|
+
uploadedKeyMem = buf;
|
|
389
|
+
res.json({ ok: true, size: uploadedKeyMem.byteLength });
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// SSH tunnel management (reverse proxy: Host端监听 → 连接回 Agent gRPC)
|
|
396
|
+
let tunnel;
|
|
397
|
+
let lastRemotePort;
|
|
398
|
+
let lastError;
|
|
399
|
+
let lastErrorCode;
|
|
400
|
+
let lastSuggestions;
|
|
401
|
+
let lastDiscoveryPath;
|
|
402
|
+
let lastParams;
|
|
403
|
+
let autoReconnect = true;
|
|
404
|
+
// 监听器管理:防止重复注册导致的内存泄漏
|
|
405
|
+
const tunnelCleanupListeners = new Set();
|
|
406
|
+
function addTunnelCleanupListener(cleanupFn) {
|
|
407
|
+
// 先移除旧的监听器(如果存在)
|
|
408
|
+
removeTunnelCleanupListeners();
|
|
409
|
+
tunnelCleanupListeners.add(cleanupFn);
|
|
410
|
+
server.once('close', cleanupFn);
|
|
411
|
+
logInfo('tunnel.cleanup_listener_added', {
|
|
412
|
+
listenerCount: tunnelCleanupListeners.size,
|
|
413
|
+
maxListeners: server.getMaxListeners(),
|
|
414
|
+
currentListeners: server.listenerCount('close'),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function removeTunnelCleanupListeners() {
|
|
418
|
+
for (const cleanupFn of tunnelCleanupListeners) {
|
|
419
|
+
server.off('close', cleanupFn);
|
|
420
|
+
}
|
|
421
|
+
tunnelCleanupListeners.clear();
|
|
422
|
+
logInfo('tunnel.cleanup_listeners_removed');
|
|
423
|
+
}
|
|
424
|
+
async function cleanupTunnel(reason, additionalCleanup) {
|
|
425
|
+
logInfo('tunnel.cleanup_start', { reason, hasTunnel: !!tunnel });
|
|
426
|
+
try {
|
|
427
|
+
if (tunnel) {
|
|
428
|
+
await tunnel.stop();
|
|
429
|
+
tunnel = undefined;
|
|
430
|
+
lastRemotePort = undefined;
|
|
431
|
+
// 添加延迟确保端口完全释放
|
|
432
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
433
|
+
logInfo('tunnel.stopped', { reason });
|
|
434
|
+
}
|
|
435
|
+
// 执行额外的清理逻辑
|
|
436
|
+
if (additionalCleanup) {
|
|
437
|
+
await additionalCleanup();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
logError('tunnel.cleanup_failed', {
|
|
442
|
+
reason,
|
|
443
|
+
error: e?.message || String(e),
|
|
444
|
+
});
|
|
445
|
+
// 即使失败也要清理引用
|
|
446
|
+
tunnel = undefined;
|
|
447
|
+
lastRemotePort = undefined;
|
|
448
|
+
}
|
|
449
|
+
// 移除所有相关的清理监听器
|
|
450
|
+
removeTunnelCleanupListeners();
|
|
451
|
+
logInfo('tunnel.cleanup_complete', { reason });
|
|
452
|
+
}
|
|
453
|
+
// 口令管理:从环境变量读取默认 SSH 密码,仅在内存中持有
|
|
454
|
+
const sshPasswordSecret = readPasswordFromEnv();
|
|
455
|
+
function hashPort(username) {
|
|
456
|
+
const base = 23745;
|
|
457
|
+
const span = 25745 - 23745 + 1; // inclusive
|
|
458
|
+
let h = 0;
|
|
459
|
+
for (let i = 0; i < username.length; i++) {
|
|
460
|
+
h = (h * 131 + username.charCodeAt(i)) >>> 0;
|
|
461
|
+
}
|
|
462
|
+
return base + (h % span);
|
|
463
|
+
}
|
|
464
|
+
app.get('/api/ssh/tunnel/status', async (_req, res) => {
|
|
465
|
+
res.json({
|
|
466
|
+
connected: !!(tunnel && tunnel.isConnected()),
|
|
467
|
+
remotePort: lastRemotePort,
|
|
468
|
+
lastError,
|
|
469
|
+
code: lastErrorCode,
|
|
470
|
+
suggestions: lastSuggestions,
|
|
471
|
+
discoveryPath: lastDiscoveryPath,
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
app.post('/api/ssh/tunnel/start', async (req, res) => {
|
|
475
|
+
const { host, port = 22, user, password, remotePort, bindAddr = '127.0.0.1', keySource = 'default', } = req.body || {};
|
|
476
|
+
if (!host || !user)
|
|
477
|
+
return res.status(400).json({
|
|
478
|
+
error: 'missing host/user',
|
|
479
|
+
code: 'EL_INVALID_PARAMS',
|
|
480
|
+
suggestions: ['请填写 SSH 主机与用户名'],
|
|
481
|
+
});
|
|
482
|
+
if (!cfg.grpcPort)
|
|
483
|
+
return res.status(500).json({ error: 'grpcPort unavailable' });
|
|
484
|
+
try {
|
|
485
|
+
// 若已存在同一 host/user/port 的隧道,则视为幂等重复调用,直接复用
|
|
486
|
+
if (tunnel &&
|
|
487
|
+
tunnel.isConnected() &&
|
|
488
|
+
lastParams &&
|
|
489
|
+
lastParams.host === host &&
|
|
490
|
+
lastParams.user === user &&
|
|
491
|
+
lastParams.port === Number(port)) {
|
|
492
|
+
lastError = undefined;
|
|
493
|
+
lastErrorCode = undefined;
|
|
494
|
+
lastSuggestions = undefined;
|
|
495
|
+
return res.json({
|
|
496
|
+
connected: true,
|
|
497
|
+
remotePort: lastRemotePort,
|
|
498
|
+
discoveryPath: lastDiscoveryPath,
|
|
499
|
+
reused: true,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// Probe existing mapping for this user+agent on remote; if active, refuse
|
|
503
|
+
const agentId = cfg.agentId || 'agent';
|
|
504
|
+
const probe = await probeRemoteExistingTunnel({
|
|
505
|
+
host,
|
|
506
|
+
sshPort: Number(port),
|
|
507
|
+
user,
|
|
508
|
+
password,
|
|
509
|
+
agentId,
|
|
510
|
+
});
|
|
511
|
+
if (probe.active && probe.port) {
|
|
512
|
+
lastError = `existing tunnel active on remote port ${probe.port}`;
|
|
513
|
+
lastErrorCode = 'EL_SSH_TUNNEL_BROKEN';
|
|
514
|
+
lastSuggestions = ['复用已存在端口', '停止其他 Agent 隧道后再试'];
|
|
515
|
+
lastDiscoveryPath = probe.path;
|
|
516
|
+
return res.status(409).json({
|
|
517
|
+
error: lastError,
|
|
518
|
+
code: lastErrorCode,
|
|
519
|
+
suggestions: lastSuggestions,
|
|
520
|
+
remotePort: probe.port,
|
|
521
|
+
discoveryPath: lastDiscoveryPath,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
await cleanupTunnel('probe_existing_tunnel');
|
|
525
|
+
// 确保私钥已正确加载
|
|
526
|
+
let privateKey = uploadedKeyMem;
|
|
527
|
+
if (keySource === 'default' && !privateKey) {
|
|
528
|
+
try {
|
|
529
|
+
const c = await loadConfig();
|
|
530
|
+
if (c.ssh?.defaultKeyPath) {
|
|
531
|
+
privateKey = await fs.readFile(c.ssh.defaultKeyPath);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
console.debug('Failed to read default SSH key:', e?.message || String(e));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// First try requested port, else fallback to username-hash mapping
|
|
539
|
+
const userName = os.userInfo().username || 'user';
|
|
540
|
+
const basePort = remotePort ? Number(remotePort) : hashPort(user);
|
|
541
|
+
// Build a list of ports to try (base port + 10 alternatives)
|
|
542
|
+
const tryPorts = [];
|
|
543
|
+
for (let i = 0; i <= 10; i++) {
|
|
544
|
+
tryPorts.push(basePort + i);
|
|
545
|
+
}
|
|
546
|
+
let bound;
|
|
547
|
+
let portBindingError;
|
|
548
|
+
for (const p of tryPorts) {
|
|
549
|
+
try {
|
|
550
|
+
// Ensure previous tunnel is fully stopped before creating new one
|
|
551
|
+
await cleanupTunnel(`port_binding_failed_${p}`);
|
|
552
|
+
tunnel = new SshTunnel();
|
|
553
|
+
const r = await tunnel.start({
|
|
554
|
+
host,
|
|
555
|
+
port: Number(port),
|
|
556
|
+
user,
|
|
557
|
+
password: password || sshPasswordSecret.value,
|
|
558
|
+
privateKey,
|
|
559
|
+
bindAddr,
|
|
560
|
+
bindPort: Number(p),
|
|
561
|
+
dstHost: '127.0.0.1',
|
|
562
|
+
dstPort: Number(cfg.grpcPort),
|
|
563
|
+
});
|
|
564
|
+
bound = r.bindPort;
|
|
565
|
+
portBindingError = undefined;
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
catch (e) {
|
|
569
|
+
portBindingError = e?.message || String(e);
|
|
570
|
+
console.warn(`Failed to bind port ${p}:`, portBindingError);
|
|
571
|
+
// Clean up failed tunnel properly
|
|
572
|
+
await cleanupTunnel(`port_binding_cleanup_${p}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!bound)
|
|
576
|
+
throw new Error(portBindingError || 'bind failed');
|
|
577
|
+
lastRemotePort = bound;
|
|
578
|
+
lastParams = { host, port: Number(port), user, password, bindAddr, keySource };
|
|
579
|
+
// 添加清理监听器:在Server关闭时自动清理隧道
|
|
580
|
+
addTunnelCleanupListener(async () => {
|
|
581
|
+
await cleanupTunnel('server_shutdown');
|
|
582
|
+
});
|
|
583
|
+
// 验证SSH隧道连通性后再写入agent.json
|
|
584
|
+
// 此时tunnel一定存在,因为bound不是undefined意味着隧道建立成功
|
|
585
|
+
const isTunnelReady = await tunnel.verifyConnectivity(5000);
|
|
586
|
+
if (!isTunnelReady) {
|
|
587
|
+
throw new Error('SSH tunnel established but connectivity verification failed');
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const originHost = os.hostname();
|
|
591
|
+
const originUser = cfg.originUser || os.userInfo().username || 'user';
|
|
592
|
+
const pairCode = cfg.pairCode;
|
|
593
|
+
const startupTime = cfg.startupTime;
|
|
594
|
+
lastDiscoveryPath = await writeRemoteAgentJson(tunnel, {
|
|
595
|
+
port: bound,
|
|
596
|
+
agentId,
|
|
597
|
+
user,
|
|
598
|
+
originHost,
|
|
599
|
+
originUser,
|
|
600
|
+
pairCode,
|
|
601
|
+
version: '0.1.0',
|
|
602
|
+
startupTime,
|
|
603
|
+
sshHost: host,
|
|
604
|
+
sshPort: Number(port),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch { }
|
|
608
|
+
lastError = undefined;
|
|
609
|
+
lastErrorCode = undefined;
|
|
610
|
+
lastSuggestions = undefined;
|
|
611
|
+
res.json({ connected: true, remotePort: bound, discoveryPath: lastDiscoveryPath });
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
const fe = friendlyError(e);
|
|
615
|
+
lastError = fe.message;
|
|
616
|
+
lastErrorCode = fe.code;
|
|
617
|
+
lastSuggestions = fe.suggestions;
|
|
618
|
+
res.status(500).json({ error: fe.message, code: fe.code, suggestions: fe.suggestions });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
app.post('/api/ssh/tunnel/stop', async (_req, res) => {
|
|
622
|
+
try {
|
|
623
|
+
await cleanupTunnel('manual_stop_api');
|
|
624
|
+
res.json({ stopped: true });
|
|
625
|
+
}
|
|
626
|
+
catch (e) {
|
|
627
|
+
// Always clear the tunnel reference even if stop fails
|
|
628
|
+
tunnel = undefined;
|
|
629
|
+
lastRemotePort = undefined;
|
|
630
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
// Test SSH connectivity (no tunnel)
|
|
634
|
+
app.post('/api/ssh/test', async (req, res) => {
|
|
635
|
+
const { host, port = 22, user, password, keySource = 'default', } = req.body || {};
|
|
636
|
+
if (!host || !user) {
|
|
637
|
+
return res.status(400).json({
|
|
638
|
+
ok: false,
|
|
639
|
+
error: 'missing host/user',
|
|
640
|
+
code: 'EL_INVALID_PARAMS',
|
|
641
|
+
suggestions: ['请填写 SSH 主机和用户名'],
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// 加载默认私钥(如果使用默认私钥选项)
|
|
645
|
+
if (keySource === 'default') {
|
|
646
|
+
try {
|
|
647
|
+
const c = await loadConfig();
|
|
648
|
+
if (c.ssh?.defaultKeyPath) {
|
|
649
|
+
uploadedKeyMem = await fs.readFile(c.ssh.defaultKeyPath);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
// 私钥读取失败时,uploadedKeyMem 保持原状
|
|
654
|
+
// SSH连接会返回相应的错误信息
|
|
655
|
+
console.debug('Failed to read default SSH key:', e?.message || String(e));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const ssh2 = await import('ssh2');
|
|
660
|
+
const ClientCtor = ssh2.Client;
|
|
661
|
+
const client = new ClientCtor();
|
|
662
|
+
await new Promise((resolve, reject) => {
|
|
663
|
+
client
|
|
664
|
+
.on('ready', () => {
|
|
665
|
+
try {
|
|
666
|
+
client.end();
|
|
667
|
+
}
|
|
668
|
+
catch { }
|
|
669
|
+
resolve();
|
|
670
|
+
})
|
|
671
|
+
.on('error', (e) => {
|
|
672
|
+
try {
|
|
673
|
+
client.end();
|
|
674
|
+
}
|
|
675
|
+
catch { }
|
|
676
|
+
reject(e);
|
|
677
|
+
})
|
|
678
|
+
.connect({
|
|
679
|
+
host,
|
|
680
|
+
port: Number(port),
|
|
681
|
+
username: user,
|
|
682
|
+
password: password || sshPasswordSecret.value,
|
|
683
|
+
privateKey: uploadedKeyMem,
|
|
684
|
+
tryKeyboard: false,
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
res.json({ ok: true });
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
const fe = friendlyError(e);
|
|
691
|
+
res
|
|
692
|
+
.status(500)
|
|
693
|
+
.json({ ok: false, error: fe.message, code: fe.code, suggestions: fe.suggestions });
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
// Auto-reconnect loop (simple fixed interval)
|
|
697
|
+
setInterval(async () => {
|
|
698
|
+
if (!autoReconnect)
|
|
699
|
+
return;
|
|
700
|
+
if (!lastParams)
|
|
701
|
+
return;
|
|
702
|
+
if (tunnel && tunnel.isConnected())
|
|
703
|
+
return;
|
|
704
|
+
try {
|
|
705
|
+
// Ensure previous tunnel is fully stopped before creating new one
|
|
706
|
+
await cleanupTunnel('auto_reconnect_start');
|
|
707
|
+
// 确保私钥已正确加载(自动重连场景)
|
|
708
|
+
let privateKey = uploadedKeyMem;
|
|
709
|
+
if (lastParams.keySource === 'default' && !privateKey) {
|
|
710
|
+
try {
|
|
711
|
+
const c = await loadConfig();
|
|
712
|
+
if (c.ssh?.defaultKeyPath) {
|
|
713
|
+
privateKey = await fs.readFile(c.ssh.defaultKeyPath);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
console.debug('Failed to read default SSH key for auto-reconnect:', e?.message || String(e));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const userName = os.userInfo().username || 'user';
|
|
721
|
+
const basePort = lastRemotePort || hashPort(userName) || 23745;
|
|
722
|
+
// Try multiple ports for auto-reconnect as well
|
|
723
|
+
const tryPorts = [];
|
|
724
|
+
for (let i = 0; i <= 10; i++) {
|
|
725
|
+
tryPorts.push(basePort + i);
|
|
726
|
+
}
|
|
727
|
+
let connected = false;
|
|
728
|
+
for (const p of tryPorts) {
|
|
729
|
+
try {
|
|
730
|
+
tunnel = new SshTunnel();
|
|
731
|
+
const r = await tunnel.start({
|
|
732
|
+
host: lastParams.host,
|
|
733
|
+
port: lastParams.port,
|
|
734
|
+
user: lastParams.user,
|
|
735
|
+
password: lastParams.password,
|
|
736
|
+
privateKey,
|
|
737
|
+
bindAddr: lastParams.bindAddr,
|
|
738
|
+
bindPort: p,
|
|
739
|
+
dstHost: '127.0.0.1',
|
|
740
|
+
dstPort: Number(cfg.grpcPort),
|
|
741
|
+
});
|
|
742
|
+
lastRemotePort = r.bindPort;
|
|
743
|
+
connected = true;
|
|
744
|
+
// 为自动重连的隧道添加清理监听器
|
|
745
|
+
addTunnelCleanupListener(async () => {
|
|
746
|
+
await cleanupTunnel('auto_reconnect_server_shutdown');
|
|
747
|
+
});
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
console.warn(`Auto-reconnect failed for port ${p}:`, e?.message || String(e));
|
|
752
|
+
await cleanupTunnel(`auto_reconnect_port_failed_${p}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (!connected) {
|
|
756
|
+
throw new Error('All auto-reconnect attempts failed');
|
|
757
|
+
}
|
|
758
|
+
// 验证SSH隧道连通性后再写入agent.json
|
|
759
|
+
const isTunnelReady = await tunnel.verifyConnectivity(5000);
|
|
760
|
+
if (!isTunnelReady) {
|
|
761
|
+
// 在自动重连中,如果验证失败,清理隧道并跳过本次写入
|
|
762
|
+
await cleanupTunnel('auto_reconnect_connectivity_failed');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const originHost = cfg.originHost || os.hostname();
|
|
767
|
+
const originUser = cfg.originUser || os.userInfo().username || 'user';
|
|
768
|
+
const pairCode = cfg.pairCode;
|
|
769
|
+
const startupTime = cfg.startupTime;
|
|
770
|
+
lastDiscoveryPath = await writeRemoteAgentJson(tunnel, {
|
|
771
|
+
port: lastRemotePort,
|
|
772
|
+
agentId: cfg.agentId || 'agent',
|
|
773
|
+
user: lastParams.user,
|
|
774
|
+
originHost,
|
|
775
|
+
originUser,
|
|
776
|
+
pairCode,
|
|
777
|
+
version: '0.1.0',
|
|
778
|
+
startupTime,
|
|
779
|
+
sshHost: lastParams.host,
|
|
780
|
+
sshPort: lastParams.port,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
catch { }
|
|
784
|
+
lastError = undefined;
|
|
785
|
+
lastErrorCode = undefined;
|
|
786
|
+
lastSuggestions = undefined;
|
|
787
|
+
}
|
|
788
|
+
catch (e) {
|
|
789
|
+
const fe = friendlyError(e);
|
|
790
|
+
lastError = fe.message;
|
|
791
|
+
lastErrorCode = fe.code;
|
|
792
|
+
lastSuggestions = fe.suggestions;
|
|
793
|
+
// Ensure tunnel is cleaned up on failure
|
|
794
|
+
await cleanupTunnel('auto_reconnect_exception');
|
|
795
|
+
}
|
|
796
|
+
}, 5000);
|
|
797
|
+
// 定期监控监听器数量,防止内存泄漏
|
|
798
|
+
setInterval(() => {
|
|
799
|
+
const closeListenerCount = server.listenerCount('close');
|
|
800
|
+
const maxListeners = server.getMaxListeners();
|
|
801
|
+
// 监控常见事件的监听器数量
|
|
802
|
+
const commonEvents = ['close', 'error', 'listening', 'request', 'connection'];
|
|
803
|
+
const totalListeners = commonEvents.reduce((sum, eventName) => {
|
|
804
|
+
return sum + server.listenerCount(eventName);
|
|
805
|
+
}, 0);
|
|
806
|
+
if (closeListenerCount > maxListeners * 0.8) {
|
|
807
|
+
// 超过80%时警告
|
|
808
|
+
logInfo('server.listeners_warning', {
|
|
809
|
+
closeListeners: closeListenerCount,
|
|
810
|
+
maxListeners,
|
|
811
|
+
totalListeners,
|
|
812
|
+
tunnelCleanupListeners: tunnelCleanupListeners.size,
|
|
813
|
+
utilization: `${Math.round((closeListenerCount / maxListeners) * 100)}%`,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
// 定期记录监听器状态(每分钟一次)
|
|
817
|
+
if (Math.random() < 0.2) {
|
|
818
|
+
// 20% 概率,避免日志过多
|
|
819
|
+
logInfo('server.listeners_status', {
|
|
820
|
+
closeListeners: closeListenerCount,
|
|
821
|
+
maxListeners,
|
|
822
|
+
totalListeners,
|
|
823
|
+
tunnelCleanupListeners: tunnelCleanupListeners.size,
|
|
824
|
+
commonEventCounts: commonEvents.map((event) => ({
|
|
825
|
+
event,
|
|
826
|
+
count: server.listenerCount(event),
|
|
827
|
+
})),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}, 60000); // 每分钟检查一次
|
|
831
|
+
// Auto-reconnect toggle
|
|
832
|
+
app.get('/api/ssh/tunnel/auto', (_req, res) => res.json({ enable: autoReconnect }));
|
|
833
|
+
app.post('/api/ssh/tunnel/auto', (req, res) => {
|
|
834
|
+
autoReconnect = !!req.body?.enable;
|
|
835
|
+
res.json({ enable: autoReconnect });
|
|
836
|
+
});
|
|
837
|
+
// Config read/write
|
|
838
|
+
let currentConfig = cfg.agentConfig ?? null;
|
|
839
|
+
const configPathCandidates = [
|
|
840
|
+
path.join(os.homedir(), '.config', 'embed_link', 'config.json'),
|
|
841
|
+
path.join(process.cwd(), 'agent.config.yaml'),
|
|
842
|
+
path.join(process.cwd(), 'agent.config.yml'),
|
|
843
|
+
path.join(process.cwd(), 'agent.config.json'),
|
|
844
|
+
];
|
|
845
|
+
async function ensureConfigLoaded() {
|
|
846
|
+
if (currentConfig) {
|
|
847
|
+
return currentConfig;
|
|
848
|
+
}
|
|
849
|
+
currentConfig = await loadConfig();
|
|
850
|
+
return currentConfig;
|
|
851
|
+
}
|
|
852
|
+
function resolveConfigPath() {
|
|
853
|
+
// 始终优先使用用户主目录下的配置文件路径
|
|
854
|
+
return configPathCandidates[0];
|
|
855
|
+
}
|
|
856
|
+
app.get('/api/config', async (_req, res) => {
|
|
857
|
+
try {
|
|
858
|
+
const c = await ensureConfigLoaded();
|
|
859
|
+
res.json({
|
|
860
|
+
config: c,
|
|
861
|
+
pairCode: cfg.pairCode,
|
|
862
|
+
agentId: cfg.agentId,
|
|
863
|
+
startupTime: cfg.startupTime,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
app.put('/api/config', async (req, res) => {
|
|
871
|
+
try {
|
|
872
|
+
const incoming = req.body?.config || {};
|
|
873
|
+
const apply = !!req.body?.apply;
|
|
874
|
+
const base = await ensureConfigLoaded();
|
|
875
|
+
const merged = { ...base, ...incoming };
|
|
876
|
+
const tftpConfigChanged = JSON.stringify(base.tftp?.server) !== JSON.stringify(merged.tftp?.server);
|
|
877
|
+
// 落盘
|
|
878
|
+
const target = resolveConfigPath();
|
|
879
|
+
const out = target.endsWith('.json') || target.endsWith('.json5')
|
|
880
|
+
? JSON.stringify(merged, null, 2)
|
|
881
|
+
: YAML.stringify(merged);
|
|
882
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
883
|
+
await fs.writeFile(target, out, 'utf8');
|
|
884
|
+
if (apply) {
|
|
885
|
+
Object.assign(base, merged);
|
|
886
|
+
if (tftpConfigChanged) {
|
|
887
|
+
const serverConfig = merged.tftp?.server;
|
|
888
|
+
if (serverConfig?.enabled && serverConfig.mode !== 'disabled') {
|
|
889
|
+
await startTftpServer(merged);
|
|
890
|
+
logInfo('tftp.started', { config: serverConfig });
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
await stopTftpServer();
|
|
894
|
+
logInfo('tftp.stopped', { reason: 'config_disabled' });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
setDefaultBoardUartHint(merged.boardUart.port, merged.boardUart.baud);
|
|
899
|
+
logInfo('config.saved', { path: target });
|
|
900
|
+
res.json({ ok: true, path: target });
|
|
901
|
+
}
|
|
902
|
+
catch (e) {
|
|
903
|
+
logError('config.save_failed', { error: e?.message || String(e) });
|
|
904
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
async function selftestExternalTftp(host, port) {
|
|
908
|
+
const id = randomUUID();
|
|
909
|
+
const remoteSubpath = `__embedlink_test/${id}.bin`;
|
|
910
|
+
const payload = Buffer.from(`embedlink-tftp-selfcheck-${id}`, 'utf8');
|
|
911
|
+
const start = Date.now();
|
|
912
|
+
await uploadToExternalTftp(host, port, remoteSubpath, payload);
|
|
913
|
+
const r = await downloadFromExternalTftp(host, port, remoteSubpath);
|
|
914
|
+
const ok = r.size === payload.length && r.content.equals(payload);
|
|
915
|
+
const cost = Date.now() - start;
|
|
916
|
+
if (ok) {
|
|
917
|
+
return {
|
|
918
|
+
ok: true,
|
|
919
|
+
message: `ok, host=${host}:${port}, rtt=${cost}ms`,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
ok: false,
|
|
924
|
+
message: `content mismatch from ${host}:${port}`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function normalizePosixSubpath(subpath) {
|
|
928
|
+
if (!subpath)
|
|
929
|
+
return '';
|
|
930
|
+
const replaced = subpath.replace(/\\/g, '/');
|
|
931
|
+
const parts = replaced.split('/').filter((p) => p && p !== '.' && p !== '..');
|
|
932
|
+
return parts.join('/');
|
|
933
|
+
}
|
|
934
|
+
// TFTP files listing
|
|
935
|
+
app.get('/api/tftp/files', async (req, res) => {
|
|
936
|
+
try {
|
|
937
|
+
const c = await ensureConfigLoaded();
|
|
938
|
+
const subpath = String(req.query.path || '').trim();
|
|
939
|
+
const result = await listTftpEntries(c.tftp.dir, subpath || undefined, 1);
|
|
940
|
+
// listTftpEntries 会把起始目录本身也作为一条 entry 返回,
|
|
941
|
+
// Web UI 只关心"当前目录的子项",这里过滤掉这条"自身"记录
|
|
942
|
+
const baseRel = normalizePosixSubpath(subpath || undefined);
|
|
943
|
+
const files = result.entries
|
|
944
|
+
.filter((entry) => !entry.isDirectory)
|
|
945
|
+
.map((entry) => {
|
|
946
|
+
const tftpEntry = entry;
|
|
947
|
+
return {
|
|
948
|
+
name: entry.path.split('/').pop() || entry.path,
|
|
949
|
+
path: entry.path,
|
|
950
|
+
size: tftpEntry.sizeBytes,
|
|
951
|
+
};
|
|
952
|
+
});
|
|
953
|
+
const directories = result.entries
|
|
954
|
+
.filter((entry) => entry.isDirectory && entry.path !== baseRel)
|
|
955
|
+
.map((entry) => ({
|
|
956
|
+
name: entry.path.split('/').pop() || entry.path,
|
|
957
|
+
path: entry.path,
|
|
958
|
+
}));
|
|
959
|
+
res.json({ files, directories });
|
|
960
|
+
}
|
|
961
|
+
catch (e) {
|
|
962
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
// NFS files listing
|
|
966
|
+
app.get('/api/nfs/files', async (req, res) => {
|
|
967
|
+
try {
|
|
968
|
+
const c = await ensureConfigLoaded();
|
|
969
|
+
if (!c.nfs?.enabled || !c.nfs?.localPath) {
|
|
970
|
+
return res.status(400).json({ error: 'NFS is not configured or disabled' });
|
|
971
|
+
}
|
|
972
|
+
const subpath = String(req.query.path || '').trim();
|
|
973
|
+
const result = await nfsList(c.nfs.localPath, subpath || undefined, 'detailed');
|
|
974
|
+
const baseRel = normalizePosixSubpath(subpath || undefined);
|
|
975
|
+
const files = result.entries
|
|
976
|
+
.filter((entry) => !entry.isDirectory)
|
|
977
|
+
.map((entry) => {
|
|
978
|
+
const detailedEntry = entry;
|
|
979
|
+
return {
|
|
980
|
+
name: entry.name, // 直接使用name字段
|
|
981
|
+
size: detailedEntry.sizeBytes,
|
|
982
|
+
};
|
|
983
|
+
});
|
|
984
|
+
const directories = result.entries
|
|
985
|
+
.filter((entry) => entry.isDirectory)
|
|
986
|
+
.map((entry) => ({
|
|
987
|
+
name: entry.name, // 直接使用name字段
|
|
988
|
+
}));
|
|
989
|
+
res.json({ files, directories });
|
|
990
|
+
}
|
|
991
|
+
catch (e) {
|
|
992
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
// External TFTP quick self-test
|
|
996
|
+
app.get('/api/tftp/external/selftest', async (req, res) => {
|
|
997
|
+
try {
|
|
998
|
+
const c = await ensureConfigLoaded();
|
|
999
|
+
const mode = c.tftp?.server?.mode || 'builtin';
|
|
1000
|
+
if (mode !== 'external') {
|
|
1001
|
+
return res.status(400).json({ ok: false, error: 'tftp server mode is not external' });
|
|
1002
|
+
}
|
|
1003
|
+
const hostParam = String(req.query.host || '').trim();
|
|
1004
|
+
const portParam = Number(req.query.port || 0);
|
|
1005
|
+
const host = hostParam || c.tftp?.server?.externalHost || '';
|
|
1006
|
+
const port = portParam || c.tftp?.server?.externalPort || 69;
|
|
1007
|
+
if (!host || !host.trim()) {
|
|
1008
|
+
return res.status(400).json({ ok: false, error: 'externalHost not set for TFTP' });
|
|
1009
|
+
}
|
|
1010
|
+
const r = await selftestExternalTftp(host, port);
|
|
1011
|
+
res.json({ ok: r.ok, message: r.message, host, port });
|
|
1012
|
+
}
|
|
1013
|
+
catch (e) {
|
|
1014
|
+
res.status(500).json({ ok: false, error: e?.message || String(e) });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
// TFTP server control endpoints
|
|
1018
|
+
app.get('/api/tftp/status', async (_req, res) => {
|
|
1019
|
+
try {
|
|
1020
|
+
const stats = await getTftpServerStats();
|
|
1021
|
+
res.json(stats);
|
|
1022
|
+
}
|
|
1023
|
+
catch (e) {
|
|
1024
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
app.post('/api/tftp/start', async (req, res) => {
|
|
1028
|
+
try {
|
|
1029
|
+
const config = await ensureConfigLoaded();
|
|
1030
|
+
await startTftpServer(config);
|
|
1031
|
+
const stats = await getTftpServerStats();
|
|
1032
|
+
logInfo('tftp.manually_started', { mode: stats.mode, host: stats.host, port: stats.port });
|
|
1033
|
+
res.json({ success: true, stats });
|
|
1034
|
+
}
|
|
1035
|
+
catch (e) {
|
|
1036
|
+
logError('tftp.start_failed', { error: e?.message || String(e) });
|
|
1037
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
app.post('/api/tftp/stop', async (_req, res) => {
|
|
1041
|
+
try {
|
|
1042
|
+
await stopTftpServer();
|
|
1043
|
+
const stats = await getTftpServerStats();
|
|
1044
|
+
logInfo('tftp.manually_stopped', { reason: 'manual_request' });
|
|
1045
|
+
res.json({ success: true, stats });
|
|
1046
|
+
}
|
|
1047
|
+
catch (e) {
|
|
1048
|
+
logError('tftp.stop_failed', { error: e?.message || String(e) });
|
|
1049
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
// Self-check
|
|
1053
|
+
app.get('/api/selfcheck', async (_req, res) => {
|
|
1054
|
+
const results = [];
|
|
1055
|
+
try {
|
|
1056
|
+
results.push({ step: 'health', ok: true, message: 'ok' });
|
|
1057
|
+
// PairCheck 由上游调用,这里仅回显配对码存在
|
|
1058
|
+
if (cfg.pairCode) {
|
|
1059
|
+
results.push({ step: 'pairCode', ok: true, message: 'pairCode present' });
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
results.push({ step: 'pairCode', ok: false, message: 'pairCode missing' });
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
const c = await ensureConfigLoaded();
|
|
1066
|
+
if (c.boardUart.port) {
|
|
1067
|
+
await boardUartWrite({
|
|
1068
|
+
port: c.boardUart.port,
|
|
1069
|
+
baud: c.boardUart.baud ?? DefaultTimeouts.boardUart.write,
|
|
1070
|
+
data: Buffer.alloc(0),
|
|
1071
|
+
mock: false,
|
|
1072
|
+
});
|
|
1073
|
+
results.push({ step: 'boardUart', ok: true, message: `${c.boardUart.port}` });
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
results.push({ step: 'boardUart', ok: false, message: 'boardUart.port not set' });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (e) {
|
|
1080
|
+
results.push({ step: 'boardUart', ok: false, message: e?.message || String(e) });
|
|
1081
|
+
}
|
|
1082
|
+
try {
|
|
1083
|
+
const c = await ensureConfigLoaded();
|
|
1084
|
+
const mode = c.tftp.server?.mode || 'builtin';
|
|
1085
|
+
const enabled = c.tftp.server?.enabled !== false && mode !== 'disabled';
|
|
1086
|
+
if (!enabled) {
|
|
1087
|
+
results.push({ step: 'tftp', ok: false, message: 'disabled' });
|
|
1088
|
+
}
|
|
1089
|
+
else if (mode === 'external') {
|
|
1090
|
+
const host = c.tftp.server?.externalHost;
|
|
1091
|
+
const port = c.tftp.server?.externalPort || 69;
|
|
1092
|
+
if (!host || !host.trim()) {
|
|
1093
|
+
results.push({
|
|
1094
|
+
step: 'tftp.external',
|
|
1095
|
+
ok: false,
|
|
1096
|
+
message: 'externalHost not set',
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
try {
|
|
1101
|
+
const r = await selftestExternalTftp(host, port);
|
|
1102
|
+
results.push({ step: 'tftp.external', ok: r.ok, message: r.message });
|
|
1103
|
+
}
|
|
1104
|
+
catch (e) {
|
|
1105
|
+
results.push({
|
|
1106
|
+
step: 'tftp.external',
|
|
1107
|
+
ok: false,
|
|
1108
|
+
message: e?.message || String(e),
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
// builtin 模式:检查目录与服务器状态
|
|
1115
|
+
const dir = c.tftp.dir;
|
|
1116
|
+
let dirOk = false;
|
|
1117
|
+
try {
|
|
1118
|
+
const st = await fs.stat(dir);
|
|
1119
|
+
dirOk = st.isDirectory();
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
dirOk = false;
|
|
1123
|
+
}
|
|
1124
|
+
const stats = await getTftpServerStats();
|
|
1125
|
+
const ok = dirOk && stats.enabled && stats.isRunning;
|
|
1126
|
+
const parts = [];
|
|
1127
|
+
parts.push(`dir=${dirOk ? dir : 'missing'}`);
|
|
1128
|
+
if (stats.mode === 'builtin') {
|
|
1129
|
+
const host = stats.host || '0.0.0.0';
|
|
1130
|
+
const port = stats.port || 69;
|
|
1131
|
+
parts.push(`server=${stats.isRunning ? 'running' : 'stopped'}@${host}:${port}`);
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
parts.push(`serverMode=${stats.mode}`);
|
|
1135
|
+
}
|
|
1136
|
+
if (stats.lastError)
|
|
1137
|
+
parts.push(`error=${stats.lastError}`);
|
|
1138
|
+
results.push({ step: 'tftp', ok, message: parts.join(', ') });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
catch (e) {
|
|
1142
|
+
results.push({ step: 'tftp', ok: false, message: e?.message || String(e) });
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const c = await ensureConfigLoaded();
|
|
1146
|
+
results.push({
|
|
1147
|
+
step: 'nfs',
|
|
1148
|
+
ok: !!(c.nfs?.enabled && c.nfs?.localPath),
|
|
1149
|
+
message: c.nfs?.localPath || 'not set',
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch (e) {
|
|
1153
|
+
results.push({ step: 'nfs', ok: false, message: e?.message || String(e) });
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const c = await ensureConfigLoaded();
|
|
1157
|
+
const binaryPath = c.firmware?.flashtool?.binaryPath;
|
|
1158
|
+
if (!binaryPath || !binaryPath.trim()) {
|
|
1159
|
+
results.push({
|
|
1160
|
+
step: 'flashtool',
|
|
1161
|
+
ok: false,
|
|
1162
|
+
message: 'firmware.flashtool.binaryPath not set',
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
try {
|
|
1167
|
+
const stIsp = await fs.stat(binaryPath);
|
|
1168
|
+
const ok = typeof stIsp.isFile === 'function'
|
|
1169
|
+
? stIsp.isFile()
|
|
1170
|
+
: !stIsp.isDirectory();
|
|
1171
|
+
results.push({
|
|
1172
|
+
step: 'flashtool',
|
|
1173
|
+
ok,
|
|
1174
|
+
message: ok ? binaryPath : `not a regular file: ${binaryPath}`,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
catch (e) {
|
|
1178
|
+
results.push({
|
|
1179
|
+
step: 'flashtool',
|
|
1180
|
+
ok: false,
|
|
1181
|
+
message: e?.message || `binaryPath not accessible: ${binaryPath}`,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
catch (e) {
|
|
1187
|
+
results.push({ step: 'flashtool', ok: false, message: e?.message || String(e) });
|
|
1188
|
+
}
|
|
1189
|
+
logInfo('selfcheck.done', { results });
|
|
1190
|
+
res.json({ ok: true, results });
|
|
1191
|
+
}
|
|
1192
|
+
catch (e) {
|
|
1193
|
+
res.status(500).json({ ok: false, error: e?.message || String(e), results });
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
// Docs read/write
|
|
1197
|
+
app.get('/api/docs/:kind', async (req, res) => {
|
|
1198
|
+
const kind = String(req.params.kind || '');
|
|
1199
|
+
try {
|
|
1200
|
+
const validKinds = ['board-interaction', 'tftp-transfer', 'nfs-mount', 'firmware-upgrade'];
|
|
1201
|
+
if (!validKinds.includes(kind))
|
|
1202
|
+
return res.status(400).json({ error: 'invalid kind' });
|
|
1203
|
+
const { content, path: p } = await readDoc(kind);
|
|
1204
|
+
res.json({ content, format: 'markdown', path: p });
|
|
1205
|
+
}
|
|
1206
|
+
catch (e) {
|
|
1207
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
app.put('/api/docs/:kind', async (req, res) => {
|
|
1211
|
+
const kind = String(req.params.kind || '');
|
|
1212
|
+
try {
|
|
1213
|
+
const validKinds = ['board-interaction', 'tftp-transfer', 'nfs-mount', 'firmware-upgrade'];
|
|
1214
|
+
if (!validKinds.includes(kind))
|
|
1215
|
+
return res.status(400).json({ error: 'invalid kind' });
|
|
1216
|
+
const content = String(req.body?.content || '');
|
|
1217
|
+
const path = await writeDoc(kind, content);
|
|
1218
|
+
if (kind === 'board-interaction') {
|
|
1219
|
+
await saveBoardNotes(content);
|
|
1220
|
+
}
|
|
1221
|
+
logInfo('docs.saved', { kind, path });
|
|
1222
|
+
res.json({ ok: true, path });
|
|
1223
|
+
}
|
|
1224
|
+
catch (e) {
|
|
1225
|
+
logError('docs.save_failed', { kind, error: e?.message || String(e) });
|
|
1226
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
app.post('/api/docs/:kind/reset', async (req, res) => {
|
|
1230
|
+
const kind = String(req.params.kind || '');
|
|
1231
|
+
try {
|
|
1232
|
+
const validKinds = ['board-interaction', 'tftp-transfer', 'nfs-mount', 'firmware-upgrade'];
|
|
1233
|
+
if (!validKinds.includes(kind))
|
|
1234
|
+
return res.status(400).json({ error: 'invalid kind' });
|
|
1235
|
+
const p = await resetDoc(kind);
|
|
1236
|
+
if (kind === 'board-interaction') {
|
|
1237
|
+
const { content } = await readDoc(kind);
|
|
1238
|
+
await saveBoardNotes(content);
|
|
1239
|
+
}
|
|
1240
|
+
logInfo('docs.reset', { kind, path: p });
|
|
1241
|
+
res.json({ ok: true, path: p });
|
|
1242
|
+
}
|
|
1243
|
+
catch (e) {
|
|
1244
|
+
logError('docs.reset_failed', { kind, error: e?.message || String(e) });
|
|
1245
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
// Flash / U-Boot / Reboot helpers
|
|
1249
|
+
app.post('/api/uboot/break', async (req, res) => {
|
|
1250
|
+
try {
|
|
1251
|
+
const { port, baud, timeoutMs } = req.body || {};
|
|
1252
|
+
const sessionId = port && baud ? buildSessionId(port, Number(baud)) : undefined;
|
|
1253
|
+
const r = await ubootBreak({
|
|
1254
|
+
sessionId,
|
|
1255
|
+
timeoutMs: timeoutMs || DefaultTimeouts.uboot.break,
|
|
1256
|
+
mock: false,
|
|
1257
|
+
});
|
|
1258
|
+
res.json(r);
|
|
1259
|
+
}
|
|
1260
|
+
catch (e) {
|
|
1261
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
app.post('/api/uboot/run', async (req, res) => {
|
|
1265
|
+
try {
|
|
1266
|
+
const { port, baud, command, timeoutMs } = req.body || {};
|
|
1267
|
+
const sessionId = port && baud ? buildSessionId(port, Number(baud)) : undefined;
|
|
1268
|
+
const r = await ubootRunCommand({
|
|
1269
|
+
sessionId,
|
|
1270
|
+
command,
|
|
1271
|
+
timeoutMs: timeoutMs || DefaultTimeouts.uboot.cmd,
|
|
1272
|
+
mock: false,
|
|
1273
|
+
});
|
|
1274
|
+
res.json(r);
|
|
1275
|
+
}
|
|
1276
|
+
catch (e) {
|
|
1277
|
+
res.status(500).json({ error: e?.message || String(e), code: e?.code });
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
// Firmware helper endpoints (bridge to firmware.prepare_images / firmware.burn_recover)
|
|
1281
|
+
app.post('/api/firmware/prepare', async (req, res) => {
|
|
1282
|
+
try {
|
|
1283
|
+
const imagesRoot = String(req.body?.imagesRoot || '').trim();
|
|
1284
|
+
if (!imagesRoot) {
|
|
1285
|
+
return res.status(400).json({ error: 'imagesRoot is required' });
|
|
1286
|
+
}
|
|
1287
|
+
const meta = await firmwarePrepareImages({
|
|
1288
|
+
cfg: await ensureConfigLoaded(),
|
|
1289
|
+
imagesRoot,
|
|
1290
|
+
});
|
|
1291
|
+
res.json({ meta }); // 只返回纯字符串
|
|
1292
|
+
}
|
|
1293
|
+
catch (e) {
|
|
1294
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
app.post('/api/firmware/burn', async (req, res) => {
|
|
1298
|
+
try {
|
|
1299
|
+
const { imagesRoot, args, timeoutMs, force } = req.body || {};
|
|
1300
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
1301
|
+
return res.status(400).json({ error: 'args must be a non-empty string array' });
|
|
1302
|
+
}
|
|
1303
|
+
const r = await firmwareBurnRecover({
|
|
1304
|
+
cfg: await ensureConfigLoaded(),
|
|
1305
|
+
imagesRoot: typeof imagesRoot === 'string' ? imagesRoot : '',
|
|
1306
|
+
args,
|
|
1307
|
+
timeoutMs: typeof timeoutMs === 'number' ? timeoutMs : 0,
|
|
1308
|
+
force: !!force,
|
|
1309
|
+
});
|
|
1310
|
+
res.json(r);
|
|
1311
|
+
}
|
|
1312
|
+
catch (e) {
|
|
1313
|
+
res.status(500).json({ error: e?.message || String(e) });
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
// WebSocket server and broadcast helpers
|
|
1317
|
+
const server = http.createServer(app);
|
|
1318
|
+
// 增加监听器数量限制,防止内存泄漏警告
|
|
1319
|
+
server.setMaxListeners(20);
|
|
1320
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
1321
|
+
function broadcast(obj) {
|
|
1322
|
+
const str = JSON.stringify(obj);
|
|
1323
|
+
wss.clients.forEach((c) => c.readyState === 1 && c.send(str));
|
|
1324
|
+
}
|
|
1325
|
+
// Stream internal log buffers to subscribed WS clients.
|
|
1326
|
+
// Clients MUST subscribe via `logs.subscribe` for each source they want.
|
|
1327
|
+
//
|
|
1328
|
+
// Optimization goal (per user request):
|
|
1329
|
+
// - Do not JSON.stringify if nobody subscribed.
|
|
1330
|
+
// - Do not iterate all WS clients; keep per-source subscriber sets.
|
|
1331
|
+
const wsLogSubs = new Map();
|
|
1332
|
+
// Optional host-side filtering by hostInstanceId.
|
|
1333
|
+
const wsHostIdSubs = new Map();
|
|
1334
|
+
const wsLogTargets = {
|
|
1335
|
+
agent: new Set(),
|
|
1336
|
+
host: new Set(),
|
|
1337
|
+
};
|
|
1338
|
+
function ensureSubs(ws) {
|
|
1339
|
+
let s = wsLogSubs.get(ws);
|
|
1340
|
+
if (!s) {
|
|
1341
|
+
s = new Set();
|
|
1342
|
+
wsLogSubs.set(ws, s);
|
|
1343
|
+
}
|
|
1344
|
+
return s;
|
|
1345
|
+
}
|
|
1346
|
+
function addAgentSub(ws) {
|
|
1347
|
+
const s = ensureSubs(ws);
|
|
1348
|
+
if (s.has('agent'))
|
|
1349
|
+
return;
|
|
1350
|
+
s.add('agent');
|
|
1351
|
+
wsLogTargets.agent.add(ws);
|
|
1352
|
+
}
|
|
1353
|
+
function removeAgentSub(ws) {
|
|
1354
|
+
const s = ensureSubs(ws);
|
|
1355
|
+
if (!s.has('agent'))
|
|
1356
|
+
return;
|
|
1357
|
+
s.delete('agent');
|
|
1358
|
+
wsLogTargets.agent.delete(ws);
|
|
1359
|
+
}
|
|
1360
|
+
function addHostSub(ws, hostInstanceId) {
|
|
1361
|
+
const s = ensureSubs(ws);
|
|
1362
|
+
if (!s.has('host'))
|
|
1363
|
+
s.add('host');
|
|
1364
|
+
wsLogTargets.host.add(ws);
|
|
1365
|
+
if (hostInstanceId) {
|
|
1366
|
+
wsHostIdSubs.set(ws, hostInstanceId);
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
wsHostIdSubs.delete(ws);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
function removeHostSub(ws) {
|
|
1373
|
+
const s = ensureSubs(ws);
|
|
1374
|
+
if (!s.has('host'))
|
|
1375
|
+
return;
|
|
1376
|
+
s.delete('host');
|
|
1377
|
+
wsLogTargets.host.delete(ws);
|
|
1378
|
+
wsHostIdSubs.delete(ws);
|
|
1379
|
+
}
|
|
1380
|
+
function clearSubs(ws) {
|
|
1381
|
+
const s = wsLogSubs.get(ws);
|
|
1382
|
+
if (!s)
|
|
1383
|
+
return;
|
|
1384
|
+
if (s.has('agent'))
|
|
1385
|
+
wsLogTargets.agent.delete(ws);
|
|
1386
|
+
if (s.has('host'))
|
|
1387
|
+
wsLogTargets.host.delete(ws);
|
|
1388
|
+
wsHostIdSubs.delete(ws);
|
|
1389
|
+
wsLogSubs.delete(ws);
|
|
1390
|
+
}
|
|
1391
|
+
subscribeLogs((source, entry) => {
|
|
1392
|
+
if (source === 'agent') {
|
|
1393
|
+
const targets = wsLogTargets.agent;
|
|
1394
|
+
if (targets.size === 0)
|
|
1395
|
+
return;
|
|
1396
|
+
const str = JSON.stringify({ type: 'agent.log', ...entry });
|
|
1397
|
+
for (const sock of targets) {
|
|
1398
|
+
if (sock.readyState !== 1) {
|
|
1399
|
+
clearSubs(sock);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
sock.send(str);
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
clearSubs(sock);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
// host: filter by per-WS hostInstanceId if set.
|
|
1412
|
+
const targets = wsLogTargets.host;
|
|
1413
|
+
if (targets.size === 0)
|
|
1414
|
+
return;
|
|
1415
|
+
const entryHostId = String(entry.hostInstanceId || '');
|
|
1416
|
+
const str = JSON.stringify({ type: 'host.log', ...entry });
|
|
1417
|
+
for (const sock of targets) {
|
|
1418
|
+
if (sock.readyState !== 1) {
|
|
1419
|
+
clearSubs(sock);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
const wantId = wsHostIdSubs.get(sock);
|
|
1423
|
+
if (wantId && wantId !== entryHostId)
|
|
1424
|
+
continue;
|
|
1425
|
+
try {
|
|
1426
|
+
sock.send(str);
|
|
1427
|
+
}
|
|
1428
|
+
catch {
|
|
1429
|
+
clearSubs(sock);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
function logInfo(event, data) {
|
|
1434
|
+
// 纯文件日志,不再进行WebSocket广播
|
|
1435
|
+
if (logger) {
|
|
1436
|
+
logger.write(LogLevel.INFO, event, data);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function logError(event, data) {
|
|
1440
|
+
// 纯文件日志,不再进行WebSocket广播
|
|
1441
|
+
if (logger) {
|
|
1442
|
+
logger.write(LogLevel.ERROR, event, data);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
function sendStatus(ws) {
|
|
1446
|
+
try {
|
|
1447
|
+
const platformInfo = getPlatformInfo();
|
|
1448
|
+
ws.send(JSON.stringify({
|
|
1449
|
+
type: 'agent.status',
|
|
1450
|
+
health: 'ok',
|
|
1451
|
+
agentId: cfg.agentId,
|
|
1452
|
+
version: cfg.version,
|
|
1453
|
+
startupTime: cfg.startupTime,
|
|
1454
|
+
originHost: cfg.originHost,
|
|
1455
|
+
originUser: cfg.originUser,
|
|
1456
|
+
pairCode: cfg.pairCode ? `${cfg.pairCode.slice(0, 4)}****` : '',
|
|
1457
|
+
platform: platformInfo.platform,
|
|
1458
|
+
arch: platformInfo.arch,
|
|
1459
|
+
ts: Date.now(),
|
|
1460
|
+
}));
|
|
1461
|
+
}
|
|
1462
|
+
catch { }
|
|
1463
|
+
}
|
|
1464
|
+
wss.on('connection', (ws) => {
|
|
1465
|
+
const boardUartAttachIds = new Set();
|
|
1466
|
+
// default: no log subscriptions until client asks
|
|
1467
|
+
ensureSubs(ws);
|
|
1468
|
+
ws.on('close', () => {
|
|
1469
|
+
clearSubs(ws);
|
|
1470
|
+
});
|
|
1471
|
+
sendStatus(ws);
|
|
1472
|
+
ws.send(JSON.stringify({ type: 'hello', now: Date.now() }));
|
|
1473
|
+
ws.on('message', async (raw) => {
|
|
1474
|
+
let msg;
|
|
1475
|
+
try {
|
|
1476
|
+
msg = JSON.parse(raw.toString('utf8'));
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (!msg || typeof msg !== 'object')
|
|
1482
|
+
return;
|
|
1483
|
+
const mgr = getBoardUartResourceManager();
|
|
1484
|
+
const DEFAULT_EXPECT_TIMEOUT_MS = 5000;
|
|
1485
|
+
const sendMacroError = (err) => {
|
|
1486
|
+
try {
|
|
1487
|
+
ws.send(JSON.stringify({
|
|
1488
|
+
type: 'macro.error',
|
|
1489
|
+
error: err?.message || String(err),
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
catch { }
|
|
1493
|
+
};
|
|
1494
|
+
const broadcastScripts = async () => {
|
|
1495
|
+
const scripts = await listMacroScripts();
|
|
1496
|
+
broadcast({ type: 'macro.scripts', scripts });
|
|
1497
|
+
};
|
|
1498
|
+
if (msg.type === 'macro.list') {
|
|
1499
|
+
try {
|
|
1500
|
+
const scripts = await listMacroScripts();
|
|
1501
|
+
ws.send(JSON.stringify({ type: 'macro.scripts', scripts, running: getCurrentRun() }));
|
|
1502
|
+
}
|
|
1503
|
+
catch (e) {
|
|
1504
|
+
sendMacroError(e);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
else if (msg.type === 'macro.get') {
|
|
1508
|
+
try {
|
|
1509
|
+
const id = String(msg.id || '').trim();
|
|
1510
|
+
if (!id)
|
|
1511
|
+
throw new Error('missing id');
|
|
1512
|
+
const script = await getMacroScript(id);
|
|
1513
|
+
if (!script)
|
|
1514
|
+
throw new Error('script not found');
|
|
1515
|
+
ws.send(JSON.stringify({ type: 'macro.script', script }));
|
|
1516
|
+
}
|
|
1517
|
+
catch (e) {
|
|
1518
|
+
sendMacroError(e);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
else if (msg.type === 'macro.upsert') {
|
|
1522
|
+
try {
|
|
1523
|
+
const input = msg.script || msg;
|
|
1524
|
+
const script = await upsertMacroScript({
|
|
1525
|
+
id: input.id,
|
|
1526
|
+
name: input.name,
|
|
1527
|
+
order: input.order,
|
|
1528
|
+
steps: input.steps,
|
|
1529
|
+
});
|
|
1530
|
+
ws.send(JSON.stringify({ type: 'macro.upserted', script }));
|
|
1531
|
+
await broadcastScripts();
|
|
1532
|
+
}
|
|
1533
|
+
catch (e) {
|
|
1534
|
+
sendMacroError(e);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else if (msg.type === 'macro.delete') {
|
|
1538
|
+
try {
|
|
1539
|
+
const id = String(msg.id || '').trim();
|
|
1540
|
+
if (!id)
|
|
1541
|
+
throw new Error('missing id');
|
|
1542
|
+
const ok = await deleteMacroScript(id);
|
|
1543
|
+
ws.send(JSON.stringify({ type: 'macro.deleted', id, ok }));
|
|
1544
|
+
await broadcastScripts();
|
|
1545
|
+
}
|
|
1546
|
+
catch (e) {
|
|
1547
|
+
sendMacroError(e);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
else if (msg.type === 'macro.run') {
|
|
1551
|
+
try {
|
|
1552
|
+
const cur = getCurrentRun();
|
|
1553
|
+
if (cur && cur.status === 'running') {
|
|
1554
|
+
ws.send(JSON.stringify({ type: 'macro.run_ack', status: 'busy', running: cur }));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const scriptId = String(msg.scriptId || '').trim();
|
|
1558
|
+
if (!scriptId)
|
|
1559
|
+
throw new Error('missing scriptId');
|
|
1560
|
+
const script = await getMacroScript(scriptId);
|
|
1561
|
+
if (!script)
|
|
1562
|
+
throw new Error('script not found');
|
|
1563
|
+
const runId = randomUUID();
|
|
1564
|
+
const sessionId = msg.sessionId ? String(msg.sessionId) : undefined;
|
|
1565
|
+
ws.send(JSON.stringify({ type: 'macro.run_ack', status: 'running', runId, scriptId }));
|
|
1566
|
+
void runMacroScript({
|
|
1567
|
+
runId,
|
|
1568
|
+
script,
|
|
1569
|
+
sessionId,
|
|
1570
|
+
defaultExpectTimeoutMs: DEFAULT_EXPECT_TIMEOUT_MS,
|
|
1571
|
+
onUpdate: (u) => broadcast({ type: 'macro.run_update', ...u }),
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
catch (e) {
|
|
1575
|
+
sendMacroError(e);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
else if (msg.type === 'macro.stop') {
|
|
1579
|
+
try {
|
|
1580
|
+
const ok = stopCurrentRun();
|
|
1581
|
+
ws.send(JSON.stringify({ type: 'macro.stopped', ok }));
|
|
1582
|
+
const cur = getCurrentRun();
|
|
1583
|
+
if (cur)
|
|
1584
|
+
broadcast({ type: 'macro.run_update', ...cur, message: 'stop requested' });
|
|
1585
|
+
}
|
|
1586
|
+
catch (e) {
|
|
1587
|
+
sendMacroError(e);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
else if (msg.type === 'macro.status') {
|
|
1591
|
+
try {
|
|
1592
|
+
ws.send(JSON.stringify({ type: 'macro.status', running: getCurrentRun() }));
|
|
1593
|
+
}
|
|
1594
|
+
catch (e) {
|
|
1595
|
+
sendMacroError(e);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
else if (msg.type === 'board_uart.status_query') {
|
|
1599
|
+
try {
|
|
1600
|
+
const st = await mgr.getStatus();
|
|
1601
|
+
ws.send(JSON.stringify({
|
|
1602
|
+
type: 'board_uart.status',
|
|
1603
|
+
status: st.status,
|
|
1604
|
+
disabled: st.disabled,
|
|
1605
|
+
hasModule: st.hasModule,
|
|
1606
|
+
port: st.port,
|
|
1607
|
+
baud: st.baud,
|
|
1608
|
+
sessions: st.sessions,
|
|
1609
|
+
attachedTools: (st.attachedTools || []).map((t) => ({
|
|
1610
|
+
...t,
|
|
1611
|
+
source: t.source || '',
|
|
1612
|
+
attachedAtMs: t.attachedAt,
|
|
1613
|
+
})),
|
|
1614
|
+
suspended: st.suspended,
|
|
1615
|
+
suspendOwner: st.suspendOwner,
|
|
1616
|
+
suspendedSinceMs: st.suspendedSinceMs,
|
|
1617
|
+
maxSuspendMs: st.maxSuspendMs,
|
|
1618
|
+
autoResumed: st.autoResumed,
|
|
1619
|
+
}));
|
|
1620
|
+
}
|
|
1621
|
+
catch (e) {
|
|
1622
|
+
try {
|
|
1623
|
+
ws.send(JSON.stringify({
|
|
1624
|
+
type: 'board_uart.error',
|
|
1625
|
+
error: e?.message || String(e),
|
|
1626
|
+
code: e?.code,
|
|
1627
|
+
}));
|
|
1628
|
+
}
|
|
1629
|
+
catch { }
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
else if (msg.type === 'logs.subscribe') {
|
|
1633
|
+
const src = String(msg.source || '');
|
|
1634
|
+
const hostInstanceId = typeof msg.hostInstanceId === 'string' ? msg.hostInstanceId.trim() : '';
|
|
1635
|
+
if (src === 'agent') {
|
|
1636
|
+
addAgentSub(ws);
|
|
1637
|
+
}
|
|
1638
|
+
else if (src === 'host') {
|
|
1639
|
+
addHostSub(ws, hostInstanceId || undefined);
|
|
1640
|
+
}
|
|
1641
|
+
else if (src === 'all') {
|
|
1642
|
+
addAgentSub(ws);
|
|
1643
|
+
addHostSub(ws);
|
|
1644
|
+
}
|
|
1645
|
+
try {
|
|
1646
|
+
ws.send(JSON.stringify({
|
|
1647
|
+
type: 'logs.subscribed',
|
|
1648
|
+
source: src || 'all',
|
|
1649
|
+
hostInstanceId: hostInstanceId || '',
|
|
1650
|
+
}));
|
|
1651
|
+
}
|
|
1652
|
+
catch { }
|
|
1653
|
+
}
|
|
1654
|
+
else if (msg.type === 'logs.unsubscribe') {
|
|
1655
|
+
const src = String(msg.source || '');
|
|
1656
|
+
if (src === 'agent') {
|
|
1657
|
+
removeAgentSub(ws);
|
|
1658
|
+
}
|
|
1659
|
+
else if (src === 'host') {
|
|
1660
|
+
removeHostSub(ws);
|
|
1661
|
+
}
|
|
1662
|
+
else if (src === 'all') {
|
|
1663
|
+
clearSubs(ws);
|
|
1664
|
+
ensureSubs(ws);
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
ws.send(JSON.stringify({ type: 'logs.unsubscribed', source: src || 'all' }));
|
|
1668
|
+
}
|
|
1669
|
+
catch { }
|
|
1670
|
+
}
|
|
1671
|
+
else if (msg.type === 'logs.snapshot_query') {
|
|
1672
|
+
const src = String(msg.source || 'agent');
|
|
1673
|
+
const source = src === 'host' ? 'host' : 'agent';
|
|
1674
|
+
const hostInstanceId = typeof msg.hostInstanceId === 'string' ? msg.hostInstanceId.trim() : '';
|
|
1675
|
+
try {
|
|
1676
|
+
let entries = getLogSnapshot(source);
|
|
1677
|
+
if (source === 'host' && hostInstanceId) {
|
|
1678
|
+
entries = entries.filter((e) => String(e?.hostInstanceId || '') === hostInstanceId);
|
|
1679
|
+
}
|
|
1680
|
+
ws.send(JSON.stringify({ type: 'logs.snapshot', source, hostInstanceId, entries }));
|
|
1681
|
+
}
|
|
1682
|
+
catch { }
|
|
1683
|
+
}
|
|
1684
|
+
else if (msg.type === 'board_uart.tools_query') {
|
|
1685
|
+
try {
|
|
1686
|
+
const st = await mgr.getStatus();
|
|
1687
|
+
ws.send(JSON.stringify({
|
|
1688
|
+
type: 'board_uart.tools',
|
|
1689
|
+
tools: st.attachedTools,
|
|
1690
|
+
}));
|
|
1691
|
+
}
|
|
1692
|
+
catch { }
|
|
1693
|
+
}
|
|
1694
|
+
else if (msg.type === 'board_uart.start_recording') {
|
|
1695
|
+
const { sessionId } = msg;
|
|
1696
|
+
if (sessionId) {
|
|
1697
|
+
const root = cfg.agentConfig?.files?.rootDir || cfg.agentConfig?.tftp?.dir || './files';
|
|
1698
|
+
const logDir = path.join(root, 'logs');
|
|
1699
|
+
try {
|
|
1700
|
+
const p = await startRecording(sessionId, logDir);
|
|
1701
|
+
ws.send(JSON.stringify({ type: 'board_uart.recording_started', sessionId, path: p }));
|
|
1702
|
+
}
|
|
1703
|
+
catch (e) {
|
|
1704
|
+
ws.send(JSON.stringify({ type: 'board_uart.error', error: e?.message || String(e) }));
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
else if (msg.type === 'board_uart.stop_recording') {
|
|
1709
|
+
const { sessionId } = msg;
|
|
1710
|
+
if (sessionId) {
|
|
1711
|
+
try {
|
|
1712
|
+
await stopRecording(sessionId);
|
|
1713
|
+
ws.send(JSON.stringify({ type: 'board_uart.recording_stopped', sessionId }));
|
|
1714
|
+
}
|
|
1715
|
+
catch (e) {
|
|
1716
|
+
ws.send(JSON.stringify({ type: 'board_uart.error', error: e?.message || String(e) }));
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
else if (msg.type === 'board_uart.force_close') {
|
|
1721
|
+
try {
|
|
1722
|
+
await mgr.setHardwareDisabled(true);
|
|
1723
|
+
logInfo('board_uart.hardware_disabled', { ts: Date.now() });
|
|
1724
|
+
ws.send(JSON.stringify({ type: 'board_uart.force_closed' }));
|
|
1725
|
+
const st = await mgr.getStatus();
|
|
1726
|
+
const payload = JSON.stringify({
|
|
1727
|
+
type: 'board_uart.status',
|
|
1728
|
+
status: st.status,
|
|
1729
|
+
disabled: st.disabled,
|
|
1730
|
+
hasModule: st.hasModule,
|
|
1731
|
+
port: st.port,
|
|
1732
|
+
baud: st.baud,
|
|
1733
|
+
sessions: st.sessions,
|
|
1734
|
+
attachedTools: (st.attachedTools || []).map((t) => ({
|
|
1735
|
+
...t,
|
|
1736
|
+
source: t.source || '',
|
|
1737
|
+
attachedAtMs: t.attachedAt,
|
|
1738
|
+
})),
|
|
1739
|
+
suspended: st.suspended,
|
|
1740
|
+
suspendOwner: st.suspendOwner,
|
|
1741
|
+
suspendedSinceMs: st.suspendedSinceMs,
|
|
1742
|
+
maxSuspendMs: st.maxSuspendMs,
|
|
1743
|
+
autoResumed: st.autoResumed,
|
|
1744
|
+
});
|
|
1745
|
+
wss.clients.forEach((c) => c.readyState === 1 && c.send(payload));
|
|
1746
|
+
}
|
|
1747
|
+
catch (e) {
|
|
1748
|
+
try {
|
|
1749
|
+
ws.send(JSON.stringify({
|
|
1750
|
+
type: 'board_uart.error',
|
|
1751
|
+
error: e?.message || String(e),
|
|
1752
|
+
code: e?.code,
|
|
1753
|
+
}));
|
|
1754
|
+
}
|
|
1755
|
+
catch { }
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
else if (msg.type === 'board_uart.open_manual') {
|
|
1759
|
+
try {
|
|
1760
|
+
const port = String(msg.port || '');
|
|
1761
|
+
const baud = Number(msg.baud || 0);
|
|
1762
|
+
if (!port || !baud) {
|
|
1763
|
+
ws.send(JSON.stringify({
|
|
1764
|
+
type: 'board_uart.error',
|
|
1765
|
+
error: 'missing port/baud',
|
|
1766
|
+
code: ErrorCodes.EL_INVALID_PARAMS,
|
|
1767
|
+
}));
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
await mgr.setHardwareDisabled(false);
|
|
1771
|
+
const session = await openManualSession({ port, baud });
|
|
1772
|
+
logInfo('board_uart.hardware_enabled', {
|
|
1773
|
+
ts: Date.now(),
|
|
1774
|
+
port,
|
|
1775
|
+
baud,
|
|
1776
|
+
sessionId: session.id,
|
|
1777
|
+
});
|
|
1778
|
+
ws.send(JSON.stringify({ type: 'board_uart.opened_manual', session }));
|
|
1779
|
+
const st = await mgr.getStatus();
|
|
1780
|
+
const payload = JSON.stringify({
|
|
1781
|
+
type: 'board_uart.status',
|
|
1782
|
+
status: st.status,
|
|
1783
|
+
disabled: st.disabled,
|
|
1784
|
+
hasModule: st.hasModule,
|
|
1785
|
+
port: st.port,
|
|
1786
|
+
baud: st.baud,
|
|
1787
|
+
sessions: st.sessions,
|
|
1788
|
+
attachedTools: (st.attachedTools || []).map((t) => ({
|
|
1789
|
+
...t,
|
|
1790
|
+
source: t.source || '',
|
|
1791
|
+
attachedAtMs: t.attachedAt,
|
|
1792
|
+
})),
|
|
1793
|
+
suspended: st.suspended,
|
|
1794
|
+
suspendOwner: st.suspendOwner,
|
|
1795
|
+
suspendedSinceMs: st.suspendedSinceMs,
|
|
1796
|
+
maxSuspendMs: st.maxSuspendMs,
|
|
1797
|
+
autoResumed: st.autoResumed,
|
|
1798
|
+
});
|
|
1799
|
+
wss.clients.forEach((c) => c.readyState === 1 && c.send(payload));
|
|
1800
|
+
}
|
|
1801
|
+
catch (e) {
|
|
1802
|
+
try {
|
|
1803
|
+
ws.send(JSON.stringify({
|
|
1804
|
+
type: 'board_uart.error',
|
|
1805
|
+
error: e?.message || String(e),
|
|
1806
|
+
code: e?.code,
|
|
1807
|
+
}));
|
|
1808
|
+
}
|
|
1809
|
+
catch { }
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
else if (msg.type === 'board_uart.listen') {
|
|
1813
|
+
const sessionId = msg.sessionId ? String(msg.sessionId) : undefined;
|
|
1814
|
+
const port = msg.port ? String(msg.port) : undefined;
|
|
1815
|
+
const baud = msg.baud ? Number(msg.baud) : undefined;
|
|
1816
|
+
const clientSessionId = msg.clientSessionId ? String(msg.clientSessionId) : undefined;
|
|
1817
|
+
const historyLinesRaw = msg.historyLines;
|
|
1818
|
+
const historyLines = typeof historyLinesRaw === 'number' && Number.isFinite(historyLinesRaw)
|
|
1819
|
+
? Math.floor(historyLinesRaw)
|
|
1820
|
+
: 0;
|
|
1821
|
+
let meta = null;
|
|
1822
|
+
let seq = 0;
|
|
1823
|
+
let replayingHistory = false;
|
|
1824
|
+
const bufferedLive = [];
|
|
1825
|
+
let bufferedBytes = 0;
|
|
1826
|
+
const MAX_BUFFERED_LIVE_BYTES = 1024 * 1024;
|
|
1827
|
+
const sendLiveChunk = (chunk, receivedAtMs) => {
|
|
1828
|
+
if (!meta)
|
|
1829
|
+
return;
|
|
1830
|
+
try {
|
|
1831
|
+
ws.send(JSON.stringify({
|
|
1832
|
+
type: 'board_uart.data',
|
|
1833
|
+
port: meta.port,
|
|
1834
|
+
baud: meta.baud,
|
|
1835
|
+
id: meta.id,
|
|
1836
|
+
sessionId: meta.sessionId,
|
|
1837
|
+
clientSessionId: meta.clientSessionId,
|
|
1838
|
+
receivedAtMs,
|
|
1839
|
+
seq: seq++,
|
|
1840
|
+
dataBase64: chunk.toString('base64'),
|
|
1841
|
+
}));
|
|
1842
|
+
}
|
|
1843
|
+
catch { }
|
|
1844
|
+
};
|
|
1845
|
+
const listener = {
|
|
1846
|
+
source: msg.source || 'web-monitor',
|
|
1847
|
+
onData(chunk) {
|
|
1848
|
+
if (!meta)
|
|
1849
|
+
return;
|
|
1850
|
+
const receivedAtMs = Date.now();
|
|
1851
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1852
|
+
if (replayingHistory) {
|
|
1853
|
+
bufferedLive.push({ chunk: buf, receivedAtMs });
|
|
1854
|
+
bufferedBytes += buf.length;
|
|
1855
|
+
while (bufferedBytes > MAX_BUFFERED_LIVE_BYTES && bufferedLive.length > 0) {
|
|
1856
|
+
const old = bufferedLive.shift();
|
|
1857
|
+
if (!old)
|
|
1858
|
+
break;
|
|
1859
|
+
bufferedBytes -= old.chunk.length;
|
|
1860
|
+
}
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
sendLiveChunk(buf, receivedAtMs);
|
|
1864
|
+
},
|
|
1865
|
+
onError(e) {
|
|
1866
|
+
if (!meta)
|
|
1867
|
+
return;
|
|
1868
|
+
try {
|
|
1869
|
+
ws.send(JSON.stringify({
|
|
1870
|
+
type: 'board_uart.error',
|
|
1871
|
+
port: meta.port,
|
|
1872
|
+
baud: meta.baud,
|
|
1873
|
+
id: meta.id,
|
|
1874
|
+
sessionId: meta.sessionId,
|
|
1875
|
+
clientSessionId: meta.clientSessionId,
|
|
1876
|
+
error: e?.message || String(e),
|
|
1877
|
+
code: e?.code,
|
|
1878
|
+
}));
|
|
1879
|
+
}
|
|
1880
|
+
catch { }
|
|
1881
|
+
},
|
|
1882
|
+
};
|
|
1883
|
+
try {
|
|
1884
|
+
if (historyLines > 0) {
|
|
1885
|
+
const targetSessionId = sessionId || (port && baud ? buildSessionId(String(port), Number(baud)) : undefined);
|
|
1886
|
+
if (targetSessionId) {
|
|
1887
|
+
setBoardUartHistoryMaxLines(targetSessionId, historyLines);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
const attached = await mgr.attachResource({
|
|
1891
|
+
listener,
|
|
1892
|
+
sessionId,
|
|
1893
|
+
port,
|
|
1894
|
+
baud,
|
|
1895
|
+
source: msg.source || 'web-monitor',
|
|
1896
|
+
});
|
|
1897
|
+
meta = {
|
|
1898
|
+
id: attached.id,
|
|
1899
|
+
sessionId: attached.sessionId,
|
|
1900
|
+
port: attached.port,
|
|
1901
|
+
baud: attached.baud,
|
|
1902
|
+
clientSessionId,
|
|
1903
|
+
};
|
|
1904
|
+
boardUartAttachIds.add(attached.id);
|
|
1905
|
+
replayingHistory = historyLines > 0;
|
|
1906
|
+
ws.send(JSON.stringify({
|
|
1907
|
+
type: 'board_uart.listening',
|
|
1908
|
+
id: attached.id,
|
|
1909
|
+
attachId: attached.id,
|
|
1910
|
+
sessionId: attached.sessionId,
|
|
1911
|
+
port: attached.port,
|
|
1912
|
+
baud: attached.baud,
|
|
1913
|
+
clientSessionId,
|
|
1914
|
+
}));
|
|
1915
|
+
if (historyLines > 0) {
|
|
1916
|
+
const HISTORY_CHUNK_LINES = 200;
|
|
1917
|
+
const lines = getBoardUartHistoryLines(attached.sessionId, historyLines);
|
|
1918
|
+
for (let i = 0; i < lines.length; i += HISTORY_CHUNK_LINES) {
|
|
1919
|
+
const items = lines.slice(i, i + HISTORY_CHUNK_LINES).map((it) => ({
|
|
1920
|
+
receivedAtMs: it.receivedAtMs,
|
|
1921
|
+
dataBase64: Buffer.from(it.text + '\n', 'utf8').toString('base64'),
|
|
1922
|
+
}));
|
|
1923
|
+
try {
|
|
1924
|
+
ws.send(JSON.stringify({
|
|
1925
|
+
type: 'board_uart.history',
|
|
1926
|
+
id: attached.id,
|
|
1927
|
+
attachId: attached.id,
|
|
1928
|
+
sessionId: attached.sessionId,
|
|
1929
|
+
clientSessionId,
|
|
1930
|
+
items,
|
|
1931
|
+
}));
|
|
1932
|
+
}
|
|
1933
|
+
catch { }
|
|
1934
|
+
}
|
|
1935
|
+
try {
|
|
1936
|
+
ws.send(JSON.stringify({
|
|
1937
|
+
type: 'board_uart.history_end',
|
|
1938
|
+
id: attached.id,
|
|
1939
|
+
attachId: attached.id,
|
|
1940
|
+
sessionId: attached.sessionId,
|
|
1941
|
+
clientSessionId,
|
|
1942
|
+
}));
|
|
1943
|
+
}
|
|
1944
|
+
catch { }
|
|
1945
|
+
while (bufferedLive.length > 0) {
|
|
1946
|
+
const item = bufferedLive.shift();
|
|
1947
|
+
if (!item)
|
|
1948
|
+
break;
|
|
1949
|
+
bufferedBytes -= item.chunk.length;
|
|
1950
|
+
sendLiveChunk(item.chunk, item.receivedAtMs);
|
|
1951
|
+
}
|
|
1952
|
+
replayingHistory = false;
|
|
1953
|
+
}
|
|
1954
|
+
logInfo('board_uart.listening', {
|
|
1955
|
+
port: attached.port,
|
|
1956
|
+
baud: attached.baud,
|
|
1957
|
+
id: attached.id,
|
|
1958
|
+
sessionId: attached.sessionId,
|
|
1959
|
+
clientSessionId,
|
|
1960
|
+
historyLines,
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
catch (e) {
|
|
1964
|
+
try {
|
|
1965
|
+
ws.send(JSON.stringify({
|
|
1966
|
+
type: 'board_uart.error',
|
|
1967
|
+
port,
|
|
1968
|
+
baud,
|
|
1969
|
+
clientSessionId,
|
|
1970
|
+
error: e?.message || String(e),
|
|
1971
|
+
code: e?.code,
|
|
1972
|
+
}));
|
|
1973
|
+
}
|
|
1974
|
+
catch { }
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
else if (msg.type === 'board_uart.send') {
|
|
1978
|
+
const dataBase64 = String(msg.dataBase64 || '');
|
|
1979
|
+
if (!dataBase64) {
|
|
1980
|
+
try {
|
|
1981
|
+
ws.send(JSON.stringify({
|
|
1982
|
+
type: 'board_uart.error',
|
|
1983
|
+
error: 'missing dataBase64',
|
|
1984
|
+
code: 'EL_INVALID_PARAMS',
|
|
1985
|
+
}));
|
|
1986
|
+
}
|
|
1987
|
+
catch { }
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
let data;
|
|
1991
|
+
try {
|
|
1992
|
+
data = Buffer.from(dataBase64, 'base64');
|
|
1993
|
+
}
|
|
1994
|
+
catch {
|
|
1995
|
+
try {
|
|
1996
|
+
ws.send(JSON.stringify({
|
|
1997
|
+
type: 'board_uart.error',
|
|
1998
|
+
error: 'invalid dataBase64',
|
|
1999
|
+
code: 'EL_INVALID_PARAMS',
|
|
2000
|
+
}));
|
|
2001
|
+
}
|
|
2002
|
+
catch { }
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const sessionId = msg.sessionId ? String(msg.sessionId) : undefined;
|
|
2006
|
+
try {
|
|
2007
|
+
await mgr.write({ data, sessionId });
|
|
2008
|
+
ws.send(JSON.stringify({
|
|
2009
|
+
type: 'board_uart.sent',
|
|
2010
|
+
sessionId: sessionId || null,
|
|
2011
|
+
bytes: data.byteLength,
|
|
2012
|
+
}));
|
|
2013
|
+
}
|
|
2014
|
+
catch (e) {
|
|
2015
|
+
try {
|
|
2016
|
+
ws.send(JSON.stringify({
|
|
2017
|
+
type: 'board_uart.error',
|
|
2018
|
+
sessionId: sessionId || null,
|
|
2019
|
+
error: e?.message || String(e),
|
|
2020
|
+
code: e?.code,
|
|
2021
|
+
}));
|
|
2022
|
+
}
|
|
2023
|
+
catch { }
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
else if (msg.type === 'board_uart.stop') {
|
|
2027
|
+
const id = String(msg.id || msg.attachId || '');
|
|
2028
|
+
if (!id)
|
|
2029
|
+
return;
|
|
2030
|
+
try {
|
|
2031
|
+
mgr.detachResource(id);
|
|
2032
|
+
}
|
|
2033
|
+
catch { }
|
|
2034
|
+
boardUartAttachIds.delete(id);
|
|
2035
|
+
try {
|
|
2036
|
+
ws.send(JSON.stringify({ type: 'board_uart.stopped', id }));
|
|
2037
|
+
}
|
|
2038
|
+
catch { }
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
ws.on('close', () => {
|
|
2042
|
+
const mgr = getBoardUartResourceManager();
|
|
2043
|
+
for (const id of boardUartAttachIds.values()) {
|
|
2044
|
+
try {
|
|
2045
|
+
mgr.detachResource(id);
|
|
2046
|
+
}
|
|
2047
|
+
catch { }
|
|
2048
|
+
}
|
|
2049
|
+
boardUartAttachIds.clear();
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
// broadcast BoardUart status periodically + status heartbeat
|
|
2053
|
+
setInterval(async () => {
|
|
2054
|
+
try {
|
|
2055
|
+
const mgr = getBoardUartResourceManager();
|
|
2056
|
+
const st = await mgr.getStatus();
|
|
2057
|
+
const msg = JSON.stringify({
|
|
2058
|
+
type: 'board_uart.status',
|
|
2059
|
+
status: st.status,
|
|
2060
|
+
disabled: st.disabled,
|
|
2061
|
+
hasModule: st.hasModule,
|
|
2062
|
+
port: st.port,
|
|
2063
|
+
baud: st.baud,
|
|
2064
|
+
sessions: st.sessions,
|
|
2065
|
+
attachedTools: (st.attachedTools || []).map((t) => ({
|
|
2066
|
+
...t,
|
|
2067
|
+
source: t.source || '',
|
|
2068
|
+
attachedAtMs: t.attachedAt,
|
|
2069
|
+
})),
|
|
2070
|
+
suspended: st.suspended,
|
|
2071
|
+
suspendOwner: st.suspendOwner,
|
|
2072
|
+
suspendedSinceMs: st.suspendedSinceMs,
|
|
2073
|
+
maxSuspendMs: st.maxSuspendMs,
|
|
2074
|
+
autoResumed: st.autoResumed,
|
|
2075
|
+
});
|
|
2076
|
+
wss.clients.forEach((c) => c.readyState === 1 && c.send(msg));
|
|
2077
|
+
}
|
|
2078
|
+
catch { }
|
|
2079
|
+
const platformInfo = getPlatformInfo();
|
|
2080
|
+
broadcast({
|
|
2081
|
+
type: 'agent.status',
|
|
2082
|
+
health: 'ok',
|
|
2083
|
+
ts: Date.now(),
|
|
2084
|
+
agentId: cfg.agentId,
|
|
2085
|
+
platform: platformInfo.platform,
|
|
2086
|
+
arch: platformInfo.arch,
|
|
2087
|
+
});
|
|
2088
|
+
}, 3000);
|
|
2089
|
+
await new Promise((resolve) => server.listen(cfg.port, cfg.host || '127.0.0.1', resolve));
|
|
2090
|
+
// 记录服务器启���成功
|
|
2091
|
+
logger.write(LogLevel.INFO, 'server.started', {
|
|
2092
|
+
agentId: cfg.agentId,
|
|
2093
|
+
port: cfg.port,
|
|
2094
|
+
host: cfg.host || '127.0.0.1',
|
|
2095
|
+
grpcPort: cfg.grpcPort,
|
|
2096
|
+
pid: process.pid,
|
|
2097
|
+
});
|
|
2098
|
+
// 应用关闭时的最终清理
|
|
2099
|
+
const gracefulShutdown = async () => {
|
|
2100
|
+
logInfo('server.graceful_shutdown_start');
|
|
2101
|
+
try {
|
|
2102
|
+
// 清理所有隧道
|
|
2103
|
+
await cleanupTunnel('application_shutdown');
|
|
2104
|
+
// 关闭WebSocket服务器
|
|
2105
|
+
wss.close();
|
|
2106
|
+
// 关闭HTTP服务器
|
|
2107
|
+
server.close();
|
|
2108
|
+
logInfo('server.graceful_shutdown_complete');
|
|
2109
|
+
}
|
|
2110
|
+
catch (e) {
|
|
2111
|
+
logError('server.graceful_shutdown_failed', {
|
|
2112
|
+
error: e?.message || String(e),
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
// 监听进程退出信号
|
|
2117
|
+
process.on('SIGINT', gracefulShutdown);
|
|
2118
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
2119
|
+
process.on('beforeExit', gracefulShutdown);
|
|
2120
|
+
return { app, server, wss };
|
|
2121
|
+
}
|
|
2122
|
+
const EMBED_LINK_HEAD = '=========EMBED_LINK=========';
|
|
2123
|
+
export function execRemoteRaw(client, cmd) {
|
|
2124
|
+
return new Promise((resolve, reject) => {
|
|
2125
|
+
// 首先发送HEAD命令来吸收横幅并提供分隔标识
|
|
2126
|
+
const headCmd = `echo '${EMBED_LINK_HEAD}'`;
|
|
2127
|
+
const fullCmd = `${headCmd} && ${cmd}`;
|
|
2128
|
+
client.exec(fullCmd, (err, stream) => {
|
|
2129
|
+
if (err)
|
|
2130
|
+
return reject(err);
|
|
2131
|
+
let out = '';
|
|
2132
|
+
let errout = '';
|
|
2133
|
+
stream.on('data', (d) => (out += d.toString()));
|
|
2134
|
+
stream.stderr.on('data', (d) => (errout += d.toString()));
|
|
2135
|
+
stream.on('close', (code) => {
|
|
2136
|
+
if (code === 0) {
|
|
2137
|
+
const output = out || errout || '';
|
|
2138
|
+
// 查找HEAD标记,提取HEAD之后的内容
|
|
2139
|
+
const headIndex = output.indexOf(EMBED_LINK_HEAD);
|
|
2140
|
+
if (headIndex !== -1) {
|
|
2141
|
+
const cleanOutput = output.substring(headIndex + EMBED_LINK_HEAD.length).trim();
|
|
2142
|
+
resolve(cleanOutput);
|
|
2143
|
+
}
|
|
2144
|
+
else {
|
|
2145
|
+
// 如果没有找到HEAD,可能是HEAD命令本身就失败了,返回原始输出
|
|
2146
|
+
resolve(output);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
else {
|
|
2150
|
+
reject(new Error(errout || out));
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
function friendlyError(e) {
|
|
2157
|
+
const msg = (e?.message || String(e || '')).toString();
|
|
2158
|
+
const lower = msg.toLowerCase();
|
|
2159
|
+
if (lower.includes('permission denied') ||
|
|
2160
|
+
lower.includes('authentication') ||
|
|
2161
|
+
lower.includes('auth')) {
|
|
2162
|
+
return {
|
|
2163
|
+
code: 'EL_SSH_CONNECT_FAILED',
|
|
2164
|
+
message: msg,
|
|
2165
|
+
suggestions: ['检查用户名/密码/私钥是否正确', '确认 SSH 服务器允许该账号登录'],
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
if (lower.includes('cannot bind') ||
|
|
2169
|
+
lower.includes('address already in use') ||
|
|
2170
|
+
lower.includes('eaddrinuse')) {
|
|
2171
|
+
return {
|
|
2172
|
+
code: 'EL_SSH_TUNNEL_BROKEN',
|
|
2173
|
+
message: msg,
|
|
2174
|
+
suggestions: [
|
|
2175
|
+
'端口被占用,复用已存在端口或停止其他隧道',
|
|
2176
|
+
'确认 SSH 服务器允许远端端口转发 (AllowTcpForwarding)',
|
|
2177
|
+
],
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
if (lower.includes('sftp')) {
|
|
2181
|
+
return {
|
|
2182
|
+
code: 'EL_SSH_CONNECT_FAILED',
|
|
2183
|
+
message: 'SFTP 不可用(仅用于写入发现文件,可忽略)',
|
|
2184
|
+
suggestions: ['继续使用隧道功能', '检查服务器 SFTP 配置(可选)'],
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
code: 'EL_SSH_CONNECT_FAILED',
|
|
2189
|
+
message: msg,
|
|
2190
|
+
suggestions: ['检查网络连通性和凭据', '查看 Agent 日志获取更多细节'],
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
async function writeRemoteAgentJson(tunnel, info) {
|
|
2194
|
+
const client = tunnel.getClient();
|
|
2195
|
+
if (!client)
|
|
2196
|
+
return undefined;
|
|
2197
|
+
try {
|
|
2198
|
+
const adapter = new SSHAdapter(client);
|
|
2199
|
+
await adapter.init();
|
|
2200
|
+
const agentData = {
|
|
2201
|
+
schemaVersion: 1,
|
|
2202
|
+
agent: {
|
|
2203
|
+
id: info.agentId,
|
|
2204
|
+
version: info.version,
|
|
2205
|
+
pairCode: info.pairCode,
|
|
2206
|
+
startupTime: info.startupTime,
|
|
2207
|
+
originHost: info.originHost,
|
|
2208
|
+
originUser: info.originUser,
|
|
2209
|
+
hostGrpcPort: info.port,
|
|
2210
|
+
},
|
|
2211
|
+
endpoint: {
|
|
2212
|
+
grpc: {
|
|
2213
|
+
host: '127.0.0.1',
|
|
2214
|
+
port: info.port,
|
|
2215
|
+
scheme: 'grpc',
|
|
2216
|
+
},
|
|
2217
|
+
},
|
|
2218
|
+
transport: {
|
|
2219
|
+
mode: 'reverse-ssh',
|
|
2220
|
+
reverseSsh: {
|
|
2221
|
+
sshHost: info.sshHost,
|
|
2222
|
+
sshPort: info.sshPort,
|
|
2223
|
+
sshUser: info.user,
|
|
2224
|
+
originHost: info.originHost,
|
|
2225
|
+
forwardPort: info.port,
|
|
2226
|
+
},
|
|
2227
|
+
},
|
|
2228
|
+
};
|
|
2229
|
+
await adapter.writeAgentJson(agentData);
|
|
2230
|
+
return adapter.getAgentJsonPath();
|
|
2231
|
+
}
|
|
2232
|
+
catch (e) {
|
|
2233
|
+
console.warn('Failed to write agent.json:', e);
|
|
2234
|
+
return undefined;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
async function probeRemoteExistingTunnel(params) {
|
|
2238
|
+
const { host, sshPort, user, password } = params;
|
|
2239
|
+
const ssh2 = await import('ssh2');
|
|
2240
|
+
const client = new ssh2.Client();
|
|
2241
|
+
return await new Promise(async (resolve) => {
|
|
2242
|
+
const adapter = new SSHAdapter(client);
|
|
2243
|
+
const finish = (result) => {
|
|
2244
|
+
try {
|
|
2245
|
+
client.end();
|
|
2246
|
+
}
|
|
2247
|
+
catch { }
|
|
2248
|
+
resolve(result);
|
|
2249
|
+
};
|
|
2250
|
+
client
|
|
2251
|
+
.on('ready', async () => {
|
|
2252
|
+
try {
|
|
2253
|
+
// 一次初始化,全程使用
|
|
2254
|
+
await adapter.init();
|
|
2255
|
+
// 读取agent.json
|
|
2256
|
+
const agentData = await adapter.readAgentJson();
|
|
2257
|
+
if (!agentData) {
|
|
2258
|
+
const agentJsonPath = adapter.getAgentJsonPath();
|
|
2259
|
+
return finish({ active: false, path: agentJsonPath });
|
|
2260
|
+
}
|
|
2261
|
+
const port = Number(agentData?.agent?.hostGrpcPort || agentData?.endpoint?.grpc?.port);
|
|
2262
|
+
if (!port) {
|
|
2263
|
+
return finish({ active: false });
|
|
2264
|
+
}
|
|
2265
|
+
// 检查端口是否在监听
|
|
2266
|
+
const isListening = await adapter.isPortListening(port);
|
|
2267
|
+
if (isListening) {
|
|
2268
|
+
const agentJsonPath = adapter.getAgentJsonPath();
|
|
2269
|
+
return finish({ active: true, port, path: agentJsonPath });
|
|
2270
|
+
}
|
|
2271
|
+
return finish({ active: false });
|
|
2272
|
+
}
|
|
2273
|
+
catch (e) {
|
|
2274
|
+
console.warn('Failed to probe remote agent.json:', e);
|
|
2275
|
+
return finish({ active: false });
|
|
2276
|
+
}
|
|
2277
|
+
})
|
|
2278
|
+
.on('error', (err) => {
|
|
2279
|
+
console.warn('Failed to probe remote agent.json:', err?.message || String(err));
|
|
2280
|
+
return finish({ active: false });
|
|
2281
|
+
})
|
|
2282
|
+
.connect({ host, port: sshPort, username: user, password });
|
|
2283
|
+
});
|
|
2284
|
+
}
|