mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move acme certificate automation into a plugin (C7)
This commit is contained in:
1 parent
4dbce08379
commit
8c1e02af89
123 files changed
+4808
-1912
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user