2023-08-04 20:54:16 +05:30
'use strict' ;
exports = module . exports = {
restart ,
start ,
generateDkimKey ,
onDomainAdded ,
onDomainRemoved ,
checkCertificate ,
getMailAuth ,
getLocation ,
2023-08-04 21:37:38 +05:30
startChangeLocation ,
changeLocation ,
2023-08-16 11:34:41 +05:30
setLocation ,
2023-08-04 20:54:16 +05:30
DEFAULT _MEMORY _LIMIT : 512 * 1024 * 1024 ,
} ;
const assert = require ( 'assert' ) ,
BoxError = require ( './boxerror.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' ) ,
2024-02-28 18:47:53 +01:00
fs = require ( 'fs' ) ,
2023-08-04 20:54:16 +05:30
hat = require ( './hat.js' ) ,
infra = require ( './infra_version.js' ) ,
2023-08-17 10:44:07 +05:30
Location = require ( './location.js' ) ,
2023-08-04 20:54:16 +05:30
mail = require ( './mail.js' ) ,
os = require ( 'os' ) ,
path = require ( 'path' ) ,
paths = require ( './paths.js' ) ,
2023-08-21 18:18:03 +05:30
platform = require ( './platform.js' ) ,
2023-08-04 20:54:16 +05:30
reverseProxy = require ( './reverseproxy.js' ) ,
safe = require ( 'safetydance' ) ,
services = require ( './services.js' ) ,
settings = require ( './settings.js' ) ,
2024-10-14 19:10:31 +02:00
shell = require ( './shell.js' ) ( 'mailserver' ) ,
2023-08-04 20:54:16 +05:30
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
2024-10-15 10:10:15 +02:00
await shell . spawn ( 'openssl' , [ 'genrsa' , '-out' , privateKeyFilePath , '1024' ] , { } ) ;
await shell . spawn ( 'openssl' , [ 'rsa' , '-in' , privateKeyFilePath , '-out' , publicKeyFilePath , '-pubout' , '-outform' , 'PEM' ] , { } ) ;
2023-08-04 20:54:16 +05:30
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 } \n mail_out_domains= ${ mailOutDomains } \n mail_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 } ] \n catch_all= ${ catchAll } \n mail_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 } ] \n enable_outbound=true \n host= ${ host } \n port= ${ port } \n enable_tls=true \n auth_type= ${ authType } \n auth_user= ${ username } \n auth_pass= ${ password } \n force_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
2023-08-08 10:42:16 +05:30
const image = infra . images . mail ;
2023-08-04 20:54:16 +05:30
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 ` ;
2024-02-28 18:47:53 +01:00
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 ) ) ;
2024-02-20 23:09:49 +01:00
if ( copyError ) throw new BoxError ( BoxError . FS _ERROR , ` Could not copy dhparams: ${ copyError . message } ` ) ;
2023-08-04 20:54:16 +05:30
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 } ` ) ;
2024-02-28 18:47:53 +01:00
debug ( 'configureMail: stopping and deleting previous mail container' ) ;
await docker . stopContainer ( 'mail' ) ;
await docker . deleteContainer ( 'mail' ) ;
2023-08-04 20:54:16 +05:30
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' ;
2024-02-22 10:34:56 +01:00
const runCmd = ` docker run --restart=always -d --name=mail \
2023-08-04 20:54:16 +05:30
-- net cloudron \
-- net - alias mail \
-- log - driver syslog \
2024-03-21 17:30:50 +01:00
-- log - opt syslog - address = unix : //${paths.SYSLOG_SOCKET_FILE} \
2023-08-04 20:54:16 +05:30
-- log - opt syslog - format = rfc5424 \
-- log - opt tag = mail \
2024-04-09 18:59:40 +02:00
- m $ { memoryLimit } \
-- memory - swap - 1 \
2023-08-04 20:54:16 +05:30
-- dns 172.18 . 0.1 \
-- dns - search = . \
2024-02-22 10:34:56 +01:00
- e CLOUDRON _MAIL _TOKEN = $ { cloudronToken } \
- e CLOUDRON _RELAY _TOKEN = $ { relayToken } \
2023-08-04 20:54:16 +05:30
- e LOGLEVEL = $ { logLevel } \
2024-02-22 10:34:56 +01:00
- v $ { paths . MAIL _DATA _DIR } : / a p p / d a t a \
- v $ { paths . MAIL _CONFIG _DIR } : / e t c / m a i l : r o \
2023-08-04 20:54:16 +05:30
$ { ports } \
-- label isCloudronManaged = true \
2023-08-08 10:42:16 +05:30
$ { readOnly } - v / run - v / tmp $ { image } $ { cmd } ` ;
2023-08-04 20:54:16 +05:30
2024-02-28 18:47:53 +01:00
debug ( 'configureMail: starting mail container' ) ;
2024-10-16 10:25:07 +02:00
await shell . bash ( runCmd , { } ) ;
2023-08-04 20:54:16 +05:30
}
async function restart ( ) {
2023-10-01 13:52:19 +05:30
if ( constants . TEST && ! process . env . TEST _CREATE _INFRA ) return ;
2023-08-04 20:54:16 +05:30
const mailConfig = await services . getServiceConfig ( 'mail' ) ;
2023-08-04 21:37:38 +05:30
const { domain , fqdn } = await getLocation ( ) ;
debug ( ` restart: restarting mail container with mailFqdn: ${ fqdn } mailDomain: ${ domain } ` ) ;
2024-04-18 13:14:59 +02:00
// 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
2023-08-04 21:37:38 +05:30
await configureMail ( fqdn , domain , mailConfig ) ;
2023-08-04 20:54:16 +05:30
}
async function start ( existingInfra ) {
assert . strictEqual ( typeof existingInfra , 'object' ) ;
debug ( 'startMail: starting' ) ;
await restart ( ) ;
}
async function restartIfActivated ( ) {
const activated = await users . isActivated ( ) ;
if ( ! activated ) {
2024-10-30 20:58:31 +01:00
debug ( 'restartIfActivated: skipping restart of mail container since Cloudron is not activated yet' ) ;
2023-08-04 20:54:16 +05:30
return ; // not provisioned yet, do not restart container after dns setup
}
2024-10-30 20:58:31 +01:00
debug ( 'restartIfActivated: restarting on activated' ) ;
2023-08-04 20:54:16 +05:30
await restart ( ) ;
}
async function onDomainAdded ( domain ) {
assert . strictEqual ( typeof domain , 'string' ) ;
2023-08-04 21:37:38 +05:30
const { fqdn } = await getLocation ( ) ;
if ( ! fqdn ) return ; // mail domain is not set yet (when provisioning)
2023-08-04 20:54:16 +05:30
debug ( ` onDomainAdded: configuring mail for added domain ${ domain } ` ) ;
2023-08-04 21:37:38 +05:30
await mail . upsertDnsRecords ( domain , fqdn ) ;
2023-08-04 20:54:16 +05:30
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 ( ) {
2023-08-16 19:28:04 +05:30
const subdomain = await settings . get ( settings . MAIL _SUBDOMAIN _KEY ) ;
2023-08-17 10:44:07 +05:30
const domain = await settings . get ( settings . MAIL _DOMAIN _KEY ) ;
2023-08-04 21:37:38 +05:30
2023-08-17 10:44:07 +05:30
return new Location ( subdomain , domain , Location . TYPE _MAIL ) ;
2023-08-04 20:54:16 +05:30
}
async function changeLocation ( auditSource , progressCallback ) {
assert . strictEqual ( typeof auditSource , 'object' ) ;
assert . strictEqual ( typeof progressCallback , 'function' ) ;
2023-08-14 09:40:31 +05:30
const location = await getLocation ( ) ;
const fqdn = dns . fqdn ( location . subdomain , location . domain ) ;
2023-08-04 20:54:16 +05:30
let progress = 20 ;
progressCallback ( { percent : progress , message : ` Setting up DNS of certs of mail server ${ fqdn } ` } ) ;
2023-08-14 09:40:31 +05:30
await dns . registerLocations ( [ location ] , { overwriteDns : true } , progressCallback ) ;
await dns . waitForLocations ( [ location ] , progressCallback ) ;
await reverseProxy . ensureCertificate ( location , { } , auditSource ) ;
2023-08-04 20:54:16 +05:30
2023-08-14 09:40:31 +05:30
const allDomains = await domains . list ( ) ;
2023-08-04 20:54:16 +05:30
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 ( ) ;
}
2023-08-16 19:28:04 +05:30
async function setLocation ( subdomain , domain ) {
assert . strictEqual ( typeof subdomain , 'string' ) ;
assert . strictEqual ( typeof domain , 'string' ) ;
2023-08-04 21:37:38 +05:30
2023-08-16 19:28:04 +05:30
await settings . set ( settings . MAIL _SUBDOMAIN _KEY , subdomain ) ;
await settings . set ( settings . MAIL _DOMAIN _KEY , domain ) ;
2023-08-04 21:37:38 +05:30
}
async function startChangeLocation ( subdomain , domain , auditSource ) {
2023-08-04 20:54:16 +05:30
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 } ` ) ;
2023-08-16 19:28:04 +05:30
await setLocation ( subdomain , domain ) ;
2023-08-04 20:54:16 +05:30
const taskId = await tasks . add ( tasks . TASK _CHANGE _MAIL _LOCATION , [ auditSource ] ) ;
2023-08-21 21:30:07 +05:30
tasks . startTask ( taskId , { } , async ( error ) => {
if ( error ) return ;
await platform . onMailServerLocationChanged ( auditSource ) ;
} ) ;
2023-08-04 20:54:16 +05:30
2023-08-21 21:30:07 +05:30
await eventlog . add ( eventlog . ACTION _MAIL _LOCATION , auditSource , { subdomain , domain , taskId } ) ;
2023-08-04 20:54:16 +05:30
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
} ;
}