security hardening
This commit is contained in:
@@ -22,7 +22,7 @@ npm run dev
|
|||||||
|
|
||||||
The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges.
|
The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges.
|
||||||
|
|
||||||
Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent.
|
Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent. The token is required and must be at least 32 characters long.
|
||||||
|
|
||||||
Required commands:
|
Required commands:
|
||||||
|
|
||||||
@@ -42,7 +42,9 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. If no password is configured, the development fallback is `admin`.
|
The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. Startup fails if the initial password is missing.
|
||||||
|
|
||||||
|
Agent URLs must use `https://` by default. For local development only, set `ALLOW_INSECURE_AGENT_HTTP=true` in `management/.env` to permit `http://` node URLs.
|
||||||
|
|
||||||
The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer.
|
The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer.
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ Changing most node-agent values applies to new API calls and jobs immediately. C
|
|||||||
|
|
||||||
## Safety Notes
|
## Safety Notes
|
||||||
|
|
||||||
Restore is intentionally guarded twice: the backend validates the snapshot against the VM, and the UI requires typing the VM name before sending the restore request. Restore jobs are never retried automatically.
|
Restore is intentionally guarded twice: the backend validates the snapshot against the VM, and the UI requires typing the VM name before sending the restore request. VM restores create a pre-restore ZFS snapshot and roll back to it if writing the disk fails. Restore jobs are never retried automatically.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ RESTIC_KEEP_DAILY=7
|
|||||||
RESTIC_KEEP_WEEKLY=0
|
RESTIC_KEEP_WEEKLY=0
|
||||||
RESTIC_KEEP_MONTHLY=0
|
RESTIC_KEEP_MONTHLY=0
|
||||||
PORT=3000
|
PORT=3000
|
||||||
API_TOKEN=""
|
API_TOKEN="change-me-to-at-least-32-characters"
|
||||||
ALLOWED_MANAGEMENT_IPS=""
|
ALLOWED_MANAGEMENT_IPS=""
|
||||||
|
|||||||
+11
-1
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const envPath = path.resolve(process.cwd(), '.env');
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
|
const minApiTokenLength = 32;
|
||||||
|
|
||||||
export const requiredEnv = [
|
export const requiredEnv = [
|
||||||
'AWS_ACCESS_KEY_ID',
|
'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 = [
|
export const editableEnv = [
|
||||||
{ key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true },
|
{ 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 },
|
{ 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_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false },
|
||||||
{ key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly 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: '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 },
|
{ 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 || {})) {
|
for (const [key, value] of Object.entries(values || {})) {
|
||||||
if (!allowedKeys.has(key)) continue;
|
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 ?? '');
|
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) {
|
export function createProgressStream(totalBytes, onProgress) {
|
||||||
let currentBytes = 0;
|
let currentBytes = 0;
|
||||||
return new Transform({
|
return new Transform({
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ app.use((req, res, next) => {
|
|||||||
res.status(403).json({ error: 'Forbidden management source.' });
|
res.status(403).json({ error: 'Forbidden management source.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!config.apiToken) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const header = req.get('authorization') || '';
|
const header = req.get('authorization') || '';
|
||||||
if (header === `Bearer ${config.apiToken}`) {
|
if (header === `Bearer ${config.apiToken}`) {
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|||||||
import { finished } from 'node:stream/promises';
|
import { finished } from 'node:stream/promises';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
import { config } from '../config.js';
|
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 { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
|
||||||
import { validateVmExists } from '../validators.js';
|
import { validateVmExists } from '../validators.js';
|
||||||
|
|
||||||
@@ -59,7 +59,9 @@ export async function runBackupJob(job, instance = null) {
|
|||||||
|
|
||||||
setJobStep(job, 'Streaming block device to Restic');
|
setJobStep(job, 'Streaming block device to Restic');
|
||||||
const snapshotStream = await deviceReadStream(snapshotDevice);
|
const snapshotStream = await deviceReadStream(snapshotDevice);
|
||||||
|
let streamedBytes = 0;
|
||||||
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||||
|
streamedBytes = currentBytes;
|
||||||
setJobProgress(job, {
|
setJobProgress(job, {
|
||||||
currentBytes,
|
currentBytes,
|
||||||
totalBytes: bytesTotal,
|
totalBytes: bytesTotal,
|
||||||
@@ -68,11 +70,13 @@ export async function runBackupJob(job, instance = null) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
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 },
|
env: { ...process.env, ...config.resticEnv },
|
||||||
input: snapshotStream.pipe(progressStream),
|
input: snapshotStream.pipe(progressStream),
|
||||||
log: (line) => appendJobLog(job, line),
|
log: (line) => appendJobLog(job, line),
|
||||||
});
|
});
|
||||||
|
const snapshotId = parseResticSnapshotId(result);
|
||||||
|
await verifyResticFileSize(job, snapshotId, `${job.vmName}.raw`, streamedBytes || totalBytes);
|
||||||
} finally {
|
} finally {
|
||||||
if (!snapshotStream.destroyed) {
|
if (!snapshotStream.destroyed) {
|
||||||
snapshotStream.destroy();
|
snapshotStream.destroy();
|
||||||
@@ -124,7 +128,9 @@ async function runContainerBackupJob(job) {
|
|||||||
const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] });
|
const zfs = spawn('zfs', ['send', snapshot], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
const zfsClosed = waitForProcess(zfs, 'zfs send');
|
const zfsClosed = waitForProcess(zfs, 'zfs send');
|
||||||
zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd()));
|
zfs.stderr.on('data', (chunk) => appendJobLog(job, chunk.toString().trimEnd()));
|
||||||
|
let streamedBytes = 0;
|
||||||
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||||
|
streamedBytes = currentBytes;
|
||||||
setJobProgress(job, {
|
setJobProgress(job, {
|
||||||
currentBytes,
|
currentBytes,
|
||||||
totalBytes: bytesTotal,
|
totalBytes: bytesTotal,
|
||||||
@@ -134,11 +140,13 @@ async function runContainerBackupJob(job) {
|
|||||||
});
|
});
|
||||||
let resticOk = false;
|
let resticOk = false;
|
||||||
try {
|
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 },
|
env: { ...process.env, ...config.resticEnv },
|
||||||
input: zfs.stdout.pipe(progressStream),
|
input: zfs.stdout.pipe(progressStream),
|
||||||
log: (line) => appendJobLog(job, line),
|
log: (line) => appendJobLog(job, line),
|
||||||
});
|
});
|
||||||
|
const snapshotId = parseResticSnapshotId(result);
|
||||||
|
await verifyResticFileSize(job, snapshotId, `${job.vmName}.zfs`, streamedBytes || totalBytes);
|
||||||
resticOk = true;
|
resticOk = true;
|
||||||
} finally {
|
} finally {
|
||||||
if (!resticOk && !zfs.killed) zfs.kill('SIGTERM');
|
if (!resticOk && !zfs.killed) zfs.kill('SIGTERM');
|
||||||
@@ -270,6 +278,40 @@ function positiveInteger(value) {
|
|||||||
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
|
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) {
|
async function cleanupBackup(job, zvol, snapshotName) {
|
||||||
setJobStep(job, 'Running cleanup');
|
setJobStep(job, 'Running cleanup');
|
||||||
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
|
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { config } from '../config.js';
|
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 { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
|
||||||
import { validateSnapshotForVm, validateVmExists } from '../validators.js';
|
import { validateSnapshotForVm, validateVmExists } from '../validators.js';
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ restoreRouter.post('/:vmName', async (req, res, next) => {
|
|||||||
async function runRestoreJob(job, snapshotId) {
|
async function runRestoreJob(job, snapshotId) {
|
||||||
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
|
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
|
||||||
const device = `/dev/zvol/${zvol}`;
|
const device = `/dev/zvol/${zvol}`;
|
||||||
|
const rollbackSnapshot = `${zvol}@pre-restore-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
|
||||||
let volmodeDev = false;
|
let volmodeDev = false;
|
||||||
let diskWriteOk = false;
|
let diskWriteOk = false;
|
||||||
|
let rollbackSnapshotCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setJobRunning(job, 'Stopping VM');
|
setJobRunning(job, 'Stopping VM');
|
||||||
@@ -45,19 +47,31 @@ async function runRestoreJob(job, snapshotId) {
|
|||||||
log: (line) => appendJobLog(job, line),
|
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');
|
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) });
|
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
|
||||||
volmodeDev = true;
|
volmodeDev = true;
|
||||||
|
|
||||||
setJobStep(job, 'Settling device nodes');
|
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', ['trigger'], { log: (line) => appendJobLog(job, line) });
|
||||||
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
|
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
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 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), {
|
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), {
|
||||||
totalBytes,
|
totalBytes,
|
||||||
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
|
||||||
@@ -80,6 +94,7 @@ async function runRestoreJob(job, snapshotId) {
|
|||||||
setJobProgress(job, { percent: 97 });
|
setJobProgress(job, { percent: 97 });
|
||||||
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
|
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
|
||||||
|
|
||||||
|
appendJobLog(job, `Pre-restore rollback snapshot kept: ${rollbackSnapshot}`);
|
||||||
finishJob(job, 'success');
|
finishJob(job, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendJobLog(job, error.message);
|
appendJobLog(job, error.message);
|
||||||
@@ -90,9 +105,15 @@ async function runRestoreJob(job, snapshotId) {
|
|||||||
log: (line) => appendJobLog(job, line),
|
log: (line) => appendJobLog(job, line),
|
||||||
}).catch((cleanupError) => appendJobLog(job, cleanupError.message));
|
}).catch((cleanupError) => appendJobLog(job, cleanupError.message));
|
||||||
}
|
}
|
||||||
if (!diskWriteOk) {
|
if (rollbackSnapshotCreated && !diskWriteOk) {
|
||||||
appendJobLog(job, 'VM was not restarted because disk restore did not complete successfully.');
|
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);
|
finishJob(job, 'failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -15,11 +15,11 @@ Important `.env` values:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
PORT=3000
|
PORT=3000
|
||||||
API_TOKEN="long-random-token"
|
API_TOKEN="long-random-token-at-least-32-characters"
|
||||||
ALLOWED_MANAGEMENT_IPS="management-server-ip"
|
ALLOWED_MANAGEMENT_IPS="management-server-ip"
|
||||||
```
|
```
|
||||||
|
|
||||||
If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses.
|
`API_TOKEN` is required and must be at least 32 characters long. If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses.
|
||||||
|
|
||||||
Install systemd service:
|
Install systemd service:
|
||||||
|
|
||||||
@@ -49,8 +49,13 @@ SESSION_SECRET="long-random-secret"
|
|||||||
AUTH_USERNAME="admin"
|
AUTH_USERNAME="admin"
|
||||||
AUTH_PASSWORD="initial-password"
|
AUTH_PASSWORD="initial-password"
|
||||||
DATABASE_PATH="./management.sqlite"
|
DATABASE_PATH="./management.sqlite"
|
||||||
|
CORS_ORIGINS="https://backup.example.com"
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
ALLOW_INSECURE_AGENT_HTTP=false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`AUTH_PASSWORD` is required for the first start when the user database is empty. `CORS_ORIGINS` must list the frontend origins that are allowed to use cookie-authenticated API calls. Agent URLs must use `https://`; only set `ALLOW_INSECURE_AGENT_HTTP=true` for local development.
|
||||||
|
|
||||||
Reset an existing password:
|
Reset an existing password:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,22 +6,6 @@ export const api = axios.create({
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = window.localStorage.getItem('incusBackupApiToken') || import.meta.env.VITE_API_TOKEN;
|
|
||||||
if (token) {
|
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setApiToken(tokenValue) {
|
|
||||||
const token = String(tokenValue || '');
|
|
||||||
if (token) {
|
|
||||||
window.localStorage.setItem('incusBackupApiToken', token);
|
|
||||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.localStorage.removeItem('incusBackupApiToken');
|
|
||||||
delete api.defaults.headers.common.Authorization;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorMessage(error) {
|
export function errorMessage(error) {
|
||||||
return error.response?.data?.error || error.message || 'Request failed.';
|
return error.response?.data?.error || error.message || 'Request failed.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ SESSION_SECRET="change-me"
|
|||||||
AUTH_USERNAME="admin"
|
AUTH_USERNAME="admin"
|
||||||
AUTH_PASSWORD="change-me"
|
AUTH_PASSWORD="change-me"
|
||||||
DATABASE_PATH="./management.sqlite"
|
DATABASE_PATH="./management.sqlite"
|
||||||
|
CORS_ORIGINS="http://localhost:5173"
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
ALLOW_INSECURE_AGENT_HTTP=true
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSession, deleteSession, getUserBySession, getUserByUsername } from './store.js';
|
import { createSession, deleteSession, getUserBySession, getUserByUsername } from './store.js';
|
||||||
|
import { config } from './config.js';
|
||||||
import { verifyPassword } from './crypto.js';
|
import { verifyPassword } from './crypto.js';
|
||||||
|
|
||||||
const cookieName = 'incus_backup_session';
|
const cookieName = 'incus_backup_session';
|
||||||
@@ -34,12 +35,12 @@ export function logout(sessionId) {
|
|||||||
|
|
||||||
export function setSessionCookie(res, session) {
|
export function setSessionCookie(res, session) {
|
||||||
res.setHeader('Set-Cookie', [
|
res.setHeader('Set-Cookie', [
|
||||||
`${cookieName}=${session.id}; Path=/; HttpOnly; SameSite=Lax; Expires=${session.expiresAt.toUTCString()}`,
|
cookieHeader(cookieName, session.id, `Expires=${session.expiresAt.toUTCString()}`),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie(res) {
|
export function clearSessionCookie(res) {
|
||||||
res.setHeader('Set-Cookie', [`${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`]);
|
res.setHeader('Set-Cookie', [cookieHeader(cookieName, '', 'Max-Age=0')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCookie(req, name) {
|
function readCookie(req, name) {
|
||||||
@@ -50,3 +51,8 @@ function readCookie(req, name) {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cookieHeader(name, value, lifetime) {
|
||||||
|
const secure = config.sessionCookieSecure ? '; Secure' : '';
|
||||||
|
return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Strict${secure}; ${lifetime}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ export const config = {
|
|||||||
authUsername: process.env.AUTH_USERNAME || 'admin',
|
authUsername: process.env.AUTH_USERNAME || 'admin',
|
||||||
authPassword: process.env.AUTH_PASSWORD || '',
|
authPassword: process.env.AUTH_PASSWORD || '',
|
||||||
databasePath: path.resolve(process.cwd(), process.env.DATABASE_PATH || './management.sqlite'),
|
databasePath: path.resolve(process.cwd(), process.env.DATABASE_PATH || './management.sqlite'),
|
||||||
|
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
sessionCookieSecure: process.env.SESSION_COOKIE_SECURE
|
||||||
|
? process.env.SESSION_COOKIE_SECURE === 'true'
|
||||||
|
: process.env.NODE_ENV === 'production',
|
||||||
|
allowInsecureAgentHttp: process.env.ALLOW_INSECURE_AGENT_HTTP === 'true',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ export function migrate() {
|
|||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
|
const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
if (!config.authPassword) {
|
||||||
|
throw new Error('AUTH_PASSWORD is required for initial admin user creation. Set AUTH_PASSWORD before starting management.');
|
||||||
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const password = config.authPassword || 'admin';
|
|
||||||
db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)')
|
db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)')
|
||||||
.run(`user_${cryptoId()}`, config.authUsername, hashPassword(password), now);
|
.run(`user_${cryptoId()}`, config.authUsername, hashPassword(config.authPassword), now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -15,7 +15,16 @@ migrate();
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({ origin: true, credentials: true }));
|
app.use(cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
if (!origin || config.corsOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(new Error('CORS origin is not allowed.'));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
|||||||
@@ -4,17 +4,43 @@ import { createAuditEvent } from '../store.js';
|
|||||||
|
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const loginAttempts = new Map();
|
||||||
|
const maxAttempts = 5;
|
||||||
|
const windowMs = 15 * 60 * 1000;
|
||||||
|
|
||||||
authRouter.get('/session', (req, res) => {
|
authRouter.get('/session', (req, res) => {
|
||||||
const session = currentSession(req);
|
const session = currentSession(req);
|
||||||
res.json({ authenticated: Boolean(session), user: session?.user || null });
|
res.json({ authenticated: Boolean(session), user: session?.user || null });
|
||||||
});
|
});
|
||||||
|
|
||||||
authRouter.post('/login', (req, res) => {
|
authRouter.post('/login', (req, res) => {
|
||||||
const result = login(req.body?.username, req.body?.password);
|
const username = String(req.body?.username || '');
|
||||||
|
const key = loginAttemptKey(req, username);
|
||||||
|
const attempt = currentAttempt(key);
|
||||||
|
if (attempt.count >= maxAttempts) {
|
||||||
|
createAuditEvent({
|
||||||
|
action: 'login_blocked',
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: username || null,
|
||||||
|
details: { ip: clientIp(req), reason: 'rate_limit' },
|
||||||
|
});
|
||||||
|
res.status(429).json({ error: 'Too many login attempts. Try again later.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = login(username, req.body?.password);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
recordFailedAttempt(key);
|
||||||
|
createAuditEvent({
|
||||||
|
action: 'login_failed',
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: username || null,
|
||||||
|
details: { ip: clientIp(req) },
|
||||||
|
});
|
||||||
res.status(401).json({ error: 'Invalid username or password.' });
|
res.status(401).json({ error: 'Invalid username or password.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loginAttempts.delete(key);
|
||||||
setSessionCookie(res, result.session);
|
setSessionCookie(res, result.session);
|
||||||
createAuditEvent({
|
createAuditEvent({
|
||||||
user: result.user,
|
user: result.user,
|
||||||
@@ -39,3 +65,23 @@ authRouter.post('/logout', (req, res) => {
|
|||||||
clearSessionCookie(res);
|
clearSessionCookie(res);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loginAttemptKey(req, username) {
|
||||||
|
return `${clientIp(req)}:${String(username || '').toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAttempt(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
const current = loginAttempts.get(key);
|
||||||
|
if (!current || current.resetAt <= now) return { count: 0, resetAt: now + windowMs };
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedAttempt(key) {
|
||||||
|
const attempt = currentAttempt(key);
|
||||||
|
loginAttempts.set(key, { count: attempt.count + 1, resetAt: attempt.resetAt });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { agentRequest, publicNode } from '../agentClient.js';
|
import { agentRequest, publicNode } from '../agentClient.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
|
import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
|
||||||
|
|
||||||
export const nodesRouter = Router();
|
export const nodesRouter = Router();
|
||||||
@@ -88,7 +89,7 @@ function validateNodeInput(body, options = {}) {
|
|||||||
error.status = 400;
|
error.status = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (baseUrl) new URL(baseUrl);
|
if (baseUrl) validateBaseUrl(baseUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
@@ -97,3 +98,15 @@ function validateNodeInput(body, options = {}) {
|
|||||||
...(values.enabled !== undefined ? { enabled: Boolean(values.enabled) } : {}),
|
...(values.enabled !== undefined ? { enabled: Boolean(values.enabled) } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateBaseUrl(baseUrl) {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (url.protocol === 'https:') return;
|
||||||
|
if (url.protocol === 'http:' && config.allowInsecureAgentHttp) {
|
||||||
|
console.warn(`ALLOW_INSECURE_AGENT_HTTP=true permits insecure agent URL: ${baseUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = new Error('Node URL must use https://. Set ALLOW_INSECURE_AGENT_HTTP=true only for local development.');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user