refactor: tls/https/acme options redesigned

refactor: modular TLS SNI loading mechanisms (file, api WIP)
deprecate: segfault-handler
feat: run workers with --heapsnapshot-signal SIGSEGV
feat: TLSv1.3 and TLSv1.2 session resumption
parent 63b36941
Pipeline #58612387 failed with stage
in 2 minutes and 28 seconds
......@@ -16,8 +16,6 @@ Settings related to the [Automated Certificate Management Environment (ACME)](ht
When a certificate is requested for a domain using the ACME HTTP challenge, the certificate authority (i.e. LetsEncrypt's [Boulder](https://github.com/letsencrypt/boulder) server) resolves the given DNS hostname and makes an HTTP request. The webserver needs to respond with values only known by the entity that requested the certificate. In a simple case of one webserver this can be handled by serving a local directory through `acme.webroot`. But when DNS records point to multiple edge servers, it is necessary to relay or deflect the challenge request, via `acme.proxy` or `acme.redirect` respectively, to the certificate requesting entity.
Any domains that do not have a certificate, as per `acme.key` and `acme.cert` lookups, are served using the fallback certificate in `https.key` and `https.cert`.
### `acme.proxy`
Default: `''`
......@@ -48,7 +46,7 @@ Redirecting is light on the edge server, but requires a second connection from B
Both HTTP and HTTPS can be used, depending on the scheme, i.e. `http:` or `https:` respectively. Note that Boulder, the LetsEncrypt reference server, is currently restricted to ports `80` and `443`. Redirecting to any other ports is not supported, and should be handled through `acme.proxy`.
This string is prepended to the challenge request's URL path. Omit any trailing shash to avoid duplication.
This string is prepended to the challenge request's URL path. Omit any trailing slash to avoid duplication.
Example:
......@@ -74,58 +72,6 @@ If a valid path is specified, the server responds to challenges by mapping to st
}
```
### `acme.store`
Default: `''`
Directory where keys and certificates are stored.
Example:
```js
{
acme: {
webroot: '/etc/ssl'
}
}
```
### `acme.key`
Default: `'$store/$domain/key.pem'`
Path to the domain-specific key file.
May contain substitution variables `$store` and `$domain`.
Example:
```js
{
acme: {
key: '$store/sites/$domain/crypto/key.pem'
}
}
```
### `acme.cert`
Default: `'$store/$domain/cert.pem'`
Path to the domain-specific certificate file.
May contain substitution variables `$store` and `$domain`.
Example:
```js
{
acme: {
cert: '$store/sites/$domain/crypto/cert.pem'
}
}
```
## `doh`
Default: `false`
......@@ -275,31 +221,7 @@ Duration, in milliseconds as a number, after which idle HTTP/2 sessions are clos
## `https`
Settings for the fallback TLS context that is used if no files match `acme.key` and `acme.cert`.
Useful when serving subdomains using a wildcard certificate.
If no fallback exists on launch, a self-signed certificate and key pair is generated using [tls-keygen](https://www.npmjs.com/package/tls-keygen). The certificate is attempted to be added to to the operating system trusted store, which may require user confirmation and entry of their password. This is only done in CLI mode, or if the `generateCertificate` API argument is `true`; otherwise the server simply fails to launch.
See the corresponding options of [tls.createSecureContext](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options).
### `https.key`
Default: `'~/.commonshost/key.pem'`
A string of the path to a PEM file containing the secret key.
### `https.cert`
Default: `'~/.commonshost/cert.pem'`
A string of the path to a PEM file containing the public certificate.
### `https.ca`
Default: `[]`
An array of strings containing the path to all files in the certificate chain.
Settings for HTTP over TLS.
### `https.port`
......@@ -339,6 +261,126 @@ Set to `0` to disable the push diary.
The number of items to allocate space for in the server push diary.
## `sni`
Processing the Server Name Indication (SNI) domain name during the TLS handshake. The `domain` value is used by the `tls.loader` to retrieve TLS credentials.
### `sni.fallbackDomain`
Any invalid or missing SNI domain names are mapped to this fallback domain name.
Example: Use `localhost` as the default domain name.
```js
{
sni: {
fallbackDomain: 'example.com'
}
}
```
### `sni.wildcards`
Array of objects mapping a domain name `suffix` to a `domain`. Used to look up a wildcard certificate for multiple subdomains.
Example: Look up the `*.commons.host` (including the asterisk symbol) domain for any sub-domains that end in `.commons.host`.
```js
{
sni: {
wildcards: [
{ suffix: '.commons.host',
domain: '*.commons.host' }
]
}
}
```
## `tls`
Dynamic retrieval of TLS credentials, i.e. public certificates, private keys, and certificate authority chains.
The domain name is provided by the SNI extension of TLS or a default fallback domain, as described in the `sni` settings.
### `loader`
The method to look up TLS credentials for a domain. Possible values:
- `api`: Use the Commons Host Core API. Used when operating as a CDN.
- `file`: Look up using a file path. Used in local development.
#### `loader: 'api'`
*Not yet implemented.*
#### `loader: 'file'`
Looks up files on the local file system based on templated file path strings and a file store path.
Example: Use default credentials if no ACME (e.g. Let's Encrypt) credentials are found.
```js
{
tls: {
loader: 'file',
fallbackCa: [
'/etc/tls/default/ca.intermediary.pem',
'/etc/tls/default/ca.root.pem'
],
fallbackCert: '/etc/tls/default/cert.pem',
fallbackKey: '/etc/tls/default/key.pem',
storePath: '/var/acme/certs',
keyPath: '$store/$domain/key.pem',
certPath: '$store/$domain/cert.pem'
}
}
```
Any domains that do not have TLS credentials, as per `tls.keyPath` and `tls.certPath` lookups, are served using the fallback credentials in `tls.fallbackKey` and `tls.fallbackCert`.
If no fallback exists on launch, a self-signed certificate and key pair is generated using [tls-keygen](https://www.npmjs.com/package/tls-keygen). The key and certificate are stored in the current user's home directory, under the `~/.commonshost` hidden directory. The certificate is attempted to be added to to the operating system trusted store, which may require user confirmation and entry of their password. This is only done in CLI mode, or if the `generateCertificate` API argument is `true`; otherwise the server process exits.
### `tls.fallbackKey`
Default: `'~/.commonshost/key.pem'`
A string of the path to a PEM file containing the secret key.
### `tls.fallbackCert`
Default: `'~/.commonshost/cert.pem'`
A string of the path to a PEM file containing the public certificate.
### `tls.fallbackCa`
Default: `[]`
An array of strings containing the path to all files in the certificate chain.
### `tls.storePath`
Default: `''`
Directory where keys and certificates are stored.
### `tls.keyPath`
Default: `'$store/$domain/key.pem'`
Path to the domain-specific key file.
May contain substitution variables `$store` and `$domain`.
### `tls.certPath`
Default: `'$store/$domain/cert.pem'`
Path to the domain-specific certificate file.
May contain substitution variables `$store` and `$domain`.
## `via`
The `Via` HTTP header is used to detect, and break out of, CDN loops. This can happen due to chained CDNs or misconfigured DNS.
......
......@@ -6,9 +6,6 @@ module.exports.defaultOptions = {
acme: {
proxy: '',
redirect: '',
store: '',
key: '$store/$domain/key.pem',
cert: '$store/$domain/cert.pem',
webroot: ''
},
doh: false,
......@@ -32,15 +29,19 @@ module.exports.defaultOptions = {
timeout: null
},
https: {
port: 8443,
key: '',
cert: '',
ca: []
port: 8443
},
push: {
diaryBitsPerItem: 12,
diaryTotalItems: 1024
},
sni: {
fallbackDomain: '',
wildcards: []
},
tls: {
loader: 'file'
},
www: {
redirect: false
},
......
......@@ -54,6 +54,17 @@ async function load ({
const result = defaultsDeep(userOptions, defaultOptions)
if (result.tls.loader === 'file') {
result.tls = defaultsDeep(result.tls, {
certPath: '$store/$domain/cert.pem',
keyPath: '$store/$domain/key.pem',
storePath: '',
fallbackCa: [],
fallbackCert: '',
fallbackKey: ''
})
}
if (serveDefaultSite === true && result.hosts.length === 0) {
const host = await normaliseHost()
if (defaultManifest) {
......@@ -112,8 +123,8 @@ async function load ({
result.acme.webroot = resolve(cwd, result.acme.webroot)
}
if (result.acme.store !== '') {
result.acme.store = resolve(cwd, result.acme.store)
if (result.tls.storePath !== '') {
result.tls.storePath = resolve(cwd, result.tls.storePath)
}
if (result.placeholder.hostNotFound !== '') {
......@@ -121,20 +132,20 @@ async function load ({
resolve(cwd, result.placeholder.hostNotFound)
}
if (result.https.key === '' && result.https.cert === '') {
if (result.tls.fallbackKey === '' && result.tls.fallbackCert === '') {
const key = join(userHome, '.commonshost/key.pem')
const cert = join(userHome, '.commonshost/cert.pem')
if (fileExists(key) && fileExists(cert)) {
result.https.key = key
result.https.cert = cert
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else if (generateCertificate === true) {
log.info(
'🔐 Generating a private TLS certificate.\n' +
' Confirm to add as a trusted certificate to your key chain.'
)
await keygen({ key, cert })
result.https.key = key
result.https.cert = cert
result.tls.fallbackKey = key
result.tls.fallbackCert = cert
} else {
throw new Error('Missing a private key & public certificate pair.')
}
......
const isDomainName = require('is-domain-name')
const isIp = require('is-ip')
const { tlsSniApiWorker } = require('./tlsSniApiWorker')
const { tlsSniFile } = require('./tlsSniFile')
function getCommonname (servername, options) {
if (
typeof servername !== 'string' ||
servername.includes('..') ||
!isDomainName(servername) ||
isIp(servername)
) {
return options.sni.fallbackDomain
}
for (const { suffix, name } of options.sni.wildcards) {
if (servername.endsWith(suffix)) {
return name
}
}
return servername
}
function getLoader (loader) {
switch (loader) {
case 'api':
return tlsSniApiWorker
case 'file':
default:
return tlsSniFile
}
}
module.exports.SNICallback =
function SNICallback (options) {
const getSecureContext = getLoader(options.tls.loader)(options)
return async function SNICallback (servername, callback) {
const commonname = getCommonname(servername, options)
let secureContext
try {
secureContext = await getSecureContext(commonname)
} catch (error) {
return callback(error)
}
return callback(null, secureContext)
}
}
const { resolve } = require('path')
const { promisify } = require('util')
const { readFile } = require('fs')
const ocsp = require('ocsp')
const { createSecureContext } = require('tls')
const isDomainName = require('is-domain-name')
const isIp = require('is-ip')
const { stringTemplate } = require('./stringTemplate')
const ecdhCurve = 'P-384:P-256'
async function read (filepath) {
const resolved = resolve(process.cwd(), filepath)
return promisify(readFile)(resolved)
}
let fallbackContext
async function fallbackSecureContext (options) {
if (!fallbackContext) {
const [key, cert, ...ca] = await Promise.all([
read(options.https.key),
read(options.https.cert),
...options.https.ca.filter(String).map(read)
])
fallbackContext = createSecureContext({ ecdhCurve, key, cert, ca })
}
return fallbackContext
}
const acmeCache = new Map()
module.exports.SNICallback =
function SNICallback (options) {
return async function SNICallback (servername, callback) {
if (acmeCache.has(servername)) {
const { key, cert } = acmeCache.get(servername)
const contextOptions = { ecdhCurve, key, cert }
return callback(null, createSecureContext(contextOptions))
}
if (
servername.includes('..') ||
!isDomainName(servername) ||
isIp(servername)
) {
return callback(null, await fallbackSecureContext(options))
}
try {
const parameters = { store: options.acme.store, domain: servername }
const keyPath = stringTemplate(options.acme.key, parameters)
const certPath = stringTemplate(options.acme.cert, parameters)
const [key, cert] = await Promise.all([
read(resolve(options.acme.store, keyPath)),
read(resolve(options.acme.store, certPath))
])
const contextOptions = { ecdhCurve, key, cert }
const context = createSecureContext(contextOptions)
acmeCache.set(servername, { key, cert })
return callback(null, context)
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(error)
}
return callback(null, await fallbackSecureContext(options))
}
}
}
process.on('message', ({ type, domain }) => {
if (type === 'certificate-issue' ||
type === 'certificate-revoke' ||
type === 'site-delete'
) {
if (acmeCache.has(domain)) {
acmeCache.delete(domain)
}
}
})
const ocspCache = new ocsp.Cache()
module.exports.OCSPRequestHandler =
function OCSPRequestHandler (certificate, issuer, callback) {
if (!issuer) return callback()
ocsp.getOCSPURI(certificate, (error, uri) => {
if (error) return callback(error)
if (uri === null) return callback()
const request = ocsp.request.generate(certificate, issuer)
ocspCache.probe(request.id, (error, { response }) => {
if (error) return callback(error)
if (response) return callback(null, response)
const options = {
url: uri,
ocsp: request.data
}
ocspCache.request(request.id, options, callback)
})
})
}
module.exports.tlsSniApiMaster =
async function tlsSniApiMaster (message, worker, options) {
const { servername } = message
try {
// TODO: Retrieve key & cert from Core API
const key = undefined
const cert = undefined
worker.send({ type: 'tls-credentials-response', servername, key, cert })
} catch (error) {
console.error(error)
worker.send({ type: 'tls-credentials-response', servername, error })
}
}
const { createSecureContext } = require('tls')
const LRU = require('lru-cache')
module.exports.tlsSniApiWorker = (options) => {
const tlsCache = new LRU({ max: 100 })
const tlsRequests = new Map()
process.on('message', ({ type, domain }) => {
if (type === 'certificate-issue' ||
type === 'certificate-revoke' ||
type === 'site-delete'
) {
tlsCache.del(domain)
}
})
function deserialiseBuffer (raw) {
if (!raw || raw instanceof Buffer) return raw
if (raw.type === 'Buffer') return Buffer.from(raw.data)
if (Array.isArray(raw)) return raw.map(deserialiseBuffer)
}
return async function getSecureContext (servername) {
let secureContext = tlsCache.get(servername)
if (!secureContext) {
if (tlsRequests.has(servername)) {
return tlsRequests.get(servername)
} else {
const lookup = new Promise((resolve, reject) => {
process.on('message', function onMessage (message) {
if (
message.type === 'tls-credentials-response' &&
message.servername === servername
) {
if ('error' in message) {
reject(message.error)
} else {
process.off('message', onMessage)
tlsRequests.delete(servername)
const secureContext = createSecureContext({
key: deserialiseBuffer(message.key),
cert: deserialiseBuffer(message.cert),
ca: deserialiseBuffer(message.ca)
})
tlsCache.set(servername, secureContext)
resolve(secureContext)
}
}
})
})
process.send({ type: 'tls-credentials-request', servername })
tlsRequests.set(servername, lookup)
return lookup
}
}
return secureContext
}
}
const { createSecureContext } = require('tls')
const LRU = require('lru-cache')
const { resolve } = require('path')
const { promises: { readFile } } = require('fs')
const { stringTemplate } = require('./stringTemplate')
require('tls').DEFAULT_ECDH_CURVE = 'P-521:P-384:P-256'
const tlsCache = new LRU({ max: 100 })
module.exports.tlsSniFile = (options) => {
return async function getSecureContext (servername) {
let secureContext = tlsCache.get(servername)
if (!secureContext) {
try {
if (!servername) throw new Error('Missing SNI servername')
const parameters = { store: options.tls.storePath, domain: servername }
const keyPath = stringTemplate(options.tls.keyPath, parameters)
const certPath = stringTemplate(options.tls.certPath, parameters)
const [key, cert] = await Promise.all([
readFile(resolve(options.tls.storePath, keyPath)),
readFile(resolve(options.tls.storePath, certPath))
])
secureContext = createSecureContext({ key, cert })
} catch (error) {
try {
const [key, cert, ca] = await Promise.all([
readFile(options.tls.fallbackKey),
readFile(options.tls.fallbackCert),
options.tls.fallbackCa.filter(String).map(readFile)
])
const ecdhCurve = 'P-521:P-384:P-256'
secureContext = createSecureContext({ key, cert, ca, ecdhCurve })
} catch (error) {
throw error
}
}
tlsCache.set(servername, secureContext)
}
return secureContext
}
}
......@@ -11,8 +11,10 @@ const { promisify } = require('util')
const pino = require('pino')
const { normaliseHost } = require('@commonshost/configuration')
const { Gopher } = require('./gopher')
const { SNICallback, OCSPRequestHandler } = require('./helpers/tls')
const { SNICallback } = require('./helpers/SNICallback')
const { OCSPRequestHandler } = require('./helpers/ocsp')
const { randomBytes } = require('crypto')
const { tlsSniApiMaster } = require('./helpers/tlsSniApiMaster')
const timeout = (time) => new Promise((resolve) => setTimeout(resolve, time))
......@@ -190,10 +192,21 @@ module.exports.Master = class Master {
if (maxWorkerCount < 1) {
throw new Error(`Workers count ${maxWorkerCount} must be at least 1`)
}
cluster.setupMaster({ exec: require.resolve('./worker.js') })
cluster.setupMaster({
args: [
'--heapsnapshot-signal', 'SIGSEGV',
...process.argv.slice(2)
],
exec: require.resolve('./worker.js')
})
while (workers.length < maxWorkerCount) {
const worker = cluster.fork()
workers.push(worker)
worker.on('message', async (message) => {
if (message.type === 'tls-credentials-request') {
await tlsSniApiMaster(message, worker, this.options)
}
})
worker.once('exit', (code, signal) => {
if (code) {
this.log.error(`Worker exit: code ${code}, signal ${signal}`)
......@@ -216,7 +229,7 @@ module.exports.Master = class Master {
this.workersKeepalive = setInterval(() => {
for (const worker of this.workers) {
worker.send({ type: 'ping' })
worker.send({ type: 'keepalive' })
}
}, 10 * 1000)
......
const { app } = require('./app')
const { createSecureServer } = require('http2')
const { SNICallback } = require('./helpers/tls')
const { SNICallback } = require('./helpers/SNICallback')
const { OCSPRequestHandler } = require('./helpers/ocsp')
module.exports.server = (ticketKeys, options, files) => {
......
require('hard-rejection/register')
const { server } = require('./server')
const segfaultHandler = require('segfault-handler')
segfaultHandler.registerHandler()
process.title = 'commonshost-worker'
function onMessage ({ type, ticketKeys, options, files }) {
......
......@@ -6,8 +6,10 @@ const { Agent, request } = require('https')
const { StringDecoder } = require('string_decoder')
const concat = require('concat-stream')
const { PassThrough: identity } = require('stream')
const { createGunzip: gzip } = require('zlib')
const { decompressStream: brotli } = require('iltorb')
const {
createGunzip: gzip,
createBrotliDecompress: brotli
} = require('zlib')
let master
test('start server', async (t) => {
......@@ -72,7 +74,7 @@ const fixtures = [
payload: 0
},
payload: '',
decoder: brotli
decoder: identity
},
{
label: 'server prefers Brotli encoding',
......
......@@ -6,11 +6,14 @@ module.exports = {
},
acme: {
redirect: 'http://localhost:12345/',
store: join(__dirname, 'acme/store'),
key: '$store/$domain/key.pem',
cert: '$store/$domain/key.pem',
webroot: join(__dirname, 'acme/webroot')
},