Compare commits

..

1 Commits

Author SHA1 Message Date
Florent Beauchamp
91f65048ca feat(plugins): auto all plugins in source use 2024-01-16 10:35:02 +00:00
5 changed files with 93 additions and 147 deletions

3
.gitignore vendored
View File

@@ -36,6 +36,3 @@ yarn-error.log.*
.nyc_output/
coverage/
.turbo/
# https://node-tap.org/dot-tap-folder/
.tap/

View File

@@ -1,13 +1,10 @@
import { coalesceCalls } from '@vates/coalesce-calls'
import { createLogger } from '@xen-orchestra/log'
import { createSecureContext } from 'tls'
import { dirname } from 'node:path'
import { X509Certificate } from 'node:crypto'
import acme from 'acme-client'
import envPath from 'env-paths'
import fs from 'node:fs/promises'
import get from 'lodash/get.js'
import { join } from 'node:path'
const { debug, info, warn } = createLogger('xo:mixins:sslCertificate')
@@ -32,27 +29,18 @@ async function outputFile(path, content) {
// from https://github.com/publishlab/node-acme-client/blob/master/examples/auto.js
class SslCertificate {
#autoConfig
#cert
#certPath
#keyPath
#clientConfig
#challengeCreateFn
#challengeRemoveFn
#delayBeforeRenewal = 30 * 24 * 60 * 60 * 1000 // 30 days
#domain
#secureContext
#store
#updateSslCertificatePromise
constructor({ autoConfig, clientConfig, domain, store }) {
this.#autoConfig = autoConfig
this.#clientConfig = clientConfig
this.#domain = domain
constructor({ challengeCreateFn, challengeRemoveFn }, cert, key) {
this.#challengeCreateFn = challengeCreateFn
this.#challengeRemoveFn = challengeRemoveFn
const dir = join(store, new URL(clientConfig.directoryUrl).hostname, 'sites', domain)
this.#certPath = join(dir, 'cert.pem')
this.#keyPath = join(dir, 'key.pem')
this.getSecureContext = coalesceCalls(this.getSecureContext)
this.#set(cert, key)
}
get #isValid() {
@@ -69,21 +57,7 @@ class SslCertificate {
this.#secureContext = createSecureContext({ cert, key })
}
async getSecureContext() {
if (this.#cert === undefined) {
try {
this.#set(await fs.readFile(this.#certPath), await fs.readFile(this.#keyPath))
} catch (error) {
if (error.code !== 'ENOENT') {
warn('could not load existing certificate', {
ca: this.#clientConfig.directoryUrl,
domain: this.#domain,
error,
})
}
}
}
async getSecureContext(config) {
if (!this.#shouldBeRenewed) {
return this.#secureContext
}
@@ -105,11 +79,9 @@ class SslCertificate {
return this.#updateSslCertificatePromise
}
async #save(cert, key) {
const certPath = this.#certPath
const keyPath = this.#keyPath
async #save(certPath, cert, keyPath, key) {
try {
await Promise.all([outputFile(certPath, cert), outputFile(keyPath, key)])
await Promise.all([outputFile(keyPath, key), outputFile(certPath, cert)])
info('new certificate generated', { cert: certPath, key: keyPath })
} catch (error) {
warn(`couldn't write let's encrypt certificates to disk `, { error })
@@ -117,18 +89,22 @@ class SslCertificate {
}
async #updateSslCertificate(config) {
const { cert: certPath, key: keyPath, acmeEmail, acmeDomain } = config
try {
const clientConfig = this.#clientConfig
if (!('accountKey' in clientConfig)) {
clientConfig.accountKey = await acme.crypto.createPrivateKey()
let { acmeCa = 'letsencrypt/production' } = config
if (!(acmeCa.startsWith('http:') || acmeCa.startsWith('https:'))) {
acmeCa = get(acme.directory, acmeCa.split('/'))
}
/* Init client */
const client = new acme.Client(clientConfig)
const client = new acme.Client({
directoryUrl: acmeCa,
accountKey: await acme.crypto.createPrivateKey(),
})
/* Create CSR */
let [key, csr] = await acme.crypto.createCsr({
commonName: this.#domain,
commonName: acmeDomain,
})
csr = csr.toString()
key = key.toString()
@@ -136,15 +112,20 @@ class SslCertificate {
/* Certificate */
const cert = await client.auto({
...this.#autoConfig,
challengeCreateFn: this.#challengeCreateFn,
challengePriority: ['http-01'],
challengeRemoveFn: this.#challengeRemoveFn,
csr,
email: acmeEmail,
skipChallengeVerification: true,
termsOfServiceAgreed: true,
})
debug('Successfully generated certificate')
this.#set(cert, key)
// don't wait for this
this.#save(cert, key)
this.#save(certPath, cert, keyPath, key)
return this.#secureContext
} catch (error) {
@@ -156,17 +137,25 @@ class SslCertificate {
}
export default class SslCertificates {
#app
#challenges = new Map()
#challengeHandlers = {
challengeCreateFn: (authz, challenge, keyAuthorization) => {
this.#challenges.set(challenge.token, keyAuthorization)
},
challengeRemoveFn: (authz, challenge, keyAuthorization) => {
this.#challenges.delete(challenge.token)
},
}
#handlers = new Map()
constructor(app, { appName, httpServer }) {
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 (appName === undefined || httpServer === undefined) {
if (httpServer === undefined) {
return
}
const prefix = '/.well-known/acme-challenge/'
httpServer.on('request', (req, res) => {
const { url } = req
@@ -176,64 +165,35 @@ export default class SslCertificates {
}
})
const autoConfig = {
challengePriority: ['http-01'],
challengeCreateFn: (authz, challenge, keyAuthorization) => {
this.#challenges.set(challenge.token, keyAuthorization)
},
challengeRemoveFn: (authz, challenge, keyAuthorization) => {
this.#challenges.delete(challenge.token)
},
skipChallengeVerification: true,
termsOfServiceAgreed: true,
}
app.config.watch('acme', (acmeConfig = {}) => {
const handlers = this.#handlers
handlers.clear()
const baseConfig = {
ca: 'letsencrypt/production',
store: join(envPath(appName, { suffix: '' }).config, 'acme'),
}
const domains = []
for (const key of Object.keys(acmeConfig)) {
const value = acmeConfig[key]
if (value.includes('.')) {
domains.push(value)
} else {
baseConfig[key] = value
}
}
for (const domain of domains) {
const { ca, store, ...clientConfig } = { ...baseConfig, ...acmeConfig[domain], domain }
clientConfig.directoryUrl =
ca.startsWith('http:') || ca.startsWith('https:') ? ca : get(acme.directory, ca.split('/'))
handlers.set(domain, new SslCertificate({ autoConfig, clientConfig, domain, store }))
}
// legacy config
Object.values(app.config.getOptional('http.listen') ?? []).foreach(config => {
const domain = config.acmeDomain
if (domain !== undefined && !handlers.has(domain)) {
const { ca, store, ...clientConfig } = { ...baseConfig, ca, domain, email }
clientConfig.directoryUrl =
ca.startsWith('http:') || ca.startsWith('https:') ? ca : get(acme.directory, ca.split('/'))
handlers.set(domain, new SslCertificate({ autoConfig, clientConfig, domain, store }))
}
})
})
this.#app = app
httpServer.getSecureContext = this.getSecureContext.bind(this)
}
async getSecureContext(httpsDomainName) {
const handler = this.#handlers.get(httpsDomainName)
async getSecureContext(httpsDomainName, configKey, initialCert, initialKey) {
const config = this.#app.config.get(['http', 'listen', configKey])
const handlers = this.#handlers
if (handler !== undefined) {
return handler.getSecureContext()
const { acmeDomain } = config
// not a let's encrypt protected end point, sommething changed in the configuration
if (acmeDomain === undefined) {
handlers.delete(configKey)
return
}
// server has been access with another domain, don't use the certificate
if (acmeDomain !== httpsDomainName) {
return
}
let handler = handlers.get(configKey)
if (handler === undefined) {
// register the handler for this domain
handler = new SslCertificate(this.#challengeHandlers, initialCert, initialKey)
handlers.set(configKey, handler)
}
return handler.getSecureContext(config)
}
// middleware that will serve the http challenge to let's encrypt servers

View File

@@ -1,7 +1,5 @@
> This module provides [Let's Encrypt](https://letsencrypt.org/) integration to `xo-proxy` and `xo-server`.
## Set up
First of all, make sure your server is listening on HTTP on port 80 and on HTTPS 443.
In `xo-server`, to avoid HTTP access, enable the redirection to HTTPs:
@@ -13,8 +11,16 @@ redirectToHttps = true
Your server must be reachable with the configured domain to the certificate provider (e.g. Let's Encrypt), it usually means publicly reachable.
Finally, add the following entries to your HTTPS configuration.
```toml
[acme]
# Must be set to true for this feature
autoCert = true
# These entries are required and indicates where the certificate and the
# private key will be saved.
cert = 'path/to/cert.pem'
key = 'path/to/key.pem'
# ACME (e.g. Let's Encrypt, ZeroSSL) CA directory
#
@@ -29,33 +35,15 @@ Your server must be reachable with the configured domain to the certificate prov
# application to generate new ones.
#
# Default is 'letsencrypt/production'
ca = 'zerossl/production'
acmeCa = 'zerossl/production'
# Domain for which the certificate should be created.
#
# This entry is required.
acmeDomain = 'my.domain.net'
# Optional email address which will be used for the certificate creation.
#
# It will be notified of any issues.
email = 'admin@my.domain.net'
# Domain for which the certificate should be created.
[acme."my.domain.net"]
# Options documented above can be overriden for a specific domain.
# ca =
# email =
acmeEmail = 'admin@my.domain.net'
```
## Behind the scenes
The certificates are stored in:
```
$XDG_CONFIG_HOME/<app name>/acme/<ca hostname>/sites/<domain>
├─ cert.pem
└─ key.pem
```
When a request arrives:
- if no ACME domain is configured for it, the certificate configured for this `http.listen` entry will be used;
- if the ACME certificate for this domain and this CA is missing or no longer valid, a new one will be generated and used for this request;
- if the ACME certificate for this domain and this CA expires soon, it is used for this request and a new one is generated.

View File

@@ -155,7 +155,6 @@ level = 'info'
[logs.transport.console]
[plugins]
lookupPaths = ['./node_modules', '../', '/usr/local/lib/node_modules']
[remoteOptions]
mountsDir = '/run/xo-server/mounts'

View File

@@ -385,32 +385,34 @@ function registerPluginWrapper(pluginPath, pluginName) {
)
}
async function findPluginsInPath(path, prefix) {
const entries = await fse.readdir(path).catch(error => {
async function registerPluginsInPath(path, prefix) {
const files = await fse.readdir(path).catch(error => {
if (error.code === 'ENOENT') {
return []
}
throw error
})
for (const entry of entries) {
if (entry.startsWith(prefix)) {
const pluginName = entry.slice(prefix.length)
if (!this.has(pluginName)) {
this.set(pluginName, path + '/' + entry)
}
await asyncMap(files, name => {
if (name.startsWith(prefix)) {
return registerPluginWrapper.call(this, `${path}/${name}`, name.slice(prefix.length))
}
}
})
}
async function registerPlugins(xo) {
const pluginPaths = new Map()
for (const path of xo.config.get('plugins.lookupPaths')) {
await findPluginsInPath.call(pluginPaths, `${path}/@xen-orchestra`, 'server-')
await findPluginsInPath.call(pluginPaths, path, 'xo-server-')
}
await Promise.all(Array.from(pluginPaths.entries(), ([name, path]) => registerPluginWrapper.call(xo, path, name)))
await Promise.all(
[
new URL('../...', import.meta.url).pathname,
new URL('../node_modules', import.meta.url).pathname,
'/usr/local/lib/node_modules',
].map(path =>
Promise.all([
registerPluginsInPath.call(xo, path, 'xo-server-'),
registerPluginsInPath.call(xo, `${path}/@xen-orchestra`, 'server-'),
])
)
)
}
// ===================================================================