372 lines
21 KiB
TypeScript
372 lines
21 KiB
TypeScript
import { Client, SFTPWrapper, Stats } from 'ssh2';
|
|
import { WebSocket } from 'ws';
|
|
import { ClientState } from '../websocket'; // 导入统一的 ClientState
|
|
|
|
// 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配)
|
|
// Note: This interface seems out of place here, but keeping it for now as it was in the original file.
|
|
// Ideally, it should be in a shared types file.
|
|
interface ServerStatus {
|
|
cpuPercent?: number;
|
|
memPercent?: number;
|
|
memUsed?: number; // MB
|
|
memTotal?: number; // MB
|
|
swapPercent?: number;
|
|
swapUsed?: number; // MB
|
|
swapTotal?: number; // MB
|
|
diskPercent?: number;
|
|
diskUsed?: number; // KB
|
|
diskTotal?: number; // KB
|
|
cpuModel?: string;
|
|
netRxRate?: number; // Bytes per second
|
|
netTxRate?: number; // Bytes per second
|
|
netInterface?: string;
|
|
osName?: string;
|
|
loadAvg?: number[]; // 系统平均负载 [1min, 5min, 15min]
|
|
timestamp: number; // 状态获取时间戳
|
|
}
|
|
|
|
// Interface for parsed network stats - Also seems out of place here.
|
|
interface NetworkStats {
|
|
[interfaceName: string]: {
|
|
rx_bytes: number;
|
|
tx_bytes: number;
|
|
}
|
|
}
|
|
|
|
// Note: These constants seem related to StatusMonitorService, not SftpService.
|
|
const DEFAULT_POLLING_INTERVAL = 1000;
|
|
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
|
|
|
|
export class SftpService {
|
|
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
|
|
|
|
constructor(clientStates: Map<string, ClientState>) {
|
|
this.clientStates = clientStates;
|
|
}
|
|
|
|
/**
|
|
* 初始化 SFTP 会话
|
|
* @param sessionId 会话 ID
|
|
*/
|
|
async initializeSftpSession(sessionId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sshClient || state.sftp) {
|
|
console.warn(`[SFTP] 无法为会话 ${sessionId} 初始化 SFTP:状态无效、SSH客户端不存在或 SFTP 已初始化。`);
|
|
return;
|
|
}
|
|
if (!state.sshClient) {
|
|
console.error(`[SFTP] 会话 ${sessionId} 的 SSH 客户端不存在,无法初始化 SFTP。`);
|
|
return;
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
state.sshClient.sftp((err, sftpInstance) => {
|
|
if (err) {
|
|
console.error(`[SFTP] 为会话 ${sessionId} 初始化 SFTP 会话失败:`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId: state.dbConnectionId, message: 'SFTP 初始化失败' } }));
|
|
reject(err);
|
|
} else {
|
|
console.log(`[SFTP] 为会话 ${sessionId} 初始化 SFTP 会话成功。`);
|
|
state.sftp = sftpInstance;
|
|
state.ws.send(JSON.stringify({ type: 'sftp_ready', payload: { connectionId: state.dbConnectionId } }));
|
|
sftpInstance.on('end', () => {
|
|
console.log(`[SFTP] 会话 ${sessionId} 的 SFTP 会话已结束。`);
|
|
if (state) state.sftp = undefined;
|
|
});
|
|
sftpInstance.on('close', () => {
|
|
console.log(`[SFTP] 会话 ${sessionId} 的 SFTP 会话已关闭。`);
|
|
if (state) state.sftp = undefined;
|
|
});
|
|
sftpInstance.on('error', (sftpErr: Error) => {
|
|
console.error(`[SFTP] 会话 ${sessionId} 的 SFTP 会话出错:`, sftpErr);
|
|
if (state) state.sftp = undefined;
|
|
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId: state.dbConnectionId, message: 'SFTP 会话错误' } }));
|
|
});
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 清理 SFTP 会话
|
|
* @param sessionId 会话 ID
|
|
*/
|
|
cleanupSftpSession(sessionId: string): void {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (state?.sftp) {
|
|
console.log(`[SFTP] 正在清理 ${sessionId} 的 SFTP 会话...`);
|
|
state.sftp.end();
|
|
state.sftp = undefined;
|
|
}
|
|
}
|
|
|
|
// --- SFTP 操作方法 ---
|
|
|
|
/** 读取目录内容 */
|
|
async readdir(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readdir (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received readdir request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.readdir(path, (err, list) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] readdir ${path} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: `读取目录失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
const files = list.map((item) => ({
|
|
filename: item.filename,
|
|
longname: item.longname,
|
|
attrs: {
|
|
size: item.attrs.size, uid: item.attrs.uid, gid: item.attrs.gid, mode: item.attrs.mode,
|
|
atime: item.attrs.atime * 1000, mtime: item.attrs.mtime * 1000,
|
|
isDirectory: item.attrs.isDirectory(), isFile: item.attrs.isFile(), isSymbolicLink: item.attrs.isSymbolicLink(),
|
|
}
|
|
}));
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readdir:success', path: path, payload: files, requestId: requestId }));
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] readdir ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readdir:error', path: path, payload: `读取目录时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 获取文件/目录状态信息 */
|
|
async stat(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 stat (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received stat request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.lstat(path, (err, stats: Stats) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] stat ${path} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: `获取状态失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
const fileStats = {
|
|
size: stats.size, uid: stats.uid, gid: stats.gid, mode: stats.mode,
|
|
atime: stats.atime * 1000, mtime: stats.mtime * 1000,
|
|
isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink(),
|
|
};
|
|
// Send specific success type
|
|
state.ws.send(JSON.stringify({ type: 'sftp:stat:success', path: path, payload: fileStats, requestId: requestId }));
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] stat ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:stat:error', path: path, payload: `获取状态时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 读取文件内容 */
|
|
async readFile(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
const readStream = state.sftp.createReadStream(path);
|
|
let fileData = Buffer.alloc(0);
|
|
let errorOccurred = false;
|
|
|
|
readStream.on('data', (chunk: Buffer) => { fileData = Buffer.concat([fileData, chunk]); });
|
|
readStream.on('error', (err: Error) => {
|
|
if (errorOccurred) return; errorOccurred = true;
|
|
console.error(`[SFTP ${sessionId}] readFile ${path} stream error (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件流错误: ${err.message}`, requestId: requestId }));
|
|
});
|
|
readStream.on('end', () => {
|
|
if (!errorOccurred) {
|
|
console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readfile:success', path: path, payload: { content: fileData.toString('base64'), encoding: 'base64' }, requestId: requestId }));
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] readFile ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 写入文件内容 */
|
|
async writefile(sessionId: string, path: string, data: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 writefile (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
const buffer = Buffer.from(data, 'utf8');
|
|
console.debug(`[SFTP ${sessionId}] Creating write stream for ${path} (ID: ${requestId})`);
|
|
const writeStream = state.sftp.createWriteStream(path);
|
|
let errorOccurred = false;
|
|
|
|
writeStream.on('error', (err: Error) => {
|
|
if (errorOccurred) return; // Prevent sending multiple errors
|
|
errorOccurred = true;
|
|
console.error(`[SFTP ${sessionId}] writefile ${path} stream error (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `写入文件流错误: ${err.message}`, requestId: requestId }));
|
|
});
|
|
|
|
// Listen for the 'close' event which indicates the stream has finished writing and the file descriptor is closed.
|
|
writeStream.on('close', () => {
|
|
if (!errorOccurred) {
|
|
console.log(`[SFTP ${sessionId}] writefile ${path} stream closed successfully (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:writefile:success', path: path, requestId: requestId }));
|
|
}
|
|
});
|
|
|
|
console.debug(`[SFTP ${sessionId}] Writing ${buffer.length} bytes to ${path} (ID: ${requestId})`);
|
|
writeStream.end(buffer); // Start writing and close the stream afterwards
|
|
console.debug(`[SFTP ${sessionId}] writefile ${path} end() called (ID: ${requestId})`);
|
|
|
|
// Success message is now sent in the 'close' event handler
|
|
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] writefile ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `写入文件时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 创建目录 */
|
|
async mkdir(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 mkdir (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received mkdir request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.mkdir(path, (err) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] mkdir ${path} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: `创建目录失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
console.log(`[SFTP ${sessionId}] mkdir ${path} success (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:success', path: path, requestId: requestId })); // Send specific success type
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] mkdir ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:mkdir:error', path: path, payload: `创建目录时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 删除空目录 */
|
|
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received rmdir request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.rmdir(path, (err) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] rmdir ${path} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
console.log(`[SFTP ${sessionId}] rmdir ${path} success (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId })); // Send specific success type
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] rmdir ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 删除文件 */
|
|
async unlink(sessionId: string, path: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 unlink (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received unlink request for ${path} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.unlink(path, (err) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] unlink ${path} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: `删除文件失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
console.log(`[SFTP ${sessionId}] unlink ${path} success (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:unlink:success', path: path, requestId: requestId })); // Send specific success type
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] unlink ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:unlink:error', path: path, payload: `删除文件时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 重命名/移动文件或目录 */
|
|
async rename(sessionId: string, oldPath: string, newPath: string, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 rename (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received rename request ${oldPath} -> ${newPath} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.rename(oldPath, newPath, (err) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: `重命名/移动失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
console.log(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} success (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rename:success', oldPath: oldPath, newPath: newPath, requestId: requestId })); // Send specific success type
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] rename ${oldPath} -> ${newPath} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:rename:error', oldPath: oldPath, newPath: newPath, payload: `重命名/移动时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
/** 修改文件/目录权限 */
|
|
async chmod(sessionId: string, path: string, mode: number, requestId: string): Promise<void> {
|
|
const state = this.clientStates.get(sessionId);
|
|
if (!state || !state.sftp) {
|
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 chmod (ID: ${requestId})`);
|
|
state?.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
|
|
return;
|
|
}
|
|
console.debug(`[SFTP ${sessionId}] Received chmod request for ${path} to ${mode.toString(8)} (ID: ${requestId})`);
|
|
try {
|
|
state.sftp.chmod(path, mode, (err) => {
|
|
if (err) {
|
|
console.error(`[SFTP ${sessionId}] chmod ${path} to ${mode.toString(8)} failed (ID: ${requestId}):`, err);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: `修改权限失败: ${err.message}`, requestId: requestId }));
|
|
} else {
|
|
console.log(`[SFTP ${sessionId}] chmod ${path} to ${mode.toString(8)} success (ID: ${requestId})`);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:chmod:success', path: path, requestId: requestId })); // Send specific success type
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
console.error(`[SFTP ${sessionId}] chmod ${path} caught unexpected error (ID: ${requestId}):`, error);
|
|
state.ws.send(JSON.stringify({ type: 'sftp:chmod:error', path: path, payload: `修改权限时发生意外错误: ${error.message}`, requestId: requestId }));
|
|
}
|
|
}
|
|
|
|
// TODO: Implement file upload/download logic with progress reporting
|
|
// async uploadFile(...)
|
|
// async downloadFile(...)
|
|
|
|
}
|