- 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
+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) => {
const node = requireNode(req.params.nodeId);
let historyId = null;
try {
const node = requireNode(req.params.nodeId);
res.status(202).json(await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
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) => {
const node = requireNode(req.params.nodeId);
let historyId = null;
try {
const node = requireNode(req.params.nodeId);
res.status(202).json(await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} }));
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);