mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-20 07:16:05 +08:00
feat(SNI): Autogenerate TLS certificates for SNI
This commit is contained in:
parent
df01bc379e
commit
40db519d9c
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
1
tasks.js
1
tasks.js
|
@ -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)
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue