Compare commits
2 Commits
test
...
refacto_ss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e4dd53373 | ||
|
|
0fa0d50b95 |
219
@xen-orchestra/mixins/SslCertificate.mjs
Normal file
219
@xen-orchestra/mixins/SslCertificate.mjs
Normal 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])
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user