feat(autoacme): Allow setting up automatic ACME certificate generation

This commit is contained in:
Andris Reinman 2024-04-25 15:18:51 +03:00
parent 46adf18e3d
commit cd8596a84d
No known key found for this signature in database
GPG key ID: DC6C83F4D584D364
13 changed files with 169 additions and 16 deletions

View file

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

View file

@ -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'];

View file

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

View file

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

View file

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

View file

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

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,
acmeConfig: config.acme,
loggelf: opts ? opts.loggelf : false
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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