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:
parent
fa748ed9de
commit
f06f89b5b4
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
81
@xen-orchestra/self-signed/readCert.js
Normal file
81
@xen-orchestra/self-signed/readCert.js
Normal 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 })
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user