Skip to content

Commit

Permalink
feat(SNI): Autogenerate TLS certificates for SNI
Browse files Browse the repository at this point in the history
  • Loading branch information
andris9 committed Apr 29, 2024
1 parent df01bc3 commit 40db519
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 85 deletions.
26 changes: 20 additions & 6 deletions config/acme.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
7 changes: 3 additions & 4 deletions lib/acme/certs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('.');
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions lib/api/certs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
119 changes: 64 additions & 55 deletions lib/cert-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 || {};
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -632,35 +632,50 @@ 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);
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();

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

// CNAME check
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 (!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('.');
Expand All @@ -671,59 +686,53 @@ class CertHandler {
// assume not found
}

if (caaRes?.length && !caaRes.some(r => (r?.issue || '').trim().toLowerCase() === CAA_DOMAIN)) {
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', domain, subdomain);
log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s caa=%s', domain, subdomain, caaRes.join(','));
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;
}
// Address check
const addressMatchRegex = tools.escapeRegexStr(`@${maindomain}`);
const addressData = await this.users.collection('addresses').findOne({
addrview: {
$regex: `${addressMatchRegex}$`
}
});

if (!resolved || !resolved.length) {
log.verbose('Certs', 'Skip ACME. reason="empty CNAME result" action=precheck domain=%s', domain);
if (!addressData) {
log.verbose('Certs', 'Skip ACME. reason="No addresses found for the domain". action=precheck domain=%s subdomain=%s', domain, subdomain);
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 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;
}

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,
Expand Down
1 change: 1 addition & 0 deletions lib/certs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
20 changes: 0 additions & 20 deletions lib/settings-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
];

Expand Down
1 change: 1 addition & 0 deletions tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
Expand Down

0 comments on commit 40db519

Please sign in to comment.