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

@ -17,6 +17,20 @@ caaDomains = [ "letsencrypt.org" ]
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

View file

@ -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;
}

View file

@ -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()

View file

@ -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,

View file

@ -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
});

View file

@ -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()
}
];

View file

@ -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)
});