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

+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);
}
+110
View File
@@ -0,0 +1,110 @@
import { execFileSync } from "child_process";
import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
const NGINX_CONF = "/tmp/nginx/nginx.conf";
const NGINX_PID = "/tmp/nginx/nginx.pid";
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");
export function persistSSLEnv(
sslPort: string,
certPath: string,
keyPath: string,
) {
const vars: Record<string, string> = {
ENABLE_SSL: "true",
SSL_PORT: sslPort,
SSL_CERT_PATH: certPath,
SSL_KEY_PATH: keyPath,
};
let content = "";
try {
content = readFileSync(ENV_FILE, "utf8");
} catch {
// no existing .env file yet
}
for (const [key, value] of Object.entries(vars)) {
const regex = new RegExp(`^${key}=.*$`, "m");
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
if (!content.includes("# SSL Configuration")) {
content += `\n# SSL Configuration (Auto-generated)\n`;
}
content += `${key}=${value}\n`;
}
}
writeFileSync(ENV_FILE, content.trim() + "\n");
}
/**
* Regenerates the nginx config from the HTTPS template and reloads nginx so a
* newly issued/uploaded certificate is served without a full container
* restart. No-ops outside the Docker image (template/binary won't exist).
*/
export function reloadNginxWithSSL(): { applied: boolean; message: string } {
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) {
return {
applied: false,
message:
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.",
};
}
const port = process.env.PORT || "8080";
const sslPort = process.env.SSL_PORT || "8443";
const certPath =
process.env.SSL_CERT_PATH || path.join(SSL_DIR, "termix.crt");
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
try {
const template = readFileSync(NGINX_TEMPLATE, "utf8");
const rendered = template
.split("${PORT}")
.join(port)
.split("${SSL_PORT}")
.join(sslPort)
.split("${SSL_CERT_PATH}")
.join(certPath)
.split("${SSL_KEY_PATH}")
.join(keyPath);
writeFileSync(NGINX_CONF, rendered);
execFileSync("nginx", ["-t", "-c", NGINX_CONF], { stdio: "pipe" });
execFileSync("nginx", ["-s", "reload", "-c", NGINX_CONF], {
stdio: "pipe",
});
process.env.ENABLE_SSL = "true";
process.env.SSL_PORT = sslPort;
process.env.SSL_CERT_PATH = certPath;
process.env.SSL_KEY_PATH = keyPath;
persistSSLEnv(sslPort, certPath, keyPath);
authLogger.info("nginx reloaded with HTTPS enabled", {
operation: "nginx_ssl_reload",
sslPort,
});
return {
applied: true,
message: `HTTPS is now active on port ${sslPort}. Make sure that port is published/mapped to this container.`,
};
} catch (err) {
authLogger.error("Failed to reload nginx with SSL config", err);
return {
applied: false,
message:
"Certificate installed, but nginx could not be reloaded automatically. Restart Termix with ENABLE_SSL=true to apply it.",
};
}
}
+262
View File
@@ -0,0 +1,262 @@
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 "../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";
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
private static readonly CERT_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.crt",
);
private static readonly KEY_FILE = path.join(
AutoSSLSetup.SSL_DIR,
"termix.key",
);
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
static async initialize(): Promise<void> {
if (process.env.ENABLE_SSL !== "true") {
systemLogger.info("SSL not enabled - skipping certificate generation", {
operation: "ssl_disabled_default",
enable_ssl: process.env.ENABLE_SSL || "undefined",
note: "Set ENABLE_SSL=true to enable SSL certificate generation",
});
return;
}
try {
if (await this.needsSelfSigned()) {
await this.generateSSLCertificates();
} else {
await this.logCertificateInfo();
}
await this.setupEnvironmentVariables();
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed",
});
systemLogger.warn("Falling back to HTTP-only mode", {
operation: "ssl_fallback_http",
});
}
}
/**
* 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.KEY_FILE);
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;
}
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> {
try {
try {
execSync("openssl version", { stdio: "pipe" });
} catch {
throw new Error(
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
);
}
await fs.mkdir(this.SSL_DIR, { recursive: true });
const configFile = path.join(this.SSL_DIR, "openssl.conf");
const opensslConfig = `
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=localhost
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
DNS.3 = *.localhost
DNS.4 = termix.local
DNS.5 = *.termix.local
IP.1 = 127.0.0.1
IP.2 = ::1
IP.3 = 0.0.0.0
`.trim();
await fs.writeFile(configFile, opensslConfig);
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
stdio: "pipe",
});
execSync(
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
{
stdio: "pipe",
},
);
await fs.chmod(this.KEY_FILE, 0o600);
await fs.chmod(this.CERT_FILE, 0o644);
await fs.unlink(configFile);
systemLogger.success("SSL certificates generated successfully", {
operation: "ssl_cert_generated",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
valid_days: 365,
});
await this.logCertificateInfo();
} catch (error) {
throw new Error(
`SSL certificate generation failed: ${getErrorMessage(error)}`,
{ cause: error },
);
}
}
private static async logCertificateInfo(): Promise<void> {
try {
const subject = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
{ stdio: "pipe" },
)
.toString()
.trim();
const issuer = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notAfter = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notBefore = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
{ stdio: "pipe" },
)
.toString()
.trim();
systemLogger.info("SSL Certificate Information:", {
operation: "ssl_cert_info",
subject: subject.replace("subject=", ""),
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
operation: "ssl_cert_info_error",
error: getErrorMessage(error),
});
}
}
private static async setupEnvironmentVariables(): Promise<void> {
const certPath = this.CERT_FILE;
const keyPath = this.KEY_FILE;
const sslEnvVars = {
ENABLE_SSL: "true",
SSL_PORT: process.env.SSL_PORT || "8443",
SSL_CERT_PATH: certPath,
SSL_KEY_PATH: keyPath,
SSL_DOMAIN: "localhost",
};
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch {
// expected - env file may not exist yet
}
let updatedContent = envContent;
let hasChanges = false;
for (const [key, value] of Object.entries(sslEnvVars)) {
const regex = new RegExp(`^${key}=.*$`, "m");
if (regex.test(updatedContent)) {
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
} else {
if (!updatedContent.includes(`# SSL Configuration`)) {
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
}
updatedContent += `${key}=${value}\n`;
hasChanges = true;
}
}
if (hasChanges || !envContent) {
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
systemLogger.info("SSL environment variables configured", {
operation: "ssl_env_configured",
file: this.ENV_FILE,
variables: Object.keys(sslEnvVars),
});
}
for (const [key, value] of Object.entries(sslEnvVars)) {
process.env[key] = value;
}
}
}
+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.",
};
}
}