This commit is contained in:
Baobhan Sith
2025-04-15 18:59:56 +08:00
parent 7649a7b69d
commit c026a42d06
43 changed files with 3479 additions and 169 deletions
@@ -0,0 +1,68 @@
import { Request, Response } from 'express';
import { AuditLogService } from '../services/audit.service';
import { AuditLogActionType } from '../types/audit.types';
const auditLogService = new AuditLogService();
export class AuditController {
/**
* 获取审计日志列表 (GET /api/v1/audit-logs)
* 支持分页和过滤查询参数: limit, offset, actionType, startDate, endDate
*/
async getAuditLogs(req: Request, res: Response): Promise<void> {
try {
// 解析查询参数
const limit = parseInt(req.query.limit as string || '50', 10);
const offset = parseInt(req.query.offset as string || '0', 10);
const actionType = req.query.actionType as AuditLogActionType | undefined;
const startDate = req.query.startDate ? parseInt(req.query.startDate as string, 10) : undefined;
const endDate = req.query.endDate ? parseInt(req.query.endDate as string, 10) : undefined;
// 输入验证 (基本)
if (isNaN(limit) || limit <= 0) {
res.status(400).json({ message: '无效的 limit 参数' });
return;
}
if (isNaN(offset) || offset < 0) {
res.status(400).json({ message: '无效的 offset 参数' });
return;
}
if (startDate && isNaN(startDate)) {
res.status(400).json({ message: '无效的 startDate 参数' });
return;
}
if (endDate && isNaN(endDate)) {
res.status(400).json({ message: '无效的 endDate 参数' });
return;
}
// TODO: 可以添加对 actionType 是否有效的验证
const result = await auditLogService.getLogs(limit, offset, actionType, startDate, endDate);
// 解析 details 字段从 JSON 字符串到对象(如果需要)
const logsWithParsedDetails = result.logs.map(log => {
let parsedDetails: any = null;
if (log.details) {
try {
parsedDetails = JSON.parse(log.details);
} catch (e) {
console.warn(`[Audit Log] Failed to parse details for log ID ${log.id}:`, e);
parsedDetails = { raw: log.details, parseError: true }; // 保留原始字符串并标记错误
}
}
return { ...log, details: parsedDetails };
});
res.status(200).json({
logs: logsWithParsedDetails,
total: result.total,
limit,
offset
});
} catch (error: any) {
console.error('获取审计日志时出错:', error);
res.status(500).json({ message: '获取审计日志失败', error: error.message });
}
}
}
@@ -0,0 +1,14 @@
import { Router } from 'express';
import { AuditController } from './audit.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // Use the correct auth middleware
const router = Router();
const auditController = new AuditController();
// Apply auth middleware to protect the audit log endpoint
router.use(isAuthenticated);
// Define route for getting audit logs
router.get('/', auditController.getAuditLogs);
export default router;
+79 -4
View File
@@ -5,9 +5,14 @@ import sqlite3, { RunResult } from 'sqlite3';
import speakeasy from 'speakeasy';
import qrcode from 'qrcode';
import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyService
import { NotificationService } from '../services/notification.service'; // 导入 NotificationService
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务
const db = getDb();
const passkeyService = new PasskeyService(); // 实例化 PasskeyService
const notificationService = new NotificationService(); // 实例化 NotificationService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
// 用户数据结构占位符 (理想情况下应定义在共享的 types 文件中)
interface User {
@@ -26,6 +31,7 @@ declare module 'express-session' {
tempTwoFactorSecret?: string;
requiresTwoFactor?: boolean;
currentChallenge?: string; // 用于存储 Passkey 操作的挑战
rememberMe?: boolean; // 新增:临时存储“记住我”选项
}
}
@@ -34,7 +40,8 @@ declare module 'express-session' {
* 处理用户登录请求 (POST /api/v1/auth/login)
*/
export const login = async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
// 从请求体中解构 username, password 和可选的 rememberMe
const { username, password, rememberMe } = req.body;
if (!username || !password) {
res.status(400).json({ message: '用户名和密码不能为空。' });
@@ -55,6 +62,13 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!user) {
console.log(`登录尝试失败: 用户未找到 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'User not found', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -63,6 +77,13 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (!isMatch) {
console.log(`登录尝试失败: 密码错误 - ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
// 发送登录失败通知
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid password', ip: clientIp });
res.status(401).json({ message: '无效的凭据。' });
return;
}
@@ -73,13 +94,30 @@ export const login = async (req: Request, res: Response): Promise<void> => {
// 不设置完整 session,只标记需要 2FA
req.session.userId = user.id; // 临时存储 userId 以便 2FA 验证
req.session.requiresTwoFactor = true;
req.session.rememberMe = rememberMe; // 临时存储 rememberMe 状态
res.status(200).json({ message: '需要进行两步验证。', requiresTwoFactor: true });
} else {
// --- 认证成功 (未启用 2FA) ---
console.log(`登录成功 (无 2FA): ${username}`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username, ip: clientIp });
req.session.userId = user.id;
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 明确标记不需要 2FA
// 根据 rememberMe 设置 cookie maxAge
if (rememberMe) {
// 如果记住我,使用默认的 maxAge (在 index.ts 中设置,通常是 7 天)
// 如果需要强制覆盖为 7 天,取消下一行注释
// req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7;
} else {
// 如果不记住我,设置为会话 cookie (浏览器关闭时过期)
req.session.cookie.maxAge = undefined; // 使用 undefined 表示会话 cookie
}
res.status(200).json({
message: '登录成功。',
user: { id: user.id, username: user.username }
@@ -183,15 +221,37 @@ export const verifyLogin2FA = async (req: Request, res: Response): Promise<void>
if (verified) {
console.log(`用户 ${user.username} 2FA 验证成功。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 重置失败尝试次数
ipBlacklistService.resetAttempts(clientIp);
// 记录审计日志 (2FA 成功也算登录成功) (添加 IP)
auditLogService.logAction('LOGIN_SUCCESS', { userId: user.id, username: user.username, ip: clientIp, twoFactor: true });
// 验证成功,建立完整会话
req.session.username = user.username;
req.session.requiresTwoFactor = false; // 标记 2FA 已完成
// 根据之前存储在 session 中的 rememberMe 设置 cookie maxAge
if (req.session.rememberMe) {
// 如果记住我,使用默认的 maxAge
// req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 7;
} else {
// 如果不记住我,设置为会话 cookie
req.session.cookie.maxAge = undefined;
}
// 清除临时的 rememberMe 状态
delete req.session.rememberMe;
res.status(200).json({
message: '登录成功。',
user: { id: user.id, username: user.username }
});
} else {
console.log(`用户 ${user.username} 2FA 验证失败: 验证码错误。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录失败尝试
ipBlacklistService.recordFailedAttempt(clientIp);
// 记录审计日志 (添加 IP)
auditLogService.logAction('LOGIN_FAILURE', { userId: user.id, username: user.username, reason: 'Invalid 2FA token', ip: clientIp });
res.status(401).json({ message: '验证码无效。' });
}
@@ -268,9 +328,12 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
}
if (this.changes === 0) {
console.error(`修改密码错误: 更新影响行数为 0 - 用户 ID ${userId}`);
return rejectUpdate(new Error('未找到要更新的用户'));
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 密码已成功修改。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('PASSWORD_CHANGED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
@@ -405,7 +468,13 @@ export const verifyPasskeyRegistration = async (req: Request, res: Response): Pr
name
);
if (verification.verified) {
// Check if verification was successful and registrationInfo is present
if (verification.verified && verification.registrationInfo) {
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
// Use type assertion 'as any' to bypass persistent TS error for now
const regInfo: any = verification.registrationInfo;
auditLogService.logAction('PASSKEY_REGISTERED', { userId, passkeyId: regInfo.credentialID, name, ip: clientIp });
res.status(201).json({ message: 'Passkey 注册成功!', verified: true });
} else {
console.error(`用户 ${userId} Passkey 注册验证失败:`, verification);
@@ -463,9 +532,12 @@ export const verifyAndActivate2FA = async (req: Request, res: Response): Promise
}
if (this.changes === 0) {
console.error(`激活 2FA 错误: 更新影响行数为 0 - 用户 ID ${userId}`);
return rejectUpdate(new Error('未找到要更新的用户'));
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 已成功激活两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_ENABLED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
@@ -535,6 +607,9 @@ export const disable2FA = async (req: Request, res: Response): Promise<void> =>
return rejectUpdate(new Error('未找到要更新的用户'));
}
console.log(`用户 ${userId} 已成功禁用两步验证。`);
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; // 获取客户端 IP
// 记录审计日志 (添加 IP)
auditLogService.logAction('2FA_DISABLED', { userId, ip: clientIp });
resolveUpdate();
});
stmt.finalize();
+6 -4
View File
@@ -11,17 +11,19 @@ import {
verifyPasskeyRegistration // 导入 Passkey 方法
} from './auth.controller';
import { isAuthenticated } from './auth.middleware';
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件
const router = Router();
// POST /api/v1/auth/login - 用户登录接口
router.post('/login', login);
// POST /api/v1/auth/login - 用户登录接口 (添加黑名单检查)
router.post('/login', ipBlacklistCheckMiddleware, login);
// PUT /api/v1/auth/password - 修改密码接口 (需要认证)
router.put('/password', isAuthenticated, changePassword);
// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session)
router.post('/login/2fa', verifyLogin2FA);
// POST /api/v1/auth/login/2fa - 登录时的 2FA 验证接口 (添加黑名单检查)
// (不需要单独的 isAuthenticated,依赖 login 接口设置的临时 session)
router.post('/login/2fa', ipBlacklistCheckMiddleware, verifyLogin2FA);
// --- 2FA 管理接口 (都需要认证) ---
// POST /api/v1/auth/2fa/setup - 开始 2FA 设置,生成密钥和二维码
@@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from 'express';
import { ipBlacklistService } from '../services/ip-blacklist.service';
/**
* IP 黑名单检查中间件
* 在处理登录相关请求前,检查来源 IP 是否在黑名单中且处于封禁期。
*/
export const ipBlacklistCheckMiddleware = async (req: Request, res: Response, next: NextFunction) => {
// 获取客户端 IP (与 auth.controller 一致)
const clientIp = req.ip || req.socket?.remoteAddress;
if (!clientIp) {
// 如果无法获取 IP,为安全起见,阻止请求
console.warn('[IP Blacklist Check] 无法获取请求 IP 地址,已拒绝访问。');
res.status(403).json({ message: '禁止访问:无法识别来源 IP。' });
return; // 显式返回 void
}
try {
const isBlocked = await ipBlacklistService.isBlocked(clientIp);
if (isBlocked) {
console.warn(`[IP Blacklist Check] 已阻止来自被封禁 IP ${clientIp} 的访问。`);
// 可以返回更通用的错误信息,避免泄露封禁状态
res.status(403).json({ message: '访问被拒绝。' });
// 或者返回更具体的错误
// res.status(429).json({ message: '尝试次数过多,请稍后再试。' });
return; // 显式返回 void
}
// IP 未被封禁,继续处理请求
next();
} catch (error) {
console.error(`[IP Blacklist Check] 检查 IP ${clientIp} 时发生错误:`, error);
// 中间件执行出错,为安全起见,阻止请求
res.status(500).json({ message: '服务器内部错误 (IP 黑名单检查失败)。' });
return; // 显式返回 void
}
};
@@ -3,6 +3,9 @@ import { Request, Response } from 'express';
import * as ConnectionService from '../services/connection.service';
import * as SshService from '../services/ssh.service'; // 引入 SshService
import * as ImportExportService from '../services/import-export.service'; // 引入 ImportExportService
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
// --- 移除所有不再需要的导入和变量 ---
// import { Statement } from 'sqlite3';
@@ -33,6 +36,8 @@ export const createConnection = async (req: Request, res: Response): Promise<voi
// 将请求体传递给服务层处理
const newConnection = await ConnectionService.createConnection(req.body);
// 记录审计日志
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
res.status(201).json({ message: '连接创建成功。', connection: newConnection });
} catch (error: any) {
@@ -107,6 +112,8 @@ export const updateConnection = async (req: Request, res: Response): Promise<voi
if (!updatedConnection) {
res.status(404).json({ message: '连接未找到。' });
} else {
// 记录审计日志
auditLogService.logAction('CONNECTION_UPDATED', { connectionId, updatedFields: Object.keys(req.body) });
res.status(200).json({ message: '连接更新成功。', connection: updatedConnection });
}
} catch (error: any) {
@@ -136,6 +143,8 @@ export const deleteConnection = async (req: Request, res: Response): Promise<voi
if (!deleted) {
res.status(404).json({ message: '连接未找到。' });
} else {
// 记录审计日志
auditLogService.logAction('CONNECTION_DELETED', { connectionId });
res.status(200).json({ message: '连接删除成功。' }); // 或使用 204 No Content
}
} catch (error: any) {
@@ -160,9 +169,13 @@ export const testConnection = async (req: Request, res: Response): Promise<void>
await SshService.testConnection(connectionId);
// 如果 SshService.testConnection 没有抛出错误,则表示成功
// 记录审计日志 (可选,看是否需要记录测试操作)
// auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: true });
res.status(200).json({ success: true, message: '连接测试成功。' });
} catch (error: any) {
// 记录审计日志 (可选)
// auditLogService.logAction('CONNECTION_TESTED', { connectionId, success: false, error: error.message });
console.error(`Controller: 测试连接 ${req.params.id} 时发生错误:`, error);
// SshService 会抛出包含具体原因的 Error
res.status(500).json({ success: false, message: error.message || '测试连接时发生内部服务器错误。' });
@@ -182,6 +195,8 @@ export const exportConnections = async (req: Request, res: Response): Promise<vo
const filename = `nexus-terminal-connections-${timestamp}.json`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/json');
// 记录审计日志 - 使用数组长度
auditLogService.logAction('CONNECTIONS_EXPORTED', { count: exportedData.length });
res.status(200).json(exportedData);
} catch (error: any) {
@@ -213,6 +228,8 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
});
} else {
// Complete success
// 记录审计日志
auditLogService.logAction('CONNECTIONS_IMPORTED', { successCount: result.successCount, failureCount: result.failureCount });
res.status(200).json({
message: `导入成功完成。共导入 ${result.successCount} 条连接。`,
successCount: result.successCount,
+10
View File
@@ -14,6 +14,8 @@ import sftpRouter from './sftp/sftp.routes';
import proxyRoutes from './proxies/proxies.routes'; // 导入代理路由
import tagsRouter from './tags/tags.routes'; // 导入标签路由
import settingsRoutes from './settings/settings.routes'; // 导入设置路由
import notificationRoutes from './notifications/notification.routes'; // 导入通知路由
import auditRoutes from './audit/audit.routes'; // 导入审计路由
import { initializeWebSocket } from './websocket';
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入 IP 白名单中间件
@@ -21,6 +23,12 @@ import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入
const app = express();
const server = http.createServer(app); // 创建 HTTP 服务器实例
// --- 信任代理设置 (用于正确获取 req.ip) ---
// 如果应用部署在反向代理后面,需要设置此项
// 'true' 信任直接连接的代理;更安全的做法是配置具体的代理 IP 或子网
app.set('trust proxy', true);
// --- 结束信任代理设置 ---
// --- 会话存储设置 ---
const SQLiteStore = connectSqlite3(session);
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
@@ -92,6 +100,8 @@ app.use('/api/v1/sftp', sftpRouter);
app.use('/api/v1/proxies', proxyRoutes); // 挂载代理相关的路由
app.use('/api/v1/tags', tagsRouter); // 挂载标签相关的路由
app.use('/api/v1/settings', settingsRoutes); // 挂载设置相关的路由
app.use('/api/v1/notifications', notificationRoutes); // 挂载通知相关的路由
app.use('/api/v1/audit-logs', auditRoutes); // 挂载审计日志相关的路由
// 状态检查接口
app.get('/api/v1/status', (req: Request, res: Response) => {
+161
View File
@@ -41,6 +41,99 @@ CREATE TABLE IF NOT EXISTS passkeys (
);
`;
const createNotificationSettingsTableSQL = `
CREATE TABLE IF NOT EXISTS notification_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_type TEXT NOT NULL CHECK(channel_type IN ('webhook', 'email', 'telegram')),
name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT false,
config TEXT NOT NULL DEFAULT '{}', -- JSON string for channel-specific config
enabled_events TEXT NOT NULL DEFAULT '[]', -- JSON array of event names
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
// --- 新增表结构定义 ---
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 DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
const createProxiesTableSQL = `
CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('SOCKS5', 'HTTP')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NULL,
auth_method TEXT NOT NULL DEFAULT 'none' CHECK(auth_method IN ('none', 'password', 'key')),
encrypted_password TEXT NULL,
encrypted_private_key TEXT NULL,
encrypted_passphrase TEXT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(name, type, host, port)
);
`;
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,
username TEXT NOT NULL,
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
encrypted_password TEXT NULL,
encrypted_private_key TEXT NULL,
encrypted_passphrase TEXT NULL,
proxy_id INTEGER NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_connected_at INTEGER NULL,
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL
);
`;
const createTagsTableSQL = `
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`;
const createConnectionTagsTableSQL = `
CREATE TABLE IF NOT EXISTS connection_tags (
connection_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (connection_id, tag_id),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
`;
const createIpBlacklistTableSQL = `
CREATE TABLE IF NOT EXISTS ip_blacklist (
ip TEXT PRIMARY KEY NOT NULL,
attempts INTEGER NOT NULL DEFAULT 1,
last_attempt_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
blocked_until INTEGER NULL -- 封禁截止时间戳 (秒),NULL 表示未封禁或永久封禁 (根据逻辑决定)
);
`;
// --- 结束新增表结构定义 ---
export const runMigrations = async (db: Database): Promise<void> => {
try {
// 创建 settings 表 (如果不存在)
@@ -96,6 +189,74 @@ export const runMigrations = async (db: Database): Promise<void> => {
});
});
// 创建 notification_settings 表 (如果不存在)
await new Promise<void>((resolve, reject) => {
db.run(createNotificationSettingsTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 notification_settings 表时出错: ${err.message}`));
console.log('Notification_Settings 表已检查/创建。');
resolve();
});
});
// --- 新增表创建逻辑 ---
// 创建 users 表
await new Promise<void>((resolve, reject) => {
db.run(createUsersTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 users 表时出错: ${err.message}`));
console.log('Users 表已检查/创建。');
resolve();
});
});
// 创建 proxies 表
await new Promise<void>((resolve, reject) => {
db.run(createProxiesTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 proxies 表时出错: ${err.message}`));
console.log('Proxies 表已检查/创建。');
resolve();
});
});
// 创建 connections 表 (依赖 proxies)
await new Promise<void>((resolve, reject) => {
db.run(createConnectionsTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 connections 表时出错: ${err.message}`));
console.log('Connections 表已检查/创建。');
resolve();
});
});
// 创建 tags 表
await new Promise<void>((resolve, reject) => {
db.run(createTagsTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 tags 表时出错: ${err.message}`));
console.log('Tags 表已检查/创建。');
resolve();
});
});
// 创建 connection_tags 表 (依赖 connections, tags)
await new Promise<void>((resolve, reject) => {
db.run(createConnectionTagsTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 connection_tags 表时出错: ${err.message}`));
console.log('Connection_Tags 表已检查/创建。');
resolve();
});
});
// 创建 ip_blacklist 表
await new Promise<void>((resolve, reject) => {
db.run(createIpBlacklistTableSQL, (err: Error | null) => {
if (err) return reject(new Error(`创建 ip_blacklist 表时出错: ${err.message}`));
console.log('Ip_Blacklist 表已检查/创建。');
resolve();
});
});
// --- 结束新增表创建逻辑 ---
console.log('所有数据库迁移已完成。');
} catch (error) {
console.error('数据库迁移过程中出错:', error);
@@ -0,0 +1,150 @@
import { Request, Response } from 'express';
import { NotificationService } from '../services/notification.service';
import { NotificationSetting } from '../types/notification.types';
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
export class NotificationController {
private notificationService: NotificationService;
constructor() {
this.notificationService = new NotificationService();
}
// GET /api/v1/notifications
getAll = async (req: Request, res: Response): Promise<void> => {
try {
const settings = await this.notificationService.getAllSettings();
res.status(200).json(settings);
} catch (error: any) {
console.error("Error fetching notification settings:", error);
res.status(500).json({ message: '获取通知设置失败', error: error.message });
}
};
// POST /api/v1/notifications
create = async (req: Request, res: Response): Promise<void> => {
const settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'> = req.body;
// Basic validation (more robust validation can be added)
if (!settingData.channel_type || !settingData.name || !settingData.config) {
res.status(400).json({ message: '缺少必要的通知设置字段 (channel_type, name, config)' });
return;
}
try {
const newSettingId = await this.notificationService.createSetting(settingData);
const newSetting = await this.notificationService.getSettingById(newSettingId); // Fetch the created setting to return it
// 记录审计日志
if (newSetting) {
auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type });
}
res.status(201).json(newSetting);
} catch (error: any) {
console.error("Error creating notification setting:", error);
res.status(500).json({ message: '创建通知设置失败', error: error.message });
}
};
// PUT /api/v1/notifications/:id
update = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10);
const settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>> = req.body;
if (isNaN(id)) {
res.status(400).json({ message: '无效的通知设置 ID' });
return;
}
if (Object.keys(settingData).length === 0) {
res.status(400).json({ message: '没有提供要更新的数据' });
return;
}
try {
const success = await this.notificationService.updateSetting(id, settingData);
if (success) {
const updatedSetting = await this.notificationService.getSettingById(id);
// 记录审计日志
auditLogService.logAction('NOTIFICATION_SETTING_UPDATED', { settingId: id, updatedFields: Object.keys(settingData) });
res.status(200).json(updatedSetting);
} else {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
}
} catch (error: any) {
console.error(`Error updating notification setting ID ${id}:`, error);
res.status(500).json({ message: '更新通知设置失败', error: error.message });
}
};
// DELETE /api/v1/notifications/:id
delete = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({ message: '无效的通知设置 ID' });
return;
}
try {
const success = await this.notificationService.deleteSetting(id);
if (success) {
// 记录审计日志
auditLogService.logAction('NOTIFICATION_SETTING_DELETED', { settingId: id });
res.status(204).send(); // No Content
} else {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
}
} catch (error: any) {
console.error(`Error deleting notification setting ID ${id}:`, error);
res.status(500).json({ message: '删除通知设置失败', error: error.message });
}
};
// POST /api/v1/notifications/:id/test
testSetting = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10);
const { config } = req.body; // Expecting the config to test in the body
if (isNaN(id)) {
res.status(400).json({ message: '无效的通知设置 ID' });
return;
}
if (!config) {
res.status(400).json({ message: '缺少用于测试的配置信息' });
return;
}
try {
// Fetch the original setting to determine the channel type
const originalSetting = await this.notificationService.getSettingById(id);
if (!originalSetting) {
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
return;
}
// Currently, only email testing is implemented
if (originalSetting.channel_type !== 'email') {
res.status(400).json({ message: `当前仅支持测试邮件通知渠道` });
return;
}
// Call the service method to send the test email using the provided config
const result = await this.notificationService.testEmailSetting(config);
if (result.success) {
// 记录审计日志 (可选,根据需要决定是否记录测试操作)
// auditLogService.logAction('NOTIFICATION_SETTING_TESTED', { settingId: id, success: true });
res.status(200).json({ message: result.message });
} else {
// 记录审计日志 (可选)
// auditLogService.logAction('NOTIFICATION_SETTING_TESTED', { settingId: id, success: false, error: result.message });
// Return 500 for test failure to indicate an issue with the config/sending
res.status(500).json({ message: result.message });
}
} catch (error: any) {
console.error(`Error testing notification setting ID ${id}:`, error);
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
}
};
}
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { NotificationController } from './notification.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // Corrected import name
const router = Router();
const notificationController = new NotificationController();
// Apply auth middleware to all notification routes
router.use(isAuthenticated);
// Define routes for notification settings CRUD
router.get('/', notificationController.getAll);
router.post('/', notificationController.create);
router.put('/:id', notificationController.update);
router.delete('/:id', notificationController.delete);
// Route for testing a notification setting (currently only email)
router.post('/:id/test', notificationController.testSetting);
export default router;
@@ -1,5 +1,8 @@
import { Request, Response } from 'express';
import * as ProxyService from '../services/proxy.service';
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
// Helper function to remove sensitive fields for response
const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyService.ProxyData> | null => {
@@ -54,6 +57,8 @@ export const createProxy = async (req: Request, res: Response) => {
}
const newProxy = await ProxyService.createProxy(req.body);
// 记录审计日志
auditLogService.logAction('PROXY_CREATED', { proxyId: newProxy.id, name: newProxy.name, type: newProxy.type });
res.status(201).json({
message: '代理创建成功',
proxy: sanitizeProxy(newProxy) // Return sanitized proxy
@@ -92,6 +97,8 @@ export const updateProxy = async (req: Request, res: Response) => {
const updatedProxy = await ProxyService.updateProxy(proxyId, req.body);
if (updatedProxy) {
// 记录审计日志
auditLogService.logAction('PROXY_UPDATED', { proxyId, updatedFields: Object.keys(req.body) });
res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) });
} else {
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` });
@@ -121,6 +128,8 @@ export const deleteProxy = async (req: Request, res: Response) => {
const deleted = await ProxyService.deleteProxy(proxyId);
if (deleted) {
// 记录审计日志
auditLogService.logAction('PROXY_DELETED', { proxyId });
res.status(200).json({ message: `代理 ${id} 删除成功` });
} else {
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` });
@@ -0,0 +1,112 @@
import { Database } from 'sqlite3';
import { getDb } from '../database';
import { AuditLogEntry, AuditLogActionType } from '../types/audit.types';
export class AuditLogRepository {
private db: Database;
constructor() {
this.db = getDb();
}
/**
* 添加一条审计日志记录
* @param actionType 操作类型
* @param details 可选的详细信息 (对象或字符串)
*/
async addLog(actionType: AuditLogActionType, details?: Record<string, any> | string | null): Promise<void> {
const timestamp = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
let detailsString: string | null = null;
if (details) {
try {
detailsString = typeof details === 'string' ? details : JSON.stringify(details);
} catch (error) {
console.error(`[Audit Log] Failed to stringify details for action ${actionType}:`, error);
detailsString = JSON.stringify({ error: 'Failed to stringify details', originalDetails: details });
}
}
const sql = 'INSERT INTO audit_logs (timestamp, action_type, details) VALUES (?, ?, ?)';
const params = [timestamp, actionType, detailsString];
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) {
console.error(`[Audit Log] Error adding log entry for action ${actionType}: ${err.message}`);
// 不拒绝 Promise,记录日志失败不应阻止核心操作
// 但可以在这里触发一个 SERVER_ERROR 通知或日志
resolve(); // Or potentially reject if logging is critical
} else {
// console.log(`[Audit Log] Logged action: ${actionType}`); // Optional: verbose logging
resolve();
}
});
});
}
/**
* 获取审计日志列表 (支持分页和基本过滤)
* @param limit 每页数量
* @param offset 偏移量
* @param actionType 可选的操作类型过滤
* @param startDate 可选的开始时间戳 (秒)
* @param endDate 可选的结束时间戳 (秒)
*/
async getLogs(
limit: number = 50,
offset: number = 0,
actionType?: AuditLogActionType,
startDate?: number,
endDate?: number
): Promise<{ logs: AuditLogEntry[], total: number }> {
let baseSql = 'SELECT * FROM audit_logs';
let countSql = 'SELECT COUNT(*) as total FROM audit_logs';
const whereClauses: string[] = [];
const params: (string | number)[] = [];
const countParams: (string | number)[] = [];
if (actionType) {
whereClauses.push('action_type = ?');
params.push(actionType);
countParams.push(actionType);
}
if (startDate) {
whereClauses.push('timestamp >= ?');
params.push(startDate);
countParams.push(startDate);
}
if (endDate) {
whereClauses.push('timestamp <= ?');
params.push(endDate);
countParams.push(endDate);
}
if (whereClauses.length > 0) {
const whereSql = ` WHERE ${whereClauses.join(' AND ')}`;
baseSql += whereSql;
countSql += whereSql;
}
baseSql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
return new Promise((resolve, reject) => {
// First get the total count
this.db.get(countSql, countParams, (err, row: { total: number }) => {
if (err) {
return reject(new Error(`Error counting audit logs: ${err.message}`));
}
const total = row.total;
// Then get the paginated logs
this.db.all(baseSql, params, (err, rows: AuditLogEntry[]) => {
if (err) {
return reject(new Error(`Error fetching audit logs: ${err.message}`));
}
resolve({ logs: rows, total });
});
});
});
}
}
@@ -104,7 +104,6 @@ export const findFullConnectionById = async (id: number): Promise<any | null> =>
c.*, -- 选择 connections 表所有列
p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type,
p.host as proxy_host, p.port as proxy_port, p.username as proxy_username,
p.auth_method as proxy_auth_method, -- 包含代理的 auth_method
p.encrypted_password as proxy_encrypted_password,
p.encrypted_private_key as proxy_encrypted_private_key, -- 包含代理的 key
p.encrypted_passphrase as proxy_encrypted_passphrase -- 包含代理的 passphrase
@@ -0,0 +1,151 @@
import { Database } from 'sqlite3';
import { getDb } from '../database';
import { NotificationSetting, RawNotificationSetting, NotificationChannelType, NotificationEvent, NotificationChannelConfig } from '../types/notification.types';
// Helper to parse raw data from DB
const parseRawSetting = (raw: RawNotificationSetting): NotificationSetting => {
try {
return {
...raw,
enabled: Boolean(raw.enabled),
config: JSON.parse(raw.config || '{}'),
enabled_events: JSON.parse(raw.enabled_events || '[]'),
};
} catch (error) {
console.error(`Error parsing notification setting ID ${raw.id}:`, error);
// Return a default/error state or re-throw, depending on desired handling
// For now, return partially parsed with defaults for JSON fields
// Cast to satisfy type checker, but this indicates a parsing error.
return {
...raw,
enabled: Boolean(raw.enabled),
config: {} as NotificationChannelConfig, // Config is invalid due to parsing error
enabled_events: [],
};
}
};
export class NotificationSettingsRepository {
private db: Database;
constructor() {
this.db = getDb();
}
async getAll(): Promise<NotificationSetting[]> {
return new Promise((resolve, reject) => {
this.db.all('SELECT * FROM notification_settings ORDER BY created_at ASC', (err, rows: RawNotificationSetting[]) => {
if (err) {
return reject(new Error(`Error fetching notification settings: ${err.message}`));
}
resolve(rows.map(parseRawSetting));
});
});
}
async getById(id: number): Promise<NotificationSetting | null> {
return new Promise((resolve, reject) => {
this.db.get('SELECT * FROM notification_settings WHERE id = ?', [id], (err, row: RawNotificationSetting) => {
if (err) {
return reject(new Error(`Error fetching notification setting by ID ${id}: ${err.message}`));
}
resolve(row ? parseRawSetting(row) : null);
});
});
}
async getEnabledByEvent(event: NotificationEvent): Promise<NotificationSetting[]> {
return new Promise((resolve, reject) => {
// Note: This query is inefficient as it fetches all enabled settings and filters in code.
// For better performance with many settings, consider normalizing enabled_events
// or using JSON functions if the SQLite version supports them well.
this.db.all('SELECT * FROM notification_settings WHERE enabled = 1', (err, rows: RawNotificationSetting[]) => {
if (err) {
return reject(new Error(`Error fetching enabled notification settings: ${err.message}`));
}
const parsedRows = rows.map(parseRawSetting);
const filteredRows = parsedRows.filter(setting => setting.enabled_events.includes(event));
resolve(filteredRows);
});
});
}
async create(setting: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
const sql = `
INSERT INTO notification_settings (channel_type, name, enabled, config, enabled_events)
VALUES (?, ?, ?, ?, ?)
`;
const params = [
setting.channel_type,
setting.name,
setting.enabled ? 1 : 0,
JSON.stringify(setting.config || {}),
JSON.stringify(setting.enabled_events || [])
];
return new Promise((resolve, reject) => {
this.db.run(sql, params, function (err) { // Use function() to access this.lastID
if (err) {
return reject(new Error(`Error creating notification setting: ${err.message}`));
}
resolve(this.lastID);
});
});
}
async update(id: number, setting: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
// Build the SET part of the query dynamically
const fields: string[] = [];
const params: (string | number | null)[] = [];
if (setting.channel_type !== undefined) {
fields.push('channel_type = ?');
params.push(setting.channel_type);
}
if (setting.name !== undefined) {
fields.push('name = ?');
params.push(setting.name);
}
if (setting.enabled !== undefined) {
fields.push('enabled = ?');
params.push(setting.enabled ? 1 : 0);
}
if (setting.config !== undefined) {
fields.push('config = ?');
params.push(JSON.stringify(setting.config || {}));
}
if (setting.enabled_events !== undefined) {
fields.push('enabled_events = ?');
params.push(JSON.stringify(setting.enabled_events || []));
}
if (fields.length === 0) {
return Promise.resolve(true); // Nothing to update
}
fields.push('updated_at = strftime(\'%s\', \'now\')'); // Always update timestamp
const sql = `UPDATE notification_settings SET ${fields.join(', ')} WHERE id = ?`;
params.push(id);
return new Promise((resolve, reject) => {
this.db.run(sql, params, function (err) { // Use function() to access this.changes
if (err) {
return reject(new Error(`Error updating notification setting ID ${id}: ${err.message}`));
}
resolve(this.changes > 0);
});
});
}
async delete(id: number): Promise<boolean> {
const sql = 'DELETE FROM notification_settings WHERE id = ?';
return new Promise((resolve, reject) => {
this.db.run(sql, [id], function (err) { // Use function() to access this.changes
if (err) {
return reject(new Error(`Error deleting notification setting ID ${id}: ${err.message}`));
}
resolve(this.changes > 0);
});
});
}
}
@@ -0,0 +1,51 @@
import { AuditLogRepository } from '../repositories/audit.repository';
import { AuditLogActionType, AuditLogEntry } from '../types/audit.types';
export class AuditLogService {
private repository: AuditLogRepository;
constructor() {
this.repository = new AuditLogRepository();
}
/**
* 记录一条审计日志
* @param actionType 操作类型
* @param details 可选的详细信息 (对象或字符串)
*/
async logAction(actionType: AuditLogActionType, details?: Record<string, any> | string | null): Promise<void> {
// 在这里可以添加额外的逻辑,例如:
// - 检查是否需要记录此类型的日志 (基于配置)
// - 格式化 details
// - 异步执行,不阻塞主流程
try {
// 使用 'await' 确保日志记录完成(如果需要保证顺序或处理错误)
// 或者不使用 'await' 让其在后台执行
await this.repository.addLog(actionType, details);
} catch (error) {
// Repository 内部已经处理了错误打印,这里可以根据需要再次处理或忽略
console.error(`[Audit Service] Failed to log action ${actionType}:`, error);
}
}
/**
* 获取审计日志列表
* @param limit 每页数量
* @param offset 偏移量
* @param actionType 可选的操作类型过滤
* @param startDate 可选的开始时间戳 (秒)
* @param endDate 可选的结束时间戳 (秒)
*/
async getLogs(
limit: number = 50,
offset: number = 0,
actionType?: AuditLogActionType,
startDate?: number,
endDate?: number
): Promise<{ logs: AuditLogEntry[], total: number }> {
return this.repository.getLogs(limit, offset, actionType, startDate, endDate);
}
}
// Optional: Export a singleton instance if needed throughout the backend
// export const auditLogService = new AuditLogService();
@@ -0,0 +1,223 @@
import { getDb } from '../database';
import { settingsService } from './settings.service'; // 用于获取配置
import * as sqlite3 from 'sqlite3'; // 导入 sqlite3 类型
const db = getDb();
// 黑名单相关设置的 Key
const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts';
const LOGIN_BAN_DURATION_KEY = 'loginBanDuration'; // 单位:秒
// 与 ipWhitelist.middleware.ts 保持一致
const LOCAL_IPS = [
'127.0.0.1', // IPv4 本地回环
'::1', // IPv6 本地回环
'localhost' // 本地主机名 (虽然通常解析为上面两者,但也包含以防万一)
];
// 黑名单条目接口
interface IpBlacklistEntry {
ip: string;
attempts: number;
last_attempt_at: number;
blocked_until: number | null;
}
export class IpBlacklistService {
/**
* 获取指定 IP 的黑名单记录
* @param ip IP 地址
* @returns 黑名单记录或 undefined
*/
private async getEntry(ip: string): Promise<IpBlacklistEntry | undefined> {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM ip_blacklist WHERE ip = ?', [ip], (err, row: IpBlacklistEntry) => {
if (err) {
console.error(`[IP Blacklist] 查询 IP ${ip} 时出错:`, err.message);
return reject(new Error('数据库查询失败'));
}
resolve(row);
});
});
}
/**
* 检查 IP 是否当前被封禁
* @param ip IP 地址
* @returns 如果被封禁则返回 true,否则返回 false
*/
async isBlocked(ip: string): Promise<boolean> {
try {
const entry = await this.getEntry(ip);
if (!entry) {
return false; // 不在黑名单中
}
// 检查封禁时间是否已过
if (entry.blocked_until && entry.blocked_until > Math.floor(Date.now() / 1000)) {
console.log(`[IP Blacklist] IP ${ip} 当前被封禁,直到 ${new Date(entry.blocked_until * 1000).toISOString()}`);
return true; // 仍在封禁期内
}
// 如果封禁时间已过或为 null,则不再封禁
return false;
} catch (error) {
console.error(`[IP Blacklist] 检查 IP ${ip} 封禁状态时出错:`, error);
return false; // 出错时默认不封禁,避免锁死用户
}
}
/**
* 记录一次登录失败尝试
* 如果达到阈值,则封禁该 IP
* @param ip IP 地址
*/
async recordFailedAttempt(ip: string): Promise<void> {
// 如果是本地 IP,则不记录失败尝试,直接返回
if (LOCAL_IPS.includes(ip)) {
console.log(`[IP Blacklist] 检测到本地 IP ${ip} 登录失败,跳过黑名单处理。`);
return;
}
const now = Math.floor(Date.now() / 1000);
try {
// 获取设置,并提供默认值处理
const maxAttemptsStr = await settingsService.getSetting(MAX_LOGIN_ATTEMPTS_KEY);
const banDurationStr = await settingsService.getSetting(LOGIN_BAN_DURATION_KEY);
// 解析设置值,如果无效或未设置,则使用默认值
const maxAttempts = parseInt(maxAttemptsStr || '5', 10) || 5;
const banDuration = parseInt(banDurationStr || '300', 10) || 300;
const entry = await this.getEntry(ip);
if (entry) {
// 更新现有记录
const newAttempts = entry.attempts + 1;
let blockedUntil = entry.blocked_until;
// 检查是否达到封禁阈值
if (newAttempts >= maxAttempts) {
blockedUntil = now + banDuration;
console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`);
}
await new Promise<void>((resolve, reject) => {
db.run(
'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?',
[newAttempts, now, blockedUntil, ip],
(err) => {
if (err) {
console.error(`[IP Blacklist] 更新 IP ${ip} 失败尝试次数时出错:`, err.message);
return reject(err);
}
resolve();
}
);
});
} else {
// 插入新记录
let blockedUntil: number | null = null;
if (1 >= maxAttempts) { // 首次尝试就达到阈值 (虽然不常见)
blockedUntil = now + banDuration;
console.warn(`[IP Blacklist] IP ${ip} 首次登录失败即达到阈值 ${maxAttempts},将被封禁 ${banDuration} 秒。`);
}
await new Promise<void>((resolve, reject) => {
db.run(
'INSERT INTO ip_blacklist (ip, attempts, last_attempt_at, blocked_until) VALUES (?, 1, ?, ?)',
[ip, now, blockedUntil],
(err) => {
if (err) {
console.error(`[IP Blacklist] 插入新 IP ${ip} 失败记录时出错:`, err.message);
return reject(err);
}
resolve();
}
);
});
}
} catch (error) {
console.error(`[IP Blacklist] 记录 IP ${ip} 失败尝试时出错:`, error);
}
}
/**
* 重置指定 IP 的失败尝试次数和封禁状态 (例如登录成功后调用)
* @param ip IP 地址
*/
async resetAttempts(ip: string): Promise<void> {
try {
await new Promise<void>((resolve, reject) => {
// 直接删除记录,或者将 attempts 重置为 0 并清除 blocked_until
db.run('DELETE FROM ip_blacklist WHERE ip = ?', [ip], (err) => {
if (err) {
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, err.message);
return reject(err);
}
console.log(`[IP Blacklist] 已重置 IP ${ip} 的失败尝试记录。`);
resolve();
});
});
} catch (error) {
console.error(`[IP Blacklist] 重置 IP ${ip} 尝试次数时出错:`, error);
}
}
/**
* 获取所有黑名单记录 (用于管理界面)
* @param limit 每页数量
* @param offset 偏移量
*/
async getBlacklist(limit: number = 50, offset: number = 0): Promise<{ entries: IpBlacklistEntry[], total: number }> {
const entries = await new Promise<IpBlacklistEntry[]>((resolve, reject) => {
db.all('SELECT * FROM ip_blacklist ORDER BY last_attempt_at DESC LIMIT ? OFFSET ?', [limit, offset], (err, rows: IpBlacklistEntry[]) => {
if (err) {
console.error('[IP Blacklist] 获取黑名单列表时出错:', err.message);
return reject(new Error('数据库查询失败'));
}
resolve(rows);
});
});
const total = await new Promise<number>((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM ip_blacklist', (err, row: { count: number }) => {
if (err) {
console.error('[IP Blacklist] 获取黑名单总数时出错:', err.message);
return reject(0); // 出错时返回 0
}
resolve(row.count);
});
});
return { entries, total };
}
/**
* 从黑名单中删除一个 IP (解除封禁)
* @param ip IP 地址
*/
async removeFromBlacklist(ip: string): Promise<void> {
try {
await new Promise<void>((resolve, reject) => {
// 将 this 类型改回 RunResult 以访问 changes 属性
db.run('DELETE FROM ip_blacklist WHERE ip = ?', [ip], function(this: sqlite3.RunResult, err: Error | null) {
if (err) {
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, err.message);
return reject(err);
}
if (this.changes === 0) {
console.warn(`[IP Blacklist] 尝试删除 IP ${ip},但该 IP 不在黑名单中。`);
} else {
console.log(`[IP Blacklist] 已从黑名单中删除 IP ${ip}`);
}
resolve();
});
});
} catch (error) {
console.error(`[IP Blacklist] 从黑名单删除 IP ${ip} 时出错:`, error);
throw error; // 重新抛出错误,以便上层处理
}
}
}
// 导出单例
export const ipBlacklistService = new IpBlacklistService();
@@ -0,0 +1,257 @@
import axios, { AxiosRequestConfig } from 'axios';
import { NotificationSettingsRepository } from '../repositories/notification.repository';
import {
NotificationSetting,
NotificationEvent,
NotificationPayload,
WebhookConfig,
EmailConfig, // Ensure EmailConfig is imported
TelegramConfig,
NotificationChannelConfig
} from '../types/notification.types';
import * as nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter
export class NotificationService {
private repository: NotificationSettingsRepository;
constructor() {
this.repository = new NotificationSettingsRepository();
}
async getAllSettings(): Promise<NotificationSetting[]> {
return this.repository.getAll();
}
async getSettingById(id: number): Promise<NotificationSetting | null> {
return this.repository.getById(id);
}
async createSetting(settingData: Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>): Promise<number> {
// Add validation if needed
return this.repository.create(settingData);
}
async updateSetting(id: number, settingData: Partial<Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>>): Promise<boolean> {
// Add validation if needed
// Ensure password is not overwritten if not provided explicitly? Or handle in controller/route.
// For now, we assume the full config (including potentially sensitive fields) is passed for updates if needed.
return this.repository.update(id, settingData);
}
async deleteSetting(id: number): Promise<boolean> {
return this.repository.delete(id);
}
// --- Test Notification Method ---
async testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> {
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 服务器, 端口, 发件人)。' };
}
// Let TypeScript infer the options type for SMTP
const transporterOptions = {
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS)
auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present
} : undefined,
// Consider adding TLS options if needed, e.g., ignore self-signed certs
// tls: {
// rejectUnauthorized: false // Use with caution!
// }
};
const transporter = nodemailer.createTransport(transporterOptions);
const mailOptions: Mail.Options = {
from: config.from,
to: config.to, // Use the 'to' from config for testing
subject: '星枢终端 (Nexus Terminal) 测试邮件',
text: `这是一封来自星枢终端 (Nexus Terminal) 的测试邮件。\n\n如果收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间: ${new Date().toISOString()}`,
html: `<p>这是一封来自 <b>星枢终端 (Nexus Terminal)</b> 的测试邮件。</p><p>如果收到此邮件,表示您的 SMTP 配置工作正常。</p><p>时间: ${new Date().toISOString()}</p>`,
};
try {
console.log(`[Notification Test] Attempting to send test email via ${config.smtpHost}:${config.smtpPort} to ${config.to}`);
const info = await transporter.sendMail(mailOptions);
console.log(`[Notification Test] Test email sent successfully: ${info.messageId}`);
// Verify connection if possible (optional)
// await transporter.verify();
// console.log('[Notification Test] SMTP Connection verified.');
return { success: true, message: '测试邮件发送成功!请检查收件箱。' };
} catch (error: any) {
console.error(`[Notification Test] Error sending test email:`, error);
return { success: false, message: `测试邮件发送失败: ${error.message || '未知错误'}` };
}
}
// --- Core Notification Sending Logic ---
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> {
console.log(`[Notification] Event triggered: ${event}`, details || '');
const payload: NotificationPayload = {
event,
timestamp: Date.now(),
details: details || undefined,
};
try {
const applicableSettings = await this.repository.getEnabledByEvent(event);
console.log(`[Notification] Found ${applicableSettings.length} applicable setting(s) for event ${event}`);
if (applicableSettings.length === 0) {
return; // No enabled settings for this event
}
const sendPromises = applicableSettings.map(setting => {
switch (setting.channel_type) {
case 'webhook':
return this._sendWebhook(setting, payload);
case 'email':
return this._sendEmail(setting, payload);
case 'telegram':
return this._sendTelegram(setting, payload);
default:
console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`);
return Promise.resolve(); // Don't fail all if one is unknown
}
});
// Wait for all notifications to be attempted
await Promise.allSettled(sendPromises);
console.log(`[Notification] Finished attempting notifications for event ${event}`);
} catch (error) {
console.error(`[Notification] Error fetching or processing settings for event ${event}:`, error);
// Decide if this error itself should trigger a notification (e.g., SERVER_ERROR)
// Be careful to avoid infinite loops
}
}
// --- Private Sending Helpers ---
private _renderTemplate(template: string | undefined, payload: NotificationPayload, defaultText: string): string {
if (!template) return defaultText;
let rendered = template;
rendered = rendered.replace(/\{\{event\}\}/g, payload.event);
rendered = rendered.replace(/\{\{timestamp\}\}/g, new Date(payload.timestamp).toISOString());
// Simple details replacement, might need more robust templating engine for complex objects
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
rendered = rendered.replace(/\{\{details\}\}/g, detailsString);
return rendered;
}
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
const config = setting.config as WebhookConfig;
if (!config.url) {
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`);
return;
}
const defaultBody = JSON.stringify(payload, null, 2);
const requestBody = this._renderTemplate(config.bodyTemplate, payload, defaultBody);
const requestConfig: AxiosRequestConfig = {
method: config.method || 'POST',
url: config.url,
headers: {
'Content-Type': 'application/json', // Default, can be overridden by config.headers
...(config.headers || {}),
},
data: requestBody,
timeout: 10000, // Add a timeout (e.g., 10 seconds)
};
try {
console.log(`[Notification] Sending Webhook to ${config.url} for event ${payload.event}`);
const response = await axios(requestConfig);
console.log(`[Notification] Webhook sent successfully to ${config.url}. Status: ${response.status}`);
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data || error.message;
console.error(`[Notification] Error sending Webhook to ${config.url} for setting ID ${setting.id}:`, errorMessage);
}
}
private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
const config = setting.config as EmailConfig;
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
console.error(`[Notification] Email setting ID ${setting.id} is missing required SMTP configuration (to, smtpHost, smtpPort, from).`);
return;
} // <-- Add missing closing brace here
// Let TypeScript infer the options type for SMTP
const transporterOptions = {
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure ?? true, // Default to true (TLS)
auth: (config.smtpUser || config.smtpPass) ? {
user: config.smtpUser,
pass: config.smtpPass, // Ensure password is included if user is present
} : undefined,
// tls: { rejectUnauthorized: false } // Add if needed for self-signed certs, USE WITH CAUTION
};
const transporter = nodemailer.createTransport(transporterOptions);
const defaultSubject = `星枢终端通知: ${payload.event}`;
const subject = this._renderTemplate(config.subjectTemplate, payload, defaultSubject);
// Basic default body (plain text)
const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2);
const defaultBody = `事件: ${payload.event}\n时间: ${new Date(payload.timestamp).toISOString()}\n详情:\n${detailsString}`;
// Note: Email body templates are not implemented in this version. Using default text.
const body = defaultBody;
const mailOptions: Mail.Options = {
from: config.from,
to: config.to,
subject: subject,
text: body,
// html: `<p>${body.replace(/\n/g, '<br>')}</p>` // Simple HTML version
};
try {
console.log(`[Notification] Sending Email via ${config.smtpHost}:${config.smtpPort} to ${config.to} for event ${payload.event}`);
const info = await transporter.sendMail(mailOptions);
console.log(`[Notification] Email sent successfully to ${config.to} for setting ID ${setting.id}. Message ID: ${info.messageId}`);
} catch (error: any) {
console.error(`[Notification] Error sending email for setting ID ${setting.id} via ${config.smtpHost}:`, error);
}
}
private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
const config = setting.config as TelegramConfig;
if (!config.botToken || !config.chatId) {
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
return;
}
// Default message format
const detailsStr = payload.details ? `\n详情: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : '';
const defaultMessage = `*星枢终端通知*\n\n事件: \`${payload.event}\`\n时间: ${new Date(payload.timestamp).toISOString()}${detailsStr}`;
const messageText = this._renderTemplate(config.messageTemplate, payload, defaultMessage);
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
try {
console.log(`[Notification] Sending Telegram message to chat ID ${config.chatId} for event ${payload.event}`);
const response = await axios.post(telegramApiUrl, {
chat_id: config.chatId,
text: messageText,
parse_mode: 'Markdown', // Or 'HTML' depending on template needs
}, { timeout: 10000 }); // Add timeout
console.log(`[Notification] Telegram message sent successfully. Response OK:`, response.data?.ok);
} catch (error: any) {
const errorMessage = error.response?.data?.description || error.response?.data || error.message;
console.error(`[Notification] Error sending Telegram message for setting ID ${setting.id}:`, errorMessage);
}
}
}
// Optional: Export a singleton instance if needed throughout the backend
// export const notificationService = new NotificationService();
+42 -17
View File
@@ -282,28 +282,53 @@ export class SftpService {
}
}
/** 删除目录 */
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sftp) {
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); // Use specific error type
return;
}
console.debug(`[SFTP ${sessionId}] Received rmdir request for ${path} (ID: ${requestId})`);
/** 删除目录 (强制递归) */
async rmdir(sessionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
// 检查 SSH 客户端是否存在,而不是 SFTP 实例
if (!state || !state.sshClient) {
console.warn(`[SSH Exec] SSH 客户端未准备好,无法在 ${sessionId} 上执行 rmdir (ID: ${requestId})`);
state?.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: 'SSH 会话未就绪', requestId: requestId }));
return;
}
console.debug(`[SSH Exec ${sessionId}] Received rmdir (force) request for ${path} (ID: ${requestId})`);
// 构建 rm -rf 命令,确保路径被正确引用
const command = `rm -rf "${path.replace(/"/g, '\\"')}"`; // Basic quoting for paths with spaces/quotes
console.log(`[SSH Exec ${sessionId}] Executing command: ${command} (ID: ${requestId})`);
try {
state.sftp.rmdir(path, (err) => {
state.sshClient.exec(command, (err, stream) => {
if (err) {
console.error(`[SFTP ${sessionId}] rmdir ${path} failed (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${err.message}`, requestId: requestId }));
} else {
console.log(`[SFTP ${sessionId}] rmdir ${path} success (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId })); // Send specific success type
console.error(`[SSH Exec ${sessionId}] Failed to start exec for rmdir ${path} (ID: ${requestId}):`, err);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除命令失败: ${err.message}`, requestId: requestId }));
return;
}
let stderrOutput = '';
stream.stderr.on('data', (data: Buffer) => {
stderrOutput += data.toString();
});
stream.on('close', (code: number | null, signal: string | null) => {
if (code === 0) {
console.log(`[SSH Exec ${sessionId}] rmdir ${path} command executed successfully (ID: ${requestId})`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:success', path: path, requestId: requestId }));
} else {
const errorMessage = stderrOutput.trim() || `命令退出,代码: ${code ?? 'N/A'}${signal ? `, 信号: ${signal}` : ''}`;
console.error(`[SSH Exec ${sessionId}] rmdir ${path} command failed (ID: ${requestId}). Code: ${code}, Signal: ${signal}, Stderr: ${stderrOutput}`);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录失败: ${errorMessage}`, requestId: requestId }));
}
});
stream.on('data', (data: Buffer) => {
// 通常 rm -rf 成功时 stdout 没有输出,但可以记录以防万一
console.debug(`[SSH Exec ${sessionId}] rmdir stdout (ID: ${requestId}): ${data.toString()}`);
});
});
} catch (error: any) {
console.error(`[SFTP ${sessionId}] rmdir ${path} caught unexpected error (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `删除目录时发生意外错误: ${error.message}`, requestId: requestId }));
console.error(`[SSH Exec ${sessionId}] rmdir ${path} caught unexpected error during exec setup (ID: ${requestId}):`, error);
state.ws.send(JSON.stringify({ type: 'sftp:rmdir:error', path: path, payload: `执行删除时发生意外错误: ${error.message}`, requestId: requestId }));
}
}
@@ -1,5 +1,9 @@
import { Request, Response } from 'express';
import { settingsService } from '../services/settings.service';
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 引入 IP 黑名单服务
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
export const settingsController = {
/**
@@ -29,6 +33,14 @@ export const settingsController = {
// 可以在这里添加更严格的验证,例如检查值的类型等
await settingsService.setMultipleSettings(settingsToUpdate);
// 记录审计日志
// 区分 IP 白名单更新和其他设置更新
const updatedKeys = Object.keys(settingsToUpdate);
if (updatedKeys.includes('ipWhitelist')) {
auditLogService.logAction('IP_WHITELIST_UPDATED', { updatedKeys });
} else {
auditLogService.logAction('SETTINGS_UPDATED', { updatedKeys });
}
res.status(200).json({ message: '设置已成功更新' });
} catch (error: any) {
console.error('更新设置时出错:', error);
@@ -42,4 +54,40 @@ export const settingsController = {
// async getSetting(req: Request, res: Response): Promise<void> { ... }
// async setSetting(req: Request, res: Response): Promise<void> { ... }
// async deleteSetting(req: Request, res: Response): Promise<void> { ... }
/**
* IP ()
*/
async getIpBlacklist(req: Request, res: Response): Promise<void> {
try {
const limit = parseInt(req.query.limit as string || '50', 10);
const offset = parseInt(req.query.offset as string || '0', 10);
const result = await ipBlacklistService.getBlacklist(limit, offset);
res.json(result);
} catch (error: any) {
console.error('获取 IP 黑名单时出错:', error);
res.status(500).json({ message: '获取 IP 黑名单失败', error: error.message });
}
},
/**
* IP IP
*/
async deleteIpFromBlacklist(req: Request, res: Response): Promise<void> {
try {
const ipToDelete = req.params.ip;
if (!ipToDelete) {
res.status(400).json({ message: '缺少要删除的 IP 地址' });
return;
}
// TODO: 可以添加对 IP 格式的验证
await ipBlacklistService.removeFromBlacklist(ipToDelete);
// 记录审计日志 (可选)
// auditLogService.logAction('IP_BLACKLIST_REMOVED', { ip: ipToDelete });
res.status(200).json({ message: `IP 地址 ${ipToDelete} 已从黑名单中移除` });
} catch (error: any) {
console.error(`从 IP 黑名单删除 ${req.params.ip} 时出错:`, error);
res.status(500).json({ message: '从 IP 黑名单删除失败', error: error.message });
}
}
};
@@ -11,4 +11,12 @@ router.use(isAuthenticated);
router.get('/', settingsController.getAllSettings); // GET /api/v1/settings
router.put('/', settingsController.updateSettings); // PUT /api/v1/settings
// --- IP 黑名单管理路由 ---
// GET /api/v1/settings/ip-blacklist - 获取 IP 黑名单列表 (需要认证)
router.get('/ip-blacklist', settingsController.getIpBlacklist);
// DELETE /api/v1/settings/ip-blacklist/:ip - 从黑名单中删除指定 IP (需要认证)
router.delete('/ip-blacklist/:ip', settingsController.deleteIpFromBlacklist);
export default router;
@@ -1,5 +1,8 @@
import { Request, Response } from 'express';
import * as TagService from '../services/tag.service';
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
/**
* (POST /api/v1/tags)
@@ -14,6 +17,8 @@ export const createTag = async (req: Request, res: Response): Promise<void> => {
try {
const newTag = await TagService.createTag(name);
// 记录审计日志
auditLogService.logAction('TAG_CREATED', { tagId: newTag.id, name: newTag.name });
res.status(201).json({ message: '标签创建成功。', tag: newTag });
} catch (error: any) {
console.error('Controller: 创建标签时发生错误:', error);
@@ -83,6 +88,8 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
if (!updatedTag) {
res.status(404).json({ message: '标签未找到。' });
} else {
// 记录审计日志
auditLogService.logAction('TAG_UPDATED', { tagId, newName: name });
res.status(200).json({ message: '标签更新成功。', tag: updatedTag });
}
} catch (error: any) {
@@ -114,6 +121,8 @@ export const deleteTag = async (req: Request, res: Response): Promise<void> => {
if (!deleted) {
res.status(404).json({ message: '标签未找到。' });
} else {
// 记录审计日志
auditLogService.logAction('TAG_DELETED', { tagId });
res.status(200).json({ message: '标签删除成功。' });
}
} catch (error: any) {
+69
View File
@@ -0,0 +1,69 @@
// 定义审计日志记录的操作类型
export type AuditLogActionType =
// Authentication
| 'LOGIN_SUCCESS'
| 'LOGIN_FAILURE'
| 'LOGOUT'
| 'PASSWORD_CHANGED'
| '2FA_ENABLED'
| '2FA_DISABLED'
| 'PASSKEY_REGISTERED'
| 'PASSKEY_DELETED' // Assuming deletion is possible later
// Connections
| 'CONNECTION_CREATED'
| 'CONNECTION_UPDATED'
| 'CONNECTION_DELETED'
| 'CONNECTION_TESTED' // Maybe log test attempts?
| 'CONNECTIONS_IMPORTED'
| 'CONNECTIONS_EXPORTED'
// Proxies
| 'PROXY_CREATED'
| 'PROXY_UPDATED'
| 'PROXY_DELETED'
// Tags
| 'TAG_CREATED'
| 'TAG_UPDATED'
| 'TAG_DELETED'
// Settings
| 'SETTINGS_UPDATED' // General settings update
| 'IP_WHITELIST_UPDATED' // Specific setting update
// Notifications
| 'NOTIFICATION_SETTING_CREATED'
| 'NOTIFICATION_SETTING_UPDATED'
| 'NOTIFICATION_SETTING_DELETED'
// API Keys
| 'API_KEY_CREATED'
| 'API_KEY_DELETED'
// SFTP (Consider logging specific actions if needed, e.g., UPLOAD, DOWNLOAD, DELETE_FILE)
| 'SFTP_ACTION' // Generic SFTP action for now
// SSH Actions (via WebSocket)
| 'SSH_CONNECT_SUCCESS'
| 'SSH_CONNECT_FAILURE'
| 'SSH_SHELL_FAILURE'
// System/Error
| 'SERVER_STARTED'
| 'SERVER_ERROR' // Log significant backend errors
| 'DATABASE_MIGRATION';
// 审计日志条目的结构 (从数据库读取时)
export interface AuditLogEntry {
id: number;
timestamp: number; // Unix timestamp (seconds)
action_type: AuditLogActionType;
details: string | null; // JSON string or null
}
// 用于创建日志条目的数据结构
export interface AuditLogData {
actionType: AuditLogActionType;
details?: Record<string, any> | string | null;
}
@@ -0,0 +1,77 @@
export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
export type NotificationEvent =
| 'LOGIN_SUCCESS'
| 'LOGIN_FAILURE'
| 'CONNECTION_ADDED'
| 'CONNECTION_UPDATED'
| 'CONNECTION_DELETED'
| 'SETTINGS_UPDATED'
| 'PROXY_ADDED'
| 'PROXY_UPDATED'
| 'PROXY_DELETED'
| 'TAG_ADDED'
| 'TAG_UPDATED'
| 'TAG_DELETED'
| 'API_KEY_ADDED'
| 'API_KEY_DELETED'
| 'PASSKEY_ADDED'
| 'PASSKEY_DELETED'
| 'SERVER_ERROR'; // Generic error event
export interface WebhookConfig {
url: string;
method?: 'POST' | 'GET' | 'PUT'; // Default to POST
headers?: Record<string, string>; // Optional custom headers
bodyTemplate?: string; // Optional template for the request body (e.g., using placeholders like {{event}}, {{details}})
}
export interface EmailConfig {
to: string; // Comma-separated list of recipient emails
subjectTemplate?: string; // Optional subject template
// SMTP settings per channel
smtpHost?: string;
smtpPort?: number;
smtpSecure?: boolean; // Use TLS
smtpUser?: string;
smtpPass?: string; // Consider encryption or secure storage
from?: string; // Sender email address
}
export interface TelegramConfig {
botToken: string; // Consider storing this securely, maybe encrypted or via env vars
chatId: string; // Target chat ID
messageTemplate?: string; // Optional message template
}
export type NotificationChannelConfig = WebhookConfig | EmailConfig | TelegramConfig;
export interface NotificationSetting {
id?: number;
channel_type: NotificationChannelType;
name: string;
enabled: boolean;
config: NotificationChannelConfig; // Parsed JSON config
enabled_events: NotificationEvent[]; // Parsed JSON array
created_at?: number | string;
updated_at?: number | string;
}
// Raw data structure from the database before parsing JSON fields
export interface RawNotificationSetting {
id: number;
channel_type: NotificationChannelType;
name: string;
enabled: number; // SQLite stores BOOLEAN as 0 or 1
config: string; // JSON string
enabled_events: string; // JSON string
created_at: number | string;
updated_at: number | string;
}
// Type for the data sent with a notification event
export interface NotificationPayload {
event: NotificationEvent;
timestamp: number;
details?: Record<string, any> | string; // Contextual information about the event
}
+95 -115
View File
@@ -5,9 +5,11 @@ import { Client, ClientChannel } from 'ssh2';
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一的会话 ID
import { getDb } from './database';
import { decrypt } from './utils/crypto';
import { SftpService } from './services/sftp.service'; // 引入 SftpService
import { StatusMonitorService } from './services/status-monitor.service'; // 引入 StatusMonitorService
import * as SshService from './services/ssh.service'; // 引入重构后的 SshService 函数
import { SftpService } from './services/sftp.service';
import { StatusMonitorService } from './services/status-monitor.service';
import * as SshService from './services/ssh.service';
import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService
import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType
// 扩展 WebSocket 类型以包含会话 ID
interface AuthenticatedWebSocket extends WebSocket {
@@ -27,6 +29,7 @@ export interface ClientState { // 导出以便 Service 可以导入
dbConnectionId: number;
sftp?: SFTPWrapper; // 添加 sftp 实例 (由 SftpService 管理)
statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理)
ipAddress?: string; // 添加 IP 地址字段
}
// 存储所有活动客户端的状态 (key: sessionId)
@@ -34,8 +37,9 @@ const clientStates = new Map<string, ClientState>();
// --- 服务实例化 ---
// 将 clientStates 传递给需要访问共享状态的服务
const sftpService = new SftpService(clientStates); // 移除 as any
const statusMonitorService = new StatusMonitorService(clientStates); // 移除 as any
const sftpService = new SftpService(clientStates);
const statusMonitorService = new StatusMonitorService(clientStates);
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
/**
* ID
@@ -55,7 +59,6 @@ const cleanupClientConnection = (sessionId: string | undefined) => {
sftpService.cleanupSftpSession(sessionId);
// 3. 清理 SSH 连接 (调用 SshService 中的底层清理逻辑,或直接操作)
// SshService.cleanupConnection(state.ws); // 旧版 SshService 的清理方式,需要调整
state.sshShellStream?.end(); // 结束 shell 流
state.sshClient?.end(); // 结束 SSH 客户端
@@ -102,10 +105,16 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
return;
}
console.log(`WebSocket 认证成功:用户 ${request.session.username} (ID: ${request.session.userId})`);
// 获取客户端 IP 地址
const ipAddress = request.ip;
console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}`);
wss.handleUpgrade(request, socket, head, (ws) => {
const extWs = ws as AuthenticatedWebSocket;
extWs.userId = request.session.userId;
extWs.username = request.session.username;
// 将 IP 地址附加到 request 对象上传递给 connection 事件处理器,以便后续使用
(request as any).clientIpAddress = ipAddress;
wss.emit('connection', extWs, request);
});
});
@@ -152,44 +161,37 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
console.log(`WebSocket: 用户 ${ws.username} 请求连接到数据库 ID: ${dbConnectionId}`);
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在处理连接请求...' }));
// 从传递过来的 request 对象获取 IP 地址 (在 catch 块中也需要访问)
const clientIp = (request as any).clientIpAddress || 'unknown';
try {
// 调用 SshService 建立连接并打开 Shell
// 注意:SshService.connectAndOpenShell 现在需要返回 Client 和 ShellStream
// 或者我们在这里编排,调用 SshService 的不同部分
// 这里采用 SshService.connectAndOpenShell 返回包含 client 和 shell 的对象的假设
// SshService 内部不再管理 activeSessions Map
// 模拟调用 SshService (实际应调用重构后的函数)
// const { client, shellStream } = await SshService.connectAndOpenShell(dbConnectionId, ws); // 假设 SshService 返回这些
// --- 手动编排 SSH 连接流程 ---
// 1. 获取连接信息 (与旧代码类似,但移到这里)
// 1. 获取连接信息
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' }));
const connInfo = await SshService.getConnectionDetails(dbConnectionId); // 假设 SshService 提供此函数
const connInfo = await SshService.getConnectionDetails(dbConnectionId);
// 2. 建立 SSH 连接 (调用 SshService 的底层连接函数)
// 2. 建立 SSH 连接
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` }));
const sshClient = await SshService.establishSshConnection(connInfo); // 假设 SshService 提供此函数
const sshClient = await SshService.establishSshConnection(connInfo);
// 3. 连接成功,创建状态
const newSessionId = uuidv4(); // 生成唯一会话 ID
ws.sessionId = newSessionId; // 关联到 WebSocket
const newSessionId = uuidv4();
ws.sessionId = newSessionId;
const newState: ClientState = {
ws: ws,
sshClient: sshClient,
dbConnectionId: dbConnectionId,
// shellStream 稍后添加
ipAddress: clientIp, // 存储 IP 地址
};
clientStates.set(newSessionId, newState);
console.log(`WebSocket: 为用户 ${ws.username} 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`);
console.log(`WebSocket: 为用户 ${ws.username} (IP: ${clientIp}) 创建新会话 ${newSessionId} (DB ID: ${dbConnectionId})`);
// 4. 打开 Shell
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' }));
try {
const shellStream = await SshService.openShell(sshClient); // 假设 SshService 提供此函数
newState.sshShellStream = shellStream; // 存储 Shell 流
const shellStream = await SshService.openShell(sshClient);
newState.sshShellStream = shellStream;
// 5. 设置 Shell 事件转发
shellStream.on('data', (data: Buffer) => {
@@ -206,46 +208,54 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
shellStream.on('close', () => {
console.log(`SSH: 会话 ${newSessionId} 的 Shell 通道已关闭。`);
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' }));
cleanupClientConnection(newSessionId); // Shell 关闭时清理整个会话
cleanupClientConnection(newSessionId);
});
// 6. 发送 SSH 连接成功消息 (Shell 已就绪)
// 6. 发送 SSH 连接成功消息
ws.send(JSON.stringify({
type: 'ssh:connected',
payload: {
connectionId: dbConnectionId,
sessionId: newSessionId
// sftpReady 标志移除,将通过 sftp_ready 消息通知
}
}));
console.log(`WebSocket: 会话 ${newSessionId} SSH 连接和 Shell 建立成功。`);
// 记录审计日志:SSH 连接成功
auditLogService.logAction('SSH_CONNECT_SUCCESS', {
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress
});
// 7. 异步初始化 SFTP 和启动状态监控
console.log(`WebSocket: 会话 ${newSessionId} 正在异步初始化 SFTP...`);
sftpService.initializeSftpSession(newSessionId)
.then(() => {
console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`);
// SFTP 初始化成功后,前端会收到 sftp_ready 消息
// FileManager 会在 isConnected 变为 true 后自动请求目录
})
.catch(sftpInitError => {
console.error(`WebSocket: 会话 ${newSessionId} 异步初始化 SFTP 失败:`, sftpInitError);
// 错误消息已在 initializeSftpSession 内部发送
});
.then(() => console.log(`SFTP: 会话 ${newSessionId} 异步初始化成功。`))
.catch(sftpInitError => console.error(`WebSocket: 会话 ${newSessionId} 异步初始化 SFTP 失败:`, sftpInitError));
console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
statusMonitorService.startStatusPolling(newSessionId); // 启动状态轮询
statusMonitorService.startStatusPolling(newSessionId);
} catch (shellError: any) {
console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError);
// 记录审计日志:打开 Shell 失败
auditLogService.logAction('SSH_SHELL_FAILURE', {
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
sessionId: newSessionId,
ip: newState.ipAddress,
reason: shellError.message
});
ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${shellError.message}` }));
cleanupClientConnection(newSessionId); // 打开 Shell 失败也需要清理
cleanupClientConnection(newSessionId);
}
// 7. 设置 SSH Client 的关闭和错误处理
// 8. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后)
sshClient.on('close', () => {
console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`);
// Shell 关闭事件通常会先触发清理,这里作为保险
cleanupClientConnection(newSessionId);
});
sshClient.on('error', (err: Error) => {
@@ -255,9 +265,16 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
});
} catch (connectError: any) {
console.error(`WebSocket: 用户 ${ws.username} 连接到数据库 ID ${dbConnectionId} 失败:`, connectError);
console.error(`WebSocket: 用户 ${ws.username} (IP: ${clientIp}) 连接到数据库 ID ${dbConnectionId} 失败:`, connectError);
// 记录审计日志:SSH 连接失败
auditLogService.logAction('SSH_CONNECT_FAILURE', {
userId: ws.userId,
username: ws.username,
connectionId: dbConnectionId,
ip: clientIp,
reason: connectError.message
});
ws.send(JSON.stringify({ type: 'ssh:error', payload: `连接失败: ${connectError.message}` }));
// 此处不需要 cleanup,因为状态尚未创建
}
break;
} // end case 'ssh:connect'
@@ -293,92 +310,73 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
case 'sftp:readdir':
case 'sftp:stat':
case 'sftp:readfile':
case 'sftp:writefile': // Added missing case
case 'sftp:writefile':
case 'sftp:mkdir':
case 'sftp:rmdir':
case 'sftp:unlink':
case 'sftp:rename':
case 'sftp:chmod':
case 'sftp:realpath': { // Add realpath case
case 'sftp:realpath': {
if (!sessionId || !state) {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`);
// 尝试包含 requestId 发送错误,如果 requestId 存在的话
const errPayload: { message: string; requestId?: string } = { message: '无效的会话' };
if (requestId) errPayload.requestId = requestId;
ws.send(JSON.stringify({ type: 'sftp_error', payload: errPayload }));
return;
}
// --- 添加 Request ID 检查 ---
// 对于需要响应关联的操作,强制要求 requestId
if (!requestId) {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 SFTP 请求 (${type}),但缺少 requestId。`);
ws.send(JSON.stringify({ type: 'sftp_error', payload: { message: `SFTP 操作 ${type} 缺少 requestId` } }));
return; // 没有 requestId 则不继续处理
return;
}
// --- 结束 Request ID 检查 ---
// Explicitly call SftpService methods based on type
// TODO: 在这里或 SftpService 内部添加 SFTP 操作的审计日志记录 (可选)
// 例如: auditLogService.logAction('SFTP_ACTION', { type, path: payload?.path, userId: ws.userId, ip: state.ipAddress });
try {
switch (type) {
case 'sftp:readdir':
if (payload?.path) {
sftpService.readdir(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for readdir"); }
if (payload?.path) sftpService.readdir(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for readdir");
break;
case 'sftp:stat':
if (payload?.path) {
sftpService.stat(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for stat"); }
if (payload?.path) sftpService.stat(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for stat");
break;
case 'sftp:readfile':
if (payload?.path) {
sftpService.readFile(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for readfile"); }
if (payload?.path) sftpService.readFile(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for readfile");
break;
case 'sftp:writefile':
// Handle both 'data' (from potential future upload refactor) and 'content'
const fileContent = payload?.content ?? payload?.data ?? ''; // Default to empty string for create
const fileContent = payload?.content ?? payload?.data ?? '';
if (payload?.path) {
// Ensure content is base64 encoded if needed (assuming frontend sends base64 for now)
// If creating empty file, data might be empty string, Buffer.from('') is fine.
const dataToSend = (typeof fileContent === 'string') ? fileContent : ''; // Ensure it's a string
const dataToSend = (typeof fileContent === 'string') ? fileContent : '';
sftpService.writefile(sessionId, payload.path, dataToSend, requestId);
} else { throw new Error("Missing 'path' in payload for writefile"); }
} else throw new Error("Missing 'path' in payload for writefile");
break;
case 'sftp:mkdir':
if (payload?.path) {
sftpService.mkdir(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for mkdir"); }
if (payload?.path) sftpService.mkdir(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for mkdir");
break;
case 'sftp:rmdir':
if (payload?.path) {
sftpService.rmdir(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for rmdir"); }
if (payload?.path) sftpService.rmdir(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for rmdir");
break;
case 'sftp:unlink':
if (payload?.path) {
sftpService.unlink(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for unlink"); }
if (payload?.path) sftpService.unlink(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for unlink");
break;
case 'sftp:rename':
if (payload?.oldPath && payload?.newPath) {
sftpService.rename(sessionId, payload.oldPath, payload.newPath, requestId);
} else { throw new Error("Missing 'oldPath' or 'newPath' in payload for rename"); }
if (payload?.oldPath && payload?.newPath) sftpService.rename(sessionId, payload.oldPath, payload.newPath, requestId);
else throw new Error("Missing 'oldPath' or 'newPath' in payload for rename");
break;
case 'sftp:chmod':
if (payload?.path && typeof payload?.mode === 'number') {
sftpService.chmod(sessionId, payload.path, payload.mode, requestId);
} else { throw new Error("Missing 'path' or invalid 'mode' in payload for chmod"); }
if (payload?.path && typeof payload?.mode === 'number') sftpService.chmod(sessionId, payload.path, payload.mode, requestId);
else throw new Error("Missing 'path' or invalid 'mode' in payload for chmod");
break;
case 'sftp:realpath': // Add realpath handler
if (payload?.path) {
sftpService.realpath(sessionId, payload.path, requestId);
} else { throw new Error("Missing 'path' in payload for realpath"); }
case 'sftp:realpath':
if (payload?.path) sftpService.realpath(sessionId, payload.path, requestId);
else throw new Error("Missing 'path' in payload for realpath");
break;
default:
// Should not happen if already checked type, but as a safeguard
throw new Error(`Unhandled SFTP type: ${type}`);
default: throw new Error(`Unhandled SFTP type: ${type}`);
}
} catch (sftpCallError: any) {
console.error(`WebSocket: Error preparing/calling SFTP service for ${type} (Request ID: ${requestId}):`, sftpCallError);
@@ -386,7 +384,7 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
}
break;
}
// --- SFTP 文件上传 (委托给 SftpService) ---
// --- SFTP 文件上传 ---
case 'sftp:upload:start': {
if (!sessionId || !state) {
console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`);
@@ -398,34 +396,27 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId, remotePath 或 size' } }));
return;
}
// TODO: Add audit log for SFTP upload start?
sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size);
break;
}
case 'sftp:upload:chunk': {
if (!sessionId || !state) {
// Don't warn repeatedly for chunks if session is gone
return;
}
if (!sessionId || !state) return;
if (!payload?.uploadId || typeof payload?.chunkIndex !== 'number' || !payload?.data) {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, chunkIndex 或 data。`);
// Avoid flooding with errors for every chunk if something is wrong
// Consider sending a single error and potentially cancelling on the service side
return;
}
// Assuming data is base64 encoded string from frontend
sftpService.handleUploadChunk(sessionId, payload.uploadId, payload.chunkIndex, payload.data);
break;
}
case 'sftp:upload:cancel': {
if (!sessionId || !state) {
// Don't warn if session is already gone
return;
}
if (!sessionId || !state) return;
if (!payload?.uploadId) {
console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId。`);
ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId' } }));
return;
}
// TODO: Add audit log for SFTP upload cancel?
sftpService.cancelUpload(sessionId, payload.uploadId);
break;
}
@@ -437,20 +428,18 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
} catch (error: any) {
console.error(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的消息 (${type}) 时发生顶层错误:`, error);
ws.send(JSON.stringify({ type: 'error', payload: `处理消息时发生内部错误: ${error.message}` }));
// 考虑是否需要清理连接?取决于错误的性质
// cleanupClientConnection(sessionId);
}
});
// --- 连接关闭和错误处理 ---
ws.on('close', (code, reason) => {
console.log(`WebSocket:客户端 ${ws.username} (会话: ${ws.sessionId}) 已断开连接。代码: ${code}, 原因: ${reason.toString()}`);
cleanupClientConnection(ws.sessionId); // 使用会话 ID 清理
cleanupClientConnection(ws.sessionId);
});
ws.on('error', (error) => {
console.error(`WebSocket:客户端 ${ws.username} (会话: ${ws.sessionId}) 发生错误:`, error);
cleanupClientConnection(ws.sessionId); // 使用会话 ID 清理
cleanupClientConnection(ws.sessionId);
});
});
@@ -458,7 +447,6 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
wss.on('close', () => {
console.log('WebSocket 服务器正在关闭,清理心跳定时器和所有活动会话...');
clearInterval(heartbeatInterval);
// 关闭所有活动的连接
clientStates.forEach((state, sessionId) => {
cleanupClientConnection(sessionId);
});
@@ -470,11 +458,3 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH
};
// --- 移除旧的辅助函数 ---
// - connectSshClient
// - fetchServerStatus
// - executeSshCommand
// - startStatusPolling
// - cleanupSshConnection (旧版本)
// - activeSshConnections Map
// - activeUploads Map
// - previousNetStats Map