diff --git a/src/domains.js b/src/domains.js index b3c5f0382..1b5e8031f 100644 --- a/src/domains.js +++ b/src/domains.js @@ -22,7 +22,7 @@ const assert = require('assert'), database = require('./database.js'), debug = require('debug')('box:domains'), eventlog = require('./eventlog.js'), - mail = require('./mail.js'), + mailServer = require('./mailserver.js'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), @@ -149,7 +149,7 @@ async function add(domain, data, auditSource) { let error = validateTlsConfig(tlsConfig, provider); if (error) throw error; - const dkimKey = await mail.generateDkimKey(); + const dkimKey = await mailServer.generateDkimKey(); if (!dkimSelector) { // create a unique suffix. this lets one add this domain can be added in another cloudron instance and not have their dkim selector conflict @@ -173,7 +173,7 @@ async function add(domain, data, auditSource) { await eventlog.add(eventlog.ACTION_DOMAIN_ADD, auditSource, { domain, zoneName, provider }); - safe(mail.onDomainAdded(domain), { debug }); // background + safe(mailServer.onDomainAdded(domain), { debug }); // background } async function get(domain) { @@ -290,7 +290,7 @@ async function del(domain, auditSource) { await eventlog.add(eventlog.ACTION_DOMAIN_REMOVE, auditSource, { domain }); - safe(mail.onDomainRemoved(domain)); + safe(mailServer.onDomainRemoved(domain)); } async function clear() { diff --git a/src/mail.js b/src/mail.js index 6989ca528..ae16f49d0 100644 --- a/src/mail.js +++ b/src/mail.js @@ -4,22 +4,15 @@ exports = module.exports = { getStatus, checkConfiguration, - getLocation, - setLocation, // triggers the change task - changeLocation, // does the actual changing - listDomains, getDomain, clearDomains, - generateDkimKey, - onDomainAdded, - onDomainRemoved, - removePrivateFields, setDnsRecords, + upsertDnsRecords, validateName, validateDisplayName, @@ -30,11 +23,6 @@ exports = module.exports = { setMailEnabled, setBanner, - startMail, - restartMail, - checkCertificate, - getMailAuth, - sendTestMail, getMailboxCount, @@ -63,8 +51,6 @@ exports = module.exports = { OWNERTYPE_GROUP: 'group', OWNERTYPE_APP: 'app', - DEFAULT_MEMORY_LIMIT: 512 * 1024 * 1024, - TYPE_MAILBOX: 'mailbox', TYPE_LIST: 'list', TYPE_ALIAS: 'alias', @@ -75,36 +61,25 @@ exports = module.exports = { 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'), dig = require('./dig.js'), dns = require('./dns.js'), - docker = require('./docker.js'), - domains = require('./domains.js'), eventlog = require('./eventlog.js'), - hat = require('./hat.js'), - infra = require('./infra_version.js'), mailer = require('./mailer.js'), + mailServer = require('./mailserver.js'), mysql = require('mysql'), net = require('net'), network = require('./network.js'), nodemailer = require('nodemailer'), notifications = require('./notifications.js'), - os = require('os'), path = require('path'), - paths = require('./paths.js'), - reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), services = require('./services.js'), settings = require('./settings.js'), shell = require('./shell.js'), superagent = require('superagent'), - system = require('./system.js'), - tasks = require('./tasks.js'), - users = require('./users.js'), validator = require('validator'), _ = require('underscore'); @@ -635,191 +610,6 @@ async function checkConfiguration() { return { status: markdownMessage === '', message: markdownMessage }; } -async function createMailConfig(mailFqdn) { - assert.strictEqual(typeof mailFqdn, 'string'); - - debug(`createMailConfig: generating mail config with ${mailFqdn}`); - - const mailDomains = await listDomains(); - - const mailOutDomains = mailDomains.filter(d => d.relay.provider !== 'noop').map(d => d.domain).join(','); - 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(`${paths.MAIL_CONFIG_DIR}/mail.ini`, - `mail_in_domains=${mailInDomains}\nmail_out_domains=${mailOutDomains}\nmail_server_name=${mailFqdn}\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(`${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}`); - } - - // create sections for per-domain configuration - for (const domain of mailDomains) { - const catchAll = domain.catchAll.join(','); - const mailFromValidation = domain.mailFromValidation; - - 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.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}/dkim/${domain.domain}/private`, 0o644)) throw new BoxError(BoxError.FS_ERROR, safe.error); - - const relay = domain.relay; - - const enableRelay = relay.provider !== 'cloudron-smtp' && relay.provider !== 'noop', - host = relay.host || '', - port = relay.port || 25, - // office365 removed plain auth (https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145) - authType = relay.username ? (relay.provider === 'office365-legacy-smtp' ? 'login' : 'plain') : '', - username = relay.username || '', - password = relay.password || '', - forceFromAddress = relay.forceFromAddress ? 'true' : 'false'; - - if (!enableRelay) continue; - - const relayData = `[${domain.domain}]\nenable_outbound=true\nhost=${host}\nport=${port}\nenable_tls=true\nauth_type=${authType}\nauth_user=${username}\nauth_pass=${password}\nforce_from_address=${forceFromAddress}\n\n`; - - if (!safe.fs.appendFileSync(paths.MAIL_CONFIG_DIR + '/smtp_forward.ini', relayData, 'utf8')) { - throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`); - } - } - - return mailInDomains.length !== 0 /* allowInbound */; -} - -async function configureMail(mailFqdn, mailDomain, serviceConfig) { - assert.strictEqual(typeof mailFqdn, 'string'); - assert.strictEqual(typeof mailDomain, 'string'); - assert.strictEqual(typeof serviceConfig, 'object'); - - // mail (note: 2587 is hardcoded in mail container and app use this port) - // MAIL_SERVER_NAME is the hostname of the mailserver i.e server uses these certs - // MAIL_DOMAIN is the domain for which this server is relaying mails - // mail container uses /app/data for backed up data and /run for restart-able data - - const tag = infra.images.mail.tag; - const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT; - const memory = await system.getMemoryAllocation(memoryLimit); - const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128); - - const certificate = await reverseProxy.getMailCertificate(); - - 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.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`); - if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key 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(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`); - - await shell.promises.exec('stopMail', 'docker stop mail || true'); - await shell.promises.exec('removeMail', 'docker rm -f mail || true'); - - const allowInbound = await createMailConfig(mailFqdn); - - const ports = allowInbound ? '-p 587:2587 -p 993:9993 -p 4190:4190 -p 25:2587 -p 465:2465 -p 995:9995' : ''; - const readOnly = !serviceConfig.recoveryMode ? '--read-only' : ''; - const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : ''; - const logLevel = serviceConfig.recoveryMode ? 'data' : 'info'; - - const runCmd = `docker run --restart=always -d --name="mail" \ - --net cloudron \ - --net-alias mail \ - --log-driver syslog \ - --log-opt syslog-address=udp://127.0.0.1:2514 \ - --log-opt syslog-format=rfc5424 \ - --log-opt tag=mail \ - -m ${memory} \ - --memory-swap ${memoryLimit} \ - --dns 172.18.0.1 \ - --dns-search=. \ - -e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \ - -e CLOUDRON_RELAY_TOKEN="${relayToken}" \ - -e LOGLEVEL=${logLevel} \ - -v "${paths.MAIL_DATA_DIR}:/app/data" \ - -v "${paths.MAIL_CONFIG_DIR}:/etc/mail:ro" \ - ${ports} \ - --label isCloudronManaged=true \ - ${readOnly} -v /run -v /tmp ${tag} ${cmd}`; - - await shell.promises.exec('startMail', runCmd); -} - -async function getMailAuth() { - const data = await docker.inspect('mail'); - const ip = safe.query(data, 'NetworkSettings.Networks.cloudron.IPAddress'); - if (!ip) throw new BoxError(BoxError.MAIL_ERROR, 'Error querying mail server IP'); - - // extract the relay token for auth - const env = safe.query(data, 'Config.Env', null); - if (!env) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting mail env'); - const tmp = env.find(function (e) { return e.indexOf('CLOUDRON_RELAY_TOKEN') === 0; }); - if (!tmp) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting CLOUDRON_RELAY_TOKEN env var'); - const relayToken = tmp.slice('CLOUDRON_RELAY_TOKEN'.length + 1); // +1 for the = sign - if (!relayToken) throw new BoxError(BoxError.MAIL_ERROR, 'Error parsing CLOUDRON_RELAY_TOKEN'); - - return { - ip, - port: constants.INTERNAL_SMTP_PORT, - relayToken - }; -} - -async function restartMail() { - if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return; - - const mailConfig = services.getServiceConfig('mail'); - debug(`restartMail: restarting mail container with mailFqdn:${settings.mailFqdn()} mailDomain:${settings.mailDomain()}`); - await configureMail(settings.mailFqdn(), settings.mailDomain(), mailConfig); -} - -async function startMail(existingInfra) { - assert.strictEqual(typeof existingInfra, 'object'); - - debug('startMail: starting'); - await restartMail(); -} - -async function restartMailIfActivated() { - const activated = await users.isActivated(); - - if (!activated) { - debug('restartMailIfActivated: skipping restart of mail container since Cloudron is not activated yet'); - return; // not provisioned yet, do not restart container after dns setup - } - - debug('restartMailIfActivated: restarting on activated'); - await restartMail(); -} - -async function checkCertificate() { - const certificate = await reverseProxy.getMailCertificate(); - const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`, { encoding: 'utf8' }); - if (cert === certificate.cert) { - debug('checkCertificate: certificate has not changed'); - return; - } - debug('checkCertificate: certificate has changed'); - - await restartMailIfActivated(); -} - async function getDomain(domain) { assert.strictEqual(typeof domain, 'string'); @@ -891,25 +681,6 @@ async function txtRecordsWithSpf(domain, mailFqdn) { return txtRecords; } -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 ${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); - - const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8'); - if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message); - safe.fs.unlinkSync(publicKeyFilePath); - - const privateKey = safe.fs.readFileSync(privateKeyFilePath, 'utf8'); - if (!privateKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message); - safe.fs.unlinkSync(privateKeyFilePath); - - return { publicKey, privateKey }; -} - async function upsertDnsRecords(domain, mailFqdn) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof mailFqdn, 'string'); @@ -951,78 +722,6 @@ async function setDnsRecords(domain) { await upsertDnsRecords(domain, settings.mailFqdn()); } -async function getLocation() { - const domain = settings.mailDomain(), fqdn = settings.mailFqdn(); - const subdomain = fqdn.substr(0, fqdn.length - domain.length - 1); - - return { domain, subdomain }; -} - -async function changeLocation(auditSource, progressCallback) { - assert.strictEqual(typeof auditSource, 'object'); - assert.strictEqual(typeof progressCallback, 'function'); - - const fqdn = settings.mailFqdn(), domain = settings.mailDomain(); - const subdomain = fqdn.substr(0, fqdn.length - domain.length - 1); - - let progress = 20; - progressCallback({ percent: progress, message: `Setting up DNS of certs of mail server ${fqdn}` }); - - await cloudron.setupDnsAndCert(subdomain, domain, auditSource, (progress) => progressCallback({ message: progress.message })); // remove the percent - const allDomains = await domains.list(); - - for (let idx = 0; idx < allDomains.length; idx++) { - const domainObject = allDomains[idx]; - progressCallback({ percent: progress, message: `Updating DNS of ${domainObject.domain}` }); - progress += Math.round(70/allDomains.length); - - const [error] = await safe(upsertDnsRecords(domainObject.domain, fqdn)); // ignore any errors. we anyway report dns errors in status tab - progressCallback({ percent: progress, message: `Updated DNS of ${domainObject.domain}: ${error ? error.message : 'success'}` }); - } - - progressCallback({ percent: 90, message: 'Restarting mail server' }); - - await restartMailIfActivated(); -} - -async function setLocation(subdomain, domain, auditSource) { - assert.strictEqual(typeof subdomain, 'string'); - assert.strictEqual(typeof domain, 'string'); - assert.strictEqual(typeof auditSource, 'object'); - - const domainObjectMap = await domains.getDomainObjectMap(); - if (!(domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain '${domain}'`); - const error = dns.validateHostname(subdomain, domain); - if (error) throw new BoxError(BoxError.BAD_FIELD, `Bad mail location: ${error.message}`); - - const fqdn = dns.fqdn(subdomain, domain); - - await settings.setMailLocation(domain, fqdn); - - const taskId = await tasks.add(tasks.TASK_CHANGE_MAIL_LOCATION, [ auditSource ]); - tasks.startTask(taskId, {}); - await eventlog.add(eventlog.ACTION_MAIL_LOCATION, auditSource, { subdomain, domain, taskId }); - - return taskId; -} - -async function onDomainAdded(domain) { - assert.strictEqual(typeof domain, 'string'); - - if (!settings.mailFqdn()) return; // mail domain is not set yet (when provisioning) - - debug(`onDomainAdded: configuring mail for added domain ${domain}`); - await upsertDnsRecords(domain, settings.mailFqdn()); - await restartMailIfActivated(); -} - -async function onDomainRemoved(domain) { - assert.strictEqual(typeof domain, 'string'); - - debug(`onDomainRemoved: configuring mail for removed domain ${domain}`); - await restartMail(); -} - async function clearDomains() { await database.query('DELETE FROM mail', []); } @@ -1043,7 +742,7 @@ async function setMailFromValidation(domain, enabled) { await updateDomain(domain, { mailFromValidation: enabled }); - safe(restartMail(), { debug }); // have to restart mail container since haraka cannot watch symlinked config files (mail.ini) + safe(mailServer.restart(), { debug }); // have to restart mail container since haraka cannot watch symlinked config files (mail.ini) } async function setBanner(domain, banner) { @@ -1052,7 +751,7 @@ async function setBanner(domain, banner) { await updateDomain(domain, { banner }); - safe(restartMail(), { debug }); + safe(mailServer.restart(), { debug }); } async function setCatchAllAddress(domain, addresses) { @@ -1065,7 +764,7 @@ async function setCatchAllAddress(domain, addresses) { await updateDomain(domain, { catchAll: addresses }); - safe(restartMail(), { debug }); // have to restart mail container since haraka cannot watch symlinked config files (mail.ini) + safe(mailServer.restart(), { debug }); // have to restart mail container since haraka cannot watch symlinked config files (mail.ini) } async function setMailRelay(domain, relay, options) { @@ -1089,7 +788,7 @@ async function setMailRelay(domain, relay, options) { await updateDomain(domain, { relay }); - safe(restartMail(), { debug }); + safe(mailServer.restart(), { debug }); } async function setMailEnabled(domain, enabled, auditSource) { @@ -1099,7 +798,7 @@ async function setMailEnabled(domain, enabled, auditSource) { await updateDomain(domain, { enabled: enabled }); - safe(restartMail(), { debug }); + safe(mailServer.restart(), { debug }); await eventlog.add(enabled ? eventlog.ACTION_MAIL_ENABLED : eventlog.ACTION_MAIL_DISABLED, auditSource, { domain }); } diff --git a/src/mailer.js b/src/mailer.js index 5b2b485d7..d7057c264 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -20,7 +20,7 @@ const assert = require('assert'), branding = require('./branding.js'), debug = require('debug')('box:mailer'), ejs = require('ejs'), - mail = require('./mail.js'), + mailServer = require('./mailserver.js'), nodemailer = require('nodemailer'), path = require('path'), safe = require('safetydance'), @@ -51,7 +51,7 @@ async function sendMail(mailOptions) { return; } - const data = await mail.getMailAuth(); + const data = await mailServer.getMailAuth(); const transport = nodemailer.createTransport({ host: data.ip, diff --git a/src/mailserver.js b/src/mailserver.js new file mode 100644 index 000000000..60eefd118 --- /dev/null +++ b/src/mailserver.js @@ -0,0 +1,322 @@ +'use strict'; + +exports = module.exports = { + restart, + start, + + generateDkimKey, + + onDomainAdded, + onDomainRemoved, + + checkCertificate, + + getMailAuth, + + getLocation, + setLocation, // triggers the change task + changeLocation, // does the actual changing + + DEFAULT_MEMORY_LIMIT: 512 * 1024 * 1024, +}; + +const assert = require('assert'), + BoxError = require('./boxerror.js'), + cloudron = require('./cloudron.js'), + constants = require('./constants.js'), + crypto = require('crypto'), + debug = require('debug')('box:mailserver'), + dns = require('./dns.js'), + docker = require('./docker.js'), + domains = require('./domains.js'), + eventlog = require('./eventlog.js'), + hat = require('./hat.js'), + infra = require('./infra_version.js'), + mail = require('./mail.js'), + os = require('os'), + path = require('path'), + paths = require('./paths.js'), + reverseProxy = require('./reverseproxy.js'), + safe = require('safetydance'), + services = require('./services.js'), + settings = require('./settings.js'), + shell = require('./shell.js'), + system = require('./system.js'), + tasks = require('./tasks.js'), + users = require('./users.js'); + +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 ${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); + + const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8'); + if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message); + safe.fs.unlinkSync(publicKeyFilePath); + + const privateKey = safe.fs.readFileSync(privateKeyFilePath, 'utf8'); + if (!privateKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message); + safe.fs.unlinkSync(privateKeyFilePath); + + return { publicKey, privateKey }; +} + +async function createMailConfig(mailFqdn) { + assert.strictEqual(typeof mailFqdn, 'string'); + + debug(`createMailConfig: generating mail config with ${mailFqdn}`); + + const mailDomains = await mail.listDomains(); + + const mailOutDomains = mailDomains.filter(d => d.relay.provider !== 'noop').map(d => d.domain).join(','); + 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(`${paths.MAIL_CONFIG_DIR}/mail.ini`, + `mail_in_domains=${mailInDomains}\nmail_out_domains=${mailOutDomains}\nmail_server_name=${mailFqdn}\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(`${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}`); + } + + // create sections for per-domain configuration + for (const domain of mailDomains) { + const catchAll = domain.catchAll.join(','); + const mailFromValidation = domain.mailFromValidation; + + 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.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}/dkim/${domain.domain}/private`, 0o644)) throw new BoxError(BoxError.FS_ERROR, safe.error); + + const relay = domain.relay; + + const enableRelay = relay.provider !== 'cloudron-smtp' && relay.provider !== 'noop', + host = relay.host || '', + port = relay.port || 25, + // office365 removed plain auth (https://support.microsoft.com/en-us/office/outlook-com-no-longer-supports-auth-plain-authentication-07f7d5e9-1697-465f-84d2-4513d4ff0145) + authType = relay.username ? (relay.provider === 'office365-legacy-smtp' ? 'login' : 'plain') : '', + username = relay.username || '', + password = relay.password || '', + forceFromAddress = relay.forceFromAddress ? 'true' : 'false'; + + if (!enableRelay) continue; + + const relayData = `[${domain.domain}]\nenable_outbound=true\nhost=${host}\nport=${port}\nenable_tls=true\nauth_type=${authType}\nauth_user=${username}\nauth_pass=${password}\nforce_from_address=${forceFromAddress}\n\n`; + + if (!safe.fs.appendFileSync(paths.MAIL_CONFIG_DIR + '/smtp_forward.ini', relayData, 'utf8')) { + throw new BoxError(BoxError.FS_ERROR, `Could not create mail var file: ${safe.error.message}`); + } + } + + return mailInDomains.length !== 0 /* allowInbound */; +} + +async function configureMail(mailFqdn, mailDomain, serviceConfig) { + assert.strictEqual(typeof mailFqdn, 'string'); + assert.strictEqual(typeof mailDomain, 'string'); + assert.strictEqual(typeof serviceConfig, 'object'); + + // mail (note: 2587 is hardcoded in mail container and app use this port) + // MAIL_SERVER_NAME is the hostname of the mailserver i.e server uses these certs + // MAIL_DOMAIN is the domain for which this server is relaying mails + // mail container uses /app/data for backed up data and /run for restart-able data + + const tag = infra.images.mail.tag; + const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT; + const memory = await system.getMemoryAllocation(memoryLimit); + const cloudronToken = hat(8 * 128), relayToken = hat(8 * 128); + + const certificate = await reverseProxy.getMailCertificate(); + + 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.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`); + if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key 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(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`); + + await shell.promises.exec('stopMail', 'docker stop mail || true'); + await shell.promises.exec('removeMail', 'docker rm -f mail || true'); + + const allowInbound = await createMailConfig(mailFqdn); + + const ports = allowInbound ? '-p 587:2587 -p 993:9993 -p 4190:4190 -p 25:2587 -p 465:2465 -p 995:9995' : ''; + const readOnly = !serviceConfig.recoveryMode ? '--read-only' : ''; + const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : ''; + const logLevel = serviceConfig.recoveryMode ? 'data' : 'info'; + + const runCmd = `docker run --restart=always -d --name="mail" \ + --net cloudron \ + --net-alias mail \ + --log-driver syslog \ + --log-opt syslog-address=udp://127.0.0.1:2514 \ + --log-opt syslog-format=rfc5424 \ + --log-opt tag=mail \ + -m ${memory} \ + --memory-swap ${memoryLimit} \ + --dns 172.18.0.1 \ + --dns-search=. \ + -e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \ + -e CLOUDRON_RELAY_TOKEN="${relayToken}" \ + -e LOGLEVEL=${logLevel} \ + -v "${paths.MAIL_DATA_DIR}:/app/data" \ + -v "${paths.MAIL_CONFIG_DIR}:/etc/mail:ro" \ + ${ports} \ + --label isCloudronManaged=true \ + ${readOnly} -v /run -v /tmp ${tag} ${cmd}`; + + await shell.promises.exec('startMail', runCmd); +} + +async function restart() { + if (process.env.BOX_ENV === 'test' && !process.env.TEST_CREATE_INFRA) return; + + const mailConfig = await services.getServiceConfig('mail'); + debug(`restart: restarting mail container with mailFqdn:${settings.mailFqdn()} mailDomain:${settings.mailDomain()}`); + await configureMail(settings.mailFqdn(), settings.mailDomain(), mailConfig); +} + +async function start(existingInfra) { + assert.strictEqual(typeof existingInfra, 'object'); + + debug('startMail: starting'); + await restart(); +} + +async function restartIfActivated() { + const activated = await users.isActivated(); + + if (!activated) { + debug('restartMailIfActivated: skipping restart of mail container since Cloudron is not activated yet'); + return; // not provisioned yet, do not restart container after dns setup + } + + debug('restartMailIfActivated: restarting on activated'); + await restart(); +} + +async function onDomainAdded(domain) { + assert.strictEqual(typeof domain, 'string'); + + if (!settings.mailFqdn()) return; // mail domain is not set yet (when provisioning) + + debug(`onDomainAdded: configuring mail for added domain ${domain}`); + await mail.upsertDnsRecords(domain, settings.mailFqdn()); + await restartIfActivated(); +} + +async function onDomainRemoved(domain) { + assert.strictEqual(typeof domain, 'string'); + + debug(`onDomainRemoved: configuring mail for removed domain ${domain}`); + await restart(); +} + +async function checkCertificate() { + const certificate = await reverseProxy.getMailCertificate(); + const cert = safe.fs.readFileSync(`${paths.MAIL_CONFIG_DIR}/tls_cert.pem`, { encoding: 'utf8' }); + if (cert === certificate.cert) { + debug('checkCertificate: certificate has not changed'); + return; + } + debug('checkCertificate: certificate has changed'); + + await restartIfActivated(); +} + +async function getLocation() { + const domain = settings.mailDomain(), fqdn = settings.mailFqdn(); + const subdomain = fqdn.substr(0, fqdn.length - domain.length - 1); + + return { domain, subdomain }; +} + +async function changeLocation(auditSource, progressCallback) { + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof progressCallback, 'function'); + + const fqdn = settings.mailFqdn(), domain = settings.mailDomain(); + const subdomain = fqdn.substr(0, fqdn.length - domain.length - 1); + + let progress = 20; + progressCallback({ percent: progress, message: `Setting up DNS of certs of mail server ${fqdn}` }); + + await cloudron.setupDnsAndCert(subdomain, domain, auditSource, (progress) => progressCallback({ message: progress.message })); // remove the percent + const allDomains = await domains.list(); + + for (let idx = 0; idx < allDomains.length; idx++) { + const domainObject = allDomains[idx]; + progressCallback({ percent: progress, message: `Updating DNS of ${domainObject.domain}` }); + progress += Math.round(70/allDomains.length); + + const [error] = await safe(mail.upsertDnsRecords(domainObject.domain, fqdn)); // ignore any errors. we anyway report dns errors in status tab + progressCallback({ percent: progress, message: `Updated DNS of ${domainObject.domain}: ${error ? error.message : 'success'}` }); + } + + progressCallback({ percent: 90, message: 'Restarting mail server' }); + + await restartIfActivated(); +} + +async function setLocation(subdomain, domain, auditSource) { + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof domain, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + + const domainObjectMap = await domains.getDomainObjectMap(); + if (!(domain in domainObjectMap)) throw new BoxError(BoxError.BAD_FIELD, `No such domain '${domain}'`); + const error = dns.validateHostname(subdomain, domain); + if (error) throw new BoxError(BoxError.BAD_FIELD, `Bad mail location: ${error.message}`); + + const fqdn = dns.fqdn(subdomain, domain); + + await settings.setMailLocation(domain, fqdn); + + const taskId = await tasks.add(tasks.TASK_CHANGE_MAIL_LOCATION, [ auditSource ]); + tasks.startTask(taskId, {}); + await eventlog.add(eventlog.ACTION_MAIL_LOCATION, auditSource, { subdomain, domain, taskId }); + + return taskId; +} + +async function getMailAuth() { + const data = await docker.inspect('mail'); + const ip = safe.query(data, 'NetworkSettings.Networks.cloudron.IPAddress'); + if (!ip) throw new BoxError(BoxError.MAIL_ERROR, 'Error querying mail server IP'); + + // extract the relay token for auth + const env = safe.query(data, 'Config.Env', null); + if (!env) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting mail env'); + const tmp = env.find(function (e) { return e.indexOf('CLOUDRON_RELAY_TOKEN') === 0; }); + if (!tmp) throw new BoxError(BoxError.MAIL_ERROR, 'Error getting CLOUDRON_RELAY_TOKEN env var'); + const relayToken = tmp.slice('CLOUDRON_RELAY_TOKEN'.length + 1); // +1 for the = sign + if (!relayToken) throw new BoxError(BoxError.MAIL_ERROR, 'Error parsing CLOUDRON_RELAY_TOKEN'); + + return { + ip, + port: constants.INTERNAL_SMTP_PORT, + relayToken + }; +} diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 285ef0fd8..b989ed27a 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -48,7 +48,7 @@ const acme2 = require('./acme2.js'), ejs = require('ejs'), eventlog = require('./eventlog.js'), fs = require('fs'), - mail = require('./mail.js'), + mailServer = require('./mailserver.js'), network = require('./network.js'), os = require('os'), path = require('path'), @@ -173,7 +173,7 @@ function validateCertificate(subdomain, domain, certificate) { } async function notifyCertChange() { - await mail.checkCertificate(); + await mailServer.checkCertificate(); await shell.promises.sudo('notifyCertChange', [ RESTART_SERVICE_CMD, 'box' ], {}); // directory server const allApps = (await apps.list()).filter(app => app.runState !== apps.RSTATE_STOPPED); for (const app of allApps) { diff --git a/src/routes/mailserver.js b/src/routes/mailserver.js index 9217db5be..59de37421 100644 --- a/src/routes/mailserver.js +++ b/src/routes/mailserver.js @@ -15,7 +15,7 @@ const assert = require('assert'), debug = require('debug')('box:routes/mailserver'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, - mail = require('../mail.js'), + mailServer = require('../mailserver.js'), middleware = require('../middleware/index.js'), safe = require('safetydance'), services = require('../services.js'), @@ -23,7 +23,7 @@ const assert = require('assert'), // because of how the proxy middleware works, the http response is already sent by the time this function is called async function restart(req, res, next) { - await safe(mail.restartMail(), { debug }); + await safe(mailServer.restartMail(), { debug }); next(); } @@ -66,7 +66,7 @@ async function queueProxy(req, res, next) { } async function getLocation(req, res, next) { - const [error, result] = await safe(mail.getLocation()); + const [error, result] = await safe(mailServer.getLocation()); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, { domain: result.domain, subdomain: result.subdomain })); @@ -78,7 +78,7 @@ async function setLocation(req, res, next) { if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string')); if (typeof req.body.subdomain !== 'string') return next(new HttpError(400, 'subdomain must be a string')); - const [error, taskId] = await safe(mail.setLocation(req.body.subdomain, req.body.domain, AuditSource.fromRequest(req))); + const [error, taskId] = await safe(mailServer.setLocation(req.body.subdomain, req.body.domain, AuditSource.fromRequest(req))); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(202, { taskId })); diff --git a/src/services.js b/src/services.js index 588d6d05a..84fb7835e 100644 --- a/src/services.js +++ b/src/services.js @@ -49,6 +49,7 @@ const addonConfigs = require('./addonconfigs.js'), infra = require('./infra_version.js'), logs = require('./logs.js'), mail = require('./mail.js'), + mailServer = require('./mailserver.js'), oidc = require('./oidc.js'), os = require('os'), path = require('path'), @@ -211,8 +212,8 @@ const SERVICES = { }, mail: { status: containerStatus.bind(null, 'mail', 'CLOUDRON_MAIL_TOKEN'), - restart: mail.restartMail, - defaultMemoryLimit: mail.DEFAULT_MEMORY_LIMIT + restart: mailServer.restart, + defaultMemoryLimit: mailServer.DEFAULT_MEMORY_LIMIT }, mongodb: { status: containerStatus.bind(null, 'mongodb', 'CLOUDRON_MONGODB_TOKEN'), @@ -511,7 +512,7 @@ async function rebuildService(id, auditSource) { await startGraphite({ version: 'none' }); break; case 'mail': - await mail.startMail({ version: 'none' }); + await mailServer.start({ version: 'none' }); break; case 'redis': { await shell.promises.exec('removeRedis', `docker rm -f redis-${instance} || true`); @@ -781,7 +782,7 @@ async function startServices(existingInfra) { if (existingInfra.version !== infra.version) { debug(`startServices: ${existingInfra.version} -> ${infra.version}. starting all services`); startFuncs.push( - mail.startMail, // start this first to reduce email downtime + mailServer.start, // start this first to reduce email downtime startTurn, startMysql, startPostgresql, @@ -793,7 +794,7 @@ async function startServices(existingInfra) { } else { assert.strictEqual(typeof existingInfra.images, 'object'); - if (infra.images.mail.tag !== existingInfra.images.mail.tag) startFuncs.push(mail.startMail); // start this first to reduce email downtime + if (infra.images.mail.tag !== existingInfra.images.mail.tag) startFuncs.push(mailServer.start); // start this first to reduce email downtime if (infra.images.turn.tag !== existingInfra.images.turn.tag) startFuncs.push(startTurn); if (infra.images.mysql.tag !== existingInfra.images.mysql.tag) startFuncs.push(startMysql); if (infra.images.postgresql.tag !== existingInfra.images.postgresql.tag) startFuncs.push(startPostgresql); diff --git a/src/taskworker.js b/src/taskworker.js index 6bdee6f8f..9351c76f9 100755 --- a/src/taskworker.js +++ b/src/taskworker.js @@ -12,7 +12,7 @@ const apptask = require('./apptask.js'), dyndns = require('./dyndns.js'), externalLdap = require('./externalldap.js'), fs = require('fs'), - mail = require('./mail.js'), + mailServer = require('./mailserver.js'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), settings = require('./settings.js'), @@ -28,7 +28,7 @@ const TASKS = { // indexed by task type setupDnsAndCert: cloudron.setupDnsAndCert, cleanBackups: backupCleaner.run, syncExternalLdap: externalLdap.sync, - changeMailLocation: mail.changeLocation, + changeMailLocation: mailServer.changeLocation, syncDnsRecords: dns.syncDnsRecords, syncDyndns: dyndns.sync, updateDiskUsage: system.updateDiskUsage,