mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
354 lines
16 KiB
JavaScript
354 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
restart,
|
|
start,
|
|
|
|
generateDkimKey,
|
|
|
|
onDomainAdded,
|
|
onDomainRemoved,
|
|
|
|
checkCertificate,
|
|
|
|
getMailAuth,
|
|
|
|
getLocation,
|
|
startChangeLocation,
|
|
changeLocation,
|
|
|
|
setLocation,
|
|
|
|
DEFAULT_MEMORY_LIMIT: 512 * 1024 * 1024,
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
crypto = require('node:crypto'),
|
|
debug = require('debug')('box:mailserver'),
|
|
dns = require('./dns.js'),
|
|
docker = require('./docker.js'),
|
|
domains = require('./domains.js'),
|
|
eventlog = require('./eventlog.js'),
|
|
fs = require('node:fs'),
|
|
hat = require('./hat.js'),
|
|
infra = require('./infra_version.js'),
|
|
Location = require('./location.js'),
|
|
locks = require('./locks.js'),
|
|
mail = require('./mail.js'),
|
|
os = require('node:os'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
platform = require('./platform.js'),
|
|
reverseProxy = require('./reverseproxy.js'),
|
|
safe = require('safetydance'),
|
|
services = require('./services.js'),
|
|
settings = require('./settings.js'),
|
|
shell = require('./shell.js')('mailserver'),
|
|
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
|
|
await shell.spawn('openssl', ['genrsa', '-out', privateKeyFilePath, '1024'], {});
|
|
await shell.spawn('openssl', ['rsa', '-in', privateKeyFilePath, '-out', publicKeyFilePath, '-pubout', '-outform', 'PEM'], {});
|
|
|
|
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 */;
|
|
}
|
|
|
|
// note: this needs a lock because of container deletion/creation being non-reentrant
|
|
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 image = infra.images.mail;
|
|
const memoryLimit = serviceConfig.memoryLimit || exports.DEFAULT_MEMORY_LIMIT;
|
|
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`;
|
|
|
|
const [readError, dhparams] = await safe(fs.promises.readFile(paths.DHPARAMS_FILE));
|
|
if (readError) throw new BoxError(BoxError.FS_ERROR, `Could not read dhparams: ${readError.message}`);
|
|
const [copyError] = await safe(fs.promises.writeFile(dhparamsFilePath, dhparams));
|
|
if (copyError) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${copyError.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}`);
|
|
|
|
debug('configureMail: stopping and deleting previous mail container');
|
|
await docker.stopContainer('mail');
|
|
await docker.deleteContainer('mail');
|
|
|
|
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=unless-stopped -d --name=mail \
|
|
--net cloudron \
|
|
--net-alias mail \
|
|
--log-driver syslog \
|
|
--log-opt syslog-address=unix://${paths.SYSLOG_SOCKET_FILE} \
|
|
--log-opt syslog-format=rfc5424 \
|
|
--log-opt tag=mail \
|
|
-m ${memoryLimit} \
|
|
--memory-swap -1 \
|
|
--dns 172.18.0.1 \
|
|
--dns-search=. \
|
|
--ip ${constants.MAIL_SERVICE_IPv4} \
|
|
-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 ${image} ${cmd}`;
|
|
|
|
debug('configureMail: starting mail container');
|
|
await shell.bash(runCmd, { encoding: 'utf8' });
|
|
}
|
|
|
|
async function restart() {
|
|
if (constants.TEST && !process.env.TEST_CREATE_INFRA) return;
|
|
|
|
const mailConfig = await services.getServiceConfig('mail');
|
|
const { domain, fqdn } = await getLocation();
|
|
debug(`restart: restarting mail container with mailFqdn:${fqdn} mailDomain:${domain}`);
|
|
|
|
// NOTE: the email container has to be re-created. this is because some of the settings like solr config rely on starting with a clean /run state
|
|
await locks.wait(locks.TYPE_MAIL_SERVER_RESTART);
|
|
const [error] = await safe(configureMail(fqdn, domain, mailConfig));
|
|
await locks.release(locks.TYPE_MAIL_SERVER_RESTART);
|
|
if (error) throw error;
|
|
}
|
|
|
|
async function start(existingInfra) {
|
|
assert.strictEqual(typeof existingInfra, 'object');
|
|
|
|
debug('startMail: starting');
|
|
await restart();
|
|
|
|
if (existingInfra.version !== 'none' && existingInfra.images.mail !== infra.images.mail) await docker.deleteImage(existingInfra.images.mail);
|
|
}
|
|
|
|
async function restartIfActivated() {
|
|
const activated = await users.isActivated();
|
|
|
|
if (!activated) {
|
|
debug('restartIfActivated: skipping restart of mail container since Cloudron is not activated yet');
|
|
return; // not provisioned yet, do not restart container after dns setup
|
|
}
|
|
|
|
debug('restartIfActivated: restarting on activated');
|
|
await restart();
|
|
}
|
|
|
|
async function onDomainAdded(domain) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
const { fqdn } = await getLocation();
|
|
if (!fqdn) return; // mail domain is not set yet (when provisioning)
|
|
|
|
debug(`onDomainAdded: configuring mail for added domain ${domain}`);
|
|
await mail.upsertDnsRecords(domain, fqdn);
|
|
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 subdomain = await settings.get(settings.MAIL_SUBDOMAIN_KEY);
|
|
const domain = await settings.get(settings.MAIL_DOMAIN_KEY);
|
|
|
|
return new Location(subdomain, domain, Location.TYPE_MAIL);
|
|
}
|
|
|
|
async function changeLocation(auditSource, progressCallback) {
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const location = await getLocation();
|
|
const fqdn = dns.fqdn(location.subdomain, location.domain);
|
|
|
|
let progress = 20;
|
|
progressCallback({ percent: progress, message: `Setting up DNS of certs of mail server ${fqdn}` });
|
|
|
|
await dns.registerLocations([location], { overwriteDns: true }, progressCallback);
|
|
await dns.waitForLocations([location], progressCallback);
|
|
await reverseProxy.ensureCertificate(location, {}, auditSource);
|
|
|
|
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) {
|
|
assert.strictEqual(typeof subdomain, 'string');
|
|
assert.strictEqual(typeof domain, 'string');
|
|
|
|
await settings.set(settings.MAIL_SUBDOMAIN_KEY, subdomain);
|
|
await settings.set(settings.MAIL_DOMAIN_KEY, domain);
|
|
}
|
|
|
|
async function startChangeLocation(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}`);
|
|
|
|
await setLocation(subdomain, domain);
|
|
|
|
const taskId = await tasks.add(tasks.TASK_CHANGE_MAIL_LOCATION, [ auditSource ]);
|
|
// background
|
|
tasks.startTask(taskId, {})
|
|
.then(async () => {
|
|
await platform.onMailServerLocationChanged(auditSource);
|
|
})
|
|
.catch((error) => debug(`startChangeLocation`, error));
|
|
|
|
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
|
|
};
|
|
}
|