From e5bcce5dbb85131123d1fd909aae0a89a8c6cbcf Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 21 May 2026 15:11:05 +0200 Subject: [PATCH] added https for client and managment --- README.md | 10 ++++++ backend/.env.example | 4 +++ backend/src/config.js | 16 +++++++++ backend/src/index.js | 22 ++++++++++-- docs/deployment.md | 23 ++++++++++++- management/.env.example | 1 + management/src/agentClient.js | 63 ++++++++++++++++++++++++++++++----- management/src/config.js | 1 + 8 files changed, 129 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 744577b..981cfcc 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ The node agent must run on every Incus host with permission to access Incus, ZFS Set `API_TOKEN` in `backend/.env`; the management server uses that token when calling the agent. The token is required and must be at least 32 characters long. +The agent can serve HTTPS directly for private networks: + +```env +HTTPS_ENABLED=true +TLS_CERT_FILE="/etc/incus-backup-agent/tls.crt" +TLS_KEY_FILE="/etc/incus-backup-agent/tls.key" +``` + Required commands: - `incus` @@ -46,6 +54,8 @@ The management API stores nodes, users, sessions, and central schedules in SQLit Agent URLs must use `https://` by default. For local development only, set `ALLOW_INSECURE_AGENT_HTTP=true` in `management/.env` to permit `http://` node URLs. +For internal/self-signed agent certificates, set `AGENT_CA_FILE` in `management/.env` to the CA certificate that signed the agent certificates. + 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: diff --git a/backend/.env.example b/backend/.env.example index 02fcda1..ed1d753 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,10 @@ RESTIC_KEEP_HOURLY=0 RESTIC_KEEP_DAILY=7 RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_MONTHLY=0 +HOST="0.0.0.0" PORT=3000 API_TOKEN="change-me-to-at-least-32-characters" +HTTPS_ENABLED=false +TLS_CERT_FILE="" +TLS_KEY_FILE="" ALLOWED_MANAGEMENT_IPS="" diff --git a/backend/src/config.js b/backend/src/config.js index b49f832..1a42d91 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -17,7 +17,11 @@ export const requiredEnv = [ export const config = { port: Number(process.env.PORT || 3000), + host: process.env.HOST || '0.0.0.0', apiToken: process.env.API_TOKEN || '', + httpsEnabled: process.env.HTTPS_ENABLED === 'true', + tlsCertFile: process.env.TLS_CERT_FILE || '', + tlsKeyFile: process.env.TLS_KEY_FILE || '', allowedManagementIps: (process.env.ALLOWED_MANAGEMENT_IPS || '') .split(',') .map((value) => value.trim()) @@ -41,6 +45,10 @@ if (!config.apiToken || config.apiToken.length < minApiTokenLength) { throw new Error(`API_TOKEN is required and must be at least ${minApiTokenLength} characters long.`); } +if (config.httpsEnabled && (!config.tlsCertFile || !config.tlsKeyFile)) { + throw new Error('TLS_CERT_FILE and TLS_KEY_FILE are required when HTTPS_ENABLED=true.'); +} + export const editableEnv = [ { key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true }, { key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS secret access key', required: true, secret: true }, @@ -52,7 +60,11 @@ export const editableEnv = [ { key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, { key: 'PORT', label: 'API port', required: false, secret: false }, + { key: 'HOST', label: 'API bind host', required: false, secret: false }, { key: 'API_TOKEN', label: 'API token', required: true, secret: true }, + { key: 'HTTPS_ENABLED', label: 'Enable HTTPS', required: false, secret: false }, + { key: 'TLS_CERT_FILE', label: 'TLS certificate file', required: false, secret: false }, + { key: 'TLS_KEY_FILE', label: 'TLS private key file', required: false, secret: false }, { key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false }, ]; @@ -122,7 +134,11 @@ function applyRuntimeEnv(values) { process.env[key] = value; } config.port = Number(process.env.PORT || 3000); + config.host = process.env.HOST || '0.0.0.0'; config.apiToken = process.env.API_TOKEN || ''; + config.httpsEnabled = process.env.HTTPS_ENABLED === 'true'; + config.tlsCertFile = process.env.TLS_CERT_FILE || ''; + config.tlsKeyFile = process.env.TLS_KEY_FILE || ''; config.allowedManagementIps = (process.env.ALLOWED_MANAGEMENT_IPS || '') .split(',') .map((value) => value.trim()) diff --git a/backend/src/index.js b/backend/src/index.js index 0b191a8..8c09ad8 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,5 +1,8 @@ import cors from 'cors'; import express from 'express'; +import http from 'node:http'; +import https from 'node:https'; +import { readFile } from 'node:fs/promises'; import { config } from './config.js'; import { backupRouter } from './routes/backup.js'; import { healthRouter } from './routes/health.js'; @@ -47,10 +50,25 @@ app.use((error, _req, res, _next) => { res.status(status).json({ error: error.message || 'Internal server error.' }); }); -app.listen(config.port, () => { - console.log(`Incus backup API listening on http://localhost:${config.port}`); +startServer().catch((error) => { + console.error(`Failed to start Incus backup API: ${error.message}`); + process.exit(1); }); startScheduler().catch((error) => { console.error(`Failed to start scheduler: ${error.message}`); }); + +async function startServer() { + const protocol = config.httpsEnabled ? 'https' : 'http'; + const server = config.httpsEnabled + ? https.createServer({ + cert: await readFile(config.tlsCertFile), + key: await readFile(config.tlsKeyFile), + }, app) + : http.createServer(app); + + server.listen(config.port, config.host, () => { + console.log(`Incus backup API listening on ${protocol}://${config.host}:${config.port}`); + }); +} diff --git a/docs/deployment.md b/docs/deployment.md index 1db2836..1deea27 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,13 +14,33 @@ sudo npm start Important `.env` values: ```env +HOST="0.0.0.0" PORT=3000 API_TOKEN="long-random-token-at-least-32-characters" +HTTPS_ENABLED=true +TLS_CERT_FILE="/etc/incus-backup-agent/tls.crt" +TLS_KEY_FILE="/etc/incus-backup-agent/tls.key" ALLOWED_MANAGEMENT_IPS="management-server-ip" ``` `API_TOKEN` is required and must be at least 32 characters long. If `ALLOWED_MANAGEMENT_IPS` is set, the agent only accepts requests from those comma-separated IP addresses. +For private networks such as NetBird, the agent can serve HTTPS directly with an internal CA. Create one CA and sign one certificate per agent. The certificate must contain the NetBird IP or internal DNS name as a SAN: + +```bash +sudo install -d -m 700 /etc/incus-backup-agent +openssl genrsa -out agent-ca.key 4096 +openssl req -x509 -new -nodes -key agent-ca.key -sha256 -days 3650 -out agent-ca.crt -subj "/CN=Incus Backup Agent CA" +openssl genrsa -out tls.key 4096 +openssl req -new -key tls.key -out tls.csr -subj "/CN=incus-node-1" +printf "subjectAltName=IP:100.127.0.10,DNS:incus-node-1.netbird\n" > tls.ext +openssl x509 -req -in tls.csr -CA agent-ca.crt -CAkey agent-ca.key -CAcreateserial -out tls.crt -days 825 -sha256 -extfile tls.ext +sudo install -m 600 tls.key /etc/incus-backup-agent/tls.key +sudo install -m 644 tls.crt /etc/incus-backup-agent/tls.crt +``` + +Copy `agent-ca.crt` to the management server and set `AGENT_CA_FILE` there. + Install systemd service: ```bash @@ -52,9 +72,10 @@ DATABASE_PATH="./management.sqlite" CORS_ORIGINS="https://backup.example.com" SESSION_COOKIE_SECURE=true ALLOW_INSECURE_AGENT_HTTP=false +AGENT_CA_FILE="/etc/incus-backup-management/agent-ca.crt" ``` -`AUTH_PASSWORD` is required for the first start when the user database is empty. `CORS_ORIGINS` must list the frontend origins that are allowed to use cookie-authenticated API calls. Agent URLs must use `https://`; only set `ALLOW_INSECURE_AGENT_HTTP=true` for local development. +`AUTH_PASSWORD` is required for the first start when the user database is empty. `CORS_ORIGINS` must list the frontend origins that are allowed to use cookie-authenticated API calls. Agent URLs must use `https://`; only set `ALLOW_INSECURE_AGENT_HTTP=true` for local development. `AGENT_CA_FILE` should point to the CA certificate that signed the internal agent certificates. Reset an existing password: diff --git a/management/.env.example b/management/.env.example index e0c4fbb..a1d9864 100644 --- a/management/.env.example +++ b/management/.env.example @@ -6,3 +6,4 @@ DATABASE_PATH="./management.sqlite" CORS_ORIGINS="http://localhost:5173" SESSION_COOKIE_SECURE=false ALLOW_INSECURE_AGENT_HTTP=true +AGENT_CA_FILE="" diff --git a/management/src/agentClient.js b/management/src/agentClient.js index 946b0cf..0aa5fd4 100644 --- a/management/src/agentClient.js +++ b/management/src/agentClient.js @@ -1,23 +1,70 @@ +import http from 'node:http'; +import https from 'node:https'; +import { readFileSync } from 'node:fs'; +import { config } from './config.js'; + +const agentCa = config.agentCaFile ? readFileSync(config.agentCaFile, 'utf8') : undefined; + export async function agentRequest(node, path, options = {}) { - const response = await fetch(`${node.baseUrl}/api${path}`, { + const url = new URL(`${node.baseUrl}/api${path}`); + const data = options.body ? JSON.stringify(options.body) : ''; + const response = await requestJson(url, { method: options.method || 'GET', headers: { Authorization: `Bearer ${node.token}`, 'Content-Type': 'application/json', + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), ...(options.headers || {}), }, - body: options.body ? JSON.stringify(options.body) : undefined, - signal: AbortSignal.timeout(options.timeout || 20000), + body: data, + 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}`); + if (response.status < 200 || response.status >= 300) { + const error = new Error(response.data?.error || `Agent request failed with ${response.status}`); error.status = response.status; throw error; } - return data; + return response.data; +} + +function requestJson(url, options) { + return new Promise((resolve, reject) => { + const transport = url.protocol === 'https:' ? https : http; + const request = transport.request({ + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method: options.method, + headers: options.headers, + timeout: options.timeout, + ...(url.protocol === 'https:' && agentCa ? { ca: agentCa } : {}), + }, (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + text += chunk; + }); + response.on('end', () => { + try { + resolve({ + status: response.statusCode || 0, + data: text ? JSON.parse(text) : null, + }); + } catch (error) { + reject(error); + } + }); + }); + + request.on('timeout', () => { + request.destroy(new Error('Agent request timed out.')); + }); + request.on('error', reject); + if (options.body) request.write(options.body); + request.end(); + }); } export function publicNode(node) { diff --git a/management/src/config.js b/management/src/config.js index 42424d8..8af124d 100644 --- a/management/src/config.js +++ b/management/src/config.js @@ -17,4 +17,5 @@ export const config = { ? process.env.SESSION_COOKIE_SECURE === 'true' : process.env.NODE_ENV === 'production', allowInsecureAgentHttp: process.env.ALLOW_INSECURE_AGENT_HTTP === 'true', + agentCaFile: process.env.AGENT_CA_FILE ? path.resolve(process.cwd(), process.env.AGENT_CA_FILE) : '', };