added https for client and managment
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) : '',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user