feat(backup): Add backup system: BackupManager, DB schema, API endpoints and UI support

Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
This commit is contained in:
2025-11-27 13:48:11 +00:00
parent e7ade45097
commit 5cd7e7c252
13 changed files with 2013 additions and 2 deletions

1112
ts/classes/backup-manager.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -319,6 +319,30 @@ export class OneboxHttpServer {
return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
// Backup endpoints
} else if (path === '/api/backups' && method === 'GET') {
return await this.handleListBackupsRequest();
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
const serviceName = path.split('/')[3];
return await this.handleListServiceBackupsRequest(serviceName);
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
const serviceName = path.split('/')[3];
return await this.handleCreateBackupRequest(serviceName);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
const backupId = Number(path.split('/').pop());
return await this.handleGetBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
const backupId = Number(path.split('/')[3]);
return await this.handleDownloadBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
const backupId = Number(path.split('/').pop());
return await this.handleDeleteBackupRequest(backupId);
} else if (path === '/api/backups/restore' && method === 'POST') {
return await this.handleRestoreBackupRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'POST') {
return await this.handleSetBackupPasswordRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'GET') {
return await this.handleCheckBackupPasswordRequest();
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -2017,6 +2041,276 @@ export class OneboxHttpServer {
}
}
// ============ Backup Endpoints ============
/**
* List all backups
*/
private async handleListBackupsRequest(): Promise<Response> {
try {
const backups = this.oneboxRef.backupManager.listBackups();
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* List backups for a specific service
*/
private async handleListServiceBackupsRequest(serviceName: string): Promise<Response> {
try {
const service = this.oneboxRef.services.getService(serviceName);
if (!service) {
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
}
const backups = this.oneboxRef.backupManager.listBackups(serviceName);
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* Create a backup for a service
*/
private async handleCreateBackupRequest(serviceName: string): Promise<Response> {
try {
const service = this.oneboxRef.services.getService(serviceName);
if (!service) {
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
}
const result = await this.oneboxRef.backupManager.createBackup(serviceName);
return this.jsonResponse({
success: true,
message: `Backup created for service ${serviceName}`,
data: result.backup,
});
} catch (error) {
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to create backup',
}, 500);
}
}
/**
* Get a specific backup by ID
*/
private async handleGetBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
return this.jsonResponse({ success: true, data: backup });
} catch (error) {
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to get backup',
}, 500);
}
}
/**
* Download a backup file
*/
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
try {
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Check if file exists
try {
await Deno.stat(filePath);
} catch {
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
}
// Read file and return as download
const backup = this.oneboxRef.database.getBackupById(backupId);
const file = await Deno.readFile(filePath);
return new Response(file, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
'Content-Length': String(file.length),
},
});
} catch (error) {
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to download backup',
}, 500);
}
}
/**
* Delete a backup
*/
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
await this.oneboxRef.backupManager.deleteBackup(backupId);
return this.jsonResponse({
success: true,
message: 'Backup deleted successfully',
});
} catch (error) {
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to delete backup',
}, 500);
}
}
/**
* Restore a backup
*/
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
if (!backupId) {
return this.jsonResponse({
success: false,
error: 'Backup ID is required',
}, 400);
}
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
return this.jsonResponse({
success: false,
error: 'Valid mode required: restore, import, or clone',
}, 400);
}
// Get backup file path
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Validate mode-specific requirements
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
return this.jsonResponse({
success: false,
error: `New service name required for '${mode}' mode`,
}, 400);
}
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
mode,
newServiceName,
overwriteExisting: overwriteExisting === true,
skipPlatformData: skipPlatformData === true,
});
return this.jsonResponse({
success: true,
message: `Backup restored successfully as service '${result.service.name}'`,
data: {
service: result.service,
platformResourcesRestored: result.platformResourcesRestored,
warnings: result.warnings,
},
});
} catch (error) {
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to restore backup',
}, 500);
}
}
/**
* Set backup encryption password
*/
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { password } = body;
if (!password || typeof password !== 'string') {
return this.jsonResponse({
success: false,
error: 'Password is required',
}, 400);
}
if (password.length < 8) {
return this.jsonResponse({
success: false,
error: 'Password must be at least 8 characters',
}, 400);
}
// Store password in settings
this.oneboxRef.database.setSetting('backup_encryption_password', password);
return this.jsonResponse({
success: true,
message: 'Backup password set successfully',
});
} catch (error) {
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to set backup password',
}, 500);
}
}
/**
* Check if backup password is configured
*/
private async handleCheckBackupPasswordRequest(): Promise<Response> {
try {
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
const isConfigured = password !== null && password.length > 0;
return this.jsonResponse({
success: true,
data: {
isConfigured,
},
});
} catch (error) {
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to check backup password',
}, 500);
}
}
/**
* Helper to create JSON response
*/

View File

@@ -20,6 +20,7 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
export class Onebox {
public database: OneboxDatabase;
@@ -36,6 +37,7 @@ export class Onebox {
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager;
private initialized = false;
@@ -67,6 +69,9 @@ export class Onebox {
// Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
}
/**