- Passwort-Reset

cd management
    npm run reset-password -- admin "neues-passwort"

  - Agent-Hardening
    backend/.env unterstützt jetzt:

    ALLOWED_MANAGEMENT_IPS="127.0.0.1,DEINE-MANAGEMENT-IP"

    Wenn gesetzt, akzeptiert der Agent nur Requests von diesen IPs.

  - Audit-Log
    Management speichert Aktionen wie Login, Logout, Node-Änderungen, Schedule-Updates, Backup/Restore, Settings-Änderungen.
  - Job-History
    Management speichert gestartete Backup/Restore/Scheduler-Jobs mit Node, VM, Typ, Agent-Job-ID und Status.
  - Operations-Seite
    Neue UI-Seite Operations mit:
      - Job History
      - Audit Log
  - systemd Templates

    deploy/systemd/incus-backup-agent.service
    deploy/systemd/incus-backup-management.service

  - Deployment-Doku

    docs/deployment.md
This commit is contained in:
Philipp
2026-05-21 10:14:33 +02:00
parent aae54167a1
commit e32ebbc734
21 changed files with 417 additions and 13 deletions
Vendored
BIN
View File
Binary file not shown.
+11
View File
@@ -43,6 +43,13 @@ The management API stores nodes, users, sessions, and central schedules in SQLit
The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer.
Reset an existing admin password without deleting the database:
```bash
cd management
npm run reset-password -- admin "new-password"
```
## Frontend
```bash
@@ -62,3 +69,7 @@ Changing most node-agent values applies to new API calls and jobs immediately. C
## 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.
## Deployment
See `docs/deployment.md` for systemd units, management/agent split, and production setup notes.
+1
View File
@@ -9,3 +9,4 @@ RESTIC_KEEP_WEEKLY=0
RESTIC_KEEP_MONTHLY=0
PORT=3000
API_TOKEN=""
ALLOWED_MANAGEMENT_IPS=""
+9
View File
@@ -17,6 +17,10 @@ export const requiredEnv = [
export const config = {
port: Number(process.env.PORT || 3000),
apiToken: process.env.API_TOKEN || '',
allowedManagementIps: (process.env.ALLOWED_MANAGEMENT_IPS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
zfsPoolName: process.env.ZFS_POOL_NAME || '',
resticEnv: {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID || '',
@@ -44,6 +48,7 @@ export const editableEnv = [
{ 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: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false },
];
export function missingEnvVars() {
@@ -108,6 +113,10 @@ function applyRuntimeEnv(values) {
}
config.port = Number(process.env.PORT || 3000);
config.apiToken = process.env.API_TOKEN || '';
config.allowedManagementIps = (process.env.ALLOWED_MANAGEMENT_IPS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
config.zfsPoolName = process.env.ZFS_POOL_NAME || '';
config.resticEnv.AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
config.resticEnv.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
+8
View File
@@ -17,6 +17,10 @@ app.use(cors());
app.use(express.json());
app.use((req, res, next) => {
if (config.allowedManagementIps.length && !config.allowedManagementIps.includes(normalizeIp(req.ip))) {
res.status(403).json({ error: 'Forbidden management source.' });
return;
}
if (!config.apiToken) {
next();
return;
@@ -29,6 +33,10 @@ app.use((req, res, next) => {
res.status(401).json({ error: 'Unauthorized.' });
});
function normalizeIp(value) {
return String(value || '').replace(/^::ffff:/, '');
}
app.use('/api/health', healthRouter);
app.use('/api/vms', vmsRouter);
app.use('/api/snapshots', snapshotsRouter);
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Incus Backup Node Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/incus-backup-ui/backend
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=Incus Backup Management API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/incus-backup-ui/management
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5
User=incus-backup
[Install]
WantedBy=multi-user.target
+82
View File
@@ -0,0 +1,82 @@
# Deployment
## Node Agent
Run this on every Incus host:
```bash
cd /opt/incus-backup-ui/backend
cp .env.example .env
npm install
sudo npm start
```
Important `.env` values:
```env
PORT=3000
API_TOKEN="long-random-token"
ALLOWED_MANAGEMENT_IPS="management-server-ip"
```
If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses.
Install systemd service:
```bash
sudo cp deploy/systemd/incus-backup-agent.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now incus-backup-agent
sudo journalctl -u incus-backup-agent -f
```
## Management API
Run this on the management server:
```bash
cd /opt/incus-backup-ui/management
cp .env.example .env
npm install
npm start
```
Important `.env` values:
```env
PORT=3100
SESSION_SECRET="long-random-secret"
AUTH_USERNAME="admin"
AUTH_PASSWORD="initial-password"
DATABASE_PATH="./management.sqlite"
```
Reset an existing password:
```bash
npm run reset-password -- admin "new-password"
```
Install systemd service:
```bash
sudo useradd --system --home /opt/incus-backup-ui --shell /usr/sbin/nologin incus-backup
sudo chown -R incus-backup:incus-backup /opt/incus-backup-ui/management
sudo cp deploy/systemd/incus-backup-management.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now incus-backup-management
sudo journalctl -u incus-backup-management -f
```
## Frontend
Point the frontend at the management API:
```bash
cd /opt/incus-backup-ui/frontend
npm install
VITE_API_URL=http://management-server:3100/api npm run build
npm run preview -- --host 0.0.0.0
```
For production, put the frontend and management API behind HTTPS.
+15 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CalendarClock, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
import { AlertTriangle, CalendarClock, ClipboardList, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
import { api, errorMessage } from './api.js';
import { Dashboard } from './components/Dashboard.jsx';
import { Login } from './components/Login.jsx';
import { Nodes } from './components/Nodes.jsx';
import { Operations } from './components/Operations.jsx';
import { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx';
@@ -139,6 +140,17 @@ export default function App() {
<CalendarClock className="h-4 w-4" />
Scheduler
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
onClick={() => {
setSelectedVm(null);
setView('operations');
}}
type="button"
>
<ClipboardList className="h-4 w-4" />
Operations
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
onClick={() => {
@@ -180,6 +192,8 @@ export default function App() {
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} />
) : view === 'operations' ? (
<Operations />
) : view === 'settings' ? (
<Settings nodes={nodes} onChanged={refresh} />
) : view === 'scheduler' ? (
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { api, errorMessage } from '../api.js';
export function Operations() {
const [audit, setAudit] = useState([]);
const [history, setHistory] = useState([]);
const [error, setError] = useState('');
async function load() {
setError('');
try {
const [auditResult, historyResult] = await Promise.all([
api.get('/operations/audit'),
api.get('/operations/history'),
]);
setAudit(auditResult.data);
setHistory(historyResult.data);
} catch (requestError) {
setError(errorMessage(requestError));
}
}
useEffect(() => {
load();
}, []);
return (
<section className="space-y-5">
<div className="border-b border-zinc-800 pb-5">
<h1 className="text-2xl font-semibold text-zinc-100">Operations</h1>
</div>
{error ? <div className="rounded-md border border-red-900 bg-red-950/50 px-4 py-3 text-sm text-red-200">{error}</div> : null}
<Table
columns={['Time', 'Type', 'Node', 'VM', 'Status', 'Agent Job']}
rows={history.map((row) => [
formatTime(row.startedAt),
row.type,
row.nodeName,
row.vmName,
row.error || row.status,
row.agentJobId || '-',
])}
title="Job History"
/>
<Table
columns={['Time', 'User', 'Action', 'Target', 'Details']}
rows={audit.map((row) => [
formatTime(row.createdAt),
row.username || 'system',
row.action,
`${row.targetType}${row.targetId ? `:${row.targetId}` : ''}`,
JSON.stringify(row.details || {}),
])}
title="Audit Log"
/>
</section>
);
}
function Table({ columns, rows, title }) {
return (
<section className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
<div className="border-b border-zinc-800 px-4 py-3">
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-left text-sm">
<thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
<tr>{columns.map((column) => <th className="px-4 py-3 font-medium" key={column}>{column}</th>)}</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => <td className="px-4 py-3 text-zinc-300" key={cellIndex}>{cell}</td>)}
</tr>
))}
{!rows.length ? <tr><td className="px-4 py-8 text-center text-zinc-500" colSpan={columns.length}>No entries.</td></tr> : null}
</tbody>
</table>
</div>
</section>
);
}
function formatTime(value) {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
}
+1
View File
@@ -5,6 +5,7 @@
"private": true,
"scripts": {
"dev": "node --watch src/index.js",
"reset-password": "node src/reset-password.js",
"start": "node src/index.js"
},
"dependencies": {
+22
View File
@@ -46,6 +46,28 @@ export function migrate() {
UNIQUE (node_id, vm_name),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
details TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_history (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
node_name TEXT NOT NULL,
vm_name TEXT NOT NULL,
type TEXT NOT NULL,
agent_job_id TEXT,
status TEXT NOT NULL,
error TEXT,
started_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
const existing = db.prepare('SELECT id FROM users LIMIT 1').get();
+2
View File
@@ -5,6 +5,7 @@ import { migrate } from './db.js';
import { requireAuth } from './auth.js';
import { authRouter } from './routes/auth.js';
import { nodesRouter } from './routes/nodes.js';
import { operationsRouter } from './routes/operations.js';
import { proxyRouter } from './routes/proxy.js';
import { schedulesRouter } from './routes/schedules.js';
import { startScheduler } from './scheduler.js';
@@ -19,6 +20,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api', requireAuth, proxyRouter);
app.use('/api/nodes', requireAuth, nodesRouter);
app.use('/api/operations', requireAuth, operationsRouter);
app.use('/api/schedules', requireAuth, schedulesRouter);
app.use((error, _req, res, _next) => {
+23
View File
@@ -0,0 +1,23 @@
import { config } from './config.js';
import { hashPassword } from './crypto.js';
import { migrate } from './db.js';
import { getUserByUsername, setUserPassword } from './store.js';
migrate();
const username = process.argv[2] || config.authUsername;
const password = process.argv[3] || config.authPassword;
if (!username || !password) {
console.error('Usage: node src/reset-password.js <username> <password>');
process.exit(1);
}
const user = getUserByUsername(username);
if (!user) {
console.error(`User "${username}" was not found.`);
process.exit(1);
}
setUserPassword(username, hashPassword(password));
console.log(`Password updated for ${username}.`);
+15
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { clearSessionCookie, currentSession, login, logout, setSessionCookie } from '../auth.js';
import { createAuditEvent } from '../store.js';
export const authRouter = Router();
@@ -15,11 +16,25 @@ authRouter.post('/login', (req, res) => {
return;
}
setSessionCookie(res, result.session);
createAuditEvent({
user: result.user,
action: 'login',
targetType: 'session',
targetId: result.session.id,
});
res.json({ user: result.user });
});
authRouter.post('/logout', (req, res) => {
const session = currentSession(req);
if (session) {
createAuditEvent({
user: session.user,
action: 'logout',
targetType: 'session',
targetId: session.sessionId,
});
}
logout(session?.sessionId);
clearSessionCookie(res);
res.json({ ok: true });
+4 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { agentRequest, publicNode } from '../agentClient.js';
import { createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
import { createAuditEvent, createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js';
export const nodesRouter = Router();
@@ -11,6 +11,7 @@ nodesRouter.get('/', (_req, res) => {
nodesRouter.post('/', async (req, res, next) => {
try {
const node = createNode(validateNodeInput(req.body));
createAuditEvent({ user: req.user, action: 'create', targetType: 'node', targetId: node.id, details: { name: node.name, baseUrl: node.baseUrl } });
res.status(201).json(publicNode(node));
} catch (error) {
next(error);
@@ -24,6 +25,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
res.status(404).json({ error: 'Node not found.' });
return;
}
createAuditEvent({ user: req.user, action: 'update', targetType: 'node', targetId: node.id, details: { name: node.name, baseUrl: node.baseUrl, enabled: node.enabled } });
res.json(publicNode(node));
} catch (error) {
next(error);
@@ -31,6 +33,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
});
nodesRouter.delete('/:nodeId', (req, res) => {
createAuditEvent({ user: req.user, action: 'delete', targetType: 'node', targetId: req.params.nodeId });
deleteNode(req.params.nodeId);
res.json({ ok: true });
});
+12
View File
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { listAuditEvents, listJobHistory } from '../store.js';
export const operationsRouter = Router();
operationsRouter.get('/audit', (_req, res) => {
res.json(listAuditEvents());
});
operationsRouter.get('/history', (_req, res) => {
res.json(listJobHistory());
});
+18 -6
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { agentRequest } from '../agentClient.js';
import { getNode, listNodes, recordNodeHealth } from '../store.js';
import { createAuditEvent, createJobHistory, getNode, listNodes, recordNodeHealth, updateJobHistory } from '../store.js';
export const proxyRouter = Router();
@@ -73,19 +73,29 @@ proxyRouter.get('/snapshots/:nodeId/:vmName/:snapshotId/files', async (req, res,
});
proxyRouter.post('/backup/:nodeId/:vmName', async (req, res, next) => {
try {
const node = requireNode(req.params.nodeId);
res.status(202).json(await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
let historyId = null;
try {
const result = await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
historyId = createJobHistory({ node, vmName: req.params.vmName, type: 'backup', agentJobId: result?.jobId, status: 'accepted' });
createAuditEvent({ user: req.user, action: 'backup', targetType: 'vm', targetId: `${node.id}:${req.params.vmName}`, details: { historyId, agentJobId: result?.jobId } });
res.status(202).json(result);
} catch (error) {
if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error);
}
});
proxyRouter.post('/restore/:nodeId/:vmName', async (req, res, next) => {
try {
const node = requireNode(req.params.nodeId);
res.status(202).json(await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
let historyId = null;
try {
const result = await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
historyId = createJobHistory({ node, vmName: req.params.vmName, type: 'restore', agentJobId: result?.jobId, status: 'accepted' });
createAuditEvent({ user: req.user, action: 'restore', targetType: 'vm', targetId: `${node.id}:${req.params.vmName}`, details: { historyId, agentJobId: result?.jobId, snapshotId: req.body?.snapshotId } });
res.status(202).json(result);
} catch (error) {
if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error);
}
});
@@ -102,7 +112,9 @@ proxyRouter.get('/settings/:nodeId', async (req, res, next) => {
proxyRouter.put('/settings/:nodeId', async (req, res, next) => {
try {
const node = requireNode(req.params.nodeId);
res.json(await agentRequest(node, '/settings', { method: 'PUT', body: req.body || {} }));
const result = await agentRequest(node, '/settings', { method: 'PUT', body: req.body || {} });
createAuditEvent({ user: req.user, action: 'update', targetType: 'node-settings', targetId: node.id });
res.json(result);
} catch (error) {
next(error);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { listSchedules, replaceSchedules } from '../store.js';
import { createAuditEvent, listSchedules, replaceSchedules } from '../store.js';
export const schedulesRouter = Router();
@@ -9,7 +9,9 @@ schedulesRouter.get('/', (_req, res) => {
schedulesRouter.put('/', (req, res, next) => {
try {
res.json(replaceSchedules(req.body?.schedules || []));
const schedules = replaceSchedules(req.body?.schedules || []);
createAuditEvent({ user: req.user, action: 'replace', targetType: 'schedules', details: { count: schedules.length } });
res.json(schedules);
} catch (error) {
next(error);
}
+7 -3
View File
@@ -1,5 +1,5 @@
import { agentRequest } from './agentClient.js';
import { listDueSchedules, markScheduleRun, nextRunFrom } from './store.js';
import { createAuditEvent, createJobHistory, listDueSchedules, markScheduleRun, nextRunFrom } from './store.js';
let timer = null;
@@ -15,11 +15,15 @@ async function runDueSchedules() {
const schedules = listDueSchedules(now);
for (const schedule of schedules) {
try {
await agentRequest({
const node = {
id: schedule.node_id,
name: schedule.node_name,
baseUrl: schedule.base_url,
token: schedule.token,
}, `/backup/${encodeURIComponent(schedule.vm_name)}`, { method: 'POST' });
};
const result = await agentRequest(node, `/backup/${encodeURIComponent(schedule.vm_name)}`, { method: 'POST' });
const historyId = createJobHistory({ node, vmName: schedule.vm_name, type: 'scheduled-backup', agentJobId: result?.jobId, status: 'accepted' });
createAuditEvent({ action: 'scheduled-backup', targetType: 'vm', targetId: `${node.id}:${schedule.vm_name}`, details: { scheduleId: schedule.id, historyId, agentJobId: result?.jobId } });
markScheduleRun(schedule.id, {
lastRunAt: now.toISOString(),
+63
View File
@@ -5,6 +5,10 @@ export function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function setUserPassword(username, passwordHash) {
return db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(passwordHash, username);
}
export function getUserBySession(sessionId) {
return db.prepare(`
SELECT users.id, users.username
@@ -64,6 +68,65 @@ export function deleteNode(id) {
db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
}
export function createAuditEvent({ user = null, action, targetType, targetId = null, details = {} }) {
db.prepare(`
INSERT INTO audit_events (id, user_id, username, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`audit_${randomToken(8)}`,
user?.id || null,
user?.username || null,
action,
targetType,
targetId,
JSON.stringify(details),
new Date().toISOString(),
);
}
export function listAuditEvents(limit = 100) {
return db.prepare('SELECT * FROM audit_events ORDER BY created_at DESC LIMIT ?').all(limit).map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username,
action: row.action,
targetType: row.target_type,
targetId: row.target_id,
details: JSON.parse(row.details || '{}'),
createdAt: row.created_at,
}));
}
export function createJobHistory({ node, vmName, type, agentJobId = null, status = 'started', error = null }) {
const now = new Date().toISOString();
const id = `hist_${randomToken(8)}`;
db.prepare(`
INSERT INTO job_history (id, node_id, node_name, vm_name, type, agent_job_id, status, error, started_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, node.id, node.name, vmName, type, agentJobId, status, error, now, now);
return id;
}
export function updateJobHistory(id, values) {
db.prepare('UPDATE job_history SET status = ?, error = ?, updated_at = ? WHERE id = ?')
.run(values.status, values.error || null, new Date().toISOString(), id);
}
export function listJobHistory(limit = 100) {
return db.prepare('SELECT * FROM job_history ORDER BY started_at DESC LIMIT ?').all(limit).map((row) => ({
id: row.id,
nodeId: row.node_id,
nodeName: row.node_name,
vmName: row.vm_name,
type: row.type,
agentJobId: row.agent_job_id,
status: row.status,
error: row.error,
startedAt: row.started_at,
updatedAt: row.updated_at,
}));
}
export function recordNodeHealth(id, status) {
db.prepare('UPDATE nodes SET last_health_status = ?, last_health_at = ? WHERE id = ?')
.run(status, new Date().toISOString(), id);