added https for client and managment

This commit is contained in:
Philipp
2026-05-21 15:11:05 +02:00
parent 0046156e58
commit e5bcce5dbb
8 changed files with 129 additions and 11 deletions
+10
View File
@@ -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. 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: Required commands:
- `incus` - `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. 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. 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: Reset an existing admin password without deleting the database:
+4
View File
@@ -7,6 +7,10 @@ RESTIC_KEEP_HOURLY=0
RESTIC_KEEP_DAILY=7 RESTIC_KEEP_DAILY=7
RESTIC_KEEP_WEEKLY=0 RESTIC_KEEP_WEEKLY=0
RESTIC_KEEP_MONTHLY=0 RESTIC_KEEP_MONTHLY=0
HOST="0.0.0.0"
PORT=3000 PORT=3000
API_TOKEN="change-me-to-at-least-32-characters" API_TOKEN="change-me-to-at-least-32-characters"
HTTPS_ENABLED=false
TLS_CERT_FILE=""
TLS_KEY_FILE=""
ALLOWED_MANAGEMENT_IPS="" ALLOWED_MANAGEMENT_IPS=""
+16
View File
@@ -17,7 +17,11 @@ export const requiredEnv = [
export const config = { export const config = {
port: Number(process.env.PORT || 3000), port: Number(process.env.PORT || 3000),
host: process.env.HOST || '0.0.0.0',
apiToken: process.env.API_TOKEN || '', 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 || '') allowedManagementIps: (process.env.ALLOWED_MANAGEMENT_IPS || '')
.split(',') .split(',')
.map((value) => value.trim()) .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.`); 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 = [ export const editableEnv = [
{ key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true }, { 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 }, { 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_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false },
{ key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false },
{ key: 'PORT', label: 'API port', required: false, secret: false }, { key: 'PORT', label: 'API port', required: false, secret: false },
{ key: 'HOST', label: 'API bind host', required: false, secret: false },
{ key: 'API_TOKEN', label: 'API token', required: true, secret: true }, { 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 }, { key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false },
]; ];
@@ -122,7 +134,11 @@ function applyRuntimeEnv(values) {
process.env[key] = value; process.env[key] = value;
} }
config.port = Number(process.env.PORT || 3000); config.port = Number(process.env.PORT || 3000);
config.host = process.env.HOST || '0.0.0.0';
config.apiToken = process.env.API_TOKEN || ''; 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 || '') config.allowedManagementIps = (process.env.ALLOWED_MANAGEMENT_IPS || '')
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
+20 -2
View File
@@ -1,5 +1,8 @@
import cors from 'cors'; import cors from 'cors';
import express from 'express'; 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 { config } from './config.js';
import { backupRouter } from './routes/backup.js'; import { backupRouter } from './routes/backup.js';
import { healthRouter } from './routes/health.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.' }); res.status(status).json({ error: error.message || 'Internal server error.' });
}); });
app.listen(config.port, () => { startServer().catch((error) => {
console.log(`Incus backup API listening on http://localhost:${config.port}`); console.error(`Failed to start Incus backup API: ${error.message}`);
process.exit(1);
}); });
startScheduler().catch((error) => { startScheduler().catch((error) => {
console.error(`Failed to start scheduler: ${error.message}`); 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}`);
});
}
+22 -1
View File
@@ -14,13 +14,33 @@ sudo npm start
Important `.env` values: Important `.env` values:
```env ```env
HOST="0.0.0.0"
PORT=3000 PORT=3000
API_TOKEN="long-random-token-at-least-32-characters" 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" 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. `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: Install systemd service:
```bash ```bash
@@ -52,9 +72,10 @@ DATABASE_PATH="./management.sqlite"
CORS_ORIGINS="https://backup.example.com" CORS_ORIGINS="https://backup.example.com"
SESSION_COOKIE_SECURE=true SESSION_COOKIE_SECURE=true
ALLOW_INSECURE_AGENT_HTTP=false 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: Reset an existing password:
+1
View File
@@ -6,3 +6,4 @@ DATABASE_PATH="./management.sqlite"
CORS_ORIGINS="http://localhost:5173" CORS_ORIGINS="http://localhost:5173"
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
ALLOW_INSECURE_AGENT_HTTP=true ALLOW_INSECURE_AGENT_HTTP=true
AGENT_CA_FILE=""
+55 -8
View File
@@ -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 = {}) { 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', method: options.method || 'GET',
headers: { headers: {
Authorization: `Bearer ${node.token}`, Authorization: `Bearer ${node.token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
...(options.headers || {}), ...(options.headers || {}),
}, },
body: options.body ? JSON.stringify(options.body) : undefined, body: data,
signal: AbortSignal.timeout(options.timeout || 20000), timeout: options.timeout || 20000,
}); });
const text = await response.text(); if (response.status < 200 || response.status >= 300) {
const data = text ? JSON.parse(text) : null; const error = new Error(response.data?.error || `Agent request failed with ${response.status}`);
if (!response.ok) {
const error = new Error(data?.error || `Agent request failed with ${response.status}`);
error.status = response.status; error.status = response.status;
throw error; 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) { export function publicNode(node) {
+1
View File
@@ -17,4 +17,5 @@ export const config = {
? process.env.SESSION_COOKIE_SECURE === 'true' ? process.env.SESSION_COOKIE_SECURE === 'true'
: process.env.NODE_ENV === 'production', : process.env.NODE_ENV === 'production',
allowInsecureAgentHttp: process.env.ALLOW_INSECURE_AGENT_HTTP === 'true', allowInsecureAgentHttp: process.env.ALLOW_INSECURE_AGENT_HTTP === 'true',
agentCaFile: process.env.AGENT_CA_FILE ? path.resolve(process.cwd(), process.env.AGENT_CA_FILE) : '',
}; };