From cd8596a84d36f9870858f3fdc4249f2af42347d9 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Thu, 25 Apr 2024 15:18:51 +0300 Subject: [PATCH] feat(autoacme): Allow setting up automatic ACME certificate generation --- imap-core/lib/imap-server.js | 2 +- imap-core/lib/imap-tools.js | 2 +- imap-core/lib/indexer/create-envelope.js | 2 +- indexer.js | 2 +- lib/api/certs.js | 3 +- lib/cert-handler.js | 144 +++++++++++++++++++++-- lib/certs.js | 1 + lib/maildropper.js | 2 +- lib/pop3/server.js | 2 +- lib/settings-handler.js | 21 ++++ lib/tools.js | 2 +- package.json | 1 - tasks.js | 1 + 13 files changed, 169 insertions(+), 16 deletions(-) diff --git a/imap-core/lib/imap-server.js b/imap-core/lib/imap-server.js index da204c63..1f62f026 100644 --- a/imap-core/lib/imap-server.js +++ b/imap-core/lib/imap-server.js @@ -7,7 +7,7 @@ const IMAPConnection = require('./imap-connection').IMAPConnection; const tlsOptions = require('./tls-options'); const EventEmitter = require('events').EventEmitter; const shared = require('nodemailer/lib/shared'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const base32 = require('base32.js'); const errors = require('../../lib/errors.js'); diff --git a/imap-core/lib/imap-tools.js b/imap-core/lib/imap-tools.js index 5eb5daf9..196667f4 100644 --- a/imap-core/lib/imap-tools.js +++ b/imap-core/lib/imap-tools.js @@ -2,7 +2,7 @@ const Indexer = require('./indexer/indexer'); const libmime = require('libmime'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const iconv = require('iconv-lite'); module.exports.systemFlagsFormatted = ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']; diff --git a/imap-core/lib/indexer/create-envelope.js b/imap-core/lib/indexer/create-envelope.js index dd59d579..f0d1f069 100644 --- a/imap-core/lib/indexer/create-envelope.js +++ b/imap-core/lib/indexer/create-envelope.js @@ -1,7 +1,7 @@ 'use strict'; const libmime = require('libmime'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); // This module converts message structure into an ENVELOPE object diff --git a/indexer.js b/indexer.js index bb65992a..e0493a86 100644 --- a/indexer.js +++ b/indexer.js @@ -11,7 +11,7 @@ const crypto = require('crypto'); const counters = require('./lib/counters'); const { ObjectId } = require('mongodb'); const libmime = require('libmime'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const { getClient } = require('./lib/elasticsearch'); let loggelf; diff --git a/lib/api/certs.js b/lib/api/certs.js index 1a0ea9b8..019eb340 100644 --- a/lib/api/certs.js +++ b/lib/api/certs.js @@ -26,7 +26,8 @@ module.exports = (db, server) => { cipher: config.certs && config.certs.cipher, secret: config.certs && config.certs.secret, database: db.database, - redis: db.redis + redis: db.redis, + acmeConfig: config.acme }); const taskHandler = new TaskHandler({ diff --git a/lib/cert-handler.js b/lib/cert-handler.js index 41525294..350205e4 100644 --- a/lib/cert-handler.js +++ b/lib/cert-handler.js @@ -10,6 +10,11 @@ const log = require('npmlog'); const tlsOptions = require('../imap-core/lib/tls-options'); const { publish, CERT_CREATED, CERT_UPDATED, CERT_DELETED } = require('./events'); 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'); const generateKeyPair = promisify(crypto.generateKeyPair); @@ -17,15 +22,21 @@ 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 || {}; this.cipher = options.cipher; this.secret = options.secret; + this.settingsHandler = new SettingsHandler({ db: options.database }); + this.database = options.database; this.redis = options.redis; + this.acmeConfig = options.acmeConfig; + this.ctxCache = new Map(); this.loggelf = options.loggelf || (() => false); @@ -282,7 +293,8 @@ class CertHandler { let certData = { updated: new Date(), - acme: !!options.acme + acme: !!options.acme, + autogenerated: !!options.autogenerated }; if (privateKey) { @@ -572,12 +584,22 @@ class CertHandler { certData = await this.database.collection('certs').findOne(altQuery, { sort: { expires: -1 } }); if (!certData || !certData.privateKey || !certData.cert) { - // still nothing, return whatever we have - sendLogs({ - _sni_match: 'no', - _sni_cache: 'miss' - }); - return (cachedContext && cachedContext.context) || false; + // try to generate a new ACME certificate + + try { + certData = await this.autogenerateAcmeCertificate(servername); + } catch (err) { + log.error('Certs', 'Failed to generate certificate. domain=%s error=%s', servername, err.message); + } + + if (!certData || !certData.privateKey || !certData.cert) { + // still nothing, return whatever we have + sendLogs({ + _sni_match: 'no', + _sni_cache: 'miss' + }); + return (cachedContext && cachedContext.context) || false; + } } } @@ -609,6 +631,114 @@ 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(); + + let subdomainTargets = ((await this.settingsHandler.get('const:acme:subdomains')) || '') + .toString() + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry); + + if (!subdomainTargets.includes(typePrefix)) { + // 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; + } + + let resolved; + try { + resolved = await resolver.resolveCname(domain); + } catch (err) { + log.error('Certs', 'DNS CNAME query failed. action=precheck domain=%s error=%s', domain, err.message); + return false; + } + + if (!resolved || !resolved.length) { + log.verbose('Certs', 'Skip ACME. reason="empty CNAME result" action=precheck domain=%s', domain); + return false; + } + + for (let row of resolved) { + if (!cnameTargets.includes(row)) { + log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row); + return false; + } + } + + return true; + } + + async autogenerateAcmeCertificate(servername) { + let domain = this.normalizeDomain(servername); + let valid = await this.precheckAcmeCertificate(domain); + if (!valid) { + return false; + } + + log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain); + + // add row to db + let certInsertResult = await this.set({ + servername, + autogenerated: true, + acme: true + }); + + if (certInsertResult) { + let certData = await getCertificate(servername, this.acmeConfig, this); + log.verbose('Certs', 'ACME certificate result. servername=%s status=%s', servername, certData && certData.status); + return certData || false; + } + + return false; + } } module.exports = CertHandler; diff --git a/lib/certs.js b/lib/certs.js index bfb64540..ca521b51 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, + acmeConfig: config.acme, loggelf: opts ? opts.loggelf : false }); } diff --git a/lib/maildropper.js b/lib/maildropper.js index 8e425203..bdea384d 100644 --- a/lib/maildropper.js +++ b/lib/maildropper.js @@ -9,7 +9,7 @@ const uuid = require('uuid'); const os = require('os'); const hostname = os.hostname().toLowerCase(); const addressparser = require('nodemailer/lib/addressparser'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const crypto = require('crypto'); const tools = require('./tools'); const plugins = require('./plugins'); diff --git a/lib/pop3/server.js b/lib/pop3/server.js index 1d231a13..70355c95 100644 --- a/lib/pop3/server.js +++ b/lib/pop3/server.js @@ -7,7 +7,7 @@ const crypto = require('crypto'); const tlsOptions = require('../../imap-core/lib/tls-options'); const shared = require('nodemailer/lib/shared'); const POP3Connection = require('./connection'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const base32 = require('base32.js'); const errors = require('../errors'); diff --git a/lib/settings-handler.js b/lib/settings-handler.js index 70eceda3..6554d0d9 100644 --- a/lib/settings-handler.js +++ b/lib/settings-handler.js @@ -98,6 +98,7 @@ const SETTING_KEYS = [ confValue: ((config.imap && config.imap.maxDownloadMB) || consts.MAX_IMAP_DOWNLOAD) * 1024 * 1024, schema: Joi.number() }, + { key: 'const:max:pop3:download', name: 'Max POP3 download', @@ -137,6 +138,26 @@ 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/lib/tools.js b/lib/tools.js index 98a4df68..16cc8e14 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -2,7 +2,7 @@ 'use strict'; const os = require('os'); -const punycode = require('punycode/'); +const punycode = require('punycode.js'); const libmime = require('libmime'); const consts = require('./consts'); const errors = require('./errors'); diff --git a/package.json b/package.json index 84d38f33..bbbcaa59 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "npmlog": "7.0.1", "openpgp": "5.11.1", "pem-jwk": "2.0.0", - "punycode": "2.3.1", "punycode.js": "2.3.1", "pwnedpasswords": "1.0.6", "qrcode": "1.5.3", diff --git a/tasks.js b/tasks.js index 58b308f7..233882de 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, + acmeConfig: config.acme, loggelf: message => loggelf(message) });