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 { 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 { 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 { 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 { 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, ...(await getAcmeSettings()) }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; 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 { 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, ...(await getAcmeSettings()) }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; 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}` }); } }); }