CI / Build & Test (push) Successful in 3m12s
- 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
393 lines
12 KiB
JavaScript
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
|
|
};
|