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:
1112
ts/classes/backup-manager.ts
Normal file
1112
ts/classes/backup-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user