diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..77831d0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "env": { + "node": true, + "mocha": true, + "es6": true + }, + "extends": "eslint:recommended", + "installedESLint": true, + "root": true, + "rules": { + "comma-dangle": [2, "only-multiline"], + "dot-notation": 2, + "indent": [2, 4, {"SwitchCase": 1}], + "one-var": [2, "never"], + "no-trailing-spaces": [2, { "skipBlankLines": false }], + "keyword-spacing": [2, { + "before": true, + "after": true + }], + "no-delete-var": 2, + "no-empty": ["error", { "allowEmptyCatch": true }], + "no-label-var": 2, + "no-shadow": 2, + "no-unused-vars": [ 1, { "args": "none" }], + "no-console": 0 + } +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..fcdc330 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: node_js +node_js: + - "4" + - "6" + +services: + - redis-server + +before_script: + +script: + - npm run lint + - npm test + +after_success: + - npm install istanbul codecov + - npm run cover + - ./node_modules/.bin/codecov + +sudo: false diff --git a/README.md b/README.md index 5fcb65c..16de39a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ -# haraka-plugin-limit -Enforce many types of limits on a Haraka mail server. +# limit + +Apply several types of limits to SMTP connections. + +Each limit type has a max value that can be defined in limit.ini. The default is empty / disabled until a value has been set. + +## See Also + +To set rate limits on connections and recipients, see the `rate_limit` plugin. + +## concurrency + +When `[concurrency]max` is defined, it limits the maximum number of simultaneous connections per IP address. Connection attempts in excess of the limit are delayed for `disconnect_delay` seconds (default: 3) before being disconnected. + +This works best in conjunction with a history / reputation database, so that +one can assign very low concurrency (1) to bad or unknown senders and higher +limits for reputable mail servers. + +### History + +History: when enabled, the `history` setting is the name of a plugin that stores IP history / reputation results. The result store must have a positive value for good connections and negative integers for poor / undesirable connections. Karma is one such plugin. + + +## recipients + +When `[recipients]max` is defined, each connection is limited to that number of recipients. The limit is imposed against **all** recipient attempts. Attempts in excess of the limit are issued a temporary failure. + + +## unrecognized_commands + +When `[unrecognized_commands]max` is set, a connection that exceeeds the limit is disconnected. + +Unrecognized commands are normally SMTP verbs invalidly issued by the client. +Examples: + +* issuing AUTH when we didn't advertise AUTH extension +* issuing STARTTLS when we didn't advertise STARTTLS +* invalid SMTP verbs + + +### Limitations + +The unrecognized_command hook is used by the `tls` and `auth` plugins, so +running this plugin before those would result in valid operations getting +counted against that connections limits. The solution is simple: list +`limit` in config/plugins after those. + + +## errors + +When `[errors]max` is set, a connection that exceeeds the limit is disconnected. Errors that count against this limit include: + +* issuing commands out of turn (MAIL before EHLO, RCPT before MAIL, etc) +* attempting MAIL on port 465/587 without AUTH +* MAIL or RCPT addresses that fail to parse + +# Error Handling + +## Too high counters + +If the NoSQL store is Redis and Haraka is restarted or crashes while active +connections are open, the concurrency counters might be inflated. This is +handled by the [concurrency]reset setting (default: 10m), which: + +* ssc: sets collection expiration time +* redis: empties the concurrency hash +* RAM: empties the in-memory hash of all keys + +## Too low counters + +Because the redis and RAM objects are emptied periodically, connections that +are open while the collections are emptied will be too low. When +that happens, log messages like these might be emitted: + + resetting 0 to 1 + resetting -1 to 1 + +This is a harmless error condition that is repaired automatically. diff --git a/config/limit.ini b/config/limit.ini new file mode 100644 index 0000000..0caa58c --- /dev/null +++ b/config/limit.ini @@ -0,0 +1,46 @@ +; limits imposed on connection(s) +; see also: rate_limit + +[redis] +; host=127.0.0.1 +; port=6387 +db=4 +; +; expire: minutes concurrency information is cached in RAM, default: 10 +; expire=10 + +; there are no defaults unless shown. Limits are disabled until a max value is set. +; help: haraka -h limits + +[concurrency] +; max=3 +; disconnect_delay=5 +; reset=10 // default: minutes + +; History: when enabled, the value must be a plugin which stores IP history +; results. The result store must have a positive value for good connections +; and negative integers for poor / undesirable connections. At present, +; karma is the only such plugin. +; history=karma +; history_bad=1 +; history_none=3 +; history_good=10 + + +[recipients] +; max= 20 +; max_relaying= 100 + +; The same history notes for [concurrency] apply here. +; history=karma +; history_bad=5 +; history_none=15 +; history_good=50 + + +[unrecognized_commands] +; max= 10 + + +[errors] +; max= 10 diff --git a/index.js b/index.js new file mode 100644 index 0000000..8224d84 --- /dev/null +++ b/index.js @@ -0,0 +1,241 @@ +'use strict'; + +var constants = require('haraka-constants'); + +exports.register = function () { + var plugin = this; + plugin.inherits('haraka-plugin-redis'); + + plugin.register_hook('init_master', 'init_redis_plugin'); + plugin.register_hook('init_child', 'init_redis_plugin'); + + plugin.load_limit_ini(); + + if (plugin.cfg.concurrency) { + plugin.register_hook('connect_init', 'incr_concurrency'); + plugin.register_hook('connect', 'check_concurrency'); + plugin.register_hook('disconnect', 'decr_concurrency'); + } + + if (plugin.cfg.errors) { + ['helo','ehlo','mail','rcpt','data'].forEach(function (hook) { + plugin.register_hook(hook, 'max_errors'); + }); + } + + if (plugin.cfg.recipients) { + plugin.register_hook('rcpt', 'max_recipients'); + } + + if (plugin.cfg.unrecognized_commands) { + plugin.register_hook('unrecognized_command', 'max_unrecognized_commands'); + } +}; + +exports.load_limit_ini = function () { + var plugin = this; + plugin.cfg = plugin.config.get('limit.ini', function () { + plugin.load_limit_ini(); + }); + + if (!plugin.cfg.concurrency) { // no config file + plugin.cfg.concurrency = {}; + } + + plugin.merge_redis_ini(); +}; + +exports.max_unrecognized_commands = function(next, connection, cmd) { + var plugin = this; + if (!plugin.cfg.unrecognized_commands) { return next(); } + + connection.results.add(plugin, {fail: 'unrecognized: ' + cmd, emit: true}); + connection.results.incr(plugin, {unrec_cmds: 1}); + + var max = parseFloat(plugin.cfg.unrecognized_commands.max); + if (!max || isNaN(max)) { return next(); } + + var uc = connection.results.get(plugin.name); + if (parseFloat(uc.unrec_cmds) <= max) { return next(); } + + connection.results.add(plugin, {fail: 'unrec_cmds.max'}); + return next(constants.DENYDISCONNECT, 'Too many unrecognized commands'); +}; + +exports.max_errors = function (next, connection) { + var plugin = this; + if (!plugin.cfg.errors) { return next(); } // disabled in config + + var max = parseFloat(plugin.cfg.errors.max); + if (!max || isNaN(max)) { return next(); } + + if (connection.errors <= max) { return next(); } + + connection.results.add(plugin, {fail: 'errors.max'}); + return next(constants.DENYSOFTDISCONNECT, 'Too many errors'); +}; + +exports.max_recipients = function (next, connection, params) { + var plugin = this; + if (!plugin.cfg.recipients) { return next(); } // disabled in config + + var max = plugin.get_recipient_limit(connection); + if (!max) { return next(); } + + var c = connection.rcpt_count; + var count = c.accept + c.tempfail + c.reject + 1; + if (count <= max) { return next(); } + + connection.results.add(plugin, {fail: 'recipients.max'}); + return next(constants.DENYSOFT, 'Too many recipients'); +}; + +exports.get_recipient_limit = function (connection) { + var plugin = this; + + if (connection.relaying && plugin.cfg.recipients.max_relaying) { + return plugin.cfg.recipients.max_relaying; + } + + var history_plugin = plugin.cfg.concurrency.history; + if (!history_plugin) { + return plugin.cfg.recipients.max; + } + + var results = connection.results.get(history_plugin); + if (!results) { + connection.logerror(plugin, 'no ' + history_plugin + ' results,' + + ' disabling history due to misconfiguration'); + delete plugin.cfg.recipients.history; + return plugin.cfg.recipients.max; + } + + if (results.history === undefined) { + connection.logerror(plugin, 'no history from : ' + history_plugin); + return plugin.cfg.recipients.max; + } + + var history = parseFloat(results.history); + connection.logdebug(plugin, 'history: ' + history); + if (isNaN(history)) { history = 0; } + + if (history > 0) return plugin.cfg.recipients.history_good || 50; + if (history < 0) return plugin.cfg.recipients.history_bad || 2; + return plugin.cfg.recipients.history_none || 15; +}; + +exports.incr_concurrency = function (next, connection) { + var plugin = this; + if (!plugin.cfg.concurrency) { return next(); } + + var dbkey = plugin.get_key(connection); + + plugin.db.incrby(dbkey, 1, function (err, concurrent) { + + if (concurrent === undefined) { + connection.logerror(plugin, 'concurrency not returned by incrby!'); + return next(); + } + if (isNaN(concurrent)) { + connection.logerror(plugin, 'concurrency isNaN!'); + return next(); + } + + connection.logdebug(plugin, 'concurrency incremented to ' + concurrent); + + // repair negative concurrency counters + if (concurrent < 1) { + connection.loginfo(plugin, 'resetting ' + concurrent + ' to 1'); + plugin.db.set(dbkey, 1); + } + + connection.notes.limit=concurrent; + next(); + }); +}; + +exports.get_key = function (connection) { + return 'concurrency|' + connection.remote.ip; +}; + +exports.check_concurrency = function (next, connection) { + var plugin = this; + + var max = plugin.get_concurrency_limit(connection); + if (!max || isNaN(max)) { + connection.logerror(plugin, "no limit?!"); + return next(); + } + connection.logdebug(plugin, 'concurrent max: ' + max); + + var concurrent = parseInt(connection.notes.limit); + + if (concurrent <= max) { + connection.results.add(plugin, { pass: concurrent + '/' + max}); + return next(); + } + + connection.results.add(plugin, { + fail: 'concurrency: ' + concurrent + '/' + max, + }); + + var delay = 3; + if (plugin.cfg.concurrency.disconnect_delay) { + delay = parseFloat(plugin.cfg.concurrency.disconnect_delay); + } + + // Disconnect slowly. + setTimeout(function () { + return next(constants.DENYSOFTDISCONNECT, 'Too many concurrent connections'); + }, delay * 1000); +}; + +exports.get_concurrency_limit = function (connection) { + var plugin = this; + + var history_plugin = plugin.cfg.concurrency.history; + if (!history_plugin) { + return plugin.cfg.concurrency.max; + } + + var results = connection.results.get(history_plugin); + if (!results) { + connection.logerror(plugin, 'no ' + history_plugin + ' results,' + + ' disabling history due to misconfiguration'); + delete plugin.cfg.concurrency.history; + return plugin.cfg.concurrency.max; + } + + if (results.history === undefined) { + connection.loginfo(plugin, 'no IP history from : ' + history_plugin); + return plugin.cfg.concurrency.max; + } + + var history = parseFloat(results.history); + connection.logdebug(plugin, 'history: ' + history); + if (isNaN(history)) { history = 0; } + + if (history < 0) { return plugin.cfg.concurrency.history_bad || 1; } + if (history > 0) { return plugin.cfg.concurrency.history_good || 5; } + return plugin.cfg.concurrency.history_none || 3; +}; + +exports.decr_concurrency = function (next, connection) { + var plugin = this; + if (!plugin.cfg.concurrency) { return next(); } + + var dbkey = plugin.get_key(connection); + plugin.db.incrby(dbkey, -1, function (err, concurrent) { + connection.logdebug(plugin, 'decrement concurrency to ' + concurrent); + + // if connections didn't increment properly (this happened a lot + // before we added the connect_init hook), the counter can go + // negative. check for and repair negative concurrency counters + if (concurrent < 0) { + connection.loginfo(plugin, 'resetting ' + concurrent + ' to 1'); + plugin.db.set(dbkey, 1); + } + + return next(); + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f5078a2 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "haraka-plugin-limit", + "version": "1.0.0", + "description": "enforce various types of limits on remote MTAs", + "main": "limit.js", + "directories": { + "test": "test" + }, + "dependencies": { + "haraka-constants": "^1.0.2", + "haraka-plugin-redis": "*", + "redis": "^2.6.5" + }, + "devDependencies": { + "eslint": "^3.14.1", + "haraka-test-fixtures": "^1.0.13", + "nodeunit": "^0.10.2" + }, + "scripts": { + "lint": "./node_modules/.bin/eslint *.js test/*.js", + "test": "./run_tests" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/haraka/haraka-plugin-limit.git" + }, + "keywords": [ + "haraka", + "smtp", + "mta", + "limit" + ], + "author": "Matt Simerson ", + "license": "MIT", + "bugs": { + "url": "https://github.com/haraka/haraka-plugin-limit/issues" + }, + "homepage": "https://github.com/haraka/haraka-plugin-limit#readme" +} diff --git a/run_tests b/run_tests new file mode 100755 index 0000000..22222c3 --- /dev/null +++ b/run_tests @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +"use strict"; + +try { + var reporter = require('nodeunit').reporters.default; +} +catch(e) { + console.log("Error: " + e.message); + console.log(""); + console.log("Cannot find nodeunit module."); + console.log("Please run the following:"); + console.log(""); + console.log(" npm install"); + console.log(""); + process.exit(); +} + +process.chdir(__dirname); + +if (process.argv[2]) { + console.log("Running tests: ", process.argv.slice(2)); + reporter.run(process.argv.slice(2), undefined, function (err) { + process.exit(((err) ? 1 : 0)); + }); +} +else { + reporter.run([ + 'test', + ], undefined, function (err) { + process.exit(((err) ? 1 : 0)); + }); +} diff --git a/test/limit.js b/test/limit.js new file mode 100644 index 0000000..395a914 --- /dev/null +++ b/test/limit.js @@ -0,0 +1,169 @@ +'use strict'; + +var constants = require('haraka-constants'); +var fixtures = require('haraka-test-fixtures'); + +var _set_up = function (done) { + + this.plugin = new fixtures.plugin('index'); + + this.plugin.cfg = { main: {} }; + + this.connection = new fixtures.connection.createConnection(); + this.connection.results = new fixtures.result_store(this.connection); + this.connection.transaction = new fixtures.transaction.createTransaction(); + + this.plugin.register(); + done(); +}; + +exports.inheritance = { + setUp : function (done) { + this.plugin = new fixtures.plugin('index'); + done(); + }, + 'inherits redis': function (test) { + test.expect(1); + this.plugin.inherits('haraka-plugin-redis'); + test.equal(typeof this.plugin.load_redis_ini, 'function'); + test.done(); + }, + 'can call parent functions': function (test) { + test.expect(1); + this.plugin.inherits('haraka-plugin-redis'); + this.plugin.load_redis_ini(); + test.ok(this.plugin.redisCfg); // loaded config + test.done(); + }, + 'register': function (test) { + test.expect(1); + this.plugin.register(); + test.ok(this.plugin.cfg); // loaded config + test.done(); + }, +}; + +exports.max_errors = { + setUp : _set_up, + 'none': function (test) { + // console.log(this); + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, null); + test.equal(msg, null); + test.done(); + }; + this.plugin.max_errors(cb, this.connection); + }, + 'too many': function (test) { + // console.log(this); + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, constants.DENYSOFTDISCONNECT); + test.equal(msg, 'Too many errors'); + test.done(); + }; + this.connection.errors=10; + this.plugin.cfg.errors = { max: 9 }; + this.plugin.max_errors(cb, this.connection); + }, +}; + +exports.max_recipients = { + setUp : _set_up, + 'none': function (test) { + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, null); + test.equal(msg, null); + test.done(); + }; + this.plugin.max_recipients(cb, this.connection); + }, + 'too many': function (test) { + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, constants.DENYSOFT); + test.equal(msg, 'Too many recipients'); + test.done(); + }; + this.connection.rcpt_count = { accept: 3, tempfail: 5, reject: 4 }; + this.plugin.cfg.recipients = { max: 10 }; + this.plugin.max_recipients(cb, this.connection); + }, +}; + +exports.max_unrecognized_commands = { + setUp : _set_up, + 'none': function (test) { + // console.log(this); + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, null); + test.equal(msg, null); + test.done(); + }; + this.plugin.max_unrecognized_commands(cb, this.connection); + }, + 'too many': function (test) { + // console.log(this); + test.expect(2); + var cb = function (rc, msg) { + // console.log(arguments); + test.equal(rc, constants.DENYDISCONNECT); + test.equal(msg, 'Too many unrecognized commands'); + test.done(); + }; + this.plugin.cfg.unrecognized_commands = { max: 5 }; + this.connection.results.incr(this.plugin, {'unrec_cmds': 6}); + this.plugin.max_unrecognized_commands(cb, this.connection); + }, +}; + +// exports.check_concurrency = { +// setUp : _set_up, +// 'none': function (test) { +// test.expect(2); +// var cb = function (rc, msg) { +// // console.log(arguments); +// test.equal(rc, null); +// test.equal(msg, null); +// test.done(); +// }; +// this.plugin.check_concurrency(cb, this.connection); +// }, +// 'at max': function (test) { +// test.expect(2); +// var cb = function (rc, msg) { +// // console.log(arguments); +// test.equal(rc, null); +// test.equal(msg, null); +// test.done(); +// }; +// var self = this; +// self.plugin.cfg.concurrency.history = undefined; +// self.plugin.cfg.concurrency = { max: 4 }; +// self.connection.notes.limit=4; +// self.plugin.check_concurrency(cb, self.connection); +// }, +// 'too many': function (test) { +// test.expect(2); +// var cb = function (rc, msg) { +// // console.log(arguments); +// test.equal(rc, constants.DENYSOFTDISCONNECT); +// test.equal(msg, 'Too many concurrent connections'); +// test.done(); +// }; +// var self = this; +// self.plugin.cfg.concurrency.history = undefined; +// self.plugin.cfg.concurrency = { max: 4 }; +// self.plugin.cfg.concurrency.disconnect_delay=1; +// self.connection.notes.limit=5; +// self.plugin.check_concurrency(cb, self.connection); +// }, +// };