diff --git a/.codeclimate.yml b/.codeclimate.yml index dfe3bb6..013b333 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,10 +1,19 @@ engines: eslint: enabled: true - channel: "eslint-6" + channel: "eslint-8" config: config: ".eslintrc.yaml" +checks: + return-statements: + enabled: false + similar-code: + enabled: false + method-complexity: + config: + threshold: 10 + ratings: paths: - "**.js" diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 9b857f7..544da39 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -3,6 +3,7 @@ env: node: true es6: true mocha: true + es2020: true plugins: [ haraka ] diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci.yml similarity index 51% rename from .github/workflows/ci-test.yml rename to .github/workflows/ci.yml index f6c46b7..63cf78c 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,38 @@ -name: Tests +name: CI -on: [ push ] +on: + - push + +env: + CI: true jobs: - ci-test: + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + test: + needs: lint runs-on: ${{ matrix.os }} - + services: + redis: + image: redis + ports: + - 6379:6379 strategy: matrix: os: - ubuntu-latest # - windows-latest (no redis yet) - node-version: - - 12 - - 14 - - 16 + node-version: [ 14, 16, 18 ] fail-fast: false steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 name: Node ${{ matrix.node-version }} on ${{ matrix.os }} with: node-version: ${{ matrix.node-version }} - - name: Start Redis - uses: supercharge/redis-github-action@1.2.0 - with: - redis-version: ${{ matrix.redis-version }} - - run: npm install - run: npm test - env: - CI: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3627451 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,14 @@ +name: CodeQL + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '18 7 * * 4' + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index be80f73..0000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Test Coverage - -# on: [ push ] # use this for non-plugins -# haraka-plugin-*, dummy event that never triggers -on: [ milestone ] - -jobs: - - coverage: - - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '12' - - - name: Install - run: | - npm install - npm install --no-save nyc codecov - - - run: npx nyc --reporter=lcovonly npm test - env: - NODE_ENV: cov - - - name: Submit to Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index c787ae0..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Lint - -on: [ push ] - -jobs: - - lint: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: - - '12' - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - name: Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - run: npm install - - run: npm run lint - env: - CI: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..42a9bb9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,14 @@ +name: publish + +on: + push: + branches: + - master + +env: + CI: true + +jobs: + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5148e52..eba3cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ jspm_packages # Optional REPL history .node_repl_history + +package-lock.json \ No newline at end of file diff --git a/Changes.md b/Changes.md index 59958c0..028c0e7 100644 --- a/Changes.md +++ b/Changes.md @@ -1,24 +1,38 @@ -## 1.0.5 - 2022-03-08 + +### 1.0.6 - 2022-05-25 + +- feat: update redis commands to be v4 compatible +- feat: only load redis when needed, fixes #23 +- style: replaced callbacks with async/await in: + get_host_key, get_mail_key, and rate_limit +- dep(eslint): v6 -> v8 +- dep(redis): 3 -> 4 +- ci: add codeql & publish + + +### 1.0.5 - 2022-03-08 - fix invalid main field in package.json -## 1.0.4 - 2017-03-23 + +### 1.0.4 - 2017-03-23 - for outbound, find domain at hmail.todo.domain then hmail.domain. - noop: use es6 arrow functions -## 1.0.3 - 2017-03-09 + +### 1.0.3 - 2017-03-09 - add `enabled=false` flag for each limit type, defaults to off, matching the docs. -## 1.0.2 - 2017-02-06 +### 1.0.2 - 2017-02-06 - when redis handle goes away, skip processing - add a 5 minute expiration on outbound rate limit entries -## 1.0.1 - 2017-01-28 + +### 1.0.1 - 2017-01-28 - increment rate_conn on connect_init - increment rate_rcpt_host on rcpt/rcpt_ok - diff --git a/README.md b/README.md index 3ed5fc9..f9f90ed 100644 --- a/README.md +++ b/README.md @@ -234,10 +234,8 @@ Applying strict connection and rate limits is an effective way to reduce spam de - enforce rate limits with your firewall instead -[ci-img]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/ci-test.yml/badge.svg -[ci-url]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/ci-test.yml -[ci-lint-img]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/lint.yml/badge.svg -[ci-lint-url]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/lint.yml +[ci-img]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-limit/actions/workflows/ci.yml [clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-limit/badges/gpa.svg [clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-limit [npm-img]: https://img.shields.io/npm/dm/haraka-plugin-limit diff --git a/index.js b/index.js index 5b4ee09..9cf21d7 100644 --- a/index.js +++ b/index.js @@ -4,57 +4,65 @@ const constants = require('haraka-constants'); const ipaddr = require('ipaddr.js'); exports.register = function () { - const plugin = this; - plugin.inherits('haraka-plugin-redis'); - - plugin.register_hook('init_master', 'init_redis_plugin'); - plugin.register_hook('init_child', 'init_redis_plugin'); + this.inherits('haraka-plugin-redis'); - plugin.load_limit_ini(); + this.load_limit_ini(); + let needs_redis = 0 - if (plugin.cfg.concurrency.enabled) { - plugin.register_hook('connect_init', 'conn_concur_incr'); - plugin.register_hook('connect', 'check_concurrency'); - plugin.register_hook('disconnect', 'conn_concur_decr'); + if (this.cfg.concurrency.enabled) { + this.register_hook('connect_init', 'conn_concur_incr'); + this.register_hook('connect', 'check_concurrency'); + this.register_hook('disconnect', 'conn_concur_decr'); } - if (plugin.cfg.errors.enabled) { + if (this.cfg.errors.enabled) { ['helo','ehlo','mail','rcpt','data'].forEach(hook => { - plugin.register_hook(hook, 'max_errors'); - }) + this.register_hook(hook, 'max_errors'); + }); } - if (plugin.cfg.recipients.enabled) { - plugin.register_hook('rcpt', 'max_recipients'); + if (this.cfg.recipients.enabled) { + this.register_hook('rcpt', 'max_recipients'); } - if (plugin.cfg.unrecognized_commands.enabled) { - plugin.register_hook('unrecognized_command', 'max_unrecognized_commands'); + if (this.cfg.unrecognized_commands.enabled) { + this.register_hook('unrecognized_command', 'max_unrecognized_commands'); } - if (plugin.cfg.rate_conn.enabled) { - plugin.register_hook('connect_init', 'rate_conn_incr'); - plugin.register_hook('connect', 'rate_conn_enforce'); + if (this.cfg.rate_conn.enabled) { + needs_redis++ + this.register_hook('connect_init', 'rate_conn_incr'); + this.register_hook('connect', 'rate_conn_enforce'); } - if (plugin.cfg.rate_rcpt_host.enabled) { - plugin.register_hook('connect', 'rate_rcpt_host_enforce'); - plugin.register_hook('rcpt', 'rate_rcpt_host_incr'); + if (this.cfg.rate_rcpt_host.enabled) { + needs_redis++ + this.register_hook('connect', 'rate_rcpt_host_enforce'); + this.register_hook('rcpt', 'rate_rcpt_host_incr'); } - if (plugin.cfg.rate_rcpt_sender.enabled) { - plugin.register_hook('rcpt', 'rate_rcpt_sender'); + if (this.cfg.rate_rcpt_sender.enabled) { + needs_redis++ + this.register_hook('rcpt', 'rate_rcpt_sender'); } - if (plugin.cfg.rate_rcpt_null.enabled) { - plugin.register_hook('rcpt', 'rate_rcpt_null'); + if (this.cfg.rate_rcpt_null.enabled) { + needs_redis++ + this.register_hook('rcpt', 'rate_rcpt_null'); } - if (plugin.cfg.rate_rcpt.enabled) { - plugin.register_hook('rcpt', 'rate_rcpt'); + if (this.cfg.rate_rcpt.enabled) { + needs_redis++ + this.register_hook('rcpt', 'rate_rcpt'); } - if (plugin.cfg.outbound.enabled) { - plugin.register_hook('send_email', 'outbound_increment'); - plugin.register_hook('delivered', 'outbound_decrement'); - plugin.register_hook('deferred', 'outbound_decrement'); - plugin.register_hook('bounce', 'outbound_decrement'); + if (this.cfg.outbound.enabled) { + needs_redis++ + this.register_hook('send_email', 'outbound_increment'); + this.register_hook('delivered', 'outbound_decrement'); + this.register_hook('deferred', 'outbound_decrement'); + this.register_hook('bounce', 'outbound_decrement'); + } + + if (needs_redis) { + this.register_hook('init_master', 'init_redis_plugin'); + this.register_hook('init_child', 'init_redis_plugin'); } } @@ -77,11 +85,11 @@ exports.load_limit_ini = function () { plugin.load_limit_ini(); }); - if (!plugin.cfg.concurrency) { // no config file - plugin.cfg.concurrency = {}; + if (!this.cfg.concurrency) { // no config file + this.cfg.concurrency = {}; } - plugin.merge_redis_ini(); + this.merge_redis_ini(); } exports.shutdown = function () { @@ -89,129 +97,124 @@ exports.shutdown = function () { } exports.max_unrecognized_commands = function (next, connection, cmd) { - const plugin = this; - if (!plugin.cfg.unrecognized_commands) return next(); - connection.results.push(plugin, {unrec_cmds: cmd, emit: true}); + if (!this.cfg.unrecognized_commands) return next(); - const max = parseFloat(plugin.cfg.unrecognized_commands.max); + connection.results.push(this, {unrec_cmds: cmd, emit: true}); + + const max = parseFloat(this.cfg.unrecognized_commands.max); if (!max || isNaN(max)) return next(); - const uc = connection.results.get(plugin).unrec_cmds; + const uc = connection.results.get(this).unrec_cmds; if (!uc || !uc.length) return next(); if (uc.length <= max) return next(); - connection.results.add(plugin, { fail: 'unrec_cmds.max' }); - plugin.penalize(connection, true, 'Too many unrecognized commands', next); + connection.results.add(this, { fail: 'unrec_cmds.max' }); + this.penalize(connection, true, 'Too many unrecognized commands', next); } exports.max_errors = function (next, connection) { - const plugin = this; - if (!plugin.cfg.errors) return next(); // disabled in config + if (!this.cfg.errors) return next(); // disabled in config - const max = parseFloat(plugin.cfg.errors.max); + const max = parseFloat(this.cfg.errors.max); if (!max || isNaN(max)) return next(); if (connection.errors <= max) return next(); - connection.results.add(plugin, {fail: 'errors.max'}); - plugin.penalize(connection, true, 'Too many errors', next); + connection.results.add(this, {fail: 'errors.max'}); + this.penalize(connection, true, 'Too many errors', next); } exports.max_recipients = function (next, connection, params) { - const plugin = this; - if (!plugin.cfg.recipients) return next(); // disabled in config + if (!this.cfg.recipients) return next(); // disabled in config - const max = plugin.get_limit('recipients', connection); + const max = this.get_limit('recipients', connection); if (!max || isNaN(max)) return next(); const c = connection.rcpt_count; const count = c.accept + c.tempfail + c.reject + 1; if (count <= max) return next(); - connection.results.add(plugin, { fail: 'recipients.max' }); - plugin.penalize(connection, false, 'Too many recipient attempts', next); + connection.results.add(this, { fail: 'recipients.max' }); + this.penalize(connection, false, 'Too many recipient attempts', next); } exports.get_history_limit = function (type, connection) { - const plugin = this; const history_cfg = `${type}_history`; - if (!plugin.cfg[history_cfg]) return; + if (!this.cfg[history_cfg]) return; - const history_plugin = plugin.cfg[history_cfg].plugin; + const history_plugin = this.cfg[history_cfg].plugin; if (!history_plugin) return; const results = connection.results.get(history_plugin); if (!results) { - connection.logerror(plugin, `no ${history_plugin} results, disabling history due to misconfiguration`); - delete plugin.cfg[history_cfg]; + connection.logerror(this, `no ${history_plugin} results, disabling history due to misconfiguration`); + delete this.cfg[history_cfg]; return; } if (results.history === undefined) { - connection.logdebug(plugin, `no history from : ${history_plugin}`); + connection.logdebug(this, `no history from : ${history_plugin}`); return; } const history = parseFloat(results.history); - connection.logdebug(plugin, `history: ${history}`); + connection.logdebug(this, `history: ${history}`); if (isNaN(history)) return; - if (history > 0) return plugin.cfg[history_cfg].good; - if (history < 0) return plugin.cfg[history_cfg].bad; - return plugin.cfg[history_cfg].none; + if (history > 0) return this.cfg[history_cfg].good; + if (history < 0) return this.cfg[history_cfg].bad; + return this.cfg[history_cfg].none; } exports.get_limit = function (type, connection) { - const plugin = this; if (type === 'recipients') { - if (connection.relaying && plugin.cfg.recipients.max_relaying) { - return plugin.cfg.recipients.max_relaying; + if (connection.relaying && this.cfg.recipients.max_relaying) { + return this.cfg.recipients.max_relaying; } } - if (plugin.cfg[`${type}_history`]) { - const history = plugin.get_history_limit(type, connection); + if (this.cfg[`${type}_history`]) { + const history = this.get_history_limit(type, connection); if (history) return history; } - return plugin.cfg[type].max || plugin.cfg[type].default; + return this.cfg[type].max || this.cfg[type].default; } -exports.conn_concur_incr = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); - if (!plugin.cfg.concurrency) return next(); +exports.conn_concur_incr = async function (next, connection) { + if (!this.db) return next(); + if (!this.cfg.concurrency) return next(); - const dbkey = plugin.get_concurrency_key(connection); + const dbkey = this.get_concurrency_key(connection); - plugin.db.incr(dbkey, (err, count) => { - if (err) { - connection.results.add(plugin, { err: `conn_concur_incr:${err}` }); - return next(); - } + try { + const count = await this.db.incr(dbkey) if (isNaN(count)) { - connection.results.add(plugin, {err: 'conn_concur_incr got isNaN'}); + connection.results.add(this, {err: 'conn_concur_incr got isNaN'}); return next(); } - connection.results.add(plugin, { concurrent_count: count }); + connection.results.add(this, { concurrent_count: count }); // repair negative concurrency counters if (count < 1) { - connection.results.add(plugin, { + connection.results.add(this, { msg: `resetting concurrent ${count} to 1` }); - plugin.db.set(dbkey, 1); + this.db.set(dbkey, 1); } - plugin.db.expire(dbkey, 3 * 60); // 3 minute lifetime - next(); - }); + this.db.expire(dbkey, 3 * 60); // 3 minute lifetime + } + catch (err) { + connection.results.add(this, { err: `conn_concur_incr:${err}` }); + } + next(); } exports.get_concurrency_key = function (connection) { @@ -219,39 +222,35 @@ exports.get_concurrency_key = function (connection) { } exports.check_concurrency = function (next, connection) { - const plugin = this; - const max = plugin.get_limit('concurrency', connection); + const max = this.get_limit('concurrency', connection); if (!max || isNaN(max)) { - connection.results.add(plugin, {err: "concurrency: no limit?!"}); + connection.results.add(this, {err: "concurrency: no limit?!"}); return next(); } - const count = parseInt(connection.results.get(plugin.name).concurrent_count); + const count = parseInt(connection.results.get(this.name).concurrent_count); if (isNaN(count)) { - connection.results.add(plugin, { err: 'concurrent.unset' }); + connection.results.add(this, { err: 'concurrent.unset' }); return next(); } - connection.results.add(plugin, { concurrent: `${count}/${max}` }); + connection.results.add(this, { concurrent: `${count}/${max}` }); if (count <= max) return next(); - connection.results.add(plugin, { fail: 'concurrency.max' }); + connection.results.add(this, { fail: 'concurrency.max' }); - plugin.penalize(connection, true, 'Too many concurrent connections', next); + this.penalize(connection, true, 'Too many concurrent connections', next); } exports.penalize = function (connection, disconnect, msg, next) { - const plugin = this; const code = disconnect ? constants.DENYSOFTDISCONNECT : constants.DENYSOFT; - if (!plugin.cfg.main.tarpit_delay) { - return next(code, msg); - } + if (!this.cfg.main.tarpit_delay) return next(code, msg); - const delay = plugin.cfg.main.tarpit_delay; - connection.loginfo(plugin, `tarpitting for ${delay}s`); + const delay = this.cfg.main.tarpit_delay; + connection.loginfo(this, `tarpitting for ${delay}s`); setTimeout(() => { if (!connection) return; @@ -259,22 +258,26 @@ exports.penalize = function (connection, disconnect, msg, next) { }, delay * 1000); } -exports.conn_concur_decr = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); - if (!plugin.cfg.concurrency) return next(); +exports.conn_concur_decr = async function (next, connection) { - const dbkey = plugin.get_concurrency_key(connection); - plugin.db.incrby(dbkey, -1, (err, concurrent) => { - if (err) connection.results.add(plugin, { err: `conn_concur_decr:${err}` }) - return next(); - }); + if (!this.db) return next(); + if (!this.cfg.concurrency) return next(); + + try { + const dbkey = this.get_concurrency_key(connection); + await this.db.incrby(dbkey, -1) + } + catch (err) { + connection.results.add(this, { err: `conn_concur_decr:${err}` }) + } + next(); } -exports.get_host_key = function (type, connection, cb) { - const plugin = this; - if (!plugin.cfg[type]) { - return cb(new Error(`${type}: not configured`)); +exports.get_host_key = function (type, connection) { + + if (!this.cfg[type]) { + connection.results.add(this, { err: `${type}: not configured` }); + return } let ip; @@ -288,14 +291,15 @@ exports.get_host_key = function (type, connection, cb) { } } catch (err) { - return cb(err); + connection.results.add(this, { err: `${type}: ${err.message}` }); + return } const ip_array = ((ip.kind === 'ipv6') ? ip.split(':') : ip.split('.')); while (ip_array.length) { const part = ((ip.kind === 'ipv6') ? ip_array.join(':') : ip_array.join('.')); - if (plugin.cfg[type][part] || plugin.cfg[type][part] === 0) { - return cb(null, part, plugin.cfg[type][part]); + if (this.cfg[type][part] || this.cfg[type][part] === 0) { + return [ part, this.cfg[type][part] ] } ip_array.pop(); } @@ -305,35 +309,34 @@ exports.get_host_key = function (type, connection, cb) { const rdns_array = connection.remote.host.toLowerCase().split('.'); while (rdns_array.length) { const part2 = rdns_array.join('.'); - if (plugin.cfg[type][part2] || plugin.cfg[type][part2] === 0) { - return cb(null, part2, plugin.cfg[type][part2]); + if (this.cfg[type][part2] || this.cfg[type][part2] === 0) { + return [ part2, this.cfg[type][part2] ] } rdns_array.pop(); } } - if (plugin.cfg[`${type}_history`]) { - const history = plugin.get_history_limit(type, connection); - if (history) return cb(null, ip, history); + if (this.cfg[`${type}_history`]) { + const history = this.get_history_limit(type, connection); + if (history) return [ ip, history ] } // Custom Default - if (plugin.cfg[type].default) { - return cb(null, ip, plugin.cfg[type].default); + if (this.cfg[type].default) { + return [ ip, this.cfg[type].default ] } // Default 0 = unlimited - cb(null, ip, 0); + return [ ip, 0 ] } -exports.get_mail_key = function (type, mail, cb) { - const plugin = this; - if (!plugin.cfg[type] || !mail) return cb(); +exports.get_mail_key = function (type, mail) { + if (!this.cfg[type] || !mail) return; // Full e-mail address (e.g. smf@fsl.com) const email = mail.address(); - if (plugin.cfg[type][email] || plugin.cfg[type][email] === 0) { - return cb(email, plugin.cfg[type][email]); + if (this.cfg[type][email] || this.cfg[type][email] === 0) { + return [ email, this.cfg[type][email] ] } // RHS parts e.g. host.sub.sub.domain.com @@ -341,20 +344,20 @@ exports.get_mail_key = function (type, mail, cb) { const rhs_split = mail.host.toLowerCase().split('.'); while (rhs_split.length) { const part = rhs_split.join('.'); - if (plugin.cfg[type][part] || plugin.cfg[type][part] === 0) { - return cb(part, plugin.cfg[type][part]); + if (this.cfg[type][part] || this.cfg[type][part] === 0) { + return [ part, this.cfg[type][part] ] } rhs_split.pop(); } } // Custom Default - if (plugin.cfg[type].default) { - return cb(email, plugin.cfg[type].default); + if (this.cfg[type].default) { + return [ email, this.cfg[type].default ] } // Default 0 = unlimited - cb(email, 0); + return [ email, 0 ] } function getTTL (value) { @@ -382,7 +385,7 @@ function getTTL (value) { ttl *= (60*60*24); // days break; default: - return; + return ttl; } return ttl; } @@ -393,217 +396,183 @@ function getLimit (value) { return parseInt(match[1], 10); } -exports.rate_limit = function (connection, key, value, cb) { - const plugin = this; +exports.rate_limit = async function (connection, key, value) { if (value === 0) { // Limit disabled for this host connection.loginfo(this, `rate limit disabled for: ${key}`); - return cb(null, false); + return false } // CAUTION: !value would match that 0 value -^ - if (!key || !value) return cb(); - if (!plugin.db) return cb(); + if (!key || !value) return + if (!this.db) return const limit = getLimit(value); const ttl = getTTL(value); if (!limit || ! ttl) { - return cb(new Error(`syntax error: key=${key} value=${value}`)); + connection.results.add(this, { err: `syntax error: key=${key} value=${value}` }); + return } - connection.logdebug(plugin, `key=${key} limit=${limit} ttl=${ttl}`); - - plugin.db.incr(key, (err, newval) => { - if (err) return cb(err); + connection.logdebug(this, `key=${key} limit=${limit} ttl=${ttl}`); - if (newval === 1) plugin.db.expire(key, ttl); - cb(err, parseInt(newval, 10) > limit); // boolean true/false - }) + try { + const newval = await this.db.incr(key) + if (newval === 1) this.db.expire(key, ttl); + return parseInt(newval, 10) > limit // boolean + } + catch (err) { + connection.results.add(this, { err: `${key}:${err}` }); + } } -exports.rate_rcpt_host_incr = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); +exports.rate_rcpt_host_incr = async function (next, connection) { + if (!this.db) return next(); - plugin.get_host_key('rate_rcpt_host', connection, (err, key, value) => { - if (!key || !value) return next(); + const [ key, value ] = this.get_host_key('rate_rcpt_host', connection) + if (!key || !value) return next(); - key = `rate_rcpt_host:${key}`; - plugin.db.incr(key, (err2, newval) => { - if (newval === 1) plugin.db.expire(key, getTTL(value)); - next(); - }) - }) + try { + const newval = await this.db.incr(`rate_rcpt_host:${key}`) + if (newval === 1) await this.db.expire(`rate_rcpt_host:${key}`, getTTL(value)); + } + catch (err) { + connection.results.add(this, { err }) + } + next(); } -exports.rate_rcpt_host_enforce = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); - - plugin.get_host_key('rate_rcpt_host', connection, (err, key, value) => { - if (err) { - connection.results.add(plugin, { err: `rate_rcpt_host:${err}` }); - return next(); - } +exports.rate_rcpt_host_enforce = async function (next, connection) { + if (!this.db) return next(); - if (!key || !value) return next(); + const [ key, value ] = this.get_host_key('rate_rcpt_host', connection) + if (!key || !value) return next(); - const match = /^(\d+)/.exec(value); - const limit = parseInt(match[0], 10); - if (!limit) return next(); + const match = /^(\d+)/.exec(value); + const limit = parseInt(match[0], 10); + if (!limit) return next(); - plugin.db.get(`rate_rcpt_host:${key}`, (err2, result) => { - if (err2) { - connection.results.add(plugin, { err: `rate_rcpt_host:${err2}` }); - return next(); - } + try { + const result = await this.db.get(`rate_rcpt_host:${key}`) - if (!result) return next(); - connection.results.add(plugin, { - rate_rcpt_host: `${key}:${result}:${value}` - }); + if (!result) return next(); + connection.results.add(this, { + rate_rcpt_host: `${key}:${result}:${value}` + }); - if (result <= limit) return next(); + if (result <= limit) return next(); - connection.results.add(plugin, { fail: 'rate_rcpt_host' }); - plugin.penalize(connection, false, 'recipient rate limit exceeded', next); - }); - }); + connection.results.add(this, { fail: 'rate_rcpt_host' }); + this.penalize(connection, false, 'recipient rate limit exceeded', next); + } + catch (err) { + connection.results.add(this, { err: `rate_rcpt_host:${err}` }); + next(); + } } -exports.rate_conn_incr = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); +exports.rate_conn_incr = async function (next, connection) { + if (!this.db) return next(); - plugin.get_host_key('rate_conn', connection, (err, key, value) => { - if (!key || !value) return next(); + const [ key, value ] = this.get_host_key('rate_conn', connection) + if (!key || !value) return next(); - key = `rate_conn:${key}`; - plugin.db.hincrby(key, + new Date(), 1, (err2, newval) => { - if (err2) connection.results.add(plugin, { err: err2 }); - // extend key expiration on every new connection - plugin.db.expire(key, getTTL(value) * 2); - next(); - }); - }); + try { + await this.db.hIncrBy(`rate_conn:${key}`, (+ new Date()).toString(), 1) + // extend key expiration on every new connection + await this.db.expire(`rate_conn:${key}`, getTTL(value) * 2) + } + catch (err) { + console.error(err) + connection.results.add(this, { err }); + } + next() } -exports.rate_conn_enforce = function (next, connection) { - const plugin = this; - if (!plugin.db) return next(); +exports.rate_conn_enforce = async function (next, connection) { + if (!this.db) return next(); - plugin.get_host_key('rate_conn', connection, (err, key, value) => { - if (err) { - connection.results.add(plugin, { err: `rate_conn:${err}` }); - return next(); - } + const [ key, value ] = this.get_host_key('rate_conn', connection) + if (!key || !value) return next(); - if (!key || !value) return next(); + const limit = getLimit(value); + if (!limit) { + connection.results.add(this, { err: `rate_conn:syntax:${value}` }); + return next(); + } - const limit = getLimit(value); - if (!limit) { - connection.results.add(plugin, { err: `rate_conn:syntax:${value}` }); + try { + const tstamps = await this.db.hGetAll(`rate_conn:${key}`) + if (!tstamps) { + connection.results.add(this, { err: 'rate_conn:no_tstamps' }); return next(); } - plugin.db.hgetall(`rate_conn:${key}`, (err2, tstamps) => { - if (err2) { - connection.results.add(plugin, { err: `rate_conn:${err}` }); - return next(); - } - - if (!tstamps) { - connection.results.add(plugin, { err: 'rate_conn:no_tstamps' }); - return next(); - } - - const d = new Date(); - d.setMinutes(d.getMinutes() - (getTTL(value) / 60)); - const periodStartTs = + d; // date as integer + const d = new Date(); + d.setMinutes(d.getMinutes() - (getTTL(value) / 60)); + const periodStartTs = + d; // date as integer - let connections_in_ttl_period = 0; - Object.keys(tstamps).forEach(ts => { - if (parseInt(ts, 10) < periodStartTs) return; // older than ttl - connections_in_ttl_period = connections_in_ttl_period + parseInt(tstamps[ts], 10); - }) - connection.results.add(plugin, { rate_conn: `${connections_in_ttl_period}:${value}`}); + let connections_in_ttl_period = 0; + Object.keys(tstamps).forEach(ts => { + if (parseInt(ts, 10) < periodStartTs) return; // older than ttl + connections_in_ttl_period = connections_in_ttl_period + parseInt(tstamps[ts], 10); + }) + connection.results.add(this, { rate_conn: `${connections_in_ttl_period}:${value}`}); - if (connections_in_ttl_period <= limit) return next(); + if (connections_in_ttl_period <= limit) return next(); - connection.results.add(plugin, { fail: 'rate_conn' }); + connection.results.add(this, { fail: 'rate_conn' }); - plugin.penalize(connection, true, 'connection rate limit exceeded', next); - }); - }); + this.penalize(connection, true, 'connection rate limit exceeded', next); + } + catch (err) { + connection.results.add(this, { err: `rate_conn:${err}` }); + next(); + } } -exports.rate_rcpt_sender = function (next, connection, params) { - const plugin = this; - - plugin.get_mail_key('rate_rcpt_sender', connection.transaction.mail_from, (key, value) => { - - plugin.rate_limit(connection, `rate_rcpt_sender:${key}`, value, (err, over) => { - if (err) { - connection.results.add(plugin, { err: `rate_rcpt_sender:${err}` }); - return next(); - } +exports.rate_rcpt_sender = async function (next, connection, params) { - connection.results.add(plugin, { rate_rcpt_sender: value }); + const [ key, value ] = this.get_mail_key('rate_rcpt_sender', connection.transaction.mail_from) + connection.results.add(this, { rate_rcpt_sender: value }); - if (!over) return next(); + const over = await this.rate_limit(connection, `rate_rcpt_sender:${key}`, value) + if (!over) return next(); - connection.results.add(plugin, { fail: 'rate_rcpt_sender' }); - plugin.penalize(connection, false, 'rcpt rate limit exceeded', next); - }); - }); + connection.results.add(this, { fail: 'rate_rcpt_sender' }); + this.penalize(connection, false, 'rcpt rate limit exceeded', next); } -exports.rate_rcpt_null = function (next, connection, params) { - const plugin = this; +exports.rate_rcpt_null = async function (next, connection, params) { if (!params) return next(); if (Array.isArray(params)) params = params[0]; if (params.user) return next(); // Message from the null sender - plugin.get_mail_key('rate_rcpt_null', params, (key, value) => { + const [ key, value ] = this.get_mail_key('rate_rcpt_null', params) + connection.results.add(this, { rate_rcpt_null: value }); - plugin.rate_limit(connection, `rate_rcpt_null:${key}`, value, (err2, over) => { - if (err2) { - connection.results.add(plugin, { err: `rate_rcpt_null:${err2}` }); - return next(); - } + const over = await this.rate_limit(connection, `rate_rcpt_null:${key}`, value) + if (!over) return next(); - connection.results.add(plugin, { rate_rcpt_null: value }); - - if (!over) return next(); - - connection.results.add(plugin, { fail: 'rate_rcpt_null' }); - plugin.penalize(connection, false, 'null recip rate limit', next); - }); - }); + connection.results.add(this, { fail: 'rate_rcpt_null' }); + this.penalize(connection, false, 'null recip rate limit', next); } -exports.rate_rcpt = function (next, connection, params) { +exports.rate_rcpt = async function (next, connection, params) { const plugin = this; if (Array.isArray(params)) params = params[0]; - plugin.get_mail_key('rate_rcpt', params, (key, value) => { - - plugin.rate_limit(connection, `rate_rcpt:${key}`, value, (err2, over) => { - if (err2) { - connection.results.add(plugin, { err: `rate_rcpt:${err2}` }); - return next(); - } - connection.results.add(plugin, { rate_rcpt: value }); + const [ key, value ] = plugin.get_mail_key('rate_rcpt', params) + connection.results.add(plugin, { rate_rcpt: value }); - if (!over) return next(); + const over = await plugin.rate_limit(connection, `rate_rcpt:${key}`, value) + if (!over) return next(); - connection.results.add(plugin, { fail: 'rate_rcpt' }); - plugin.penalize(connection, false, 'rate limit exceeded', next); - }); - }); + connection.results.add(plugin, { fail: 'rate_rcpt' }); + plugin.penalize(connection, false, 'rate limit exceeded', next); } /* @@ -612,49 +581,45 @@ exports.rate_rcpt = function (next, connection, params) { */ function getOutDom (hmail) { - // outbound isn't internally consistent in the use of hmail.domain - // vs hmail.todo.domain. + // outbound isn't internally consistent using hmail.domain and hmail.todo.domain. // TODO: fix haraka/Haraka/outbound/HMailItem to be internally consistent. - if (hmail.todo && hmail.todo.domain) return hmail.todo.domain; - return hmail.domain; + return hmail?.todo?.domain || hmail.domain; } function getOutKey (domain) { return `outbound-rate:${domain}`; } -exports.outbound_increment = function (next, hmail) { - const plugin = this; - if (!plugin.db) return next(); +exports.outbound_increment = async function (next, hmail) { + if (!this.db) return next(); const outDom = getOutDom(hmail); const outKey = getOutKey(outDom); - plugin.db.hincrby(outKey, 'TOTAL', 1, (err, count) => { - if (err) { - plugin.logerror(`outbound_increment: ${err}`); - return next(); // just deliver - } - + try { + let count = await this.db.hIncrBy(outKey, 'TOTAL', 1) - plugin.db.expire(outKey, 300); // 5 min expire + this.db.expire(outKey, 300); // 5 min expire - if (!plugin.cfg.outbound[outDom]) return next(); - const limit = parseInt(plugin.cfg.outbound[outDom], 10); + if (!this.cfg.outbound[outDom]) return next(); + const limit = parseInt(this.cfg.outbound[outDom], 10); if (!limit) return next(); count = parseInt(count, 10); if (count <= limit) return next(); - const delay = plugin.cfg.outbound.delay || 30; + const delay = this.cfg.outbound.delay || 30; next(constants.delay, delay); - }) + } + catch (err) { + this.logerror(`outbound_increment: ${err}`); + next(); // just deliver + } } exports.outbound_decrement = function (next, hmail) { - const plugin = this; - if (!plugin.db) return next(); + if (!this.db) return next(); - plugin.db.hincrby(getOutKey(getOutDom(hmail)), 'TOTAL', -1); - return next(); + this.db.hIncrBy(getOutKey(getOutDom(hmail)), 'TOTAL', -1); + next(); } diff --git a/package.json b/package.json index b4dd972..ebd0c48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "haraka-plugin-limit", - "version": "1.0.5", + "version": "1.0.6", "description": "enforce various types of limits on remote MTAs", "main": "index.js", "directories": { @@ -8,20 +8,20 @@ }, "dependencies": { "haraka-constants": "*", - "haraka-plugin-redis": "*", - "ipaddr.js": "^2.0.0", - "redis": "^3.1.1" + "haraka-plugin-redis": "2", + "ipaddr.js": "^2.0.1", + "redis": "4" }, "devDependencies": { "address-rfc2821": "*", - "eslint": "*", + "eslint": "8", "eslint-plugin-haraka": "*", "haraka-test-fixtures": "*", - "mocha": "*" + "mocha": "9" }, "scripts": { - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", + "lint": "npx eslint *.js test", + "lintfix": "npx eslint --fix *.js test", "versions": "npx dependency-version-checker check", "test": "npx mocha --exit" }, diff --git a/test/config.js b/test/config.js index 93c5fbb..b3b0d60 100644 --- a/test/config.js +++ b/test/config.js @@ -16,28 +16,26 @@ const default_config = { rate_rcpt_host: { '127': 0, enabled: false, default: '50/5m' }, rate_rcpt_sender: { '127': 0, enabled: false, default: '50/5m' }, rate_rcpt_null: { enabled: false, default: 1 }, - redis: { db: 4, host: '127.0.0.1', port: '6379' }, + redis: { db: 4, socket: { host: '127.0.0.1', port: '6379' } }, concurrency: { plugin: 'karma', good: 10, bad: 1, none: 2 } }; describe('plugin_setup', function () { - before(function (done) { + before(function () { this.plugin = new fixtures.plugin('index'); this.plugin.config = this.plugin.config.module_config(path.resolve('test')); - done() }) - it('loads config', function (done) { + + it('loads config', function () { // gotta inherit b/c config loader merges in defaults from redis.ini this.plugin.inherits('haraka-plugin-redis'); this.plugin.load_limit_ini(); assert.deepEqual(this.plugin.cfg, default_config); // loaded config - done() }) - it('registers', function (done) { + it('registers', function () { this.plugin.register(); - assert.deepEqual(this.plugin.cfg, default_config); // loaded config - done(); + assert.deepEqual(this.plugin.cfg, default_config); }) }) diff --git a/test/history.js b/test/history.js index 1140b4c..f2703b1 100644 --- a/test/history.js +++ b/test/history.js @@ -8,7 +8,7 @@ const fixtures = require('haraka-test-fixtures'); describe('get_history_limit', function () { - before(function (done) { + before(function () { this.plugin = new fixtures.plugin('index'); this.plugin.config = this.plugin.config.module_config(path.resolve('test')); @@ -23,33 +23,29 @@ describe('get_history_limit', function () { bad: 1, none: 2, }; - done(); }) - it('good', function (done) { + it('good', function () { this.connection.results.add({name: 'karma'}, { history: 1 }); assert.equal( 5, this.plugin.get_history_limit('concurrency', this.connection) ); - done(); }) - it('bad', function (done) { + it('bad', function () { this.connection.results.add({name: 'karma'}, { history: -1 }); assert.equal( 1, this.plugin.get_history_limit('concurrency', this.connection) ); - done(); }) - it('none', function (done) { + it('none', function () { this.connection.results.add({name: 'karma'}, { history: 0 }); assert.equal( 2, this.plugin.get_history_limit('concurrency', this.connection) ); - done(); }) }) diff --git a/test/inheritance.js b/test/inheritance.js index 32ad0ba..a43d442 100644 --- a/test/inheritance.js +++ b/test/inheritance.js @@ -6,27 +6,23 @@ const fixtures = require('haraka-test-fixtures'); describe('inheritance', function () { - beforeEach(function (done) { + beforeEach(function () { this.plugin = new fixtures.plugin('index'); - done(); }) - it('inherits redis', function (done) { + it('inherits redis', function () { this.plugin.inherits('haraka-plugin-redis'); assert.equal(typeof this.plugin.load_redis_ini, 'function'); - done(); }) - it('can call parent functions', function (done) { + it('can call parent functions', function () { this.plugin.inherits('haraka-plugin-redis'); this.plugin.load_redis_ini(); assert.ok(this.plugin.redisCfg); // loaded config - done(); }) - it('register', function (done) { + it('register', function () { this.plugin.register(); assert.ok(this.plugin.cfg); // loaded config - done(); }) }) \ No newline at end of file diff --git a/test/limit.js b/test/limit.js index d156bc6..7913682 100644 --- a/test/limit.js +++ b/test/limit.js @@ -6,7 +6,7 @@ const path = require('path'); const constants = require('haraka-constants'); const fixtures = require('haraka-test-fixtures'); -function setUp (done) { +function setUp () { this.plugin = new fixtures.plugin('index'); this.plugin.config = this.plugin.config.module_config(path.resolve('test')); @@ -14,7 +14,6 @@ function setUp (done) { this.connection.transaction = new fixtures.transaction.createTransaction(); this.plugin.register(); - done() } describe('max_errors', function () { diff --git a/test/outbound.js b/test/outbound.js index 68da2c6..6442a7e 100644 --- a/test/outbound.js +++ b/test/outbound.js @@ -8,12 +8,9 @@ const fixtures = require('haraka-test-fixtures'); function _set_up (done) { this.plugin = new fixtures.plugin('index'); - // gotta inhert b/c config loader merges in defaults from redis.ini - // this.plugin.inherits('haraka-plugin-redis'); this.plugin.register(); this.server = { notes: {} }; this.plugin.init_redis_plugin(function () { - // console.log(arguments); done(); }, this.server); @@ -34,7 +31,7 @@ describe('outbound_increment', function () { it('limits has delay', function (done) { const self = this; self.plugin.cfg.outbound['slow.test.com'] = 3; - self.plugin.db.hset('outbound-rate:slow.test.com', 'TOTAL', 4, function () { + self.plugin.db.hSet('outbound-rate:slow.test.com', 'TOTAL', 4).then(() => { self.plugin.outbound_increment(function (code, delay) { assert.equal(code, constants.delay); assert.equal(delay, 30); diff --git a/test/rate_limit.js b/test/rate_limit.js index 1ca2421..9982a8a 100644 --- a/test/rate_limit.js +++ b/test/rate_limit.js @@ -7,68 +7,54 @@ const Address = require('address-rfc2821').Address; const constants = require('haraka-constants'); const fixtures = require('haraka-test-fixtures'); -function setUp (done) { +function setUp () { this.plugin = new fixtures.plugin('rate_limit'); this.connection = new fixtures.connection.createConnection(); this.connection.remote = { ip: '1.2.3.4', host: 'test.com' }; this.plugin.register(); - done(); } describe('get_host_key', function () { before(setUp) - it('rate_conn', function (done) { - this.plugin.get_host_key('rate_conn', this.connection, function (err, ip, limit) { - assert.equal(err, undefined); - assert.equal(ip, '1.2.3.4'); - assert.equal(limit, 5); - done(); - }) + it('rate_conn', function () { + const [ ip, limit ] = this.plugin.get_host_key('rate_conn', this.connection) + assert.equal(ip, '1.2.3.4'); + assert.equal(limit, 5); }) - it('rate_rcpt_host', function (done) { - this.plugin.get_host_key('rate_rcpt_host', this.connection, function (err, ip, limit) { - assert.equal(err, undefined); - assert.equal(ip, '1.2.3.4'); - assert.equal(limit, '50/5m'); - done(); - }) + it('rate_rcpt_host', function () { + const [ ip, limit ] = this.plugin.get_host_key('rate_rcpt_host', this.connection) + assert.equal(ip, '1.2.3.4'); + assert.equal(limit, '50/5m'); }) }) describe('get_mail_key', function () { - beforeEach(function (done) { + beforeEach(function () { this.plugin = new fixtures.plugin('rate_limit'); this.connection = new fixtures.connection.createConnection(); this.plugin.register(); - done(); }) - it('rate_rcpt_sender', function (done) { - this.plugin.get_mail_key('rate_rcpt_sender', new Address(''), function (addr, limit) { - // console.log(arguments); - assert.equal(addr, 'user@example.com'); - assert.equal(limit, '50/5m'); - done(); - }); + it('rate_rcpt_sender', function () { + const [ addr, limit ] = this.plugin.get_mail_key('rate_rcpt_sender', new Address('')) + // console.log(arguments); + assert.equal(addr, 'user@example.com'); + assert.equal(limit, '50/5m'); }) - it('rate_rcpt_null', function (done) { - this.plugin.get_mail_key('rate_rcpt_null', new Address(''), function (addr, limit) { - // console.log(arguments); - assert.equal(addr, 'postmaster'); - assert.equal(limit, '1'); - done(); - }); + it('rate_rcpt_null', function () { + const [ addr, limit ] = this.plugin.get_mail_key('rate_rcpt_null', new Address('')) + // console.log(arguments); + assert.equal(addr, 'postmaster'); + assert.equal(limit, '1'); }) - it('rate_rcpt', function (done) { - this.plugin.get_mail_key('rate_rcpt', new Address(''), function (addr, limit) { - // console.log(arguments); - assert.equal(addr, 'user@example.com'); - assert.equal(limit, '50/5m'); - done(); - }); + it('rate_rcpt', function () { + const [ addr, limit ] = this.plugin.get_mail_key('rate_rcpt', new Address('')) + // console.log(arguments); + assert.equal(addr, 'user@example.com'); + assert.equal(limit, '50/5m'); }) }) @@ -84,27 +70,21 @@ describe('rate_limit', function () { server); }) - it('no limit', function (done) { - this.plugin.rate_limit(this.connection, 'key', 0, function (err, is_limited) { - // console.log(arguments); - assert.equal(err, undefined); - assert.equal(is_limited, false); - done(); - }) + it('no limit', async function () { + const is_limited = await this.plugin.rate_limit(this.connection, 'key', 0) + assert.equal(is_limited, false); }) - it('below 50/5m limit', function (done) { - this.plugin.rate_limit(this.connection, 'key', '50/5m', function (err, is_limited) { - // console.log(arguments); - assert.equal(err, undefined); - assert.equal(is_limited, false); - done(); - }) + it('below 50/5m limit', async function () { + const is_limited = await this.plugin.rate_limit(this.connection, 'key', '50/5m') + assert.equal(is_limited, false); }) }) describe('rate_conn', function () { beforeEach(function (done) { + this.server = { notes: {} }; + this.plugin = new fixtures.plugin('rate_limit'); this.plugin.config = this.plugin.config.module_config(path.resolve('test')); @@ -113,11 +93,10 @@ describe('rate_conn', function () { this.connection.remote.host = 'mail.example.com'; this.plugin.register(); - const server = { notes: {} }; this.plugin.init_redis_plugin(function () { done(); }, - server); + this.server); }) it('default limit', function (done) {