This commit is contained in:
Baobhan Sith
2025-04-14 22:51:05 +08:00
parent 286492fc63
commit a974b8b1d9
49 changed files with 13954 additions and 0 deletions
@@ -0,0 +1,82 @@
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { getDb } from '../database';
const db = getDb(); // 获取数据库实例
// 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中)
interface User {
id: number;
username: string;
hashed_password: string; // 数据库中存储的哈希密码
// 其他可能的字段...
}
/**
* 处理用户登录请求 (POST /api/v1/auth/login)
*/
export const login = async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body; // 从请求体获取用户名和密码
// 基础输入验证
if (!username || !password) {
res.status(400).json({ message: '用户名和密码不能为空。' });
return;
}
try {
// 根据用户名查询用户
const user = await new Promise<User | undefined>((resolve, reject) => {
// 从 users 表中选择需要的字段
db.get('SELECT id, username, hashed_password FROM users WHERE username = ?', [username], (err, row: User) => {
if (err) {
console.error('查询用户时出错:', err.message);
// 返回通用错误信息,避免泄露数据库细节
return reject(new Error('数据库查询失败'));
}
resolve(row); // 如果找到用户,则 resolve 用户对象;否则 resolve undefined
});
});
// 如果未找到用户
if (!user) {
console.log(`登录尝试失败: 用户未找到 - ${username}`);
// 返回 401 未授权状态码和通用错误信息
res.status(401).json({ message: '无效的凭据。' });
return;
}
// 比较用户提交的密码和数据库中存储的哈希密码
const isMatch = await bcrypt.compare(password, user.hashed_password);
// 如果密码不匹配
if (!isMatch) {
console.log(`登录尝试失败: 密码错误 - ${username}`);
// 返回 401 未授权状态码和通用错误信息
res.status(401).json({ message: '无效的凭据。' });
return;
}
// --- 认证成功 ---
console.log(`登录成功: ${username}`);
// 在 session 中存储用户信息
req.session.userId = user.id;
req.session.username = user.username;
// 返回成功响应 (可以包含一些非敏感的用户信息)
res.status(200).json({
message: '登录成功。',
user: { id: user.id, username: user.username } // 不返回密码哈希
});
} catch (error) {
// 捕获数据库查询或其他异步操作中的错误
console.error('登录时出错:', error);
res.status(500).json({ message: '登录过程中发生内部服务器错误。' });
}
};
// 其他认证相关函数的占位符 (登出, 管理员设置等)
// export const logout = ...
// export const setupAdmin = ...
@@ -0,0 +1,17 @@
import { Request, Response, NextFunction } from 'express';
/**
* 认证中间件:检查用户是否已登录 (通过 session 中的 userId 判断)
*/
export const isAuthenticated = (req: Request, res: Response, next: NextFunction): void => {
if (req.session && req.session.userId) {
// 用户已登录,继续处理请求
next();
} else {
// 用户未登录,返回 401 未授权错误
res.status(401).json({ message: '未授权:请先登录。' });
}
};
// 未来可以添加基于角色的授权中间件等
// export const isAdmin = ...
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import { login } from './auth.controller';
const router = Router();
// POST /api/v1/auth/login - 用户登录接口
router.post('/login', login);
// 未来可以添加的其他认证相关路由
// router.post('/logout', logout); // 登出
// router.get('/status', getStatus); // 获取当前登录状态
// router.post('/setup', setupAdmin); // 用于首次创建管理员账号的接口
export default router;
@@ -0,0 +1,116 @@
import { Request, Response } from 'express';
import { Statement } from 'sqlite3'; // 引入 Statement 类型
import { getDb } from '../database';
import { encrypt } from '../utils/crypto'; // 引入加密函数
const db = getDb();
// 连接数据结构 (仅用于类型提示,不包含敏感信息)
interface ConnectionInfo {
id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: 'password'; // MVP 仅支持密码
created_at: number;
updated_at: number;
last_connected_at: number | null;
}
/**
* 创建新连接 (POST /api/v1/connections)
*/
export const createConnection = async (req: Request, res: Response): Promise<void> => {
const { name, host, port = 22, username, password } = req.body;
const auth_method = 'password'; // MVP 强制为 password
const userId = req.session.userId; // 从会话获取用户 ID
// 输入验证 (基础)
if (!name || !host || !username || !password) {
res.status(400).json({ message: '缺少必要的连接信息 (name, host, username, password)。' });
return;
}
if (typeof port !== 'number' || port <= 0 || port > 65535) {
res.status(400).json({ message: '端口号无效。' });
return;
}
try {
// 加密密码
const encryptedPassword = encrypt(password);
const now = Math.floor(Date.now() / 1000); // 当前 Unix 时间戳 (秒)
// 插入数据库
const result = await new Promise<{ lastID: number }>((resolve, reject) => {
const stmt = db.prepare(
`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
// 注意:这里没有存储 userId,因为 MVP 只有一个用户。如果未来支持多用户,需要添加 user_id 字段。
// 使用 function 关键字以保留正确的 this 上下文,并为 err 和 this 添加类型注解
stmt.run(name, host, port, username, auth_method, encryptedPassword, now, now, function (this: Statement, err: Error | null) {
if (err) {
console.error('插入连接时出错:', err.message);
return reject(new Error('创建连接失败'));
}
// this.lastID 包含新插入行的 ID
// 使用类型断言 (as any) 来解决 TS 类型检查问题
resolve({ lastID: (this as any).lastID });
});
stmt.finalize(); // 完成语句执行
});
// 返回成功响应
res.status(201).json({
message: '连接创建成功。',
connection: {
id: result.lastID,
name, host, port, username, auth_method,
created_at: now, updated_at: now, last_connected_at: null
}
});
} catch (error) {
console.error('创建连接时发生错误:', error);
res.status(500).json({ message: '创建连接时发生内部服务器错误。' });
}
};
/**
* 获取连接列表 (GET /api/v1/connections)
*/
export const getConnections = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId; // 虽然 MVP 只有一个用户,但保留以备将来使用
try {
// 查询数据库,排除敏感字段 encrypted_password
// 注意:如果未来支持多用户,需要添加 WHERE user_id = ? 条件
const connections = await new Promise<ConnectionInfo[]>((resolve, reject) => {
db.all(
`SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at
FROM connections
ORDER BY name ASC`, // 按名称排序
(err, rows: ConnectionInfo[]) => {
if (err) {
console.error('查询连接列表时出错:', err.message);
return reject(new Error('获取连接列表失败'));
}
resolve(rows);
}
);
});
res.status(200).json(connections);
} catch (error) {
console.error('获取连接列表时发生错误:', error);
res.status(500).json({ message: '获取连接列表时发生内部服务器错误。' });
}
};
// 其他控制器函数的占位符
// export const getConnectionById = ...
// export const updateConnection = ...
// export const deleteConnection = ...
// export const testConnection = ...
@@ -0,0 +1,22 @@
import { Router } from 'express';
import { isAuthenticated } from '../auth/auth.middleware'; // 引入认证中间件
import { createConnection, getConnections } from './connections.controller';
const router = Router();
// 应用认证中间件到所有 /connections 路由
router.use(isAuthenticated); // 恢复认证检查
// GET /api/v1/connections - 获取连接列表
router.get('/', getConnections);
// POST /api/v1/connections - 创建新连接
router.post('/', createConnection);
// 未来可以添加其他路由,如获取单个连接、更新、删除、测试连接等
// router.get('/:id', getConnectionById);
// router.put('/:id', updateConnection);
// router.delete('/:id', deleteConnection);
// router.post('/:id/test', testConnection);
export default router;
+59
View File
@@ -0,0 +1,59 @@
import sqlite3 from 'sqlite3';
import path from 'path';
import fs from 'fs';
// 数据库文件路径 (相对于 backend 项目根目录)
const dbDir = path.resolve(__dirname, '../../data'); // 使用 '../../data' 定位到 monorepo 根目录下的 data 文件夹
const dbPath = path.join(dbDir, 'nexus-terminal.db');
// 确保数据库目录存在
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`数据库目录已创建: ${dbDir}`);
}
// 使用详细模式以获得更丰富的错误信息
const verboseSqlite3 = sqlite3.verbose();
// 创建并连接数据库
// 使用单例 (singleton) 模式确保只有一个数据库连接实例
let dbInstance: sqlite3.Database | null = null;
export const getDb = (): sqlite3.Database => {
if (!dbInstance) {
dbInstance = new verboseSqlite3.Database(dbPath, (err) => {
if (err) {
console.error('打开数据库时出错:', err.message);
// 在实际应用中,这里可能需要更健壮的错误处理,例如直接退出进程
process.exit(1);
} else {
console.log(`已连接到 SQLite 数据库: ${dbPath}`);
// 可选:启用外键约束 (如果数据库设计中使用了外键)
// dbInstance.run('PRAGMA foreign_keys = ON;', (pragmaErr) => {
// if (pragmaErr) {
// console.error('启用外键约束失败:', pragmaErr.message);
// }
// });
}
});
}
return dbInstance;
};
// 优雅停机:在应用接收到中断信号 (如 Ctrl+C) 时关闭数据库连接
process.on('SIGINT', () => {
if (dbInstance) {
dbInstance.close((err) => {
if (err) {
console.error('关闭数据库时出错:', err.message);
} else {
console.log('数据库连接已关闭。');
}
process.exit(0);
});
} else {
process.exit(0);
}
});
export default getDb;
+157
View File
@@ -0,0 +1,157 @@
import express = require('express');
// import express = require('express'); // 移除重复导入
import { Request, Response, NextFunction, RequestHandler } from 'express'; // 添加 RequestHandler
import http from 'http'; // 引入 http 模块
import session from 'express-session';
import connectSqlite3 from 'connect-sqlite3';
import path from 'path'; // 需要 path 模块
import bcrypt from 'bcrypt'; // 引入 bcrypt 用于哈希密码
import { getDb } from './database';
import { runMigrations } from './migrations';
import authRouter from './auth/auth.routes'; // 导入认证路由
import connectionsRouter from './connections/connections.routes'; // 导入连接路由
import sftpRouter from './sftp/sftp.routes'; // 导入 SFTP 路由
import { initializeWebSocket } from './websocket'; // 导入 WebSocket 初始化函数
// 基础 Express 应用设置 (后续会扩展)
const app = express();
const server = http.createServer(app); // 创建 HTTP 服务器实例
// --- 会话存储设置 ---
const SQLiteStore = connectSqlite3(session);
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
// --- 中间件 ---
app.use(express.json()); // 添加此行以解析 JSON 请求体
// 会话中间件配置
// TODO: 将 secret 移到环境变量中,不要硬编码在代码里!
const sessionSecret = process.env.SESSION_SECRET || 'a-very-insecure-secret-for-dev';
if (sessionSecret === 'a-very-insecure-secret-for-dev') {
console.warn('警告:正在使用默认的不安全会话密钥,请在生产环境中设置 SESSION_SECRET 环境变量!');
}
app.use(session({
// 使用类型断言 (as any) 来解决 @types/connect-sqlite3 和 @types/express-session 的类型冲突
store: new SQLiteStore({
db: 'nexus-terminal.db', // 数据库文件名
dir: dbPath, // 数据库文件所在目录
table: 'sessions' // 存储会话的表名 (会自动创建)
}) as any,
secret: sessionSecret,
resave: false, // 强制保存 session 即使它没有变化 (通常为 false)
saveUninitialized: false, // 强制将未初始化的 session 存储 (通常为 false)
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // Cookie 有效期:7天 (毫秒)
httpOnly: true, // 防止客户端脚本访问 cookie
secure: process.env.NODE_ENV === 'production' // 仅在 HTTPS 下发送 cookie (生产环境)
}
}));
// 将 session 中间件保存到一个变量,以便传递给 WebSocket 初始化函数
const sessionMiddleware = session({
// 使用类型断言 (as any) 来解决 @types/connect-sqlite3 和 @types/express-session 的类型冲突
store: new SQLiteStore({
db: 'nexus-terminal.db', // 数据库文件名
dir: dbPath, // 数据库文件所在目录
table: 'sessions' // 存储会话的表名 (会自动创建)
}) as any,
secret: sessionSecret,
resave: false, // 强制保存 session 即使它没有变化 (通常为 false)
saveUninitialized: false, // 强制将未初始化的 session 存储 (通常为 false)
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // Cookie 有效期:7天 (毫秒)
httpOnly: true, // 防止客户端脚本访问 cookie
secure: process.env.NODE_ENV === 'production' // 仅在 HTTPS 下发送 cookie (生产环境)
}
});
app.use(sessionMiddleware); // 应用会话中间件
// 扩展 Express Request 类型以包含 session 数据 (如果需要更明确的类型提示)
declare module 'express-session' {
interface SessionData {
userId?: number; // 存储登录用户的 ID
username?: string;
}
}
const port = process.env.PORT || 3001; // 示例端口,可配置
// --- API 路由 ---
app.use('/api/v1/auth', authRouter); // 挂载认证相关的路由
app.use('/api/v1/connections', connectionsRouter); // 挂载连接相关的路由
app.use('/api/v1/sftp', sftpRouter); // 挂载 SFTP 相关的路由
// 状态检查接口
app.get('/api/v1/status', (req: Request, res: Response) => {
res.json({ status: '后端服务运行中!' }); // 响应也改为中文
});
// 在服务器启动前初始化数据库并执行迁移
const initializeDatabase = async () => {
try {
const db = getDb(); // 获取数据库实例 (同时会建立连接)
await runMigrations(db); // 执行数据库迁移 (创建表)
// console.log('数据库迁移执行成功。'); // 日志已移至 migrations.ts
// 检查管理员用户是否存在
const userCount = await new Promise<number>((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM users', (err, row: { count: number }) => { // 查询用户数量
if (err) {
console.error('检查 users 表时出错:', err.message);
return reject(err);
}
resolve(row.count);
});
});
if (userCount === 0) {
console.warn('------------------------------------------------------');
console.warn('警告: 数据库中未找到任何用户。正在创建默认管理员...');
// 创建默认管理员
const defaultAdminUsername = 'admin';
const defaultAdminPassword = 'adminpassword'; // 仅用于首次创建
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(defaultAdminPassword, saltRounds);
const now = Math.floor(Date.now() / 1000);
await new Promise<void>((resolveInsert, rejectInsert) => {
const stmt = db.prepare(
`INSERT INTO users (username, hashed_password, created_at, updated_at)
VALUES (?, ?, ?, ?)`
);
stmt.run(defaultAdminUsername, hashedPassword, now, now, function (err: Error | null) {
if (err) {
console.error('创建默认管理员时出错:', err.message);
return rejectInsert(new Error('创建默认管理员失败'));
}
console.log(`默认管理员 '${defaultAdminUsername}' (密码: '${defaultAdminPassword}') 已创建。请尽快修改密码!`);
resolveInsert();
});
stmt.finalize();
});
console.warn('------------------------------------------------------');
} else {
console.log(`数据库中找到 ${userCount} 个用户。`);
}
console.log('数据库初始化检查完成。');
} catch (error) {
console.error('数据库初始化失败:', error);
process.exit(1); // 如果数据库初始化失败,则退出进程
}
};
// 启动 HTTP 服务器 (而不是直接 app.listen)
const startServer = () => {
server.listen(port, () => { // 使用 server.listen
console.log(`后端服务器正在监听 http://localhost:${port}`);
// 初始化 WebSocket 服务器,并传入 HTTP 服务器实例和会话解析器
initializeWebSocket(server, sessionMiddleware as RequestHandler);
});
};
// 先执行数据库初始化,成功后再启动服务器
initializeDatabase().then(startServer);
+86
View File
@@ -0,0 +1,86 @@
import { Database } from 'sqlite3';
import { getDb } from './database';
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
two_factor_secret TEXT NULL, -- 2FA 密钥占位符
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`;
// MVP (最小可行产品) 阶段: 只包含基础字段,支持密码认证,暂不考虑代理和标签
const createConnectionsTableSQL = `
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL,
auth_method TEXT NOT NULL CHECK(auth_method IN ('password')), -- MVP 阶段仅支持密码认证
encrypted_password TEXT NULL, -- 加密存储的密码占位符 (加密逻辑在应用层实现)
-- encrypted_private_key TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
-- encrypted_passphrase TEXT NULL, -- MVP 阶段跳过密钥认证相关字段
-- proxy_id INTEGER NULL, -- MVP 阶段跳过代理相关字段
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_connected_at INTEGER NULL
);
`;
// 未来可能需要的其他表 (根据项目文档)
// const createProxiesTableSQL = \`...\`; // 代理表
// const createTagsTableSQL = \`...\`; // 标签表
// const createConnectionTagsTableSQL = \`...\`; // 连接与标签的关联表
// const createSettingsTableSQL = \`...\`; // 设置表
// const createAuditLogsTableSQL = \`...\`; // 审计日志表
// const createApiKeysTableSQL = \`...\`; // API 密钥表
/**
* 执行数据库迁移 (创建表)
* @param db - 数据库实例
* @returns Promise,在所有迁移完成后 resolve
*/
export const runMigrations = (db: Database): Promise<void> => {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error('创建 users 表时出错:', err.message);
return reject(err);
}
console.log('Users 表已检查/创建。');
});
db.run(createConnectionsTableSQL, (err) => {
if (err) {
console.error('创建 connections 表时出错:', err.message);
return reject(err);
}
console.log('Connections 表已检查/创建。');
resolve(); // 所有表创建完成后 resolve Promise
});
// 如果未来添加了更多表,在此处继续链式调用 db.run(...)
// db.run(createProxiesTableSQL, callback);
});
});
};
// 允许通过命令行直接运行此文件来执行迁移 (例如: node dist/migrations.js)
if (require.main === module) {
const db = getDb();
runMigrations(db)
.then(() => {
console.log('数据库迁移执行成功。');
// 如果是独立运行,可以选择关闭数据库连接,但在应用启动流程中通常不需要
// db.close();
})
.catch((err) => {
console.error('数据库迁移执行失败:', err);
process.exit(1);
});
}
@@ -0,0 +1,104 @@
import { Request, Response } from 'express';
import path from 'path'; // 需要 path 用于处理文件名
import { activeSshConnections } from '../websocket'; // 导入共享的连接 Map
/**
* 处理文件下载请求 (GET /api/v1/sftp/download)
*/
export const downloadFile = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
const connectionId = req.query.connectionId as string; // 从查询参数获取
const remotePath = req.query.remotePath as string; // 从查询参数获取
// 参数验证
if (!userId) {
res.status(401).json({ message: '未授权:需要登录。' });
return;
}
if (!connectionId || !remotePath) {
res.status(400).json({ message: '缺少必要的查询参数 (connectionId, remotePath)。' });
return;
}
console.log(`SFTP 下载请求:用户 ${userId}, 连接 ${connectionId}, 路径 ${remotePath}`);
// 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话
let userSftpSession = null;
// 注意:这种查找方式效率不高,实际应用中可能需要更优化的结构来按 userId 查找连接
for (const [ws, connData] of activeSshConnections.entries()) {
// 假设 AuthenticatedWebSocket 上存储了 userId
if ((ws as any).userId === userId && connData.sftp) {
// 这里简单地取第一个找到的匹配连接,没有处理 connectionId 的匹配
// TODO: 需要一种方式将 HTTP 请求与特定的 WebSocket/SSH/SFTP 会话关联起来
// 临时方案:假设一个用户只有一个活动的 SSH/SFTP 会话
userSftpSession = connData.sftp;
console.log(`找到用户 ${userId} 的活动 SFTP 会话。`);
break;
}
}
if (!userSftpSession) {
console.warn(`SFTP 下载失败:未找到用户 ${userId} 的活动 SFTP 会话。`);
res.status(404).json({ message: '未找到活动的 SFTP 会话。请确保您已通过 WebSocket 连接到目标服务器。' });
return;
}
try {
// 获取文件状态以确定文件大小(可选,但有助于设置 Content-Length
const stats = await new Promise<import('ssh2').Stats>((resolve, reject) => {
userSftpSession!.lstat(remotePath, (err, stats) => {
if (err) return reject(err);
resolve(stats);
});
});
if (!stats.isFile()) {
res.status(400).json({ message: '指定的路径不是一个文件。' });
return;
}
// 设置响应头
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(remotePath)}"`); // 建议浏览器下载的文件名
res.setHeader('Content-Type', 'application/octet-stream'); // 通用二进制类型
if (stats.size) {
res.setHeader('Content-Length', stats.size.toString());
}
// 创建可读流并 pipe 到响应对象
const readStream = userSftpSession.createReadStream(remotePath);
readStream.on('error', (err: Error) => { // 添加 Error 类型注解
console.error(`SFTP 读取流错误 (用户 ${userId}, 路径 ${remotePath}):`, err);
// 如果响应头还没发送,可以发送错误状态码
if (!res.headersSent) {
res.status(500).json({ message: `读取远程文件失败: ${err.message}` });
} else {
// 如果头已发送,只能尝试结束响应
res.end();
}
});
readStream.pipe(res); // 将文件流直接传输给客户端
// 监听响应对象的 close 事件,确保流被正确关闭 (虽然 pipe 通常会处理)
res.on('close', () => {
console.log(`SFTP 下载流关闭 (用户 ${userId}, 路径 ${remotePath})`);
// readStream.destroy(); // 可选:显式销毁流
});
console.log(`SFTP 开始下载 (用户 ${userId}, 路径 ${remotePath})`);
} catch (error: any) {
console.error(`SFTP 下载处理失败 (用户 ${userId}, 路径 ${remotePath}):`, error);
if (!res.headersSent) {
if (error.message?.includes('No such file')) {
res.status(404).json({ message: '远程文件未找到。' });
} else {
res.status(500).json({ message: `处理下载请求时出错: ${error.message}` });
}
}
}
};
// 其他 SFTP 控制器函数 (例如上传)
// export const uploadFile = ...
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { isAuthenticated } from '../auth/auth.middleware';
import { downloadFile } from './sftp.controller'; // 稍后创建
const router = Router();
// 应用认证中间件
router.use(isAuthenticated);
// GET /api/v1/sftp/download?connectionId=...&remotePath=...
router.get('/download', downloadFile);
// 未来可以添加其他 SFTP 相关 REST API (如果需要,例如上传的大文件断点续传初始化)
export default router;
+78
View File
@@ -0,0 +1,78 @@
import crypto from 'crypto';
// 从环境变量获取加密密钥,提供一个不安全的默认值用于开发
// 警告:生产环境中必须设置一个强随机的 32 字节密钥 (例如通过 openssl rand -base64 32 生成)
const encryptionKeyEnv = process.env.ENCRYPTION_KEY;
if (!encryptionKeyEnv && process.env.NODE_ENV === 'production') {
console.error('错误:生产环境中必须设置 ENCRYPTION_KEY 环境变量!');
process.exit(1);
}
// 使用一个 32 字节的字符串作为不安全的开发默认值
const defaultDevKey = '12345678901234567890123456789012';
const encryptionKey = Buffer.from(
encryptionKeyEnv || defaultDevKey,
'utf8' // 或者 'base64' 如果环境变量是 base64 编码的
); // Buffer.from utf8 string of 32 chars is 32 bytes
// 重新检查,虽然 Buffer.from 应该保证了长度,但以防万一
if (encryptionKey.length !== 32) {
console.error(`错误:加密密钥长度必须是 32 字节,当前长度为 ${encryptionKey.length}`);
process.exit(1);
}
if (!encryptionKeyEnv) { // 仅在未设置环境变量时显示警告
console.warn('警告:正在使用默认的不安全加密密钥,请在生产环境中设置 ENCRYPTION_KEY 环境变量!');
}
const algorithm = 'aes-256-gcm';
const ivLength = 16; // GCM 推荐的 IV 长度为 12 或 16 字节
const tagLength = 16; // GCM 认证标签长度
/**
* 加密文本 (例如连接密码)
* @param text - 需要加密的明文
* @returns Base64 编码的字符串,格式为 "iv:encrypted:tag"
*/
export const encrypt = (text: string): string => {
try {
const iv = crypto.randomBytes(ivLength);
const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// 将 iv、密文和认证标签组合并编码
return Buffer.concat([iv, encrypted, tag]).toString('base64');
} catch (error) {
console.error('加密失败:', error);
throw new Error('加密过程中发生错误');
}
};
/**
* 解密文本
* @param encryptedText - Base64 编码的加密字符串 ("iv:encrypted:tag")
* @returns 解密后的明文
*/
export const decrypt = (encryptedText: string): string => {
try {
const data = Buffer.from(encryptedText, 'base64');
if (data.length < ivLength + tagLength) {
throw new Error('无效的加密数据格式');
}
// 从组合数据中提取 iv、密文和认证标签
const iv = data.slice(0, ivLength);
const encrypted = data.slice(ivLength, data.length - tagLength);
const tag = data.slice(data.length - tagLength);
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
decipher.setAuthTag(tag); // 设置认证标签以供验证
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
} catch (error) {
console.error('解密失败:', error);
// 在实际应用中,解密失败通常意味着数据被篡改或密钥错误
// 不应向客户端泄露具体错误细节
throw new Error('解密过程中发生错误或数据无效');
}
};
+771
View File
@@ -0,0 +1,771 @@
import WebSocket, { WebSocketServer } from 'ws';
import http from 'http';
import { Request, RequestHandler } from 'express';
import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2'; // 引入 SFTPWrapper 和 Stats
import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的流类型不同,但可以借用)
import { getDb } from './database'; // 引入数据库实例
import { decrypt } from './utils/crypto'; // 引入解密函数
import path from 'path'; // 需要 path
// 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息
interface AuthenticatedWebSocket extends WebSocket {
isAlive?: boolean;
userId?: number;
username?: string;
sshClient?: Client; // 关联的 SSH Client 实例
sshShellStream?: ClientChannel; // 关联的 SSH Shell Stream
sftpStream?: SFTPWrapper; // 关联的 SFTP Stream
}
// 存储活跃的 SSH/SFTP 连接 (导出以便其他模块访问)
export const activeSshConnections = new Map<AuthenticatedWebSocket, { client: Client, shell: ClientChannel, sftp?: SFTPWrapper }>();
// 存储正在进行的 SFTP 上传操作 (key: uploadId, value: WriteStream)
// 注意:WriteStream 类型来自 'fs',但 ssh2 的流行为类似
const activeUploads = new Map<string, WriteStream>();
// 数据库连接信息接口 (包含加密密码)
interface DbConnectionInfo {
id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: 'password';
encrypted_password?: string; // 注意是可选的,因为可能没有密码 (虽然 MVP 要求有)
// 其他字段...
}
/**
* 清理指定 WebSocket 连接关联的 SSH 资源
* @param ws - WebSocket 连接实例
*/
const cleanupSshConnection = (ws: AuthenticatedWebSocket) => {
const connection = activeSshConnections.get(ws);
if (connection) {
console.log(`WebSocket: 清理用户 ${ws.username} 的 SSH/SFTP 连接...`);
// 注意:SFTP 流通常不需要显式关闭,它依赖于 SSH Client 的关闭
// connection.sftp?.end(); // SFTPWrapper 没有 end 方法
connection.shell?.end(); // 尝试结束 shell 流
connection.client?.end(); // 结束 SSH 客户端连接会隐式关闭 SFTP
activeSshConnections.delete(ws); // 从 Map 中移除
}
};
export const initializeWebSocket = (server: http.Server, sessionParser: RequestHandler): WebSocketServer => {
const wss = new WebSocketServer({ noServer: true });
const db = getDb(); // 获取数据库实例
const interval = setInterval(() => {
wss.clients.forEach((ws: WebSocket) => {
const extWs = ws as AuthenticatedWebSocket;
if (extWs.isAlive === false) {
console.log(`WebSocket 心跳检测:用户 ${extWs.username} 连接无响应,正在终止...`);
cleanupSshConnection(extWs); // 清理 SSH 资源
return extWs.terminate();
}
extWs.isAlive = false;
extWs.ping(() => {});
});
}, 60000); // Increased interval to 60 seconds
server.on('upgrade', (request: Request, socket, head) => {
// @ts-ignore
sessionParser(request, {} as any, () => {
if (!request.session || !request.session.userId) {
console.log('WebSocket 认证失败:未找到会话或用户未登录。');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
console.log(`WebSocket 认证成功:用户 ${request.session.username} (ID: ${request.session.userId})`);
wss.handleUpgrade(request, socket, head, (ws) => {
const extWs = ws as AuthenticatedWebSocket;
extWs.userId = request.session.userId;
extWs.username = request.session.username;
wss.emit('connection', extWs, request);
});
});
});
wss.on('connection', (ws: AuthenticatedWebSocket, request: Request) => {
ws.isAlive = true;
console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 已连接。`);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', async (message) => {
console.log(`WebSocket:收到来自 ${ws.username} 的消息: ${message.toString().substring(0, 100)}...`); // 截断长消息日志
try {
const parsedMessage = JSON.parse(message.toString());
const connection = activeSshConnections.get(ws); // 获取当前连接信息
const sftp = connection?.sftp; // 获取 SFTP 实例
// 辅助函数发送错误消息
const sendSftpError = (action: string, path: string | undefined, error: any, customMsg?: string) => {
const errorMessage = customMsg || (error instanceof Error ? error.message : String(error));
console.error(`SFTP: 用户 ${ws.username} 执行 ${action} 操作 ${path ? `${path}` : ''} 失败:`, error);
ws.send(JSON.stringify({ type: `sftp:${action}:error`, path, payload: `${action} 失败: ${errorMessage}` }));
};
// 辅助函数发送成功消息
const sendSftpSuccess = (action: string, path: string | undefined, payload?: any) => {
console.log(`SFTP: 用户 ${ws.username} 执行 ${action} 操作 ${path ? `${path}` : ''} 成功。`);
ws.send(JSON.stringify({ type: `sftp:${action}:success`, path, payload }));
};
// 检查 SFTP 会话是否存在
const ensureSftp = (action: string, path?: string): SFTPWrapper | null => {
if (!sftp) {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP ${action} 请求,但无活动 SFTP 会话。`);
ws.send(JSON.stringify({ type: `sftp:${action}:error`, path, payload: 'SFTP 会话未初始化或已断开。' }));
return null;
}
return sftp;
};
switch (parsedMessage.type) {
// --- 处理 SSH 连接请求 ---
case 'ssh:connect': {
// 注意:ssh:connect 内部逻辑需要自行处理 sftp 实例的获取,不能依赖顶层的 sftp 变量
if (activeSshConnections.has(ws)) {
console.warn(`WebSocket: 用户 ${ws.username} 已有活动的 SSH 连接,忽略新的连接请求。`);
ws.send(JSON.stringify({ type: 'ssh:error', payload: '已存在活动的 SSH 连接。' }));
return;
}
const connectionId = parsedMessage.payload?.connectionId;
if (!connectionId) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: '缺少 connectionId。' }));
return;
}
console.log(`WebSocket: 用户 ${ws.username} 请求连接到 ID: ${connectionId}`);
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' }));
// 1. 从数据库获取连接信息
const connInfo = await new Promise<DbConnectionInfo | null>((resolve, reject) => {
// 注意:如果多用户,需要验证 connectionId 是否属于当前 userId
db.get('SELECT * FROM connections WHERE id = ?', [connectionId], (err, row: DbConnectionInfo) => {
if (err) return reject(new Error('查询连接信息失败'));
resolve(row ?? null);
});
});
if (!connInfo) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: `未找到 ID 为 ${connectionId} 的连接配置。` }));
return;
}
if (!connInfo.encrypted_password) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: '连接配置缺少密码信息。' }));
return;
}
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` }));
// 2. 解密密码
let password = '';
try {
password = decrypt(connInfo.encrypted_password);
} catch (decryptError: any) {
console.error(`解密连接 ${connectionId} 密码失败:`, decryptError);
ws.send(JSON.stringify({ type: 'ssh:error', payload: '无法解密连接凭证。' }));
return;
}
// 3. 建立 SSH 连接
const sshClient = new Client();
ws.sshClient = sshClient; // 关联 client
sshClient.on('ready', () => {
console.log(`SSH: 用户 ${ws.username}${connInfo.host} 连接成功!`);
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' }));
// 4. 请求 Shell 通道
sshClient.shell((err, stream) => {
if (err) {
console.error(`SSH: 用户 ${ws.username} 打开 Shell 失败:`, err);
ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${err.message}` }));
cleanupSshConnection(ws);
return;
}
ws.sshShellStream = stream; // 关联 stream
// 存储活动连接 (此时 sftp 可能还未就绪)
activeSshConnections.set(ws, { client: sshClient, shell: stream });
console.log(`SSH: 用户 ${ws.username} Shell 通道已打开。`);
// 尝试初始化 SFTP 会话
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
console.error(`SFTP: 用户 ${ws.username} 初始化失败:`, sftpErr);
// 即使 SFTP 失败,也保持 Shell 连接,但发送错误通知
ws.send(JSON.stringify({ type: 'sftp:error', payload: `SFTP 初始化失败: ${sftpErr.message}` }));
// 不再发送 ssh:connected,因为 SFTP 也是核心功能的一部分
// ws.send(JSON.stringify({ type: 'ssh:connected' }));
// 可以在这里发送一个包含错误的状态
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'Shell 已连接,但 SFTP 初始化失败。' }));
return;
}
console.log(`SFTP: 用户 ${ws.username} 会话已初始化。`);
// 将 SFTP 实例存入 Map
const existingConn = activeSshConnections.get(ws);
if (existingConn) {
existingConn.sftp = sftp;
}
// SFTP 就绪后,才真正通知前端连接完成
ws.send(JSON.stringify({ type: 'ssh:connected' }));
});
// 5. 数据转发:Shell -> WebSocket (发送 Base64 编码的数据)
stream.on('data', (data: Buffer) => {
// console.log('SSH Output Buffer Length:', data.length); // Debug log
ws.send(JSON.stringify({
type: 'ssh:output',
payload: data.toString('base64'), // 将 Buffer 转为 Base64 字符串
encoding: 'base64' // 明确告知前端编码方式
}));
});
// 6. 处理 Shell 关闭
stream.on('close', () => {
console.log(`SSH: 用户 ${ws.username} Shell 通道已关闭。`);
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' }));
cleanupSshConnection(ws); // 清理资源
});
// Stderr 也使用 Base64 发送
stream.stderr.on('data', (data: Buffer) => {
console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`); // 日志中尝试 utf8 解码预览
ws.send(JSON.stringify({
type: 'ssh:output', // 同样使用 ssh:output 类型
payload: data.toString('base64'),
encoding: 'base64'
}));
});
});
}).on('error', (err) => {
console.error(`SSH: 用户 ${ws.username} 连接错误:`, err);
ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` }));
cleanupSshConnection(ws);
}).on('close', () => {
console.log(`SSH: 用户 ${ws.username} 连接已关闭。`);
// 确保即使 shell 没关闭,也要通知前端并清理
if (activeSshConnections.has(ws)) {
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' }));
cleanupSshConnection(ws);
}
}).connect({
host: connInfo.host,
port: connInfo.port,
username: connInfo.username,
password: password, // 使用解密后的密码
// TODO: 添加对密钥认证的支持
// privateKey: require('fs').readFileSync('/path/to/key'),
// passphrase: 'key passphrase'
keepaliveInterval: 30000, // Send keep-alive every 30 seconds (milliseconds)
keepaliveCountMax: 3, // Disconnect after 3 missed keep-alives
readyTimeout: 20000 // 连接超时时间 (毫秒)
});
break;
} // end case 'ssh:connect'
// --- 处理 SSH 输入 ---
case 'ssh:input': {
const connection = activeSshConnections.get(ws);
if (connection?.shell && parsedMessage.payload?.data) {
connection.shell.write(parsedMessage.payload.data);
} else {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SSH 输入,但无活动 Shell 或数据为空。`);
}
break;
}
// --- 处理终端大小调整 ---
case 'ssh:resize': {
const connection = activeSshConnections.get(ws);
const { cols, rows } = parsedMessage.payload || {};
if (connection?.shell && cols && rows) {
console.log(`SSH: 用户 ${ws.username} 调整终端大小: ${cols}x${rows}`);
connection.shell.setWindow(rows, cols, 0, 0); // 注意参数顺序 rows, cols
} else {
console.warn(`WebSocket: 收到来自 ${ws.username} 的调整大小请求,但无活动 Shell 或尺寸数据无效。`);
}
break;
}
// --- 处理 SFTP 目录列表请求 ---
case 'sftp:readdir': {
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('readdir', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('readdir', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求读取目录: ${targetPath}`);
currentSftp.readdir(targetPath, (err, list) => {
if (err) {
sendSftpError('readdir', targetPath, err);
return;
}
// 格式化文件列表以便前端使用
const formattedList = 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(),
}
}));
sendSftpSuccess('readdir', targetPath, formattedList);
});
break;
}
// --- 处理 SFTP 文件/目录状态获取请求 ---
case 'sftp:stat': {
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('stat', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('stat', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求获取状态: ${targetPath}`);
currentSftp.lstat(targetPath, (err, stats) => { // 使用 lstat 获取链接本身信息
if (err) {
sendSftpError('stat', targetPath, err);
return;
}
const formattedStats = {
mode: stats.mode,
uid: stats.uid,
gid: stats.gid,
size: stats.size,
atime: stats.atime * 1000,
mtime: stats.mtime * 1000,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
isBlockDevice: stats.isBlockDevice(),
isCharacterDevice: stats.isCharacterDevice(),
isSymbolicLink: stats.isSymbolicLink(),
isFIFO: stats.isFIFO(),
isSocket: stats.isSocket(),
};
sendSftpSuccess('stat', targetPath, formattedStats);
});
break;
}
// --- 处理 SFTP 文件上传 ---
case 'sftp:upload:start': {
const { remotePath, uploadId, size } = parsedMessage.payload || {};
const currentSftp = ensureSftp('upload:start', remotePath);
if (!currentSftp) break;
if (typeof remotePath !== 'string' || !uploadId) {
sendSftpError('upload:start', remotePath, '无效的上传请求参数 (remotePath, uploadId)。', undefined);
break;
}
if (activeUploads.has(uploadId)) {
sendSftpError('upload:start', remotePath, '具有相同 ID 的上传已在进行中。', undefined);
break;
}
console.log(`SFTP: 用户 ${ws.username} 开始上传到 ${remotePath} (ID: ${uploadId}, 大小: ${size ?? '未知'})`);
try {
const writeStream = currentSftp.createWriteStream(remotePath);
writeStream.on('error', (err: Error) => {
sendSftpError('upload', remotePath, err, `写入远程文件失败: ${err.message}`);
activeUploads.delete(uploadId);
});
let uploadFinished = false;
const onStreamEnd = (eventName: string) => {
if (uploadFinished) return;
uploadFinished = true;
sendSftpSuccess('upload', remotePath, { uploadId }); // 成功时也带上 uploadId
activeUploads.delete(uploadId);
};
writeStream.on('finish', () => onStreamEnd('finish'));
writeStream.on('close', () => onStreamEnd('close'));
activeUploads.set(uploadId, writeStream as any);
ws.send(JSON.stringify({ type: 'sftp:upload:ready', uploadId }));
} catch (err: any) {
sendSftpError('upload:start', remotePath, err, `无法创建远程文件: ${err.message}`);
}
break;
}
case 'sftp:upload:chunk': {
const { uploadId, data, isLast } = parsedMessage.payload || {};
const writeStream = activeUploads.get(uploadId);
if (!writeStream) {
// console.warn(`WebSocket: 收到上传数据块 (ID: ${uploadId}),但未找到对应的上传任务。`);
// 不必每次都报错,前端可能已经取消或完成
break;
}
if (typeof data !== 'string') {
sendSftpError('upload:chunk', undefined, '无效的数据块格式。', undefined);
break;
}
try {
const buffer = Buffer.from(data, 'base64');
const canWriteMore = writeStream.write(buffer);
if (!canWriteMore) {
writeStream.once('drain', () => {
ws.send(JSON.stringify({ type: 'sftp:upload:resume', uploadId }));
});
ws.send(JSON.stringify({ type: 'sftp:upload:pause', uploadId }));
}
if (isLast) {
writeStream.end();
}
} catch (err: any) {
sendSftpError('upload:chunk', undefined, err, `处理数据块失败: ${err.message}`);
writeStream.end();
activeUploads.delete(uploadId);
}
break;
}
case 'sftp:upload:cancel': {
const { uploadId } = parsedMessage.payload || {};
const writeStream = activeUploads.get(uploadId);
if (writeStream) {
console.log(`SFTP: 用户 ${ws.username} 取消上传 (ID: ${uploadId})`);
writeStream.end(); // 触发清理
// TODO: 删除部分文件? sftp.unlink?
ws.send(JSON.stringify({ type: 'sftp:upload:cancelled', uploadId }));
} else {
// console.warn(`WebSocket: 收到取消上传请求 (ID: ${uploadId}),但未找到对应的上传任务。`);
}
break;
}
// --- 处理 SFTP 文件读取请求 ---
case 'sftp:readfile': {
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('readfile', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('readfile', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求读取文件: ${targetPath}`);
const readStream = currentSftp.createReadStream(targetPath);
let fileContent = '';
let hasError = false;
readStream.on('data', (chunk: Buffer) => {
// 尝试多种编码解码,优先 UTF-8
try {
fileContent += chunk.toString('utf8');
} catch (e) {
// 如果 UTF-8 失败,尝试其他常见编码,例如 GBK (适用于中文 Windows)
// 注意:这只是一个尝试,可能不准确。更可靠的方法是让用户指定编码。
try {
// 需要安装 iconv-lite: npm install iconv-lite @types/iconv-lite -w packages/backend
// import * as iconv from 'iconv-lite';
// fileContent += iconv.decode(chunk, 'gbk');
// 暂时回退到 base64 发送原始数据,让前端处理解码
console.warn(`SFTP: 文件 ${targetPath} 无法以 UTF-8 解码,将发送 Base64 编码内容。`);
fileContent = Buffer.concat([Buffer.from(fileContent), chunk]).toString('base64');
} catch (decodeError) {
console.error(`SFTP: 文件 ${targetPath} 解码失败:`, decodeError);
sendSftpError('readfile', targetPath, '文件解码失败。');
readStream.destroy(); // 停止读取
hasError = true;
}
}
});
readStream.on('error', (err: Error) => {
if (hasError) return; // 避免重复发送错误
sendSftpError('readfile', targetPath, err);
hasError = true;
});
readStream.on('end', () => {
if (hasError) return; // 如果之前已出错,则不发送成功消息
// 判断是发送文本内容还是 Base64
let payload: { content: string; encoding: 'utf8' | 'base64' };
try {
// 尝试再次解码整个内容为 UTF-8,如果成功则发送 UTF-8
Buffer.from(fileContent, 'base64').toString('utf8');
// 如果上一步是 base64 编码,这里会是原始 base64 字符串
if (fileContent === Buffer.from(fileContent, 'base64').toString('base64')) {
payload = { content: fileContent, encoding: 'base64' };
} else {
payload = { content: fileContent, encoding: 'utf8' };
}
} catch (e) {
// 如果整体解码失败,则发送 Base64
payload = { content: Buffer.from(fileContent).toString('base64'), encoding: 'base64' };
}
// 限制发送内容的大小,避免 WebSocket 拥塞 (例如 1MB)
const MAX_CONTENT_SIZE = 1 * 1024 * 1024;
if (Buffer.byteLength(payload.content, payload.encoding === 'base64' ? 'base64' : 'utf8') > MAX_CONTENT_SIZE) {
sendSftpError('readfile', targetPath, `文件过大 (超过 ${MAX_CONTENT_SIZE / 1024 / 1024}MB),无法在编辑器中打开。`);
} else {
sendSftpSuccess('readfile', targetPath, payload);
}
});
break;
}
// --- 处理 SFTP 文件写入请求 ---
case 'sftp:writefile': {
const { path: targetPath, content, encoding } = parsedMessage.payload || {};
const currentSftp = ensureSftp('writefile', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string' || typeof content !== 'string' || (encoding !== 'utf8' && encoding !== 'base64')) {
sendSftpError('writefile', targetPath, '请求参数无效 (需要 path, content, encoding[\'utf8\'|\'base64\'])。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求写入文件: ${targetPath}, Encoding: ${encoding}, Content length: ${content.length}`); // 增加日志细节
try {
console.log(`[writefile] Attempting to create buffer for ${targetPath}`);
const buffer = Buffer.from(content, encoding); // 根据 encoding 解码/转换内容为 Buffer
console.log(`[writefile] Buffer created successfully for ${targetPath}. Attempting to create write stream.`);
const writeStream = currentSftp.createWriteStream(targetPath);
console.log(`[writefile] Write stream created for ${targetPath}. Attaching listeners.`);
let hasError = false;
let operationCompleted = false; // Flag to track if finish/error occurred
let backendTimeoutId: NodeJS.Timeout | null = null;
const streamId = Math.random().toString(36).substring(2, 9); // Unique ID for logging this stream instance
const BACKEND_WRITE_TIMEOUT = 15000; // 15 seconds backend timeout
console.log(`[${streamId}] SFTP: Attaching listeners for writeStream to ${targetPath}`);
const cleanupTimeout = () => {
if (backendTimeoutId) {
clearTimeout(backendTimeoutId);
backendTimeoutId = null;
}
};
writeStream.on('error', (err: Error) => {
console.error(`[${streamId}] SFTP: writeStream 'error' event for ${targetPath}:`, err);
if (operationCompleted) return; // Already completed
operationCompleted = true;
cleanupTimeout();
sendSftpError('writefile', targetPath, err, `写入远程文件失败: ${err.message}`);
hasError = true; // Keep track for close handler if needed
});
writeStream.on('finish', () => { // 'finish' 表示所有数据已刷入底层系统
console.log(`[${streamId}] SFTP: writeStream 'finish' event for ${targetPath}. HasError: ${hasError}`);
if (operationCompleted) return; // Already completed (e.g., error occurred first)
operationCompleted = true;
cleanupTimeout();
if (hasError) return; // Error occurred before finish
sendSftpSuccess('writefile', targetPath);
});
writeStream.on('close', () => { // 'close' 表示流已关闭
console.log(`[${streamId}] SFTP: writeStream 'close' event for ${targetPath}. writableFinished: ${writeStream.writableFinished}, HasError: ${hasError}, OperationCompleted: ${operationCompleted}`);
cleanupTimeout(); // Clear timeout if close happens before it fires
// If the stream closed and no error/finish/timeout event handled it yet,
// consider it a success. This handles cases where 'finish' might not fire reliably,
// even if writableFinished is false when close is emitted prematurely.
if (!operationCompleted) {
console.warn(`[${streamId}] SFTP: writeStream 'close' event occurred before 'finish' or 'error'. Assuming success for ${targetPath}.`);
sendSftpSuccess('writefile', targetPath);
operationCompleted = true; // Mark as completed via close
}
// If an error or finish occurred, the respective handlers already sent the message.
// If finish occurred, the 'finish' handler sent success.
// If closed without finishing and without error, the backend timeout might handle it,
// or it might be a legitimate early close after an error on the server side not reported via 'error' event.
});
// 写入数据并结束流
console.log(`[${streamId}] SFTP: Calling writeStream.end() for ${targetPath}`);
writeStream.end(buffer, () => {
console.log(`[${streamId}] SFTP: writeStream.end() callback fired for ${targetPath}. Starting backend timeout.`);
// Start backend timeout *after* end() callback fires (or immediately if no callback needed)
backendTimeoutId = setTimeout(() => {
if (!operationCompleted) {
console.error(`[${streamId}] SFTP: Backend write timeout (${BACKEND_WRITE_TIMEOUT}ms) reached for ${targetPath}.`);
operationCompleted = true; // Mark as completed due to timeout
sendSftpError('writefile', targetPath, `后端写入超时 (${BACKEND_WRITE_TIMEOUT / 1000}秒)`);
}
}, BACKEND_WRITE_TIMEOUT);
});
} catch (err: any) {
console.error(`[writefile] Error during write stream creation or buffer processing for ${targetPath}:`, err); // 增加 catch 日志
// Buffer.from 可能因无效编码或内容抛出错误
sendSftpError('writefile', targetPath, err, `处理文件内容或创建写入流失败: ${err.message}`);
}
break;
}
// --- 新增 SFTP 操作 ---
case 'sftp:mkdir': {
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('mkdir', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('mkdir', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求创建目录: ${targetPath}`);
// TODO: 考虑添加 mode 参数支持
currentSftp.mkdir(targetPath, (err) => {
if (err) {
sendSftpError('mkdir', targetPath, err);
} else {
sendSftpSuccess('mkdir', targetPath);
}
});
break;
}
case 'sftp:rmdir': {
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('rmdir', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('rmdir', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求删除目录: ${targetPath}`);
currentSftp.rmdir(targetPath, (err) => {
if (err) {
sendSftpError('rmdir', targetPath, err);
} else {
sendSftpSuccess('rmdir', targetPath);
}
});
break;
}
case 'sftp:unlink': { // 删除文件
const targetPath = parsedMessage.payload?.path;
const currentSftp = ensureSftp('unlink', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string') {
sendSftpError('unlink', targetPath, '请求路径无效。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求删除文件: ${targetPath}`);
currentSftp.unlink(targetPath, (err) => {
if (err) {
sendSftpError('unlink', targetPath, err);
} else {
sendSftpSuccess('unlink', targetPath);
}
});
break;
}
case 'sftp:rename': {
const { oldPath, newPath } = parsedMessage.payload || {};
const currentSftp = ensureSftp('rename', oldPath);
if (!currentSftp) break;
if (typeof oldPath !== 'string' || typeof newPath !== 'string') {
sendSftpError('rename', oldPath, '无效的旧路径或新路径。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求重命名: ${oldPath} -> ${newPath}`);
currentSftp.rename(oldPath, newPath, (err) => {
if (err) {
sendSftpError('rename', oldPath, err);
} else {
sendSftpSuccess('rename', oldPath, { oldPath, newPath }); // 返回新旧路径
}
});
break;
}
case 'sftp:chmod': {
const { targetPath, mode } = parsedMessage.payload || {};
const currentSftp = ensureSftp('chmod', targetPath);
if (!currentSftp) break;
if (typeof targetPath !== 'string' || typeof mode !== 'number') {
sendSftpError('chmod', targetPath, '无效的路径或权限模式。');
break;
}
console.log(`SFTP: 用户 ${ws.username} 请求修改权限: ${targetPath} -> ${mode.toString(8)}`); // 以八进制显示 mode
currentSftp.chmod(targetPath, mode, (err) => {
if (err) {
sendSftpError('chmod', targetPath, err);
} else {
sendSftpSuccess('chmod', targetPath, { mode }); // 返回设置的 mode
}
});
break;
}
default:
console.warn(`WebSocket:收到未知类型的消息: ${parsedMessage.type}`);
ws.send(JSON.stringify({ type: 'error', payload: `不支持的消息类型: ${parsedMessage.type}` }));
}
} catch (e) {
console.error('WebSocket:解析消息时出错:', e);
ws.send(JSON.stringify({ type: 'error', payload: '无效的消息格式' }));
}
});
ws.on('close', (code, reason) => {
console.log(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 已断开连接。代码: ${code}, 原因: ${reason.toString()}`);
cleanupSshConnection(ws); // 清理关联的 SSH 资源
});
ws.on('error', (error) => {
console.error(`WebSocket:客户端 ${ws.username} (ID: ${ws.userId}) 发生错误:`, error);
cleanupSshConnection(ws); // 清理关联的 SSH 资源
});
// 不再发送通用欢迎消息,等待前端发起 ssh:connect
// ws.send(JSON.stringify({ type: 'info', payload: `欢迎, ${ws.username}! WebSocket 连接已建立。` }));
});
wss.on('close', () => {
console.log('WebSocket 服务器正在关闭,清理心跳定时器...');
clearInterval(interval);
// 关闭所有活动的 SSH 连接
console.log('关闭所有活动的 SSH 连接...');
activeSshConnections.forEach((conn, ws) => {
cleanupSshConnection(ws);
});
});
console.log('WebSocket 服务器初始化完成。');
return wss;
};