UNPKG

@small-tech/auto-encrypt

Version:

Automatically provisions and renews Let’s Encrypt TLS certificates on Node.js https servers (including Kitten, Polka, Express.js, etc.)

315 lines (269 loc) 12.2 kB
/** * Automatically provisions and renews Let’s Encrypt™ TLS certificates for * Node.js® https servers (including Express.js, etc.) * * Implements the subset of RFC 8555 – Automatic Certificate Management * Environment (ACME) – necessary for a Node.js https server to provision TLS * certificates from Let’s Encrypt using the HTTP-01 challenge on first * hit of an HTTPS route via use of the Server Name Indication (SNI) callback. * * @module @small-tech/auto-encrypt * @copyright © 2020 Aral Balkan, Small Technology Foundation. * @license AGPLv3 or later. */ import os from 'os' import util from 'util' import https from 'https' import ocsp from 'ocsp' import monkeyPatchTls from './lib/staging/monkeyPatchTls.js' import LetsEncryptServer from './lib/LetsEncryptServer.js' import Configuration from './lib/Configuration.js' import Certificate from './lib/Certificate.js' import Pluralise from './lib/util/Pluralise.js' import Throws from './lib/util/Throws.js' import HttpServer from './lib/HttpServer.js' import log from './lib/util/log.js' // This reverts IP address sort order to pre-Node 17 behaviour. // See https://github.com/nodejs/node/issues/40537 import dns from 'node:dns' dns.setDefaultResultOrder('ipv4first') // Custom errors thrown by the autoEncrypt function. const throws = new Throws({ [Symbol.for('BusyProvisioningCertificateError')]: () => 'We’re busy provisioning TLS certificates and rejecting all other calls at the moment.', [Symbol.for('SNIIgnoreUnsupportedDomainError')]: (serverName, domains) => { return `SNI: Not responding to request for unsupported domain ${serverName} (valid ${Pluralise.word('domain', domains)} ${Pluralise.isAre(domains)} ${domains}).` } }) /** * Auto Encrypt is a static class. Please do not instantiate. * * Use: AutoEncrypt.https.createServer(…) * * @alias module:@small-tech/auto-encrypt * @hideconstructor */ export default class AutoEncrypt { static letsEncryptServer = null static defaultDomains = null static domains = null static settingsPath = null static listener = null static certificate = null /** * Enumeration. * * @type {LetsEncryptServer.type} * @readonly * @static */ static serverType = LetsEncryptServer.type /** * By aliasing the https property to the AutoEncrypt static class itself, we enable * people to add AutoEncrypt to their existing apps by requiring the module * and prefixing their https.createServer(…) line with AutoEncrypt: * * @example import AutoEncrypt from '@small-tech/auto-encrypt' * const server = AutoEncrypt.https.createServer() * * @static */ static get https () { return AutoEncrypt } static ocspCache = null /** * Automatically manages Let’s Encrypt certificate provisioning and renewal for Node.js * https servers using the HTTP-01 challenge on first hit of an HTTPS route via use of * the Server Name Indication (SNI) callback. * * @static * @param {Object} [options] Optional HTTPS options object with optional additional * Auto Encrypt-specific configuration settings. * @param {String[]} [options.domains] Domain names to provision TLS certificates for. If missing, defaults to * the hostname of the current computer and its www prefixed subdomain. * @param {Enum} [options.serverType=AutoEncrypt.serverType.PRODUCTION] Let’s Encrypt server type to use. * AutoEncrypt.serverType.PRODUCTION, ….STAGING, * or ….PEBBLE (see LetsEncryptServer.type). * @param {String} [options.settingsPath=~/.small-tech.org/auto-encrypt/] Path to save certificates/keys to. * * @returns {https.Server} The server instance returned by Node’s https.createServer() method. */ static createServer(_options, _listener) { // The first parameter is optional. If omitted, the first argument, if any, is treated as the request listener. if (typeof _options === 'function') { _listener = _options _options = {} } const defaultStagingAndProductionDomains = [os.hostname(), `www.${os.hostname()}`] const defaultPebbleDomains = ['localhost', 'pebble'] const options = _options || {} const letsEncryptServer = new LetsEncryptServer(options.serverType || LetsEncryptServer.type.PRODUCTION) const listener = _listener || null const settingsPath = options.settingsPath || null // // Ignore passed domains (if any) if we’re using pebble as we can only issue for localhost and pebble. // let defaultDomains = defaultStagingAndProductionDomains switch (letsEncryptServer.type) { case LetsEncryptServer.type.PEBBLE: options.domains = null defaultDomains = defaultPebbleDomains break // If this is a staging server, we add the intermediary certificate to Node.js’s trust store (only valid during // the current Node.js process) so that Node will accept the certificate. Useful when running tests against the // staging server. // // If you’re using Pebble for your tests, please install and use node-pebble manually in your tests. // (We cannot automatically provide support for Pebble as it dynamically generates its root and // intermediary CA certificates, which is an asynchronous process whereas the createServer method is // synchronous.)* // // * Yes, we could check for and start the Pebble server in the asynchronous SNICallback, below, but given how // often that function is called, I will not add anything to it beyond the essentials for performance reasons. case LetsEncryptServer.type.STAGING: monkeyPatchTls() break } const domains = options.domains || defaultDomains // Delete the Auto Encrypt-specific properties from the options object to not pollute the namespace. delete options.domains delete options.serverType delete options.settingsPath const configuration = new Configuration({ settingsPath, domains, server: letsEncryptServer}) const certificate = new Certificate(configuration) this.letsEncryptServer = letsEncryptServer this.defaultDomains = defaultDomains this.domains = domains this.settingsPath = settingsPath this.listener = listener this.certificate = certificate function sniError (symbolName, callback, emoji, ...args) { const error = Symbol.for(symbolName) log(` ${emoji} ❨auto-encrypt❩ ${throws.errors[error](...args)}`) callback(throws.createError(error, ...args)) } options.SNICallback = async (serverName, callback) => { if (domains.includes(serverName)) { const secureContext = await certificate.getSecureContext() if (secureContext === null) { sniError('BusyProvisioningCertificateError', callback, '⏳') return } callback(null, secureContext) } else { sniError('SNIIgnoreUnsupportedDomainError', callback, '🤨', serverName, domains) } } const server = this.addOcspStapling(https.createServer(options, listener)) // // Monkey-patch the server. // server.__autoEncrypt__self = this // Monkey-patch the server’s listen method so that we can start up the HTTP // Server at the same time. server.__autoEncrypt__originalListen = server.listen server.listen = function(...args) { // Start the HTTP server. HttpServer.getSharedInstance().then(() => { // Start the HTTPS server. return this.__autoEncrypt__originalListen.apply(this, args) }) } // Monkey-patch the server’s close method so that we can perform clean-up and // also shut down the HTTP server transparently when server.close() is called. server.__autoEncrypt__originalClose = server.close server.close = function (...args) { // Clean-up our own house. this.__autoEncrypt__self.shutdown() // Shut down the HTTP server. HttpServer.destroySharedInstance().then(() => { // Shut down the HTTPS server. return this.__autoEncrypt__originalClose.apply(this, args) }) } return server } /** * The OCSP module does not have a means of clearing its cache check timers * so we do it here. (Otherwise, the test suite would hang.) */ static clearOcspCacheTimers () { if (this.ocspCache !== null) { const cacheIds = Object.keys(this.ocspCache.cache) cacheIds.forEach(cacheId => { clearInterval(this.ocspCache.cache[cacheId].timer) }) } } /** * Shut Auto Encrypt down. Do this before app exit. Performs necessary clean-up and removes * any references that might cause the app to not exit. */ static shutdown () { this.clearOcspCacheTimers() this.certificate.stopCheckingForRenewal() } // // Private. // /** * Adds Online Certificate Status Protocol (OCSP) stapling (also known as TLS Certificate Status Request extension) * support to the passed server instance. * * @private * @param {https.Server} server HTTPS server instance without OCSP Stapling support. * @returns {https.Server} HTTPS server instance with OCSP Stapling support. */ static addOcspStapling(server) { // OCSP stapling // // Many browsers will fetch OCSP from Let’s Encrypt when they load your site. This is a performance and privacy // problem. Ideally, connections to your site should not wait for a secondary connection to Let’s Encrypt. Also, // OCSP requests tell Let’s Encrypt which sites people are visiting. We have a good privacy policy and do not record // individually identifying details from OCSP requests, we’d rather not even receive the data in the first place. // Additionally, we anticipate our bandwidth costs for serving OCSP every time a browser visits a Let’s Encrypt site // for the first time will be a big part of our infrastructure expense. // // By turning on OCSP Stapling, you can improve the performance of your website, provide better privacy protections // … and help Let’s Encrypt efficiently serve as many people as possible. // // (Source: https://letsencrypt.org/docs/integration-guide/implement-ocsp-stapling) this.ocspCache = new ocsp.Cache() const cache = this.ocspCache server.on('OCSPRequest', (certificate, issuer, callback) => { if (certificate == null) { return callback(new Error('Cannot OCSP staple: certificate not yet provisioned.')) } ocsp.getOCSPURI(certificate, function(error, uri) { if (error) return callback(error) if (uri === null) return callback() const request = ocsp.request.generate(certificate, issuer) cache.probe(request.id, (error, cached) => { if (error) return callback(error) if (cached !== false) { return callback(null, cached.response) } const options = { url: uri, ocsp: request.data } cache.request(request.id, options, callback); }) }) }) return server } // Custom object description for console output (for debugging). static [util.inspect.custom] () { return ` # AutoEncrypt (static class) - Using Let’s Encrypt ${this.letsEncryptServer.name} server. - Managing TLS for ${this.domains.toString().replace(',', ', ')}${this.domains === this.defaultDomains ? ' (default domains)' : ''}. - Settings stored at ${this.settingsPath === null ? 'default settings path' : this.settingsPath}. - Listener ${typeof this.listener === 'function' ? 'is set' : 'not set'}. ` } constructor () { throws.error(Symbol.for('StaticClassCannotBeInstantiatedError')) } }