feat(self-signed): readCert utility (#7282)

Expired certificates are not automatically detected, which is not a big deal for user certificates because they can still be used and it's their responsibility to update them.

But automatic certificates must be regenerated in that case which was not the case until now.

This commit unifies certificate/key reading, checking and generation for both xo-server and xo-proxy.
This commit is contained in:
Julien Fontanet 2024-01-16 16:58:15 +01:00 committed by GitHub
parent fa748ed9de
commit f06f89b5b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 204 additions and 55 deletions

View File

@ -2,14 +2,13 @@
import fse from 'fs-extra'
import getopts from 'getopts'
import pRetry from 'promise-toolbox/retry'
import { catchGlobalErrors } from '@xen-orchestra/log/configure'
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'
import { readCert } from '@xen-orchestra/self-signed/readCert'
// -------------------------------------------------------------------
@ -79,36 +78,17 @@ ${APP_NAME} v${APP_VERSION}
}
}
niceAddress = await pRetry(
async () => {
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
}
niceAddress = await readCert(cert, key, {
autoCert,
info,
warn,
use({ cert, key }) {
opts.cert = cert
opts.key = key
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)
},
}
)
})
} else {
niceAddress = await httpServer.listen(opts)
}

View File

@ -1,3 +1,7 @@
### `genSelfSigned()`
> Generate a self-signed cert/key pair with OpenSSL.
```js
import { genSelfSigned } from '@xen-orchestra/self-signed'
@ -18,3 +22,41 @@ console.log(
// '-----END RSA PRIVATE KEY-----\n'
// }
```
### `readCert()`
> Reads a cert/key pair from the filesystem, if missing or invalid, generates a new one and write them to the filesystem.
```js
import { readCert } from '@xen-orchestra/self-signed/readCert'
const { cert, key } = await readCert('path/to/cert.pem', 'path/to/key.pem', {
// if false, do not generate a new one in case of error
autoCert: false,
// this function is called in case a new pair is generated
info: console.log,
// mode used when creating files or directories after generating a new pair
mode: 0o400,
// this function is called when there is a non fatal error (fatal errors are thrown)
warn: console.warn,
})
// unfortunately some cert/key issues are detected only when attempting to use them
//
// that's why you can pass a `use` function to `readCert` that will received the pair
// and in case some specific errors are thrown, it will trigger a new generation
await readCert('path/to/cert.pem', 'path/to/key.pem', {
autoCert: true,
async use({ cert, key }) {
const server = https.createServer({ cert, key })
await new Promise((resolve, reject) => {
server.once('error', reject).listen(443, resolve)
})
},
})
```

View File

@ -16,6 +16,10 @@ npm install --save @xen-orchestra/self-signed
## Usage
### `genSelfSigned()`
> Generate a self-signed cert/key pair with OpenSSL.
```js
import { genSelfSigned } from '@xen-orchestra/self-signed'
@ -37,6 +41,44 @@ console.log(
// }
```
### `readCert()`
> Reads a cert/key pair from the filesystem, if missing or invalid, generates a new one and write them to the filesystem.
```js
import { readCert } from '@xen-orchestra/self-signed/readCert'
const { cert, key } = await readCert('path/to/cert.pem', 'path/to/key.pem', {
// if false, do not generate a new one in case of error
autoCert: false,
// this function is called in case a new pair is generated
info: console.log,
// mode used when creating files or directories after generating a new pair
mode: 0o400,
// this function is called when there is a non fatal error (fatal errors are thrown)
warn: console.warn,
})
// unfortunately some cert/key issues are detected only when attempting to use them
//
// that's why you can pass a `use` function to `readCert` that will received the pair
// and in case some specific errors are thrown, it will trigger a new generation
await readCert('path/to/cert.pem', 'path/to/key.pem', {
autoCert: true,
async use({ cert, key }) {
const server = https.createServer({ cert, key })
await new Promise((resolve, reject) => {
server.once('error', reject).listen(443, resolve)
})
},
})
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@ -11,7 +11,7 @@
},
"version": "0.1.3",
"engines": {
"node": ">=8.10"
"node": ">=15.6"
},
"scripts": {
"postversion": "npm publish --access public"
@ -20,5 +20,9 @@
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"exports": {
".": "./index.js",
"./readCert": "./readCert.js"
}
}

View File

@ -0,0 +1,81 @@
'use strict'
const { dirname } = require('node:path')
const { mkdir, readFile, writeFile, unlink } = require('node:fs/promises')
const { X509Certificate } = require('node:crypto')
const { genSelfSignedCert } = require('./index.js')
const identity = value => value
const noop = Function.prototype
async function outputFile(path, content, mode) {
for (let attempt = 0; attempt < 5; ++attempt) {
try {
return await writeFile(path, content, { mode })
} catch (error) {
const { code } = error
if (code === 'ENOENT') {
await mkdir(dirname(path), { mode, recursive: true })
} else if (code === 'EACCES') {
await unlink(path)
} else {
throw error
}
}
}
}
exports.readCert = async function readCert(
certPath,
keyPath,
{
autoCert = false,
use = identity,
info = noop,
mode = 0o400,
warn = noop,
...opts
}
) {
let readingDone = false
try {
const cert = await readFile(certPath)
if (autoCert) {
const x509 = new X509Certificate(cert)
const now = Date.now()
if (now < Date.parse(x509.validFrom) || now > Date.parse(x509.validTo)) {
const e = new Error('certificate is not valid')
// same code used when attempting to connect to a server with an expired certificate
e.code = 'CERT_HAS_EXPIRED'
throw e
}
}
const key = await readFile(keyPath)
readingDone = true
return await use({ cert, key })
} catch (error) {
// only regen if a reading error or if the use error was ERR_SSL_EE_KEY_TOO_SMALL
if (!(autoCert && (!readingDone || error.code === 'ERR_SSL_EE_KEY_TOO_SMALL'))) {
throw error
}
warn(error)
const { cert, key } = await genSelfSignedCert(opts)
info('new certificate generated', { cert, key })
await Promise.all([outputFile(certPath, cert, mode).catch(warn), outputFile(keyPath, key, mode).catch(warn)])
return use({ cert, key })
}
}

View File

@ -44,6 +44,7 @@
<!--packages-start-->
- @xen-orchestra/backups patch
- @xen-orchestra/self-signed minor
- @xen-orchestra/xapi minor
- xen-api patch
- xo-server minor

View File

@ -24,8 +24,8 @@ 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 { readCert } from '@xen-orchestra/self-signed/readCert'
import { URL } from 'url'
import { verifyTotp } from '@vates/otp'
@ -440,6 +440,7 @@ async function makeWebServerListen(
delete opts[key]
}
let niceAddress
if (cert && key) {
if (useAcme) {
opts.SNICallback = async (serverName, callback) => {
@ -454,32 +455,30 @@ async function makeWebServerListen(
}
}
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
}
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
}
niceAddress = await readCert(cert, key, {
autoCert,
info: log.info,
warn: log.warn,
async use({ cert, key }) {
if (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)
})
}
opts.cert = cert
opts.key = key
return webServer.listen(opts)
},
})
} else {
niceAddress = await webServer.listen(opts)
}
const niceAddress = await webServer.listen(opts)
log.info(`Web server listening on ${niceAddress}`)
} catch (error) {
if (error.niceAddress) {