feat(SNI): Autogenerate TLS certificates for SNI

This commit is contained in:
Andris Reinman 2024-04-29 09:57:37 +03:00
parent df01bc379e
commit 40db519d9c
No known key found for this signature in database
GPG key ID: DC6C83F4D584D364
7 changed files with 96 additions and 88 deletions

View file

@ -1,8 +1,8 @@
# ACME production settings # 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" 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 # ACME development settings
#key = "devel" # variable to identify account settings for specified directory url #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 # 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 # 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 # Private key settings, if WildDuck has to generate a key by itself
keyBits = 2048 keyBits = 2048
keyExponent = 65537 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] [agent]
# If enabled then starts a HTTP server that listens for ACME verification requests # 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 # If you have WildDuck API already listening on port 80 then you don't need this
enabled = false enabled = false
port = 80 # use 80 in production port = 80 # use 80 in production
redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL

View file

@ -138,8 +138,7 @@ const validateDomain = async domain => {
// check CAA support // check CAA support
const caaDomains = config.acme.caaDomains.map(normalizeDomain).filter(d => d); const caaDomains = config.acme.caaDomains.map(normalizeDomain).filter(d => d);
// CAA support in node 15+ if (caaDomains.length) {
if (typeof resolver.resolveCaa === 'function' && caaDomains.length) {
let parts = domain.split('.'); let parts = domain.split('.');
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
let subdomain = parts.slice(i).join('.'); let subdomain = parts.slice(i).join('.');
@ -151,12 +150,12 @@ const validateDomain = async domain => {
// assume not found // 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})`); let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`);
err.responseCode = 403; err.responseCode = 403;
err.code = 'caa_mismatch'; err.code = 'caa_mismatch';
throw err; throw err;
} else if (caaRes && caaRes.length) { } else if (caaRes?.length) {
log.info('ACME', 'Found matching CAA record for %s (%s)', subdomain, domain); log.info('ACME', 'Found matching CAA record for %s (%s)', subdomain, domain);
break; break;
} }

View file

@ -27,6 +27,7 @@ module.exports = (db, server) => {
secret: config.certs && config.certs.secret, secret: config.certs && config.certs.secret,
database: db.database, database: db.database,
redis: db.redis, redis: db.redis,
users: db.users,
acmeConfig: config.acme 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'), .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'), 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'), 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() altNames: Joi.array()
.items(Joi.string().required()) .items(Joi.string().required())
.required() .required()
@ -203,6 +205,7 @@ module.exports = (db, server) => {
description: certData.description, description: certData.description,
fingerprint: certData.fingerprint, fingerprint: certData.fingerprint,
expires: certData.expires, expires: certData.expires,
autogenerated: certData.autogenerated,
altNames: certData.altNames, altNames: certData.altNames,
acme: !!certData.acme, acme: !!certData.acme,
created: certData.created 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'), .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'), 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'), 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() altNames: Joi.array()
.items(Joi.string().required()) .items(Joi.string().required())
.required() .required()

View file

@ -13,7 +13,6 @@ const { encrypt, decrypt } = require('./encrypt');
const { SettingsHandler } = require('./settings-handler'); const { SettingsHandler } = require('./settings-handler');
const { Resolver } = require('dns').promises; const { Resolver } = require('dns').promises;
const resolver = new Resolver(); const resolver = new Resolver();
const punycode = require('punycode.js');
const { getCertificate } = require('./acme/certs'); const { getCertificate } = require('./acme/certs');
const { promisify } = require('util'); const { promisify } = require('util');
@ -22,8 +21,6 @@ const generateKeyPair = promisify(crypto.generateKeyPair);
const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000; const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000;
const CERT_RENEW_DELAY = 24 * 3600 * 100; const CERT_RENEW_DELAY = 24 * 3600 * 100;
const CAA_DOMAIN = 'letsencrypt.org';
class CertHandler { class CertHandler {
constructor(options) { constructor(options) {
options = options || {}; options = options || {};
@ -35,6 +32,8 @@ class CertHandler {
this.database = options.database; this.database = options.database;
this.redis = options.redis; this.redis = options.redis;
this.users = options.users;
this.acmeConfig = options.acmeConfig; this.acmeConfig = options.acmeConfig;
this.ctxCache = new Map(); this.ctxCache = new Map();
@ -450,6 +449,7 @@ class CertHandler {
description: certData.description, description: certData.description,
fingerprint: certData.fingerprint || certData.fp, fingerprint: certData.fingerprint || certData.fp,
expires: certData.expires, expires: certData.expires,
autogenerated: certData.autogenerated,
altNames: certData.altNames, altNames: certData.altNames,
acme: !!certData.acme, acme: !!certData.acme,
hasCert: (!!certData.privateKey && certData.cert) || false, hasCert: (!!certData.privateKey && certData.cert) || false,
@ -632,66 +632,26 @@ class CertHandler {
return context; 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) { 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')) || '') let subdomainTargets = [].concat(this.acmeConfig.autogenerate?.cnameMapping?.[subdomain] || []);
.toString() if (!subdomainTargets.length) {
.split(',')
.map(entry => entry.trim())
.filter(entry => entry);
if (!subdomainTargets.includes(typePrefix)) {
// unsupported subdomain // unsupported subdomain
log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain); log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain);
return false; return false;
} }
// CAA check // CNAME 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; let resolved;
try { try {
resolved = await resolver.resolveCname(domain); resolved = await resolver.resolveCname(domain);
@ -706,17 +666,60 @@ class CertHandler {
} }
for (let row of resolved) { 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); log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row);
return false; 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; return true;
} }
async autogenerateAcmeCertificate(servername) { 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); let valid = await this.precheckAcmeCertificate(domain);
if (!valid) { if (!valid) {
return false; return false;
@ -724,6 +727,12 @@ class CertHandler {
log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain); 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 // add row to db
let certInsertResult = await this.set({ let certInsertResult = await this.set({
servername, servername,

View file

@ -112,6 +112,7 @@ module.exports.getContextForServername = async (servername, serverOptions, meta,
secret: config.certs && config.certs.secret, secret: config.certs && config.certs.secret,
database: db.database, database: db.database,
redis: db.redis, redis: db.redis,
users: db.users,
acmeConfig: config.acme, acmeConfig: config.acme,
loggelf: opts ? opts.loggelf : false loggelf: opts ? opts.loggelf : false
}); });

View file

@ -138,26 +138,6 @@ const SETTING_KEYS = [
.allow('') .allow('')
.trim() .trim()
.pattern(/^\d+\s*[a-z]*(\s*,\s*\d+\s*[a-z]*)*$/) .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()
} }
]; ];

View file

@ -124,6 +124,7 @@ module.exports.start = callback => {
secret: config.certs && config.certs.secret, secret: config.certs && config.certs.secret,
database: db.database, database: db.database,
redis: db.redis, redis: db.redis,
users: db.users,
acmeConfig: config.acme, acmeConfig: config.acme,
loggelf: message => loggelf(message) loggelf: message => loggelf(message)
}); });