/** * Backup manager for Holesail Browser. * Creates/restores tar.gz backups of the state directory and certificates directory. * Uses the system `tar` command via bare-subprocess. * * Archive layout: * storage/ ← contents of holesail-browser-storage (excluding backups/) * certs/ ← contents of holesail-browser-certs (CA key, cert, domain certs) */ const path = require('bare-path'); const fs = require('bare-fs'); let spawn = null; try { const cp = require('child_process'); if (cp && typeof cp.spawn === 'function') spawn = cp.spawn; } catch (_) {} const BACKUP_DIR_NAME = 'backups'; const DEFAULT_RETENTION = 5; let storageDir = null; let certsDir = null; function setStoragePath(dir) { storageDir = dir; } function setCertsPath(dir) { certsDir = dir; } function getBackupDir() { if (!storageDir) throw new Error('Storage path not set'); return path.join(storageDir, BACKUP_DIR_NAME); } function ensureBackupDir() { const dir = getBackupDir(); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } return dir; } function runCommand(cmd, args, opts) { return new Promise((resolve, reject) => { if (!spawn) return reject(new Error('child_process.spawn not available')); const proc = spawn(cmd, args, opts || {}); let stdout = ''; let stderr = ''; if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d.toString(); }); if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d.toString(); }); proc.on('exit', (code) => { if (code === 0) resolve({ stdout, stderr }); else reject(new Error('Command failed (exit ' + code + '): ' + stderr.trim())); }); proc.on('error', reject); }); } /** * Creates a backup of the storage directory and certs directory. * Archive layout: * storage/ ← state.json, etc. (backups/ subdir excluded) * certs/ ← CA key/cert and domain cert dirs * Returns { ok, filename, path, size, createdAt } */ async function createBackup() { if (!storageDir) return { ok: false, error: 'Storage path not set' }; if (!spawn) return { ok: false, error: 'child_process.spawn not available — cannot run tar' }; const backupDir = ensureBackupDir(); const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); const filename = 'holesail-backup-' + ts + '.tar.gz'; const outPath = path.join(backupDir, filename); // Build tar args: interleave -C pairs so each entry is // placed under a named prefix inside the archive. // tar supports: tar -czf out.tar.gz -C /base/of/a storage/file1 -C /base/of/b certs/file2 // We achieve the prefix by creating a temp staging area... but that requires // write access and cleanup. Instead, use the simpler approach of archiving // each directory separately using --transform (GNU tar) or -s (BSD tar). // Most reliable cross-platform: stage into a temp dir inside backupDir, then tar it. const stagingDir = path.join(backupDir, '.staging-' + ts); const stagingStorage = path.join(stagingDir, 'storage'); const stagingCerts = path.join(stagingDir, 'certs'); try { fs.mkdirSync(stagingStorage, { recursive: true }); } catch (e) { return { ok: false, error: 'Failed to create staging dir: ' + e.message }; } // Copy storage entries (excluding backups/) let storageEntries; try { storageEntries = fs.readdirSync(storageDir).filter(e => e !== BACKUP_DIR_NAME); } catch (e) { cleanupStaging(stagingDir); return { ok: false, error: 'Failed to read storage directory: ' + e.message }; } try { for (const entry of storageEntries) { await runCommand('cp', ['-R', path.join(storageDir, entry), path.join(stagingStorage, entry)]); } } catch (e) { cleanupStaging(stagingDir); return { ok: false, error: 'Failed to copy storage files: ' + e.message }; } // Copy certs directory if it exists if (certsDir && fs.existsSync(certsDir)) { try { fs.mkdirSync(stagingCerts, { recursive: true }); const certEntries = fs.readdirSync(certsDir); for (const entry of certEntries) { await runCommand('cp', ['-R', path.join(certsDir, entry), path.join(stagingCerts, entry)]); } } catch (e) { // Non-fatal: log but continue — state backup is still valuable without certs if (process.stderr) process.stderr.write('[backup-manager] Warning: failed to copy certs: ' + e.message + '\n'); } } // Check we have something to archive let hasContent = false; try { hasContent = fs.readdirSync(stagingDir).length > 0; } catch (_) {} if (!hasContent) { cleanupStaging(stagingDir); return { ok: false, error: 'Nothing to back up' }; } try { // tar -czf -C storage certs (whichever exist) const stagingEntries = fs.readdirSync(stagingDir); await runCommand('tar', ['-czf', outPath, '-C', stagingDir, ...stagingEntries]); } catch (e) { cleanupStaging(stagingDir); return { ok: false, error: 'tar failed: ' + e.message }; } cleanupStaging(stagingDir); // Verify archive integrity before reporting success try { await runCommand('tar', ['-tzf', outPath]); } catch (e) { try { fs.unlinkSync(outPath); } catch (_) {} return { ok: false, error: 'Backup archive failed integrity check: ' + e.message }; } let size = 0; try { const stat = fs.statSync(outPath); size = stat.size; } catch (_) {} return { ok: true, filename, path: outPath, size, createdAt: Date.now() }; } function cleanupStaging(stagingDir) { try { if (spawn) { const proc = spawn('rm', ['-rf', stagingDir]); proc.on('error', (err) => { if (process.stderr) process.stderr.write('[backup-manager] cleanupStaging error: ' + err.message + '\n'); }); proc.on('exit', (code) => { if (code !== 0 && process.stderr) { process.stderr.write('[backup-manager] cleanupStaging exited with code ' + code + ' for ' + stagingDir + '\n'); } }); } } catch (_) {} } /** * Lists all backups sorted newest-first. * Returns { ok, backups: [{ filename, path, size, createdAt }] } */ function listBackups() { if (!storageDir) return { ok: false, error: 'Storage path not set' }; let backupDir; try { backupDir = getBackupDir(); } catch (e) { return { ok: false, error: e.message }; } if (!fs.existsSync(backupDir)) { return { ok: true, backups: [] }; } let files; try { files = fs.readdirSync(backupDir); } catch (e) { return { ok: false, error: 'Failed to read backup directory: ' + e.message }; } const backups = []; for (const f of files) { if (!f.endsWith('.tar.gz')) continue; const fullPath = path.join(backupDir, f); let stat; try { stat = fs.statSync(fullPath); } catch (_) { continue; } backups.push({ filename: f, path: fullPath, size: stat.size, createdAt: stat.mtimeMs || stat.mtime }); } // Sort newest first backups.sort((a, b) => b.createdAt - a.createdAt); return { ok: true, backups }; } /** * Restores a backup by filename. * The archive has a storage/ prefix and optionally a certs/ prefix. * Extracts each prefix to its corresponding real directory. * Returns { ok, restoredStorage, restoredCerts } */ async function restoreBackup(filename) { if (!storageDir) return { ok: false, error: 'Storage path not set' }; if (!spawn) return { ok: false, error: 'child_process.spawn not available — cannot run tar' }; if (!filename || typeof filename !== 'string') return { ok: false, error: 'filename is required' }; // Sanitize: only allow the basename, no path traversal const safe = path.basename(filename); if (!safe.endsWith('.tar.gz')) return { ok: false, error: 'Invalid backup filename' }; const backupDir = getBackupDir(); const backupPath = path.join(backupDir, safe); if (!fs.existsSync(backupPath)) { return { ok: false, error: 'Backup not found: ' + safe }; } // List top-level entries in the archive to detect layout let topEntries = []; try { const { stdout } = await runCommand('tar', ['-tzf', backupPath]); // Get unique top-level directory names const seen = new Set(); for (const line of stdout.split('\n')) { const top = line.split('/')[0]; if (top) seen.add(top); } topEntries = Array.from(seen); } catch (e) { return { ok: false, error: 'Failed to inspect archive: ' + e.message }; } const hasStoragePrefix = topEntries.includes('storage'); const hasCertsPrefix = topEntries.includes('certs'); // Legacy backup (no storage/ prefix): extract directly into storageDir if (!hasStoragePrefix) { try { await runCommand('tar', ['-xzf', backupPath, '-C', storageDir]); } catch (e) { return { ok: false, error: 'tar extract failed: ' + e.message }; } return { ok: true, restoredStorage: true, restoredCerts: false }; } // New-style backup: extract storage/ into storageDir, certs/ into certsDir // Use a temp staging dir to extract, then move files into place const ts = Date.now().toString(); const stagingDir = path.join(backupDir, '.restore-' + ts); try { fs.mkdirSync(stagingDir, { recursive: true }); await runCommand('tar', ['-xzf', backupPath, '-C', stagingDir]); } catch (e) { cleanupStaging(stagingDir); return { ok: false, error: 'tar extract failed: ' + e.message }; } let restoredStorage = false; let restoredCerts = false; // Restore storage/ // Remove existing destination entries before copying to avoid macOS cp -R // nesting bug (when dst already exists, cp -R src dst/ creates dst/src/). const extractedStorage = path.join(stagingDir, 'storage'); if (fs.existsSync(extractedStorage)) { try { const entries = fs.readdirSync(extractedStorage); for (const entry of entries) { const dest = path.join(storageDir, entry); if (fs.existsSync(dest)) { await runCommand('rm', ['-rf', dest]); } await runCommand('cp', ['-R', path.join(extractedStorage, entry), dest]); } restoredStorage = true; } catch (e) { cleanupStaging(stagingDir); return { ok: false, error: 'Failed to restore storage files: ' + e.message }; } } // Restore certs/ const extractedCerts = path.join(stagingDir, 'certs'); if (hasCertsPrefix && fs.existsSync(extractedCerts) && certsDir) { try { if (!fs.existsSync(certsDir)) fs.mkdirSync(certsDir, { recursive: true }); const entries = fs.readdirSync(extractedCerts); for (const entry of entries) { const dest = path.join(certsDir, entry); if (fs.existsSync(dest)) { await runCommand('rm', ['-rf', dest]); } await runCommand('cp', ['-R', path.join(extractedCerts, entry), dest]); } restoredCerts = true; } catch (e) { // Non-fatal: storage was already restored if (process.stderr) process.stderr.write('[backup-manager] Warning: failed to restore certs: ' + e.message + '\n'); } } cleanupStaging(stagingDir); return { ok: true, restoredStorage, restoredCerts }; } /** * Deletes a backup by filename. * Returns { ok } */ function deleteBackup(filename) { if (!storageDir) return { ok: false, error: 'Storage path not set' }; if (!filename || typeof filename !== 'string') return { ok: false, error: 'filename is required' }; const safe = path.basename(filename); if (!safe.endsWith('.tar.gz')) return { ok: false, error: 'Invalid backup filename' }; const backupDir = getBackupDir(); const backupPath = path.join(backupDir, safe); if (!fs.existsSync(backupPath)) { return { ok: false, error: 'Backup not found: ' + safe }; } try { fs.unlinkSync(backupPath); } catch (e) { return { ok: false, error: 'Failed to delete backup: ' + e.message }; } return { ok: true }; } /** * Prunes old backups, keeping only the `retention` most recent. * Called automatically after createBackup. */ function pruneOldBackups(retention) { const n = typeof retention === 'number' && retention > 0 ? retention : DEFAULT_RETENTION; const result = listBackups(); if (!result.ok) return; const toDelete = result.backups.slice(n); for (const b of toDelete) { try { fs.unlinkSync(b.path); } catch (_) {} } } module.exports = { setStoragePath, setCertsPath, createBackup, listBackups, restoreBackup, deleteBackup, pruneOldBackups, DEFAULT_RETENTION };