@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.)
239 lines (202 loc) • 8.99 kB
JavaScript
/**
* Global configuration class. Use initialise() method to populate.
*
* @module lib/Configuration
* @exports Configuration
* @copyright © 2020 Aral Balkan, Small Technology Foundation.
* @license AGPLv3 or later.
*/
import os from 'os'
import fs from 'fs'
import path from 'path'
import util from 'util'
import crypto from 'crypto'
import log from './util/log.js'
import Throws from './util/Throws.js'
// Custom errors thrown by this class.
const throws = new Throws({
[Symbol.for('Configuration.domainsArrayIsNotAnArrayOfStringsError')]:
() => 'Domains array must be an array of strings'
})
function isAnArrayOfStrings (object) {
const containsOnlyStrings = arrayOfStrings => arrayOfStrings.length !== 0 && arrayOfStrings.filter(s => typeof s === 'string').length === arrayOfStrings.length
return Array.isArray(object) && containsOnlyStrings(object)
}
function ensureDirSync (directory) {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true })
}
}
/**
* @alias module:lib/Configuration
* @hideconstructor
*/
export default class Configuration {
#server = null
#domains = null
#settingsPath = null
#accountPath = null
#accountIdentityPath = null
#certificatePath = null
#certificateDirectoryPath = null
#certificateIdentityPath = null
/**
* Initialise the configuration. Must be called before accessing settings. May be called more than once.
*
* @param {Object} settings Settings to initialise configuration with.
* @param {String[]} settings.domains List of domains Auto Encrypt will manage TLS certs for.
* @param {LetsEncryptServer} settings.server Let’s Encrypt Server to use.
* @param {String} settings.settingsPath Root settings path to use. Will use default path if null.
*/
constructor (settings = throws.ifMissing()) {
// Additional argument validation.
throws.ifUndefinedOrNull(settings.server, 'settings.server')
throws.ifUndefined(settings.settingsPath, 'settings.settingsPath')
throws.if(!isAnArrayOfStrings(settings.domains), Symbol.for('Configuration.domainsArrayIsNotAnArrayOfStringsError'))
if (!util.inspect(settings.server).includes('Let’s Encrypt Server (instance)')) {
throws.error(Symbol.for('ArgumentError'), 'settings.server', 'must be an instance of LetsEncryptServer but isn’t')
}
this.#server = settings.server
this.#domains = settings.domains
const defaultPathFor = serverName => path.join(os.homedir(), '.small-tech.org', 'auto-encrypt', serverName)
if (settings.settingsPath === null) {
// Use the correct default path based on staging state.
this.#settingsPath = defaultPathFor(this.#server.name)
} else {
// Create the path to use based on the custom root path we’ve been passed.
this.#settingsPath = path.join(settings.settingsPath, this.#server.name)
}
// And ensure that the settings path exists in the file system.
ensureDirSync(this.#settingsPath)
//
// Create account paths.
//
this.#accountPath = path.join(this.#settingsPath, 'account.json')
this.#accountIdentityPath = path.join(this.#settingsPath, 'account-identity.pem')
//
// Create certificate paths.
//
// The naming of the certificate directory aims to strike a balance between readability and uniqueness.
// For details, see https://source.small-tech.org/site.js/lib/auto-encrypt/issues/3
const certificateDirectoryName = (domains => {
if (domains.length === 1) {
return domains[0] // e.g., ar.al
} else if (domains.length >= 2 && domains.length <= 4) {
// e.g., ar.al--www.ar.al--2018.ar.al--and--aralbalkan.com
return domains.slice(0,domains.length-1).join('--').concat(`--and--${domains[domains.length-1]}`)
} else {
// For more than 5 domains, show the first two, followed by the number of domains, and a
// hash to ensure that the directory name is unique.
// e.g., ar.al--www.ar.al--and--4--others--9364bd18ea526b3462e4cebc2a6d97c2ffd3b5312ef7b8bb6165c66a37975e46
const hashOf = arrayOfStrings => crypto.createHash('blake2s256').update(arrayOfStrings.join('--')).digest('hex')
return domains.slice(0, 2).join('--').concat(`--and--${domains.length-2}--others--${hashOf(domains)}`)
}
})(this.#domains)
this.#certificateDirectoryPath = path.join(this.#settingsPath, certificateDirectoryName)
// And ensure that the certificate directory path exists in the file system.
ensureDirSync(this.#certificateDirectoryPath)
this.#certificatePath = path.join(this.#certificateDirectoryPath, 'certificate.pem')
this.#certificateIdentityPath = path.join(this.#certificateDirectoryPath, 'certificate-identity.pem')
log(' ⚙️ ❨auto-encrypt❩ Configuration initialised.')
}
//
// Accessors.
//
/**
* The Let’s Encrypt Server instance.
*
* @type {LetsEncryptServer}
* @readonly
*/
get server () {
return this.#server
}
/**
* List of domains that Auto Encrypt will manage TLS certificates for.
*
* @type {String[]}
* @readonly
*/
get domains () {
return this.#domains
}
/**
* The root settings path. There is a different root settings path for pebble, staging and production modes.
*
* @type {String}
* @readonly
*/
get settingsPath () {
return this.#settingsPath
}
/**
* Path to the account.json file that contains the Key Id that uniquely identifies and authorises your account
* in the absence of a JWT (see RFC 8555 § 6.2. Request Authentication).
*
* @type {String}
* @readonly
*/
get accountPath () {
return this.#accountPath
}
/**
* The path to the account-identity.pem file that contains the private key for the account.
*
* @type {String}
* @readonly
*/
get accountIdentityPath () { return this.#accountIdentityPath }
/**
* The path to the certificate.pem file that contains the certificate chain provisioned from Let’s Encrypt.
*
* @type {String}
* @readonly
*/
get certificatePath () { return this.#certificatePath }
/**
* The directory the certificate and certificate identity (private key) PEM files are stored in.
*
* @type {String}
* @readonly
*/
get certificateDirectoryPath () { return this.#certificateDirectoryPath }
/**
* The path to the certificate-identity.pem file that holds the private key for the TLS certificate.
*
* @type {String}
* @readonly
*/
get certificateIdentityPath () { return this.#certificateIdentityPath }
//
// Enforce read-only access.
//
set server (state) { this.throwReadOnlyAccessorError('server') }
set domains (state) { this.throwReadOnlyAccessorError('domains') }
set settingsPath (state) { this.throwReadOnlyAccessorError('settingsPath') }
set accountPath (state) { this.throwReadOnlyAccessorError('accountPath') }
set accountIdentityPath (state) { this.throwReadOnlyAccessorError('accountIdentityPath') }
set certificatePath (state) { this.throwReadOnlyAccessorError('certificatePath') }
set certificateDirectoryPath (state) { this.throwReadOnlyAccessorError('certificateDirectoryPath') }
set certificateIdentityPath (state) { this.throwReadOnlyAccessorError('certificateIdentityPath') }
throwReadOnlyAccessorError (setterName) {
throws.error(Symbol.for('ReadOnlyAccessorError'), setterName, 'All configuration accessors are read-only.')
}
// Custom object description for console output (for debugging).
[util.inspect.custom] () {
return `
# Configuration (class)
A single location for shared configuration.
Using ${this.server.name} server paths.
Property Description Value
-------------------------- --------------------------------------- ---------------------------------------
.server : Lets Encrypt Server details {name: '${this.server.name}', endpoint: '${this.server.endpoint}}'
.domains : Domains in certificate ${this.domains.join(', ')}
.settingsPath : Top-level settings path ${this.settingsPath}
.accountPath : Path to LE account details JSON file ${this.accountPath}
.accountIdentityPath : Path to private key for LE account ${this.accountIdentityPath}
.certificateDirectoryPath: Path to certificate directory ${this.certificateDirectoryPath}
.certificatePath : Path to certificate file ${this.certificatePath}
.certificateIdentityPath : Path to private key for certificate ${this.certificateIdentityPath}
`
}
}