Compare commits

...

2 Commits

Author SHA1 Message Date
Florent Beauchamp
4e4dd53373 review 2022-07-19 16:20:32 +02:00
Florent Beauchamp
0fa0d50b95 refactor(ssl): mutualize ssl certificate handling between xo-server and xo-proxy 2022-07-18 16:54:34 +02:00
8 changed files with 262 additions and 67 deletions

View File

@@ -0,0 +1,219 @@
import { createLogger } from '@xen-orchestra/log'
import { genSelfSignedCert } from '@xen-orchestra/self-signed'
import pRetry from 'promise-toolbox/retry'
import { X509Certificate } from 'crypto'
import fs from 'node:fs/promises'
import { dirname } from 'path'
import pw from 'pw'
import tls from 'node:tls'
const { debug, info, warn } = createLogger('xo:mixins:sslCertificate')
async function outputFile(path, content) {
await fs.mkdir(dirname(path), { recursive: true })
await fs.writeFile(path, content, { flag: 'w', mode: 0o400 })
}
class SslCertificate {
#app
#configKey
#updateSslCertificatePromise
#secureContext
#validTo
constructor(app, configKey) {
this.#app = app
this.#configKey = configKey
}
#createSecureContext(cert, key, passphrase) {
return tls.createSecureContext({
cert,
key,
passphrase,
})
}
// load on register
async #loadSslCertificate(config) {
const certPath = config.cert
const keyPath = config.key
let key, cert, passphrase
try {
;[cert, key] = await Promise.all([fs.readFile(certPath), fs.readFile(keyPath)])
if (keyPath.includes('ENCRYPTED')) {
if (config.autoCert) {
throw new Error(`encrytped certificates aren't compatible with autoCert option`)
}
passphrase = await new Promise(resolve => {
// eslint-disable-next-line no-console
process.stdout.write(`Enter pass phrase: `)
pw(resolve)
})
}
} catch (error) {
if (!(config.autoCert && error.code === 'ENOENT')) {
throw error
}
// self signed certificate or let's encrypt will be generated on demand
}
// create secure context also make a validation of the certificate
const secureContext = this.#createSecureContext(cert, key, passphrase)
this.#secureContext = secureContext
// will be tested and eventually renewed on first query
const { validTo } = new X509Certificate(cert)
this.#validTo = new Date(validTo)
}
#getConfig() {
const config = this.#app.config.get(this.#configKey)
if (config === undefined) {
throw new Error(`config for key ${this.#configKey} is unavailable`)
}
return config
}
async #getSelfSignedContext(config) {
return pRetry(
async () => {
const { cert, key } = await genSelfSignedCert()
info('new certificates generated', { cert, key })
try {
await Promise.all([outputFile(config.cert, cert), outputFile(config.key, key)])
} catch (error) {
warn(`can't save self signed certificates `, { error, config })
}
// create secure context also make a validation of the certificate
const { validTo } = new X509Certificate(cert)
return { secureContext: this.#createSecureContext(cert, key), validTo: new Date(validTo) }
},
{
tries: 2,
when: e => e.code === 'ERR_SSL_EE_KEY_TOO_SMALL',
onRetry: () => {
warn('got ERR_SSL_EE_KEY_TOO_SMALL while generating self signed certificate ')
},
}
)
}
// get the current certificate for this hostname
async getSecureContext(hostName) {
const config = this.#getConfig()
if (config === undefined) {
throw new Error(`config for key ${this.#configKey} is unavailable`)
}
if (this.#updateSslCertificatePromise) {
debug('certificate is already refreshing')
return this.#updateSslCertificatePromise
}
let certificateIsValid = this.#validTo !== undefined
let shouldRenew = !certificateIsValid
if (certificateIsValid) {
certificateIsValid = this.#validTo >= new Date()
shouldRenew = !certificateIsValid || this.#validTo - new Date() < 30 * 24 * 60 * 60 * 1000
}
let promise = Promise.resolve()
if (shouldRenew) {
try {
// @todo : should also handle let's encrypt
if (config.autoCert === true) {
promise = promise.then(() => this.#getSelfSignedContext(config))
}
this.#updateSslCertificatePromise = promise
// cleanup and store
promise = promise.then(
({ secureContext, validTo }) => {
this.#validTo = validTo
this.#secureContext = secureContext
this.#updateSslCertificatePromise = undefined
return secureContext
},
async error => {
console.warn('error while updating ssl certificate', { error })
this.#updateSslCertificatePromise = undefined
if (!certificateIsValid) {
// we couldn't generate a valid certificate
// only throw if the current certificate is invalid
warn('deleting invalid certificate')
this.#secureContext = undefined
this.#validTo = undefined
await Promise.all([fs.unlink(config.cert), fs.unlink(config.key)])
throw error
}
}
)
} catch (error) {
warn('error while refreshing ssl certificate', { error })
throw error
}
}
if (certificateIsValid) {
// still valid : does not need to wait for the refresh
return this.#secureContext
}
if (this.#updateSslCertificatePromise === undefined) {
throw new Error(`Invalid certificate and no strategy defined to renew it. Try activating autoCert in the config`)
}
// invalid cert : wait for refresh
return this.#updateSslCertificatePromise
}
async register() {
await this.#loadSslCertificate(this.#getConfig())
}
}
export default class SslCertificates {
#app
#handlers = {}
constructor(app, { httpServer }) {
// don't setup the proxy if httpServer is not present
//
// that can happen when the app is instanciated in another context like xo-server-recover-account
if (httpServer === undefined) {
return
}
this.#app = app
httpServer.getSecureContext = this.getSecureContext.bind(this)
}
async getSecureContext(hostname, configKey) {
const config = this.#app.config.get(`http.listen.${configKey}`)
if (!config || !config.cert || !config.key) {
throw new Error(`HTTPS configuration does no exists for key http.listen.${configKey}`)
}
if (this.#handlers[configKey] === undefined) {
throw new Error(`the SslCertificate handler for key http.listen.${configKey} does not exists.`)
}
return this.#handlers[configKey].getSecureContext(hostname, config)
}
async register() {
// http.listen can be an array or an object
const configs = this.#app.config.get('http.listen') || []
const configKeys = Object.keys(configs) || []
await Promise.all(
configKeys
.filter(configKey => configs[configKey].cert !== undefined && configs[configKey].key !== undefined)
.map(async configKey => {
this.#handlers[configKey] = new SslCertificate(this.#app, `http.listen.${configKey}`)
return this.#handlers[configKey].register(configs[configKey])
})
)
}
}

View File

@@ -16,16 +16,18 @@
"license": "AGPL-3.0-or-later",
"version": "0.5.0",
"engines": {
"node": ">=12"
"node": ">=14"
},
"dependencies": {
"@vates/event-listeners-manager": "^1.0.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/emit-async": "^1.0.0",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/self-signed": "^0.1.3",
"app-conf": "^2.1.0",
"lodash": "^4.17.21",
"promise-toolbox": "^0.21.0"
"promise-toolbox": "^0.21.0",
"pw": "^0.0.4"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -3,13 +3,11 @@
import forOwn from 'lodash/forOwn.js'
import fse from 'fs-extra'
import getopts from 'getopts'
import pRetry from 'promise-toolbox/retry'
import { catchGlobalErrors } from '@xen-orchestra/log/configure.js'
import { create as createServer } from 'http-server-plus'
import { createCachedLookup } from '@vates/cached-dns.lookup'
import { createLogger } from '@xen-orchestra/log'
import { createSecureServer } from 'http2'
import { genSelfSignedCert } from '@xen-orchestra/self-signed'
import { load as loadConfig } from 'app-conf'
// -------------------------------------------------------------------
@@ -56,41 +54,21 @@ ${APP_NAME} v${APP_VERSION}
createSecureServer: opts => createSecureServer({ ...opts, allowHTTP1: true }),
})
forOwn(config.http.listen, async ({ autoCert, cert, key, ...opts }) => {
forOwn(config.http.listen, async ({ autoCert, cert, key, ...opts }, listenKey) => {
try {
const niceAddress = await pRetry(
async () => {
if (cert !== undefined && key !== undefined) {
try {
opts.cert = fse.readFileSync(cert)
opts.key = fse.readFileSync(key)
} catch (error) {
if (!(autoCert && error.code === 'ENOENT')) {
throw error
}
const pems = await genSelfSignedCert()
fse.outputFileSync(cert, pems.cert, { flag: 'wx', mode: 0o400 })
fse.outputFileSync(key, pems.key, { flag: 'wx', mode: 0o400 })
info('new certificate generated', { cert, key })
opts.cert = pems.cert
opts.key = pems.key
}
if (cert !== undefined && key !== undefined) {
opts.SNICallback = async (serverName, callback) => {
// injected by @xen-orchestr/mixins/sslCertificate.mjs
try {
const secureContext = await httpServer.getSecureContext(serverName, listenKey)
callback(null, secureContext)
} catch (error) {
warn('An error occured during certificate context creation', { error, listenKey, serverName })
callback(error)
}
return httpServer.listen(opts)
},
{
tries: 2,
when: e => autoCert && e.code === 'ERR_SSL_EE_KEY_TOO_SMALL',
onRetry: () => {
warn('deleting invalid certificate')
fse.unlinkSync(cert)
fse.unlinkSync(key)
},
}
)
}
const niceAddress = httpServer.listen(opts)
info(`Web server listening on ${niceAddress}`)
} catch (error) {
if (error.niceAddress !== undefined) {
@@ -138,6 +116,8 @@ ${APP_NAME} v${APP_VERSION}
const { default: fromCallback } = await import('promise-toolbox/fromCallback')
app.hooks.on('stop', () => fromCallback(cb => httpServer.stop(cb)))
await app.sslCertificate.register()
await app.hooks.start()
// Gracefully shutdown on signals.
@@ -146,6 +126,7 @@ ${APP_NAME} v${APP_VERSION}
process.on(signal, () => {
if (alreadyCalled) {
warn('forced exit')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
alreadyCalled = true
@@ -163,7 +144,7 @@ main(process.argv.slice(2)).then(
},
error => {
fatal(error)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
)

View File

@@ -37,7 +37,6 @@
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.5.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/xapi": "^1.4.0",
"ajv": "^8.0.3",
"app-conf": "^2.1.0",

View File

@@ -28,5 +28,8 @@
<!--packages-start-->
- @vates/async-each major
- @xen-orchestra/mixins minor
- @xen-orchestra/proxy patch
- @xen-orchestra/xo-server patch
<!--packages-end-->

View File

@@ -48,7 +48,6 @@
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.5.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/template": "^0.1.0",
"@xen-orchestra/xapi": "^1.4.0",
"ajv": "^8.0.3",
@@ -110,7 +109,6 @@
"proxy-agent": "^5.0.0",
"pug": "^3.0.0",
"pumpify": "^2.0.0",
"pw": "^0.0.4",
"readable-stream": "^3.2.0",
"redis": "^3.0.2",
"schema-inspector": "^2.0.1",

View File

@@ -17,7 +17,6 @@ import merge from 'lodash/merge.js'
import ms from 'ms'
import once from 'lodash/once.js'
import proxyConsole from './proxy-console.mjs'
import pw from 'pw'
import serveStatic from 'serve-static'
import stoppable from 'stoppable'
import WebServer from 'http-server-plus'
@@ -26,7 +25,6 @@ import { asyncMap } from '@xen-orchestra/async-map'
import { xdgConfig } from 'xdg-basedir'
import { createLogger } from '@xen-orchestra/log'
import { createRequire } from 'module'
import { genSelfSignedCert } from '@xen-orchestra/self-signed'
import { parseDuration } from '@vates/parse-duration'
import { URL } from 'url'
@@ -77,7 +75,6 @@ configure([
])
const log = createLogger('xo:main')
// ===================================================================
const DEPRECATED_ENTRIES = ['users', 'servers']
@@ -400,34 +397,24 @@ async function makeWebServerListen(
cert = certificate,
key,
listenKey,
...opts
}
) {
try {
if (cert && key) {
try {
;[opts.cert, opts.key] = await Promise.all([fse.readFile(cert), fse.readFile(key)])
if (opts.key.includes('ENCRYPTED')) {
opts.passphrase = await new Promise(resolve => {
// eslint-disable-next-line no-console
console.log('Encrypted key %s', key)
process.stdout.write(`Enter pass phrase: `)
pw(resolve)
})
}
} catch (error) {
if (!(autoCert && error.code === 'ENOENT')) {
throw error
}
opts.SNICallback = async (serverName, callback) => {
// injected by @xen-orchestr/mixins/sslCertificate.mjs
try {
const secureContext = await webServer.getSecureContext(serverName, listenKey)
const pems = await genSelfSignedCert()
await Promise.all([
fse.outputFile(cert, pems.cert, { flag: 'wx', mode: 0o400 }),
fse.outputFile(key, pems.key, { flag: 'wx', mode: 0o400 }),
])
log.info('new certificate generated', { cert, key })
opts.cert = pems.cert
opts.key = pems.key
callback(null, secureContext)
} catch (error) {
log.warn('An error occured during certificate context creation', { error, listenKey, serverName })
callback(error)
}
}
}
@@ -453,7 +440,9 @@ async function makeWebServerListen(
async function createWebServer({ listen, listenOptions }) {
const webServer = stoppable(new WebServer())
await Promise.all(map(listen, opts => makeWebServerListen(webServer, { ...listenOptions, ...opts })))
await Promise.all(
map(listen, (opts, listenKey) => makeWebServerListen(webServer, { ...listenOptions, ...opts, listenKey }))
)
return webServer
}
@@ -797,6 +786,8 @@ export default async function main(args) {
// Must be set up before the API.
express.use(xo._handleHttpRequest.bind(xo))
await xo.sslCertificate.register()
// Everything above is not protected by the sign in, allowing xo-cli
// to work properly.
await setUpPassport(express, xo, config)
@@ -823,6 +814,7 @@ export default async function main(args) {
process.on(signal, () => {
if (alreadyCalled) {
log.warn('forced exit')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
alreadyCalled = true

View File

@@ -8,6 +8,7 @@ import iteratee from 'lodash/iteratee.js'
import mixin from '@xen-orchestra/mixin'
import mixinLegacy from '@xen-orchestra/mixin/legacy.js'
import stubTrue from 'lodash/stubTrue.js'
import SslCertificate from '@xen-orchestra/mixins/SslCertificate.mjs'
import { Collection as XoCollection } from 'xo-collection'
import { createClient as createRedisClient } from 'redis'
import { createDebounceResource } from '@vates/disposable/debounceResource.js'
@@ -29,7 +30,7 @@ export default class Xo extends EventEmitter {
constructor(opts) {
super()
mixin(this, { Config, Hooks, HttpProxy }, [opts])
mixin(this, { Config, Hooks, HttpProxy, SslCertificate }, [opts])
// a lot of mixins adds listener for start/stop/… events
this.hooks.setMaxListeners(0)