- 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:
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user