Skip to content

Commit

Permalink
feat(autoacme): Allow setting up automatic ACME certificate generation
Browse files Browse the repository at this point in the history
  • Loading branch information
andris9 committed Apr 25, 2024
1 parent 46adf18 commit cd8596a
Show file tree
Hide file tree
Showing 13 changed files with 169 additions and 16 deletions.
2 changes: 1 addition & 1 deletion imap-core/lib/imap-server.js
Expand Up @@ -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');

Expand Down
2 changes: 1 addition & 1 deletion imap-core/lib/imap-tools.js
Expand Up @@ -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'];
Expand Down
2 changes: 1 addition & 1 deletion imap-core/lib/indexer/create-envelope.js
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion indexer.js
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion lib/api/certs.js
Expand Up @@ -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({
Expand Down
144 changes: 137 additions & 7 deletions lib/cert-handler.js
Expand Up @@ -10,22 +10,33 @@ 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);

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);
Expand Down Expand Up @@ -282,7 +293,8 @@ class CertHandler {

let certData = {
updated: new Date(),
acme: !!options.acme
acme: !!options.acme,
autogenerated: !!options.autogenerated
};

if (privateKey) {
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions lib/certs.js
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,
acmeConfig: config.acme,
loggelf: opts ? opts.loggelf : false
});
}
Expand Down
2 changes: 1 addition & 1 deletion lib/maildropper.js
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion lib/pop3/server.js
Expand Up @@ -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');

Expand Down
21 changes: 21 additions & 0 deletions lib/settings-handler.js
Expand Up @@ -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',
Expand Down Expand Up @@ -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()
}
];

Expand Down
2 changes: 1 addition & 1 deletion lib/tools.js
Expand Up @@ -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');
Expand Down
1 change: 0 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tasks.js
Expand Up @@ -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)
});

Expand Down

0 comments on commit cd8596a

Please sign in to comment.