mail: move dkim keys into the database

This commit is contained in:
Girish Ramakrishnan
2021-10-11 19:51:29 -07:00
parent a63e04359c
commit dc8ec9dcd8
7 changed files with 108 additions and 85 deletions

View File

@@ -13,6 +13,7 @@ exports = module.exports = {
getDomain,
clearDomains,
generateDkimKey,
onDomainAdded,
onDomainRemoved,
@@ -64,7 +65,6 @@ exports = module.exports = {
TYPE_ALIAS: 'alias',
_delByDomain: delByDomain,
_readDkimPublicKeySync: readDkimPublicKeySync,
_updateDomain: updateDomain
};
@@ -72,6 +72,7 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
cloudron = require('./cloudron.js'),
constants = require('./constants.js'),
crypto = require('crypto'),
database = require('./database.js'),
debug = require('debug')('box:mail'),
dns = require('./dns.js'),
@@ -84,6 +85,7 @@ const assert = require('assert'),
mysql = require('mysql'),
net = require('net'),
nodemailer = require('nodemailer'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
reverseProxy = require('./reverseproxy.js'),
@@ -105,7 +107,7 @@ const DNS_OPTIONS = { timeout: 5000 };
const REMOVE_MAILBOX_CMD = path.join(__dirname, 'scripts/rmmailbox.sh');
const MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain', 'active', 'enablePop3' ].join(',');
const MAILDB_FIELDS = [ 'domain', 'enabled', 'mailFromValidation', 'catchAllJson', 'relayJson', 'dkimSelector', 'bannerJson' ].join(',');
const MAILDB_FIELDS = [ 'domain', 'enabled', 'mailFromValidation', 'catchAllJson', 'relayJson', 'dkimKeyJson', 'dkimSelector', 'bannerJson' ].join(',');
function postProcessMailbox(data) {
data.members = safe.JSON.parse(data.membersJson) || [ ];
@@ -143,6 +145,9 @@ function postProcessDomain(data) {
data.banner = safe.JSON.parse(data.bannerJson) || { text: null, html: null };
delete data.bannerJson;
data.dkimKey = safe.JSON.parse(data.dkimKeyJson) || null;
delete data.dkimKeyJson;
return data;
}
@@ -259,13 +264,9 @@ async function checkDkim(mailDomain) {
errorMessage: ''
};
const dkimKey = readDkimPublicKeySync(domain);
if (!dkimKey) {
dkim.errorMessage = `Failed to read dkim public key of ${domain}`;
return dkim;
}
const publicKey = mailDomain.dkimKey.publicKey.split('\n').slice(1, -2).join(''); // remove header, footer and new lines
dkim.expected = 'v=DKIM1; t=s; p=' + dkimKey;
dkim.expected = `v=DKIM1; t=s; p=${publicKey}`;
const [error, txtRecords] = await safe(dns.promises.resolve(dkim.domain, dkim.type, DNS_OPTIONS));
if (error) {
@@ -276,7 +277,7 @@ async function checkDkim(mailDomain) {
if (txtRecords.length !== 0) {
dkim.value = txtRecords[0].join('');
const actual = txtToDict(dkim.value);
dkim.status = actual.p === dkimKey;
dkim.status = actual.p === publicKey;
}
return dkim;
@@ -620,13 +621,13 @@ async function createMailConfig(mailFqdn, mailDomain) {
const mailInDomains = mailDomains.filter(function (d) { return d.enabled; }).map(function (d) { return d.domain; }).join(',');
// mail_domain is used for SRS
if (!safe.fs.writeFileSync(path.join(paths.ADDON_CONFIG_DIR, 'mail/mail.ini'),
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/mail.ini`,
`mail_in_domains=${mailInDomains}\nmail_out_domains=${mailOutDomains}\nmail_server_name=${mailFqdn}\nmail_domain=${mailDomain}\n\n`, 'utf8')) {
throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
}
// enable_outbound makes plugin forward email for relayed mail. non-relayed mail always hits LMTP plugin first
if (!safe.fs.writeFileSync(path.join(paths.ADDON_CONFIG_DIR, 'mail/smtp_forward.ini'), 'enable_outbound=false\ndomain_selector=mail_from\n', 'utf8')) {
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/smtp_forward.ini`, 'enable_outbound=false\ndomain_selector=mail_from\n', 'utf8')) {
throw new BoxError(BoxError.FS_ERROR, `Could not create smtp forward file: ${safe.error.message}`);
}
@@ -635,13 +636,21 @@ async function createMailConfig(mailFqdn, mailDomain) {
const catchAll = domain.catchAll.map(function (c) { return `${c}@${domain.domain}`; }).join(',');
const mailFromValidation = domain.mailFromValidation;
if (!safe.fs.appendFileSync(path.join(paths.ADDON_CONFIG_DIR, 'mail/mail.ini'),
if (!safe.fs.appendFileSync(`${paths.MAIL_CONFIG_DIR}/mail.ini`,
`[${domain.domain}]\ncatch_all=${catchAll}\nmail_from_validation=${mailFromValidation}\n\n`, 'utf8')) {
throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
}
if (!safe.fs.writeFileSync(`${paths.ADDON_CONFIG_DIR}/mail/banner/${domain.domain}.text`, domain.banner.text || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create text banner file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(`${paths.ADDON_CONFIG_DIR}/mail/banner/${domain.domain}.html`, domain.banner.html || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create html banner file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/banner/${domain.domain}.text`, domain.banner.text || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create text banner file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/banner/${domain.domain}.html`, domain.banner.html || '')) throw new BoxError(BoxError.FS_ERROR, `Could not create html banner file: ${safe.error.message}`);
safe.fs.mkdirSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}`, { recursive: true });
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/public`, domain.dkimKey.publicKey)) throw new BoxError(BoxError.FS_ERROR, `Could not create public key file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/private`, domain.dkimKey.privateKey)) throw new BoxError(BoxError.FS_ERROR, `Could not create private key file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(`${paths.MAIL_CONFIG_DIR}/dkim/${domain.domain}/selector`, domain.dkimSelector)) throw new BoxError(BoxError.FS_ERROR, `Could not create selector file: ${safe.error.message}`);
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
if (!safe.fs.chmodSync(`${paths.MAIL_CONFIG_DIR}/mail/${domain.domain}/private`, 0o644)) return new BoxError(BoxError.FS_ERROR, safe.error);
const relay = domain.relay;
@@ -654,7 +663,7 @@ async function createMailConfig(mailFqdn, mailDomain) {
if (!enableRelay) continue;
if (!safe.fs.appendFileSync(paths.ADDON_CONFIG_DIR + '/mail/smtp_forward.ini',
if (!safe.fs.appendFileSync(paths.MAIL_CONFIG_DIR + '/smtp_forward.ini',
`[${domain.domain}]\nenable_outbound=true\nhost=${host}\nport=${port}\nenable_tls=true\nauth_type=${authType}\nauth_user=${username}\nauth_pass=${password}\n\n`, 'utf8')) {
throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`);
}
@@ -680,9 +689,9 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
const bundle = await reverseProxy.getCertificatePath(mailFqdn, mailDomain);
const dhparamsFilePath = path.join(paths.ADDON_CONFIG_DIR, 'mail/dhparams.pem');
const mailCertFilePath = path.join(paths.ADDON_CONFIG_DIR, 'mail/tls_cert.pem');
const mailKeyFilePath = path.join(paths.ADDON_CONFIG_DIR, 'mail/tls_key.pem');
const dhparamsFilePath = `${paths.MAIL_CONFIG_DIR}/dhparams.pem`;
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not copy dhparams:' + safe.error.message);
if (!safe.child_process.execSync(`cp ${bundle.certFilePath} ${mailCertFilePath}`)) throw new BoxError(BoxError.FS_ERROR, 'Could not create cert file:' + safe.error.message);
@@ -711,7 +720,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
-e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \
-e CLOUDRON_RELAY_TOKEN="${relayToken}" \
-v "${paths.MAIL_DATA_DIR}:/app/data" \
-v "${paths.PLATFORM_DATA_DIR}/addons/mail:/etc/mail" \
-v "${paths.MAIL_CONFIG_DIR}:/etc/mail" \
${ports} \
--label isCloudronManaged=true \
${readOnly} -v /run -v /tmp ${tag} ${cmd}`;
@@ -842,58 +851,23 @@ async function txtRecordsWithSpf(domain, mailFqdn) {
return txtRecords;
}
function ensureDkimKeySync(mailDomain) {
assert.strictEqual(typeof mailDomain, 'object');
const domain = mailDomain.domain;
const dkimPath = path.join(paths.MAIL_DATA_DIR, `dkim/${domain}`);
const dkimPrivateKeyFile = path.join(dkimPath, 'private');
const dkimPublicKeyFile = path.join(dkimPath, 'public');
const dkimSelectorFile = path.join(dkimPath, 'selector');
if (safe.fs.existsSync(dkimPublicKeyFile) &&
safe.fs.existsSync(dkimPublicKeyFile) &&
safe.fs.existsSync(dkimPublicKeyFile)) {
debug(`Reusing existing DKIM keys for ${domain}`);
return null;
}
debug(`Generating new DKIM keys for ${domain}`);
if (!safe.fs.mkdirSync(dkimPath) && safe.error.code !== 'EEXIST') {
debug('Error creating dkim.', safe.error);
return new BoxError(BoxError.FS_ERROR, safe.error);
}
async function generateDkimKey() {
const publicKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.public`);
const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`);
// https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size
if (!safe.child_process.execSync('openssl genrsa -out ' + dkimPrivateKeyFile + ' 1024')) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.child_process.execSync('openssl rsa -in ' + dkimPrivateKeyFile + ' -out ' + dkimPublicKeyFile + ' -pubout -outform PEM')) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.child_process.execSync(`openssl genrsa -out ${privateKeyFilePath} 1024`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.child_process.execSync(`openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.fs.writeFileSync(dkimSelectorFile, mailDomain.dkimSelector, 'utf8')) return new BoxError(BoxError.FS_ERROR, safe.error);
const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8');
if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
safe.fs.unlinkSync(publicKeyFilePath);
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
if (!safe.fs.chmodSync(dkimPrivateKeyFile, 0o644)) return new BoxError(BoxError.FS_ERROR, safe.error);
const privateKey = safe.fs.readFileSync(privateKeyFilePath, 'utf8');
if (!privateKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
safe.fs.unlinkSync(privateKeyFilePath);
return null;
}
function readDkimPublicKeySync(domain) {
assert.strictEqual(typeof domain, 'string');
var dkimPath = path.join(paths.MAIL_DATA_DIR, `dkim/${domain}`);
var dkimPublicKeyFile = path.join(dkimPath, 'public');
var publicKey = safe.fs.readFileSync(dkimPublicKeyFile, 'utf8');
if (publicKey === null) {
debug('Error reading dkim public key.', safe.error);
return null;
}
// remove header, footer and new lines
publicKey = publicKey.split('\n').slice(1, -2).join('');
return publicKey;
return { publicKey, privateKey };
}
async function upsertDnsRecords(domain, mailFqdn) {
@@ -905,18 +879,14 @@ async function upsertDnsRecords(domain, mailFqdn) {
const mailDomain = await getDomain(domain);
if (!mailDomain) throw new BoxError(BoxError.NOT_FOUND, 'mail domain not found');
let error = ensureDkimKeySync(mailDomain);
if (error) throw error;
if (process.env.BOX_ENV === 'test') return;
const dkimKey = readDkimPublicKeySync(domain);
if (!dkimKey) throw new BoxError(BoxError.FS_ERROR, 'Failed to read dkim public key');
const publicKey = mailDomain.dkimKey.publicKey.split('\n').slice(1, -2).join(''); // remove header, footer and new lines
// t=s limits the domainkey to this domain and not it's subdomains
const dkimRecord = { subdomain: `${mailDomain.dkimSelector}._domainkey`, domain: domain, type: 'TXT', values: [ `"v=DKIM1; t=s; p=${dkimKey}"` ] };
const dkimRecord = { subdomain: `${mailDomain.dkimSelector}._domainkey`, domain: domain, type: 'TXT', values: [ `"v=DKIM1; t=s; p=${publicKey}"` ] };
let records = [];
const records = [];
records.push(dkimRecord);
if (mailDomain.enabled) records.push({ subdomain: '', domain: domain, type: 'MX', values: [ '10 ' + mailFqdn + '.' ] });
@@ -997,7 +967,7 @@ async function onDomainAdded(domain) {
if (!settings.mailFqdn()) return; // mail domain is not set yet (when provisioning)
await upsertDnsRecords(domain, settings.mailFqdn()); // do this first to ensure DKIM keys
await upsertDnsRecords(domain, settings.mailFqdn());
await restartMailIfActivated();
}