security hardening
This commit is contained in:
@@ -8,5 +8,5 @@ RESTIC_KEEP_DAILY=7
|
||||
RESTIC_KEEP_WEEKLY=0
|
||||
RESTIC_KEEP_MONTHLY=0
|
||||
PORT=3000
|
||||
API_TOKEN=""
|
||||
API_TOKEN="change-me-to-at-least-32-characters"
|
||||
ALLOWED_MANAGEMENT_IPS=""
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
dotenv.config();
|
||||
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
const minApiTokenLength = 32;
|
||||
|
||||
export const requiredEnv = [
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
@@ -36,6 +37,10 @@ export const config = {
|
||||
},
|
||||
};
|
||||
|
||||
if (!config.apiToken || config.apiToken.length < minApiTokenLength) {
|
||||
throw new Error(`API_TOKEN is required and must be at least ${minApiTokenLength} characters long.`);
|
||||
}
|
||||
|
||||
export const editableEnv = [
|
||||
{ key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true },
|
||||
{ key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS secret access key', required: true, secret: true },
|
||||
@@ -47,7 +52,7 @@ export const editableEnv = [
|
||||
{ key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false },
|
||||
{ key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false },
|
||||
{ key: 'PORT', label: 'API port', required: false, secret: false },
|
||||
{ key: 'API_TOKEN', label: 'API token', required: false, secret: true },
|
||||
{ key: 'API_TOKEN', label: 'API token', required: true, secret: true },
|
||||
{ key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false },
|
||||
];
|
||||
|
||||
@@ -77,6 +82,11 @@ export async function writeEnvSettings(values) {
|
||||
|
||||
for (const [key, value] of Object.entries(values || {})) {
|
||||
if (!allowedKeys.has(key)) continue;
|
||||
if (key === 'API_TOKEN' && String(value || '').length < minApiTokenLength) {
|
||||
const error = new Error(`API_TOKEN must be at least ${minApiTokenLength} characters long.`);
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
nextValues[key] = String(value ?? '');
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,21 @@ export function runRestic(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resticSnapshotFileSize(snapshotId, filename) {
|
||||
const result = await runRestic(['ls', '--json', snapshotId]);
|
||||
const wanted = `/${filename}`;
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'file' && (entry.path === filename || entry.path === wanted || entry.path?.endsWith(wanted))) {
|
||||
return Number(entry.size);
|
||||
}
|
||||
}
|
||||
const error = new Error(`Could not verify Restic file size for ${filename} in snapshot ${snapshotId}.`);
|
||||
error.status = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function createProgressStream(totalBytes, onProgress) {
|
||||
let currentBytes = 0;
|
||||
return new Transform({
|
||||
|
||||
@@ -21,10 +21,6 @@ app.use((req, res, next) => {
|
||||
res.status(403).json({ error: 'Forbidden management source.' });
|
||||
return;
|
||||
}
|
||||
if (!config.apiToken) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const header = req.get('authorization') || '';
|
||||
if (header === `Bearer ${config.apiToken}`) {
|
||||
next();
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from 'node:path';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config } from '../config.js';
|
||||
import { createProgressStream, spawnCommand, runRestic } from '../executor.js';
|
||||
import { createProgressStream, resticSnapshotFileSize, spawnCommand, runRestic } from '../executor.js';
|
||||
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
|
||||
import { validateVmExists } from '../validators.js';
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function runBackupJob(job, instance = null) {
|
||||
|
||||
setJobStep(job, 'Streaming block device to Restic');
|
||||
const snapshotStream = await deviceReadStream(snapshotDevice);
|
||||
let streamedBytes = 0;
|
||||
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||
streamedBytes = currentBytes;
|
||||
setJobProgress(job, {
|
||||
currentBytes,
|
||||
totalBytes: bytesTotal,
|
||||
@@ -68,11 +70,13 @@ export async function runBackupJob(job, instance = null) {
|
||||
});
|
||||
});
|
||||
try {
|
||||
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName, '--tag', 'data', '--tag', 'virtual-machine'], {
|
||||
const result = await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName, '--tag', 'data', '--tag', 'virtual-machine'], {
|
||||
env: { ...process.env, ...config.resticEnv },
|
||||
input: snapshotStream.pipe(progressStream),
|
||||
log: (line) => appendJobLog(job, line),
|
||||
});
|
||||
const snapshotId = parseResticSnapshotId(result);
|
||||
await verifyResticFileSize(job, snapshotId, `${job.vmName}.raw`, streamedBytes || totalBytes);
|
||||
} finally {
|
||||
if (!snapshotStream.destroyed) {
|
||||
snapshotStream.destroy();
|
||||
@@ -124,7 +128,9 @@ async function runContainerBackupJob(job) {
|
||||
const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const zfsClosed = waitForProcess(zfs, 'zfs send');
|
||||
zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd()));
|
||||
let streamedBytes = 0;
|
||||
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||
streamedBytes = currentBytes;
|
||||
setJobProgress(job, {
|
||||
currentBytes,
|
||||
totalBytes: bytesTotal,
|
||||
@@ -134,11 +140,13 @@ async function runContainerBackupJob(job) {
|
||||
});
|
||||
let resticOk = false;
|
||||
try {
|
||||
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.zfs`, '--tag', job.vmName, '--tag', 'data', '--tag', 'container'], {
|
||||
const result = await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.zfs`, '--tag', job.vmName, '--tag', 'data', '--tag', 'container'], {
|
||||
env: { ...process.env, ...config.resticEnv },
|
||||
input: zfs.stdout.pipe(progressStream),
|
||||
log: (line) => appendJobLog(job, line),
|
||||
});
|
||||
const snapshotId = parseResticSnapshotId(result);
|
||||
await verifyResticFileSize(job, snapshotId, `${job.vmName}.zfs`, streamedBytes || totalBytes);
|
||||
resticOk = true;
|
||||
} finally {
|
||||
if (!resticOk && !zfs.killed) zfs.kill('SIGTERM');
|
||||
@@ -270,6 +278,40 @@ function positiveInteger(value) {
|
||||
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
|
||||
}
|
||||
|
||||
function parseResticSnapshotId(result) {
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
const match = output.match(/snapshot\s+([0-9a-f]{8,64})\s+saved/i);
|
||||
if (!match) {
|
||||
throw new Error('Restic backup completed but no snapshot ID could be parsed.');
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function verifyResticFileSize(job, snapshotId, filename, expectedBytes) {
|
||||
if (!expectedBytes) {
|
||||
appendJobLog(job, `Skipping size verification for ${snapshotId}: expected size is unknown.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const storedBytes = await resticSnapshotFileSize(snapshotId, filename);
|
||||
if (storedBytes !== expectedBytes) {
|
||||
throw new Error(`Backup verification failed for ${snapshotId}: stored ${formatBytes(storedBytes)}, expected ${formatBytes(expectedBytes)}.`);
|
||||
}
|
||||
appendJobLog(job, `Verified Restic snapshot ${snapshotId}: ${formatBytes(storedBytes)}.`);
|
||||
} catch (error) {
|
||||
appendJobLog(job, error.message);
|
||||
await forgetFailedSnapshot(job, snapshotId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetFailedSnapshot(job, snapshotId) {
|
||||
appendJobLog(job, `Removing failed Restic snapshot ${snapshotId}.`);
|
||||
await runRestic(['forget', snapshotId, '--prune'], {
|
||||
log: (line) => appendJobLog(job, line),
|
||||
}).catch((error) => appendJobLog(job, `Failed to remove Restic snapshot ${snapshotId}: ${error.message}`));
|
||||
}
|
||||
|
||||
async function cleanupBackup(job, zvol, snapshotName) {
|
||||
setJobStep(job, 'Running cleanup');
|
||||
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { config } from '../config.js';
|
||||
import { spawnCommand, streamResticDumpToDd } from '../executor.js';
|
||||
import { resticSnapshotFileSize, spawnCommand, streamResticDumpToDd } from '../executor.js';
|
||||
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
|
||||
import { validateSnapshotForVm, validateVmExists } from '../validators.js';
|
||||
|
||||
@@ -34,8 +34,10 @@ restoreRouter.post('/:vmName', async (req, res, next) => {
|
||||
async function runRestoreJob(job, snapshotId) {
|
||||
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
|
||||
const device = `/dev/zvol/${zvol}`;
|
||||
const rollbackSnapshot = `${zvol}@pre-restore-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
|
||||
let volmodeDev = false;
|
||||
let diskWriteOk = false;
|
||||
let rollbackSnapshotCreated = false;
|
||||
|
||||
try {
|
||||
setJobRunning(job, 'Stopping VM');
|
||||
@@ -45,19 +47,31 @@ async function runRestoreJob(job, snapshotId) {
|
||||
log: (line) => appendJobLog(job, line),
|
||||
});
|
||||
|
||||
setJobStep(job, 'Creating pre-restore ZFS snapshot');
|
||||
setJobProgress(job, { percent: 6 });
|
||||
await spawnCommand('zfs', ['snapshot', rollbackSnapshot], { log: (line) => appendJobLog(job, line) });
|
||||
rollbackSnapshotCreated = true;
|
||||
appendJobLog(job, `Created rollback snapshot ${rollbackSnapshot}`);
|
||||
|
||||
setJobStep(job, 'Setting ZFS volume to device mode');
|
||||
setJobProgress(job, { percent: 8 });
|
||||
setJobProgress(job, { percent: 10 });
|
||||
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
|
||||
volmodeDev = true;
|
||||
|
||||
setJobStep(job, 'Settling device nodes');
|
||||
setJobProgress(job, { percent: 12 });
|
||||
setJobProgress(job, { percent: 14 });
|
||||
await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) });
|
||||
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
setJobStep(job, 'Writing Restic snapshot to block device');
|
||||
setJobStep(job, 'Checking restore size');
|
||||
const totalBytes = await zfsVolumeSize(zvol);
|
||||
const resticBytes = await resticSnapshotFileSize(snapshotId, `${job.vmName}.raw`);
|
||||
if (totalBytes && resticBytes && totalBytes !== resticBytes) {
|
||||
throw new Error(`Restore size mismatch: Restic file is ${formatBytes(resticBytes)}, target volume is ${formatBytes(totalBytes)}.`);
|
||||
}
|
||||
|
||||
setJobStep(job, 'Writing Restic snapshot to block device');
|
||||
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), {
|
||||
totalBytes,
|
||||
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||
@@ -80,6 +94,7 @@ async function runRestoreJob(job, snapshotId) {
|
||||
setJobProgress(job, { percent: 97 });
|
||||
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
|
||||
|
||||
appendJobLog(job, `Pre-restore rollback snapshot kept: ${rollbackSnapshot}`);
|
||||
finishJob(job, 'success');
|
||||
} catch (error) {
|
||||
appendJobLog(job, error.message);
|
||||
@@ -90,9 +105,15 @@ async function runRestoreJob(job, snapshotId) {
|
||||
log: (line) => appendJobLog(job, line),
|
||||
}).catch((cleanupError) => appendJobLog(job, cleanupError.message));
|
||||
}
|
||||
if (!diskWriteOk) {
|
||||
appendJobLog(job, 'VM was not restarted because disk restore did not complete successfully.');
|
||||
if (rollbackSnapshotCreated && !diskWriteOk) {
|
||||
setJobStep(job, 'Rolling back failed restore');
|
||||
await spawnCommand('zfs', ['rollback', '-r', rollbackSnapshot], {
|
||||
ignoreExitCode: true,
|
||||
log: (line) => appendJobLog(job, line),
|
||||
}).catch((cleanupError) => appendJobLog(job, cleanupError.message));
|
||||
appendJobLog(job, `Rolled back to ${rollbackSnapshot}`);
|
||||
}
|
||||
appendJobLog(job, 'VM was not restarted because restore did not complete successfully.');
|
||||
finishJob(job, 'failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user