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,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 }));
}
}