diff --git a/Changes.md b/Changes.md
index 97d5fe450..a2380d7dd 100644
--- a/Changes.md
+++ b/Changes.md
@@ -28,6 +28,9 @@
- logger: extend add_log_methods to Classes (connection, plugins, hmail) #3322
- logger: when logging via `logger` methods, use short names #3322
- logger: check Object.hasOwn to avoid circular deps
+- mail_from.resolvable: refactored, leaning on improved net_utils #3322
+ - fixes haraka/haraka-net-utils#88
+- messagesniffer: repackaged as NPM module
- outbound
- check for local_mx only when default route is used #3307
- client_pool: use tls_socket directly (shed line_socket)
@@ -49,8 +52,6 @@
- remove undocumented use of send_email with arity of 2. #3322
- encapsulate force_tls logic into get_force_tls #3322
- queue/lmtp: refactored for DRY and improved readability #3322
-- mail_from.resolvable: refactored, leaning on improved net_utils #3322
- - fixes haraka/haraka-net-utils#88
- smtp_client: pass connect_timeout, maybe fixes #3281
- spamassassin: repackaged as NPM module #3348
- style(es6): more for...of loops
diff --git a/Plugins.md b/Plugins.md
index c5010a1ca..aa7cc9e04 100644
--- a/Plugins.md
+++ b/Plugins.md
@@ -171,7 +171,7 @@ A comprehensive list of known plugins. Create a PR to add yours to these lists.
[url-logreader]: https://github.com/haraka/haraka-plugin-log-reader
[url-milter]: https://github.com/haraka/haraka-plugin-milter
[url-mfres]: https://github.com/haraka/Haraka/blob/master/docs/plugins/mail_from.is_resolvable.md
-[url-msgsniff]: https://github.com/haraka/Haraka/blob/master/docs/plugins/messagesniffer.md
+[url-msgsniff]: https://github.com/haraka/haraka-plugin-messagesniffer
[url-ms]: http://www.armresearch.com/Products/
[url-creds]: https://github.com/haraka/Haraka/blob/master/docs/plugins/prevent_credential_leaks.md
[url-postgres]: https://github.com/haraka/haraka-plugin-rcpt-postgresql
diff --git a/config/messagesniffer.ini b/config/messagesniffer.ini
deleted file mode 100644
index d1750ddc3..000000000
--- a/config/messagesniffer.ini
+++ /dev/null
@@ -1,18 +0,0 @@
-;port=9001
-;tmpdir=/tmp
-;gbudb_report_deny=true
-;tag_string=[SPAM]
-
-;[gbudb]
-;white=accept
-;caution=allow
-;black=allow
-;truncate=reject
-
-;[message]
-;white=allow
-;local_white=accept
-;caution=allow
-;black=allow
-;truncate=reject
-;nonzero=reject
diff --git a/docs/plugins/messagesniffer.md b/docs/plugins/messagesniffer.md
deleted file mode 100644
index 871ab8651..000000000
--- a/docs/plugins/messagesniffer.md
+++ /dev/null
@@ -1,163 +0,0 @@
-messagesniffer
-==============
-
-This plugin provides integration with the commerical Anti-Spam product [MessageSniffer](http://armresearch.com/products/sniffer.jsp) by Arm Research Labs using its XML Client interface [XCI](http://armresearch.com/support/articles/software/snfServer/xci/) over TCP.
-
-Installation
-------------
-
-Install the SNF Client/Server package for your platform as per the instructions on the MessageSniffer website.
-
-Modify your SNFServer.xml file and under the 'xheaders' section set:
-
-* output mode='api'
-
-This prevents MessageSniffer from adding additional headers to the temporary file used to send it the message data which is
-unnecessary as Haraka reads the headers from the XCI response.
-
-* rulebase on-off='on'
-* result on-off='on'
-* black on-off='on'
-* while on-off='on'
-* clean on-off='on'
-* all symbol on-off='on'
-
-These cause SNFServer to send Haraka additional headers that are inserted into all messages scanned by MessageSniffer and
-will aid debugging and troubleshooting.
-
-Once this is done start/restart the SNF server.
-
-Configuration
--------------
-
-This plugin uses `messagesniffer.ini` for configuration. The `[main]` section is for global configuration, the `[gbudb]`
-section is used to specify the action that should be taken based on the GBUdb result which is checked at the start of the
-connection and the `[message]` section is used to specify the action to be taken based on the main scan result.
-
-`[main]`
-
-- port
-
- Default: 9001
- TCP port to use when communicating to the SNFServer daemon.
- This needs to match the `` value in the SNFServer.xml file.
-
-- tmpdir
-
- Default: /tmp
- Temporary directory used to write temporary message files to that are read by the SNFServer daemon.
- This directory and the files within need to be readable by the user that SNFServer is running as.
-
-- gbudb\_report\_deny = [ true | false | 0 | 1 ]
-
- Default: false
- This is an experimental option that will record a GBUdb 'bad' encounter for a connected IP address when a client
- disconnects with no message having been sent or seen by MessageSniffer but Haraka has recorded a hard rejection at
- some point during the session. The idea behind this option is that it allows other Haraka plugins rejections influence
- GBUdb IP reputation where MessageSniffer isn't seeing the actual message because it is being rejected pre-DATA.
-
-- tag\_string
-
- Default: [SPAM]
- String to prepend to the Subject line if the 'tag' action is applied.
-
-`[gbudb]`
-
-- white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: accept
- Action to take when GBUdb reports a 'white' result.
-
-- caution = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: continue
- Action to take when GBUdb reports a 'caution' result.
-
-- black = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: continue
- Action to take when GBUdb reports a 'black' result.
-
-- truncate = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: reject
- Action to take when GBUdb reports a 'truncate' result.
-
-`[message]`
-
-- white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: continue
- Action to take when MessageSniffer reports a 'white' result (result code: 0).
-
-- local\_white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: accept
- Action to take when MessageSniffer reports a local whitelist result (result code: 1).
- NOTE: You will not see this result unless you Arm support have customized your rulebase and added white rules for you.
-
-- truncate = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: reject
- Action to take when MessageSniffer reports a GBUdb result of 'truncate' (result code: 20).
- NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured
- Source and DrillDown options in the Training section of SNFServer.xml.
-
-- caution = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: continue
- Action to take when MessageSniffer reports a GBUdb result of 'caution' (result code: 40).
- NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured
- Source and DrillDown options in the Training section of SNFServer.xml.
-
-- black = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Default: continue
- Action to take when MessageSniffer reports a GBUdb result of 'black' (result code: 63).
- NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured
- Source and DrillDown options in the Training section of SNFServer.xml.
-
-- code\_NN = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- NOTE: replace NN with the numeric MessageSniffer [result code](http://armresearch.com/support/articles/software/snfServer/core.jsp)
- Action to take when MessageSniffer reports a result code other than those explicitly defined above.
-
-- nonzero = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ]
-
- Defalt: reject
- Action to take for any non-zero result code other than those explicity defined above. This is a catch-all result that
- is checked last after all other settings have been checked so you can define a code\_NN value to prevent this action from
- being taken.
-
-Actions
--------
-
-* accept
-
- Accept the message and skip further plugins (whitelist).
-
-* allow | continute
-
- Continue to the next plugin.
-
-* retry | tempfail
-
- Reject the message with a temporary failure message (DENYSOFT).
-
-* reject
-
- Reject the message with a permanent failure message (DENY).
-
-* quarantine
-
- Continue to the next plugin. If the message isn't rejected by another plugin - it will cause the message to be quarantined
- and the message will not be delivered to the recipient(s).
-
- NOTE: this option requires the queue/quarantine plugin in your config/plugins files and it must be listed before any
- other queue plugins.
-
-* tag
-
- Tag the subject with the default 'tag\_string' defined in the `main` section above, this will also set X-Spam-Flag: YES in
- the message headers. Once tagged, processing will continue to the next plugin.
-
diff --git a/package.json b/package.json
index e081e292b..f3d430123 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"haraka-plugin-karma": "^2.1.5",
"haraka-plugin-known-senders": "^1.1.0",
"haraka-plugin-limit": "^1.2.3",
+ "haraka-plugin-messagesniffer": "^1.0.0",
"haraka-plugin-p0f": "^1.0.9",
"haraka-plugin-qmail-deliverable": "^1.2.3",
"haraka-plugin-rcpt-ldap": "^1.1.0",
diff --git a/plugins/messagesniffer.js b/plugins/messagesniffer.js
deleted file mode 100644
index 8d08ce13a..000000000
--- a/plugins/messagesniffer.js
+++ /dev/null
@@ -1,381 +0,0 @@
-// messagesniffer
-
-const fs = require('fs');
-const net = require('net');
-const plugin = exports;
-
-// Defaults
-let port = 9001;
-
-exports.register = function () {
- const cfg = this.config.get('messagesniffer.ini');
- if (cfg.main.port) port = parseInt(cfg.main.port);
-}
-
-exports.hook_connect = function (next, connection) {
- const cfg = this.config.get('messagesniffer.ini');
- // Skip any private IP ranges
- // Skip connection.transaction undefined
- if (connection?.remote?.is_private || !connection?.transaction) return next();
-
- // Retrieve GBUdb information for the connecting IP
- SNFClient(``, (err, result) => {
- if (err) {
- connection.logerror(this, err.message);
- return next();
- }
- let match;
- if ((match = /)[^])+)\/>/.exec(result))) {
- // Log result
- connection.loginfo(this, match[1]);
- // Populate result
- const gbudb = {};
- const split = match[1].toString().split(/\s+/);
- for (const element of split) {
- const split2 = element.split(/=/);
- gbudb[split2[0]] = split2[1].replace(/(?:^'|'$)/g,'');
- }
- // Set notes for other plugins
- connection.notes.gbudb = gbudb;
- // Handle result
- switch (gbudb.range) {
- case 'new':
- case 'normal':
- return next();
- case 'white':
- // Default for white if no configuration
- if (!cfg.gbudb || (cfg.gbudb && !cfg.gbudb[gbudb.range])) {
- return next(OK);
- }
- // fall through
- case 'caution':
- case 'black':
- case 'truncate':
- if (cfg.gbudb?.[gbudb.range]) {
- connection.loginfo(this, `range=${gbudb.range} action=${cfg.gbudb[gbudb.range]}`);
- switch (cfg.gbudb[gbudb.range]) {
- case 'accept':
- // Whitelist
- connection.notes.gbudb.action = 'accept';
- return next(OK);
- case 'allow':
- case 'continue':
- // Continue to next plugin
- connection.notes.gbudb.action = 'allow';
- return next();
- case 'retry':
- case 'tempfail':
- return next(DENYSOFT, `Poor GBUdb reputation for [${connection.remote.ip}]`);
- case 'reject':
- return next(DENY, `Poor GBUdb reputation for [${connection.remote.ip}]`);
- case 'quarantine':
- connection.notes.gbudb.action = 'quarantine';
- connection.notes.quarantine = true;
- connection.notes.quarantine_action = [ OK, `Message quarantined (${connection.transaction.uuid})` ];
- break;
- case 'tag':
- connection.notes.gbudb.action = 'tag';
- break;
- default:
- // Unknown action
- return next();
- }
- }
- else if (gbudb.range === 'truncate') {
- // Default for truncate
- return next(DENY, `Poor GBUdb reputation for [${connection.remote.ip}]`);
- }
- return next();
- default:
- // Unknown
- connection.logerror(this, `Unknown GBUdb range: ${gbudb.range}`);
- next();
- }
- }
- else {
- next();
- }
- });
-}
-
-exports.hook_data_post = function (next, connection) {
- const cfg = this.config.get('messagesniffer.ini');
- const txn = connection?.transaction;
- if (!txn) return next();
-
- function tag_subject (){
- const tag = cfg.main.tag_string || '[SPAM]';
- const subj = txn.header.get_decoded('Subject');
- // Try and prevent any double subject modifications
- const subject_re = new RegExp(`^${tag}`);
- if (!subject_re.test(subj)) {
- txn.remove_header('Subject');
- txn.add_header('Subject', `${tag} ${subj}`);
- }
- // Add spam flag
- txn.remove_header('X-Spam-Flag');
- txn.add_header('X-Spam-Flag', 'YES');
- }
-
- // Check GBUdb results
- if (connection.notes.gbudb?.action) {
- switch (connection.notes.gbudb.action) {
- case 'accept':
- case 'quarantine':
- return next(OK);
- case 'tag':
- // Tag message
- tag_subject();
- return next();
- }
- }
-
- const tmpdir = cfg.main.tmpdir || '/tmp';
- const tmpfile = `${tmpdir}/${txn.uuid}.tmp`;
- const ws = fs.createWriteStream(tmpfile);
-
- ws.once('error', err => {
- connection.logerror(this, `Error writing temporary file: ${err.message}`);
- next();
- });
-
- ws.once('close', () => {
- const start_time = Date.now();
- SNFClient(``, (err, result) => {
- const end_time = Date.now();
- const elapsed = end_time - start_time;
- // Delete the tempfile
- fs.unlink(tmpfile, () => {});
- let match;
- // Make sure we actually got a result
- if ((match = /((?:(?!<\/xhdr>)[^])+)/.exec(result,'m'))) {
- // Parse the returned headers and add them to the message
- const xhdr = match[1].split('\r\n');
- const headers = [];
- for (const line of xhdr) {
- // Check for continuation
- if (/^\s/.test(line)) {
- // Continuation; add to previous header value
- if (headers[headers.length-1]) {
- headers[headers.length-1].value += `${line}\r\n`;
- }
- }
- else {
- // Must be a header
- match = /^([^: ]+):(?:\s*(.+))?$/.exec(line);
- if (match) {
- headers.push({ header: match[1], value: (match[2] ? `${match[2]}\r\n` : '\r\n') });
- }
- }
- }
- // Add headers to message
- for (const header of headers) {
- // If present save the group for logging purposes
- if (header.header === 'X-MessageSniffer-SNF-Group') {
- group = header.value.replace(/\r?\n/gm, '');
- }
- // Log GBUdb analysis
- if (header.header === 'X-GBUdb-Analysis') {
- // Retrieve IP address determined by GBUdb
- const gbudb_split = header.value.split(/,\s*/);
- gbudb_ip = gbudb_split[1];
- connection.logdebug(this, `GBUdb: ${header.value.replace(/\r?\n/gm, '')}`);
- }
- if (header.header === 'X-MessageSniffer-Rules') {
- rules = header.value.replace(/\r?\n/gm, '').replace(/\s+/g,' ').trim();
- connection.logdebug(this, `rules: ${rules}`);
- }
- // Remove any existing headers
- txn.remove_header(header.header);
- txn.add_header(header.header, header.value);
- }
- }
- // Summary log
- connection.loginfo(this, `result: time=${elapsed}ms code=${code
- }${gbudb_ip ? ` ip="${gbudb_ip}"` : ''
- }${group ? ` group="${group}"` : ''
- }${rules ? ` rule_count=${rules.split(/\s+/).length}` : ''
- }${rules ? ` rules="${rules}"` : ''}`);
- // Result code MUST in the 0-63 range otherwise we got an error
- // http://www.armresearch.com/support/articles/software/snfServer/errors.jsp
- if (code === 0 || (code && code <= 63)) {
- // Handle result
- let action;
- if (cfg.message) {
- if (code === 0 && cfg.message.white) {
- action = cfg.message.white;
- }
- else if (code === 1) {
- if (cfg.message.local_white) {
- action = cfg.message.local_white;
- }
- else {
- return next(OK);
- }
- }
- else if (code === 20) {
- if (cfg.message.truncate) {
- action = cfg.message.truncate;
- }
- else {
- return next(DENY, `Poor GBUdb reputation for IP [${connection.remote.ip}]`);
- }
- }
- else if (code === 40 && cfg.message.caution) {
- action = cfg.message.caution;
- }
- else if (code === 63 && cfg.message.black) {
- action = cfg.message.black;
- }
- else {
- if (cfg.message[`code_${code}`]) {
- action = cfg.message[`code_${code}`];
- }
- else {
- if (code > 1 && code !== 40) {
- if (cfg.message.nonzero) {
- action = cfg.message.nonzero;
- }
- else {
- return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`);
- }
- }
- }
- }
- }
- else {
- // Default with no configuration
- if (code > 1 && code !== 40) {
- return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`);
- }
- else {
- return next();
- }
- }
- switch (action) {
- case 'accept':
- // Whitelist
- return next(OK);
- case 'allow':
- case 'continue':
- // Continue to next plugin
- return next();
- case 'retry':
- case 'tempfail':
- return next(DENYSOFT, `Spam detected by MessageSniffer (code=${code} group=${group})`);
- case 'reject':
- return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`);
- case 'quarantine':
- // Set flag for queue/quarantine plugin
- txn.notes.quarantine = true;
- txn.notes.quarantine_action = [ OK, `Message quarantined (${txn.uuid})` ];
- break;
- case 'tag':
- tag_subject();
- // fall through
- default:
- return next();
- }
- }
- else {
- // Out-of-band code returned
- // Handle Bulk/Noisy special rule by re-writing the Precedence header
- if (code === 100) {
- let precedence = txn.header.get('precedence');
- if (precedence) {
- // We already have a precedence header
- precedence = precedence.trim().toLowerCase();
- switch (precedence) {
- case 'bulk':
- case 'list':
- case 'junk':
- // Leave these as they are
- break;
- default:
- // Remove anything else and replace it with 'bulk'
- txn.remove_header('precedence');
- txn.add_header('Precedence', 'bulk');
- }
- }
- else {
- txn.add_header('Precedence', 'bulk');
- }
- }
- return next();
- }
- }
- else {
- // Something must have gone wrong
- connection.logwarn(this, `unexpected response: ${result}`);
- }
- return next();
- });
- });
-
- // TODO: we only need the first 64Kb of the message
- txn.message_stream.pipe(ws);
-}
-
-exports.hook_disconnect = function (next, connection) {
- const cfg = this.config.get('messagesniffer.ini');
-
- // Train GBUdb on rejected messages and recipients
- if (cfg.main.gbudb_report_deny && !connection.notes.snf_run &&
- (connection.rcpt_count.reject > 0 || connection.msg_count.reject > 0)) {
- const snfreq = ``;
- SNFClient(snfreq, (err, result) => {
- if (err) {
- connection.logerror(this, err.message);
- }
- else {
- connection.logdebug(this, `GBUdb bad encounter added for ${connection.remote.ip}`);
- }
- next();
- });
- }
- else {
- next();
- }
-}
-
-function SNFClient (req, cb) {
- let result;
- const sock = new net.Socket();
- sock.setTimeout(30 * 1000); // Connection timeout
- sock.once('timeout', function () {
- this.destroy();
- cb(new Error('connection timed out'));
- });
- sock.once('error', err => cb(err));
- sock.once('connect', function () {
- // Connected, send request
- plugin.logprotocol(`> ${req}`);
- this.write(`${req}\n`);
- });
- sock.on('data', data => {
- plugin.logprotocol(`< ${data}`);
- // Buffer all the received lines
- (result ? result += data : result = data);
- });
- sock.once('end', () => {
- // Check for result
- if (/