update
This commit is contained in:
@@ -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 = ...
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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 = ...
|
||||
@@ -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;
|
||||
@@ -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('解密过程中发生错误或数据无效');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user