From 40db519d9c08ebe588a6ce820f6287d4f52f038f Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Mon, 29 Apr 2024 09:57:37 +0300 Subject: [PATCH] feat(SNI): Autogenerate TLS certificates for SNI --- config/acme.toml | 26 +++++++-- lib/acme/certs.js | 7 +-- lib/api/certs.js | 4 ++ lib/cert-handler.js | 125 +++++++++++++++++++++------------------- lib/certs.js | 1 + lib/settings-handler.js | 20 ------- tasks.js | 1 + 7 files changed, 96 insertions(+), 88 deletions(-) diff --git a/config/acme.toml b/config/acme.toml index f7b8127..9aeb131 100644 --- a/config/acme.toml +++ b/config/acme.toml @@ -1,8 +1,8 @@ # ACME production settings -key = "production" # variable to identify account settings for specified directory url +key = "production" # variable to identify account settings for specified directory url directoryUrl = "https://acme-v02.api.letsencrypt.org/directory" -email = "domainadmin@example.com" # must be valid email address +email = "domainadmin@example.com" # must be valid email address # ACME development settings #key = "devel" # variable to identify account settings for specified directory url @@ -11,15 +11,29 @@ email = "domainadmin@example.com" # must be valid email address # If hostname has a CAA record set then match it against this list # CAA check is done before WildDuck tries to request certificate from ACME -caaDomains = [ "letsencrypt.org" ] +caaDomains = ["letsencrypt.org"] # Private key settings, if WildDuck has to generate a key by itself keyBits = 2048 keyExponent = 65537 +[autogenerate] +# If enabled then automatically generates TLS certificates based on SNI servernames +enabled = true +[autogenerate.cnameMapping] +# Sudomain CNAME mapping +# "abc" = ["def.com"] means that if the SNI servername domain is "abc.{domain}" +# then there must be a CNAME record for this domain that points to "def.com". +# If multiple CNAME targets are defined (eg ["def.com", "bef.com"], then at least 1 must match. +# Additionally, there must be at least 1 email account with "@{domain}" address. +# If there is no match, then TLS certificate is not generated. +imap = ["imap.example.com"] +smtp = ["smtp.example.com"] +pop3 = ["imap.example.com"] + [agent] # If enabled then starts a HTTP server that listens for ACME verification requests # If you have WildDuck API already listening on port 80 then you don't need this - enabled = false - port = 80 # use 80 in production - redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL +enabled = false +port = 80 # use 80 in production +redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL diff --git a/lib/acme/certs.js b/lib/acme/certs.js index bad6505..5ed6edd 100644 --- a/lib/acme/certs.js +++ b/lib/acme/certs.js @@ -138,8 +138,7 @@ const validateDomain = async domain => { // check CAA support const caaDomains = config.acme.caaDomains.map(normalizeDomain).filter(d => d); - // CAA support in node 15+ - if (typeof resolver.resolveCaa === 'function' && caaDomains.length) { + if (caaDomains.length) { let parts = domain.split('.'); for (let i = 0; i < parts.length - 1; i++) { let subdomain = parts.slice(i).join('.'); @@ -151,12 +150,12 @@ const validateDomain = async domain => { // assume not found } - if (caaRes && caaRes.length && !caaRes.some(r => config.acme.caaDomains.includes(normalizeDomain(r && r.issue)))) { + if (caaRes?.length && !caaRes.some(r => caaDomains.includes(normalizeDomain(r?.issue)))) { let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`); err.responseCode = 403; err.code = 'caa_mismatch'; throw err; - } else if (caaRes && caaRes.length) { + } else if (caaRes?.length) { log.info('ACME', 'Found matching CAA record for %s (%s)', subdomain, domain); break; } diff --git a/lib/api/certs.js b/lib/api/certs.js index 019eb34..aceb3c1 100644 --- a/lib/api/certs.js +++ b/lib/api/certs.js @@ -27,6 +27,7 @@ module.exports = (db, server) => { secret: config.certs && config.certs.secret, database: db.database, redis: db.redis, + users: db.users, acmeConfig: config.acme }); @@ -80,6 +81,7 @@ module.exports = (db, server) => { .example('59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'), created: Joi.date().required().description('Datestring').example('2024-03-13T20:06:46.179Z'), expires: Joi.date().required().description('Certificate expiration time').example('2024-04-26T21:55:55.000Z'), + autogenerated: Joi.boolean().description('Was the certificate automatically generated on SNI request'), altNames: Joi.array() .items(Joi.string().required()) .required() @@ -203,6 +205,7 @@ module.exports = (db, server) => { description: certData.description, fingerprint: certData.fingerprint, expires: certData.expires, + autogenerated: certData.autogenerated, altNames: certData.altNames, acme: !!certData.acme, created: certData.created @@ -477,6 +480,7 @@ module.exports = (db, server) => { .example('59:8b:ed:11:5b:4f:ce:b4:e5:1a:2f:35:b1:6f:7d:93:40:c8:2f:9c:38:3b:cd:f4:04:92:a1:0e:17:2c:3f:f3'), expires: Joi.date().required().description('Certificate expiration time').example('2024-06-26T21:55:55.000Z'), created: Joi.date().required().description('Created datestring').example('2024-05-13T20:06:46.179Z'), + autogenerated: Joi.boolean().description('Was the certificate automatically generated on SNI request'), altNames: Joi.array() .items(Joi.string().required()) .required() diff --git a/lib/cert-handler.js b/lib/cert-handler.js index 350205e..7139a83 100644 --- a/lib/cert-handler.js +++ b/lib/cert-handler.js @@ -13,7 +13,6 @@ const { encrypt, decrypt } = require('./encrypt'); const { SettingsHandler } = require('./settings-handler'); const { Resolver } = require('dns').promises; const resolver = new Resolver(); -const punycode = require('punycode.js'); const { getCertificate } = require('./acme/certs'); const { promisify } = require('util'); @@ -22,8 +21,6 @@ const generateKeyPair = promisify(crypto.generateKeyPair); const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000; const CERT_RENEW_DELAY = 24 * 3600 * 100; -const CAA_DOMAIN = 'letsencrypt.org'; - class CertHandler { constructor(options) { options = options || {}; @@ -35,6 +32,8 @@ class CertHandler { this.database = options.database; this.redis = options.redis; + this.users = options.users; + this.acmeConfig = options.acmeConfig; this.ctxCache = new Map(); @@ -450,6 +449,7 @@ class CertHandler { description: certData.description, fingerprint: certData.fingerprint || certData.fp, expires: certData.expires, + autogenerated: certData.autogenerated, altNames: certData.altNames, acme: !!certData.acme, hasCert: (!!certData.privateKey && certData.cert) || false, @@ -632,66 +632,26 @@ class CertHandler { return context; } - normalizeDomain(domain) { - domain = (domain || '').toString().toLowerCase().trim(); - try { - if (/[\x80-\uFFFF]/.test(domain)) { - domain = punycode.toASCII(domain); - } - } catch (E) { - // ignore - } - - return domain; - } - async precheckAcmeCertificate(domain) { - let typePrefix = domain.split('.').shift().toLowerCase().trim(); + const dotPos = domain.indexOf('.'); + if (dotPos < 0) { + // not a FQDN + return false; + } + const subdomain = domain.substring(0, dotPos).toLowerCase().trim(); + const maindomain = domain + .substring(dotPos + 1) + .toLowerCase() + .trim(); - let subdomainTargets = ((await this.settingsHandler.get('const:acme:subdomains')) || '') - .toString() - .split(',') - .map(entry => entry.trim()) - .filter(entry => entry); - - if (!subdomainTargets.includes(typePrefix)) { + let subdomainTargets = [].concat(this.acmeConfig.autogenerate?.cnameMapping?.[subdomain] || []); + if (!subdomainTargets.length) { // unsupported subdomain log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain); return false; } - // CAA check - let parts = domain.split('.'); - for (let i = 0; i < parts.length - 1; i++) { - let subdomain = parts.slice(i).join('.'); - let caaRes; - try { - caaRes = await resolver.resolveCaa(subdomain); - } catch (err) { - // assume not found - } - - if (caaRes?.length && !caaRes.some(r => (r?.issue || '').trim().toLowerCase() === CAA_DOMAIN)) { - log.verbose('Certs', 'Skip ACME. reason="LE not listed in the CAA record". action=precheck domain=%s subdomain=%s', domain, subdomain); - return false; - } else if (caaRes?.length) { - log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s', domain, subdomain); - break; - } - } - - // check if the domain points to correct cname - let cnameTargets = ((await this.settingsHandler.get('const:acme:cname')) || '') - .toString() - .split(',') - .map(entry => entry.trim()) - .filter(entry => entry); - - if (!cnameTargets) { - log.verbose('Certs', 'Skip ACME. reason="no cname targets" action=precheck domain=%s', domain); - return false; - } - + // CNAME check let resolved; try { resolved = await resolver.resolveCname(domain); @@ -706,17 +666,60 @@ class CertHandler { } for (let row of resolved) { - if (!cnameTargets.includes(row)) { + if (!subdomainTargets.includes(row)) { log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row); return false; } } + // CAA check + + const caaDomains = this.acmeConfig.caaDomains?.map(domain => tools.normalizeDomain(domain)).filter(d => d); + + let parts = domain.split('.'); + for (let i = 0; i < parts.length - 1; i++) { + let subdomain = parts.slice(i).join('.'); + let caaRes; + try { + caaRes = await resolver.resolveCaa(subdomain); + } catch (err) { + // assume not found + } + + if (caaRes?.length && !caaRes.some(r => caaDomains.includes(tools.normalizeDomain(r?.issue)))) { + log.verbose('Certs', 'Skip ACME. reason="LE not listed in the CAA record". action=precheck domain=%s subdomain=%s', domain, subdomain); + return false; + } else if (caaRes?.length) { + log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s caa=%s', domain, subdomain, caaRes.join(',')); + break; + } + } + + // Address check + const addressMatchRegex = tools.escapeRegexStr(`@${maindomain}`); + const addressData = await this.users.collection('addresses').findOne({ + addrview: { + $regex: `${addressMatchRegex}$` + } + }); + + if (!addressData) { + log.verbose('Certs', 'Skip ACME. reason="No addresses found for the domain". action=precheck domain=%s subdomain=%s', domain, subdomain); + return false; + } + return true; } async autogenerateAcmeCertificate(servername) { - let domain = this.normalizeDomain(servername); + let domain = tools.normalizeDomain(servername); + + if (!this.acmeConfig.autogenerate?.enabled) { + // can not create autogenerated TLS certificates + log.verbose('Certs', 'Skip ACME. reason="Certificate autogeneration not enabled" action=precheck domain=%s', domain); + return false; + } + let valid = await this.precheckAcmeCertificate(domain); if (!valid) { return false; @@ -724,6 +727,12 @@ class CertHandler { log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain); + this.loggelf({ + short_message: ` Autogenerating TLS certificate for ${domain}`, + _sni_servername: domain, + _cert_action: 'sni_autogenerate' + }); + // add row to db let certInsertResult = await this.set({ servername, diff --git a/lib/certs.js b/lib/certs.js index ca521b5..627a853 100644 --- a/lib/certs.js +++ b/lib/certs.js @@ -112,6 +112,7 @@ module.exports.getContextForServername = async (servername, serverOptions, meta, secret: config.certs && config.certs.secret, database: db.database, redis: db.redis, + users: db.users, acmeConfig: config.acme, loggelf: opts ? opts.loggelf : false }); diff --git a/lib/settings-handler.js b/lib/settings-handler.js index 6554d0d..a75bc03 100644 --- a/lib/settings-handler.js +++ b/lib/settings-handler.js @@ -138,26 +138,6 @@ const SETTING_KEYS = [ .allow('') .trim() .pattern(/^\d+\s*[a-z]*(\s*,\s*\d+\s*[a-z]*)*$/) - }, - - { - key: 'const:acme:cname', - name: 'Required CNAME for auto-ACME', - description: 'Comma separated list of allowed CNAME targets for automatic ACME domains', - type: 'string', - constKey: false, - confValue: '', - schema: Joi.string().allow('').trim() - }, - - { - key: 'const:acme:subdomains', - name: 'Subdomains for auto-ACME', - description: 'Comma separated list of allowed subdomains for automatic ACME domains', - type: 'string', - constKey: false, - confValue: 'imap, smtp, pop3', - schema: Joi.string().allow('').trim() } ]; diff --git a/tasks.js b/tasks.js index 233882d..adc76ac 100644 --- a/tasks.js +++ b/tasks.js @@ -124,6 +124,7 @@ module.exports.start = callback => { secret: config.certs && config.certs.secret, database: db.database, redis: db.redis, + users: db.users, acmeConfig: config.acme, loggelf: message => loggelf(message) });