feat: shared TLS ticket keys across workers

fix: rotate TLS ticket keys hourly
fix: reduce TLS handshake timeout to deter slow loris
feat: keepalive workers IPC to detect crashed workers
feat: exit master process if any worker crashes
parent 6327a246
Pipeline #57896801 passed with stage
in 2 minutes and 44 seconds
......@@ -12,6 +12,7 @@ const pino = require('pino')
const { normaliseHost } = require('@commonshost/configuration')
const { Gopher } = require('./gopher')
const { SNICallback, OCSPRequestHandler } = require('./helpers/tls')
const { randomBytes } = require('crypto')
const timeout = (time) => new Promise((resolve) => setTimeout(resolve, time))
......@@ -28,6 +29,8 @@ module.exports.Master = class Master {
this._options = options
this.workers = []
this.watchers = new Map()
this.workersKeepalive = undefined
this.refreshTicketKeys = undefined
}
async close () {
......@@ -37,6 +40,8 @@ module.exports.Master = class Master {
if (this.gopher) {
await this.gopher.stop()
}
clearInterval(this.workersKeepalive)
clearInterval(this.refreshTicketKeys)
for (const worker of this.workers) {
worker.kill()
await eventToPromise(worker, 'exit')
......@@ -197,21 +202,38 @@ module.exports.Master = class Master {
})
}
await Promise.all(workers.map((worker) => eventToPromise(worker, 'online')))
const message = {
type: 'start',
files,
options: this.options,
ticketKeys: randomBytes(48).toString('hex')
}
for (const worker of workers) {
const message = { type: 'start', files, options: this.options }
worker.send(message)
}
await Promise.all(workers.map((worker) => eventToPromise(worker, 'listening')))
this.workers = workers
const keepaliveTty = setInterval(() => console.log(''), 20000) // Placebo to pass CI
this.workersKeepalive = setInterval(() => {
for (const worker of this.workers) {
worker.send({ type: 'ping' })
}
}, 10 * 1000)
this.refreshTicketKeys = setInterval(() => {
const message = {
type: 'ticket-keys-refresh',
ticketKeys: randomBytes(48).toString('hex')
}
for (const worker of this.workers) {
worker.send(message)
}
}, 60 * 60 * 1000)
for (const { root, domain } of this.options.hosts) {
const authority = this.options.https.port === 443
? domain : `${domain}:${this.options.https.port}`
this.log.info(`Serving ${root} on https://${authority}`)
}
clearInterval(keepaliveTty) // Placebo to pass CI
}
}
......@@ -2,13 +2,25 @@ const { app } = require('./app')
const { createSecureServer } = require('http2')
const { SNICallback, OCSPRequestHandler } = require('./helpers/tls')
module.exports.server = (options, files) => {
module.exports.server = (ticketKeys, options, files) => {
const requestListener = app(options, files)
const serverOptions = { allowHTTP1: true, SNICallback: SNICallback(options) }
const serverOptions = {
allowHTTP1: true,
handshakeTimeout: 12000, // milliseconds, default: 120000
sessionTimeout: 3600, // seconds, default: 300
SNICallback: SNICallback(options),
ticketKeys: Buffer.from(ticketKeys, 'hex')
}
const server = createSecureServer(serverOptions, requestListener)
server.on('OCSPRequest', OCSPRequestHandler)
if (options.http2.timeout !== null) {
server.setTimeout(options.http2.timeout)
}
process.on('message', ({ type, ticketKeys }) => {
if (type === 'ticket-keys-refresh') {
server.setTicketKeys(Buffer.from(ticketKeys, 'hex'))
}
})
return server
}
......@@ -6,10 +6,12 @@ segfaultHandler.registerHandler()
process.title = 'commonshost-worker'
process.on('message', function onMessage ({ type, options, files }) {
function onMessage ({ type, ticketKeys, options, files }) {
if (type === 'start') {
process.off('message', onMessage)
const app = server(options, files)
const app = server(ticketKeys, options, files)
app.listen(options.https.port)
}
})
}
process.on('message', onMessage)
const test = require('blue-tape')
const { Master } = require('..')
const { join } = require('path')
const { connect } = require('tls')
const eventToPromise = require('event-to-promise')
let master
test('start server', async (t) => {
const cwd = join(__dirname, 'fixtures')
const options = {
workers: { count: 16 }
}
master = new Master({ cwd, options })
await master.listen()
})
test('Resume TLS sessions across multiple workers', async (t) => {
let session
for (let i = 0; i < 10; i++) {
const socket = connect({
host: '127.0.0.1',
port: 8443,
rejectUnauthorized: false,
servername: 'localhost',
session
})
await eventToPromise(socket, 'secureConnect')
const isFirstSession = i === 0
t.is(socket.isSessionReused(), !isFirstSession)
session = socket.getSession()
socket.end()
await eventToPromise(socket, 'close')
}
})
test('stop server', async (t) => master.close())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment