Files
holesail-browser/native-host/managers/backup-manager.js
T
Raven Scott 80759f8b0c
CI / Build & Test (push) Successful in 3m12s
chore(native-host): group modules into proxy/, managers/, host/
- Move connect-proxy and https-proxy into proxy/
- Move certificate-authority, backup-manager, ssh-manager, rdp-manager into managers/
- Move messenger.js into host/
- Move test-dirname.cjs into test/
- Update imports, CI lint paths, and ARCHITECTURE.md
2026-03-03 23:53:51 -05:00

393 lines
12 KiB
JavaScript

/**
* 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/<entries> ← state.json, etc. (backups/ subdir excluded)
* certs/<entries> ← 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 <dir> <entry> 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 <outPath> -C <stagingDir> 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
};