UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

255 lines (228 loc) 8.89 kB
/** * Transport Layer Security (TLS) / SSL utilities and HTTPS server creation and management. * @module src/server/tls.js * @namespace UnderpostTLS */ import fs from 'fs-extra'; import https from 'https'; import path from 'path'; import { loggerFactory } from './logger.js'; const logger = loggerFactory(import.meta); const DEFAULT_HOST = 'localhost'; const SSL_BASE = (host = DEFAULT_HOST) => path.resolve(`./engine-private/ssl/${host}`); // Common filename candidates for certs/keys produced by various tools (mkcert, certbot, openssl scripts). const CERT_CANDIDATES = [ 'fullchain.pem', 'cert.pem', 'ca_bundle.crt', 'crt.crt', `${DEFAULT_HOST}.pem`, `${DEFAULT_HOST}.crt`, `${DEFAULT_HOST}-fullchain.pem`, ]; const KEY_CANDIDATES = [ 'privkey.pem', 'key.key', 'private.key', 'key.pem', `${DEFAULT_HOST}-key.pem`, `${DEFAULT_HOST}.key`, ]; const ROOT_CANDIDATES = ['rootCA.pem', 'ca.pem', 'ca.crt', 'root.pem']; class TLS { /** * Look for existing SSL files under engine-private/ssl/<host> and return canonical paths. * It attempts to be permissive: accepts cert-only, cert+ca, or fullchain. * @param {string} host * @returns {{key?:string, cert?:string, fullchain?:string, ca?:string, dir:string}} * @memberof UnderpostTLS */ static locateSslFiles(host = DEFAULT_HOST) { const dir = SSL_BASE(host); const result = { dir }; if (!fs.existsSync(dir)) { logger.warn('SSL dir does not exist', { dir }); return result; } // find key for (const name of KEY_CANDIDATES) { const p = path.join(dir, name); if (fs.existsSync(p) && fs.statSync(p).isFile()) { result.key = p; break; } } // find fullchain first for (const name of CERT_CANDIDATES) { const p = path.join(dir, name); if (fs.existsSync(p) && fs.statSync(p).isFile()) { // treat fullchain.pem / ca_bundle.crt as fullchain if name indicates so if ( ['fullchain.pem', 'ca_bundle.crt', `${host}-fullchain.pem`].includes(name) || name.endsWith('fullchain.pem') ) { result.fullchain = p; result.cert = p; // fullchain will be used as cert when building context break; } // otherwise candidate may be leaf cert if (!result.cert) result.cert = p; } } // find root/ca if not using fullchain if (!result.fullchain) { // check for direct ca bundle (cert + ca combined) names const caCandidates = ROOT_CANDIDATES.concat(['ca_bundle.crt']); for (const name of caCandidates) { const p = path.join(dir, name); if (fs.existsSync(p) && fs.statSync(p).isFile()) { result.ca = p; break; } } // if no dedicated ca found but cert looks like leaf and there is separate ca under other known names, // try to detect cert + ca in a single file (not trivial) — we prefer explicit ca } return result; } /** * Validate that a secure context can be built for host (key + cert or fullchain present) * @param {string} host * @returns {boolean} * @memberof UnderpostTLS */ static validateSecureContext(host = DEFAULT_HOST) { const files = TLS.locateSslFiles(host); return Boolean((files.key && files.cert) || (files.key && files.fullchain)); } /** * Build a Node.js https.createServer options object (key, cert, ca) for the given host. * If a fullchain is available it will be used for cert and ca will be omitted (fullchain already includes chain). * If separate cert + ca are found, they will be used accordingly. * @param {string} host * @returns {{key:string, cert:string, ca?:string}} options * @memberof UnderpostTLS */ static buildSecureContext(host = DEFAULT_HOST) { const files = TLS.locateSslFiles(host); if (!files.key) throw new Error(`SSL key not found for host ${host} (looked in ${files.dir})`); if (!files.cert) throw new Error(`SSL certificate not found for host ${host} (looked in ${files.dir})`); const key = fs.readFileSync(files.key, 'utf8'); const cert = fs.readFileSync(files.cert, 'utf8'); // If we have a root CA file (explicit) and cert is leaf-only, include ca if (files.ca && files.ca !== files.cert) { const ca = fs.readFileSync(files.ca, 'utf8'); return { key, cert, ca }; } // If cert is fullchain (already contains chain), just return key/cert return { key, cert }; } /** * Convenience: ensure default host directory exists and copy any matching cert/key files into it using canonical names. * This is useful if your generator produced nonstandard names and you want to normalize them. * The function will copy existing discovered files to: key.key, crt.crt, ca_bundle.crt when possible. * @param {string} host * @returns {boolean} true if at least key+cert exist after operation * @memberof UnderpostTLS */ static async buildLocalSSL(host = DEFAULT_HOST) { const dir = SSL_BASE(host); await fs.ensureDir(dir); const files = TLS.locateSslFiles(host); // If key+cert already exist under canonical names, done const canonicalKey = path.join(dir, 'key.key'); const canonicalCert = path.join(dir, 'crt.crt'); const canonicalCa = path.join(dir, 'ca_bundle.crt'); try { if (files.key && files.key !== canonicalKey) await fs.copy(files.key, canonicalKey, { overwrite: true }); if (files.cert && files.cert !== canonicalCert) await fs.copy(files.cert, canonicalCert, { overwrite: true }); if (files.ca && files.ca !== canonicalCa) await fs.copy(files.ca, canonicalCa, { overwrite: true }); // If we had a fullchain but not a separate ca, write fullchain also to ca_bundle if missing if (files.fullchain && !fs.existsSync(canonicalCa)) { await fs.copy(files.fullchain, canonicalCa, { overwrite: false }); } } catch (err) { logger.warn('buildLocalSSL copy step failed', { err: err.message }); } return TLS.validateSecureContext(host); } /** * Create an HTTPS server (first host) and/or attach SNI contexts for additional hosts. * hosts param is an object whose keys are hostnames (e.g. { 'localhost': {...} }). * Returns the created https.Server instance (or undefined if none created). * @param {import('express').Application} app * @param {Object<string, any>} hosts * @returns {{ServerSSL?: https.Server}} * @memberof UnderpostTLS */ static async createSslServer(app, hosts = { [DEFAULT_HOST]: {} }) { let server; for (const host of Object.keys(hosts)) { // ensure canonical files exist (copies where possible) await TLS.buildLocalSSL(host); if (!TLS.validateSecureContext(host)) { logger.error('Invalid SSL context, skipping host', { host }); continue; } // build secure context options try { const ctx = TLS.buildSecureContext(host); if (!server) { server = https.createServer(ctx, app); logger.info('Created HTTPS server for host', { host }); } else { server.addContext(host, ctx); logger.info('Added SNI context for host', { host }); } } catch (err) { logger.error('Failed to build secure context', { host, message: err.message }); } } return { ServerSSL: server }; } /** * Middleware that redirects HTTP -> HTTPS in production for recognized hosts. * Skips ACME challenge paths. * @param {import('express').Request} req * @param {import('express').Response} res * @param {number} port * @param {Object<string, any>} proxyRouter * @returns {import('express').RequestHandler} * @memberof UnderpostTLS */ static sslRedirectMiddleware(req, res, port = 80, proxyRouter = {}) { const sslRedirectUrl = `https://${req.headers.host}${req.url}`; if ( process.env.NODE_ENV === 'production' && port !== 443 && !req.secure && !req.url.startsWith('/.well-known/acme-challenge') && proxyRouter[443] && Object.keys(proxyRouter[443]).find((host) => { const [hostSSL] = host.split('/'); return sslRedirectUrl.match(hostSSL) && TLS.validateSecureContext(hostSSL); }) ) { return res.status(302).redirect(sslRedirectUrl); } } } /** * @class UnderpostTLS * @description TLS/SSL utilities for Underpost. * @memberof UnderpostTLS */ class UnderpostTLS { static API = { TLS, SSL_BASE, locateSslFiles: TLS.locateSslFiles, validateSecureContext: TLS.validateSecureContext, buildSecureContext: TLS.buildSecureContext, buildLocalSSL: TLS.buildLocalSSL, createSslServer: TLS.createSslServer, sslRedirectMiddleware: TLS.sslRedirectMiddleware, }; } export { TLS, SSL_BASE, UnderpostTLS }; export default UnderpostTLS;