feat: add auth and dual-stack tls management
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import selfsigned from 'selfsigned';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
async function startTlsRuntime() {
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-tls-'));
|
||||
const runtime = await startServer({
|
||||
port: 0,
|
||||
httpsPort: 0,
|
||||
appDbPath: path.join(tempDirectory, 'app.sqlite'),
|
||||
uploadDir: path.join(tempDirectory, 'sources'),
|
||||
tlsDir: path.join(tempDirectory, 'tls'),
|
||||
previewMaxBytes: 1024
|
||||
});
|
||||
|
||||
const httpAddress = runtime.httpServer.address();
|
||||
const httpsAddress = runtime.httpsServer.address();
|
||||
const httpBaseUrl = `http://127.0.0.1:${httpAddress.port}`;
|
||||
const httpsBaseUrl = `https://127.0.0.1:${httpsAddress.port}`;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
httpBaseUrl,
|
||||
httpsBaseUrl,
|
||||
async close() {
|
||||
await runtime.close();
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function httpsRequestJson(baseUrl, pathname, { method = 'GET', headers = {}, body = null } = {}) {
|
||||
const target = new URL(pathname, baseUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
target,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const payloadText = Buffer.concat(chunks).toString('utf8');
|
||||
let payload = {};
|
||||
try {
|
||||
payload = payloadText ? JSON.parse(payloadText) : {};
|
||||
} catch (error) {
|
||||
payload = { raw: payloadText };
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
headers: response.headers,
|
||||
payload
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
if (body) {
|
||||
request.write(body);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function setupAuthenticatedClient(baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: 'Password123!'
|
||||
})
|
||||
});
|
||||
const payload = await response.json();
|
||||
const cookie = response.headers.get('set-cookie')?.split(';', 1)?.[0] ?? '';
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(payload.auth.authenticated, true);
|
||||
assert.ok(cookie);
|
||||
|
||||
return {
|
||||
cookie,
|
||||
async json(pathname, options = {}) {
|
||||
const responseValue = await fetch(`${baseUrl}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
Cookie: cookie
|
||||
}
|
||||
});
|
||||
const payloadValue = await responseValue.json();
|
||||
return {
|
||||
response: responseValue,
|
||||
payload: payloadValue
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('dual-stack startup auto-generates a self-signed certificate and serves health checks over HTTP and HTTPS', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json());
|
||||
assert.equal(httpHealth.ok, true);
|
||||
|
||||
const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz');
|
||||
assert.equal(httpsHealth.status, 200);
|
||||
assert.equal(httpsHealth.payload.ok, true);
|
||||
|
||||
const authState = await fetch(`${runtime.httpBaseUrl}/api/auth/state`).then((response) => response.json());
|
||||
assert.equal(authState.auth.setupRequired, true);
|
||||
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const tlsSummary = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(tlsSummary.status, 200);
|
||||
assert.equal(tlsSummary.payload.tls.mode, 'self-signed');
|
||||
assert.equal(tlsSummary.payload.tls.activeSource, 'self-signed');
|
||||
assert.ok(tlsSummary.payload.tls.certificate.fingerprint256);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('saving self-signed TLS settings regenerates certificate metadata and keeps HTTP/HTTPS both available', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const before = await client.json('/api/system/tls');
|
||||
const previousFingerprint = before.payload.tls.certificate.fingerprint256;
|
||||
|
||||
const updated = await client.json('/api/system/tls', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: 'self-signed',
|
||||
primaryDomain: 'preview.example.com',
|
||||
subjectAltNames: ['preview.internal', '192.168.5.10']
|
||||
})
|
||||
});
|
||||
assert.equal(updated.response.status, 200);
|
||||
assert.equal(updated.payload.tls.primaryDomain, 'preview.example.com');
|
||||
assert.equal(updated.payload.tls.activeSource, 'self-signed');
|
||||
assert.notEqual(updated.payload.tls.certificate.fingerprint256, previousFingerprint);
|
||||
|
||||
const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(httpsState.status, 200);
|
||||
assert.equal(httpsState.payload.tls.primaryDomain, 'preview.example.com');
|
||||
assert.deepEqual(httpsState.payload.tls.subjectAltNames, ['preview.internal', '192.168.5.10']);
|
||||
|
||||
const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json());
|
||||
assert.equal(httpHealth.ok, true);
|
||||
const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz');
|
||||
assert.equal(httpsHealth.status, 200);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('custom PEM upload activates custom certificate and delete falls back to self-signed', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const generated = await selfsigned.generate(
|
||||
[{ name: 'commonName', value: 'custom.example.com' }],
|
||||
{
|
||||
algorithm: 'sha256',
|
||||
keySize: 2048,
|
||||
days: 365,
|
||||
extensions: [
|
||||
{
|
||||
name: 'subjectAltName',
|
||||
altNames: [
|
||||
{ type: 2, value: 'custom.example.com' },
|
||||
{ type: 2, value: 'cdn.custom.example.com' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.set('primaryDomain', 'custom.example.com');
|
||||
uploadForm.set('subjectAltNames', 'cdn.custom.example.com');
|
||||
uploadForm.set('certificate', new File([generated.cert], 'fullchain.pem', { type: 'text/plain' }));
|
||||
uploadForm.set('privateKey', new File([generated.private], 'privkey.pem', { type: 'text/plain' }));
|
||||
|
||||
const uploadResponse = await fetch(`${runtime.httpBaseUrl}/api/system/tls/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
},
|
||||
body: uploadForm
|
||||
});
|
||||
const uploadPayload = await uploadResponse.json();
|
||||
assert.equal(uploadResponse.status, 200);
|
||||
assert.equal(uploadPayload.tls.mode, 'custom-pem');
|
||||
assert.equal(uploadPayload.tls.activeSource, 'custom-pem');
|
||||
const customFingerprint = uploadPayload.tls.certificate.fingerprint256;
|
||||
|
||||
const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(httpsState.status, 200);
|
||||
assert.equal(httpsState.payload.tls.activeSource, 'custom-pem');
|
||||
|
||||
const deleteResult = await client.json('/api/system/tls/custom-certificate', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
assert.equal(deleteResult.response.status, 200);
|
||||
assert.equal(deleteResult.payload.tls.mode, 'self-signed');
|
||||
assert.equal(deleteResult.payload.tls.activeSource, 'self-signed');
|
||||
assert.notEqual(deleteResult.payload.tls.certificate.fingerprint256, customFingerprint);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user