@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.
Files changed (53) hide show
  1. package/README.md +107 -0
  2. package/dist/.platform +1 -0
  3. package/dist/board/docs.js +59 -0
  4. package/dist/board/notes.js +11 -0
  5. package/dist/board_uart/history.js +81 -0
  6. package/dist/board_uart/index.js +66 -0
  7. package/dist/board_uart/manager.js +313 -0
  8. package/dist/board_uart/resource.js +578 -0
  9. package/dist/board_uart/sessions.js +559 -0
  10. package/dist/config/index.js +341 -0
  11. package/dist/core/activity.js +7 -0
  12. package/dist/core/errors.js +45 -0
  13. package/dist/core/log_stream.js +26 -0
  14. package/dist/files/__tests__/files_manager.test.js +209 -0
  15. package/dist/files/artifact_manager.js +68 -0
  16. package/dist/files/file_operation_logger.js +271 -0
  17. package/dist/files/files_manager.js +511 -0
  18. package/dist/files/index.js +87 -0
  19. package/dist/files/types.js +5 -0
  20. package/dist/firmware/burn_recover.js +733 -0
  21. package/dist/firmware/prepare_images.js +184 -0
  22. package/dist/firmware/user_guide.js +43 -0
  23. package/dist/index.js +449 -0
  24. package/dist/logger.js +245 -0
  25. package/dist/macro/index.js +241 -0
  26. package/dist/macro/runner.js +168 -0
  27. package/dist/nfs/index.js +105 -0
  28. package/dist/plugins/loader.js +30 -0
  29. package/dist/proto/agent.proto +473 -0
  30. package/dist/resources/docs/board-interaction.md +115 -0
  31. package/dist/resources/docs/firmware-upgrade.md +404 -0
  32. package/dist/resources/docs/nfs-mount-guide.md +78 -0
  33. package/dist/resources/docs/tftp-transfer-guide.md +81 -0
  34. package/dist/secrets/index.js +9 -0
  35. package/dist/server/grpc.js +1069 -0
  36. package/dist/server/web.js +2284 -0
  37. package/dist/ssh/adapter.js +126 -0
  38. package/dist/ssh/candidates.js +85 -0
  39. package/dist/ssh/index.js +3 -0
  40. package/dist/ssh/paircheck.js +35 -0
  41. package/dist/ssh/tunnel.js +111 -0
  42. package/dist/tftp/client.js +345 -0
  43. package/dist/tftp/index.js +284 -0
  44. package/dist/tftp/server.js +731 -0
  45. package/dist/uboot/index.js +45 -0
  46. package/dist/ui/assets/index-BlnLVmbt.js +374 -0
  47. package/dist/ui/assets/index-xMbarYXA.css +32 -0
  48. package/dist/ui/index.html +21 -0
  49. package/dist/utils/network.js +150 -0
  50. package/dist/utils/platform.js +83 -0
  51. package/dist/utils/port-check.js +153 -0
  52. package/dist/utils/user-prompt.js +139 -0
  53. package/package.json +64 -0
@@ -0,0 +1,731 @@
1
+ import dgram from 'node:dgram';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { EventEmitter } from 'node:events';
5
+ import { error, ErrorCodes } from '../core/errors.js';
6
+ import { getAgentLogger, LogLevel } from '../logger.js';
7
+ import { ensureDir } from './index.js';
8
+ // TFTP操作码
9
+ const OPCODES = {
10
+ RRQ: 1, // 读请求
11
+ WRQ: 2, // 写请求
12
+ DATA: 3, // 数据包
13
+ ACK: 4, // 确认
14
+ ERROR: 5, // 错误
15
+ OACK: 6, // 选项确认
16
+ };
17
+ // TFTP错误码
18
+ const ERROR_CODES = {
19
+ NOT_DEFINED: 0,
20
+ FILE_NOT_FOUND: 1,
21
+ ACCESS_VIOLATION: 2,
22
+ DISK_FULL: 3,
23
+ ILLEGAL_OPERATION: 4,
24
+ UNKNOWN_TID: 5,
25
+ FILE_EXISTS: 6,
26
+ NO_SUCH_USER: 7,
27
+ };
28
+ // 服务端重试配置
29
+ const SERVER_RETRY_CONFIG = {
30
+ MAX_RETRIES: 5,
31
+ INITIAL_BACKOFF_MS: 500,
32
+ MAX_BACKOFF_MS: 3000,
33
+ };
34
+ export class TftpServer extends EventEmitter {
35
+ /**
36
+ * 跨平台安全的路径检查函数
37
+ */
38
+ async validateSecurePath(requestedPath, rootDir) {
39
+ try {
40
+ // 1. 标准化路径(处理大小写和分隔符)
41
+ const normalizedRoot = path.resolve(rootDir).toLowerCase();
42
+ const normalizedRequested = path.resolve(rootDir, requestedPath).toLowerCase();
43
+ // 2. 检查是否在根目录内(跨平台安全比较)
44
+ if (!normalizedRequested.startsWith(normalizedRoot)) {
45
+ return { valid: false, normalizedPath: '' };
46
+ }
47
+ // 3. 检查路径是否包含非法字符(Windows限制)
48
+ const windowsReservedChars = /[<>:"|?*]/;
49
+ if (process.platform === 'win32' && windowsReservedChars.test(requestedPath)) {
50
+ return { valid: false, normalizedPath: '' };
51
+ }
52
+ // 4. 检查保留名称(Windows限制)
53
+ const windowsReservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
54
+ const basename = path.basename(requestedPath);
55
+ if (process.platform === 'win32' && windowsReservedNames.test(basename)) {
56
+ return { valid: false, normalizedPath: '' };
57
+ }
58
+ return { valid: true, normalizedPath: path.resolve(rootDir, requestedPath) };
59
+ }
60
+ catch {
61
+ return { valid: false, normalizedPath: '' };
62
+ }
63
+ }
64
+ /**
65
+ * 跨平台文件类型检查
66
+ */
67
+ async validateFileType(filepath, forRead = true) {
68
+ try {
69
+ const stats = await fs.stat(filepath);
70
+ if (forRead) {
71
+ if (!stats.isFile()) {
72
+ return {
73
+ valid: false,
74
+ error: process.platform === 'win32'
75
+ ? 'Path is a directory, not a file'
76
+ : 'Path is not a regular file',
77
+ };
78
+ }
79
+ }
80
+ else {
81
+ if (stats.isDirectory()) {
82
+ return {
83
+ valid: false,
84
+ error: 'Path is a directory, cannot write to directory',
85
+ };
86
+ }
87
+ }
88
+ return { valid: true };
89
+ }
90
+ catch (err) {
91
+ // 跨平台错误码映射
92
+ if (err.code === 'EISDIR') {
93
+ return { valid: false, error: 'Path is a directory, not a file' };
94
+ }
95
+ else if (err.code === 'ENOENT') {
96
+ return { valid: false, error: 'Path does not exist' };
97
+ }
98
+ else if (err.code === 'EACCES' || err.code === 'EPERM') {
99
+ return { valid: false, error: 'Access denied' };
100
+ }
101
+ else {
102
+ return { valid: false, error: `File system error: ${err.code}` };
103
+ }
104
+ }
105
+ }
106
+ constructor(config) {
107
+ super();
108
+ this.startTime = 0;
109
+ this.stats = {
110
+ requestsHandled: 0,
111
+ errors: 0,
112
+ activeConnections: 0,
113
+ };
114
+ this.running = false;
115
+ this.sessions = new Map();
116
+ this.config = {
117
+ timeout: 5000,
118
+ retryCount: 3,
119
+ blockSize: 512,
120
+ ...config,
121
+ };
122
+ }
123
+ async start() {
124
+ if (this.running) {
125
+ throw error(ErrorCodes.EL_TFTP_SERVER_ALREADY_RUNNING, 'TFTP服务器已在运行');
126
+ }
127
+ try {
128
+ // 确保根目录存在
129
+ await ensureDir(this.config.rootDir);
130
+ // 创建UDP socket
131
+ this.socket = dgram.createSocket({
132
+ type: 'udp4',
133
+ reuseAddr: true,
134
+ });
135
+ // 绑定端口
136
+ await new Promise((resolve, reject) => {
137
+ this.socket.on('error', (err) => {
138
+ this.stats.errors++;
139
+ this.emit('error', err);
140
+ reject(err);
141
+ });
142
+ this.socket.on('message', (buffer, rinfo) => {
143
+ this.handleRequest(buffer, rinfo);
144
+ });
145
+ this.socket.bind(this.config.port, this.config.host, () => {
146
+ this.startTime = Date.now();
147
+ this.running = true;
148
+ this.stats.activeConnections = 0;
149
+ // 启动会话清理定时器
150
+ this.startCleanupTimer();
151
+ resolve();
152
+ });
153
+ });
154
+ this.emit('started');
155
+ }
156
+ catch (e) {
157
+ throw error(ErrorCodes.EL_TFTP_SERVER_START_FAILED, e.message);
158
+ }
159
+ }
160
+ async stop() {
161
+ if (!this.running)
162
+ return;
163
+ this.running = false;
164
+ // 停止清理定时器
165
+ if (this.cleanupInterval) {
166
+ clearInterval(this.cleanupInterval);
167
+ this.cleanupInterval = undefined;
168
+ }
169
+ // 关闭所有活动会话
170
+ for (const session of this.sessions.values()) {
171
+ try {
172
+ if (session.fileHandle) {
173
+ await session.fileHandle.close();
174
+ }
175
+ session.socket.close();
176
+ }
177
+ catch (error) {
178
+ // 忽略关闭错误
179
+ }
180
+ }
181
+ this.sessions.clear();
182
+ // 关闭主socket
183
+ if (this.socket) {
184
+ this.socket.close();
185
+ this.socket = undefined;
186
+ }
187
+ this.emit('stopped');
188
+ }
189
+ isRunning() {
190
+ return this.running;
191
+ }
192
+ getStats() {
193
+ return {
194
+ enabled: true,
195
+ mode: 'builtin',
196
+ port: this.config.port,
197
+ host: this.config.host || '0.0.0.0',
198
+ uptime: this.running ? Date.now() - this.startTime : 0,
199
+ requestsHandled: this.stats.requestsHandled,
200
+ errors: this.stats.errors,
201
+ isRunning: this.running,
202
+ activeConnections: this.stats.activeConnections,
203
+ };
204
+ }
205
+ startCleanupTimer() {
206
+ this.cleanupInterval = setInterval(() => {
207
+ this.cleanupExpiredSessions();
208
+ }, 60000); // 改为60秒清理一次(原30秒)
209
+ }
210
+ cleanupExpiredSessions() {
211
+ const now = Date.now();
212
+ const timeout = this.config.timeout * 3; // 改为3倍超时时间(原2倍)
213
+ for (const [sessionId, session] of this.sessions.entries()) {
214
+ if (now - session.lastActivity > timeout) {
215
+ this.closeSession(sessionId);
216
+ }
217
+ }
218
+ }
219
+ async handleRequest(buffer, rinfo) {
220
+ try {
221
+ this.stats.requestsHandled++;
222
+ // 解析请求
223
+ const opcode = buffer.readUInt16BE(0);
224
+ const sessionId = `${rinfo.address}:${rinfo.port}`;
225
+ switch (opcode) {
226
+ case OPCODES.RRQ:
227
+ case OPCODES.WRQ:
228
+ await this.handleInitialRequest(opcode, buffer, rinfo);
229
+ break;
230
+ case OPCODES.ACK:
231
+ case OPCODES.DATA:
232
+ case OPCODES.ERROR:
233
+ await this.handleTransferMessage(sessionId, opcode, buffer, rinfo);
234
+ break;
235
+ default:
236
+ await this.sendError(rinfo, ERROR_CODES.ILLEGAL_OPERATION, 'Unknown opcode');
237
+ }
238
+ }
239
+ catch (err) {
240
+ this.stats.errors++;
241
+ this.emit('error', err);
242
+ }
243
+ }
244
+ async handleInitialRequest(opcode, buffer, rinfo) {
245
+ let offset = 2;
246
+ const filename = this.readString(buffer, offset);
247
+ offset += filename.length + 1;
248
+ const mode = this.readString(buffer, offset);
249
+ offset += mode.length + 1;
250
+ // 解析选项
251
+ const options = {};
252
+ while (offset < buffer.length) {
253
+ const option = this.readString(buffer, offset);
254
+ offset += option.length + 1;
255
+ const value = this.readString(buffer, offset);
256
+ offset += value.length + 1;
257
+ options[option.toLowerCase()] = value;
258
+ }
259
+ // 验证请求
260
+ if (!filename || !mode) {
261
+ await this.sendError(rinfo, ERROR_CODES.ILLEGAL_OPERATION, 'Invalid request');
262
+ return;
263
+ }
264
+ if (mode !== 'netascii' && mode !== 'octet') {
265
+ await this.sendError(rinfo, ERROR_CODES.ILLEGAL_OPERATION, 'Unsupported mode');
266
+ return;
267
+ }
268
+ // 创建会话
269
+ const sessionId = `${rinfo.address}:${rinfo.port}`;
270
+ const rootDir = this.config.rootDir;
271
+ const imagesRootDir = path.join(rootDir, 'images_agent');
272
+ // 路径解析策略:
273
+ // - RRQ(读):images_agent 优先,其次 rootDir;两处同名则报错(冲突)
274
+ // - WRQ(写):只允许写 rootDir,且禁止写入 images_agent 子树
275
+ const operation = opcode === OPCODES.RRQ ? 'read' : 'write';
276
+ let resolvedPath = '';
277
+ if (operation === 'read') {
278
+ const pvImages = await this.validateSecurePath(filename, imagesRootDir);
279
+ const pvRoot = await this.validateSecurePath(filename, rootDir);
280
+ if (!pvImages.valid && !pvRoot.valid) {
281
+ await this.sendError(rinfo, ERROR_CODES.ACCESS_VIOLATION, 'Access denied: path traversal detected');
282
+ return;
283
+ }
284
+ const exists = async (p) => {
285
+ const r = await this.validateFileType(p, true);
286
+ return r.valid;
287
+ };
288
+ const hitImages = pvImages.valid ? await exists(pvImages.normalizedPath) : false;
289
+ const hitRoot = pvRoot.valid ? await exists(pvRoot.normalizedPath) : false;
290
+ if (hitImages && hitRoot) {
291
+ try {
292
+ const logger = getAgentLogger();
293
+ logger.write(LogLevel.WARN, 'tftp.rrq.path_conflict', {
294
+ filename,
295
+ imagesPath: pvImages.normalizedPath,
296
+ rootPath: pvRoot.normalizedPath,
297
+ remote: `${rinfo.address}:${rinfo.port}`,
298
+ });
299
+ }
300
+ catch {
301
+ // ignore logging failures
302
+ }
303
+ await this.sendError(rinfo, ERROR_CODES.ACCESS_VIOLATION, `Path conflict: file exists in both images_agent and rootDir. filename=${filename} images=${pvImages.normalizedPath} root=${pvRoot.normalizedPath}`);
304
+ return;
305
+ }
306
+ if (hitImages)
307
+ resolvedPath = pvImages.normalizedPath;
308
+ else if (hitRoot)
309
+ resolvedPath = pvRoot.normalizedPath;
310
+ else {
311
+ await this.sendError(rinfo, ERROR_CODES.FILE_NOT_FOUND, 'File not found');
312
+ return;
313
+ }
314
+ }
315
+ else {
316
+ const pv = await this.validateSecurePath(filename, rootDir);
317
+ if (!pv.valid) {
318
+ await this.sendError(rinfo, ERROR_CODES.ACCESS_VIOLATION, 'Access denied: path traversal detected');
319
+ return;
320
+ }
321
+ // 禁止写入 images_agent 子树(避免污染固件资产目录)
322
+ const normalizedTarget = path.resolve(pv.normalizedPath).toLowerCase();
323
+ const normalizedImagesRoot = path.resolve(imagesRootDir).toLowerCase();
324
+ const imagesPrefix = normalizedImagesRoot.endsWith(path.sep)
325
+ ? normalizedImagesRoot
326
+ : normalizedImagesRoot + path.sep;
327
+ if (normalizedTarget === normalizedImagesRoot || normalizedTarget.startsWith(imagesPrefix)) {
328
+ try {
329
+ const logger = getAgentLogger();
330
+ logger.write(LogLevel.WARN, 'tftp.wrq.images_agent_denied', {
331
+ filename,
332
+ targetPath: pv.normalizedPath,
333
+ remote: `${rinfo.address}:${rinfo.port}`,
334
+ });
335
+ }
336
+ catch {
337
+ // ignore logging failures
338
+ }
339
+ await this.sendError(rinfo, ERROR_CODES.ACCESS_VIOLATION, 'Access denied: writes to images_agent are not allowed');
340
+ return;
341
+ }
342
+ resolvedPath = pv.normalizedPath;
343
+ }
344
+ const session = {
345
+ tid: { port: rinfo.port, address: rinfo.address },
346
+ socket: dgram.createSocket('udp4'),
347
+ filename,
348
+ filepath: resolvedPath,
349
+ mode: mode,
350
+ operation,
351
+ blockNumber: operation === 'read' ? 1 : 0,
352
+ options: {
353
+ ...options, // 只包含客户端明确请求的选项,不自动添加blksize
354
+ },
355
+ createdAt: Date.now(),
356
+ lastActivity: Date.now(),
357
+ };
358
+ await this.bindSessionSocket(session, sessionId);
359
+ this.sessions.set(sessionId, session);
360
+ this.stats.activeConnections++;
361
+ try {
362
+ if (opcode === OPCODES.RRQ) {
363
+ await this.handleReadRequest(session);
364
+ }
365
+ else {
366
+ await this.handleWriteRequest(session);
367
+ }
368
+ }
369
+ catch (err) {
370
+ this.closeSession(sessionId);
371
+ throw err;
372
+ }
373
+ }
374
+ async bindSessionSocket(session, sessionId) {
375
+ const address = this.config.host || '0.0.0.0';
376
+ await new Promise((resolve, reject) => {
377
+ const onError = (err) => {
378
+ session.socket.off('error', onError);
379
+ reject(err);
380
+ };
381
+ session.socket.once('error', onError);
382
+ session.socket.bind({ address, port: 0 }, () => {
383
+ session.socket.off('error', onError);
384
+ session.socket.on('message', async (msg, info) => {
385
+ if (msg.length < 2)
386
+ return;
387
+ const opcode = msg.readUInt16BE(0);
388
+ if (opcode === OPCODES.ACK || opcode === OPCODES.DATA || opcode === OPCODES.ERROR) {
389
+ try {
390
+ await this.handleTransferMessage(sessionId, opcode, msg, info);
391
+ }
392
+ catch (err) {
393
+ this.emit('error', err);
394
+ }
395
+ }
396
+ });
397
+ session.socket.on('error', (err) => this.emit('error', err));
398
+ resolve();
399
+ });
400
+ });
401
+ }
402
+ async handleReadRequest(session) {
403
+ try {
404
+ // 1. 跨平台文件类型检查
405
+ const fileValidation = await this.validateFileType(session.filepath, true);
406
+ if (!fileValidation.valid) {
407
+ await this.sendErrorToSession(session, ERROR_CODES.FILE_NOT_FOUND, fileValidation.error || 'Invalid file path');
408
+ return;
409
+ }
410
+ // 2. 打开文件
411
+ session.fileHandle = await fs.open(session.filepath, 'r');
412
+ // 3. 如果有选项协商,发送OACK
413
+ if (Object.keys(session.options).length > 0) {
414
+ const oack = this.createOackPacket(session.options);
415
+ await this.sendPacketWithRetry(session, oack, 'send OACK');
416
+ session.blockNumber = 0; // 等待ACK后从block 1开始
417
+ }
418
+ else {
419
+ // 直接发送第一个数据块
420
+ await this.sendNextDataBlock(session);
421
+ }
422
+ }
423
+ catch (err) {
424
+ // 跨平台错误处理
425
+ let errorCode = ERROR_CODES.FILE_NOT_FOUND;
426
+ let errorMessage = 'File access error';
427
+ if (err.code === 'ENOENT') {
428
+ errorMessage = 'File not found';
429
+ }
430
+ else if (err.code === 'EACCES' || err.code === 'EPERM') {
431
+ errorMessage = 'Access denied';
432
+ }
433
+ else if (err.code === 'EISDIR') {
434
+ errorMessage = 'Path is a directory, not a file';
435
+ }
436
+ else if (err.code === 'EMFILE' || err.code === 'ENFILE') {
437
+ errorMessage = 'Too many open files';
438
+ }
439
+ await this.sendErrorToSession(session, errorCode, errorMessage);
440
+ throw err;
441
+ }
442
+ }
443
+ async handleWriteRequest(session) {
444
+ try {
445
+ // 1. 检查父目录是否存在且可写
446
+ const parentDir = path.dirname(session.filepath);
447
+ try {
448
+ await fs.access(parentDir, fs.constants.W_OK);
449
+ }
450
+ catch {
451
+ await this.sendErrorToSession(session, ERROR_CODES.ACCESS_VIOLATION, 'Parent directory does not exist or is not writable');
452
+ return;
453
+ }
454
+ // 2. 检查目标路径是否已存在且为目录
455
+ try {
456
+ const fileValidation = await this.validateFileType(session.filepath, false);
457
+ if (!fileValidation.valid && fileValidation.error?.includes('directory')) {
458
+ await this.sendErrorToSession(session, ERROR_CODES.FILE_NOT_FOUND, fileValidation.error);
459
+ return;
460
+ }
461
+ // 如果文件存在且是普通文件,检查是否允许覆盖
462
+ if (fileValidation.valid) {
463
+ await this.sendErrorToSession(session, ERROR_CODES.FILE_EXISTS, 'File already exists');
464
+ return;
465
+ }
466
+ }
467
+ catch (err) {
468
+ // 文件不存在是正常情况,继续处理
469
+ if (err.code !== 'ENOENT') {
470
+ let errorMessage = 'Access denied';
471
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
472
+ errorMessage = 'Permission denied';
473
+ }
474
+ else if (err.code === 'EISDIR') {
475
+ await this.sendErrorToSession(session, ERROR_CODES.FILE_EXISTS, 'Path is a directory');
476
+ return;
477
+ }
478
+ await this.sendErrorToSession(session, ERROR_CODES.ACCESS_VIOLATION, errorMessage);
479
+ return;
480
+ }
481
+ }
482
+ // 3. 创建文件
483
+ session.fileHandle = await fs.open(session.filepath, 'w');
484
+ // 4. 如果有选项协商,发送OACK
485
+ if (Object.keys(session.options).length > 0) {
486
+ const oack = this.createOackPacket(session.options);
487
+ await this.sendPacketWithRetry(session, oack, 'send OACK');
488
+ }
489
+ else {
490
+ // 发送ACK block 0
491
+ const ack = this.createAckPacket(0);
492
+ await this.sendPacketWithRetry(session, ack, 'send ACK block 0');
493
+ }
494
+ }
495
+ catch (err) {
496
+ // 跨平台错误处理
497
+ let errorCode = ERROR_CODES.ACCESS_VIOLATION;
498
+ let errorMessage = 'Access denied';
499
+ if (err.code === 'ENOENT') {
500
+ errorMessage = 'Parent directory does not exist';
501
+ }
502
+ else if (err.code === 'EACCES' || err.code === 'EPERM') {
503
+ errorMessage = 'Permission denied';
504
+ }
505
+ else if (err.code === 'EISDIR') {
506
+ await this.sendErrorToSession(session, ERROR_CODES.FILE_EXISTS, 'Path is a directory, cannot write to directory');
507
+ throw err;
508
+ }
509
+ else if (err.code === 'ENOSPC') {
510
+ await this.sendErrorToSession(session, ERROR_CODES.DISK_FULL, 'Disk full');
511
+ throw err;
512
+ }
513
+ else if (err.code === 'EMFILE' || err.code === 'ENFILE') {
514
+ errorMessage = 'Too many open files';
515
+ }
516
+ await this.sendErrorToSession(session, errorCode, errorMessage);
517
+ throw err;
518
+ }
519
+ }
520
+ async handleTransferMessage(sessionId, opcode, buffer, rinfo) {
521
+ const session = this.sessions.get(sessionId);
522
+ if (!session || session.tid.port !== rinfo.port || session.tid.address !== rinfo.address) {
523
+ await this.sendError(rinfo, ERROR_CODES.UNKNOWN_TID, 'Unknown transfer ID');
524
+ return;
525
+ }
526
+ session.lastActivity = Date.now();
527
+ switch (opcode) {
528
+ case OPCODES.ACK:
529
+ await this.handleAck(session, buffer);
530
+ break;
531
+ case OPCODES.DATA:
532
+ await this.handleData(session, buffer);
533
+ break;
534
+ case OPCODES.ERROR:
535
+ await this.handleError(session, buffer);
536
+ break;
537
+ }
538
+ }
539
+ async handleAck(session, buffer) {
540
+ const blockNumber = buffer.readUInt16BE(2);
541
+ if (session.operation === 'read') {
542
+ if (blockNumber === session.blockNumber) {
543
+ // 收到期望的 ACK 后推进到下一块
544
+ session.blockNumber++;
545
+ if (session.pendingEof) {
546
+ this.closeSession(`${session.tid.address}:${session.tid.port}`);
547
+ return;
548
+ }
549
+ await this.sendNextDataBlock(session);
550
+ }
551
+ else {
552
+ // 重复ACK,重新发送数据
553
+ if (session.lastData) {
554
+ await this.sendPacket(session, session.lastData);
555
+ }
556
+ }
557
+ }
558
+ }
559
+ async handleData(session, buffer) {
560
+ if (session.operation !== 'write') {
561
+ await this.sendErrorToSession(session, ERROR_CODES.ILLEGAL_OPERATION, 'Invalid operation');
562
+ return;
563
+ }
564
+ const blockNumber = buffer.readUInt16BE(2);
565
+ const data = buffer.slice(4);
566
+ if (blockNumber === session.blockNumber + 1) {
567
+ // 写入数据
568
+ if (session.fileHandle) {
569
+ await session.fileHandle.write(data);
570
+ }
571
+ session.blockNumber = blockNumber;
572
+ // 发送ACK
573
+ const ack = this.createAckPacket(blockNumber);
574
+ await this.sendPacket(session, ack);
575
+ // 如果数据小于块大小,传输完成
576
+ if (data.length < this.getBlockSize(session)) {
577
+ this.closeSession(`${session.tid.address}:${session.tid.port}`);
578
+ }
579
+ }
580
+ }
581
+ async handleError(session, buffer) {
582
+ const errorCode = buffer.readUInt16BE(2);
583
+ const message = this.readString(buffer, 4);
584
+ this.emit('sessionError', {
585
+ session,
586
+ errorCode,
587
+ message,
588
+ });
589
+ this.closeSession(`${session.tid.address}:${session.tid.port}`);
590
+ }
591
+ async sendNextDataBlock(session) {
592
+ if (!session.fileHandle) {
593
+ throw new Error('No file handle');
594
+ }
595
+ if (session.blockNumber < 1) {
596
+ session.blockNumber = 1; // OACK 之后收到 ACK block 0,首个数据块应从 1 开始
597
+ }
598
+ const blockSize = this.getBlockSize(session);
599
+ const buffer = Buffer.allocUnsafe(blockSize);
600
+ // 修复:TFTP块号从1开始,但文件位置应从0开始,所以需要减1
601
+ const filePosition = (session.blockNumber - 1) * blockSize;
602
+ const { bytesRead } = await session.fileHandle.read(buffer, 0, blockSize, filePosition);
603
+ // 默认认为还未到 EOF
604
+ session.pendingEof = false;
605
+ if (bytesRead === 0) {
606
+ // 文件结束,发送空数据块表示EOF
607
+ const packet = this.createDataPacket(session.blockNumber, Buffer.alloc(0));
608
+ session.lastData = packet;
609
+ session.pendingEof = true;
610
+ await this.sendPacketWithRetry(session, packet, `send empty DATA block ${session.blockNumber}`);
611
+ return;
612
+ }
613
+ const data = buffer.slice(0, bytesRead);
614
+ const packet = this.createDataPacket(session.blockNumber, data);
615
+ session.lastData = packet;
616
+ await this.sendPacketWithRetry(session, packet, `send DATA block ${session.blockNumber}`);
617
+ // 如果数据小于块大小,下一次收到 ACK 后结束
618
+ session.pendingEof = bytesRead < blockSize;
619
+ }
620
+ createDataPacket(blockNumber, data) {
621
+ const packet = Buffer.allocUnsafe(4 + data.length);
622
+ packet.writeUInt16BE(OPCODES.DATA, 0);
623
+ packet.writeUInt16BE(blockNumber, 2);
624
+ data.copy(packet, 4);
625
+ return packet;
626
+ }
627
+ createAckPacket(blockNumber) {
628
+ const packet = Buffer.allocUnsafe(4);
629
+ packet.writeUInt16BE(OPCODES.ACK, 0);
630
+ packet.writeUInt16BE(blockNumber, 2);
631
+ return packet;
632
+ }
633
+ createOackPacket(options) {
634
+ const optionsList = [];
635
+ for (const [key, value] of Object.entries(options)) {
636
+ optionsList.push(key, value);
637
+ }
638
+ const optionsData = Buffer.from(optionsList.join('\0') + '\0');
639
+ const packet = Buffer.allocUnsafe(2 + optionsData.length);
640
+ packet.writeUInt16BE(OPCODES.OACK, 0);
641
+ optionsData.copy(packet, 2);
642
+ return packet;
643
+ }
644
+ createErrorPacket(code, message) {
645
+ const messageData = Buffer.from(message + '\0');
646
+ const packet = Buffer.allocUnsafe(4 + messageData.length);
647
+ packet.writeUInt16BE(OPCODES.ERROR, 0);
648
+ packet.writeUInt16BE(code, 2);
649
+ messageData.copy(packet, 4);
650
+ return packet;
651
+ }
652
+ async sendPacket(session, packet) {
653
+ return new Promise((resolve, reject) => {
654
+ session.socket.send(packet, session.tid.port, session.tid.address, (err) => {
655
+ if (err) {
656
+ reject(err);
657
+ }
658
+ else {
659
+ resolve();
660
+ }
661
+ });
662
+ });
663
+ }
664
+ async sendPacketWithRetry(session, packet, operation = 'send packet') {
665
+ let retryCount = 0;
666
+ let backoffMs = SERVER_RETRY_CONFIG.INITIAL_BACKOFF_MS;
667
+ while (retryCount < SERVER_RETRY_CONFIG.MAX_RETRIES) {
668
+ try {
669
+ await this.sendPacket(session, packet);
670
+ return; // 发送成功
671
+ }
672
+ catch (err) {
673
+ retryCount++;
674
+ if (retryCount >= SERVER_RETRY_CONFIG.MAX_RETRIES) {
675
+ this.emit('error', new Error(`${operation} failed after ${SERVER_RETRY_CONFIG.MAX_RETRIES} retries: ${err.message}`));
676
+ throw err;
677
+ }
678
+ // 指数退避
679
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
680
+ backoffMs = Math.min(backoffMs * 2, SERVER_RETRY_CONFIG.MAX_BACKOFF_MS);
681
+ }
682
+ }
683
+ }
684
+ async sendError(rinfo, code, message) {
685
+ if (!this.socket)
686
+ return;
687
+ const packet = this.createErrorPacket(code, message);
688
+ return new Promise((resolve, reject) => {
689
+ this.socket.send(packet, rinfo.port, rinfo.address, (err) => {
690
+ if (err) {
691
+ reject(err);
692
+ }
693
+ else {
694
+ resolve();
695
+ }
696
+ });
697
+ });
698
+ }
699
+ async sendErrorToSession(session, code, message) {
700
+ await this.sendError(session.tid, code, message);
701
+ this.closeSession(`${session.tid.address}:${session.tid.port}`);
702
+ }
703
+ closeSession(sessionId) {
704
+ const session = this.sessions.get(sessionId);
705
+ if (session) {
706
+ try {
707
+ if (session.fileHandle) {
708
+ session.fileHandle.close();
709
+ }
710
+ session.socket.close();
711
+ }
712
+ catch (error) {
713
+ // 忽略关闭错误
714
+ }
715
+ this.sessions.delete(sessionId);
716
+ this.stats.activeConnections--;
717
+ this.emit('sessionClosed', session);
718
+ }
719
+ }
720
+ readString(buffer, offset) {
721
+ let end = offset;
722
+ while (end < buffer.length && buffer[end] !== 0) {
723
+ end++;
724
+ }
725
+ return buffer.toString('ascii', offset, end);
726
+ }
727
+ getBlockSize(session) {
728
+ const blksize = parseInt(session.options['blksize'] || (this.config.blockSize || 512).toString());
729
+ return Math.min(Math.max(blksize, 8), 65464); // RFC 2348限制
730
+ }
731
+ }