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

+52 -49
View File
@@ -41,7 +41,8 @@ import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
@@ -302,6 +303,28 @@ app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
/**
* @openapi
* /.well-known/acme-challenge/{token}:
* get:
* summary: Answer an ACME http-01 challenge
* description: Public. Serves the key authorization a plugin published through ctx.system.publishHttpChallenge while it proves control of the domain.
* tags:
* - General
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The key authorization, as text/plain.
* 404:
* description: No challenge is published for this token.
*/
app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
/**
* @openapi
* /version:
@@ -2089,7 +2112,7 @@ export const serverReady = new Promise<void>((resolve) => {
});
});
const sslConfig = AutoSSLSetup.getSSLConfig();
const sslConfig = getTlsConfig();
if (sslConfig.enabled) {
databaseLogger.info(`SSL is enabled`, {
operation: "ssl_info",
@@ -2098,59 +2121,39 @@ if (sslConfig.enabled) {
});
}
if (
sslConfig.enabled &&
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
) {
try {
const httpsServer = https.createServer(
{
cert: fs.readFileSync(sslConfig.certPath),
key: fs.readFileSync(sslConfig.keyPath),
},
app,
);
// Built through the TLS service so a new certificate can be swapped in, or
// HTTPS started, without a restart.
void configureDirectHttps((options) => {
const port = getTlsConfig().port;
const httpsServer = https.createServer(options, app);
attachPluginWebSockets(httpsServer);
attachPluginWebSockets(httpsServer);
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`SSL port ${sslConfig.port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "https_server_port_conflict",
port: sslConfig.port,
},
);
return;
}
databaseLogger.error("HTTPS server error", err, {
operation: "https_server_error",
});
});
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
databaseLogger.success(
`Backend is now also listening for HTTPS directly`,
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`SSL port ${port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "https_server_started",
port: sslConfig.port,
operation: "https_server_port_conflict",
port,
},
);
return;
}
databaseLogger.error("HTTPS server error", err, {
operation: "https_server_error",
});
} catch (error) {
databaseLogger.error(
"Failed to start HTTPS server with configured SSL certificate",
error,
{
operation: "https_server_start_failed",
cert_path: sslConfig.certPath,
key_path: sslConfig.keyPath,
},
);
}
}
});
httpsServer.listen(port, "127.0.0.1", () => {
databaseLogger.success(`Backend is now also listening for HTTPS directly`, {
operation: "https_server_started",
port,
});
});
return httpsServer;
});
/**
* Status check columns from an export row. Exports from before 2.9.0 only
@@ -1,586 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
DATA_DIR,
"ssl",
"cloudflare.ini",
);
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
function getCertInfo(): {
status: "none" | "valid" | "expiring" | "expired";
expiresAt: string | null;
} {
const certFile = path.join(SSL_DIR, "termix.crt");
try {
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
stdio: "pipe",
});
} catch {
return { status: "none", expiresAt: null };
}
try {
const endDateRaw = execFileSync(
"openssl",
["x509", "-in", certFile, "-noout", "-enddate"],
{ stdio: "pipe" },
)
.toString()
.trim()
.replace("notAfter=", "");
const expiresAt = new Date(endDateRaw).toISOString();
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "0", "-noout"],
{
stdio: "pipe",
},
);
} catch {
return { status: "expired", expiresAt };
}
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
{
stdio: "pipe",
},
);
return { status: "valid", expiresAt };
} catch {
return { status: "expiring", expiresAt };
}
} catch {
return { status: "none", expiresAt: null };
}
}
async function getAcmeSettings(): Promise<AcmeSettings> {
const { status, expiresAt } = getCertInfo();
const value =
await createCurrentSettingsRepository().get("acme_ssl_settings");
const stored = value ? JSON.parse(value) : {};
return {
enabled: stored.enabled ?? false,
domain: stored.domain ?? "",
email: stored.email ?? "",
challengeType: stored.challengeType ?? "http-webroot",
cloudflareToken: stored.cloudflareToken
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
: "",
lastIssuedAt: stored.lastIssuedAt ?? null,
certStatus: status,
certExpiresAt: expiresAt,
};
}
export function registerAcmeSSLRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/acme-ssl-settings:
* get:
* summary: Get ACME SSL settings
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
* tags:
* - Users
* responses:
* 200:
* description: ACME SSL settings and certificate status.
* 500:
* description: Failed to get ACME SSL settings.
*/
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to get ACME SSL settings", err);
res.status(500).json({ error: "Failed to get ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-settings:
* patch:
* summary: Update ACME SSL settings (admin only)
* description: Saves ACME/Let's Encrypt configuration.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* domain:
* type: string
* email:
* type: string
* challengeType:
* type: string
* enum: [http-webroot, dns-cloudflare, manual]
* cloudflareToken:
* type: string
* responses:
* 200:
* description: ACME SSL settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update ACME SSL settings.
*/
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const { enabled, domain, email, challengeType, cloudflareToken } =
req.body;
const updated = {
...current,
...(typeof enabled === "boolean" && { enabled }),
...(typeof domain === "string" && { domain }),
...(typeof email === "string" && { email }),
...(typeof challengeType === "string" && { challengeType }),
...(typeof cloudflareToken === "string" &&
cloudflareToken &&
!cloudflareToken.includes("*") && { cloudflareToken }),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_acme_ssl_settings",
resourceType: "setting",
details: JSON.stringify({
enabled,
domain,
email,
challengeType,
hasCloudflareToken: !!updated.cloudflareToken,
}),
ipAddress,
userAgent,
success: true,
});
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to update ACME SSL settings", err);
res.status(500).json({ error: "Failed to update ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-request:
* post:
* summary: Request or renew Let's Encrypt certificate (admin only)
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
* tags:
* - Users
* responses:
* 200:
* description: Certificate issued or renewed successfully.
* 400:
* description: Invalid configuration.
* 403:
* description: Not authorized.
* 500:
* description: Certificate issuance failed.
*/
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsValue =
await createCurrentSettingsRepository().get("acme_ssl_settings");
if (!settingsValue) {
return res.status(400).json({ error: "ACME settings not configured" });
}
const settings = JSON.parse(settingsValue);
const { domain, email, challengeType, cloudflareToken } = settings;
if (!domain || !email) {
return res.status(400).json({ error: "Domain and email are required" });
}
try {
execFileSync("certbot", ["--version"], { stdio: "pipe" });
} catch {
return res
.status(500)
.json({ error: "certbot is not available in this environment" });
}
await fs.mkdir(SSL_DIR, { recursive: true });
await fs.mkdir(ACME_WEBROOT, { recursive: true });
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
const certbotDirArgs = [
"--config-dir",
CERTBOT_CONFIG_DIR,
"--work-dir",
CERTBOT_WORK_DIR,
"--logs-dir",
CERTBOT_LOGS_DIR,
];
let certbotArgs: string[];
if (challengeType === "dns-cloudflare") {
if (!cloudflareToken) {
return res.status(400).json({
error: "Cloudflare API token is required for DNS challenge",
});
}
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
recursive: true,
});
await fs.writeFile(
CLOUDFLARE_CREDENTIALS_FILE,
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
{ mode: 0o600 },
);
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--dns-cloudflare",
"--dns-cloudflare-credentials",
CLOUDFLARE_CREDENTIALS_FILE,
"--dns-cloudflare-propagation-seconds",
"30",
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
} else {
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--webroot",
"-w",
ACME_WEBROOT,
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
}
authLogger.info("Requesting Let's Encrypt certificate", {
domain,
challengeType,
operation: "acme_cert_request",
});
execFileSync("certbot", certbotArgs, {
stdio: "pipe",
timeout: 120000,
});
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
const fullchainSrc = path.join(liveDir, "fullchain.pem");
const privkeySrc = path.join(liveDir, "privkey.pem");
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.copyFile(fullchainSrc, certDest);
await fs.copyFile(privkeySrc, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
await createCurrentSettingsRepository().set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Let's Encrypt certificate issued and installed", {
domain,
operation: "acme_cert_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ domain, challengeType, success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
/**
* @openapi
* /users/manual-ssl-upload:
* post:
* summary: Upload a manual/custom SSL certificate and key (admin only)
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate uploaded and installed successfully.
* 400:
* description: Invalid or missing certificate/key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { certificate, privateKey } = req.body;
if (
typeof certificate !== "string" ||
typeof privateKey !== "string" ||
!certificate.includes("BEGIN CERTIFICATE") ||
!privateKey.includes("PRIVATE KEY")
) {
return res.status(400).json({
error: "A valid PEM certificate and private key are required",
});
}
await fs.mkdir(SSL_DIR, { recursive: true });
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
try {
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
try {
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
stdio: "pipe",
});
execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
{ stdio: "pipe" },
);
} catch {
return res.status(400).json({
error:
"The provided certificate or private key is not valid PEM data",
});
}
const certPubkey = execFileSync(
"openssl",
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
{ stdio: "pipe" },
);
const keyPubkey = execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-pubout"],
{ stdio: "pipe" },
);
if (!certPubkey.equals(keyPubkey)) {
return res
.status(400)
.json({ error: "The certificate and private key do not match" });
}
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.rename(tmpCertFile, certDest);
await fs.rename(tmpKeyFile, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
} finally {
await fs.rm(tmpCertFile, { force: true });
await fs.rm(tmpKeyFile, { force: true });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const updated = {
...current,
challengeType: "manual",
lastIssuedAt: new Date().toISOString(),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Manual SSL certificate installed", {
operation: "manual_ssl_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("Manual SSL certificate upload failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
+128
View File
@@ -0,0 +1,128 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import { TlsValidationError } from "../../tls/certificate.js";
import {
getTlsStatus,
reloadTls,
writeTlsCertificate,
} from "../../tls/tls-service.js";
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
export function registerTlsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/tls-certificate:
* get:
* summary: Get the served TLS certificate (admin only)
* description: Returns whether HTTPS is on, the certificate core serves, and the plugin that renews it, if any.
* tags:
* - Users
* responses:
* 200:
* description: Certificate status.
* 403:
* description: Not authorized.
* 500:
* description: Failed to read the certificate.
*/
router.get("/tls-certificate", authenticateJWT, async (req, res) => {
try {
const actor = await getAdminActor((req as AuthenticatedRequest).userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
res.json(await getTlsStatus());
} catch (err) {
authLogger.error("Failed to read TLS certificate status", err);
res.status(500).json({ error: "Failed to read TLS certificate status" });
}
});
/**
* @openapi
* /users/tls-certificate:
* post:
* summary: Upload a TLS certificate and key (admin only)
* description: Validates a PEM certificate chain and private key, installs them as the served certificate and reloads HTTPS without a restart.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate installed. Carries the new status and a reload message.
* 400:
* description: Missing, unreadable, expired or mismatched certificate and key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/tls-certificate", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
const audit = (success: boolean, details: object, errorMessage?: string) =>
logAudit({
userId,
username: actor.username ?? userId,
action: "tls_certificate_upload",
resourceType: "setting",
details: JSON.stringify(details),
ipAddress,
userAgent,
success,
errorMessage,
});
try {
const { certificate, privateKey } = req.body ?? {};
const info = await writeTlsCertificate(certificate, privateKey);
const reload = await reloadTls();
await audit(true, { subject: info.subject, notAfter: info.notAfter });
res.json({
success: true,
reloadMessage: reload.message,
...(await getTlsStatus()),
});
} catch (err) {
const message = getErrorMessage(err);
await audit(false, {}, message);
if (err instanceof TlsValidationError) {
return res.status(400).json({ error: message });
}
authLogger.error("TLS certificate upload failed", err);
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
+2 -2
View File
@@ -18,7 +18,7 @@ import { PermissionManager } from "../../utils/permission-manager.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
@@ -1648,7 +1648,7 @@ registerUserOidcAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerAcmeSSLRoutes(router, authenticateJWT);
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
+129
View File
@@ -0,0 +1,129 @@
/**
* ctx.system: the server certificate. Core serves it; a plugin under
* system:tls may replace it, reload it, answer ACME http-01 challenges and
* say that it renews it. Every call is audited.
*/
import type { PluginSystem } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const CAPABILITY = "system:tls";
async function audited<T>(
deps: Deps,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
CAPABILITY,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Puts an undo in the plugin's bag and hands back one that also takes it out
* again, so it runs once whether the plugin or deactivate gets there first.
*/
function tracked(deps: Deps, undo: () => void, label: string): () => void {
let done = false;
const run = () => {
if (done) return;
done = true;
undo();
};
const untrack = deps.bag.add(run, label);
return () => {
untrack();
run();
};
}
export function createPluginSystem(deps: Deps): PluginSystem {
return {
tlsStatus: () =>
audited(deps, "tls_status", "read certificate status", async () => {
const { getTlsStatus } = await import("../tls/tls-service.js");
return getTlsStatus();
}),
writeTlsCertificate: (certificatePem, privateKeyPem) =>
audited(
deps,
"tls_write",
"replaced the server certificate",
async () => {
const { writeTlsCertificate } = await import("../tls/tls-service.js");
return writeTlsCertificate(certificatePem, privateKeyPem);
},
),
reloadTls: () =>
audited(
deps,
"tls_reload",
"reloaded the server certificate",
async () => {
const { reloadTls } = await import("../tls/tls-service.js");
return reloadTls();
},
),
publishHttpChallenge: (token, content) =>
audited(
deps,
"tls_challenge",
"published an ACME challenge",
async () => {
const { publishChallenge } =
await import("../tls/acme-challenges.js");
return tracked(
deps,
publishChallenge(token, content),
"acme challenge",
);
},
),
registerTlsRenewer: () =>
audited(
deps,
"tls_renewer",
"renews the server certificate",
async () => {
const { registerTlsRenewer } = await import("../tls/tls-service.js");
const undo = registerTlsRenewer({
pluginId: deps.manifest.id,
pluginName: deps.manifest.name,
});
return tracked(deps, undo, "tls renewer");
},
),
};
}
+2
View File
@@ -57,6 +57,7 @@ import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -828,6 +829,7 @@ export function createPluginContext(
audit: auditCall,
}),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
+5 -1
View File
@@ -3,7 +3,7 @@ import dotenv from "dotenv";
import { promises as fs, readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
import { AutoSSLSetup } from "./tls/self-signed.js";
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
@@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runAcmeSslSettingsMigration } =
await import("./utils/crypto-migration/acme-ssl-settings-migration.js");
await runAcmeSslSettingsMigration();
const { runVaultSettingsMigration } =
await import("./utils/crypto-migration/vault-settings-migration.js");
await runVaultSettingsMigration();
@@ -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([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* ACME http-01 answers. The CA asks for /.well-known/acme-challenge/<token>
* on port 80 of the domain, which no plugin route can own, so core serves
* whatever a plugin published through ctx.system.publishHttpChallenge.
*/
import type { Request, Response } from "express";
const MAX_AGE_MS = 60 * 60 * 1000;
const TOKEN = /^[A-Za-z0-9_-]{1,256}$/;
const challenges = new Map<string, { content: string; expiresAt: number }>();
/** Returns the undo. A challenge also expires on its own after an hour. */
export function publishChallenge(token: string, content: string): () => void {
if (!TOKEN.test(token)) {
throw new Error("ACME challenge tokens are base64url");
}
if (typeof content !== "string" || content.length === 0) {
throw new Error("ACME challenge content must be a non-empty string");
}
const entry = { content, expiresAt: Date.now() + MAX_AGE_MS };
challenges.set(token, entry);
return () => {
if (challenges.get(token) === entry) challenges.delete(token);
};
}
export function lookupChallenge(token: string): string | null {
const entry = challenges.get(token);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
challenges.delete(token);
return null;
}
return entry.content;
}
export function clearChallengesForTests(): void {
challenges.clear();
}
/** GET /.well-known/acme-challenge/:token, public by design. */
export function acmeChallengeHandler(req: Request, res: Response): void {
const content = lookupChallenge(String(req.params.token ?? ""));
if (content === null) {
res.status(404).type("text/plain").send("Not found");
return;
}
res.status(200).type("text/plain").send(content);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Reading and checking PEM certificates with node's own X509 parser, so none
* of this needs the openssl binary.
*/
import { X509Certificate, createPrivateKey } from "crypto";
export interface TlsCertificateInfo {
subject: string;
issuer: string;
names: string[];
notBefore: string;
notAfter: string;
selfSigned: boolean;
fingerprint: string;
}
export class TlsValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsValidationError";
}
}
const PEM_CERT =
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
/** The leaf is the first certificate in a full chain. */
export function splitPemChain(pem: string): string[] {
return pem.match(PEM_CERT) ?? [];
}
function parseNames(subjectAltName: string | undefined): string[] {
if (!subjectAltName) return [];
return subjectAltName
.split(",")
.map((entry) => entry.trim())
.map((entry) => {
const match = /^(DNS|IP Address):(.+)$/.exec(entry);
return match ? match[2].trim() : null;
})
.filter((name): name is string => !!name);
}
function isSelfSigned(cert: X509Certificate): boolean {
if (cert.subject !== cert.issuer) return false;
try {
return cert.checkIssued(cert) && cert.verify(cert.publicKey);
} catch {
return false;
}
}
export function describeCertificate(cert: X509Certificate): TlsCertificateInfo {
return {
subject: cert.subject.replace(/\n/g, ", "),
issuer: cert.issuer.replace(/\n/g, ", "),
names: parseNames(cert.subjectAltName),
notBefore: new Date(cert.validFrom).toISOString(),
notAfter: new Date(cert.validTo).toISOString(),
selfSigned: isSelfSigned(cert),
fingerprint: cert.fingerprint256,
};
}
/** Parses the leaf of a PEM chain. Null when there is nothing readable. */
export function readCertificateInfo(pem: string): TlsCertificateInfo | null {
const [leaf] = splitPemChain(pem);
if (!leaf) return null;
try {
return describeCertificate(new X509Certificate(leaf));
} catch {
return null;
}
}
/**
* Throws TlsValidationError unless the chain parses, the leaf is still valid
* and the key belongs to it. Returns the leaf's details.
*/
export function validateCertificatePair(
certificatePem: string,
privateKeyPem: string,
now: Date = new Date(),
): TlsCertificateInfo {
if (typeof certificatePem !== "string" || typeof privateKeyPem !== "string") {
throw new TlsValidationError(
"A PEM certificate and private key are required",
);
}
const chain = splitPemChain(certificatePem);
if (chain.length === 0) {
throw new TlsValidationError("The certificate is not PEM data");
}
let leaf: X509Certificate;
try {
leaf = new X509Certificate(chain[0]);
for (const extra of chain.slice(1)) new X509Certificate(extra);
} catch {
throw new TlsValidationError("The certificate could not be parsed");
}
let key: ReturnType<typeof createPrivateKey>;
try {
key = createPrivateKey(privateKeyPem);
} catch {
throw new TlsValidationError("The private key could not be parsed");
}
if (!leaf.checkPrivateKey(key)) {
throw new TlsValidationError(
"The certificate and private key do not match",
);
}
if (new Date(leaf.validTo).getTime() <= now.getTime()) {
throw new TlsValidationError("The certificate has expired");
}
return describeCertificate(leaf);
}
@@ -1,7 +1,7 @@
import { execFileSync } from "child_process";
import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { authLogger } from "./logger.js";
import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
const NGINX_CONF = "/tmp/nginx/nginx.conf";
@@ -11,7 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ENV_FILE = path.join(DATA_DIR, ".env");
function persistSSLEnv(sslPort: string, certPath: string, keyPath: string) {
export function persistSSLEnv(
sslPort: string,
certPath: string,
keyPath: string,
) {
const vars: Record<string, string> = {
ENABLE_SSL: "true",
SSL_PORT: sslPort,
@@ -1,8 +1,11 @@
import { getErrorMessage } from "./error-message.js";
import { getErrorMessage } from "../utils/error-message.js";
import { execSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import { systemLogger } from "./logger.js";
import { systemLogger } from "../utils/logger.js";
import { readCertificateInfo } from "./certificate.js";
const RENEW_WINDOW_MS = 30 * 86_400_000;
export class AutoSSLSetup {
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
@@ -28,29 +31,12 @@ export class AutoSSLSetup {
}
try {
if (await this.isSSLConfigured()) {
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
}
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
systemLogger.info("SSL certificates found from entrypoint script", {
operation: "ssl_cert_found_entrypoint",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
});
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
} catch {
if (await this.needsSelfSigned()) {
await this.generateSSLCertificates();
await this.setupEnvironmentVariables();
} else {
await this.logCertificateInfo();
}
await this.setupEnvironmentVariables();
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed",
@@ -62,40 +48,44 @@ export class AutoSSLSetup {
}
}
private static async isSSLConfigured(): Promise<boolean> {
/**
* Only core's own certificate is ever replaced here. One a CA issued keeps
* being served until it expires, even when nothing renews it.
*/
private static async needsSelfSigned(): Promise<boolean> {
let pem: string;
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
execSync(
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
{
stdio: "pipe",
},
);
pem = await fs.readFile(this.CERT_FILE, "utf8");
} catch {
systemLogger.info("SSL certificate not found, will generate one", {
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
});
return true;
} catch (error) {
if (error instanceof Error && error.message.includes("checkend")) {
systemLogger.warn(
"SSL certificate is expired or expiring soon, will regenerate",
{
operation: "ssl_cert_expired",
cert_path: this.CERT_FILE,
error: error.message,
},
);
} else {
systemLogger.info(
"SSL certificate not found or invalid, will generate new one",
{
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
},
);
}
return false;
}
const info = readCertificateInfo(pem);
if (!info) {
systemLogger.warn("SSL certificate is unreadable, will generate one", {
operation: "ssl_cert_invalid",
cert_path: this.CERT_FILE,
});
return true;
}
if (
info.selfSigned &&
new Date(info.notAfter).getTime() - Date.now() < RENEW_WINDOW_MS
) {
systemLogger.warn(
"Self-signed SSL certificate is expiring, will regenerate",
{ operation: "ssl_cert_expired", cert_path: this.CERT_FILE },
);
return true;
}
return false;
}
private static async generateSSLCertificates(): Promise<void> {
@@ -210,7 +200,6 @@ IP.3 = 0.0.0.0
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
note: "Certificate will auto-renew 30 days before expiration",
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
@@ -270,14 +259,4 @@ IP.3 = 0.0.0.0
process.env[key] = value;
}
}
static getSSLConfig() {
return {
enabled: process.env.ENABLE_SSL === "true",
port: parseInt(process.env.SSL_PORT || "8443"),
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
domain: process.env.SSL_DOMAIN || "localhost",
};
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Core's side of HTTPS: where the certificate lives, replacing it, and
* serving a new one without a restart. Getting a certificate (ACME) is a
* plugin's job; it reaches this through ctx.system.
*/
import { promises as fs } from "fs";
import https from "https";
import path from "path";
import { systemLogger } from "../utils/logger.js";
import {
readCertificateInfo,
validateCertificatePair,
type TlsCertificateInfo,
} from "./certificate.js";
import { persistSSLEnv, reloadNginxWithSSL } from "./nginx-reload.js";
export interface TlsConfig {
enabled: boolean;
port: number;
certPath: string;
keyPath: string;
domain: string;
/** In Docker nginx serves HTTPS and the backend only speaks HTTP. */
terminatedByNginx: boolean;
}
export interface TlsRenewer {
pluginId: string;
pluginName: string;
}
export interface TlsStatus {
enabled: boolean;
certificate: TlsCertificateInfo | null;
renewal: TlsRenewer | null;
}
export interface TlsReloadResult {
applied: boolean;
message: string;
}
export function getTlsConfig(env: NodeJS.ProcessEnv = process.env): TlsConfig {
const sslDir = path.join(env.DATA_DIR || "./db/data", "ssl");
return {
enabled: env.ENABLE_SSL === "true",
port: parseInt(env.SSL_PORT || "8443"),
certPath: env.SSL_CERT_PATH || path.join(sslDir, "termix.crt"),
keyPath: env.SSL_KEY_PATH || path.join(sslDir, "termix.key"),
domain: env.SSL_DOMAIN || "localhost",
terminatedByNginx: env.TERMIX_SSL_TERMINATED_BY_NGINX === "true",
};
}
type HttpsFactory = (options: https.ServerOptions) => https.Server;
let directServer: https.Server | null = null;
let directFactory: HttpsFactory | null = null;
/**
* Called once by the backend with how to build its direct HTTPS server. Starts
* it now when HTTPS is on and nginx is not the one serving it.
*/
export async function configureDirectHttps(
factory: HttpsFactory,
): Promise<https.Server | null> {
directFactory = factory;
const config = getTlsConfig();
if (!config.enabled || config.terminatedByNginx) return null;
return startDirectServer(config);
}
async function readPair(
config: TlsConfig,
): Promise<{ cert: Buffer; key: Buffer }> {
const [cert, key] = await Promise.all([
fs.readFile(config.certPath),
fs.readFile(config.keyPath),
]);
return { cert, key };
}
async function startDirectServer(
config: TlsConfig,
): Promise<https.Server | null> {
if (!directFactory) return null;
try {
directServer = directFactory(await readPair(config));
return directServer;
} catch (error) {
systemLogger.error(
"Failed to start HTTPS server with configured SSL certificate",
error,
{
operation: "https_server_start_failed",
cert_path: config.certPath,
key_path: config.keyPath,
},
);
return null;
}
}
/** Only for tests: forget the server and factory. */
export function resetTlsServiceForTests(): void {
directServer = null;
directFactory = null;
renewers.clear();
}
const renewers = new Map<string, { renewer: TlsRenewer; count: number }>();
/** Records that a plugin renews the certificate. Returns the undo. */
export function registerTlsRenewer(renewer: TlsRenewer): () => void {
const entry = renewers.get(renewer.pluginId) ?? { renewer, count: 0 };
entry.count++;
renewers.set(renewer.pluginId, entry);
let live = true;
return () => {
if (!live) return;
live = false;
const current = renewers.get(renewer.pluginId);
if (!current) return;
current.count--;
if (current.count <= 0) renewers.delete(renewer.pluginId);
};
}
export function currentTlsRenewer(): TlsRenewer | null {
const first = renewers.values().next();
return first.done ? null : first.value.renewer;
}
export async function getTlsStatus(): Promise<TlsStatus> {
const config = getTlsConfig();
let certificate: TlsCertificateInfo | null = null;
try {
certificate = readCertificateInfo(
await fs.readFile(config.certPath, "utf8"),
);
} catch {
certificate = null;
}
return {
enabled: config.enabled,
certificate,
renewal: currentTlsRenewer(),
};
}
/**
* Validates the pair, then swaps both files in with a rename so nginx or the
* HTTPS server never reads half a certificate. Does not reload.
*/
export async function writeTlsCertificate(
certificatePem: string,
privateKeyPem: string,
): Promise<TlsCertificateInfo> {
const info = validateCertificatePair(certificatePem, privateKeyPem);
const config = getTlsConfig();
await fs.mkdir(path.dirname(config.certPath), { recursive: true });
await fs.mkdir(path.dirname(config.keyPath), { recursive: true });
const certTmp = `${config.certPath}.tmp`;
const keyTmp = `${config.keyPath}.tmp`;
try {
await fs.writeFile(certTmp, certificatePem, { mode: 0o644 });
await fs.writeFile(keyTmp, privateKeyPem, { mode: 0o600 });
await fs.rename(keyTmp, config.keyPath);
await fs.rename(certTmp, config.certPath);
await fs.chmod(config.keyPath, 0o600).catch(() => undefined);
await fs.chmod(config.certPath, 0o644).catch(() => undefined);
} finally {
await fs.rm(certTmp, { force: true });
await fs.rm(keyTmp, { force: true });
}
systemLogger.info("TLS certificate replaced", {
operation: "tls_certificate_written",
subject: info.subject,
not_after: info.notAfter,
});
return info;
}
/** Serves the certificate on disk now, through nginx or the direct server. */
export async function reloadTls(): Promise<TlsReloadResult> {
const config = getTlsConfig();
if (config.terminatedByNginx) {
return reloadNginxWithSSL();
}
try {
const pair = await readPair(config);
if (directServer) {
directServer.setSecureContext(pair);
return { applied: true, message: "The new certificate is being served." };
}
process.env.ENABLE_SSL = "true";
const server = await startDirectServer(getTlsConfig());
if (!server) {
return {
applied: false,
message:
"Certificate installed, but HTTPS could not be started. Restart Termix with ENABLE_SSL=true to apply it.",
};
}
persistSSLEnv(String(config.port), config.certPath, config.keyPath);
return {
applied: true,
message: `HTTPS is now active on port ${config.port}.`,
};
} catch (error) {
systemLogger.error("Failed to reload the TLS certificate", error, {
operation: "tls_reload_failed",
});
return {
applied: false,
message:
"Certificate installed, but it could not be loaded. Restart Termix to apply it.",
};
}
}
@@ -0,0 +1,127 @@
/**
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
*
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
* uploaded certificate, which core still handles), so it turns renewal off.
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
* then removed from the old row. The rest of the row stays for one release.
*
* Idempotent: a field already set in plugin_settings is never overwritten.
*/
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
const PLUGIN_ID = "acme-ssl";
const LEGACY_KEY = "acme_ssl_settings";
export interface AcmeSslSettingsMigrationResult {
moved: string[];
}
interface LegacyAcmeSettings {
enabled?: unknown;
domain?: unknown;
email?: unknown;
challengeType?: unknown;
cloudflareToken?: unknown;
}
const text = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null;
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
const result: AcmeSslSettingsMigrationResult = { moved: [] };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const raw = await settings.get(LEGACY_KEY);
if (!raw) return result;
let legacy: LegacyAcmeSettings;
try {
legacy = JSON.parse(raw) as LegacyAcmeSettings;
} catch {
return result;
}
if (!legacy || typeof legacy !== "object") return result;
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (
field: string,
value: unknown,
secret = false,
) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
secret,
);
result.moved.push(field);
};
const challenge = legacy.challengeType;
const manual = challenge === "manual";
const domain = text(legacy.domain);
if (domain) await writeIfUnset("domain", domain);
const email = text(legacy.email);
if (email) await writeIfUnset("email", email);
if (challenge === "dns-cloudflare") {
await writeIfUnset("challengeType", "dns-cloudflare");
} else if (challenge === "http-webroot") {
await writeIfUnset("challengeType", "http-01");
}
if (typeof legacy.enabled === "boolean") {
await writeIfUnset("autoRenew", legacy.enabled && !manual);
}
const token = text(legacy.cloudflareToken);
if (token) {
await writeIfUnset(
"cloudflareToken",
await encryptSystemSecret(token),
true,
);
const { cloudflareToken: _dropped, ...rest } = legacy;
await settings.set(LEGACY_KEY, JSON.stringify(rest));
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
{
operation: "acme_ssl_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then shows "not configured".
databaseLogger.warn("ACME settings migration failed", {
operation: "acme_ssl_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}