diff --git a/lib/filter-handler.js b/lib/filter-handler.js new file mode 100644 index 00000000..967810c0 --- /dev/null +++ b/lib/filter-handler.js @@ -0,0 +1,464 @@ +'use strict'; + +const log = require('npmlog'); +const ObjectID = require('mongodb').ObjectID; +const db = require('./db'); +const forward = require('./forward'); +const autoreply = require('./autoreply'); + +const defaultSpamHeaderKeys = [ + { + key: 'X-Spam-Status', + value: '^yes', + target: '\\Junk' + }, + + { + key: 'X-Rspamd-Spam', + value: '^yes', + target: '\\Junk' + }, + { + key: 'X-Haraka-Virus', + value: '.', + target: '\\Junk' + } +]; + +class FilterHandler { + constructor(options) { + this.database = options.database; + this.users = options.users || options.database; + this.redis = options.redis; + this.messageHandler = options.messageHandler; + this.spamChecks = options.spamChecks; + this.spamHeaderKeys = options.spamHeaderKeys; + } + + getUserData(address, callback) { + let query = {}; + if (!address) { + return callback(null, false); + } + if (typeof address === 'object' && address._id) { + return callback(null, address); + } + + let collection; + + if (typeof address === 'object' && typeof address.getTimestamp === 'function') { + query._id = address; + collection = 'users'; + } else if (/^[a-f0-9]{24}$/.test(address)) { + query._id = new ObjectID(address); + collection = 'users'; + } else if (typeof address !== 'string') { + return callback(null, false); + } else if (address.indexOf('@') >= 0) { + query.addrview = address.substr(0, address.indexOf('@')).replace(/\./g, '') + address.substr(address.indexOf('@')); + collection = 'addresses'; + } else { + query.unameview = address.replace(/\./g, ''); + collection = 'users'; + } + + let fields = { + name: true, + forwards: true, + forward: true, + targetUrl: true, + autoreply: true, + encryptMessages: true, + pubKey: true + }; + + if (collection === 'users') { + return db.users.collection('users').findOne( + query, + { + fields + }, + callback + ); + } + + return db.users.collection('addresses').findOne(query, (err, addressData) => { + if (err) { + return callback(err); + } + if (!addressData) { + return callback(null, false); + } + return db.users.collection('users').findOne( + { _id: addressData.user }, + { + fields + }, + callback + ); + }); + } + + process(options, callback) { + this.getUserData(options.user || options.recipient, (err, userData) => { + if (err) { + return callback(err); + } + if (!userData) { + return callback(null, false); + } + + this.storeMessage(userData, options, callback); + }); + } + + storeMessage(userData, options, callback) { + let sender = options.sender || ''; + let recipient = options.recipient || userData.address; + + // create Delivered-To and Return-Path headers + let extraHeader = Buffer.from(['Delivered-To: ' + recipient, 'Return-Path: <' + sender + '>'].join('\r\n') + '\r\n'); + + let chunks = options.chunks; + let chunklen = options.chunklen; + let raw = Buffer.concat([extraHeader].concat(chunks), chunklen + extraHeader.length); + + let prepared = this.messageHandler.prepareMessage({ + raw, + indexedHeaders: this.spamHeaderKeys + }); + + console.log(require('util').inspect(prepared, false, 22)); + + let maildata = this.messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree); + + // default flags are empty + let flags = []; + + // default mailbox target is INBOX + let mailboxQueryKey = 'path'; + let mailboxQueryValue = 'INBOX'; + + db.database + .collection('filters') + .find({ user: userData._id }) + .sort({ _id: 1 }) + .toArray((err, filters) => { + if (err) { + // ignore, as filtering is not so important + } + + filters = (filters || []).concat( + this.spamChecks.map((check, i) => ({ + id: 'SPAM#' + (i + 1), + query: { + headers: { + [check.key]: check.value + } + }, + action: { + // only applies if any other filter does not already mark message as spam or ham + spam: true + } + })) + ); + + let forwardTargets = new Set(); + let forwardTargetUrls = new Set(); + let matchingFilters = []; + let filterActions = new Map(); + + filters + // apply all filters to the message + .map(filter => checkFilter(filter, prepared, maildata)) + // remove all unmatched filters + .filter(filter => filter) + // apply filter actions + .forEach(filter => { + matchingFilters.push(filter.id); + + // apply matching filter + if (!filterActions) { + filterActions = filter.action; + } else { + Object.keys(filter.action).forEach(key => { + if (key === 'forward') { + forwardTargets.add(filter.action[key]); + return; + } + + if (key === 'targetUrl') { + forwardTargetUrls.add(filter.action[key]); + return; + } + + // if a previous filter already has set a value then do not touch it + if (!filterActions.has(key)) { + filterActions.set(key, filter.action[key]); + } + }); + } + }); + + let forwardMessage = done => { + if (userData.forward && !filterActions.get('delete')) { + // forward to default recipient only if the message is not deleted + forwardTargets.add(userData.forward); + } + + if (userData.targetUrl && !filterActions.get('delete')) { + // forward to default URL only if the message is not deleted + forwardTargetUrls.add(userData.targetUrl); + } + + // never forward messages marked as spam + if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) { + return setImmediate(done); + } + + // check limiting counters + this.messageHandler.counters.ttlcounter( + 'wdf:' + userData._id.toString(), + forwardTargets.size + forwardTargetUrls.size, + userData.forwards, + false, + (err, result) => { + if (err) { + // failed checks + log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message); + } else if (!result.success) { + log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed'); + return done(); + } + + forward( + { + userData, + sender, + recipient, + + forward: forwardTargets.size ? Array.from(forwardTargets) : false, + targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false, + + chunks, + chunklen + }, + done + ); + } + ); + }; + + let sendAutoreply = done => { + // never reply to messages marked as spam + if (!sender || !userData.autoreply || filterActions.get('spam')) { + return setImmediate(done); + } + + autoreply( + { + userData, + sender, + recipient, + chunks, + chunklen, + messageHandler: this.messageHandler + }, + done + ); + }; + + forwardMessage((err, id) => { + if (err) { + log.error( + 'LMTP', + '%s FRWRDFAIL from=%s to=%s target=%s error=%s', + prepared.id.toString(), + sender, + recipient, + Array.from(forwardTargets) + .concat(forwardTargetUrls) + .join(','), + err.message + ); + } else if (id) { + log.silly( + 'LMTP', + '%s FRWRDOK id=%s from=%s to=%s target=%s', + prepared.id.toString(), + id, + sender, + recipient, + Array.from(forwardTargets) + .concat(forwardTargetUrls) + .join(',') + ); + } + + sendAutoreply((err, id) => { + if (err) { + log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message); + } else if (id) { + log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender); + } + + if (filterActions.get('delete')) { + // nothing to do with the message, just continue + return callback(null, { + userData, + response: 'Message dropped by policy as ' + prepared.id.toString() + }); + } + + // apply filter results to the message + filterActions.forEach((value, key) => { + switch (key) { + case 'spam': + if (value > 0) { + // positive value is spam + mailboxQueryKey = 'specialUse'; + mailboxQueryValue = '\\Junk'; + } + break; + case 'seen': + if (value) { + flags.push('\\Seen'); + } + break; + case 'flag': + if (value) { + flags.push('\\Flagged'); + } + break; + case 'mailbox': + if (value) { + // positive value is spam + mailboxQueryKey = 'mailbox'; + mailboxQueryValue = value; + } + break; + } + }); + + let messageOpts = { + user: userData._id, + [mailboxQueryKey]: mailboxQueryValue, + + prepared, + maildata, + + meta: options.meta, + + filters: matchingFilters, + + date: false, + flags, + + // if similar message exists, then skip + skipExisting: true + }; + + this.messageHandler.encryptMessage(userData.encryptMessages ? userData.pubKey : false, raw, (err, encrypted) => { + if (!err && encrypted) { + messageOpts.prepared = this.messageHandler.prepareMessage({ + raw: encrypted, + indexedHeaders: this.spamHeaderKeys + }); + messageOpts.maildata = this.messageHandler.indexer.getMaildata(messageOpts.prepared.id, messageOpts.prepared.mimeTree); + } + + this.messageHandler.add(messageOpts, (err, inserted, info) => + // push to response list + callback(null, { + userData, + response: err ? err : 'Message stored as ' + info.id.toString() + }) + ); + }); + }); + }); + }); + } +} + +function checkFilter(filter, prepared, maildata) { + if (!filter || !filter.query) { + return false; + } + + let query = filter.query; + + // prepare filter data + let headerFilters = new Map(); + if (query.headers) { + Object.keys(query.headers).forEach(key => { + let value = query.headers[key]; + if (!value || !value.isRegex) { + value = (query.headers[key] || '').toString().toLowerCase(); + } + headerFilters.set(key, value); + }); + } + + // check headers + if (headerFilters.size) { + let headerMatches = new Set(); + for (let j = prepared.headers.length - 1; j >= 0; j--) { + let header = prepared.headers[j]; + if (headerFilters.has(header.key)) { + let check = headerFilters.get(header.key); + if (check && check.isRegex && check.test(header.value)) { + headerMatches.add(header.key); + } else if (header.value.indexOf(headerFilters.get(header.key)) >= 0) { + headerMatches.add(header.key); + } + } + } + if (headerMatches.size < headerFilters.size) { + // not enough matches + return false; + } + } + + if (typeof query.ha === 'boolean') { + let hasAttachments = maildata.attachments && maildata.attachments.length; + // false ha means no attachmens + if (hasAttachments && !query.ha) { + return false; + } + // true ha means attachmens must exist + if (!hasAttachments && query.ha) { + return false; + } + } + + if (query.size) { + let messageSize = prepared.size; + let filterSize = Math.abs(query.size); + // negative value means "less than", positive means "more than" + if (query.size < 0 && messageSize > filterSize) { + return false; + } + if (query.size > 0 && messageSize < filterSize) { + return false; + } + } + + if ( + query.text && + maildata.text + .toLowerCase() + .replace(/\s+/g, ' ') + .indexOf(query.text.toLowerCase()) < 0 + ) { + // message plaintext does not match the text field value + return false; + } + + log.silly('Filter', 'Filter %s matched message %s', filter.id, prepared.id); + + // we reached the end of the filter, so this means we have a match + return filter; +} + +module.exports = FilterHandler; diff --git a/lmtp.js b/lmtp.js index c869c41d..c3b85dee 100644 --- a/lmtp.js +++ b/lmtp.js @@ -4,21 +4,27 @@ const config = require('wild-config'); const log = require('npmlog'); +const ObjectID = require('mongodb').ObjectID; const SMTPServer = require('smtp-server').SMTPServer; const tools = require('./lib/tools'); const MessageHandler = require('./lib/message-handler'); +const FilterHandler = require('./lib/filter-handler'); const db = require('./lib/db'); -const forward = require('./lib/forward'); -const autoreply = require('./lib/autoreply'); const certs = require('./lib/certs'); let messageHandler; -let spamChecks = prepareSmapChecks(config.spamHeader); -let spamHeaderKeys = spamChecks.map(check => check.key); +let filterHandler; +let spamChecks, spamHeaderKeys; config.on('reload', () => { - spamChecks = prepareSmapChecks(config.spamHeader); + spamChecks = prepareSpamChecks(config.spamHeader); spamHeaderKeys = spamChecks.map(check => check.key); + + if (filterHandler) { + filterHandler.spamChecks = spamChecks; + filterHandler.spamHeaderKeys = spamHeaderKeys; + } + log.info('LMTP', 'Configuration reloaded'); }); @@ -132,6 +138,8 @@ const serverOptions = { let users = session.users; let stored = 0; + let transactionId = new ObjectID(); + let storeNext = () => { if (stored >= users.length) { return callback(null, responses.map(r => r.response)); @@ -147,287 +155,35 @@ const serverOptions = { return storeNext(); } - // create Delivered-To and Received headers - let header = Buffer.from( - ['Delivered-To: ' + recipient, 'Return-Path: <' + (sender || '') + '>'].join('\r\n') + '\r\n' - //+ 'Received: ' + generateReceivedHeader(session, queueId, os.hostname().toLowerCase(), recipient) + '\r\n' - ); - - chunks.unshift(header); - chunklen += header.length; - - let raw = Buffer.concat(chunks, chunklen); - - let meta = { - source: 'LMTP', - from: sender, - to: recipient, - origin: session.remoteAddress, - originhost: session.clientHostname, - transhost: session.hostNameAppearsAs, - transtype: session.transmissionType, - time: Date.now() - }; - - let prepared = messageHandler.prepareMessage({ - raw, - indexedHeaders: spamHeaderKeys - }); - let maildata = messageHandler.indexer.getMaildata(prepared.id, prepared.mimeTree); - - // default flags are empty - let flags = []; - - // default mailbox target is INBOX - let mailboxQueryKey = 'path'; - let mailboxQueryValue = 'INBOX'; - - db.database - .collection('filters') - .find({ user: userData._id }) - .sort({ _id: 1 }) - .toArray((err, filters) => { - if (err) { - // ignore, as filtering is not so important + filterHandler.process( + { + user: userData, + sender, + recipient, + chunks, + chunklen, + meta: { + transactionId, + source: 'LMTP', + from: sender, + to: recipient, + origin: session.remoteAddress, + originhost: session.clientHostname, + transhost: session.hostNameAppearsAs, + transtype: session.transmissionType, + time: Date.now() } - - filters = (filters || []).concat( - spamChecks.map((check, i) => ({ - id: 'SPAM#' + (i + 1), - query: { - headers: { - [check.key]: check.value - } - }, - action: { - // only applies if any other filter does not already mark message as spam or ham - spam: true - } - })) - ); - - let forwardTargets = new Set(); - let forwardTargetUrls = new Set(); - let matchingFilters = []; - let filterActions = new Map(); - - filters - // apply all filters to the message - .map(filter => checkFilter(filter, prepared, maildata)) - // remove all unmatched filters - .filter(filter => filter) - // apply filter actions - .forEach(filter => { - matchingFilters.push(filter.id); - - // apply matching filter - if (!filterActions) { - filterActions = filter.action; - } else { - Object.keys(filter.action).forEach(key => { - if (key === 'forward') { - forwardTargets.add(filter.action[key]); - return; - } - - if (key === 'targetUrl') { - forwardTargetUrls.add(filter.action[key]); - return; - } - - // if a previous filter already has set a value then do not touch it - if (!filterActions.has(key)) { - filterActions.set(key, filter.action[key]); - } - }); - } - }); - - let forwardMessage = done => { - if (userData.forward && !filterActions.get('delete')) { - // forward to default recipient only if the message is not deleted - forwardTargets.add(userData.forward); - } - - if (userData.targetUrl && !filterActions.get('delete')) { - // forward to default URL only if the message is not deleted - forwardTargetUrls.add(userData.targetUrl); - } - - // never forward messages marked as spam - if ((!forwardTargets.size && !forwardTargetUrls.size) || filterActions.get('spam')) { - return setImmediate(done); - } - - // check limiting counters - messageHandler.counters.ttlcounter( - 'wdf:' + userData._id.toString(), - forwardTargets.size + forwardTargetUrls.size, - userData.forwards, - false, - (err, result) => { - if (err) { - // failed checks - log.error('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), err.message); - } else if (!result.success) { - log.silly('LMTP', 'FRWRDFAIL key=%s error=%s', 'wdf:' + userData._id.toString(), 'Precondition failed'); - return done(); - } - - forward( - { - userData, - sender, - recipient, - - forward: forwardTargets.size ? Array.from(forwardTargets) : false, - targetUrl: forwardTargetUrls.size ? Array.from(forwardTargetUrls) : false, - - chunks - }, - done - ); - } - ); - }; - - let sendAutoreply = done => { - // never reply to messages marked as spam - if (!sender || !userData.autoreply || filterActions.get('spam')) { - return setImmediate(done); - } - - autoreply( - { - userData, - sender, - recipient, - chunks, - messageHandler - }, - done - ); - }; - - forwardMessage((err, id) => { - if (err) { - log.error( - 'LMTP', - '%s FRWRDFAIL from=%s to=%s target=%s error=%s', - prepared.id.toString(), - sender, - recipient, - Array.from(forwardTargets) - .concat(forwardTargetUrls) - .join(','), - err.message - ); - } else if (id) { - log.silly( - 'LMTP', - '%s FRWRDOK id=%s from=%s to=%s target=%s', - prepared.id.toString(), - id, - sender, - recipient, - Array.from(forwardTargets) - .concat(forwardTargetUrls) - .join(',') - ); - } - - sendAutoreply((err, id) => { - if (err) { - log.error('LMTP', '%s AUTOREPLYFAIL from=%s to=%s error=%s', prepared.id.toString(), '<>', sender, err.message); - } else if (id) { - log.silly('LMTP', '%s AUTOREPLYOK id=%s from=%s to=%s', prepared.id.toString(), id, '<>', sender); - } - - if (filterActions.get('delete')) { - // nothing to do with the message, just continue - responses.push({ - userData, - response: 'Message dropped by policy as ' + prepared.id.toString() - }); - prepared = false; - maildata = false; - return storeNext(); - } - - // apply filter results to the message - filterActions.forEach((value, key) => { - switch (key) { - case 'spam': - if (value > 0) { - // positive value is spam - mailboxQueryKey = 'specialUse'; - mailboxQueryValue = '\\Junk'; - } - break; - case 'seen': - if (value) { - flags.push('\\Seen'); - } - break; - case 'flag': - if (value) { - flags.push('\\Flagged'); - } - break; - case 'mailbox': - if (value) { - // positive value is spam - mailboxQueryKey = 'mailbox'; - mailboxQueryValue = value; - } - break; - } - }); - - let messageOpts = { - user: userData._id, - [mailboxQueryKey]: mailboxQueryValue, - - prepared, - maildata, - - meta, - - filters: matchingFilters, - - date: false, - flags, - - // if similar message exists, then skip - skipExisting: true - }; - - messageHandler.encryptMessage(userData.encryptMessages ? userData.pubKey : false, raw, (err, encrypted) => { - if (!err && encrypted) { - messageOpts.prepared = messageHandler.prepareMessage({ - raw: encrypted, - indexedHeaders: spamHeaderKeys - }); - messageOpts.maildata = messageHandler.indexer.getMaildata(messageOpts.prepared.id, messageOpts.prepared.mimeTree); - } - - messageHandler.add(messageOpts, (err, inserted, info) => { - // remove Delivered-To - chunks.shift(); - chunklen -= header.length; - - // push to response list - responses.push({ - userData, - response: err ? err : 'Message stored as ' + info.id.toString() - }); - - storeNext(); - }); - }); - }); - }); - }); + }, + (err, response) => { + if (err) { + // ??? + } + if (response) { + responses.push(response); + } + setImmediate(storeNext); + } + ); }; storeNext(); @@ -446,6 +202,9 @@ module.exports = done => { return setImmediate(() => done(null, false)); } + spamChecks = prepareSpamChecks(config.spamHeader); + spamHeaderKeys = spamChecks.map(check => check.key); + messageHandler = new MessageHandler({ database: db.database, users: db.users, @@ -454,6 +213,15 @@ module.exports = done => { attachments: config.attachments }); + filterHandler = new FilterHandler({ + database: db.database, + users: db.users, + redis: db.redis, + messageHandler, + spamHeaderKeys, + spamChecks + }); + let started = false; server.on('error', err => { @@ -473,87 +241,7 @@ module.exports = done => { }); }; -function checkFilter(filter, prepared, maildata) { - if (!filter || !filter.query) { - return false; - } - - let query = filter.query; - - // prepare filter data - let headerFilters = new Map(); - if (query.headers) { - Object.keys(query.headers).forEach(key => { - let value = query.headers[key]; - if (!value || !value.isRegex) { - value = (query.headers[key] || '').toString().toLowerCase(); - } - headerFilters.set(key, value); - }); - } - - // check headers - if (headerFilters.size) { - let headerMatches = new Set(); - for (let j = prepared.headers.length - 1; j >= 0; j--) { - let header = prepared.headers[j]; - if (headerFilters.has(header.key)) { - let check = headerFilters.get(header.key); - if (check && check.isRegex && check.test(header.value)) { - headerMatches.add(header.key); - } else if (header.value.indexOf(headerFilters.get(header.key)) >= 0) { - headerMatches.add(header.key); - } - } - } - if (headerMatches.size < headerFilters.size) { - // not enough matches - return false; - } - } - - if (typeof query.ha === 'boolean') { - let hasAttachments = maildata.attachments && maildata.attachments.length; - // false ha means no attachmens - if (hasAttachments && !query.ha) { - return false; - } - // true ha means attachmens must exist - if (!hasAttachments && query.ha) { - return false; - } - } - - if (query.size) { - let messageSize = prepared.size; - let filterSize = Math.abs(query.size); - // negative value means "less than", positive means "more than" - if (query.size < 0 && messageSize > filterSize) { - return false; - } - if (query.size > 0 && messageSize < filterSize) { - return false; - } - } - - if ( - query.text && - maildata.text - .toLowerCase() - .replace(/\s+/g, ' ') - .indexOf(query.text.toLowerCase()) < 0 - ) { - // message plaintext does not match the text field value - return false; - } - - log.silly('Filter', 'Filter %s matched message %s', filter.id, prepared.id); - - // we reached the end of the filter, so this means we have a match - return filter; -} - -function prepareSmapChecks(spamHeader) { +function prepareSpamChecks(spamHeader) { return (Array.isArray(spamHeader) ? spamHeader : [].concat(spamHeader || [])) .map(header => { if (!header) { diff --git a/package.json b/package.json index f64f2ec3..7d931577 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "grunt-mocha-test": "^0.13.3", "grunt-shell-spawn": "^0.3.10", "grunt-wait": "^0.1.0", - "icedfrisby": "^1.3.1", + "icedfrisby": "^1.4.0", "mocha": "^4.0.1" }, "dependencies": { @@ -34,7 +34,7 @@ "iconv-lite": "0.4.19", "ioredfour": "1.0.2-ioredis", "ioredis": "3.1.4", - "joi": "11.1.1", + "joi": "13.0.0", "js-yaml": "3.10.0", "libbase64": "0.2.0", "libmime": "3.1.0", @@ -43,17 +43,17 @@ "mailsplit": "4.0.2", "mobileconfig": "2.1.0", "mongo-cursor-pagination": "5.0.0", - "mongodb": "2.2.31", - "nodemailer": "4.1.3", + "mongodb": "2.2.33", + "nodemailer": "4.2.0", "npmlog": "4.1.2", - "openpgp": "2.5.11", + "openpgp": "2.5.12", "qrcode": "0.9.0", "restify": "6.0.1", "seq-index": "1.1.0", "smtp-server": "3.3.0", "speakeasy": "2.0.0", - "tlds": "1.197.0", - "u2f": "^0.1.2", + "tlds": "1.198.0", + "u2f": "^0.1.3", "utf7": "1.0.2", "uuid": "3.1.0", "wild-config": "1.3.5" diff --git a/setup/README.md b/setup/README.md index 0d1c0ee6..4f6ae84f 100644 --- a/setup/README.md +++ b/setup/README.md @@ -6,6 +6,8 @@ Here you can find an example install script to install Wild Duck with Haraka and sudo ./install.sh mydomain.com +Make sure that mydomain.com points to that instance as the install script tries to fetch an SSL certificate from let's Encrypt. + Where mydomain.com is the domain name of your server. If everything succeeds then open your browser http://mydomain.com/ and you should see the Wild Duck example webmail app. Create an account using that app and start receiving and sending emails! (Make sure though that your MX DNS uses mydomain.com) diff --git a/setup/install.sh b/setup/install.sh index 66f3d264..91604b16 100755 --- a/setup/install.sh +++ b/setup/install.sh @@ -3,11 +3,18 @@ # Run as root: # sudo ./install.sh [maildomain.com] +INSTALLDIR=`pwd` + +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" 1>&2 + exit 1 +fi + HOSTNAME="$1" WILDDUCK_COMMIT="30f0e83ed34efcaacd56b997d85a0b76ad1cdd8d" ZONEMTA_COMMIT="88f73b6f6fa4c1135af611d1bb79213ed5ee3869" -WEBMAIL_COMMIT="bbac73339f192b1dfa39be20ac3a6acf5ffffc07" +WEBMAIL_COMMIT="e2453fa150b28a72ccec613a04dfecca1b4e74a1" if [[ $EUID -ne 0 ]]; then echo "This script must be run as root" 1>&2 @@ -65,22 +72,18 @@ mv /etc/wildduck/default.toml /etc/wildduck/wildduck.toml echo 'sender="zone-mta"' >> /etc/wildduck/dbs.toml -echo 'enabled=true -port=993 -host="0.0.0.0" -secure=true' > /etc/wildduck/imap.toml - -echo 'enabled=true -port=995 -host="0.0.0.0" -secure=true' > /etc/wildduck/pop3.toml +sed -i -e "s/999/99/g;s/localhost/$HOSTNAME/g" /etc/wildduck/imap.toml +sed -i -e "s/999/99/g;s/localhost/$HOSTNAME/g" /etc/wildduck/pop3.toml echo "enabled=true port=24 -emailDomain=\"$HOSTNAME\"" > /etc/wildduck/lmtp.toml +disableSTARTTLS=true" > /etc/wildduck/lmtp.toml -echo 'user="wildduck" -group="wildduck"' | cat - /etc/wildduck/wildduck.toml > temp && mv temp /etc/wildduck/wildduck.toml +echo "user=\"wildduck\" +group=\"wildduck\" +emailDomain=\"$HOSTNAME\"" | cat - /etc/wildduck/wildduck.toml > temp && mv temp /etc/wildduck/wildduck.toml + +sed -i -e "s/localhost:3000/$HOSTNAME/g;s/localhost/$HOSTNAME/g;s/2587/587/g" /etc/wildduck/wildduck.toml cd /opt/wildduck sudo npm install --production @@ -119,9 +122,14 @@ echo "26214400" > config/databytes echo "$HOSTNAME" > config/me -echo "queue/lmtp +echo "spf +tls +queue/lmtp wildduck" > config/plugins +echo "key=/etc/wildduck/certs/privkey.pem +cert=/etc/wildduck/certs/fullchain.pem" > config/tls.ini + echo "host=127.0.0.1 port=24" > config/lmtp.ini @@ -214,6 +222,13 @@ localMxPort=24 address=\"127.0.0.1\" name=\"$HOSTNAME\"" > /etc/zone-mta/plugins/wildduck.toml +sed -i -e "s/test/wildduck/g;s/example.com/$HOSTNAME/g;s/signTransportDomain=true/signTransportDomain=false/g;" /etc/zone-mta/plugins/dkim.toml +cd /opt/zone-mta/keys +openssl genrsa -out "$HOSTNAME-dkim.pem" 2048 +chmod 400 "$HOSTNAME-dkim.pem" +openssl rsa -in "$HOSTNAME-dkim.pem" -out "$HOSTNAME-dkim.cert" -pubout +DNS_ADDRESS="v=DKIM1;p=$(grep -v -e '^-' $HOSTNAME-dkim.cert | tr -d "\n")" + cd /opt/zone-mta sudo npm install zonemta-wildduck --save sudo npm install --production @@ -247,7 +262,7 @@ mkdir /opt/wildduck-webmail git --git-dir=/var/opt/wildduck-webmail.git --work-tree=/opt/wildduck-webmail checkout "$WEBMAIL_COMMIT" cp /opt/wildduck-webmail/config/default.toml /etc/wildduck/wildduck-webmail.toml -sed -i -e "s/localhost/$HOSTNAME/g" /etc/wildduck/wildduck-webmail.toml +sed -i -e "s/localhost/$HOSTNAME/g;s/999/99/g;s/2587/587/g" /etc/wildduck/wildduck-webmail.toml cd /opt/wildduck-webmail sudo npm install --production @@ -272,23 +287,36 @@ WantedBy=multi-user.target' > /etc/systemd/system/wildduck-webmail.service systemctl enable wildduck-webmail.service -mv /etc/nginx/sites-available/default /etc/nginx/sites-available/default.bak +#### NGINX #### -echo 'server { - listen 80 default_server; - listen [::]:80 default_server; +# Create initial certs. These will be overwritten later by Let's Encrypt certs +mkdir /etc/wildduck/certs +cd /etc/wildduck/certs +openssl req -subj "/CN=$HOSTNAME/O=My Company Name LTD./C=US" -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout privkey.pem -out fullchain.pem - server_name _; +chown -R wildduck:wildduck /etc/wildduck/certs +chmod 0700 /etc/wildduck/certs/privkey.pem + +# Setup domain without SSL at first, otherwise acme.sh will fail +echo "server { + listen 80; + + server_name $HOSTNAME; + + ssl_certificate /etc/wildduck/certs/fullchain.pem; + ssl_certificate_key /etc/wildduck/certs/privkey.pem; location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header HOST $http_host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header HOST \$http_host; proxy_set_header X-NginX-Proxy true; proxy_pass http://127.0.0.1:3000; proxy_redirect off; } -}' > /etc/nginx/sites-available/default +}" > "/etc/nginx/sites-available/$HOSTNAME" +ln -s "/etc/nginx/sites-available/$HOSTNAME" "/etc/nginx/sites-enabled/$HOSTNAME" +systemctl reload nginx #### UFW #### @@ -300,6 +328,63 @@ ufw allow 587/tcp ufw allow 993/tcp ufw --force enable +#### SSL CERTS #### + +curl https://get.acme.sh | sh + +echo 'cert="/etc/wildduck/certs/fullchain.pem" +key="/etc/wildduck/certs/privkey.pem"' > /etc/wildduck/tls.toml + +sed -i -e "s/key=/#key=/g;s/cert=/#cert=/g" /etc/zone-mta/interfaces/feeder.toml +echo '# @include "../../wildduck/tls.toml"' >> /etc/zone-mta/interfaces/feeder.toml + +# vanity script as first run should not restart anything +echo '#!/bin/bash +echo "OK"' > /usr/local/bin/reload-services.sh +chmod +x /usr/local/bin/reload-services.sh + +/root/.acme.sh/acme.sh --issue --nginx \ + -d "$HOSTNAME" \ + --key-file /etc/wildduck/certs/privkey.pem \ + --fullchain-file /etc/wildduck/certs/fullchain.pem \ + --reloadcmd "/usr/local/bin/reload-services.sh" \ + --force || echo "Warning: Failed to generate certificates, using self-signed certs" + +# Update site config, make sure ssl is enabled +echo "server { + listen 80; + server_name $HOSTNAME; + return 301 https://\$server_name\$request_uri; +} + +server { + listen 443 ssl http2; + + server_name $HOSTNAME; + + ssl_certificate /etc/wildduck/certs/fullchain.pem; + ssl_certificate_key /etc/wildduck/certs/privkey.pem; + + location / { + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header HOST \$http_host; + proxy_set_header X-NginX-Proxy true; + proxy_pass http://127.0.0.1:3000; + proxy_redirect off; + } +}" > "/etc/nginx/sites-available/$HOSTNAME" +systemctl reload nginx + +# update reload script for future updates +echo '#!/bin/bash +/bin/systemctl reload nginx +/bin/systemctl reload wildduck +/bin/systemctl restart zone-mta +/bin/systemctl restart haraka +/bin/systemctl restart wildduck-webmail' > /usr/local/bin/reload-services.sh +chmod +x /usr/local/bin/reload-services.sh + ### start services #### systemctl start mongod @@ -308,3 +393,33 @@ systemctl start haraka systemctl start zone-mta systemctl start wildduck-webmail systemctl reload nginx + +cd "$INSTALLDIR" + +echo "NAMESERVER SETUP +================ + +MX +-- +Add this MX record to the $HOSTNAME DNS zone: + +$HOSTNAME. IN MX 5 $HOSTNAME. + +SPF +--- +Add this TXT record to the $HOSTNAME DNS zone: + +$HOSTNAME. IN TXT \"v=spf1 a ~all\" + +DKIM +---- +Add this TXT record to the $HOSTNAME DNS zone: + +wildduck._domainkey.$HOSTNAME. IN TXT \"$DNS_ADDRESS\" + +(these settings are stored to $INSTALLDIR/$HOSTNAME-nameserver.txt)" > "$INSTALLDIR/$HOSTNAME-nameserver.txt" + +echo "" +cat "$HOSTNAME-nameserver.txt" +echo "" +echo "All done, open https://$HOSTNAME/ in your browser"