From aae54167a1920e4af35b2b974a63fd1f42fe472c Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 21 May 2026 09:51:07 +0200 Subject: [PATCH] Builded new structure. Now with remote setup and management Server more than one Node can be connected added user auth --- .gitignore | 8 +- README.md | 34 +- frontend/src/App.jsx | 75 ++- frontend/src/api.js | 3 +- frontend/src/components/Dashboard.jsx | 8 +- frontend/src/components/Login.jsx | 64 ++ frontend/src/components/Nodes.jsx | 149 +++++ frontend/src/components/Scheduler.jsx | 35 +- frontend/src/components/Settings.jsx | 34 +- frontend/src/components/VMDetail.jsx | 13 +- management/.env.example | 5 + management/package-lock.json | 867 ++++++++++++++++++++++++++ management/package.json | 15 + management/src/agentClient.js | 34 + management/src/auth.js | 52 ++ management/src/config.js | 12 + management/src/crypto.js | 19 + management/src/db.js | 62 ++ management/src/index.js | 33 + management/src/routes/auth.js | 26 + management/src/routes/nodes.js | 96 +++ management/src/routes/proxy.js | 119 ++++ management/src/routes/schedules.js | 16 + management/src/scheduler.js | 37 ++ management/src/store.js | 178 ++++++ 25 files changed, 1942 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/Login.jsx create mode 100644 frontend/src/components/Nodes.jsx create mode 100644 management/.env.example create mode 100644 management/package-lock.json create mode 100644 management/package.json create mode 100644 management/src/agentClient.js create mode 100644 management/src/auth.js create mode 100644 management/src/config.js create mode 100644 management/src/crypto.js create mode 100644 management/src/db.js create mode 100644 management/src/index.js create mode 100644 management/src/routes/auth.js create mode 100644 management/src/routes/nodes.js create mode 100644 management/src/routes/proxy.js create mode 100644 management/src/routes/schedules.js create mode 100644 management/src/scheduler.js create mode 100644 management/src/store.js diff --git a/.gitignore b/.gitignore index 12f230b..29dee2e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,14 +4,20 @@ node_modules/ # Local environment and secrets .env .env.* -.env.example +!.env.example backend/.env backend/.env.* !backend/.env.example +management/.env +management/.env.* +!management/.env.example # Runtime state backend/schedules.json backend/schedules.json.tmp +management/*.sqlite +management/*.sqlite-shm +management/*.sqlite-wal # Build output dist/ diff --git a/README.md b/README.md index 12e6d77..cd8396e 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ Dark-mode control plane for Incus VM backups using ZFS block devices, Restic, an ## Layout -- `backend/`: Express API that validates Incus VMs, runs host commands with `spawn`, manages in-memory jobs, and enforces one active backup/restore per VM. -- `frontend/`: React/Vite dashboard for health, VM status, snapshots, backup jobs, and explicit destructive restore confirmation. +- `management/`: Central Express API with cookie login, SQLite storage, node registry, central schedules, and proxy calls to node agents. +- `backend/`: Node agent API that runs on each Incus host, validates Incus VMs, runs host commands with `spawn`, manages in-memory jobs, and enforces one active backup/restore per VM. +- `frontend/`: React/Vite dashboard for management login, node management, health, VM status, snapshots, backup jobs, and explicit destructive restore confirmation. - `incus-backup-ui-plan.md`: Product and implementation plan. -## Backend +## Node Agent ```bash cd backend @@ -17,7 +18,9 @@ npm install npm run dev ``` -The backend must run on the Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges. +The node agent must run on every Incus host with permission to access Incus, ZFS, `/dev/zvol`, Restic, and S3 credentials. In production this usually means running it as root or through a tightly scoped service account with the needed privileges. + +Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent. Required commands: @@ -27,6 +30,19 @@ Required commands: - `udevadm` - `dd` +## Management API + +```bash +cd management +cp .env.example .env +npm install +npm run dev +``` + +The management API stores nodes, users, sessions, and central schedules in SQLite. Configure the first admin user through `AUTH_USERNAME` and `AUTH_PASSWORD` before the first start. If no password is configured, the development fallback is `admin`. + +The management API uses Node's built-in SQLite module and requires Node.js 22.5 or newer. + ## Frontend ```bash @@ -35,17 +51,13 @@ npm install npm run dev ``` -Set `VITE_API_URL` if the API is not available at `http://localhost:3000/api`. - -If `API_TOKEN` is set in the backend, set matching `VITE_API_TOKEN` for the frontend. +Set `VITE_API_URL` if the management API is not available at `http://localhost:3100/api`. ## Settings Page -The UI includes a Settings page for editing the backend environment values directly. It writes to `backend/.env` through `GET /api/settings` and `PUT /api/settings`. +The UI includes a Settings page for editing selected node-agent environment values through the management API. The management server forwards those requests to the selected node agent. -If `API_TOKEN` is set from the Settings page, the browser stores that token locally and immediately uses it for subsequent API requests. If a token is already configured on the backend and the browser does not know it yet, start the frontend with `VITE_API_TOKEN` or clear/update the token manually in browser storage. - -Changing most values applies to new API calls and jobs immediately. Changing `PORT` requires restarting the backend process. +Changing most node-agent values applies to new API calls and jobs immediately. Changing a node-agent `PORT` requires restarting that agent process. ## Safety Notes diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5431d5a..928ad93 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, CalendarClock, DatabaseBackup, RefreshCw, Settings as SettingsIcon } from 'lucide-react'; +import { AlertTriangle, CalendarClock, 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 { Scheduler } from './components/Scheduler.jsx'; import { Settings } from './components/Settings.jsx'; import { VMDetail } from './components/VMDetail.jsx'; @@ -11,7 +13,10 @@ export default function App() { const [vms, setVms] = useState([]); const [jobs, setJobs] = useState([]); const [schedules, setSchedules] = useState([]); + const [nodes, setNodes] = useState([]); const [selectedVm, setSelectedVm] = useState(null); + const [session, setSession] = useState(null); + const [sessionLoading, setSessionLoading] = useState(true); const [view, setView] = useState('dashboard'); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); @@ -19,11 +24,12 @@ export default function App() { async function refresh() { setError(''); try { - const [healthResult, vmsResult, jobsResult, schedulesResult] = await Promise.allSettled([ + const [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult] = await Promise.allSettled([ api.get('/health'), api.get('/vms'), api.get('/jobs'), api.get('/schedules'), + api.get('/nodes'), ]); if (healthResult.status === 'fulfilled') { @@ -35,8 +41,9 @@ export default function App() { if (vmsResult.status === 'fulfilled') setVms(vmsResult.value.data); if (jobsResult.status === 'fulfilled') setJobs(jobsResult.value.data); if (schedulesResult.status === 'fulfilled') setSchedules(schedulesResult.value.data); + if (nodesResult.status === 'fulfilled') setNodes(nodesResult.value.data); - const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult].find((result) => result.status === 'rejected'); + const firstFailure = [healthResult, vmsResult, jobsResult, schedulesResult, nodesResult].find((result) => result.status === 'rejected'); if (firstFailure) setError(errorMessage(firstFailure.reason)); } catch (requestError) { setError(errorMessage(requestError)); @@ -46,16 +53,49 @@ export default function App() { } useEffect(() => { + loadSession(); + }, []); + + useEffect(() => { + if (!session?.authenticated) return undefined; refresh(); const timer = window.setInterval(refresh, 5000); return () => window.clearInterval(timer); - }, []); + }, [session?.authenticated]); const currentVm = useMemo( - () => vms.find((vm) => vm.name === selectedVm) || null, + () => vms.find((vm) => vm.id === selectedVm) || null, [selectedVm, vms], ); + async function loadSession() { + try { + const result = await api.get('/auth/session'); + setSession(result.data); + } catch { + setSession({ authenticated: false, user: null }); + } finally { + setSessionLoading(false); + } + } + + async function logout() { + await api.post('/auth/logout'); + setSession({ authenticated: false, user: null }); + setVms([]); + setJobs([]); + setSchedules([]); + setNodes([]); + } + + if (sessionLoading) { + return
; + } + + if (!session?.authenticated) { + return ; + } + return (
@@ -77,6 +117,17 @@ export default function App() {
+ +
@@ -119,8 +178,10 @@ export default function App() { ) : null}
- {view === 'settings' ? ( - + {view === 'nodes' ? ( + + ) : view === 'settings' ? ( + ) : view === 'scheduler' ? ( ) : currentVm ? ( diff --git a/frontend/src/api.js b/frontend/src/api.js index d3e5210..ac94402 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,8 +1,9 @@ import axios from 'axios'; export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api', + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3100/api', timeout: 15000, + withCredentials: true, }); const token = window.localStorage.getItem('incusBackupApiToken') || import.meta.env.VITE_API_TOKEN; diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 53fd489..eb4f2d2 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -26,6 +26,7 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) { VM + Node Incus Last Job Active Job @@ -34,8 +35,9 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) { {vms.map((vm) => ( - + {vm.name} + {vm.nodeName || '-'} @@ -59,7 +61,7 @@ export function Dashboard({ health, jobs, loading, onManage, vms }) { +
+ +
+ ); +} diff --git a/frontend/src/components/Nodes.jsx b/frontend/src/components/Nodes.jsx new file mode 100644 index 0000000..b6389fb --- /dev/null +++ b/frontend/src/components/Nodes.jsx @@ -0,0 +1,149 @@ +import { Plus, Save, Trash2, Wifi } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { api, errorMessage } from '../api.js'; + +export function Nodes({ nodes, onChanged }) { + const [drafts, setDrafts] = useState(() => nodes); + const [newNode, setNewNode] = useState({ name: '', baseUrl: '', token: '', enabled: true }); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + async function createNode(event) { + event.preventDefault(); + setError(''); + setMessage(''); + try { + await api.post('/nodes', newNode); + setNewNode({ name: '', baseUrl: '', token: '', enabled: true }); + setMessage('Node added.'); + await onChanged(); + } catch (requestError) { + setError(errorMessage(requestError)); + } + } + + async function saveNode(node) { + setError(''); + setMessage(''); + try { + const body = { ...node }; + if (!body.token) delete body.token; + await api.put(`/nodes/${encodeURIComponent(node.id)}`, body); + setMessage('Node saved.'); + await onChanged(); + } catch (requestError) { + setError(errorMessage(requestError)); + } + } + + async function testNode(node) { + setError(''); + setMessage(''); + try { + await api.post(`/nodes/${encodeURIComponent(node.id)}/test`); + setMessage('Node connection ok.'); + await onChanged(); + } catch (requestError) { + setError(errorMessage(requestError)); + } + } + + async function deleteNode(node) { + setError(''); + setMessage(''); + try { + await api.delete(`/nodes/${encodeURIComponent(node.id)}`); + setMessage('Node deleted.'); + await onChanged(); + } catch (requestError) { + setError(errorMessage(requestError)); + } + } + + function updateDraft(id, values) { + setDrafts((current) => current.map((node) => node.id === id ? { ...node, ...values } : node)); + } + + useEffect(() => { + setDrafts(nodes); + }, [nodes]); + + return ( +
+
+

Nodes

+

Register Incus backend agents by URL and token.

+
+ + {error ? : null} + {message ? : null} + +
+
+ setNewNode((current) => ({ ...current, name: value }))} value={newNode.name} /> + setNewNode((current) => ({ ...current, baseUrl: value }))} placeholder="http://node:3000" value={newNode.baseUrl} /> + setNewNode((current) => ({ ...current, token: value }))} type="password" value={newNode.token} /> + +
+
+ +
+
+ + + + + + + + + + + + + {drafts.map((node) => ( + + + + + + + + + ))} + {!drafts.length ? : null} + +
EnabledNameURLNew TokenStatusActions
+ updateDraft(node.id, { enabled: event.target.checked })} type="checkbox" /> + updateDraft(node.id, { name: event.target.value })} value={node.name} /> updateDraft(node.id, { baseUrl: event.target.value })} value={node.baseUrl} /> updateDraft(node.id, { token: event.target.value })} type="password" value={node.token || ''} />{node.lastHealthStatus || 'unknown'} +
+ + + +
+
No nodes configured.
+
+
+
+ ); +} + +function Input({ label, onChange, placeholder = '', type = 'text', value }) { + return ( + + ); +} + +function Notice({ text, tone }) { + const classes = { + bad: 'border-red-900 bg-red-950/50 text-red-200', + good: 'border-emerald-900 bg-emerald-950/50 text-emerald-200', + }; + return
{text}
; +} diff --git a/frontend/src/components/Scheduler.jsx b/frontend/src/components/Scheduler.jsx index ef80288..a0085dd 100644 --- a/frontend/src/components/Scheduler.jsx +++ b/frontend/src/components/Scheduler.jsx @@ -27,6 +27,7 @@ export function Scheduler({ onChanged, schedules, vms }) { .filter((row) => row.enabled) .map((row) => ({ id: row.id, + nodeId: row.nodeId, vmName: row.vmName, enabled: row.enabled, intervalHours: Number(row.intervalHours) || 24, @@ -47,10 +48,10 @@ export function Scheduler({ onChanged, schedules, vms }) { } } - function updateRow(vmName, values) { + function updateRow(nodeId, vmName, values) { setDirty(true); setDrafts((current) => mergeSchedules(vms, current).map((row) => ( - row.vmName === vmName ? { ...row, ...values } : row + row.nodeId === nodeId && row.vmName === vmName ? { ...row, ...values } : row ))); } @@ -73,6 +74,7 @@ export function Scheduler({ onChanged, schedules, vms }) { Enabled VM + Node Interval Time Next Run @@ -82,20 +84,21 @@ export function Scheduler({ onChanged, schedules, vms }) { {rows.map((row) => ( - + updateRow(row.vmName, { enabled: event.target.checked })} + onChange={(event) => updateRow(row.nodeId, row.vmName, { enabled: event.target.checked })} type="checkbox" /> {row.vmName} + {row.nodeName || '-'} updateRow(row.vmName, { timeOfDay: event.target.value, nextRunAt: null })} + onChange={(event) => updateRow(row.nodeId, row.vmName, { timeOfDay: event.target.value, nextRunAt: null })} type="time" value={row.timeOfDay || '02:00'} /> @@ -119,7 +122,7 @@ export function Scheduler({ onChanged, schedules, vms }) { ))} {!rows.length ? ( - + No VMs loaded. @@ -143,16 +146,18 @@ export function Scheduler({ onChanged, schedules, vms }) { } function mergeSchedules(vms, schedules) { - const byVm = new Map((schedules || []).map((schedule) => [schedule.vmName, schedule])); + const byVm = new Map((schedules || []).map((schedule) => [`${schedule.nodeId}:${schedule.vmName}`, schedule])); return (vms || []).map((vm) => ({ - id: byVm.get(vm.name)?.id, + id: byVm.get(`${vm.nodeId}:${vm.name}`)?.id, + nodeId: vm.nodeId, + nodeName: vm.nodeName, vmName: vm.name, - enabled: Boolean(byVm.get(vm.name)?.enabled), - intervalHours: byVm.get(vm.name)?.intervalHours || 24, - timeOfDay: byVm.get(vm.name)?.timeOfDay || '02:00', - nextRunAt: byVm.get(vm.name)?.nextRunAt || null, - lastRunAt: byVm.get(vm.name)?.lastRunAt || null, - lastError: byVm.get(vm.name)?.lastError || '', + enabled: Boolean(byVm.get(`${vm.nodeId}:${vm.name}`)?.enabled), + intervalHours: byVm.get(`${vm.nodeId}:${vm.name}`)?.intervalHours || 24, + timeOfDay: byVm.get(`${vm.nodeId}:${vm.name}`)?.timeOfDay || '02:00', + nextRunAt: byVm.get(`${vm.nodeId}:${vm.name}`)?.nextRunAt || null, + lastRunAt: byVm.get(`${vm.nodeId}:${vm.name}`)?.lastRunAt || null, + lastError: byVm.get(`${vm.nodeId}:${vm.name}`)?.lastError || '', })); } diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index a915de4..277dc45 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -1,10 +1,11 @@ import { Eye, EyeOff, Save } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { api, errorMessage, setApiToken } from '../api.js'; +import { api, errorMessage } from '../api.js'; -export function Settings({ onChanged }) { +export function Settings({ nodes, onChanged }) { const [fields, setFields] = useState([]); const [values, setValues] = useState({}); + const [nodeId, setNodeId] = useState(nodes[0]?.id || ''); const [visibleSecrets, setVisibleSecrets] = useState({}); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(''); @@ -16,9 +17,10 @@ export function Settings({ onChanged }) { ); async function loadSettings() { + if (!nodeId) return; setError(''); try { - const result = await api.get('/settings'); + const result = await api.get(`/settings/${encodeURIComponent(nodeId)}`); setFields(result.data.fields || []); setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || '']))); } catch (requestError) { @@ -28,7 +30,11 @@ export function Settings({ onChanged }) { useEffect(() => { loadSettings(); - }, []); + }, [nodeId]); + + useEffect(() => { + if (!nodeId && nodes[0]?.id) setNodeId(nodes[0].id); + }, [nodeId, nodes]); async function saveSettings(event) { event.preventDefault(); @@ -36,8 +42,7 @@ export function Settings({ onChanged }) { setMessage(''); setError(''); try { - setApiToken(values.API_TOKEN); - const result = await api.put('/settings', { values }); + const result = await api.put(`/settings/${encodeURIComponent(nodeId)}`, { values }); setFields(result.data.fields || []); setValues(Object.fromEntries((result.data.fields || []).map((field) => [field.key, field.value || '']))); setMessage('Settings saved. Running jobs use the updated values; a changed PORT still needs a backend restart.'); @@ -54,7 +59,7 @@ export function Settings({ onChanged }) {

Settings

- Values are written to backend/.env. Keep access to this UI restricted because secrets are editable here. + Values are written to the selected node agent. Keep access to this UI restricted because secrets are editable here.

@@ -65,6 +70,19 @@ export function Settings({ onChanged }) { ) : null}
+
+ +
{fields.map((field) => { const secretVisible = visibleSecrets[field.key]; @@ -102,7 +120,7 @@ export function Settings({ onChanged }) {
+

Node: {vm.nodeName || '-'}

Latest snapshot: {latestSnapshot ? formatTime(latestSnapshot) : 'None'}

diff --git a/management/.env.example b/management/.env.example new file mode 100644 index 0000000..5914720 --- /dev/null +++ b/management/.env.example @@ -0,0 +1,5 @@ +PORT=3100 +SESSION_SECRET="change-me" +AUTH_USERNAME="admin" +AUTH_PASSWORD="change-me" +DATABASE_PATH="./management.sqlite" diff --git a/management/package-lock.json b/management/package-lock.json new file mode 100644 index 0000000..0443af7 --- /dev/null +++ b/management/package-lock.json @@ -0,0 +1,867 @@ +{ + "name": "incus-backup-ui-management", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "incus-backup-ui-management", + "version": "0.1.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/management/package.json b/management/package.json new file mode 100644 index 0000000..0318e43 --- /dev/null +++ b/management/package.json @@ -0,0 +1,15 @@ +{ + "name": "incus-backup-ui-management", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } +} diff --git a/management/src/agentClient.js b/management/src/agentClient.js new file mode 100644 index 0000000..946b0cf --- /dev/null +++ b/management/src/agentClient.js @@ -0,0 +1,34 @@ +export async function agentRequest(node, path, options = {}) { + const response = await fetch(`${node.baseUrl}/api${path}`, { + method: options.method || 'GET', + headers: { + Authorization: `Bearer ${node.token}`, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: AbortSignal.timeout(options.timeout || 20000), + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + if (!response.ok) { + const error = new Error(data?.error || `Agent request failed with ${response.status}`); + error.status = response.status; + throw error; + } + return data; +} + +export function publicNode(node) { + return { + id: node.id, + name: node.name, + baseUrl: node.baseUrl, + enabled: node.enabled, + lastHealthStatus: node.lastHealthStatus, + lastHealthAt: node.lastHealthAt, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + }; +} diff --git a/management/src/auth.js b/management/src/auth.js new file mode 100644 index 0000000..62e9ea6 --- /dev/null +++ b/management/src/auth.js @@ -0,0 +1,52 @@ +import { createSession, deleteSession, getUserBySession, getUserByUsername } from './store.js'; +import { verifyPassword } from './crypto.js'; + +const cookieName = 'incus_backup_session'; + +export function requireAuth(req, res, next) { + const sessionId = readCookie(req, cookieName); + const user = sessionId ? getUserBySession(sessionId) : null; + if (!user) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + req.user = user; + req.sessionId = sessionId; + next(); +} + +export function currentSession(req) { + const sessionId = readCookie(req, cookieName); + const user = sessionId ? getUserBySession(sessionId) : null; + return user ? { sessionId, user } : null; +} + +export function login(username, password) { + const user = getUserByUsername(username); + if (!user || !verifyPassword(password, user.password_hash)) return null; + const session = createSession(user.id); + return { session, user: { id: user.id, username: user.username } }; +} + +export function logout(sessionId) { + if (sessionId) deleteSession(sessionId); +} + +export function setSessionCookie(res, session) { + res.setHeader('Set-Cookie', [ + `${cookieName}=${session.id}; Path=/; HttpOnly; SameSite=Lax; Expires=${session.expiresAt.toUTCString()}`, + ]); +} + +export function clearSessionCookie(res) { + res.setHeader('Set-Cookie', [`${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`]); +} + +function readCookie(req, name) { + const header = req.headers.cookie || ''; + for (const part of header.split(';')) { + const [key, ...value] = part.trim().split('='); + if (key === name) return decodeURIComponent(value.join('=')); + } + return ''; +} diff --git a/management/src/config.js b/management/src/config.js new file mode 100644 index 0000000..d69cc88 --- /dev/null +++ b/management/src/config.js @@ -0,0 +1,12 @@ +import dotenv from 'dotenv'; +import path from 'node:path'; + +dotenv.config(); + +export const config = { + port: Number(process.env.PORT || 3100), + sessionSecret: process.env.SESSION_SECRET || 'change-me', + authUsername: process.env.AUTH_USERNAME || 'admin', + authPassword: process.env.AUTH_PASSWORD || '', + databasePath: path.resolve(process.cwd(), process.env.DATABASE_PATH || './management.sqlite'), +}; diff --git a/management/src/crypto.js b/management/src/crypto.js new file mode 100644 index 0000000..b2412f1 --- /dev/null +++ b/management/src/crypto.js @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +const keyLength = 64; + +export function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) { + const hash = crypto.scryptSync(String(password), salt, keyLength).toString('hex'); + return `scrypt$${salt}$${hash}`; +} + +export function verifyPassword(password, storedHash) { + const [algorithm, salt, hash] = String(storedHash || '').split('$'); + if (algorithm !== 'scrypt' || !salt || !hash) return false; + const candidate = hashPassword(password, salt).split('$')[2]; + return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(candidate, 'hex')); +} + +export function randomToken(bytes = 32) { + return crypto.randomBytes(bytes).toString('hex'); +} diff --git a/management/src/db.js b/management/src/db.js new file mode 100644 index 0000000..9655e8c --- /dev/null +++ b/management/src/db.js @@ -0,0 +1,62 @@ +import { DatabaseSync } from 'node:sqlite'; +import { config } from './config.js'; +import { hashPassword } from './crypto.js'; + +export const db = new DatabaseSync(config.databasePath); + +export function migrate() { + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + token TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_health_status TEXT, + last_health_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + vm_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + interval_hours INTEGER NOT NULL DEFAULT 24, + time_of_day TEXT NOT NULL DEFAULT '02:00', + next_run_at TEXT, + last_run_at TEXT, + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (node_id, vm_name), + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + `); + + const existing = db.prepare('SELECT id FROM users LIMIT 1').get(); + if (!existing) { + const now = new Date().toISOString(); + const password = config.authPassword || 'admin'; + db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)') + .run(`user_${cryptoId()}`, config.authUsername, hashPassword(password), now); + } +} + +function cryptoId() { + return Math.random().toString(16).slice(2) + Date.now().toString(16); +} diff --git a/management/src/index.js b/management/src/index.js new file mode 100644 index 0000000..053f99e --- /dev/null +++ b/management/src/index.js @@ -0,0 +1,33 @@ +import cors from 'cors'; +import express from 'express'; +import { config } from './config.js'; +import { migrate } from './db.js'; +import { requireAuth } from './auth.js'; +import { authRouter } from './routes/auth.js'; +import { nodesRouter } from './routes/nodes.js'; +import { proxyRouter } from './routes/proxy.js'; +import { schedulesRouter } from './routes/schedules.js'; +import { startScheduler } from './scheduler.js'; + +migrate(); + +const app = express(); + +app.use(cors({ origin: true, credentials: true })); +app.use(express.json()); + +app.use('/api/auth', authRouter); +app.use('/api', requireAuth, proxyRouter); +app.use('/api/nodes', requireAuth, nodesRouter); +app.use('/api/schedules', requireAuth, schedulesRouter); + +app.use((error, _req, res, _next) => { + const status = error.status || 500; + res.status(status).json({ error: error.message || 'Internal server error.' }); +}); + +app.listen(config.port, () => { + console.log(`Incus backup management API listening on http://localhost:${config.port}`); +}); + +startScheduler(); diff --git a/management/src/routes/auth.js b/management/src/routes/auth.js new file mode 100644 index 0000000..b6b73d2 --- /dev/null +++ b/management/src/routes/auth.js @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { clearSessionCookie, currentSession, login, logout, setSessionCookie } from '../auth.js'; + +export const authRouter = Router(); + +authRouter.get('/session', (req, res) => { + const session = currentSession(req); + res.json({ authenticated: Boolean(session), user: session?.user || null }); +}); + +authRouter.post('/login', (req, res) => { + const result = login(req.body?.username, req.body?.password); + if (!result) { + res.status(401).json({ error: 'Invalid username or password.' }); + return; + } + setSessionCookie(res, result.session); + res.json({ user: result.user }); +}); + +authRouter.post('/logout', (req, res) => { + const session = currentSession(req); + logout(session?.sessionId); + clearSessionCookie(res); + res.json({ ok: true }); +}); diff --git a/management/src/routes/nodes.js b/management/src/routes/nodes.js new file mode 100644 index 0000000..3a6f91f --- /dev/null +++ b/management/src/routes/nodes.js @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import { agentRequest, publicNode } from '../agentClient.js'; +import { createNode, deleteNode, getNode, listNodes, recordNodeHealth, updateNode } from '../store.js'; + +export const nodesRouter = Router(); + +nodesRouter.get('/', (_req, res) => { + res.json(listNodes().map(publicNode)); +}); + +nodesRouter.post('/', async (req, res, next) => { + try { + const node = createNode(validateNodeInput(req.body)); + res.status(201).json(publicNode(node)); + } catch (error) { + next(error); + } +}); + +nodesRouter.put('/:nodeId', async (req, res, next) => { + try { + const node = updateNode(req.params.nodeId, validateNodeInput(req.body, { partial: true })); + if (!node) { + res.status(404).json({ error: 'Node not found.' }); + return; + } + res.json(publicNode(node)); + } catch (error) { + next(error); + } +}); + +nodesRouter.delete('/:nodeId', (req, res) => { + deleteNode(req.params.nodeId); + res.json({ ok: true }); +}); + +nodesRouter.get('/:nodeId/health', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + const health = await agentRequest(node, '/health', { timeout: 10000 }); + recordNodeHealth(node.id, health?.ok ? 'ok' : 'degraded'); + res.json(health); + } catch (error) { + if (error.status === 404) { + next(error); + return; + } + recordNodeHealth(req.params.nodeId, 'error'); + next(error); + } +}); + +nodesRouter.post('/:nodeId/test', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + const health = await agentRequest(node, '/health', { timeout: 10000 }); + recordNodeHealth(node.id, health?.ok ? 'ok' : 'degraded'); + res.json({ ok: true, health }); + } catch (error) { + recordNodeHealth(req.params.nodeId, 'error'); + next(error); + } +}); + +function requireNode(id) { + const node = getNode(id); + if (!node) { + const error = new Error('Node not found.'); + error.status = 404; + throw error; + } + return node; +} + +function validateNodeInput(body, options = {}) { + const values = body || {}; + const required = !options.partial; + const name = values.name === undefined ? undefined : String(values.name).trim(); + const baseUrl = values.baseUrl === undefined ? undefined : String(values.baseUrl).trim(); + const token = values.token === undefined ? undefined : String(values.token).trim(); + + if (required && (!name || !baseUrl || !token)) { + const error = new Error('Node name, URL and token are required.'); + error.status = 400; + throw error; + } + if (baseUrl) new URL(baseUrl); + + return { + ...(name !== undefined ? { name } : {}), + ...(baseUrl !== undefined ? { baseUrl } : {}), + ...(token !== undefined ? { token } : {}), + ...(values.enabled !== undefined ? { enabled: Boolean(values.enabled) } : {}), + }; +} diff --git a/management/src/routes/proxy.js b/management/src/routes/proxy.js new file mode 100644 index 0000000..44ba998 --- /dev/null +++ b/management/src/routes/proxy.js @@ -0,0 +1,119 @@ +import { Router } from 'express'; +import { agentRequest } from '../agentClient.js'; +import { getNode, listNodes, recordNodeHealth } from '../store.js'; + +export const proxyRouter = Router(); + +proxyRouter.get('/health', async (_req, res) => { + const nodes = listNodes().filter((node) => node.enabled); + const checks = { database: 'ok' }; + let ok = true; + await Promise.all(nodes.map(async (node) => { + try { + const health = await agentRequest(node, '/health', { timeout: 8000 }); + checks[node.name] = health?.ok ? 'ok' : 'degraded'; + recordNodeHealth(node.id, checks[node.name]); + ok = ok && Boolean(health?.ok); + } catch { + checks[node.name] = 'error'; + recordNodeHealth(node.id, 'error'); + ok = false; + } + })); + res.json({ ok, checks }); +}); + +proxyRouter.get('/vms', async (_req, res) => { + const nodes = listNodes().filter((node) => node.enabled); + const rows = []; + await Promise.all(nodes.map(async (node) => { + try { + const vms = await agentRequest(node, '/vms'); + for (const vm of vms) { + rows.push({ ...vm, id: `${node.id}:${vm.name}`, nodeId: node.id, nodeName: node.name }); + } + recordNodeHealth(node.id, 'ok'); + } catch { + recordNodeHealth(node.id, 'error'); + } + })); + res.json(rows.sort((a, b) => `${a.nodeName}/${a.name}`.localeCompare(`${b.nodeName}/${b.name}`))); +}); + +proxyRouter.get('/jobs', async (_req, res) => { + const nodes = listNodes().filter((node) => node.enabled); + const rows = []; + await Promise.all(nodes.map(async (node) => { + try { + const jobs = await agentRequest(node, '/jobs'); + for (const job of jobs) rows.push({ ...job, nodeId: node.id, nodeName: node.name }); + } catch { + recordNodeHealth(node.id, 'error'); + } + })); + res.json(rows.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))); +}); + +proxyRouter.get('/snapshots/:nodeId/:vmName', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + res.json(await agentRequest(node, `/snapshots/${encodeURIComponent(req.params.vmName)}`)); + } catch (error) { + next(error); + } +}); + +proxyRouter.get('/snapshots/:nodeId/:vmName/:snapshotId/files', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + res.json(await agentRequest(node, `/snapshots/${encodeURIComponent(req.params.vmName)}/${encodeURIComponent(req.params.snapshotId)}/files`)); + } catch (error) { + next(error); + } +}); + +proxyRouter.post('/backup/:nodeId/:vmName', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + res.status(202).json(await agentRequest(node, `/backup/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} })); + } catch (error) { + next(error); + } +}); + +proxyRouter.post('/restore/:nodeId/:vmName', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + res.status(202).json(await agentRequest(node, `/restore/${encodeURIComponent(req.params.vmName)}`, { method: 'POST', body: req.body || {} })); + } catch (error) { + next(error); + } +}); + +proxyRouter.get('/settings/:nodeId', async (req, res, next) => { + try { + const node = requireNode(req.params.nodeId); + res.json(await agentRequest(node, '/settings')); + } catch (error) { + next(error); + } +}); + +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 || {} })); + } catch (error) { + next(error); + } +}); + +function requireNode(id) { + const node = getNode(id); + if (!node) { + const error = new Error('Node not found.'); + error.status = 404; + throw error; + } + return node; +} diff --git a/management/src/routes/schedules.js b/management/src/routes/schedules.js new file mode 100644 index 0000000..8bd0527 --- /dev/null +++ b/management/src/routes/schedules.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { listSchedules, replaceSchedules } from '../store.js'; + +export const schedulesRouter = Router(); + +schedulesRouter.get('/', (_req, res) => { + res.json(listSchedules()); +}); + +schedulesRouter.put('/', (req, res, next) => { + try { + res.json(replaceSchedules(req.body?.schedules || [])); + } catch (error) { + next(error); + } +}); diff --git a/management/src/scheduler.js b/management/src/scheduler.js new file mode 100644 index 0000000..5646b4b --- /dev/null +++ b/management/src/scheduler.js @@ -0,0 +1,37 @@ +import { agentRequest } from './agentClient.js'; +import { listDueSchedules, markScheduleRun, nextRunFrom } from './store.js'; + +let timer = null; + +export function startScheduler() { + if (timer) return; + timer = setInterval(runDueSchedules, 30 * 1000); + timer.unref?.(); + runDueSchedules(); +} + +async function runDueSchedules() { + const now = new Date(); + const schedules = listDueSchedules(now); + for (const schedule of schedules) { + try { + await agentRequest({ + id: schedule.node_id, + baseUrl: schedule.base_url, + token: schedule.token, + }, `/backup/${encodeURIComponent(schedule.vm_name)}`, { method: 'POST' }); + + markScheduleRun(schedule.id, { + lastRunAt: now.toISOString(), + lastError: '', + nextRunAt: nextRunFrom(now, schedule.interval_hours, schedule.time_of_day), + }); + } catch (error) { + markScheduleRun(schedule.id, { + lastRunAt: schedule.last_run_at, + lastError: String(error.message || error), + nextRunAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(), + }); + } + } +} diff --git a/management/src/store.js b/management/src/store.js new file mode 100644 index 0000000..07e76da --- /dev/null +++ b/management/src/store.js @@ -0,0 +1,178 @@ +import { db } from './db.js'; +import { randomToken } from './crypto.js'; + +export function getUserByUsername(username) { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username); +} + +export function getUserBySession(sessionId) { + return db.prepare(` + SELECT users.id, users.username + FROM sessions + JOIN users ON users.id = sessions.user_id + WHERE sessions.id = ? AND sessions.expires_at > ? + `).get(sessionId, new Date().toISOString()); +} + +export function createSession(userId) { + const now = new Date(); + const expires = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const id = `sess_${randomToken()}`; + db.prepare('INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)') + .run(id, userId, expires.toISOString(), now.toISOString()); + return { id, expiresAt: expires }; +} + +export function deleteSession(sessionId) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); +} + +export function listNodes() { + return db.prepare('SELECT * FROM nodes ORDER BY name ASC').all().map(formatNode); +} + +export function getNode(id) { + const row = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id); + return row ? formatNode(row) : null; +} + +export function createNode(values) { + const now = new Date().toISOString(); + const id = `node_${randomToken(8)}`; + db.prepare(` + INSERT INTO nodes (id, name, base_url, token, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, values.name, normalizeBaseUrl(values.baseUrl), values.token, values.enabled ? 1 : 0, now, now); + return getNode(id); +} + +export function updateNode(id, values) { + const current = getNode(id); + if (!current) return null; + const next = { + name: values.name ?? current.name, + baseUrl: values.baseUrl ? normalizeBaseUrl(values.baseUrl) : current.baseUrl, + token: values.token ?? current.token, + enabled: values.enabled ?? current.enabled, + }; + db.prepare('UPDATE nodes SET name = ?, base_url = ?, token = ?, enabled = ?, updated_at = ? WHERE id = ?') + .run(next.name, next.baseUrl, next.token, next.enabled ? 1 : 0, new Date().toISOString(), id); + return getNode(id); +} + +export function deleteNode(id) { + db.prepare('DELETE FROM nodes WHERE id = ?').run(id); +} + +export function recordNodeHealth(id, status) { + db.prepare('UPDATE nodes SET last_health_status = ?, last_health_at = ? WHERE id = ?') + .run(status, new Date().toISOString(), id); +} + +export function listSchedules() { + return db.prepare(` + SELECT schedules.*, nodes.name AS node_name + FROM schedules + JOIN nodes ON nodes.id = schedules.node_id + ORDER BY node_name ASC, vm_name ASC + `).all().map(formatSchedule); +} + +export function replaceSchedules(schedules) { + const now = new Date().toISOString(); + try { + db.exec('BEGIN'); + db.prepare('DELETE FROM schedules').run(); + const insert = db.prepare(` + INSERT INTO schedules + (id, node_id, vm_name, enabled, interval_hours, time_of_day, next_run_at, last_run_at, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of schedules) { + insert.run( + row.id || `sched_${randomToken(8)}`, + row.nodeId, + row.vmName, + row.enabled ? 1 : 0, + Math.max(1, Number(row.intervalHours) || 24), + normalizeTimeOfDay(row.timeOfDay), + row.nextRunAt || nextRunFrom(new Date(), Math.max(1, Number(row.intervalHours) || 24), normalizeTimeOfDay(row.timeOfDay)), + row.lastRunAt || null, + row.lastError || '', + now, + now, + ); + } + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + return listSchedules(); +} + +export function listDueSchedules(date = new Date()) { + return db.prepare(` + SELECT schedules.*, nodes.name AS node_name, nodes.base_url, nodes.token, nodes.enabled AS node_enabled + FROM schedules + JOIN nodes ON nodes.id = schedules.node_id + WHERE schedules.enabled = 1 AND nodes.enabled = 1 AND schedules.next_run_at <= ? + `).all(date.toISOString()); +} + +export function markScheduleRun(id, values) { + db.prepare('UPDATE schedules SET next_run_at = ?, last_run_at = ?, last_error = ?, updated_at = ? WHERE id = ?') + .run(values.nextRunAt, values.lastRunAt || null, values.lastError || '', new Date().toISOString(), id); +} + +export function nextRunFrom(date, intervalHours, timeOfDay) { + const next = withTimeOfDay(date, timeOfDay); + const intervalMs = intervalHours * 60 * 60 * 1000; + while (next <= date) next.setTime(next.getTime() + intervalMs); + return next.toISOString(); +} + +function formatNode(row) { + return { + id: row.id, + name: row.name, + baseUrl: row.base_url, + token: row.token, + enabled: Boolean(row.enabled), + lastHealthStatus: row.last_health_status, + lastHealthAt: row.last_health_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function formatSchedule(row) { + return { + id: row.id, + nodeId: row.node_id, + nodeName: row.node_name, + vmName: row.vm_name, + enabled: Boolean(row.enabled), + intervalHours: row.interval_hours, + timeOfDay: row.time_of_day, + nextRunAt: row.next_run_at, + lastRunAt: row.last_run_at, + lastError: row.last_error || '', + }; +} + +function normalizeBaseUrl(value) { + return String(value || '').replace(/\/+$/, ''); +} + +function normalizeTimeOfDay(value) { + const text = String(value || '02:00'); + return /^\d{2}:\d{2}$/.test(text) ? text : '02:00'; +} + +function withTimeOfDay(date, timeOfDay) { + const [hours, minutes] = normalizeTimeOfDay(timeOfDay).split(':').map(Number); + const next = new Date(date); + next.setHours(hours, minutes, 0, 0); + return next; +}