Skip to content

Commit 40db519

Browse files
committed
feat(SNI): Autogenerate TLS certificates for SNI
1 parent df01bc3 commit 40db519

File tree

7 files changed

+93
-85
lines changed

7 files changed

+93
-85
lines changed

config/acme.toml

+20-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11

22
# ACME production settings
3-
key = "production" # variable to identify account settings for specified directory url
3+
key = "production" # variable to identify account settings for specified directory url
44
directoryUrl = "https://acme-v02.api.letsencrypt.org/directory"
5-
email = "[email protected]" # must be valid email address
5+
email = "[email protected]" # must be valid email address
66

77
# ACME development settings
88
#key = "devel" # variable to identify account settings for specified directory url
@@ -11,15 +11,29 @@ email = "[email protected]" # must be valid email address
1111

1212
# If hostname has a CAA record set then match it against this list
1313
# CAA check is done before WildDuck tries to request certificate from ACME
14-
caaDomains = [ "letsencrypt.org" ]
14+
caaDomains = ["letsencrypt.org"]
1515

1616
# Private key settings, if WildDuck has to generate a key by itself
1717
keyBits = 2048
1818
keyExponent = 65537
1919

20+
[autogenerate]
21+
# If enabled then automatically generates TLS certificates based on SNI servernames
22+
enabled = true
23+
[autogenerate.cnameMapping]
24+
# Sudomain CNAME mapping
25+
# "abc" = ["def.com"] means that if the SNI servername domain is "abc.{domain}"
26+
# then there must be a CNAME record for this domain that points to "def.com".
27+
# If multiple CNAME targets are defined (eg ["def.com", "bef.com"], then at least 1 must match.
28+
# Additionally, there must be at least 1 email account with "@{domain}" address.
29+
# If there is no match, then TLS certificate is not generated.
30+
imap = ["imap.example.com"]
31+
smtp = ["smtp.example.com"]
32+
pop3 = ["imap.example.com"]
33+
2034
[agent]
2135
# If enabled then starts a HTTP server that listens for ACME verification requests
2236
# If you have WildDuck API already listening on port 80 then you don't need this
23-
enabled = false
24-
port = 80 # use 80 in production
25-
redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL
37+
enabled = false
38+
port = 80 # use 80 in production
39+
redirect = "https://wildduck.email" # redirect requests unrelated to ACME updates to this URL

lib/acme/certs.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ const validateDomain = async domain => {
138138
// check CAA support
139139
const caaDomains = config.acme.caaDomains.map(normalizeDomain).filter(d => d);
140140

141-
// CAA support in node 15+
142-
if (typeof resolver.resolveCaa === 'function' && caaDomains.length) {
141+
if (caaDomains.length) {
143142
let parts = domain.split('.');
144143
for (let i = 0; i < parts.length - 1; i++) {
145144
let subdomain = parts.slice(i).join('.');
@@ -151,12 +150,12 @@ const validateDomain = async domain => {
151150
// assume not found
152151
}
153152

154-
if (caaRes && caaRes.length && !caaRes.some(r => config.acme.caaDomains.includes(normalizeDomain(r && r.issue)))) {
153+
if (caaRes?.length && !caaRes.some(r => caaDomains.includes(normalizeDomain(r?.issue)))) {
155154
let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`);
156155
err.responseCode = 403;
157156
err.code = 'caa_mismatch';
158157
throw err;
159-
} else if (caaRes && caaRes.length) {
158+
} else if (caaRes?.length) {
160159
log.info('ACME', 'Found matching CAA record for %s (%s)', subdomain, domain);
161160
break;
162161
}

lib/api/certs.js

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module.exports = (db, server) => {
2727
secret: config.certs && config.certs.secret,
2828
database: db.database,
2929
redis: db.redis,
30+
users: db.users,
3031
acmeConfig: config.acme
3132
});
3233

@@ -80,6 +81,7 @@ module.exports = (db, server) => {
8081
.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'),
8182
created: Joi.date().required().description('Datestring').example('2024-03-13T20:06:46.179Z'),
8283
expires: Joi.date().required().description('Certificate expiration time').example('2024-04-26T21:55:55.000Z'),
84+
autogenerated: Joi.boolean().description('Was the certificate automatically generated on SNI request'),
8385
altNames: Joi.array()
8486
.items(Joi.string().required())
8587
.required()
@@ -203,6 +205,7 @@ module.exports = (db, server) => {
203205
description: certData.description,
204206
fingerprint: certData.fingerprint,
205207
expires: certData.expires,
208+
autogenerated: certData.autogenerated,
206209
altNames: certData.altNames,
207210
acme: !!certData.acme,
208211
created: certData.created
@@ -477,6 +480,7 @@ module.exports = (db, server) => {
477480
.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'),
478481
expires: Joi.date().required().description('Certificate expiration time').example('2024-06-26T21:55:55.000Z'),
479482
created: Joi.date().required().description('Created datestring').example('2024-05-13T20:06:46.179Z'),
483+
autogenerated: Joi.boolean().description('Was the certificate automatically generated on SNI request'),
480484
altNames: Joi.array()
481485
.items(Joi.string().required())
482486
.required()

lib/cert-handler.js

+64-55
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const { encrypt, decrypt } = require('./encrypt');
1313
const { SettingsHandler } = require('./settings-handler');
1414
const { Resolver } = require('dns').promises;
1515
const resolver = new Resolver();
16-
const punycode = require('punycode.js');
1716
const { getCertificate } = require('./acme/certs');
1817

1918
const { promisify } = require('util');
@@ -22,8 +21,6 @@ const generateKeyPair = promisify(crypto.generateKeyPair);
2221
const CERT_RENEW_TTL = 30 * 24 * 3600 * 1000;
2322
const CERT_RENEW_DELAY = 24 * 3600 * 100;
2423

25-
const CAA_DOMAIN = 'letsencrypt.org';
26-
2724
class CertHandler {
2825
constructor(options) {
2926
options = options || {};
@@ -35,6 +32,8 @@ class CertHandler {
3532
this.database = options.database;
3633
this.redis = options.redis;
3734

35+
this.users = options.users;
36+
3837
this.acmeConfig = options.acmeConfig;
3938

4039
this.ctxCache = new Map();
@@ -450,6 +449,7 @@ class CertHandler {
450449
description: certData.description,
451450
fingerprint: certData.fingerprint || certData.fp,
452451
expires: certData.expires,
452+
autogenerated: certData.autogenerated,
453453
altNames: certData.altNames,
454454
acme: !!certData.acme,
455455
hasCert: (!!certData.privateKey && certData.cert) || false,
@@ -632,35 +632,50 @@ class CertHandler {
632632
return context;
633633
}
634634

635-
normalizeDomain(domain) {
636-
domain = (domain || '').toString().toLowerCase().trim();
637-
try {
638-
if (/[\x80-\uFFFF]/.test(domain)) {
639-
domain = punycode.toASCII(domain);
640-
}
641-
} catch (E) {
642-
// ignore
643-
}
644-
645-
return domain;
646-
}
647-
648635
async precheckAcmeCertificate(domain) {
649-
let typePrefix = domain.split('.').shift().toLowerCase().trim();
650-
651-
let subdomainTargets = ((await this.settingsHandler.get('const:acme:subdomains')) || '')
652-
.toString()
653-
.split(',')
654-
.map(entry => entry.trim())
655-
.filter(entry => entry);
636+
const dotPos = domain.indexOf('.');
637+
if (dotPos < 0) {
638+
// not a FQDN
639+
return false;
640+
}
641+
const subdomain = domain.substring(0, dotPos).toLowerCase().trim();
642+
const maindomain = domain
643+
.substring(dotPos + 1)
644+
.toLowerCase()
645+
.trim();
656646

657-
if (!subdomainTargets.includes(typePrefix)) {
647+
let subdomainTargets = [].concat(this.acmeConfig.autogenerate?.cnameMapping?.[subdomain] || []);
648+
if (!subdomainTargets.length) {
658649
// unsupported subdomain
659650
log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain);
660651
return false;
661652
}
662653

654+
// CNAME check
655+
let resolved;
656+
try {
657+
resolved = await resolver.resolveCname(domain);
658+
} catch (err) {
659+
log.error('Certs', 'DNS CNAME query failed. action=precheck domain=%s error=%s', domain, err.message);
660+
return false;
661+
}
662+
663+
if (!resolved || !resolved.length) {
664+
log.verbose('Certs', 'Skip ACME. reason="empty CNAME result" action=precheck domain=%s', domain);
665+
return false;
666+
}
667+
668+
for (let row of resolved) {
669+
if (!subdomainTargets.includes(row)) {
670+
log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row);
671+
return false;
672+
}
673+
}
674+
663675
// CAA check
676+
677+
const caaDomains = this.acmeConfig.caaDomains?.map(domain => tools.normalizeDomain(domain)).filter(d => d);
678+
664679
let parts = domain.split('.');
665680
for (let i = 0; i < parts.length - 1; i++) {
666681
let subdomain = parts.slice(i).join('.');
@@ -671,59 +686,53 @@ class CertHandler {
671686
// assume not found
672687
}
673688

674-
if (caaRes?.length && !caaRes.some(r => (r?.issue || '').trim().toLowerCase() === CAA_DOMAIN)) {
689+
if (caaRes?.length && !caaRes.some(r => caaDomains.includes(tools.normalizeDomain(r?.issue)))) {
675690
log.verbose('Certs', 'Skip ACME. reason="LE not listed in the CAA record". action=precheck domain=%s subdomain=%s', domain, subdomain);
676691
return false;
677692
} else if (caaRes?.length) {
678-
log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s', domain, subdomain);
693+
log.verbose('Certs', 'CAA record found. action=precheck domain=%s subdomain=%s caa=%s', domain, subdomain, caaRes.join(','));
679694
break;
680695
}
681696
}
682697

683-
// check if the domain points to correct cname
684-
let cnameTargets = ((await this.settingsHandler.get('const:acme:cname')) || '')
685-
.toString()
686-
.split(',')
687-
.map(entry => entry.trim())
688-
.filter(entry => entry);
689-
690-
if (!cnameTargets) {
691-
log.verbose('Certs', 'Skip ACME. reason="no cname targets" action=precheck domain=%s', domain);
692-
return false;
693-
}
694-
695-
let resolved;
696-
try {
697-
resolved = await resolver.resolveCname(domain);
698-
} catch (err) {
699-
log.error('Certs', 'DNS CNAME query failed. action=precheck domain=%s error=%s', domain, err.message);
700-
return false;
701-
}
698+
// Address check
699+
const addressMatchRegex = tools.escapeRegexStr(`@${maindomain}`);
700+
const addressData = await this.users.collection('addresses').findOne({
701+
addrview: {
702+
$regex: `${addressMatchRegex}$`
703+
}
704+
});
702705

703-
if (!resolved || !resolved.length) {
704-
log.verbose('Certs', 'Skip ACME. reason="empty CNAME result" action=precheck domain=%s', domain);
706+
if (!addressData) {
707+
log.verbose('Certs', 'Skip ACME. reason="No addresses found for the domain". action=precheck domain=%s subdomain=%s', domain, subdomain);
705708
return false;
706709
}
707710

708-
for (let row of resolved) {
709-
if (!cnameTargets.includes(row)) {
710-
log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row);
711-
return false;
712-
}
713-
}
714-
715711
return true;
716712
}
717713

718714
async autogenerateAcmeCertificate(servername) {
719-
let domain = this.normalizeDomain(servername);
715+
let domain = tools.normalizeDomain(servername);
716+
717+
if (!this.acmeConfig.autogenerate?.enabled) {
718+
// can not create autogenerated TLS certificates
719+
log.verbose('Certs', 'Skip ACME. reason="Certificate autogeneration not enabled" action=precheck domain=%s', domain);
720+
return false;
721+
}
722+
720723
let valid = await this.precheckAcmeCertificate(domain);
721724
if (!valid) {
722725
return false;
723726
}
724727

725728
log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain);
726729

730+
this.loggelf({
731+
short_message: ` Autogenerating TLS certificate for ${domain}`,
732+
_sni_servername: domain,
733+
_cert_action: 'sni_autogenerate'
734+
});
735+
727736
// add row to db
728737
let certInsertResult = await this.set({
729738
servername,

lib/certs.js

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ module.exports.getContextForServername = async (servername, serverOptions, meta,
112112
secret: config.certs && config.certs.secret,
113113
database: db.database,
114114
redis: db.redis,
115+
users: db.users,
115116
acmeConfig: config.acme,
116117
loggelf: opts ? opts.loggelf : false
117118
});

lib/settings-handler.js

-20
Original file line numberDiff line numberDiff line change
@@ -138,26 +138,6 @@ const SETTING_KEYS = [
138138
.allow('')
139139
.trim()
140140
.pattern(/^\d+\s*[a-z]*(\s*,\s*\d+\s*[a-z]*)*$/)
141-
},
142-
143-
{
144-
key: 'const:acme:cname',
145-
name: 'Required CNAME for auto-ACME',
146-
description: 'Comma separated list of allowed CNAME targets for automatic ACME domains',
147-
type: 'string',
148-
constKey: false,
149-
confValue: '',
150-
schema: Joi.string().allow('').trim()
151-
},
152-
153-
{
154-
key: 'const:acme:subdomains',
155-
name: 'Subdomains for auto-ACME',
156-
description: 'Comma separated list of allowed subdomains for automatic ACME domains',
157-
type: 'string',
158-
constKey: false,
159-
confValue: 'imap, smtp, pop3',
160-
schema: Joi.string().allow('').trim()
161141
}
162142
];
163143

tasks.js

+1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ module.exports.start = callback => {
124124
secret: config.certs && config.certs.secret,
125125
database: db.database,
126126
redis: db.redis,
127+
users: db.users,
127128
acmeConfig: config.acme,
128129
loggelf: message => loggelf(message)
129130
});

0 commit comments

Comments
 (0)