diff --git a/.DS_Store b/.DS_Store index ee47e97..9284dd2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index cd8396e..2d7445e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example index bb414a7..051175c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,4 @@ RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_MONTHLY=0 PORT=3000 API_TOKEN="" +ALLOWED_MANAGEMENT_IPS="" diff --git a/backend/src/config.js b/backend/src/config.js index d4b51af..a9053d2 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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 || ''; diff --git a/backend/src/index.js b/backend/src/index.js index a5c5815..4a83f81 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/deploy/systemd/incus-backup-agent.service b/deploy/systemd/incus-backup-agent.service new file mode 100644 index 0000000..587f0d1 --- /dev/null +++ b/deploy/systemd/incus-backup-agent.service @@ -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 diff --git a/deploy/systemd/incus-backup-management.service b/deploy/systemd/incus-backup-management.service new file mode 100644 index 0000000..b1dc941 --- /dev/null +++ b/deploy/systemd/incus-backup-management.service @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..825cce5 --- /dev/null +++ b/docs/deployment.md @@ -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. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 928ad93..57d835c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { Scheduler +