mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-20 07:16:05 +08:00
802 lines
26 KiB
JavaScript
802 lines
26 KiB
JavaScript
'use strict';
|
|
|
|
const ObjectId = require('mongodb').ObjectId;
|
|
const fingerprint = require('key-fingerprint').fingerprint;
|
|
const crypto = require('crypto');
|
|
const tls = require('tls');
|
|
const forge = require('node-forge');
|
|
const tools = require('./tools');
|
|
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 { 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 * 1000;
|
|
// delete uninitialized certificates after 1 day
|
|
const CERT_GARBAGE_TTL = 24 * 3600 * 1000;
|
|
|
|
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.users = options.users;
|
|
|
|
this.acme = options.acmeConfig;
|
|
|
|
this.ctxCache = new Map();
|
|
|
|
this.loggelf = options.loggelf || (() => false);
|
|
}
|
|
|
|
getAltNames(parsedCert) {
|
|
let response = [];
|
|
let altNames = parsedCert.extensions && parsedCert.extensions.find(ext => ext.id === '2.5.29.17');
|
|
let subject = parsedCert.subject && parsedCert.subject.attributes && parsedCert.subject.attributes.find(attr => attr.type === '2.5.4.3');
|
|
|
|
if (altNames && altNames.altNames && altNames.altNames.length) {
|
|
response = altNames.altNames.map(an => an.value).filter(value => value);
|
|
}
|
|
|
|
if (!response.length && subject && subject.value) {
|
|
response.push(subject.value);
|
|
}
|
|
|
|
response = response.map(name => tools.normalizeDomain(name));
|
|
|
|
return response;
|
|
}
|
|
|
|
async generateKey(keyBits, keyExponent, opts) {
|
|
opts = opts || {};
|
|
const { privateKey /*, publicKey */ } = await generateKeyPair('rsa', {
|
|
modulusLength: keyBits || 2048, // options
|
|
publicExponent: keyExponent || 65537,
|
|
publicKeyEncoding: {
|
|
type: opts.publicKeyEncoding || 'spki',
|
|
format: 'pem'
|
|
},
|
|
privateKeyEncoding: {
|
|
// jwk functions fail on other encodings (eg. pkcs8)
|
|
type: opts.privateKeyEncoding || 'pkcs1',
|
|
format: 'pem'
|
|
}
|
|
});
|
|
|
|
return privateKey;
|
|
}
|
|
|
|
getCertName(parsedCert) {
|
|
let subject = parsedCert.subject && parsedCert.subject.attributes && parsedCert.subject.attributes.find(attr => attr.type === '2.5.4.3');
|
|
if (subject && subject.value) {
|
|
return tools.normalizeDomain(subject.value);
|
|
}
|
|
|
|
let altNames = parsedCert.extensions && parsedCert.extensions.find(ext => ext.id === '2.5.29.17');
|
|
if (altNames && altNames.altNames && altNames.altNames.length) {
|
|
let list = altNames.altNames.map(an => an.value && tools.normalizeDomain(an.value)).filter(value => value);
|
|
return list[0];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
prepareQuery(options) {
|
|
let query = {};
|
|
options = options || {};
|
|
|
|
if (options.servername) {
|
|
query.servername = tools.normalizeDomain(options.servername);
|
|
} else if (options._id && tools.isId(options._id)) {
|
|
query._id = new ObjectId(options._id);
|
|
} else {
|
|
let err = new Error('Invalid or unknown cert');
|
|
err.responseCode = 404;
|
|
err.code = 'CertNotFound';
|
|
throw err;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
async clearGarbage() {
|
|
// delete expired and uninitialized SNI certificates
|
|
let r = await this.database.collection('certs').deleteMany({
|
|
acme: true,
|
|
updated: {
|
|
$lt: new Date(Date.now() - CERT_GARBAGE_TTL)
|
|
},
|
|
$or: [{ expires: { $exists: false } }, { expires: { $lt: new Date() } }],
|
|
autogenerated: true
|
|
});
|
|
|
|
if (r?.deletedCount) {
|
|
log.verbose('Certs', 'Deleted uninitialized and expired autogenerated certificates. count=%s', r?.deletedCount);
|
|
}
|
|
}
|
|
|
|
async getNextRenewal() {
|
|
let r = await this.database.collection('certs').findOneAndUpdate(
|
|
{
|
|
acme: true,
|
|
expires: {
|
|
$lt: new Date(Date.now() + CERT_RENEW_TTL)
|
|
},
|
|
$or: [
|
|
{ '_acme.lastRenewalCheck': { $exists: false } },
|
|
{
|
|
'_acme.lastRenewalCheck': {
|
|
$lt: new Date(Date.now() - CERT_RENEW_DELAY)
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{ $set: { '_acme.lastRenewalCheck': new Date() } },
|
|
{
|
|
upsert: false,
|
|
returnDocument: 'after',
|
|
projection: { _id: true, autogenerated: true, expires: true, servername: true }
|
|
}
|
|
);
|
|
|
|
if (r?.value) {
|
|
const certData = r.value;
|
|
const now = new Date();
|
|
|
|
if (certData.autogenerated && certData.expires < now) {
|
|
// delete expired automatic cert, do not try to renew it
|
|
try {
|
|
let r = await this.database.collection('certs').deleteOne({ _id: certData._id });
|
|
if (r?.deletedCount) {
|
|
this.loggelf({
|
|
short_message: `Deleted autogenerated certificate ${certData.cervername}`,
|
|
_sni_servername: certData.cervername,
|
|
_cert_action: 'sni_autodelete'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
//ignore
|
|
}
|
|
|
|
return await this.getNextRenewal();
|
|
}
|
|
|
|
// use getRecord to decrypt secrets
|
|
return await this.getRecord({ _id: certData._id }, true);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async update(options, updates, updateOptions) {
|
|
updateOptions = updateOptions || {};
|
|
let query = this.prepareQuery(options);
|
|
|
|
let r;
|
|
|
|
if (!updates || typeof updates !== 'object' || !Object.keys(updates).length) {
|
|
// nothing to do here
|
|
return false;
|
|
}
|
|
|
|
const changes = {
|
|
$set: Object.assign({}, updates)
|
|
};
|
|
|
|
let fp;
|
|
if (updates.privateKey) {
|
|
try {
|
|
fp = fingerprint(updates.privateKey, 'sha256', true);
|
|
} catch (E) {
|
|
let err = new Error('Invalid or incompatible private key. ' + E.message);
|
|
err.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
|
|
let encodedPrivateKey = await encrypt(updates.privateKey, this.secret);
|
|
|
|
changes.$set = Object.assign({}, updates, { fp, privateKey: encodedPrivateKey });
|
|
}
|
|
|
|
if (updateOptions.certUpdated) {
|
|
changes.$set['_acme.lastError'] = null;
|
|
}
|
|
|
|
if (updates.privateKey || updateOptions.certUpdated) {
|
|
changes.$inc = { v: 1 };
|
|
changes.$set.updated = new Date();
|
|
}
|
|
|
|
try {
|
|
r = await this.database.collection('certs').findOneAndUpdate(query, changes, {
|
|
upsert: false,
|
|
returnDocument: 'after'
|
|
});
|
|
} catch (err) {
|
|
if (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Failed to update Cert data');
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (updateOptions.certUpdated) {
|
|
this.loggelf({
|
|
short_message: `SNI cert updated for ${r.value.servername}`,
|
|
_sni_servername: r.value.servername,
|
|
_cert_action: 'update',
|
|
_cert_expires: r.value.expires && r.value.expires.toISOString()
|
|
});
|
|
}
|
|
|
|
if (this.redis && updates.cert) {
|
|
try {
|
|
await publish(this.redis, {
|
|
ev: CERT_UPDATED,
|
|
cert: r.value._id.toString(),
|
|
servername: r.value.servername,
|
|
fingerprint: r.value.fp
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async resetPrivateKey(options, acme) {
|
|
let query = this.prepareQuery(options);
|
|
|
|
let privateKey = await this.generateKey(acme.keyBits, acme.keyExponent);
|
|
|
|
let fp;
|
|
try {
|
|
fp = fingerprint(privateKey, 'sha256', true);
|
|
} catch (E) {
|
|
let err = new Error('Invalid or incompatible private key. ' + E.message);
|
|
err.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
|
|
let encodedPrivateKey = await encrypt(privateKey, this.secret);
|
|
|
|
let r;
|
|
try {
|
|
r = await this.database.collection('certs').findOneAndUpdate(
|
|
query,
|
|
{
|
|
$set: {
|
|
fp,
|
|
privateKey: encodedPrivateKey,
|
|
updated: new Date()
|
|
},
|
|
$inc: { v: 1 }
|
|
},
|
|
{
|
|
upsert: false,
|
|
returnDocument: 'after'
|
|
}
|
|
);
|
|
} catch (err) {
|
|
if (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Failed to insert Cert key');
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
return privateKey;
|
|
}
|
|
|
|
async set(options) {
|
|
// if not set then resolve from certificate
|
|
let servername = options.servername && tools.normalizeDomain(options.servername);
|
|
|
|
const description = options.description;
|
|
|
|
let privateKey = options.privateKey;
|
|
let cert = options.cert;
|
|
let ca = options.ca;
|
|
let primaryCert;
|
|
|
|
let certData = {
|
|
updated: new Date(),
|
|
acme: !!options.acme,
|
|
autogenerated: !!options.autogenerated
|
|
};
|
|
|
|
if (privateKey) {
|
|
try {
|
|
certData.fingerprint = fingerprint(privateKey, 'sha256', true);
|
|
} catch (E) {
|
|
let err = new Error('Invalid or incompatible private key. ' + E.message);
|
|
err.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
|
|
certData.privateKey = await encrypt(privateKey, this.secret);
|
|
}
|
|
|
|
if (cert) {
|
|
primaryCert = cert;
|
|
let certEnd = primaryCert.match(/END CERTIFICATE-+/);
|
|
if (certEnd) {
|
|
primaryCert = primaryCert.substr(0, certEnd.index + certEnd[0].length);
|
|
}
|
|
certData.cert = cert;
|
|
}
|
|
|
|
if (primaryCert) {
|
|
try {
|
|
const parsedCert = forge.pki.certificateFromPem(primaryCert);
|
|
|
|
certData.expires = new Date(parsedCert.validity.notAfter.toISOString());
|
|
certData.altNames = this.getAltNames(parsedCert);
|
|
|
|
if (!servername) {
|
|
servername = this.getCertName(parsedCert);
|
|
}
|
|
} catch (err) {
|
|
log.error('Certs', 'Failed to parse certificate. error=%s', err.stack);
|
|
}
|
|
}
|
|
|
|
if (!servername) {
|
|
let err = new Error('Invalid or missing servername');
|
|
err.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
|
|
if (!certData.altNames || !certData.altNames.length) {
|
|
certData.altNames = [servername];
|
|
}
|
|
|
|
if (description) {
|
|
certData.description = description;
|
|
}
|
|
|
|
if (privateKey && cert) {
|
|
try {
|
|
// should fail on invalid input
|
|
tls.createSecureContext({
|
|
key: privateKey,
|
|
cert,
|
|
ca
|
|
});
|
|
} catch (E) {
|
|
let err = new Error('Invalid or incompatible key and certificate. ' + E.message);
|
|
err.responseCode = 400;
|
|
err.code = 'InputValidationError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let r;
|
|
try {
|
|
r = await this.database.collection('certs').findOneAndUpdate(
|
|
{
|
|
servername
|
|
},
|
|
{
|
|
$set: certData,
|
|
$inc: { v: 1 },
|
|
$setOnInsert: {
|
|
servername,
|
|
created: new Date()
|
|
}
|
|
},
|
|
{
|
|
upsert: true,
|
|
returnDocument: 'after'
|
|
}
|
|
);
|
|
} catch (err) {
|
|
if (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Failed to insert Cert key');
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (r.lastErrorObject.upserted) {
|
|
this.loggelf({
|
|
short_message: `SNI cert created for ${r.value.servername}`,
|
|
_sni_servername: r.value.servername,
|
|
_cert_action: 'create',
|
|
_cert_expires: r.value.expires && r.value.expires.toISOString()
|
|
});
|
|
} else if (r.lastErrorObject.updatedExisting) {
|
|
this.loggelf({
|
|
short_message: `SNI cert updated for ${r.value.servername}`,
|
|
_sni_servername: r.value.servername,
|
|
_cert_action: 'update',
|
|
_cert_expires: r.value.expires && r.value.expires.toISOString()
|
|
});
|
|
}
|
|
|
|
if (this.redis && certData.cert) {
|
|
try {
|
|
if (r.lastErrorObject.upserted) {
|
|
await publish(this.redis, {
|
|
ev: CERT_CREATED,
|
|
cert: r.value._id.toString(),
|
|
servername,
|
|
fingerprint: certData.fingerprint || certData.fp
|
|
});
|
|
} else if (r.lastErrorObject.updatedExisting) {
|
|
await publish(this.redis, {
|
|
ev: CERT_UPDATED,
|
|
cert: r.value._id.toString(),
|
|
servername,
|
|
fingerprint: certData.fingerprint || certData.fp
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: r.value._id.toString(),
|
|
servername,
|
|
description: certData.description,
|
|
fingerprint: certData.fingerprint || certData.fp,
|
|
expires: certData.expires && certData.expires.toISOString(),
|
|
altNames: certData.altNames,
|
|
acme: certData.acme
|
|
};
|
|
}
|
|
|
|
async get(options, includePrivateKey) {
|
|
let certData = await this.getRecord(options, includePrivateKey);
|
|
|
|
let res = {
|
|
id: certData._id.toString(),
|
|
servername: certData.servername,
|
|
description: certData.description,
|
|
fingerprint: certData.fingerprint || certData.fp,
|
|
expires: certData.expires,
|
|
autogenerated: certData.autogenerated,
|
|
altNames: certData.altNames,
|
|
acme: !!certData.acme,
|
|
hasCert: (!!certData.privateKey && certData.cert) || false,
|
|
created: certData.created
|
|
};
|
|
|
|
if (includePrivateKey && certData.privateKey) {
|
|
res.privateKey = certData.privateKey;
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
async getRecord(options, includePrivateKey) {
|
|
let query = this.prepareQuery(options);
|
|
|
|
let certData;
|
|
try {
|
|
certData = await this.database.collection('certs').findOne(query);
|
|
} catch (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!certData) {
|
|
let err = new Error('Invalid or unknown cert');
|
|
err.responseCode = 404;
|
|
err.code = 'CertNotFound';
|
|
throw err;
|
|
}
|
|
|
|
if (includePrivateKey) {
|
|
certData.privateKey = await decrypt(certData.privateKey, this.secret, this.cipher);
|
|
} else {
|
|
delete certData.privateKey;
|
|
}
|
|
|
|
return certData;
|
|
}
|
|
|
|
async del(options) {
|
|
let query = this.prepareQuery(options);
|
|
|
|
// delete cert key from database
|
|
let r;
|
|
try {
|
|
r = await this.database.collection('certs').findOneAndDelete(query);
|
|
} catch (err) {
|
|
err.responseCode = 500;
|
|
err.code = 'InternalDatabaseError';
|
|
throw err;
|
|
}
|
|
|
|
if (!r.value) {
|
|
let err = new Error('Invalid or unknown cert');
|
|
err.responseCode = 404;
|
|
err.code = 'CertNotFound';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
await publish(this.redis, {
|
|
ev: CERT_DELETED,
|
|
cert: r.value._id,
|
|
servername: r.value.servername,
|
|
fingerprint: r.value.fingerprint || r.value.fp
|
|
});
|
|
} catch (err) {
|
|
// ignore?
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async getContextForServername(servername, serverOptions, meta) {
|
|
meta = meta || {};
|
|
let query = { servername };
|
|
|
|
let cachedContext = false;
|
|
if (this.ctxCache.has(servername)) {
|
|
cachedContext = this.ctxCache.get(servername);
|
|
if (cachedContext.entry && cachedContext.entry.v) {
|
|
// check for updates
|
|
query.v = { $ne: cachedContext.entry.v };
|
|
}
|
|
}
|
|
|
|
let sendLogs = payload => {
|
|
if (!payload || typeof payload !== 'object' || !Object.keys(payload).length) {
|
|
return;
|
|
}
|
|
|
|
this.loggelf(
|
|
Object.assign(
|
|
{
|
|
short_message: `SNI request for ${servername}`,
|
|
_sni_servername: servername,
|
|
_sni_source: meta.source
|
|
},
|
|
payload || {}
|
|
)
|
|
);
|
|
};
|
|
|
|
// search for exact servername match at first
|
|
let certData = await this.database.collection('certs').findOne(query);
|
|
if (!certData || !certData.privateKey || !certData.cert) {
|
|
if (cachedContext && cachedContext.context && cachedContext.entry && cachedContext.entry.servername === servername) {
|
|
// we have a valid cached context
|
|
sendLogs({
|
|
_sni_found: cachedContext.entry.servername,
|
|
_sni_match: 'yes',
|
|
_sni_cache: 'hit'
|
|
});
|
|
return cachedContext.context;
|
|
}
|
|
|
|
// try altNames as well
|
|
const altQuery = {
|
|
$or: [{ altNames: servername }]
|
|
};
|
|
|
|
if (servername.indexOf('.') >= 0) {
|
|
let wcMatch = '*' + servername.substr(servername.indexOf('.'));
|
|
altQuery.$or.push({ altNames: wcMatch });
|
|
}
|
|
|
|
if (query.v) {
|
|
altQuery.v = query.v;
|
|
}
|
|
|
|
certData = await this.database.collection('certs').findOne(altQuery, { sort: { expires: -1 } });
|
|
if (!certData || !certData.privateKey || !certData.cert) {
|
|
// 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.stack);
|
|
}
|
|
|
|
if (!certData || !certData.privateKey || !certData.cert) {
|
|
// still nothing, return whatever we have
|
|
sendLogs({
|
|
_sni_match: 'no',
|
|
_sni_cache: 'miss'
|
|
});
|
|
return (cachedContext && cachedContext.context) || false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// key might be encrypted
|
|
let privateKey = await decrypt(certData.privateKey, this.secret, this.cipher);
|
|
|
|
let serviceCtxOpts = {
|
|
key: privateKey,
|
|
cert: tools.buildCertChain(certData.cert, certData.ca)
|
|
};
|
|
|
|
for (let key of ['dhparam']) {
|
|
if (serverOptions[key]) {
|
|
serviceCtxOpts[key] = serverOptions[key];
|
|
}
|
|
}
|
|
|
|
let ctxOptions = tlsOptions(serviceCtxOpts);
|
|
|
|
let context = tls.createSecureContext(ctxOptions);
|
|
|
|
this.ctxCache.set(servername, { entry: certData, context });
|
|
|
|
sendLogs({
|
|
_sni_found: certData.servername,
|
|
_sni_match: 'yes',
|
|
_sni_cache: 'miss'
|
|
});
|
|
|
|
return context;
|
|
}
|
|
|
|
async precheckAcmeCertificate(domain) {
|
|
const dotPos = domain.indexOf('.');
|
|
if (dotPos < 0) {
|
|
// not a FQDN
|
|
return false;
|
|
}
|
|
|
|
const subdomain = domain.substring(0, dotPos).toLowerCase().trim();
|
|
const maindomain = tools.normalizeDomain(domain.substring(dotPos + 1));
|
|
|
|
if (!this.acme.autogenerate?.cnameMapping?.hasOwnProperty(subdomain)) {
|
|
log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain);
|
|
return false;
|
|
}
|
|
|
|
let subdomainTargets = [].concat(this.acme.autogenerate?.cnameMapping?.[subdomain] || []);
|
|
if (!subdomainTargets.length) {
|
|
// unsupported subdomain
|
|
log.verbose('Certs', 'Skip ACME. reason="unsupported subdomain" action=precheck domain=%s', domain);
|
|
return false;
|
|
}
|
|
|
|
// CNAME check
|
|
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.stack);
|
|
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 (!subdomainTargets.includes(row)) {
|
|
log.verbose('Certs', 'Skip ACME. reason="unknown CNAME target" action=precheck domain=%s target=%s', domain, row);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// CAA check
|
|
const caaDomains = this.acme.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;
|
|
}
|
|
|
|
async autogenerateAcmeCertificate(servername) {
|
|
let domain = tools.normalizeDomain(servername);
|
|
|
|
if (!this.acme.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);
|
|
if (!valid) {
|
|
return false;
|
|
}
|
|
|
|
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
|
|
let certInsertResult = await this.set({
|
|
servername,
|
|
autogenerated: true,
|
|
acme: true
|
|
});
|
|
|
|
if (certInsertResult) {
|
|
let certData = await getCertificate(servername, this.acme, this);
|
|
log.verbose('Certs', 'ACME certificate result. servername=%s status=%s', servername, certData && certData.status);
|
|
return certData || false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
module.exports = CertHandler;
|