- 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. 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 ## Frontend
```bash ```bash
@@ -62,3 +69,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. 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 RESTIC_KEEP_MONTHLY=0
PORT=3000 PORT=3000
API_TOKEN="" API_TOKEN=""
ALLOWED_MANAGEMENT_IPS=""
+9
View File
@@ -17,6 +17,10 @@ export const requiredEnv = [
export const config = { export const config = {
port: Number(process.env.PORT || 3000), port: Number(process.env.PORT || 3000),
apiToken: process.env.API_TOKEN || '', apiToken: process.env.API_TOKEN || '',
allowedManagementIps: (process.env.ALLOWED_MANAGEMENT_IPS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
zfsPoolName: process.env.ZFS_POOL_NAME || '', zfsPoolName: process.env.ZFS_POOL_NAME || '',
resticEnv: { resticEnv: {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID || '', 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: '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: false, secret: true },
{ key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false },
]; ];
export function missingEnvVars() { export function missingEnvVars() {
@@ -108,6 +113,10 @@ function applyRuntimeEnv(values) {
} }
config.port = Number(process.env.PORT || 3000); config.port = Number(process.env.PORT || 3000);
config.apiToken = process.env.API_TOKEN || ''; 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.zfsPoolName = process.env.ZFS_POOL_NAME || '';
config.resticEnv.AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; config.resticEnv.AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
config.resticEnv.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; 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(express.json());
app.use((req, res, next) => { 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) { if (!config.apiToken) {
next(); next();
return; return;
@@ -29,6 +33,10 @@ app.use((req, res, next) => {
res.status(401).json({ error: 'Unauthorized.' }); res.status(401).json({ error: 'Unauthorized.' });
}); });
function normalizeIp(value) {
return String(value || '').replace(/^::ffff:/, '');
}
app.use('/api/health', healthRouter); app.use('/api/health', healthRouter);
app.use('/api/vms', vmsRouter); app.use('/api/vms', vmsRouter);
app.use('/api/snapshots', snapshotsRouter); 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 { 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 { api, errorMessage } from './api.js';
import { Dashboard } from './components/Dashboard.jsx'; import { Dashboard } from './components/Dashboard.jsx';
import { Login } from './components/Login.jsx'; import { Login } from './components/Login.jsx';
import { Nodes } from './components/Nodes.jsx'; import { Nodes } from './components/Nodes.jsx';
import { Operations } from './components/Operations.jsx';
import { Scheduler } from './components/Scheduler.jsx'; import { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx'; import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx'; import { VMDetail } from './components/VMDetail.jsx';
@@ -139,6 +140,17 @@ export default function App() {
<CalendarClock className="h-4 w-4" /> <CalendarClock className="h-4 w-4" />
Scheduler Scheduler
</button> </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 <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" 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={() => { onClick={() => {
@@ -180,6 +192,8 @@ export default function App() {
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'nodes' ? ( {view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} /> <Nodes nodes={nodes} onChanged={refresh} />
) : view === 'operations' ? (
<Operations />
) : view === 'settings' ? ( ) : view === 'settings' ? (
<Settings nodes={nodes} onChanged={refresh} /> <Settings nodes={nodes} onChanged={refresh} />
) : view === 'scheduler' ? ( ) : 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, "private": true,
"scripts": { "scripts": {
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"reset-password": "node src/reset-password.js",
"start": "node src/index.js" "start": "node src/index.js"
}, },
"dependencies": { "dependencies": {
+22
View File
@@ -46,6 +46,28 @@ export function migrate() {
UNIQUE (node_id, vm_name), UNIQUE (node_id, vm_name),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE 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(); 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 { requireAuth } from './auth.js';
import { authRouter } from './routes/auth.js'; import { authRouter } from './routes/auth.js';
import { nodesRouter } from './routes/nodes.js'; import { nodesRouter } from './routes/nodes.js';
import { operationsRouter } from './routes/operations.js';
import { proxyRouter } from './routes/proxy.js'; import { proxyRouter } from './routes/proxy.js';
import { schedulesRouter } from './routes/schedules.js'; import { schedulesRouter } from './routes/schedules.js';
import { startScheduler } from './scheduler.js'; import { startScheduler } from './scheduler.js';
@@ -19,6 +20,7 @@ app.use(express.json());
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api', requireAuth, proxyRouter); app.use('/api', requireAuth, proxyRouter);
app.use('/api/nodes', requireAuth, nodesRouter); app.use('/api/nodes', requireAuth, nodesRouter);
app.use('/api/operations', requireAuth, operationsRouter);
app.use('/api/schedules', requireAuth, schedulesRouter); app.use('/api/schedules', requireAuth, schedulesRouter);
app.use((error, _req, res, _next) => { 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 { Router } from 'express';
import { clearSessionCookie, currentSession, login, logout, setSessionCookie } from '../auth.js'; import { clearSessionCookie, currentSession, login, logout, setSessionCookie } from '../auth.js';
import { createAuditEvent } from '../store.js';
export const authRouter = Router(); export const authRouter = Router();
@@ -15,11 +16,25 @@ authRouter.post('/login', (req, res) => {
return; return;
} }
setSessionCookie(res, result.session); setSessionCookie(res, result.session);
createAuditEvent({
user: result.user,
action: 'login',
targetType: 'session',
targetId: result.session.id,
});
res.json({ user: result.user }); res.json({ user: result.user });
}); });
authRouter.post('/logout', (req, res) => { authRouter.post('/logout', (req, res) => {
const session = currentSession(req); const session = currentSession(req);
if (session) {
createAuditEvent({
user: session.user,
action: 'logout',
targetType: 'session',
targetId: session.sessionId,
});
}
logout(session?.sessionId); logout(session?.sessionId);
clearSessionCookie(res); clearSessionCookie(res);
res.json({ ok: true }); res.json({ ok: true });
+4 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { agentRequest, publicNode } from '../agentClient.js'; 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(); export const nodesRouter = Router();
@@ -11,6 +11,7 @@ nodesRouter.get('/', (_req, res) => {
nodesRouter.post('/', async (req, res, next) => { nodesRouter.post('/', async (req, res, next) => {
try { try {
const node = createNode(validateNodeInput(req.body)); 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)); res.status(201).json(publicNode(node));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -24,6 +25,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
res.status(404).json({ error: 'Node not found.' }); res.status(404).json({ error: 'Node not found.' });
return; 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)); res.json(publicNode(node));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -31,6 +33,7 @@ nodesRouter.put('/:nodeId', async (req, res, next) => {
}); });
nodesRouter.delete('/:nodeId', (req, res) => { nodesRouter.delete('/:nodeId', (req, res) => {
createAuditEvent({ user: req.user, action: 'delete', targetType: 'node', targetId: req.params.nodeId });
deleteNode(req.params.nodeId); deleteNode(req.params.nodeId);
res.json({ ok: true }); 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 { Router } from 'express';
import { agentRequest } from '../agentClient.js'; 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(); 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) => { proxyRouter.post('/backup/:nodeId/:vmName', async (req, res, next) => {
const node = requireNode(req.params.nodeId);
let historyId = null;
try { try {
const node = requireNode(req.params.nodeId); const result = await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
res.status(202).json(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) { } catch (error) {
if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error); next(error);
} }
}); });
proxyRouter.post('/restore/:nodeId/:vmName', async (req, res, next) => { proxyRouter.post('/restore/:nodeId/:vmName', async (req, res, next) => {
const node = requireNode(req.params.nodeId);
let historyId = null;
try { try {
const node = requireNode(req.params.nodeId); const result = await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} });
res.status(202).json(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) { } catch (error) {
if (historyId) updateJobHistory(historyId, { status: 'failed', error: error.message });
next(error); next(error);
} }
}); });
@@ -102,7 +112,9 @@ proxyRouter.get('/settings/:nodeId', async (req, res, next) => {
proxyRouter.put('/settings/:nodeId', async (req, res, next) => { proxyRouter.put('/settings/:nodeId', async (req, res, next) => {
try { try {
const node = requireNode(req.params.nodeId); 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) { } catch (error) {
next(error); next(error);
} }
+4 -2
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { listSchedules, replaceSchedules } from '../store.js'; import { createAuditEvent, listSchedules, replaceSchedules } from '../store.js';
export const schedulesRouter = Router(); export const schedulesRouter = Router();
@@ -9,7 +9,9 @@ schedulesRouter.get('/', (_req, res) => {
schedulesRouter.put('/', (req, res, next) => { schedulesRouter.put('/', (req, res, next) => {
try { 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) { } catch (error) {
next(error); next(error);
} }
+7 -3
View File
@@ -1,5 +1,5 @@
import { agentRequest } from './agentClient.js'; import { agentRequest } from './agentClient.js';
import { listDueSchedules, markScheduleRun, nextRunFrom } from './store.js'; import { createAuditEvent, createJobHistory, listDueSchedules, markScheduleRun, nextRunFrom } from './store.js';
let timer = null; let timer = null;
@@ -15,11 +15,15 @@ async function runDueSchedules() {
const schedules = listDueSchedules(now); const schedules = listDueSchedules(now);
for (const schedule of schedules) { for (const schedule of schedules) {
try { try {
await agentRequest({ const node = {
id: schedule.node_id, id: schedule.node_id,
name: schedule.node_name,
baseUrl: schedule.base_url, baseUrl: schedule.base_url,
token: schedule.token, 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, { markScheduleRun(schedule.id, {
lastRunAt: now.toISOString(), lastRunAt: now.toISOString(),
+63
View File
@@ -5,6 +5,10 @@ export function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(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) { export function getUserBySession(sessionId) {
return db.prepare(` return db.prepare(`
SELECT users.id, users.username SELECT users.id, users.username
@@ -64,6 +68,65 @@ export function deleteNode(id) {
db.prepare('DELETE FROM nodes WHERE id = ?').run(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) { export function recordNodeHealth(id, status) {
db.prepare('UPDATE nodes SET last_health_status = ?, last_health_at = ? WHERE id = ?') db.prepare('UPDATE nodes SET last_health_status = ?, last_health_at = ? WHERE id = ?')
.run(status, new Date().toISOString(), id); .run(status, new Date().toISOString(), id);