feat: move acme certificate automation into a plugin (C7)

This commit is contained in:
LukeGus committed 2026-09-24 21:19:27 -05:00
1 parent 4dbce08379
commit 8c1e02af89
123 files changed
+4808 -1912

No files matched your search

@@ -0,0 +1,155 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type RequestHandler } from "express";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const state = vi.hoisted(() => ({
admins: new Set<string>(["admin-1"]),
writes: [] as Array<[unknown, unknown]>,
reloads: 0,
audits: [] as Array<{ action: string; success: boolean }>,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: { action: string; success: boolean }) => {
state.audits.push({ action: entry.action, success: entry.success });
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
}));
vi.mock("../../../tls/tls-service.js", async () => {
const { TlsValidationError } = await import("../../../tls/certificate.js");
return {
getTlsStatus: async () => ({
enabled: true,
certificate: null,
renewal: null,
}),
writeTlsCertificate: async (cert: unknown, key: unknown) => {
if (key === "mismatch") {
throw new TlsValidationError(
"The certificate and private key do not match",
);
}
state.writes.push([cert, key]);
return { subject: "CN=x", notAfter: "2030-01-01T00:00:00.000Z" };
},
reloadTls: async () => {
state.reloads++;
return { applied: true, message: "reloaded" };
},
};
});
const { registerTlsRoutes } =
await import("../../../database/routes/tls-routes.js");
const authenticate: RequestHandler = (req, _res, next) => {
(req as unknown as { userId?: string }).userId =
req.header("x-user") ?? undefined;
next();
};
let server: http.Server;
let base: string;
beforeAll(async () => {
const router = express.Router();
registerTlsRoutes(router, authenticate);
const app = express();
app.use(express.json());
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
state.writes = [];
state.reloads = 0;
state.audits = [];
});
const upload = (user: string, body: unknown) =>
fetch(`${base}/users/tls-certificate`, {
method: "POST",
headers: { "content-type": "application/json", "x-user": user },
body: JSON.stringify(body),
});
describe("TLS certificate routes", () => {
it("returns the status to an admin only", async () => {
const ok = await fetch(`${base}/users/tls-certificate`, {
headers: { "x-user": "admin-1" },
});
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ enabled: true, renewal: null });
const denied = await fetch(`${base}/users/tls-certificate`, {
headers: { "x-user": "user-1" },
});
expect(denied.status).toBe(403);
});
it("installs an uploaded pair and reloads", async () => {
const response = await upload("admin-1", {
certificate: "CERT",
privateKey: "KEY",
});
expect(response.status).toBe(200);
expect((await response.json()).reloadMessage).toBe("reloaded");
expect(state.writes).toEqual([["CERT", "KEY"]]);
expect(state.reloads).toBe(1);
expect(state.audits).toEqual([
{ action: "tls_certificate_upload", success: true },
]);
});
it("answers 400 for a mismatched pair without reloading", async () => {
const response = await upload("admin-1", {
certificate: "CERT",
privateKey: "mismatch",
});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/do not match/);
expect(state.reloads).toBe(0);
expect(state.audits).toEqual([
{ action: "tls_certificate_upload", success: false },
]);
});
it("refuses a non-admin upload", async () => {
const response = await upload("user-1", {
certificate: "CERT",
privateKey: "KEY",
});
expect(response.status).toBe(403);
expect(state.writes).toEqual([]);
});
});
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
writes: [] as Array<[string, string]>,
reloads: 0,
renewers: [] as Array<{ pluginId: string; pluginName: string }>,
challenges: new Map<string, string>(),
}));
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../tls/tls-service.js", () => ({
getTlsStatus: async () => ({
enabled: true,
certificate: null,
renewal: h.renewers[0] ?? null,
}),
writeTlsCertificate: async (cert: string, key: string) => {
if (key === "wrong")
throw new Error("The certificate and private key do not match");
h.writes.push([cert, key]);
return { subject: "CN=x" };
},
reloadTls: async () => {
h.reloads++;
return { applied: true, message: "ok" };
},
registerTlsRenewer: (renewer: { pluginId: string; pluginName: string }) => {
h.renewers.push(renewer);
return () => {
h.renewers = h.renewers.filter((entry) => entry !== renewer);
};
},
}));
vi.mock("../../tls/acme-challenges.js", () => ({
publishChallenge: (token: string, content: string) => {
h.challenges.set(token, content);
return () => h.challenges.delete(token);
},
}));
const { createPluginSystem } = await import("../../plugins/ctx-system.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
let audits: Array<{ action: string; success: boolean }>;
function create(capabilities = ["system:tls"]) {
const bag = new DisposableBag("acme-ssl");
const system = createPluginSystem({
manifest: {
id: "acme-ssl",
name: "ACME Certificates",
version: "1.0.0",
capabilities,
} as never,
bag,
audit: async (action, _details, outcome) => {
audits.push({ action, success: outcome.success });
},
});
return { system, bag };
}
beforeEach(() => {
h.granted = new Set(["system:tls"]);
h.writes = [];
h.reloads = 0;
h.renewers = [];
h.challenges = new Map();
audits = [];
});
describe("ctx.system", () => {
it("writes, reloads and audits every call", async () => {
const { system } = create();
await system.writeTlsCertificate("CERT", "KEY");
await system.reloadTls();
expect(h.writes).toEqual([["CERT", "KEY"]]);
expect(h.reloads).toBe(1);
expect(audits).toEqual([
{ action: "tls_write", success: true },
{ action: "tls_reload", success: true },
]);
});
it("passes a rejected pair back to the plugin and audits the failure", async () => {
const { system } = create();
await expect(system.writeTlsCertificate("CERT", "wrong")).rejects.toThrow(
/do not match/,
);
expect(audits).toEqual([{ action: "tls_write", success: false }]);
});
it("refuses without system:tls, declared or granted", async () => {
await expect(create([]).system.reloadTls()).rejects.toBeInstanceOf(
PluginCapabilityError,
);
h.granted.clear();
await expect(create().system.tlsStatus()).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(h.reloads).toBe(0);
expect(audits.every((entry) => !entry.success)).toBe(true);
});
it("drops the renewer and challenges when the plugin is disabled", async () => {
const { system, bag } = create();
await system.registerTlsRenewer();
await system.publishHttpChallenge("tok", "tok.key");
expect((await system.tlsStatus()).renewal?.pluginId).toBe("acme-ssl");
expect(h.challenges.get("tok")).toBe("tok.key");
await bag.disposeAll();
expect(h.renewers).toEqual([]);
expect(h.challenges.size).toBe(0);
});
it("lets the plugin withdraw a challenge itself, once", async () => {
const { system, bag } = create();
const remove = await system.publishHttpChallenge("tok", "tok.key");
remove();
remove();
expect(h.challenges.size).toBe(0);
expect(bag.size).toBe(0);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
TlsValidationError,
readCertificateInfo,
validateCertificatePair,
} from "../../tls/certificate.js";
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
describe("validateCertificatePair", () => {
it("accepts a matching pair and describes the leaf", async () => {
const pair = await caIssuedPair("termix.example.com");
const info = validateCertificatePair(pair.certificate, pair.privateKey);
expect(info.names).toEqual(["termix.example.com"]);
expect(info.selfSigned).toBe(false);
expect(info.issuer).toContain("Test CA");
expect(new Date(info.notAfter).getTime()).toBeGreaterThan(Date.now());
});
it("rejects a key that belongs to another certificate", async () => {
const one = await caIssuedPair();
const other = await caIssuedPair();
expect(() =>
validateCertificatePair(one.certificate, other.privateKey),
).toThrow(/do not match/);
});
it("rejects garbage and an expired certificate", async () => {
const pair = await caIssuedPair();
expect(() => validateCertificatePair("nope", pair.privateKey)).toThrow(
TlsValidationError,
);
expect(() =>
validateCertificatePair(pair.certificate, "not a key"),
).toThrow(/private key/);
const expired = await caIssuedPair("old.example", { daysLeft: -1 });
expect(() =>
validateCertificatePair(expired.certificate, expired.privateKey),
).toThrow(/expired/);
});
it("reads the leaf of a full chain", async () => {
const leaf = await caIssuedPair("chain.example");
const ca = await selfSignedPair("ca.example");
const info = validateCertificatePair(
leaf.certificate + ca.certificate,
leaf.privateKey,
);
expect(info.names).toEqual(["chain.example"]);
});
});
describe("readCertificateInfo", () => {
it("marks core's own certificate as self-signed", async () => {
const pair = await selfSignedPair("localhost");
expect(readCertificateInfo(pair.certificate)?.selfSigned).toBe(true);
expect(readCertificateInfo("")).toBeNull();
});
});
+82
View File
@@ -0,0 +1,82 @@
/** Throwaway certificates for the TLS tests, made in-process. */
import "reflect-metadata";
import { webcrypto } from "node:crypto";
import * as x509 from "@peculiar/x509";
x509.cryptoProvider.set(webcrypto as unknown as Crypto);
const ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
const DAY = 86_400_000;
export interface TestPair {
certificate: string;
privateKey: string;
}
async function keys(): Promise<webcrypto.CryptoKeyPair> {
return (await webcrypto.subtle.generateKey(ALG, true, [
"sign",
"verify",
])) as webcrypto.CryptoKeyPair;
}
async function keyPem(key: webcrypto.CryptoKey): Promise<string> {
const der = Buffer.from(await webcrypto.subtle.exportKey("pkcs8", key));
const body = der
.toString("base64")
.match(/.{1,64}/g)!
.join("\n");
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
}
function sans(names: string[]) {
return new x509.SubjectAlternativeNameExtension(
names.map((value) => ({ type: "dns" as const, value })),
);
}
export async function selfSignedPair(
name = "localhost",
options: { daysLeft?: number } = {},
): Promise<TestPair> {
const pair = await keys();
const cert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: "01",
name: `CN=${name}`,
notBefore: new Date(Date.now() - DAY),
notAfter: new Date(Date.now() + (options.daysLeft ?? 365) * DAY),
keys: pair,
signingAlgorithm: ALG,
extensions: [sans([name])],
});
return {
certificate: cert.toString("pem"),
privateKey: await keyPem(pair.privateKey),
};
}
/** A leaf signed by a throwaway CA, like one from Let's Encrypt. */
export async function caIssuedPair(
name = "termix.example.com",
options: { daysLeft?: number } = {},
): Promise<TestPair> {
const caKeys = await keys();
const leafKeys = await keys();
const cert = await x509.X509CertificateGenerator.create({
serialNumber: "02",
subject: `CN=${name}`,
issuer: "CN=Test CA",
notBefore: new Date(Date.now() - 2 * DAY),
notAfter: new Date(Date.now() + (options.daysLeft ?? 90) * DAY),
signingKey: caKeys.privateKey,
publicKey: leafKeys.publicKey,
signingAlgorithm: ALG,
extensions: [sans([name])],
});
return {
certificate: cert.toString("pem"),
privateKey: await keyPem(leafKeys.privateKey),
};
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Core keeps serving HTTPS on its own: a certificate on disk is served with
* no plugin renewing it, a validated replacement is swapped in without a
* restart, and nginx is reloaded instead when it terminates TLS.
*/
import fs from "node:fs/promises";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import tls from "node:tls";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
const nginx = vi.hoisted(() => ({
reload: vi.fn(() => ({ applied: true, message: "nginx reloaded" })),
}));
vi.mock("../../utils/logger.js", () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { systemLogger: logger, authLogger: logger };
});
vi.mock("../../tls/nginx-reload.js", () => ({
reloadNginxWithSSL: nginx.reload,
persistSSLEnv: vi.fn(),
}));
const service = await import("../../tls/tls-service.js");
const challenges = await import("../../tls/acme-challenges.js");
const { X509Certificate } = await import("node:crypto");
let dir: string;
let servers: https.Server[] = [];
const savedEnv = { ...process.env };
async function install(pair: { certificate: string; privateKey: string }) {
await fs.mkdir(path.join(dir, "ssl"), { recursive: true });
await fs.writeFile(path.join(dir, "ssl", "termix.crt"), pair.certificate);
await fs.writeFile(path.join(dir, "ssl", "termix.key"), pair.privateKey);
}
function fingerprint(pem: string): string {
return new X509Certificate(pem).fingerprint256;
}
async function startServer(): Promise<number> {
let resolvePort: (port: number) => void = () => {};
const port = new Promise<number>((resolve) => (resolvePort = resolve));
await service.configureDirectHttps((options) => {
const server = https.createServer(options, (_req, res) => res.end("ok"));
servers.push(server);
server.listen(0, "127.0.0.1", () =>
resolvePort((server.address() as AddressInfo).port),
);
return server;
});
return port;
}
function servedFingerprint(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const socket = tls.connect(
{ host: "127.0.0.1", port, rejectUnauthorized: false },
() => {
const cert = socket.getPeerCertificate();
socket.end();
resolve(cert.fingerprint256);
},
);
socket.on("error", reject);
});
}
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "tls-service-"));
process.env.DATA_DIR = dir;
process.env.ENABLE_SSL = "true";
delete process.env.SSL_CERT_PATH;
delete process.env.SSL_KEY_PATH;
delete process.env.TERMIX_SSL_TERMINATED_BY_NGINX;
service.resetTlsServiceForTests();
challenges.clearChallengesForTests();
nginx.reload.mockClear();
});
afterEach(async () => {
await Promise.all(
servers.map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
),
);
servers = [];
process.env = { ...savedEnv };
await fs.rm(dir, { recursive: true, force: true });
});
describe("serving with no renewer", () => {
it("serves the certificate on disk when no plugin renews it", async () => {
const pair = await caIssuedPair("termix.example.com");
await install(pair);
const port = await startServer();
expect(await servedFingerprint(port)).toBe(fingerprint(pair.certificate));
const status = await service.getTlsStatus();
expect(status.renewal).toBeNull();
expect(status.certificate?.names).toEqual(["termix.example.com"]);
expect(status.certificate?.selfSigned).toBe(false);
});
it("reports the plugin that renews it until it goes away", async () => {
const undo = service.registerTlsRenewer({
pluginId: "acme-ssl",
pluginName: "ACME Certificates",
});
expect((await service.getTlsStatus()).renewal?.pluginId).toBe("acme-ssl");
undo();
undo();
expect((await service.getTlsStatus()).renewal).toBeNull();
});
});
describe("writeTlsCertificate and reloadTls", () => {
it("swaps a new certificate in without a restart", async () => {
await install(await selfSignedPair("localhost"));
const port = await startServer();
const next = await caIssuedPair("termix.example.com");
await service.writeTlsCertificate(next.certificate, next.privateKey);
const result = await service.reloadTls();
expect(result.applied).toBe(true);
expect(await servedFingerprint(port)).toBe(fingerprint(next.certificate));
expect(nginx.reload).not.toHaveBeenCalled();
});
it("refuses a mismatched key and leaves the files alone", async () => {
const current = await caIssuedPair("keep.example");
await install(current);
const one = await caIssuedPair("new.example");
const other = await caIssuedPair("new.example");
await expect(
service.writeTlsCertificate(one.certificate, other.privateKey),
).rejects.toThrow(/do not match/);
expect(await fs.readFile(path.join(dir, "ssl", "termix.crt"), "utf8")).toBe(
current.certificate,
);
});
it("reloads nginx when nginx terminates TLS", async () => {
process.env.TERMIX_SSL_TERMINATED_BY_NGINX = "true";
const pair = await caIssuedPair();
await service.writeTlsCertificate(pair.certificate, pair.privateKey);
const result = await service.reloadTls();
expect(nginx.reload).toHaveBeenCalledOnce();
expect(result.message).toBe("nginx reloaded");
});
});
describe("ACME http-01 challenges", () => {
it("answers a published token until it is withdrawn", () => {
const undo = challenges.publishChallenge("abc_DEF-1", "abc_DEF-1.thumb");
expect(challenges.lookupChallenge("abc_DEF-1")).toBe("abc_DEF-1.thumb");
const res = {
statusCode: 0,
body: "",
status(code: number) {
this.statusCode = code;
return this;
},
type() {
return this;
},
send(body: string) {
this.body = body;
return this;
},
};
challenges.acmeChallengeHandler(
{ params: { token: "abc_DEF-1" } } as never,
res as never,
);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("abc_DEF-1.thumb");
undo();
expect(challenges.lookupChallenge("abc_DEF-1")).toBeNull();
expect(() => challenges.publishChallenge("../etc", "x")).toThrow();
});
});
@@ -0,0 +1,144 @@
/**
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
* and leaves the old row, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{
key: string;
value: string | null;
encrypted: boolean;
}>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
set: async (key: string, value: string) => {
state.legacy[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value, encrypted });
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
acme_ssl_settings: JSON.stringify({
enabled: true,
domain: "termix.example.com",
email: "admin@example.com",
challengeType: "dns-cloudflare",
cloudflareToken: "cf-token",
lastIssuedAt: "2026-01-01T00:00:00.000Z",
}),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runAcmeSslSettingsMigration", () => {
it("moves every field and encrypts the token", async () => {
const result = await runAcmeSslSettingsMigration();
expect(result.moved.sort()).toEqual(
[
"autoRenew",
"challengeType",
"cloudflareToken",
"domain",
"email",
].sort(),
);
expect(value("domain")).toBe("termix.example.com");
expect(value("email")).toBe("admin@example.com");
expect(value("challengeType")).toBe("dns-cloudflare");
expect(value("autoRenew")).toBe(true);
expect(value("cloudflareToken")).toBe("enc:cf-token");
expect(
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
).toBe(true);
const left = JSON.parse(state.legacy.acme_ssl_settings);
expect(left.cloudflareToken).toBeUndefined();
expect(left.domain).toBe("termix.example.com");
});
it("maps the webroot challenge and turns renewal off for manual", async () => {
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
domain: "a.example",
email: "a@example.com",
challengeType: "http-webroot",
});
await runAcmeSslSettingsMigration();
expect(value("challengeType")).toBe("http-01");
expect(value("autoRenew")).toBe(true);
state.pluginRows = [];
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
challengeType: "manual",
});
await runAcmeSslSettingsMigration();
expect(value("autoRenew")).toBe(false);
expect(value("challengeType")).toBeUndefined();
});
it("changes nothing on a second run or over values already set", async () => {
await runAcmeSslSettingsMigration();
const snapshot = JSON.stringify(state.pluginRows);
const second = await runAcmeSslSettingsMigration();
expect(second.moved).toEqual([]);
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
});
it("does nothing before the plugin row exists or without a legacy row", async () => {
state.pluginInstalled = false;
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
state.pluginInstalled = true;
state.legacy = {};
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
expect(state.pluginRows).toEqual([]);
});
});