security hardening

This commit is contained in:
Philipp
2026-05-21 14:09:08 +02:00
parent 3103aba972
commit 0046156e58
16 changed files with 205 additions and 43 deletions
+1 -1
View File
@@ -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
View File
@@ -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 ?? '');
}
+15
View File
@@ -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({
-4
View File
@@ -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();
+45 -3
View File
@@ -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], {
+27 -6
View File
@@ -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);
}
}