mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-12-26 01:40:48 +08:00
feat(autoacme): Allow setting up automatic ACME certificate generation
This commit is contained in:
parent
46adf18e3d
commit
cd8596a84d
13 changed files with 169 additions and 16 deletions
|
@ -7,7 +7,7 @@ const IMAPConnection = require('./imap-connection').IMAPConnection;
|
|||
const tlsOptions = require('./tls-options');
|
||||
const EventEmitter = require('events').EventEmitter;
|
||||
const shared = require('nodemailer/lib/shared');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const base32 = require('base32.js');
|
||||
const errors = require('../../lib/errors.js');
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const Indexer = require('./indexer/indexer');
|
||||
const libmime = require('libmime');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const iconv = require('iconv-lite');
|
||||
|
||||
module.exports.systemFlagsFormatted = ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen'];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const libmime = require('libmime');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
|
||||
// This module converts message structure into an ENVELOPE object
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ const crypto = require('crypto');
|
|||
const counters = require('./lib/counters');
|
||||
const { ObjectId } = require('mongodb');
|
||||
const libmime = require('libmime');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const { getClient } = require('./lib/elasticsearch');
|
||||
|
||||
let loggelf;
|
||||
|
|
|
@ -26,7 +26,8 @@ module.exports = (db, server) => {
|
|||
cipher: config.certs && config.certs.cipher,
|
||||
secret: config.certs && config.certs.secret,
|
||||
database: db.database,
|
||||
redis: db.redis
|
||||
redis: db.redis,
|
||||
acmeConfig: config.acme
|
||||
});
|
||||
|
||||
const taskHandler = new TaskHandler({
|
||||
|
|
|
@ -10,6 +10,11 @@ const log = require('npmlog');
|
|||
const tlsOptions = require('../imap-core/lib/tls-options');
|
||||
const { publish, CERT_CREATED, CERT_UPDATED, CERT_DELETED } = require('./events');
|
||||
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');
|
||||
const generateKeyPair = promisify(crypto.generateKeyPair);
|
||||
|
@ -17,15 +22,21 @@ 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 || {};
|
||||
this.cipher = options.cipher;
|
||||
this.secret = options.secret;
|
||||
|
||||
this.settingsHandler = new SettingsHandler({ db: options.database });
|
||||
|
||||
this.database = options.database;
|
||||
this.redis = options.redis;
|
||||
|
||||
this.acmeConfig = options.acmeConfig;
|
||||
|
||||
this.ctxCache = new Map();
|
||||
|
||||
this.loggelf = options.loggelf || (() => false);
|
||||
|
@ -282,7 +293,8 @@ class CertHandler {
|
|||
|
||||
let certData = {
|
||||
updated: new Date(),
|
||||
acme: !!options.acme
|
||||
acme: !!options.acme,
|
||||
autogenerated: !!options.autogenerated
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
|
@ -572,12 +584,22 @@ class CertHandler {
|
|||
|
||||
certData = await this.database.collection('certs').findOne(altQuery, { sort: { expires: -1 } });
|
||||
if (!certData || !certData.privateKey || !certData.cert) {
|
||||
// still nothing, return whatever we have
|
||||
sendLogs({
|
||||
_sni_match: 'no',
|
||||
_sni_cache: 'miss'
|
||||
});
|
||||
return (cachedContext && cachedContext.context) || false;
|
||||
// try to generate a new ACME certificate
|
||||
|
||||
try {
|
||||
certData = await this.autogenerateAcmeCertificate(servername);
|
||||
} catch (err) {
|
||||
log.error('Certs', 'Failed to generate certificate. domain=%s error=%s', servername, err.message);
|
||||
}
|
||||
|
||||
if (!certData || !certData.privateKey || !certData.cert) {
|
||||
// still nothing, return whatever we have
|
||||
sendLogs({
|
||||
_sni_match: 'no',
|
||||
_sni_cache: 'miss'
|
||||
});
|
||||
return (cachedContext && cachedContext.context) || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -609,6 +631,114 @@ 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);
|
||||
|
||||
if (!subdomainTargets.includes(typePrefix)) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 (!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 valid = await this.precheckAcmeCertificate(domain);
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.verbose('Certs', 'ACME precheck passed. action=precheck domain=%s', domain);
|
||||
|
||||
// add row to db
|
||||
let certInsertResult = await this.set({
|
||||
servername,
|
||||
autogenerated: true,
|
||||
acme: true
|
||||
});
|
||||
|
||||
if (certInsertResult) {
|
||||
let certData = await getCertificate(servername, this.acmeConfig, this);
|
||||
log.verbose('Certs', 'ACME certificate result. servername=%s status=%s', servername, certData && certData.status);
|
||||
return certData || false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CertHandler;
|
||||
|
|
|
@ -112,6 +112,7 @@ module.exports.getContextForServername = async (servername, serverOptions, meta,
|
|||
secret: config.certs && config.certs.secret,
|
||||
database: db.database,
|
||||
redis: db.redis,
|
||||
acmeConfig: config.acme,
|
||||
loggelf: opts ? opts.loggelf : false
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ const uuid = require('uuid');
|
|||
const os = require('os');
|
||||
const hostname = os.hostname().toLowerCase();
|
||||
const addressparser = require('nodemailer/lib/addressparser');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const crypto = require('crypto');
|
||||
const tools = require('./tools');
|
||||
const plugins = require('./plugins');
|
||||
|
|
|
@ -7,7 +7,7 @@ const crypto = require('crypto');
|
|||
const tlsOptions = require('../../imap-core/lib/tls-options');
|
||||
const shared = require('nodemailer/lib/shared');
|
||||
const POP3Connection = require('./connection');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const base32 = require('base32.js');
|
||||
const errors = require('../errors');
|
||||
|
||||
|
|
|
@ -98,6 +98,7 @@ const SETTING_KEYS = [
|
|||
confValue: ((config.imap && config.imap.maxDownloadMB) || consts.MAX_IMAP_DOWNLOAD) * 1024 * 1024,
|
||||
schema: Joi.number()
|
||||
},
|
||||
|
||||
{
|
||||
key: 'const:max:pop3:download',
|
||||
name: 'Max POP3 download',
|
||||
|
@ -137,6 +138,26 @@ 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()
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
'use strict';
|
||||
|
||||
const os = require('os');
|
||||
const punycode = require('punycode/');
|
||||
const punycode = require('punycode.js');
|
||||
const libmime = require('libmime');
|
||||
const consts = require('./consts');
|
||||
const errors = require('./errors');
|
||||
|
|
|
@ -89,7 +89,6 @@
|
|||
"npmlog": "7.0.1",
|
||||
"openpgp": "5.11.1",
|
||||
"pem-jwk": "2.0.0",
|
||||
"punycode": "2.3.1",
|
||||
"punycode.js": "2.3.1",
|
||||
"pwnedpasswords": "1.0.6",
|
||||
"qrcode": "1.5.3",
|
||||
|
|
1
tasks.js
1
tasks.js
|
@ -124,6 +124,7 @@ module.exports.start = callback => {
|
|||
secret: config.certs && config.certs.secret,
|
||||
database: db.database,
|
||||
redis: db.redis,
|
||||
acmeConfig: config.acme,
|
||||
loggelf: message => loggelf(message)
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue