@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,1069 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Server, ServerCredentials, loadPackageDefinition } from '@grpc/grpc-js';
|
|
5
|
+
import { loadSync } from '@grpc/proto-loader';
|
|
6
|
+
import { downloadFromExternalTftp, downloadFromTftp, listTftpEntries, uploadToExternalTftp, uploadToTftp, } from '../tftp/index.js';
|
|
7
|
+
import { ubootBreak, ubootRunCommand } from '../uboot/index.js';
|
|
8
|
+
import { ErrorCodes, error, DefaultTimeouts } from '../core/errors.js';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { gatherSshCandidates } from '../ssh/candidates.js';
|
|
11
|
+
import { listBoardUartPorts } from '../board_uart/index.js';
|
|
12
|
+
import { SshTunnel } from '../ssh/tunnel.js';
|
|
13
|
+
import { readPasswordFromEnv, eraseSecret } from '../secrets/index.js';
|
|
14
|
+
import { nfsDownload, nfsList, nfsUpload } from '../nfs/index.js';
|
|
15
|
+
import { getNetworkInfo } from '../utils/network.js';
|
|
16
|
+
import { getPlatformInfo } from '../utils/platform.js';
|
|
17
|
+
import { loadBoardNotes } from '../board/notes.js';
|
|
18
|
+
import { readDoc } from '../board/docs.js';
|
|
19
|
+
import { closeSession, listBoardUartSessions, openManualSession, setDefaultBoardUartHint, } from '../board_uart/sessions.js';
|
|
20
|
+
import { getBoardUartResourceManager } from '../board_uart/resource.js';
|
|
21
|
+
import { firmwarePrepareImages } from '../firmware/prepare_images.js';
|
|
22
|
+
import { firmwareBurnRecover } from '../firmware/burn_recover.js';
|
|
23
|
+
import { getFirmwareUserGuideMarkdown } from '../firmware/user_guide.js';
|
|
24
|
+
import { FilesManager } from '../files/files_manager.js';
|
|
25
|
+
import { FileType } from '../files/types.js';
|
|
26
|
+
import { getAgentLogger, LogLevel } from '../logger.js';
|
|
27
|
+
import { markGrpcActivity } from '../core/activity.js';
|
|
28
|
+
import { publishHostLog } from '../core/log_stream.js';
|
|
29
|
+
const PROTO_FILE_TYPE_FILE = 1;
|
|
30
|
+
const PROTO_FILE_TYPE_DIRECTORY = 2;
|
|
31
|
+
const PROTO_FILE_TYPE_UNSPECIFIED = 0;
|
|
32
|
+
const DEFAULT_CHUNK_SIZE = 64 * 1024;
|
|
33
|
+
function getHostInstanceIdFromCall(call) {
|
|
34
|
+
try {
|
|
35
|
+
const md = call?.metadata;
|
|
36
|
+
const vals = md?.get?.('x-embedlink-host-instance-id');
|
|
37
|
+
const first = Array.isArray(vals) && vals.length > 0 ? vals[0] : undefined;
|
|
38
|
+
const raw = Buffer.isBuffer(first) ? first.toString('utf8') : String(first || '');
|
|
39
|
+
return raw.trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function normalizeProtoFileType(v) {
|
|
46
|
+
if (v === PROTO_FILE_TYPE_FILE || v === 'FILE_TYPE_FILE' || v === 'FILE') {
|
|
47
|
+
return PROTO_FILE_TYPE_FILE;
|
|
48
|
+
}
|
|
49
|
+
if (v === PROTO_FILE_TYPE_DIRECTORY || v === 'FILE_TYPE_DIRECTORY' || v === 'DIRECTORY') {
|
|
50
|
+
return PROTO_FILE_TYPE_DIRECTORY;
|
|
51
|
+
}
|
|
52
|
+
return PROTO_FILE_TYPE_UNSPECIFIED;
|
|
53
|
+
}
|
|
54
|
+
// 注意:移除了有害的端口清理功能
|
|
55
|
+
// Agent现在会自动随机选择可用端口,不会干扰其他应用程序
|
|
56
|
+
// 获取当前文件的目录路径(兼容ES模块)
|
|
57
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
58
|
+
const __dirname = path.dirname(__filename);
|
|
59
|
+
export async function startGrpcServer(cfg, identity) {
|
|
60
|
+
// 创建gRPC服务器,配置端口复用选项
|
|
61
|
+
const server = new Server({
|
|
62
|
+
// 启用端口复用,解决Agent异常退出后端口占用问题
|
|
63
|
+
'grpc.max_receive_message_length': -1, // 无限制消息大小
|
|
64
|
+
'grpc.max_send_message_length': -1, // 无限制消息大小
|
|
65
|
+
});
|
|
66
|
+
// 智能端口分配 - 如果默认端口被占用,尝试随机端口
|
|
67
|
+
const candidates = [
|
|
68
|
+
// 优先:构建输出目录中的 proto (dist/proto/agent.proto)
|
|
69
|
+
path.join(__dirname, '..', 'proto', 'agent.proto'),
|
|
70
|
+
// 尝试相对于当前文件的路径解析 (开发环境或旧模式)
|
|
71
|
+
path.join(__dirname, '..', '..', 'proto', 'agent.proto'),
|
|
72
|
+
path.join(process.cwd(), 'packages', 'proto', 'agent.proto'),
|
|
73
|
+
path.join(process.cwd(), 'proto', 'agent.proto'),
|
|
74
|
+
path.join(__dirname, '..', '..', '..', 'packages', 'proto', 'agent.proto'),
|
|
75
|
+
];
|
|
76
|
+
const protoPath = candidates.find((p) => fsSync.existsSync(p)) || candidates[0];
|
|
77
|
+
const def = loadSync(protoPath, {
|
|
78
|
+
longs: String,
|
|
79
|
+
enums: String,
|
|
80
|
+
defaults: true,
|
|
81
|
+
oneofs: true,
|
|
82
|
+
keepCase: true,
|
|
83
|
+
});
|
|
84
|
+
const pkg = loadPackageDefinition(def);
|
|
85
|
+
const svc = pkg.embedlink.agent.v1.AgentService;
|
|
86
|
+
const pairingSvc = pkg.embedlink.agent.v1.PairingService;
|
|
87
|
+
const filesSvc = pkg.embedlink.agent.v1.FilesService;
|
|
88
|
+
// 提前告知板卡 UART 会话管理默认端口/波特率(可能在首次使用时才真正打开)
|
|
89
|
+
setDefaultBoardUartHint(cfg.boardUart.port, cfg.boardUart.baud ?? DefaultTimeouts.boardUart.write);
|
|
90
|
+
// 初始化 FilesManager
|
|
91
|
+
const agentLogger = getAgentLogger();
|
|
92
|
+
const filesManager = new FilesManager({
|
|
93
|
+
rootDir: cfg.files?.rootDir || './files',
|
|
94
|
+
maxFileSize: cfg.files?.maxFileSize,
|
|
95
|
+
allowedPaths: cfg.files?.allowedPaths,
|
|
96
|
+
maxTotalBytes: cfg.files?.maxTotalBytes,
|
|
97
|
+
maxTotalFiles: cfg.files?.maxTotalFiles,
|
|
98
|
+
skipHiddenByDefault: cfg.files?.skipHiddenByDefault,
|
|
99
|
+
}, agentLogger, cfg);
|
|
100
|
+
const streamFile = async (call, fullPath, relativePath, chunkSize, srcTypeProto, totalFiles, completedFiles, isLastFile) => new Promise((resolve, reject) => {
|
|
101
|
+
const readStream = fsSync.createReadStream(fullPath, { highWaterMark: chunkSize });
|
|
102
|
+
readStream.on('data', (chunk) => {
|
|
103
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
104
|
+
call.write({
|
|
105
|
+
src_type: srcTypeProto,
|
|
106
|
+
relative_path: relativePath,
|
|
107
|
+
content: buf,
|
|
108
|
+
is_file_complete: false,
|
|
109
|
+
is_directory_complete: false,
|
|
110
|
+
files_completed: completedFiles,
|
|
111
|
+
total_files: totalFiles,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
readStream.on('end', () => {
|
|
115
|
+
const nextCompleted = completedFiles + 1;
|
|
116
|
+
call.write({
|
|
117
|
+
src_type: srcTypeProto,
|
|
118
|
+
relative_path: relativePath,
|
|
119
|
+
content: Buffer.alloc(0),
|
|
120
|
+
is_file_complete: true,
|
|
121
|
+
is_directory_complete: isLastFile,
|
|
122
|
+
files_completed: nextCompleted,
|
|
123
|
+
total_files: totalFiles,
|
|
124
|
+
});
|
|
125
|
+
resolve(nextCompleted);
|
|
126
|
+
});
|
|
127
|
+
readStream.on('error', (err) => reject(err));
|
|
128
|
+
});
|
|
129
|
+
let sshTunnel;
|
|
130
|
+
const sshPasswordSecret = readPasswordFromEnv();
|
|
131
|
+
server.addService(svc.service, {
|
|
132
|
+
Status: async (call, cb) => {
|
|
133
|
+
const shareUsername = (cfg.nfs?.username || identity.originUser || '').trim();
|
|
134
|
+
const sharePassword = cfg.nfs?.password || '';
|
|
135
|
+
const nfsExportPath = cfg.nfs?.exportName || '';
|
|
136
|
+
try {
|
|
137
|
+
markGrpcActivity();
|
|
138
|
+
const hostInstanceId = getHostInstanceIdFromCall(call);
|
|
139
|
+
if (hostInstanceId) {
|
|
140
|
+
try {
|
|
141
|
+
getBoardUartResourceManager().updateHostHeartbeat(hostInstanceId);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore heartbeat tracking errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const networkInfo = await getNetworkInfo();
|
|
148
|
+
const platformInfo = getPlatformInfo();
|
|
149
|
+
const preferredIp = (cfg.network?.preferredIp || '').trim();
|
|
150
|
+
const preferredInterface = (cfg.network?.preferredInterface || '').trim();
|
|
151
|
+
const preferredMatch = preferredIp && preferredInterface
|
|
152
|
+
? networkInfo.find((n) => n.ip === preferredIp && n.interface === preferredInterface)
|
|
153
|
+
: undefined;
|
|
154
|
+
const reportedNetworkInfo = preferredMatch ? [preferredMatch] : [];
|
|
155
|
+
const reportError = preferredIp && preferredInterface
|
|
156
|
+
? preferredMatch
|
|
157
|
+
? undefined
|
|
158
|
+
: `preferred network not available: interface=${preferredInterface}, ip=${preferredIp}`
|
|
159
|
+
: 'preferred network not configured (set network.preferredInterface/preferredIp)';
|
|
160
|
+
cb(null, {
|
|
161
|
+
status: 'ok',
|
|
162
|
+
version: '0.1.0',
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
shareUsername,
|
|
165
|
+
sharePassword,
|
|
166
|
+
nfsExportPath,
|
|
167
|
+
networkInterfaces: reportedNetworkInfo.map((info) => ({
|
|
168
|
+
ip: info.ip,
|
|
169
|
+
netmask: info.netmask,
|
|
170
|
+
broadcast: info.broadcast || null,
|
|
171
|
+
gateway: info.gateway || null,
|
|
172
|
+
interface: info.interface,
|
|
173
|
+
internal: info.internal,
|
|
174
|
+
})),
|
|
175
|
+
error: reportError,
|
|
176
|
+
platform: platformInfo.platform,
|
|
177
|
+
arch: platformInfo.arch,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// 如果获取网络信息失败,返回基本状态和平台信息
|
|
182
|
+
const platformInfo = getPlatformInfo();
|
|
183
|
+
cb(null, {
|
|
184
|
+
status: 'ok',
|
|
185
|
+
version: '0.1.0',
|
|
186
|
+
timestamp: Date.now(),
|
|
187
|
+
shareUsername,
|
|
188
|
+
sharePassword,
|
|
189
|
+
nfsExportPath,
|
|
190
|
+
networkInterfaces: [],
|
|
191
|
+
error: 'Failed to get network info',
|
|
192
|
+
platform: platformInfo.platform,
|
|
193
|
+
arch: platformInfo.arch,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
HostLogStream: (call, cb) => {
|
|
198
|
+
markGrpcActivity();
|
|
199
|
+
const hostInstanceId = getHostInstanceIdFromCall(call);
|
|
200
|
+
if (!hostInstanceId) {
|
|
201
|
+
cb(error(ErrorCodes.EL_INVALID_PARAMS, 'missing hostInstanceId (metadata: x-embedlink-host-instance-id)'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
call.on('data', (msg) => {
|
|
205
|
+
try {
|
|
206
|
+
markGrpcActivity();
|
|
207
|
+
try {
|
|
208
|
+
getBoardUartResourceManager().updateHostHeartbeat(hostInstanceId);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// ignore
|
|
212
|
+
}
|
|
213
|
+
const tsRaw = msg?.ts;
|
|
214
|
+
const ts = typeof tsRaw === 'number' ? tsRaw : Number(tsRaw || Date.now());
|
|
215
|
+
const level = String(msg?.level || 'info');
|
|
216
|
+
const event = String(msg?.event || 'host.log');
|
|
217
|
+
const pidRaw = msg?.pid;
|
|
218
|
+
const pid = typeof pidRaw === 'number' ? pidRaw : Number(pidRaw || 0);
|
|
219
|
+
const dataJson = msg?.data_json ?? msg?.dataJson ?? '';
|
|
220
|
+
let data = undefined;
|
|
221
|
+
if (typeof dataJson === 'string' && dataJson) {
|
|
222
|
+
try {
|
|
223
|
+
data = JSON.parse(dataJson);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
data = { raw: dataJson };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
publishHostLog({ ts: Number.isFinite(ts) ? ts : Date.now(), level, event, data, pid, hostInstanceId });
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// best-effort
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
call.on('error', () => {
|
|
236
|
+
// ignore
|
|
237
|
+
});
|
|
238
|
+
call.on('end', () => {
|
|
239
|
+
cb(null, { accepted: true, reason: '' });
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
BoardUartWrite: async (call, cb) => {
|
|
243
|
+
try {
|
|
244
|
+
markGrpcActivity();
|
|
245
|
+
const { sessionId, data, mock, waitFor, timeoutMs } = call.request;
|
|
246
|
+
const hostInstanceId = getHostInstanceIdFromCall(call);
|
|
247
|
+
if (!hostInstanceId) {
|
|
248
|
+
throw error(ErrorCodes.EL_INVALID_PARAMS, 'missing hostInstanceId (metadata: x-embedlink-host-instance-id)');
|
|
249
|
+
}
|
|
250
|
+
const mgr = getBoardUartResourceManager();
|
|
251
|
+
await mgr.touchHostIo({ hostInstanceId, sessionId });
|
|
252
|
+
const result = await mgr.writeCommand({
|
|
253
|
+
command: data.toString(),
|
|
254
|
+
sessionId,
|
|
255
|
+
waitFor,
|
|
256
|
+
timeoutMs,
|
|
257
|
+
});
|
|
258
|
+
cb(null, {
|
|
259
|
+
status: result.status,
|
|
260
|
+
output: result.output,
|
|
261
|
+
nextStep: result.nextStep,
|
|
262
|
+
elapsedMs: result.elapsedMs,
|
|
263
|
+
lastOutputGapMs: result.lastOutputGapMs,
|
|
264
|
+
remains: result.remains,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
cb(e);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
BoardUartListSessions: async (_call, cb) => {
|
|
272
|
+
try {
|
|
273
|
+
const sessions = await listBoardUartSessions();
|
|
274
|
+
cb(null, { sessions });
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
cb(e);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
BoardUartSessionWrite: async (call, cb) => {
|
|
281
|
+
try {
|
|
282
|
+
markGrpcActivity();
|
|
283
|
+
const { sessionId, data } = call.request;
|
|
284
|
+
const mgrWrite = getBoardUartResourceManager();
|
|
285
|
+
const hostInstanceId = getHostInstanceIdFromCall(call);
|
|
286
|
+
if (!hostInstanceId) {
|
|
287
|
+
throw error(ErrorCodes.EL_INVALID_PARAMS, 'missing hostInstanceId (metadata: x-embedlink-host-instance-id)');
|
|
288
|
+
}
|
|
289
|
+
await mgrWrite.touchHostIo({ hostInstanceId, sessionId });
|
|
290
|
+
const bytesWritten = await mgrWrite.write({
|
|
291
|
+
sessionId,
|
|
292
|
+
data: Buffer.from(data || Buffer.alloc(0)),
|
|
293
|
+
});
|
|
294
|
+
// 获取当前会话的缓冲区剩余数据量
|
|
295
|
+
// 对于 write 操作,我们无法直接获取缓冲区状态,设为 0
|
|
296
|
+
// 用户可以通过后续的 read 操作获取剩余数据
|
|
297
|
+
const remains = 0;
|
|
298
|
+
cb(null, { bytesWritten, remains });
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
cb(e);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
BoardUartSessionRead: async (call, cb) => {
|
|
305
|
+
try {
|
|
306
|
+
markGrpcActivity();
|
|
307
|
+
const { sessionId, maxBytes, quietMs, timeoutMs } = call.request;
|
|
308
|
+
const mgrRead = getBoardUartResourceManager();
|
|
309
|
+
const hostInstanceId = getHostInstanceIdFromCall(call);
|
|
310
|
+
if (!hostInstanceId) {
|
|
311
|
+
throw error(ErrorCodes.EL_INVALID_PARAMS, 'missing hostInstanceId (metadata: x-embedlink-host-instance-id)');
|
|
312
|
+
}
|
|
313
|
+
const r = await mgrRead.readForHost({
|
|
314
|
+
hostInstanceId,
|
|
315
|
+
sessionId,
|
|
316
|
+
maxBytes,
|
|
317
|
+
quietMs,
|
|
318
|
+
timeoutMs,
|
|
319
|
+
});
|
|
320
|
+
cb(null, { data: r.data, bytes: r.bytes, timedOut: r.timedOut, remains: r.remains });
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
cb(e);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
BoardUartOpenManual: async (call, cb) => {
|
|
327
|
+
try {
|
|
328
|
+
const { port, baud } = call.request;
|
|
329
|
+
const session = await openManualSession({ port, baud });
|
|
330
|
+
cb(null, { session });
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
cb(e);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
BoardUartCloseSession: async (call, cb) => {
|
|
337
|
+
try {
|
|
338
|
+
const { sessionId } = call.request;
|
|
339
|
+
const closed = await closeSession(sessionId);
|
|
340
|
+
cb(null, { closed });
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
cb(e);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
BoardUartStatus: async (_call, cb) => {
|
|
347
|
+
try {
|
|
348
|
+
markGrpcActivity();
|
|
349
|
+
const mgr = getBoardUartResourceManager();
|
|
350
|
+
const st = await mgr.getStatus();
|
|
351
|
+
cb(null, {
|
|
352
|
+
status: st.status,
|
|
353
|
+
disabled: st.disabled,
|
|
354
|
+
hasModule: st.hasModule,
|
|
355
|
+
port: st.port ?? '',
|
|
356
|
+
baud: st.baud ?? 0,
|
|
357
|
+
sessions: st.sessions,
|
|
358
|
+
attachedTools: st.attachedTools.map((t) => ({
|
|
359
|
+
id: t.id,
|
|
360
|
+
source: t.source || '',
|
|
361
|
+
sessionId: t.sessionId,
|
|
362
|
+
port: t.port,
|
|
363
|
+
baud: t.baud,
|
|
364
|
+
attachedAtMs: t.attachedAt,
|
|
365
|
+
})),
|
|
366
|
+
suspended: st.suspended,
|
|
367
|
+
suspendOwner: st.suspendOwner ?? '',
|
|
368
|
+
suspendedSinceMs: st.suspendedSinceMs ?? 0,
|
|
369
|
+
maxSuspendMs: st.maxSuspendMs ?? 0,
|
|
370
|
+
autoResumed: st.autoResumed,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
cb(e);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
BoardUartForceClose: async (_call, cb) => {
|
|
378
|
+
try {
|
|
379
|
+
const mgr = getBoardUartResourceManager();
|
|
380
|
+
await mgr.forceCloseHardware();
|
|
381
|
+
cb(null, { ok: true });
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
cb(e);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
TftpUpload: async (call, cb) => {
|
|
388
|
+
try {
|
|
389
|
+
const { fileName, content } = call.request;
|
|
390
|
+
const mode = cfg.tftp.server?.mode || 'builtin';
|
|
391
|
+
const enabled = cfg.tftp.server?.enabled !== false && mode !== 'disabled';
|
|
392
|
+
if (!enabled) {
|
|
393
|
+
throw error(ErrorCodes.EL_TFTP_DISABLED, 'TFTP is disabled');
|
|
394
|
+
}
|
|
395
|
+
const timeoutMs = cfg.timeouts?.tftp?.upload ?? DefaultTimeouts.tftp.upload;
|
|
396
|
+
if (mode === 'external') {
|
|
397
|
+
const host = cfg.tftp.server?.externalHost;
|
|
398
|
+
const port = cfg.tftp.server?.externalPort || 69;
|
|
399
|
+
if (!host || !host.trim()) {
|
|
400
|
+
throw error(ErrorCodes.EL_TFTP_UPLOAD_FAILED, 'external TFTP host is not configured');
|
|
401
|
+
}
|
|
402
|
+
const r = await Promise.race([
|
|
403
|
+
uploadToExternalTftp(host, port, fileName, Buffer.from(content)),
|
|
404
|
+
new Promise((_, reject) => setTimeout(() => reject(error(ErrorCodes.EL_TFTP_UPLOAD_FAILED, `tftp.upload timeout after ${timeoutMs} ms`)), timeoutMs)),
|
|
405
|
+
]);
|
|
406
|
+
cb(null, { path: r.remoteSubpath, size: r.size, sha256: r.sha256 });
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
const r = await Promise.race([
|
|
410
|
+
uploadToTftp(cfg.tftp.dir, fileName, Buffer.from(content)),
|
|
411
|
+
new Promise((_, reject) => setTimeout(() => reject(error(ErrorCodes.EL_TFTP_UPLOAD_FAILED, `tftp.upload timeout after ${timeoutMs} ms`)), timeoutMs)),
|
|
412
|
+
]);
|
|
413
|
+
cb(null, r);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
cb(e);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
TftpDownload: async (call, cb) => {
|
|
421
|
+
try {
|
|
422
|
+
const { path: relPath } = call.request;
|
|
423
|
+
const mode = cfg.tftp.server?.mode || 'builtin';
|
|
424
|
+
const enabled = cfg.tftp.server?.enabled !== false && mode !== 'disabled';
|
|
425
|
+
if (!enabled) {
|
|
426
|
+
throw error(ErrorCodes.EL_TFTP_DISABLED, 'TFTP is disabled');
|
|
427
|
+
}
|
|
428
|
+
if (mode === 'external') {
|
|
429
|
+
const host = cfg.tftp.server?.externalHost;
|
|
430
|
+
const port = cfg.tftp.server?.externalPort || 69;
|
|
431
|
+
if (!host || !host.trim()) {
|
|
432
|
+
throw error(ErrorCodes.EL_TFTP_VERIFY_FAILED, 'external TFTP host is not configured');
|
|
433
|
+
}
|
|
434
|
+
const r = await downloadFromExternalTftp(host, port, relPath);
|
|
435
|
+
cb(null, { content: r.content, size: r.size });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
const r = await downloadFromTftp(cfg.tftp.dir, relPath);
|
|
439
|
+
cb(null, { content: r.content, size: r.size });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
cb(e);
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
TftpList: async (call, cb) => {
|
|
447
|
+
try {
|
|
448
|
+
const { basePath, depth } = call.request;
|
|
449
|
+
const d = typeof depth === 'number' && depth >= 0 ? depth : 1;
|
|
450
|
+
const r = await listTftpEntries(cfg.tftp.dir, basePath, d);
|
|
451
|
+
// 转换为新的 FileEntry 结构
|
|
452
|
+
const protoEntries = r.entries.map((entry) => ({
|
|
453
|
+
name: entry.name,
|
|
454
|
+
path: entry.path,
|
|
455
|
+
type: entry.isDirectory ? 2 : 1, // FILE_TYPE_DIRECTORY = 2, FILE_TYPE_FILE = 1
|
|
456
|
+
size: entry.sizeBytes,
|
|
457
|
+
mtime: entry.modifiedAtMs,
|
|
458
|
+
}));
|
|
459
|
+
cb(null, { entries: protoEntries, truncated: r.truncated });
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
cb(e);
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
TftpUserguide: async (_call, cb) => {
|
|
466
|
+
try {
|
|
467
|
+
const doc = await readDoc('tftp-transfer');
|
|
468
|
+
cb(null, { content: doc.content, format: doc.format });
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
cb(e);
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
NfsUpload: async (call, cb) => {
|
|
475
|
+
try {
|
|
476
|
+
if (!cfg.nfs || !cfg.nfs.enabled || !cfg.nfs.localPath) {
|
|
477
|
+
throw error(ErrorCodes.EL_NFS_DISABLED, 'NFS is not configured or disabled');
|
|
478
|
+
}
|
|
479
|
+
const { remoteSubpath, content } = call.request;
|
|
480
|
+
const r = await nfsUpload(cfg.nfs.localPath, remoteSubpath, Buffer.from(content || Buffer.alloc(0)));
|
|
481
|
+
cb(null, { remoteSubpath: r.remoteSubpath, size: r.size });
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
cb(e);
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
NfsDownload: async (call, cb) => {
|
|
488
|
+
try {
|
|
489
|
+
if (!cfg.nfs || !cfg.nfs.enabled || !cfg.nfs.localPath) {
|
|
490
|
+
throw error(ErrorCodes.EL_NFS_DISABLED, 'NFS is not configured or disabled');
|
|
491
|
+
}
|
|
492
|
+
const { remoteSubpath } = call.request;
|
|
493
|
+
const r = await nfsDownload(cfg.nfs.localPath, remoteSubpath);
|
|
494
|
+
cb(null, { content: r.content, size: r.size });
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
cb(e);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
NfsList: async (call, cb) => {
|
|
501
|
+
try {
|
|
502
|
+
if (!cfg.nfs || !cfg.nfs.enabled || !cfg.nfs.localPath) {
|
|
503
|
+
throw error(ErrorCodes.EL_NFS_DISABLED, 'NFS is not configured or disabled');
|
|
504
|
+
}
|
|
505
|
+
const { remoteSubpath, mode, limit } = call.request;
|
|
506
|
+
const listMode = mode === 2 ? 'detailed' : 'simple'; // 2 = NFS_LIST_MODE_DETAILED
|
|
507
|
+
const r = await nfsList(cfg.nfs.localPath, remoteSubpath, listMode, limit);
|
|
508
|
+
// 转换为新的 FileEntry 结构
|
|
509
|
+
const protoEntries = r.entries.map((entry) => ({
|
|
510
|
+
name: entry.name,
|
|
511
|
+
path: entry.name, // NFS 没有相对路径概念,使用 name
|
|
512
|
+
type: entry.isDirectory ? 2 : 1, // FILE_TYPE_DIRECTORY = 2, FILE_TYPE_FILE = 1
|
|
513
|
+
size: listMode === 'detailed' ? entry.sizeBytes : 0,
|
|
514
|
+
mtime: listMode === 'detailed' ? entry.modifiedAtMs : Date.now(),
|
|
515
|
+
}));
|
|
516
|
+
cb(null, { entries: protoEntries, truncated: r.truncated });
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
cb(e);
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
NfsInfo: async (_call, cb) => {
|
|
523
|
+
try {
|
|
524
|
+
if (!cfg.nfs || !cfg.nfs.enabled || !cfg.nfs.localPath) {
|
|
525
|
+
cb(null, { enabled: false, exportName: '', description: '' });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
cb(null, {
|
|
529
|
+
enabled: true,
|
|
530
|
+
exportName: cfg.nfs.exportName,
|
|
531
|
+
description: cfg.nfs.description || '',
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
cb(e);
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
NfsUserguide: async (_call, cb) => {
|
|
539
|
+
try {
|
|
540
|
+
const doc = await readDoc('nfs-mount');
|
|
541
|
+
cb(null, { content: doc.content, format: doc.format });
|
|
542
|
+
}
|
|
543
|
+
catch (e) {
|
|
544
|
+
cb(e);
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
BoardNotes: async (call, cb) => {
|
|
548
|
+
try {
|
|
549
|
+
const { board, section } = call.request;
|
|
550
|
+
const r = await loadBoardNotes(cfg, board, section);
|
|
551
|
+
cb(null, r);
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
cb(e);
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
UBootBreak: async (call, cb) => {
|
|
558
|
+
try {
|
|
559
|
+
const { sessionId, timeoutMs, mock } = call.request;
|
|
560
|
+
const r = await ubootBreak({
|
|
561
|
+
sessionId,
|
|
562
|
+
timeoutMs: timeoutMs || cfg.timeouts.uboot.break,
|
|
563
|
+
mock: mock ?? cfg.boardUart.mock,
|
|
564
|
+
});
|
|
565
|
+
cb(null, r);
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
cb(e);
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
UBootRunCommand: async (call, cb) => {
|
|
572
|
+
try {
|
|
573
|
+
const { sessionId, command, timeoutMs, mock } = call.request;
|
|
574
|
+
const r = await ubootRunCommand({
|
|
575
|
+
sessionId,
|
|
576
|
+
command,
|
|
577
|
+
timeoutMs: timeoutMs || cfg.timeouts.uboot.cmd,
|
|
578
|
+
mock: mock ?? cfg.boardUart.mock,
|
|
579
|
+
});
|
|
580
|
+
cb(null, r);
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
cb(e);
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
FirmwarePrepareImages: async (call, cb) => {
|
|
587
|
+
try {
|
|
588
|
+
const { imagesRoot } = call.request;
|
|
589
|
+
const meta = await firmwarePrepareImages({
|
|
590
|
+
cfg,
|
|
591
|
+
imagesRoot,
|
|
592
|
+
});
|
|
593
|
+
cb(null, { meta }); // 只返回纯字符串
|
|
594
|
+
}
|
|
595
|
+
catch (e) {
|
|
596
|
+
cb(e);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
FirmwareBurnRecover: async (call, cb) => {
|
|
600
|
+
try {
|
|
601
|
+
const { imagesRoot, args, timeoutMs, force } = call.request;
|
|
602
|
+
const r = await firmwareBurnRecover({
|
|
603
|
+
cfg,
|
|
604
|
+
imagesRoot,
|
|
605
|
+
args,
|
|
606
|
+
timeoutMs,
|
|
607
|
+
force,
|
|
608
|
+
});
|
|
609
|
+
cb(null, r);
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
cb(e);
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
FirmwareUserGuide: async (_call, cb) => {
|
|
616
|
+
try {
|
|
617
|
+
const json = await getFirmwareUserGuideMarkdown();
|
|
618
|
+
cb(null, { json });
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
cb(e);
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
TunnelStart: async (call, cb) => {
|
|
625
|
+
try {
|
|
626
|
+
const { host, port, user, password, privateKeyPath, localPort, forwardHost, forwardPort } = call.request;
|
|
627
|
+
if (!host || !user) {
|
|
628
|
+
throw error(ErrorCodes.EL_INVALID_PARAMS, 'missing host/user for TunnelStart');
|
|
629
|
+
}
|
|
630
|
+
const sshPort = port && port > 0 ? port : 22;
|
|
631
|
+
const bindPort = localPort && localPort > 0 ? localPort : 23745;
|
|
632
|
+
const dstPort = forwardPort && forwardPort > 0 ? forwardPort : cfg.grpc.port;
|
|
633
|
+
const dstHost = forwardHost || '127.0.0.1';
|
|
634
|
+
let privateKey;
|
|
635
|
+
if (privateKeyPath) {
|
|
636
|
+
try {
|
|
637
|
+
privateKey = await fs.readFile(privateKeyPath);
|
|
638
|
+
}
|
|
639
|
+
catch (e) {
|
|
640
|
+
throw error(ErrorCodes.EL_SSH_CONNECT_FAILED, `failed to read private key: ${e?.message || String(e)}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (sshTunnel) {
|
|
644
|
+
await sshTunnel.stop().catch(() => { });
|
|
645
|
+
}
|
|
646
|
+
sshTunnel = new SshTunnel();
|
|
647
|
+
await sshTunnel.start({
|
|
648
|
+
host,
|
|
649
|
+
port: Number(sshPort),
|
|
650
|
+
user,
|
|
651
|
+
password: password || sshPasswordSecret.value,
|
|
652
|
+
privateKey,
|
|
653
|
+
bindAddr: '127.0.0.1',
|
|
654
|
+
bindPort: Number(bindPort),
|
|
655
|
+
dstHost,
|
|
656
|
+
dstPort: Number(dstPort),
|
|
657
|
+
});
|
|
658
|
+
cb(null, {
|
|
659
|
+
connected: true,
|
|
660
|
+
details: `ssh://${user}@${host} remote ${bindPort} -> ${dstHost}:${dstPort}`,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
cb(e);
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
TunnelStop: async (_call, cb) => {
|
|
668
|
+
try {
|
|
669
|
+
if (sshTunnel) {
|
|
670
|
+
await sshTunnel.stop().catch(() => { });
|
|
671
|
+
sshTunnel = undefined;
|
|
672
|
+
// 任务结束后清理内存中的口令引用
|
|
673
|
+
eraseSecret(sshPasswordSecret);
|
|
674
|
+
}
|
|
675
|
+
cb(null, { stopped: true });
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
cb(e);
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
BoardUartListPorts: async (_call, cb) => {
|
|
682
|
+
try {
|
|
683
|
+
const ports = await listBoardUartPorts();
|
|
684
|
+
cb(null, { ports });
|
|
685
|
+
}
|
|
686
|
+
catch (e) {
|
|
687
|
+
cb(e);
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
ListSshCandidates: async (_call, cb) => {
|
|
691
|
+
try {
|
|
692
|
+
const hosts = await gatherSshCandidates();
|
|
693
|
+
cb(null, { hosts });
|
|
694
|
+
}
|
|
695
|
+
catch (e) {
|
|
696
|
+
cb(e);
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
// Pairing service: simple pairCode-based identity check
|
|
701
|
+
server.addService(pairingSvc.service, {
|
|
702
|
+
PairCheck: (call, cb) => {
|
|
703
|
+
markGrpcActivity();
|
|
704
|
+
const req = call.request;
|
|
705
|
+
const ok = !!req.pairCode && req.pairCode === identity.pairCode;
|
|
706
|
+
if (ok) {
|
|
707
|
+
cb(null, {
|
|
708
|
+
accepted: true,
|
|
709
|
+
agentId: identity.id,
|
|
710
|
+
version: identity.version,
|
|
711
|
+
startupTime: identity.startupTime,
|
|
712
|
+
reason: '',
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
cb(null, {
|
|
717
|
+
accepted: false,
|
|
718
|
+
agentId: identity.id,
|
|
719
|
+
version: identity.version,
|
|
720
|
+
startupTime: identity.startupTime,
|
|
721
|
+
reason: 'PAIR_CODE_MISMATCH',
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
// Files Service 注册
|
|
727
|
+
server.addService(filesSvc.service, {
|
|
728
|
+
PutPath: (call, cb) => {
|
|
729
|
+
const limits = filesManager.getLimits();
|
|
730
|
+
const writers = new Map();
|
|
731
|
+
let meta = null;
|
|
732
|
+
let requestId;
|
|
733
|
+
let filesWritten = 0;
|
|
734
|
+
let bytesWritten = 0;
|
|
735
|
+
const failedFiles = [];
|
|
736
|
+
let processing = Promise.resolve();
|
|
737
|
+
let finished = false;
|
|
738
|
+
const closeAll = async () => {
|
|
739
|
+
const closers = Array.from(writers.values()).map((w) => new Promise((resolve) => {
|
|
740
|
+
w.stream.end(() => resolve());
|
|
741
|
+
}));
|
|
742
|
+
writers.clear();
|
|
743
|
+
await Promise.all(closers);
|
|
744
|
+
};
|
|
745
|
+
const handleError = async (err) => {
|
|
746
|
+
if (finished)
|
|
747
|
+
return;
|
|
748
|
+
finished = true;
|
|
749
|
+
// 记录错误信息
|
|
750
|
+
if (requestId) {
|
|
751
|
+
agentLogger.write(LogLevel.ERROR, 'grpc.put_path.error', {
|
|
752
|
+
requestId,
|
|
753
|
+
pathContext: meta?.pathContext,
|
|
754
|
+
error: err?.message || String(err),
|
|
755
|
+
stack: err?.stack,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
await closeAll().catch(() => { });
|
|
759
|
+
cb(err);
|
|
760
|
+
call.destroy(err);
|
|
761
|
+
};
|
|
762
|
+
const resolveUploadPath = (m, relativePath) => {
|
|
763
|
+
const base = m.src_type === PROTO_FILE_TYPE_DIRECTORY
|
|
764
|
+
? path.join(m.dst_path || '', relativePath)
|
|
765
|
+
: m.dst_path && m.dst_path.trim()
|
|
766
|
+
? m.dst_path
|
|
767
|
+
: relativePath;
|
|
768
|
+
return filesManager.resolvePath(base || relativePath);
|
|
769
|
+
};
|
|
770
|
+
const processChunk = async (chunk) => {
|
|
771
|
+
if (chunk.meta) {
|
|
772
|
+
if (meta) {
|
|
773
|
+
throw new Error('PutPath meta already received');
|
|
774
|
+
}
|
|
775
|
+
const srcType = normalizeProtoFileType(chunk.meta.src_type);
|
|
776
|
+
const chunkRequestId = chunk.meta.requestId ||
|
|
777
|
+
`files_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
778
|
+
requestId = chunkRequestId;
|
|
779
|
+
meta = {
|
|
780
|
+
src_type: srcType,
|
|
781
|
+
dst_path: chunk.meta.dst_path,
|
|
782
|
+
total_files: chunk.meta.total_files,
|
|
783
|
+
total_bytes: chunk.meta.total_bytes,
|
|
784
|
+
overwrite: chunk.meta.overwrite,
|
|
785
|
+
requestId: chunkRequestId,
|
|
786
|
+
pathContext: chunk.meta.pathContext,
|
|
787
|
+
};
|
|
788
|
+
// 记录接收到的路径上下���
|
|
789
|
+
agentLogger.write(LogLevel.DEBUG, 'grpc.put_path.received', {
|
|
790
|
+
requestId: chunkRequestId,
|
|
791
|
+
pathContext: chunk.meta.pathContext,
|
|
792
|
+
agentReceivedTargetPath: chunk.meta.dst_path,
|
|
793
|
+
agentTimestamp: Date.now(),
|
|
794
|
+
agentId: identity.id,
|
|
795
|
+
});
|
|
796
|
+
if (meta.src_type !== PROTO_FILE_TYPE_FILE &&
|
|
797
|
+
meta.src_type !== PROTO_FILE_TYPE_DIRECTORY) {
|
|
798
|
+
throw new Error('Invalid src_type for PutPath');
|
|
799
|
+
}
|
|
800
|
+
if (meta.dst_path && path.isAbsolute(meta.dst_path)) {
|
|
801
|
+
throw new Error('dst_path must be relative to Agent files root');
|
|
802
|
+
}
|
|
803
|
+
if (meta.src_type === PROTO_FILE_TYPE_DIRECTORY && meta.dst_path) {
|
|
804
|
+
const targetRoot = filesManager.resolvePath(meta.dst_path);
|
|
805
|
+
if (fsSync.existsSync(targetRoot)) {
|
|
806
|
+
const st = fsSync.statSync(targetRoot);
|
|
807
|
+
if (st.isFile()) {
|
|
808
|
+
throw new Error(`Type mismatch: destination '${meta.dst_path}' is a file, source is DIRECTORY`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (meta.total_files && limits.maxTotalFiles && meta.total_files > limits.maxTotalFiles) {
|
|
813
|
+
throw new Error(`Total files ${meta.total_files} exceed limit ${limits.maxTotalFiles}`);
|
|
814
|
+
}
|
|
815
|
+
if (meta.total_bytes && limits.maxTotalBytes && meta.total_bytes > limits.maxTotalBytes) {
|
|
816
|
+
throw new Error(`Total bytes ${meta.total_bytes} exceed limit ${limits.maxTotalBytes}`);
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (!meta) {
|
|
821
|
+
throw new Error('PutPath meta must be sent before data');
|
|
822
|
+
}
|
|
823
|
+
const data = chunk.data;
|
|
824
|
+
if (!data || !data.relative_path) {
|
|
825
|
+
throw new Error('Missing data.relative_path');
|
|
826
|
+
}
|
|
827
|
+
if (path.isAbsolute(data.relative_path)) {
|
|
828
|
+
throw new Error('relative_path must be relative (not absolute)');
|
|
829
|
+
}
|
|
830
|
+
const targetPath = resolveUploadPath(meta, data.relative_path);
|
|
831
|
+
const overwrite = meta.overwrite !== false;
|
|
832
|
+
let writer = writers.get(data.relative_path);
|
|
833
|
+
if (!writer) {
|
|
834
|
+
if (fsSync.existsSync(targetPath)) {
|
|
835
|
+
const st = fsSync.statSync(targetPath);
|
|
836
|
+
if (st.isDirectory()) {
|
|
837
|
+
failedFiles.push(data.relative_path);
|
|
838
|
+
throw new Error(`Type mismatch: destination '${data.relative_path}' is a directory, source is FILE`);
|
|
839
|
+
}
|
|
840
|
+
if (!overwrite) {
|
|
841
|
+
throw new Error(`Target exists and overwrite=false: ${data.relative_path}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
await filesManager.ensureDirectory(path.dirname(targetPath));
|
|
845
|
+
writer = {
|
|
846
|
+
stream: fsSync.createWriteStream(targetPath, { flags: 'w' }),
|
|
847
|
+
bytes: 0,
|
|
848
|
+
targetPath,
|
|
849
|
+
};
|
|
850
|
+
writers.set(data.relative_path, writer);
|
|
851
|
+
}
|
|
852
|
+
const buf = data.content ? Buffer.from(data.content) : Buffer.alloc(0);
|
|
853
|
+
if (buf.length > 0) {
|
|
854
|
+
writer.stream.write(buf);
|
|
855
|
+
writer.bytes += buf.length;
|
|
856
|
+
bytesWritten += buf.length;
|
|
857
|
+
}
|
|
858
|
+
if (limits.maxFileSize && writer.bytes > limits.maxFileSize) {
|
|
859
|
+
failedFiles.push(data.relative_path);
|
|
860
|
+
throw new Error(`File ${data.relative_path} exceeds size limit ${limits.maxFileSize}`);
|
|
861
|
+
}
|
|
862
|
+
if (limits.maxTotalBytes && bytesWritten > limits.maxTotalBytes) {
|
|
863
|
+
failedFiles.push(data.relative_path);
|
|
864
|
+
throw new Error(`Total bytes ${bytesWritten} exceed limit ${limits.maxTotalBytes}`);
|
|
865
|
+
}
|
|
866
|
+
if (data.is_file_complete) {
|
|
867
|
+
await new Promise((resolve, reject) => writer.stream.end((err) => err ? reject(err) : resolve()));
|
|
868
|
+
writers.delete(data.relative_path);
|
|
869
|
+
filesWritten += 1;
|
|
870
|
+
if (limits.maxTotalFiles && filesWritten > limits.maxTotalFiles) {
|
|
871
|
+
failedFiles.push(data.relative_path);
|
|
872
|
+
throw new Error(`Files count ${filesWritten} exceed limit ${limits.maxTotalFiles}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
call.on('data', (chunk) => {
|
|
877
|
+
processing = processing.then(() => processChunk(chunk)).catch(handleError);
|
|
878
|
+
});
|
|
879
|
+
call.on('error', handleError);
|
|
880
|
+
call.on('end', () => {
|
|
881
|
+
processing
|
|
882
|
+
.then(async () => {
|
|
883
|
+
if (finished)
|
|
884
|
+
return;
|
|
885
|
+
finished = true;
|
|
886
|
+
await closeAll().catch(() => { });
|
|
887
|
+
// 记录操作完成结果
|
|
888
|
+
if (requestId) {
|
|
889
|
+
agentLogger.write(LogLevel.DEBUG, 'grpc.put_path.completed', {
|
|
890
|
+
requestId,
|
|
891
|
+
result: {
|
|
892
|
+
success: failedFiles.length === 0,
|
|
893
|
+
filesProcessed: filesWritten,
|
|
894
|
+
totalBytes: bytesWritten,
|
|
895
|
+
failedFiles: failedFiles,
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
cb(null, {
|
|
900
|
+
success: failedFiles.length === 0,
|
|
901
|
+
files_written: filesWritten,
|
|
902
|
+
bytes_written: bytesWritten,
|
|
903
|
+
failed_files: failedFiles,
|
|
904
|
+
});
|
|
905
|
+
})
|
|
906
|
+
.catch(handleError);
|
|
907
|
+
});
|
|
908
|
+
},
|
|
909
|
+
GetPath: async (call) => {
|
|
910
|
+
const requestId = call.request.requestId ||
|
|
911
|
+
`files_get_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
912
|
+
try {
|
|
913
|
+
const req = call.request;
|
|
914
|
+
// 记录接收到的路径上下文
|
|
915
|
+
agentLogger.write(LogLevel.DEBUG, 'grpc.get_path.received', {
|
|
916
|
+
requestId,
|
|
917
|
+
pathContext: req.pathContext,
|
|
918
|
+
agentReceivedSourcePath: req.src_path,
|
|
919
|
+
agentTimestamp: Date.now(),
|
|
920
|
+
agentId: identity.id,
|
|
921
|
+
});
|
|
922
|
+
const chunkSize = typeof req.chunk_size === 'number' && req.chunk_size > 0
|
|
923
|
+
? req.chunk_size
|
|
924
|
+
: DEFAULT_CHUNK_SIZE;
|
|
925
|
+
const skipHidden = typeof req.skip_hidden === 'boolean' ? req.skip_hidden : cfg.files?.skipHiddenByDefault;
|
|
926
|
+
const stat = await filesManager.getStat(req.src_path);
|
|
927
|
+
const isDirectory = stat.isDirectory;
|
|
928
|
+
const srcTypeProto = isDirectory ? PROTO_FILE_TYPE_DIRECTORY : PROTO_FILE_TYPE_FILE;
|
|
929
|
+
if (!isDirectory) {
|
|
930
|
+
const fullPath = filesManager.resolvePath(stat.path);
|
|
931
|
+
const relativePath = path.basename(fullPath);
|
|
932
|
+
await streamFile(call, fullPath, relativePath, chunkSize, srcTypeProto, 1, 0, true);
|
|
933
|
+
call.end();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const entries = await filesManager.listDir(req.src_path, {
|
|
937
|
+
recursive: req.recursive !== false,
|
|
938
|
+
all: !skipHidden,
|
|
939
|
+
});
|
|
940
|
+
const fileEntries = entries.filter((e) => e.type === FileType.FILE);
|
|
941
|
+
const totalFiles = fileEntries.length;
|
|
942
|
+
let completedFiles = 0;
|
|
943
|
+
const baseDir = path.normalize(req.src_path);
|
|
944
|
+
for (const entry of fileEntries) {
|
|
945
|
+
const fullPath = filesManager.resolvePath(entry.path);
|
|
946
|
+
const relInDir = path.relative(baseDir, entry.path) || path.basename(entry.path);
|
|
947
|
+
completedFiles = await streamFile(call, fullPath, relInDir, chunkSize, srcTypeProto, totalFiles, completedFiles, completedFiles + 1 === totalFiles);
|
|
948
|
+
}
|
|
949
|
+
// 记录操作完成
|
|
950
|
+
agentLogger.write(LogLevel.DEBUG, 'grpc.get_path.completed', {
|
|
951
|
+
requestId,
|
|
952
|
+
success: true,
|
|
953
|
+
});
|
|
954
|
+
call.end();
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
// 记录错误信息
|
|
958
|
+
agentLogger.write(LogLevel.ERROR, 'grpc.get_path.error', {
|
|
959
|
+
requestId,
|
|
960
|
+
error: error?.message || String(error),
|
|
961
|
+
stack: error?.stack,
|
|
962
|
+
});
|
|
963
|
+
call.destroy(error);
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
ListFiles: async (call, cb) => {
|
|
967
|
+
try {
|
|
968
|
+
const req = call.request;
|
|
969
|
+
const entries = await filesManager.listDir(req.path, {
|
|
970
|
+
all: req.all,
|
|
971
|
+
recursive: req.recursive,
|
|
972
|
+
pattern: req.pattern,
|
|
973
|
+
});
|
|
974
|
+
cb(null, {
|
|
975
|
+
entries: entries.map((entry) => ({
|
|
976
|
+
name: entry.name,
|
|
977
|
+
path: entry.path,
|
|
978
|
+
type: entry.type === FileType.FILE ? PROTO_FILE_TYPE_FILE : PROTO_FILE_TYPE_DIRECTORY,
|
|
979
|
+
size: entry.size,
|
|
980
|
+
mtime: entry.mtime,
|
|
981
|
+
})),
|
|
982
|
+
total_entries: entries.length,
|
|
983
|
+
truncated: false,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
cb(error);
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
RemoveFiles: async (call, cb) => {
|
|
991
|
+
try {
|
|
992
|
+
const req = call.request;
|
|
993
|
+
await filesManager.remove(req.path, req.recursive);
|
|
994
|
+
cb(null, {
|
|
995
|
+
success: true,
|
|
996
|
+
deleted_count: 1,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
cb(error);
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
MakeDirectory: async (call, cb) => {
|
|
1004
|
+
try {
|
|
1005
|
+
const req = call.request;
|
|
1006
|
+
await filesManager.makeDir(req.path, req.parents);
|
|
1007
|
+
cb(null, {
|
|
1008
|
+
success: true,
|
|
1009
|
+
path: req.path,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
cb(error);
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
GetStat: async (call, cb) => {
|
|
1017
|
+
try {
|
|
1018
|
+
const req = call.request;
|
|
1019
|
+
const stat = await filesManager.getStat(req.path);
|
|
1020
|
+
cb(null, {
|
|
1021
|
+
success: true,
|
|
1022
|
+
stat: {
|
|
1023
|
+
path: stat.path,
|
|
1024
|
+
name: stat.name,
|
|
1025
|
+
size: stat.size,
|
|
1026
|
+
is_file: stat.isFile,
|
|
1027
|
+
is_directory: stat.isDirectory,
|
|
1028
|
+
modified_at: stat.modifiedAt,
|
|
1029
|
+
permissions: stat.permissions,
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
cb(error);
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
// 智能端口绑定 - 尝试配置端口,如果被占用则随机选择
|
|
1039
|
+
await new Promise((resolve, reject) => {
|
|
1040
|
+
const credentials = ServerCredentials.createInsecure();
|
|
1041
|
+
const tryBindPort = (port, isRandom = false) => {
|
|
1042
|
+
server.bindAsync(`${cfg.grpc.host}:${port}`, credentials, (err) => {
|
|
1043
|
+
if (err) {
|
|
1044
|
+
if (!isRandom) {
|
|
1045
|
+
// 配置的端口被占用,尝试随机端口
|
|
1046
|
+
console.log(`Port ${port} occupied, trying random port...`);
|
|
1047
|
+
const randomPort = Math.floor(Math.random() * 10000) + 50000; // 50000-59999
|
|
1048
|
+
setTimeout(() => tryBindPort(randomPort, true), 100);
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
// 随机端口也失败
|
|
1052
|
+
reject(error(ErrorCodes.EL_AGENT_UNAVAILABLE, String(err)));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
// 绑定成功
|
|
1057
|
+
if (isRandom) {
|
|
1058
|
+
console.log(`Using random port ${port}`);
|
|
1059
|
+
// 更新配置中的实际端口
|
|
1060
|
+
cfg.grpc.port = port;
|
|
1061
|
+
}
|
|
1062
|
+
resolve();
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1066
|
+
tryBindPort(cfg.grpc.port);
|
|
1067
|
+
});
|
|
1068
|
+
return server;
|
|
1069
|
+
}
|