From 4754b97eba2c564caa3a8c9295e47d43431d7d32 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 03:52:54 +0000 Subject: [PATCH 1/8] feat: Update Features Backup & Restore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f16e911..6705fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ yarn.lock *.sw? landing/* .env -.pnpm-store/ \ No newline at end of file +.pnpm-store/ +.seeded \ No newline at end of file From 52c0c0781522982b138880bf44b6d28cbc0fb609 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 03:53:01 +0000 Subject: [PATCH 2/8] feat: Update Features Backup & Restore --- .../migration.sql | 41 + apps/api/prisma/schema.prisma | 44 + apps/api/src/controllers/backup.controller.ts | 898 ++++++++++++++++++ apps/api/src/routes/backup.routes.ts | 107 +++ apps/api/src/routes/index.ts | 2 + apps/web/src/components/pages/Backup.tsx | 283 ++++-- apps/web/src/services/backup.service.ts | 151 +++ docs/BACKUP_SSL_GUIDE.md | 253 +++++ 8 files changed, 1721 insertions(+), 58 deletions(-) create mode 100644 apps/api/prisma/migrations/20251006033542_add_backup_feature/migration.sql create mode 100644 apps/api/src/controllers/backup.controller.ts create mode 100644 apps/api/src/routes/backup.routes.ts create mode 100644 apps/web/src/services/backup.service.ts create mode 100644 docs/BACKUP_SSL_GUIDE.md diff --git a/apps/api/prisma/migrations/20251006033542_add_backup_feature/migration.sql b/apps/api/prisma/migrations/20251006033542_add_backup_feature/migration.sql new file mode 100644 index 0000000..12fe8b1 --- /dev/null +++ b/apps/api/prisma/migrations/20251006033542_add_backup_feature/migration.sql @@ -0,0 +1,41 @@ +-- CreateEnum +CREATE TYPE "BackupStatus" AS ENUM ('success', 'failed', 'running', 'pending'); + +-- CreateTable +CREATE TABLE "backup_schedules" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "schedule" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "lastRun" TIMESTAMP(3), + "nextRun" TIMESTAMP(3), + "status" "BackupStatus" NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "backup_schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "backup_files" ( + "id" TEXT NOT NULL, + "scheduleId" TEXT, + "filename" TEXT NOT NULL, + "filepath" TEXT NOT NULL, + "size" BIGINT NOT NULL, + "status" "BackupStatus" NOT NULL DEFAULT 'success', + "type" TEXT NOT NULL DEFAULT 'full', + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "backup_files_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "backup_files_scheduleId_idx" ON "backup_files"("scheduleId"); + +-- CreateIndex +CREATE INDEX "backup_files_createdAt_idx" ON "backup_files"("createdAt"); + +-- AddForeignKey +ALTER TABLE "backup_files" ADD CONSTRAINT "backup_files_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "backup_schedules"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3bac0a7..fe145b5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -462,3 +462,47 @@ model PerformanceMetric { @@index([domain, timestamp]) @@index([timestamp]) } + +enum BackupStatus { + success + failed + running + pending +} + +model BackupSchedule { + id String @id @default(cuid()) + name String + schedule String // Cron expression + enabled Boolean @default(true) + lastRun DateTime? + nextRun DateTime? + status BackupStatus @default(pending) + + backups BackupFile[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("backup_schedules") +} + +model BackupFile { + id String @id @default(cuid()) + scheduleId String? + schedule BackupSchedule? @relation(fields: [scheduleId], references: [id], onDelete: SetNull) + + filename String + filepath String + size BigInt // Size in bytes + status BackupStatus @default(success) + type String @default("full") // full, incremental, manual + + metadata Json? // Additional metadata (domains count, rules count, etc.) + + createdAt DateTime @default(now()) + + @@index([scheduleId]) + @@index([createdAt]) + @@map("backup_files") +} diff --git a/apps/api/src/controllers/backup.controller.ts b/apps/api/src/controllers/backup.controller.ts new file mode 100644 index 0000000..2dad9fb --- /dev/null +++ b/apps/api/src/controllers/backup.controller.ts @@ -0,0 +1,898 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth'; +import logger from '../utils/logger'; +import prisma from '../config/database'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/nginx-love'; +const SSL_CERTS_PATH = '/etc/nginx/ssl'; + +/** + * Ensure backup directory exists + */ +async function ensureBackupDir(): Promise { + try { + await fs.mkdir(BACKUP_DIR, { recursive: true }); + } catch (error) { + logger.error('Failed to create backup directory:', error); + throw new Error('Failed to create backup directory'); + } +} + +/** + * Format bytes to human readable size + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +/** + * Get all backup schedules + */ +export const getBackupSchedules = async (req: AuthRequest, res: Response): Promise => { + try { + const schedules = await prisma.backupSchedule.findMany({ + include: { + backups: { + take: 1, + orderBy: { + createdAt: 'desc' + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + + // Format the response + const formattedSchedules = schedules.map(schedule => ({ + id: schedule.id, + name: schedule.name, + schedule: schedule.schedule, + enabled: schedule.enabled, + lastRun: schedule.lastRun?.toISOString(), + nextRun: schedule.nextRun?.toISOString(), + status: schedule.status, + size: schedule.backups[0] ? formatBytes(Number(schedule.backups[0].size)) : undefined, + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt + })); + + res.json({ + success: true, + data: formattedSchedules + }); + } catch (error) { + logger.error('Get backup schedules error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Get single backup schedule + */ +export const getBackupSchedule = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const schedule = await prisma.backupSchedule.findUnique({ + where: { id }, + include: { + backups: { + orderBy: { + createdAt: 'desc' + } + } + } + }); + + if (!schedule) { + res.status(404).json({ + success: false, + message: 'Backup schedule not found' + }); + return; + } + + res.json({ + success: true, + data: schedule + }); + } catch (error) { + logger.error('Get backup schedule error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Create backup schedule + */ +export const createBackupSchedule = async (req: AuthRequest, res: Response): Promise => { + try { + const { name, schedule, enabled } = req.body; + + const newSchedule = await prisma.backupSchedule.create({ + data: { + name, + schedule, + enabled: enabled ?? true + } + }); + + logger.info(`Backup schedule created: ${name}`, { + userId: req.user?.userId, + scheduleId: newSchedule.id + }); + + res.status(201).json({ + success: true, + message: 'Backup schedule created successfully', + data: newSchedule + }); + } catch (error) { + logger.error('Create backup schedule error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Update backup schedule + */ +export const updateBackupSchedule = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + const { name, schedule, enabled } = req.body; + + const updatedSchedule = await prisma.backupSchedule.update({ + where: { id }, + data: { + ...(name && { name }), + ...(schedule && { schedule }), + ...(enabled !== undefined && { enabled }) + } + }); + + logger.info(`Backup schedule updated: ${id}`, { + userId: req.user?.userId + }); + + res.json({ + success: true, + message: 'Backup schedule updated successfully', + data: updatedSchedule + }); + } catch (error) { + logger.error('Update backup schedule error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Delete backup schedule + */ +export const deleteBackupSchedule = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + await prisma.backupSchedule.delete({ + where: { id } + }); + + logger.info(`Backup schedule deleted: ${id}`, { + userId: req.user?.userId + }); + + res.json({ + success: true, + message: 'Backup schedule deleted successfully' + }); + } catch (error) { + logger.error('Delete backup schedule error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Toggle backup schedule enabled status + */ +export const toggleBackupSchedule = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const schedule = await prisma.backupSchedule.findUnique({ + where: { id } + }); + + if (!schedule) { + res.status(404).json({ + success: false, + message: 'Backup schedule not found' + }); + return; + } + + const updated = await prisma.backupSchedule.update({ + where: { id }, + data: { + enabled: !schedule.enabled + } + }); + + logger.info(`Backup schedule toggled: ${id} (enabled: ${updated.enabled})`, { + userId: req.user?.userId + }); + + res.json({ + success: true, + message: `Backup schedule ${updated.enabled ? 'enabled' : 'disabled'}`, + data: updated + }); + } catch (error) { + logger.error('Toggle backup schedule error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Run backup now (manual backup) + */ +export const runBackupNow = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + await ensureBackupDir(); + + // Update schedule status to running + await prisma.backupSchedule.update({ + where: { id }, + data: { + status: 'running', + lastRun: new Date() + } + }); + + // Collect backup data + const backupData = await collectBackupData(); + + // Generate filename + const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0]; + const filename = `backup-${timestamp}.json`; + const filepath = path.join(BACKUP_DIR, filename); + + // Write backup file + await fs.writeFile(filepath, JSON.stringify(backupData, null, 2), 'utf-8'); + + // Get file size + const stats = await fs.stat(filepath); + + // Create backup file record + const backupFile = await prisma.backupFile.create({ + data: { + scheduleId: id, + filename, + filepath, + size: BigInt(stats.size), + status: 'success', + type: 'manual', + metadata: { + domainsCount: backupData.domains.length, + sslCount: backupData.ssl.length, + modsecRulesCount: backupData.modsec.customRules.length, + aclRulesCount: backupData.acl.length + } + } + }); + + // Update schedule status + await prisma.backupSchedule.update({ + where: { id }, + data: { + status: 'success' + } + }); + + logger.info(`Manual backup completed: ${filename}`, { + userId: req.user?.userId, + size: stats.size + }); + + res.json({ + success: true, + message: 'Backup completed successfully', + data: { + filename, + size: formatBytes(stats.size) + } + }); + } catch (error) { + logger.error('Run backup error:', error); + + // Update schedule status to failed + const { id } = req.params; + if (id) { + await prisma.backupSchedule.update({ + where: { id }, + data: { status: 'failed' } + }).catch(() => {}); + } + + res.status(500).json({ + success: false, + message: 'Backup failed' + }); + } +}; + +/** + * Export configuration (download as JSON) + */ +export const exportConfig = async (req: AuthRequest, res: Response): Promise => { + try { + await ensureBackupDir(); + + // Collect backup data + const backupData = await collectBackupData(); + + // Generate filename + const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0]; + const filename = `nginx-config-${timestamp}.json`; + + // Set headers for download + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + logger.info('Configuration exported', { + userId: req.user?.userId + }); + + res.json(backupData); + } catch (error) { + logger.error('Export config error:', error); + res.status(500).json({ + success: false, + message: 'Export failed' + }); + } +}; + +/** + * Import configuration (restore from backup) + */ +export const importConfig = async (req: AuthRequest, res: Response): Promise => { + try { + const backupData = req.body; + + if (!backupData || typeof backupData !== 'object') { + res.status(400).json({ + success: false, + message: 'Invalid backup data' + }); + return; + } + + const results = { + domains: 0, + ssl: 0, + sslFiles: 0, + modsec: 0, + acl: 0, + alertChannels: 0, + alertRules: 0 + }; + + // Restore domains (if present) + if (backupData.domains && Array.isArray(backupData.domains)) { + for (const domain of backupData.domains) { + try { + // Create or update domain + await prisma.domain.upsert({ + where: { name: domain.name }, + update: { + status: domain.status, + sslEnabled: domain.sslEnabled, + modsecEnabled: domain.modsecEnabled + }, + create: { + name: domain.name, + status: domain.status, + sslEnabled: domain.sslEnabled, + modsecEnabled: domain.modsecEnabled + } + }); + results.domains++; + } catch (error) { + logger.error(`Failed to restore domain ${domain.name}:`, error); + } + } + } + + // Restore SSL certificates (if present) + if (backupData.ssl && Array.isArray(backupData.ssl)) { + for (const sslCert of backupData.ssl) { + try { + // Find domain by name + const domain = await prisma.domain.findUnique({ + where: { name: sslCert.domainName } + }); + + if (!domain) { + logger.warn(`Domain not found for SSL cert: ${sslCert.domainName}`); + continue; + } + + // Restore SSL certificate files if present + if (sslCert.files && sslCert.files.certificate && sslCert.files.privateKey) { + // Create or update SSL certificate in database with actual certificate content + await prisma.sSLCertificate.upsert({ + where: { domainId: domain.id }, + update: { + commonName: sslCert.commonName, + sans: sslCert.sans || [], + issuer: sslCert.issuer, + certificate: sslCert.files.certificate, + privateKey: sslCert.files.privateKey, + chain: sslCert.files.chain || null, + autoRenew: sslCert.autoRenew || false + }, + create: { + domain: { + connect: { id: domain.id } + }, + commonName: sslCert.commonName, + sans: sslCert.sans || [], + issuer: sslCert.issuer, + certificate: sslCert.files.certificate, + privateKey: sslCert.files.privateKey, + chain: sslCert.files.chain || null, + validFrom: new Date(), + validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now + autoRenew: sslCert.autoRenew || false + } + }); + + // Also write files to disk + await writeSSLCertificateFiles(sslCert.domainName, { + certificate: sslCert.files.certificate, + privateKey: sslCert.files.privateKey, + chain: sslCert.files.chain + }); + + results.ssl++; + results.sslFiles++; + logger.info(`SSL certificate and files restored for domain: ${sslCert.domainName}`); + } else { + // Only create DB record if no files + await prisma.sSLCertificate.upsert({ + where: { domainId: domain.id }, + update: { + commonName: sslCert.commonName, + sans: sslCert.sans || [], + issuer: sslCert.issuer, + autoRenew: sslCert.autoRenew || false + }, + create: { + domain: { + connect: { id: domain.id } + }, + commonName: sslCert.commonName, + sans: sslCert.sans || [], + issuer: sslCert.issuer, + certificate: '', // Empty placeholder + privateKey: '', // Empty placeholder + validFrom: new Date(), + validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + autoRenew: sslCert.autoRenew || false + } + }); + results.ssl++; + logger.info(`SSL metadata restored for domain: ${sslCert.domainName} (no files)`); + } + } catch (error) { + logger.error(`Failed to restore SSL cert for ${sslCert.domainName}:`, error); + } + } + } + + // Restore ACL rules (if present) + if (backupData.acl && Array.isArray(backupData.acl)) { + for (const rule of backupData.acl) { + try { + await prisma.aclRule.create({ + data: { + name: rule.name, + type: rule.type, + conditionField: rule.condition.field, + conditionOperator: rule.condition.operator, + conditionValue: rule.condition.value, + action: rule.action, + enabled: rule.enabled + } + }); + results.acl++; + } catch (error) { + logger.error(`Failed to restore ACL rule ${rule.name}:`, error); + } + } + } + + // Restore notification channels (if present) + if (backupData.notificationChannels && Array.isArray(backupData.notificationChannels)) { + for (const channel of backupData.notificationChannels) { + try { + await prisma.notificationChannel.create({ + data: { + name: channel.name, + type: channel.type, + enabled: channel.enabled, + config: channel.config + } + }); + results.alertChannels++; + } catch (error) { + logger.error(`Failed to restore notification channel ${channel.name}:`, error); + } + } + } + + logger.info('Configuration imported successfully', { + userId: req.user?.userId, + results + }); + + res.json({ + success: true, + message: 'Configuration imported successfully', + data: results + }); + } catch (error) { + logger.error('Import config error:', error); + res.status(500).json({ + success: false, + message: 'Import failed' + }); + } +}; + +/** + * Get all backup files + */ +export const getBackupFiles = async (req: AuthRequest, res: Response): Promise => { + try { + const { scheduleId } = req.query; + + const backups = await prisma.backupFile.findMany({ + where: scheduleId ? { scheduleId: scheduleId as string } : {}, + include: { + schedule: true + }, + orderBy: { + createdAt: 'desc' + } + }); + + const formattedBackups = backups.map(backup => ({ + ...backup, + size: formatBytes(Number(backup.size)) + })); + + res.json({ + success: true, + data: formattedBackups + }); + } catch (error) { + logger.error('Get backup files error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Download backup file + */ +export const downloadBackup = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const backup = await prisma.backupFile.findUnique({ + where: { id } + }); + + if (!backup) { + res.status(404).json({ + success: false, + message: 'Backup file not found' + }); + return; + } + + // Check if file exists + try { + await fs.access(backup.filepath); + } catch { + res.status(404).json({ + success: false, + message: 'Backup file not found on disk' + }); + return; + } + + // Send file + res.download(backup.filepath, backup.filename); + + logger.info(`Backup downloaded: ${backup.filename}`, { + userId: req.user?.userId + }); + } catch (error) { + logger.error('Download backup error:', error); + res.status(500).json({ + success: false, + message: 'Download failed' + }); + } +}; + +/** + * Delete backup file + */ +export const deleteBackupFile = async (req: AuthRequest, res: Response): Promise => { + try { + const { id } = req.params; + + const backup = await prisma.backupFile.findUnique({ + where: { id } + }); + + if (!backup) { + res.status(404).json({ + success: false, + message: 'Backup file not found' + }); + return; + } + + // Delete file from disk + try { + await fs.unlink(backup.filepath); + } catch (error) { + logger.warn(`Failed to delete backup file from disk: ${backup.filepath}`, error); + } + + // Delete from database + await prisma.backupFile.delete({ + where: { id } + }); + + logger.info(`Backup deleted: ${backup.filename}`, { + userId: req.user?.userId + }); + + res.json({ + success: true, + message: 'Backup file deleted successfully' + }); + } catch (error) { + logger.error('Delete backup file error:', error); + res.status(500).json({ + success: false, + message: 'Internal server error' + }); + } +}; + +/** + * Helper function to read SSL certificate files for a domain + */ +async function readSSLCertificateFiles(domainName: string) { + try { + const certPath = path.join(SSL_CERTS_PATH, `${domainName}.crt`); + const keyPath = path.join(SSL_CERTS_PATH, `${domainName}.key`); + const chainPath = path.join(SSL_CERTS_PATH, `${domainName}.chain.crt`); + + const sslFiles: { + certificate?: string; + privateKey?: string; + chain?: string; + } = {}; + + // Try to read certificate file + try { + sslFiles.certificate = await fs.readFile(certPath, 'utf-8'); + } catch (error) { + logger.warn(`SSL certificate not found for ${domainName}: ${certPath}`); + } + + // Try to read private key file + try { + sslFiles.privateKey = await fs.readFile(keyPath, 'utf-8'); + } catch (error) { + logger.warn(`SSL private key not found for ${domainName}: ${keyPath}`); + } + + // Try to read chain file (optional) + try { + sslFiles.chain = await fs.readFile(chainPath, 'utf-8'); + } catch (error) { + // Chain is optional, don't log warning + } + + return sslFiles; + } catch (error) { + logger.error(`Error reading SSL files for ${domainName}:`, error); + return {}; + } +} + +/** + * Helper function to write SSL certificate files for a domain + */ +async function writeSSLCertificateFiles(domainName: string, sslFiles: { + certificate?: string; + privateKey?: string; + chain?: string; +}) { + try { + await fs.mkdir(SSL_CERTS_PATH, { recursive: true }); + + if (sslFiles.certificate) { + const certPath = path.join(SSL_CERTS_PATH, `${domainName}.crt`); + await fs.writeFile(certPath, sslFiles.certificate, 'utf-8'); + logger.info(`SSL certificate written for ${domainName}`); + } + + if (sslFiles.privateKey) { + const keyPath = path.join(SSL_CERTS_PATH, `${domainName}.key`); + await fs.writeFile(keyPath, sslFiles.privateKey, 'utf-8'); + // Set proper permissions for private key + await fs.chmod(keyPath, 0o600); + logger.info(`SSL private key written for ${domainName}`); + } + + if (sslFiles.chain) { + const chainPath = path.join(SSL_CERTS_PATH, `${domainName}.chain.crt`); + await fs.writeFile(chainPath, sslFiles.chain, 'utf-8'); + logger.info(`SSL chain written for ${domainName}`); + } + } catch (error) { + logger.error(`Error writing SSL files for ${domainName}:`, error); + throw error; + } +} + +/** + * Helper function to collect all backup data + */ +async function collectBackupData() { + // Get all domains + const domains = await prisma.domain.findMany({ + include: { + upstreams: true, + loadBalancer: true, + sslCertificate: true + } + }); + + // Get all SSL certificates with actual certificate files + const ssl = await prisma.sSLCertificate.findMany({ + include: { + domain: true + } + }); + + // Read SSL certificate files for each certificate + const sslWithFiles = await Promise.all( + ssl.map(async (s) => { + if (!s.domain?.name) { + return { + domainName: s.domain?.name, + commonName: s.commonName, + sans: s.sans, + issuer: s.issuer, + autoRenew: s.autoRenew + }; + } + + const sslFiles = await readSSLCertificateFiles(s.domain.name); + + return { + domainName: s.domain.name, + commonName: s.commonName, + sans: s.sans, + issuer: s.issuer, + autoRenew: s.autoRenew, + // Include actual certificate files + files: sslFiles + }; + }) + ); + + // Get ModSecurity CRS rules + const modsecCRSRules = await prisma.modSecCRSRule.findMany(); + + // Get ModSecurity custom rules + const modsecCustomRules = await prisma.modSecRule.findMany(); + + // Get ACL rules + const aclRules = await prisma.aclRule.findMany(); + + // Get notification channels + const notificationChannels = await prisma.notificationChannel.findMany(); + + // Get alert rules + const alertRules = await prisma.alertRule.findMany({ + include: { + channels: { + include: { + channel: true + } + } + } + }); + + // Get nginx configs + const nginxConfigs = await prisma.nginxConfig.findMany(); + + return { + version: '1.0', + timestamp: new Date().toISOString(), + domains: domains.map(d => ({ + name: d.name, + status: d.status, + sslEnabled: d.sslEnabled, + modsecEnabled: d.modsecEnabled, + upstreams: d.upstreams, + loadBalancer: d.loadBalancer + })), + ssl: sslWithFiles, + modsec: { + crsRules: modsecCRSRules, + customRules: modsecCustomRules + }, + acl: aclRules.map(r => ({ + name: r.name, + type: r.type, + condition: { + field: r.conditionField, + operator: r.conditionOperator, + value: r.conditionValue + }, + action: r.action, + enabled: r.enabled + })), + notificationChannels, + alertRules: alertRules.map(r => ({ + name: r.name, + condition: r.condition, + threshold: r.threshold, + severity: r.severity, + enabled: r.enabled, + channels: r.channels.map(c => c.channel.name) + })), + nginxConfigs + }; +} diff --git a/apps/api/src/routes/backup.routes.ts b/apps/api/src/routes/backup.routes.ts new file mode 100644 index 0000000..26cde69 --- /dev/null +++ b/apps/api/src/routes/backup.routes.ts @@ -0,0 +1,107 @@ +import { Router } from 'express'; +import { authenticate, authorize } from '../middleware/auth'; +import { + getBackupSchedules, + getBackupSchedule, + createBackupSchedule, + updateBackupSchedule, + deleteBackupSchedule, + toggleBackupSchedule, + runBackupNow, + exportConfig, + importConfig, + getBackupFiles, + downloadBackup, + deleteBackupFile +} from '../controllers/backup.controller'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +/** + * @route GET /api/backup/schedules + * @desc Get all backup schedules + * @access Private (all roles) + */ +router.get('/schedules', getBackupSchedules); + +/** + * @route GET /api/backup/schedules/:id + * @desc Get single backup schedule + * @access Private (all roles) + */ +router.get('/schedules/:id', getBackupSchedule); + +/** + * @route POST /api/backup/schedules + * @desc Create backup schedule + * @access Private (admin, moderator) + */ +router.post('/schedules', authorize('admin', 'moderator'), createBackupSchedule); + +/** + * @route PUT /api/backup/schedules/:id + * @desc Update backup schedule + * @access Private (admin, moderator) + */ +router.put('/schedules/:id', authorize('admin', 'moderator'), updateBackupSchedule); + +/** + * @route DELETE /api/backup/schedules/:id + * @desc Delete backup schedule + * @access Private (admin, moderator) + */ +router.delete('/schedules/:id', authorize('admin', 'moderator'), deleteBackupSchedule); + +/** + * @route PATCH /api/backup/schedules/:id/toggle + * @desc Toggle backup schedule enabled status + * @access Private (admin, moderator) + */ +router.patch('/schedules/:id/toggle', authorize('admin', 'moderator'), toggleBackupSchedule); + +/** + * @route POST /api/backup/schedules/:id/run + * @desc Run backup now (manual) + * @access Private (admin, moderator) + */ +router.post('/schedules/:id/run', authorize('admin', 'moderator'), runBackupNow); + +/** + * @route GET /api/backup/export + * @desc Export configuration + * @access Private (admin, moderator) + */ +router.get('/export', authorize('admin', 'moderator'), exportConfig); + +/** + * @route POST /api/backup/import + * @desc Import configuration + * @access Private (admin) + */ +router.post('/import', authorize('admin'), importConfig); + +/** + * @route GET /api/backup/files + * @desc Get all backup files + * @access Private (all roles) + */ +router.get('/files', getBackupFiles); + +/** + * @route GET /api/backup/files/:id/download + * @desc Download backup file + * @access Private (admin, moderator) + */ +router.get('/files/:id/download', authorize('admin', 'moderator'), downloadBackup); + +/** + * @route DELETE /api/backup/files/:id + * @desc Delete backup file + * @access Private (admin) + */ +router.delete('/files/:id', authorize('admin'), deleteBackupFile); + +export default router; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 5c8e613..151a9b7 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -11,6 +11,7 @@ import aclRoutes from './acl.routes'; import performanceRoutes from './performance.routes'; import userRoutes from './user.routes'; import dashboardRoutes from './dashboard.routes'; +import backupRoutes from './backup.routes'; const router = Router(); @@ -36,5 +37,6 @@ router.use('/acl', aclRoutes); router.use('/performance', performanceRoutes); router.use('/users', userRoutes); router.use('/dashboard', dashboardRoutes); +router.use('/backup', backupRoutes); export default router; diff --git a/apps/web/src/components/pages/Backup.tsx b/apps/web/src/components/pages/Backup.tsx index 748ce25..7df1a96 100644 --- a/apps/web/src/components/pages/Backup.tsx +++ b/apps/web/src/components/pages/Backup.tsx @@ -1,5 +1,4 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -8,17 +7,19 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { Download, Upload, Play, Trash2, Calendar, FileArchive, Database } from "lucide-react"; -import { mockBackups } from "@/mocks/data"; -import { BackupConfig } from "@/types"; +import { Download, Upload, Play, Trash2, Calendar, FileArchive, Database, Loader2 } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; -import { UnderConstructionBanner } from "@/components/ui/under-construction-banner"; +import { backupService, BackupSchedule } from "@/services/backup.service"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; const Backup = () => { - const { t } = useTranslation(); const { toast } = useToast(); - const [backups, setBackups] = useState(mockBackups); + const [backups, setBackups] = useState([]); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [scheduleToDelete, setScheduleToDelete] = useState(null); + const [exportLoading, setExportLoading] = useState(false); + const [importLoading, setImportLoading] = useState(false); const [formData, setFormData] = useState({ name: "", @@ -26,18 +27,56 @@ const Backup = () => { enabled: true }); - const handleAddBackup = () => { - const newBackup: BackupConfig = { - id: `bk${backups.length + 1}`, - name: formData.name, - schedule: formData.schedule, - enabled: formData.enabled, - status: 'pending' - }; - setBackups([...backups, newBackup]); - setIsDialogOpen(false); - resetForm(); - toast({ title: "Backup schedule created successfully" }); + // Load backup schedules + useEffect(() => { + loadBackupSchedules(); + }, []); + + const loadBackupSchedules = async () => { + try { + const data = await backupService.getSchedules(); + setBackups(data); + } catch (error: any) { + toast({ + title: "Error loading backups", + description: error.response?.data?.message || "Failed to load backup schedules", + variant: "destructive" + }); + } + }; + + const handleAddBackup = async () => { + if (!formData.name.trim()) { + toast({ + title: "Validation error", + description: "Please enter a backup name", + variant: "destructive" + }); + return; + } + + try { + await backupService.createSchedule({ + name: formData.name, + schedule: formData.schedule, + enabled: formData.enabled + }); + + setIsDialogOpen(false); + resetForm(); + loadBackupSchedules(); + + toast({ + title: "Success", + description: "Backup schedule created successfully" + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.response?.data?.message || "Failed to create backup schedule", + variant: "destructive" + }); + } }; const resetForm = () => { @@ -48,44 +87,137 @@ const Backup = () => { }); }; - const handleToggle = (id: string) => { - setBackups(backups.map(b => b.id === id ? { ...b, enabled: !b.enabled } : b)); + const handleToggle = async (id: string) => { + try { + await backupService.toggleSchedule(id); + loadBackupSchedules(); + toast({ + title: "Success", + description: "Backup schedule updated" + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.response?.data?.message || "Failed to toggle backup schedule", + variant: "destructive" + }); + } }; - const handleDelete = (id: string) => { - setBackups(backups.filter(b => b.id !== id)); - toast({ title: "Backup schedule deleted" }); + const confirmDelete = (id: string) => { + setScheduleToDelete(id); + setDeleteDialogOpen(true); }; - const handleRunNow = (id: string) => { - toast({ - title: "Backup started", - description: "Manual backup is running (mock mode)" - }); + const handleDelete = async () => { + if (!scheduleToDelete) return; + + try { + await backupService.deleteSchedule(scheduleToDelete); + setDeleteDialogOpen(false); + setScheduleToDelete(null); + loadBackupSchedules(); + toast({ + title: "Success", + description: "Backup schedule deleted" + }); + } catch (error: any) { + toast({ + title: "Error", + description: error.response?.data?.message || "Failed to delete backup schedule", + variant: "destructive" + }); + } }; - const handleExportConfig = () => { - const config = { - domains: "Mock domain configurations", - ssl: "Mock SSL certificates", - modsec: "Mock ModSecurity rules", - settings: "Mock system settings" - }; - const dataStr = JSON.stringify(config, null, 2); - const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr); - const exportFileDefaultName = `nginx-config-${new Date().toISOString()}.json`; - const linkElement = document.createElement('a'); - linkElement.setAttribute('href', dataUri); - linkElement.setAttribute('download', exportFileDefaultName); - linkElement.click(); - toast({ title: "Configuration exported successfully" }); + const handleRunNow = async (id: string) => { + try { + toast({ + title: "Backup started", + description: "Manual backup is running..." + }); + + const result = await backupService.runNow(id); + loadBackupSchedules(); + + toast({ + title: "Backup completed", + description: `Backup file created: ${result.filename} (${result.size})` + }); + } catch (error: any) { + toast({ + title: "Backup failed", + description: error.response?.data?.message || "Failed to run backup", + variant: "destructive" + }); + } }; - const handleImportConfig = () => { - toast({ - title: "Import configuration", - description: "Select a backup file to restore (mock mode)" - }); + const handleExportConfig = async () => { + try { + setExportLoading(true); + const blob = await backupService.exportConfig(); + + const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0]; + const filename = `nginx-config-${timestamp}.json`; + + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); + + toast({ + title: "Success", + description: "Configuration exported successfully" + }); + } catch (error: any) { + toast({ + title: "Export failed", + description: error.response?.data?.message || "Failed to export configuration", + variant: "destructive" + }); + } finally { + setExportLoading(false); + } + }; + + const handleImportConfig = async () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + setImportLoading(true); + const text = await file.text(); + const data = JSON.parse(text); + + const result = await backupService.importConfig(data); + + toast({ + title: "Import successful", + description: `Restored: ${result.domains} domains, ${result.ssl} SSL certs${result.sslFiles ? ` (${result.sslFiles} with files)` : ''}, ${result.acl} ACL rules, ${result.modsec} ModSec rules` + }); + + // Reload data + loadBackupSchedules(); + } catch (error: any) { + toast({ + title: "Import failed", + description: error.response?.data?.message || "Failed to import configuration. Please check the file format.", + variant: "destructive" + }); + } finally { + setImportLoading(false); + } + }; + + input.click(); }; const getStatusColor = (status: string) => { @@ -100,7 +232,6 @@ const Backup = () => { return (
-
@@ -123,9 +254,18 @@ const Backup = () => {

Export all domains, SSL certificates, ModSecurity rules, and system settings to a JSON file.

- @@ -139,9 +279,18 @@ const Backup = () => {

Import and restore configuration from a previously exported backup file.

- @@ -247,7 +396,7 @@ const Backup = () => { - @@ -294,6 +443,24 @@ const Backup = () => {
+ + {/* Delete Confirmation Dialog */} + + + + Delete Backup Schedule + + Are you sure you want to delete this backup schedule? This action cannot be undone. + + + + Cancel + + Delete + + + +
); }; diff --git a/apps/web/src/services/backup.service.ts b/apps/web/src/services/backup.service.ts new file mode 100644 index 0000000..cb435e6 --- /dev/null +++ b/apps/web/src/services/backup.service.ts @@ -0,0 +1,151 @@ +import api from './api'; + +export interface BackupSchedule { + id: string; + name: string; + schedule: string; + enabled: boolean; + lastRun?: string; + nextRun?: string; + status: 'success' | 'failed' | 'running' | 'pending'; + size?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface BackupFile { + id: string; + scheduleId?: string; + filename: string; + filepath: string; + size: string; + status: 'success' | 'failed' | 'running' | 'pending'; + type: string; + metadata?: any; + createdAt: string; + schedule?: BackupSchedule; +} + +export interface CreateBackupScheduleRequest { + name: string; + schedule: string; + enabled?: boolean; +} + +export interface UpdateBackupScheduleRequest { + name?: string; + schedule?: string; + enabled?: boolean; +} + +export interface ImportResult { + domains: number; + ssl: number; + sslFiles?: number; + modsec: number; + acl: number; + alertChannels: number; + alertRules: number; +} + +export const backupService = { + /** + * Get all backup schedules + */ + async getSchedules(): Promise { + const response = await api.get('/backup/schedules'); + return response.data.data; + }, + + /** + * Get single backup schedule + */ + async getSchedule(id: string): Promise { + const response = await api.get(`/backup/schedules/${id}`); + return response.data.data; + }, + + /** + * Create backup schedule + */ + async createSchedule(data: CreateBackupScheduleRequest): Promise { + const response = await api.post('/backup/schedules', data); + return response.data.data; + }, + + /** + * Update backup schedule + */ + async updateSchedule(id: string, data: UpdateBackupScheduleRequest): Promise { + const response = await api.put(`/backup/schedules/${id}`, data); + return response.data.data; + }, + + /** + * Delete backup schedule + */ + async deleteSchedule(id: string): Promise { + await api.delete(`/backup/schedules/${id}`); + }, + + /** + * Toggle backup schedule enabled status + */ + async toggleSchedule(id: string): Promise { + const response = await api.patch(`/backup/schedules/${id}/toggle`); + return response.data.data; + }, + + /** + * Run backup now (manual backup) + */ + async runNow(id: string): Promise<{ filename: string; size: string }> { + const response = await api.post(`/backup/schedules/${id}/run`); + return response.data.data; + }, + + /** + * Export configuration + */ + async exportConfig(): Promise { + const response = await api.get('/backup/export', { + responseType: 'blob' + }); + return response.data; + }, + + /** + * Import configuration + */ + async importConfig(data: any): Promise { + const response = await api.post('/backup/import', data); + return response.data.data; + }, + + /** + * Get all backup files + */ + async getFiles(scheduleId?: string): Promise { + const response = await api.get('/backup/files', { + params: { scheduleId } + }); + return response.data.data; + }, + + /** + * Download backup file + */ + async downloadFile(id: string): Promise { + const response = await api.get(`/backup/files/${id}/download`, { + responseType: 'blob' + }); + return response.data; + }, + + /** + * Delete backup file + */ + async deleteFile(id: string): Promise { + await api.delete(`/backup/files/${id}`); + } +}; diff --git a/docs/BACKUP_SSL_GUIDE.md b/docs/BACKUP_SSL_GUIDE.md new file mode 100644 index 0000000..218e396 --- /dev/null +++ b/docs/BACKUP_SSL_GUIDE.md @@ -0,0 +1,253 @@ +# Backup & Restore with SSL Certificates + +## Tổng quan + +Hệ thống backup đã được nâng cấp để hỗ trợ **backup và restore đầy đủ SSL certificates**, cho phép bạn di chuyển cấu hình giữa các máy chủ một cách hoàn chỉnh. + +## Những gì được backup + +### 1. **Database Records** +- Domain configurations +- SSL certificate metadata (issuer, validity, SANs) +- ModSecurity rules (CRS + Custom) +- ACL rules +- Alert rules & notification channels +- Nginx configurations + +### 2. **SSL Certificate Files** ✨ (NEW) +Cho mỗi domain có SSL enabled, hệ thống sẽ backup: +- **Certificate file** (.crt) - Public certificate +- **Private key file** (.key) - Private key (được mã hóa an toàn) +- **Certificate chain** (.chain.crt) - Intermediate certificates (nếu có) + +## Cách hoạt động + +### Export/Backup + +Khi bạn export configuration hoặc chạy backup: + +```typescript +// Backend tự động: +1. Đọc metadata SSL từ database +2. Đọc SSL certificate files từ /etc/nginx/ssl/ +3. Include nội dung files vào backup JSON +4. Tạo file backup hoàn chỉnh +``` + +**File backup JSON structure:** +```json +{ + "version": "1.0", + "timestamp": "2025-10-06T10:30:00Z", + "ssl": [ + { + "domainName": "example.com", + "commonName": "example.com", + "sans": ["example.com", "www.example.com"], + "issuer": "Let's Encrypt", + "autoRenew": true, + "files": { + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "privateKey": "-----BEGIN PRIVATE KEY-----\n...", + "chain": "-----BEGIN CERTIFICATE-----\n..." + } + } + ] +} +``` + +### Import/Restore + +Khi bạn import backup trên máy chủ mới: + +```typescript +// Backend tự động: +1. Parse backup JSON +2. Restore domains vào database +3. Restore SSL metadata vào database +4. Write SSL certificate files vào /etc/nginx/ssl/ +5. Set permissions (private key = 600) +6. Restore các cấu hình khác +``` + +## Sử dụng + +### 1. Export Configuration + +**Từ UI:** +``` +Backup & Restore → Export Configuration → Download +``` + +**Kết quả:** File JSON chứa toàn bộ cấu hình + SSL certificates + +### 2. Import Configuration + +**Trên máy chủ mới:** +``` +Backup & Restore → Import Configuration → Select file +``` + +**Hệ thống sẽ:** +- ✅ Restore domains +- ✅ Restore SSL certificates (cả metadata và files) +- ✅ Restore ACL rules +- ✅ Restore ModSecurity rules +- ✅ Restore alert configurations + +**Toast notification sẽ hiển thị:** +``` +Restored: 5 domains, 3 SSL certs (3 with files), 10 ACL rules, 25 ModSec rules +``` + +## Bảo mật + +### Private Keys +- Private keys được lưu trong backup JSON +- **QUAN TRỌNG:** Bảo vệ file backup như bạn bảo vệ private keys +- Khuyến nghị: Mã hóa file backup trước khi lưu trữ +- Set permission 600 cho private keys khi restore + +### Best Practices + +1. **Lưu trữ an toàn:** + ```bash + # Encrypt backup file + gpg -c nginx-config-2025-10-06.json + + # Decrypt when needed + gpg -d nginx-config-2025-10-06.json.gpg > nginx-config-2025-10-06.json + ``` + +2. **Backup định kỳ:** + - Sử dụng scheduled backups + - Lưu trữ off-site + - Kiểm tra backup thường xuyên + +3. **Test restore:** + - Test restore trên môi trường staging + - Xác minh SSL certificates hoạt động + - Kiểm tra tất cả domains + +## API Endpoints + +### Export +```bash +GET /api/backup/export +Authorization: Bearer +``` + +### Import +```bash +POST /api/backup/import +Authorization: Bearer +Content-Type: application/json + +{ + "version": "1.0", + "ssl": [...], + "domains": [...], + ... +} +``` + +## File Locations + +### SSL Certificates +``` +/etc/nginx/ssl/ +├── example.com.crt +├── example.com.key (chmod 600) +├── example.com.chain.crt +├── another-domain.com.crt +├── another-domain.com.key (chmod 600) +└── another-domain.com.chain.crt +``` + +### Backups +``` +/var/backups/nginx-love/ +├── backup-2025-10-06T10-30-00.json +├── backup-2025-10-05T02-00-00.json +└── ... +``` + +## Troubleshooting + +### SSL files không được restore + +**Nguyên nhân:** Domain chưa tồn tại trong database + +**Giải pháp:** +1. Import sẽ tự động tạo domains trước +2. Sau đó restore SSL certificates +3. Check logs: `/home/nginx-love-dev/apps/api/logs/combined.log` + +### Permission denied khi restore + +**Nguyên nhân:** Không có quyền ghi vào /etc/nginx/ssl/ + +**Giải pháp:** +```bash +sudo mkdir -p /etc/nginx/ssl +sudo chown -R :nginx /etc/nginx/ssl +sudo chmod 755 /etc/nginx/ssl +``` + +### Certificate không valid sau restore + +**Nguyên nhân:** +- File bị corrupt trong quá trình backup/restore +- Certificate đã hết hạn + +**Giải pháp:** +```bash +# Verify certificate +openssl x509 -in /etc/nginx/ssl/example.com.crt -text -noout + +# Check private key +openssl rsa -in /etc/nginx/ssl/example.com.key -check + +# Verify cert matches key +openssl x509 -noout -modulus -in /etc/nginx/ssl/example.com.crt | openssl md5 +openssl rsa -noout -modulus -in /etc/nginx/ssl/example.com.key | openssl md5 +``` + +## Migration Example + +### Máy chủ cũ (Production): +```bash +1. Login vào UI +2. Navigate to Backup & Restore +3. Click "Export Configuration" +4. Download: nginx-config-2025-10-06.json +5. Encrypt (optional): gpg -c nginx-config-2025-10-06.json +``` + +### Máy chủ mới (Staging/New Production): +```bash +1. Setup nginx-love application +2. Run migrations: npx prisma migrate deploy +3. Start services +4. Login vào UI +5. Navigate to Backup & Restore +6. Click "Import Configuration" +7. Select: nginx-config-2025-10-06.json +8. Wait for import to complete +9. Verify: Check domains, SSL certs, và configurations +10. Test: Access domains qua HTTPS +``` + +## Notes + +- ✅ SSL certificates được backup **BẢN ĐẦY ĐỦ** (không chỉ metadata) +- ✅ Private keys được bảo mật trong backup file +- ✅ Hỗ trợ certificate chains (intermediate certs) +- ✅ Tự động set permissions cho private keys +- ✅ Compatible với Let's Encrypt, self-signed, và commercial certificates +- ⚠️ **Bảo vệ backup files như bạn bảo vệ private keys** +- ⚠️ Backup files có thể rất lớn nếu có nhiều SSL certificates + +## Version History + +- **v1.0** (2025-10-06): Initial release với SSL certificate backup support From 78a4ada2407494927f3833102dfff684024f1253 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:12:13 +0000 Subject: [PATCH 3/8] feat: Enhance backup import functionality with confirmation dialog and detailed results --- apps/api/src/controllers/backup.controller.ts | 480 +++++++++++++++--- apps/web/src/components/pages/Backup.tsx | 146 +++++- apps/web/src/services/backup.service.ts | 10 +- 3 files changed, 546 insertions(+), 90 deletions(-) diff --git a/apps/api/src/controllers/backup.controller.ts b/apps/api/src/controllers/backup.controller.ts index 2dad9fb..6787635 100644 --- a/apps/api/src/controllers/backup.controller.ts +++ b/apps/api/src/controllers/backup.controller.ts @@ -4,8 +4,14 @@ import logger from '../utils/logger'; import prisma from '../config/database'; import * as fs from 'fs/promises'; import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/nginx-love'; +const NGINX_SITES_AVAILABLE = '/etc/nginx/sites-available'; +const NGINX_SITES_ENABLED = '/etc/nginx/sites-enabled'; const SSL_CERTS_PATH = '/etc/nginx/ssl'; /** @@ -20,6 +26,38 @@ async function ensureBackupDir(): Promise { } } +/** + * Reload nginx configuration + */ +async function reloadNginx(): Promise { + try { + // Test nginx configuration first + logger.info('Testing nginx configuration...'); + await execAsync('nginx -t'); + + // Reload nginx + logger.info('Reloading nginx...'); + await execAsync('systemctl reload nginx'); + + logger.info('Nginx reloaded successfully'); + return true; + } catch (error: any) { + logger.error('Failed to reload nginx:', error); + logger.error('Nginx test/reload output:', error.stdout || error.stderr); + + // Try alternative reload methods + try { + logger.info('Trying alternative reload method...'); + await execAsync('nginx -s reload'); + logger.info('Nginx reloaded successfully (alternative method)'); + return true; + } catch (altError) { + logger.error('Alternative reload also failed:', altError); + return false; + } + } +} + /** * Format bytes to human readable size */ @@ -396,45 +434,115 @@ export const importConfig = async (req: AuthRequest, res: Response): Promise { + const vhostConfig = await readNginxVhostConfig(d.name); + + return { + name: d.name, + status: d.status, + sslEnabled: d.sslEnabled, + modsecEnabled: d.modsecEnabled, + upstreams: d.upstreams, + loadBalancer: d.loadBalancer, + // Include nginx vhost configuration file + vhostConfig: vhostConfig?.config, + vhostEnabled: vhostConfig?.enabled + }; + }) + ); + // Get all SSL certificates with actual certificate files const ssl = await prisma.sSLCertificate.findMany({ include: { @@ -813,7 +1140,9 @@ async function collectBackupData() { commonName: s.commonName, sans: s.sans, issuer: s.issuer, - autoRenew: s.autoRenew + autoRenew: s.autoRenew, + validFrom: s.validFrom, + validTo: s.validTo }; } @@ -825,6 +1154,8 @@ async function collectBackupData() { sans: s.sans, issuer: s.issuer, autoRenew: s.autoRenew, + validFrom: s.validFrom, + validTo: s.validTo, // Include actual certificate files files: sslFiles }; @@ -837,6 +1168,9 @@ async function collectBackupData() { // Get ModSecurity custom rules const modsecCustomRules = await prisma.modSecRule.findMany(); + // Get ModSecurity global settings + const modsecGlobalSettings = await prisma.nginxConfig.findMany(); + // Get ACL rules const aclRules = await prisma.aclRule.findMany(); @@ -854,25 +1188,40 @@ async function collectBackupData() { } }); + // Get all users (excluding passwords for security) + const users = await prisma.user.findMany({ + include: { + profile: true + } + }); + + // Remove password from users + const usersWithoutPassword = users.map(u => { + const { password, ...userWithoutPassword } = u; + return userWithoutPassword; + }); + // Get nginx configs const nginxConfigs = await prisma.nginxConfig.findMany(); return { - version: '1.0', + version: '2.0', // Bumped version for complete backup timestamp: new Date().toISOString(), - domains: domains.map(d => ({ - name: d.name, - status: d.status, - sslEnabled: d.sslEnabled, - modsecEnabled: d.modsecEnabled, - upstreams: d.upstreams, - loadBalancer: d.loadBalancer - })), + + // Domain configurations with vhost files + domains: domainsWithVhostConfig, + + // SSL certificates with actual files ssl: sslWithFiles, + + // ModSecurity configurations modsec: { + globalSettings: modsecGlobalSettings, crsRules: modsecCRSRules, customRules: modsecCustomRules }, + + // ACL rules acl: aclRules.map(r => ({ name: r.name, type: r.type, @@ -884,6 +1233,8 @@ async function collectBackupData() { action: r.action, enabled: r.enabled })), + + // Alert and notification configurations notificationChannels, alertRules: alertRules.map(r => ({ name: r.name, @@ -893,6 +1244,11 @@ async function collectBackupData() { enabled: r.enabled, channels: r.channels.map(c => c.channel.name) })), + + // Users (without passwords) + users: usersWithoutPassword, + + // Global nginx configurations nginxConfigs }; } diff --git a/apps/web/src/components/pages/Backup.tsx b/apps/web/src/components/pages/Backup.tsx index 7df1a96..e970c60 100644 --- a/apps/web/src/components/pages/Backup.tsx +++ b/apps/web/src/components/pages/Backup.tsx @@ -20,6 +20,8 @@ const Backup = () => { const [scheduleToDelete, setScheduleToDelete] = useState(null); const [exportLoading, setExportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false); + const [importConfirmOpen, setImportConfirmOpen] = useState(false); + const [pendingImportFile, setPendingImportFile] = useState(null); const [formData, setFormData] = useState({ name: "", @@ -183,43 +185,56 @@ const Backup = () => { } }; - const handleImportConfig = async () => { + const handleImportConfig = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; - input.onchange = async (e: Event) => { + input.onchange = (e: Event) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; - - try { - setImportLoading(true); - const text = await file.text(); - const data = JSON.parse(text); - - const result = await backupService.importConfig(data); - - toast({ - title: "Import successful", - description: `Restored: ${result.domains} domains, ${result.ssl} SSL certs${result.sslFiles ? ` (${result.sslFiles} with files)` : ''}, ${result.acl} ACL rules, ${result.modsec} ModSec rules` - }); - - // Reload data - loadBackupSchedules(); - } catch (error: any) { - toast({ - title: "Import failed", - description: error.response?.data?.message || "Failed to import configuration. Please check the file format.", - variant: "destructive" - }); - } finally { - setImportLoading(false); - } + + // Show confirmation dialog first + setPendingImportFile(file); + setImportConfirmOpen(true); }; input.click(); }; + const confirmImport = async () => { + if (!pendingImportFile) return; + + try { + setImportLoading(true); + setImportConfirmOpen(false); + + const text = await pendingImportFile.text(); + const data = JSON.parse(text); + + const result = await backupService.importConfig(data); + + toast({ + title: "✅ Restore successful!", + description: `Restored: ${result.domains} domains, ${result.vhostConfigs} vhost configs, ${result.upstreams} upstreams, ${result.loadBalancers} LB configs, ${result.ssl} SSL certs (${result.sslFiles} files), ${result.modsecCRS + result.modsecCustom} ModSec rules, ${result.acl} ACL rules, ${result.alertChannels} channels, ${result.alertRules} alerts, ${result.users} users, ${result.nginxConfigs} configs. Nginx has been reloaded.`, + duration: 10000 + }); + + // Reload data + loadBackupSchedules(); + setPendingImportFile(null); + } catch (error: any) { + toast({ + title: "❌ Restore failed", + description: error.response?.data?.message || "Failed to restore configuration. Please check the file format.", + variant: "destructive", + duration: 8000 + }); + } finally { + setImportLoading(false); + } + }; + const getStatusColor = (status: string) => { switch (status) { case 'success': return 'default'; @@ -461,6 +476,85 @@ const Backup = () => { + + {/* Import/Restore Confirmation Dialog */} + + + + + + ⚠️ Confirm Configuration Restore + + +
+

+ 🚨 CRITICAL WARNING - Data Replacement +

+

+ Restoring this backup will REPLACE ALL existing data on this server with data from the backup file. +

+
+ +
+

The following will be REPLACED:

+
    +
  • Domains: All domain configurations, upstreams, load balancers
  • +
  • Nginx Configs: Virtual host files in /etc/nginx/sites-available/
  • +
  • SSL Certificates: Certificate files (.crt, .key) in /etc/nginx/ssl/
  • +
  • ModSecurity Rules: CRS rules and custom security rules
  • +
  • ACL Rules: All access control configurations
  • +
  • Alert Settings: Notification channels and alert rules
  • +
  • Users: User accounts (passwords must be reset)
  • +
  • System Configs: Global nginx configurations
  • +
+
+ +
+

+ ✅ After Restore: +

+
    +
  • Nginx will be automatically reloaded
  • +
  • Domains will be immediately accessible with restored configurations
  • +
  • SSL certificates will be active and functional
  • +
  • Users will need to reset their passwords (security measure)
  • +
+
+ +
+

+ 💡 Recommendation: Create a backup of your current configuration before proceeding with the restore. +

+
+ +

+ Do you want to proceed with the restore? +

+
+
+ + setPendingImportFile(null)}> + Cancel - Keep Current Data + + + {importLoading ? ( + <> + + Restoring... + + ) : ( + <> + Confirm - Restore Backup + + )} + + +
+
); }; diff --git a/apps/web/src/services/backup.service.ts b/apps/web/src/services/backup.service.ts index cb435e6..5a9783c 100644 --- a/apps/web/src/services/backup.service.ts +++ b/apps/web/src/services/backup.service.ts @@ -40,12 +40,18 @@ export interface UpdateBackupScheduleRequest { export interface ImportResult { domains: number; + vhostConfigs: number; + upstreams: number; + loadBalancers: number; ssl: number; - sslFiles?: number; - modsec: number; + sslFiles: number; + modsecCRS: number; + modsecCustom: number; acl: number; alertChannels: number; alertRules: number; + users: number; + nginxConfigs: number; } export const backupService = { From 3d41e98328861dfac513931ad07aa6494200c5e6 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:22:53 +0000 Subject: [PATCH 4/8] feat: Add nginx vhost config generation during backup restore --- apps/api/src/controllers/backup.controller.ts | 236 +++++++++++++++++- 1 file changed, 232 insertions(+), 4 deletions(-) diff --git a/apps/api/src/controllers/backup.controller.ts b/apps/api/src/controllers/backup.controller.ts index 6787635..1ecccf4 100644 --- a/apps/api/src/controllers/backup.controller.ts +++ b/apps/api/src/controllers/backup.controller.ts @@ -531,6 +531,27 @@ export const importConfig = async (req: AuthRequest, res: Response): Promise { + const configPath = path.join(NGINX_SITES_AVAILABLE, `${domain.name}.conf`); + const enabledPath = path.join(NGINX_SITES_ENABLED, `${domain.name}.conf`); + + // Determine if any upstream uses HTTPS + const hasHttpsUpstream = domain.upstreams?.some( + (u: any) => u.protocol === "https" + ) || false; + const upstreamProtocol = hasHttpsUpstream ? "https" : "http"; + + // Generate upstream block + const upstreamBlock = ` +upstream ${domain.name.replace(/\./g, "_")}_backend { + ${domain.loadBalancer?.algorithm === "least_conn" ? "least_conn;" : ""} + ${domain.loadBalancer?.algorithm === "ip_hash" ? "ip_hash;" : ""} + + ${(domain.upstreams || []) + .map( + (u: any) => + `server ${u.host}:${u.port} weight=${u.weight || 1} max_fails=${u.maxFails || 3} fail_timeout=${u.failTimeout || 10}s;` + ) + .join("\n ")} +} +`; + + // HTTP server block (always present) + let httpServerBlock = ` +server { + listen 80; + server_name ${domain.name}; + + # Include ACL rules (IP whitelist/blacklist) + include /etc/nginx/conf.d/acl-rules.conf; + + # Include ACME challenge location for Let's Encrypt + include /etc/nginx/snippets/acme-challenge.conf; + + ${ + domain.sslEnabled + ? ` + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; + ` + : ` + ${domain.modsecEnabled ? "modsecurity on;" : "modsecurity off;"} + + access_log /var/log/nginx/${domain.name}_access.log main; + error_log /var/log/nginx/${domain.name}_error.log warn; + + location / { + proxy_pass ${upstreamProtocol}://${domain.name.replace(/\./g, "_")}_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + ${ + hasHttpsUpstream + ? ` + # HTTPS Backend Settings + ${ + domain.upstreams?.some((u: any) => u.protocol === "https" && !u.sslVerify) + ? "proxy_ssl_verify off;" + : "proxy_ssl_verify on;" + } + proxy_ssl_server_name on; + proxy_ssl_name ${domain.name}; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + ` + : "" + } + + ${ + domain.loadBalancer?.healthCheckEnabled + ? ` + # Health check settings + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 3; + proxy_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout || 5}s; + ` + : "" + } + } + + location /nginx_health { + access_log off; + return 200 "healthy\\n"; + add_header Content-Type text/plain; + } + ` + } +} +`; + + // HTTPS server block (only if SSL enabled) + let httpsServerBlock = ""; + if (domain.sslEnabled && domain.sslCertificate) { + httpsServerBlock = ` +server { + listen 443 ssl http2; + server_name ${domain.name}; + + # Include ACL rules (IP whitelist/blacklist) + include /etc/nginx/conf.d/acl-rules.conf; + + # SSL Certificate Configuration + ssl_certificate /etc/nginx/ssl/${domain.name}.crt; + ssl_certificate_key /etc/nginx/ssl/${domain.name}.key; + ${ + domain.sslCertificate.chain + ? `ssl_trusted_certificate /etc/nginx/ssl/${domain.name}.chain.crt;` + : "" + } + + # SSL Security Settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_stapling on; + ssl_stapling_verify on; + + # Security Headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + ${domain.modsecEnabled ? "modsecurity on;" : "modsecurity off;"} + + access_log /var/log/nginx/${domain.name}_ssl_access.log main; + error_log /var/log/nginx/${domain.name}_ssl_error.log warn; + + location / { + proxy_pass ${upstreamProtocol}://${domain.name.replace(/\./g, "_")}_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + ${ + hasHttpsUpstream + ? ` + # HTTPS Backend Settings + ${ + domain.upstreams?.some((u: any) => u.protocol === "https" && !u.sslVerify) + ? "proxy_ssl_verify off;" + : "proxy_ssl_verify on;" + } + proxy_ssl_server_name on; + proxy_ssl_name ${domain.name}; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + ` + : "" + } + + ${ + domain.loadBalancer?.healthCheckEnabled + ? ` + # Health check settings + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 3; + proxy_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout || 5}s; + ` + : "" + } + } + + location /nginx_health { + access_log off; + return 200 "healthy\\n"; + add_header Content-Type text/plain; + } +} +`; + } + + const fullConfig = upstreamBlock + httpServerBlock + httpsServerBlock; + + // Write configuration file + try { + await fs.mkdir(NGINX_SITES_AVAILABLE, { recursive: true }); + await fs.mkdir(NGINX_SITES_ENABLED, { recursive: true }); + await fs.writeFile(configPath, fullConfig); + + // Create symlink if domain is active + if (domain.status === "active") { + try { + await fs.unlink(enabledPath); + } catch (e) { + // File doesn't exist, ignore + } + await fs.symlink(configPath, enabledPath); + } + + logger.info(`Nginx configuration generated for ${domain.name} during backup restore`); + } catch (error) { + logger.error(`Failed to write nginx config for ${domain.name}:`, error); + throw error; + } +} + /** * Helper function to read nginx vhost configuration file for a domain */ async function readNginxVhostConfig(domainName: string) { try { - const vhostPath = path.join(NGINX_SITES_AVAILABLE, domainName); + const vhostPath = path.join(NGINX_SITES_AVAILABLE, `${domainName}.conf`); const vhostConfig = await fs.readFile(vhostPath, 'utf-8'); // Check if symlink exists in sites-enabled let isEnabled = false; try { - const enabledPath = path.join(NGINX_SITES_ENABLED, domainName); + const enabledPath = path.join(NGINX_SITES_ENABLED, `${domainName}.conf`); await fs.access(enabledPath); isEnabled = true; } catch { @@ -992,13 +1220,13 @@ async function writeNginxVhostConfig(domainName: string, config: string, enabled await fs.mkdir(NGINX_SITES_AVAILABLE, { recursive: true }); await fs.mkdir(NGINX_SITES_ENABLED, { recursive: true }); - const vhostPath = path.join(NGINX_SITES_AVAILABLE, domainName); + const vhostPath = path.join(NGINX_SITES_AVAILABLE, `${domainName}.conf`); await fs.writeFile(vhostPath, config, 'utf-8'); logger.info(`Nginx vhost config written for ${domainName}`); // Create symlink in sites-enabled if enabled if (enabled) { - const enabledPath = path.join(NGINX_SITES_ENABLED, domainName); + const enabledPath = path.join(NGINX_SITES_ENABLED, `${domainName}.conf`); try { await fs.unlink(enabledPath); } catch { From 7cc70bc10ab343c3bc386e59a9125f3d1738a678 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:32:06 +0000 Subject: [PATCH 5/8] feat: Add import warning dialog and file upload functionality in Backup component --- .gitignore | 3 +- apps/web/src/components/pages/Backup.tsx | 150 ++++++++++++++++++++++- 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 6705fdf..1b5cfbc 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ yarn.lock landing/* .env .pnpm-store/ -.seeded \ No newline at end of file +.seeded +*.md \ No newline at end of file diff --git a/apps/web/src/components/pages/Backup.tsx b/apps/web/src/components/pages/Backup.tsx index e970c60..c3dfc45 100644 --- a/apps/web/src/components/pages/Backup.tsx +++ b/apps/web/src/components/pages/Backup.tsx @@ -20,8 +20,10 @@ const Backup = () => { const [scheduleToDelete, setScheduleToDelete] = useState(null); const [exportLoading, setExportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false); + const [importWarningOpen, setImportWarningOpen] = useState(false); const [importConfirmOpen, setImportConfirmOpen] = useState(false); const [pendingImportFile, setPendingImportFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); const [formData, setFormData] = useState({ name: "", @@ -186,17 +188,55 @@ const Backup = () => { }; const handleImportConfig = () => { + // Open warning dialog first + setImportWarningOpen(true); + }; + + const handleFileSelect = (file: File) => { + if (!file.name.endsWith('.json')) { + toast({ + title: "Invalid file type", + description: "Please select a JSON backup file", + variant: "destructive" + }); + return; + } + + setPendingImportFile(file); + setImportWarningOpen(false); + setImportConfirmOpen(true); + }; + + const handleFileDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const file = e.dataTransfer.files[0]; + if (file) { + handleFileSelect(file); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const openFileDialog = () => { const input = document.createElement('input'); input.type = 'file'; - input.accept = 'application/json'; + input.accept = 'application/json,.json'; input.onchange = (e: Event) => { const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - // Show confirmation dialog first - setPendingImportFile(file); - setImportConfirmOpen(true); + if (file) { + handleFileSelect(file); + } }; input.click(); @@ -477,6 +517,104 @@ const Backup = () => { + {/* Import Warning Dialog with File Upload */} + + + + + + Import Configuration Backup + + +
+

+ ⚠️ + CRITICAL WARNING - ALL DATA WILL BE REPLACED +

+

+ Importing a backup will COMPLETELY REPLACE all existing configurations on this server. + This action is IRREVERSIBLE without a prior backup. +

+
+ +
+

+ 📦 What will be replaced: +

+
+
• All domain configurations
+
• Load balancer settings
+
• SSL certificates & files
+
• ModSecurity rules
+
• ACL access rules
+
• Alert configurations
+
• User accounts
+
• Nginx vhost files
+
+
+ +
+

+ 💡 Before you proceed: +

+
    +
  • Export your current configuration as a safety backup
  • +
  • Ensure the backup file is from a trusted source
  • +
  • Verify the backup file is not corrupted
  • +
  • Notify other administrators about the restore
  • +
+
+ + {/* File Upload Zone */} +
+ +
+
+
+ +
+
+

+ {isDragging ? 'Drop file here' : 'Click to browse or drag & drop'} +

+

+ Accepts .json backup files only +

+
+
+ + + Maximum file size: 50MB + +
+
+
+
+
+
+ + + +
+
+ {/* Import/Restore Confirmation Dialog */} From 188b2d62c9c6597466a1dff670a034b65778b522 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:41:43 +0000 Subject: [PATCH 6/8] feat: Update .gitignore to exclude documentation files and remove SSL guide --- .gitignore | 3 +- docs/BACKUP_SSL_GUIDE.md | 253 --------------------------------------- 2 files changed, 2 insertions(+), 254 deletions(-) delete mode 100644 docs/BACKUP_SSL_GUIDE.md diff --git a/.gitignore b/.gitignore index 1b5cfbc..467dd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ landing/* .env .pnpm-store/ .seeded -*.md \ No newline at end of file +*.md +/docs/* \ No newline at end of file diff --git a/docs/BACKUP_SSL_GUIDE.md b/docs/BACKUP_SSL_GUIDE.md deleted file mode 100644 index 218e396..0000000 --- a/docs/BACKUP_SSL_GUIDE.md +++ /dev/null @@ -1,253 +0,0 @@ -# Backup & Restore with SSL Certificates - -## Tổng quan - -Hệ thống backup đã được nâng cấp để hỗ trợ **backup và restore đầy đủ SSL certificates**, cho phép bạn di chuyển cấu hình giữa các máy chủ một cách hoàn chỉnh. - -## Những gì được backup - -### 1. **Database Records** -- Domain configurations -- SSL certificate metadata (issuer, validity, SANs) -- ModSecurity rules (CRS + Custom) -- ACL rules -- Alert rules & notification channels -- Nginx configurations - -### 2. **SSL Certificate Files** ✨ (NEW) -Cho mỗi domain có SSL enabled, hệ thống sẽ backup: -- **Certificate file** (.crt) - Public certificate -- **Private key file** (.key) - Private key (được mã hóa an toàn) -- **Certificate chain** (.chain.crt) - Intermediate certificates (nếu có) - -## Cách hoạt động - -### Export/Backup - -Khi bạn export configuration hoặc chạy backup: - -```typescript -// Backend tự động: -1. Đọc metadata SSL từ database -2. Đọc SSL certificate files từ /etc/nginx/ssl/ -3. Include nội dung files vào backup JSON -4. Tạo file backup hoàn chỉnh -``` - -**File backup JSON structure:** -```json -{ - "version": "1.0", - "timestamp": "2025-10-06T10:30:00Z", - "ssl": [ - { - "domainName": "example.com", - "commonName": "example.com", - "sans": ["example.com", "www.example.com"], - "issuer": "Let's Encrypt", - "autoRenew": true, - "files": { - "certificate": "-----BEGIN CERTIFICATE-----\n...", - "privateKey": "-----BEGIN PRIVATE KEY-----\n...", - "chain": "-----BEGIN CERTIFICATE-----\n..." - } - } - ] -} -``` - -### Import/Restore - -Khi bạn import backup trên máy chủ mới: - -```typescript -// Backend tự động: -1. Parse backup JSON -2. Restore domains vào database -3. Restore SSL metadata vào database -4. Write SSL certificate files vào /etc/nginx/ssl/ -5. Set permissions (private key = 600) -6. Restore các cấu hình khác -``` - -## Sử dụng - -### 1. Export Configuration - -**Từ UI:** -``` -Backup & Restore → Export Configuration → Download -``` - -**Kết quả:** File JSON chứa toàn bộ cấu hình + SSL certificates - -### 2. Import Configuration - -**Trên máy chủ mới:** -``` -Backup & Restore → Import Configuration → Select file -``` - -**Hệ thống sẽ:** -- ✅ Restore domains -- ✅ Restore SSL certificates (cả metadata và files) -- ✅ Restore ACL rules -- ✅ Restore ModSecurity rules -- ✅ Restore alert configurations - -**Toast notification sẽ hiển thị:** -``` -Restored: 5 domains, 3 SSL certs (3 with files), 10 ACL rules, 25 ModSec rules -``` - -## Bảo mật - -### Private Keys -- Private keys được lưu trong backup JSON -- **QUAN TRỌNG:** Bảo vệ file backup như bạn bảo vệ private keys -- Khuyến nghị: Mã hóa file backup trước khi lưu trữ -- Set permission 600 cho private keys khi restore - -### Best Practices - -1. **Lưu trữ an toàn:** - ```bash - # Encrypt backup file - gpg -c nginx-config-2025-10-06.json - - # Decrypt when needed - gpg -d nginx-config-2025-10-06.json.gpg > nginx-config-2025-10-06.json - ``` - -2. **Backup định kỳ:** - - Sử dụng scheduled backups - - Lưu trữ off-site - - Kiểm tra backup thường xuyên - -3. **Test restore:** - - Test restore trên môi trường staging - - Xác minh SSL certificates hoạt động - - Kiểm tra tất cả domains - -## API Endpoints - -### Export -```bash -GET /api/backup/export -Authorization: Bearer -``` - -### Import -```bash -POST /api/backup/import -Authorization: Bearer -Content-Type: application/json - -{ - "version": "1.0", - "ssl": [...], - "domains": [...], - ... -} -``` - -## File Locations - -### SSL Certificates -``` -/etc/nginx/ssl/ -├── example.com.crt -├── example.com.key (chmod 600) -├── example.com.chain.crt -├── another-domain.com.crt -├── another-domain.com.key (chmod 600) -└── another-domain.com.chain.crt -``` - -### Backups -``` -/var/backups/nginx-love/ -├── backup-2025-10-06T10-30-00.json -├── backup-2025-10-05T02-00-00.json -└── ... -``` - -## Troubleshooting - -### SSL files không được restore - -**Nguyên nhân:** Domain chưa tồn tại trong database - -**Giải pháp:** -1. Import sẽ tự động tạo domains trước -2. Sau đó restore SSL certificates -3. Check logs: `/home/nginx-love-dev/apps/api/logs/combined.log` - -### Permission denied khi restore - -**Nguyên nhân:** Không có quyền ghi vào /etc/nginx/ssl/ - -**Giải pháp:** -```bash -sudo mkdir -p /etc/nginx/ssl -sudo chown -R :nginx /etc/nginx/ssl -sudo chmod 755 /etc/nginx/ssl -``` - -### Certificate không valid sau restore - -**Nguyên nhân:** -- File bị corrupt trong quá trình backup/restore -- Certificate đã hết hạn - -**Giải pháp:** -```bash -# Verify certificate -openssl x509 -in /etc/nginx/ssl/example.com.crt -text -noout - -# Check private key -openssl rsa -in /etc/nginx/ssl/example.com.key -check - -# Verify cert matches key -openssl x509 -noout -modulus -in /etc/nginx/ssl/example.com.crt | openssl md5 -openssl rsa -noout -modulus -in /etc/nginx/ssl/example.com.key | openssl md5 -``` - -## Migration Example - -### Máy chủ cũ (Production): -```bash -1. Login vào UI -2. Navigate to Backup & Restore -3. Click "Export Configuration" -4. Download: nginx-config-2025-10-06.json -5. Encrypt (optional): gpg -c nginx-config-2025-10-06.json -``` - -### Máy chủ mới (Staging/New Production): -```bash -1. Setup nginx-love application -2. Run migrations: npx prisma migrate deploy -3. Start services -4. Login vào UI -5. Navigate to Backup & Restore -6. Click "Import Configuration" -7. Select: nginx-config-2025-10-06.json -8. Wait for import to complete -9. Verify: Check domains, SSL certs, và configurations -10. Test: Access domains qua HTTPS -``` - -## Notes - -- ✅ SSL certificates được backup **BẢN ĐẦY ĐỦ** (không chỉ metadata) -- ✅ Private keys được bảo mật trong backup file -- ✅ Hỗ trợ certificate chains (intermediate certs) -- ✅ Tự động set permissions cho private keys -- ✅ Compatible với Let's Encrypt, self-signed, và commercial certificates -- ⚠️ **Bảo vệ backup files như bạn bảo vệ private keys** -- ⚠️ Backup files có thể rất lớn nếu có nhiều SSL certificates - -## Version History - -- **v1.0** (2025-10-06): Initial release với SSL certificate backup support From b63e88c5054751e45cbe4edb2f05d94dd428b571 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:55:16 +0000 Subject: [PATCH 7/8] feat: Update backup import process to include hashed passwords for user accounts --- apps/api/src/controllers/backup.controller.ts | 65 +++++++++---------- apps/web/src/components/pages/Backup.tsx | 4 +- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/apps/api/src/controllers/backup.controller.ts b/apps/api/src/controllers/backup.controller.ts index 1ecccf4..752bec2 100644 --- a/apps/api/src/controllers/backup.controller.ts +++ b/apps/api/src/controllers/backup.controller.ts @@ -747,7 +747,7 @@ export const importConfig = async (req: AuthRequest, res: Response): Promise { - const { password, ...userWithoutPassword } = u; - return userWithoutPassword; - }); + // Keep passwords as they are already hashed (bcrypt) + // This allows users to login immediately after restore without password reset // Get nginx configs const nginxConfigs = await prisma.nginxConfig.findMany(); @@ -1473,8 +1466,8 @@ async function collectBackupData() { channels: r.channels.map(c => c.channel.name) })), - // Users (without passwords) - users: usersWithoutPassword, + // Users (with hashed passwords for complete restore) + users: users, // Global nginx configurations nginxConfigs diff --git a/apps/web/src/components/pages/Backup.tsx b/apps/web/src/components/pages/Backup.tsx index c3dfc45..ce9e35e 100644 --- a/apps/web/src/components/pages/Backup.tsx +++ b/apps/web/src/components/pages/Backup.tsx @@ -642,7 +642,7 @@ const Backup = () => {
  • ModSecurity Rules: CRS rules and custom security rules
  • ACL Rules: All access control configurations
  • Alert Settings: Notification channels and alert rules
  • -
  • Users: User accounts (passwords must be reset)
  • +
  • Users: User accounts with passwords (can login immediately)
  • System Configs: Global nginx configurations
  • @@ -655,7 +655,7 @@ const Backup = () => {
  • Nginx will be automatically reloaded
  • Domains will be immediately accessible with restored configurations
  • SSL certificates will be active and functional
  • -
  • Users will need to reset their passwords (security measure)
  • +
  • Users can login with their original passwords from backup
  • From a3ea1cb904ef951b2b198a9284a3a1799c3f5c23 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Mon, 6 Oct 2025 07:59:40 +0000 Subject: [PATCH 8/8] feat: Enhance user import functionality to upsert users and profiles with hashed passwords from backup --- apps/api/src/controllers/backup.controller.ts | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/apps/api/src/controllers/backup.controller.ts b/apps/api/src/controllers/backup.controller.ts index 752bec2..4dcd85e 100644 --- a/apps/api/src/controllers/backup.controller.ts +++ b/apps/api/src/controllers/backup.controller.ts @@ -751,38 +751,63 @@ export const importConfig = async (req: AuthRequest, res: Response): Promise