- 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:
@@ -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.
|
||||
|
||||
@@ -9,3 +9,4 @@ RESTIC_KEEP_WEEKLY=0
|
||||
RESTIC_KEEP_MONTHLY=0
|
||||
PORT=3000
|
||||
API_TOKEN=""
|
||||
ALLOWED_MANAGEMENT_IPS=""
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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' ? (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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