From 0062b2657e45536d3521f0597b0132c3884adb84 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 2 Feb 2017 10:00:05 -0800 Subject: [PATCH] - initial commit --- .codeclimate.yml | 23 ++ .eslintrc.yaml | 25 ++ .github/ISSUE_TEMPLATE.md | 11 + .github/PULL_REQUEST_TEMPLATE.md | 12 + .github/dependabot.yml | 10 + .github/workflows/ci.yml | 22 ++ .github/workflows/codeql.yml | 13 ++ .github/workflows/publish.yml | 17 ++ .gitignore | 45 ++++ .gitmodules | 3 + .npmignore | 59 +++++ .release | 1 + Changes.md | 3 + LICENSE | 21 ++ README.md | 113 +++++++++ config/dns-list.ini | 122 ++++++++++ index.js | 389 +++++++++++++++++++++++++++++++ package.json | 38 +++ test/dns-list.js | 218 +++++++++++++++++ 19 files changed, 1145 insertions(+) create mode 100644 .codeclimate.yml create mode 100644 .eslintrc.yaml create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .npmignore create mode 160000 .release create mode 100644 Changes.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/dns-list.ini create mode 100644 index.js create mode 100644 package.json create mode 100644 test/dns-list.js diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..424a868 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,23 @@ +engines: + eslint: + enabled: true + channel: "eslint-8" + config: + config: ".eslintrc.yaml" + +ratings: + paths: + - "**.js" + +checks: + return-statements: + enabled: false + file-lines: + config: + threshold: 400 + method-lines: + config: + threshold: 30 +method-complexity: + config: + threshold: 6 \ No newline at end of file diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 0000000..fe947ea --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,25 @@ +env: + node: true + es6: true + mocha: true + es2020: true + +plugins: + - haraka + +extends: + - eslint:recommended + - plugin:haraka/recommended + +rules: + indent: [2, 2, {"SwitchCase": 1}] + +root: true + +globals: + OK: true + CONT: true + DENY: true + DENYSOFT: true + DENYDISCONNECT: true + DENYSOFTDISCONNECT: true diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0dccc85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +### system info + +Please report your OS, Node version, and Haraka version by running this shell script on your Haraka server and replacing this section with the output. + +echo "Haraka | $(haraka -v)"; echo " --- | :--- "; echo "Node | $(node -v)"; echo "OS | $(uname -a)"; echo "openssl | $(openssl version)" + +### Expected behavior + +### Observed behavior + +### Steps to reproduce diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ba28fbb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +Changes proposed in this pull request: + +- +- + +Fixes # + +Checklist: +- [ ] docs updated +- [ ] tests updated +- [ ] Changes.md updated +- [ ] package.json.version bumped diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8c3ac4a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" + allow: + - dependency-type: production diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..325fb6d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: [ push, pull_request ] + +env: + CI: true + +jobs: + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + + # coverage: + # uses: haraka/.github/.github/workflows/coverage.yml@master + # secrets: inherit + + ubuntu: + needs: [ lint ] + uses: haraka/.github/.github/workflows/ubuntu.yml@master + + windows: + needs: [ lint ] + uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..383aca2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,13 @@ +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: '18 7 * * 4' + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9bfdec3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,17 @@ +name: publish + +on: + push: + branches: + - master + paths: + - package.json + +env: + CI: true + +jobs: + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..625981f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8e94cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".release"] + path = .release + url = git@github.com:msimerson/.release.git diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..a1802f9 --- /dev/null +++ b/.npmignore @@ -0,0 +1,59 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +package-lock.json +bower_components +# Optional npm cache directory +.npmrc + +.idea +.DS_Store +haraka-update.sh + +.github +.release +.codeclimate.yml +.editorconfig +.gitignore +.gitmodules +.lgtm.yml +appveyor.yml +codecov.yml +.travis.yml +.eslintrc.yaml +.eslintrc.json diff --git a/.release b/.release new file mode 160000 index 0000000..0890e94 --- /dev/null +++ b/.release @@ -0,0 +1 @@ +Subproject commit 0890e945e4e061c96c7b2ab45017525904c17728 diff --git a/Changes.md b/Changes.md new file mode 100644 index 0000000..5697bc1 --- /dev/null +++ b/Changes.md @@ -0,0 +1,3 @@ +## 1.0.0 - 2023-12-15 + +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29f9810 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Haraka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ab5059 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +[![CI Test Status][ci-img]][ci-url] +[![Code Climate][clim-img]][clim-url] + +[![NPM][npm-img]][npm-url] + +# haraka-plugin-dns-lists + +## dns lists + +Looks up the IP address of the remote host in a IP list(s). There are several types of DNS based lists: + +### block + +Block lists (aka: DNSBL) are designed to be used for blocking mail from any host listed in them. Block lists are the most common DNS list type and lists without a type specifier are considered block lists. The default action for block lists is the reject the connection. This can be changed by setting `reject=false` in the zone's settings block. + + +### allow + +When the remote IP is found in an allow list, this plugin returns OK for the ehlo, helo, and mail hooks. + +IMPORTANT! The order of plugins in config/plugins is important when this feature is used. It should be listed *before* any plugins that you wish to skip, but after any plugins that accept recipients. + + +## INSTALL + +```sh +cd /path/to/local/haraka +npm install haraka-plugin-dns-list +echo "dns-list" >> config/plugins +service haraka restart +``` + +### Configuration + +If the default configuration is insufficient, copy the config file from the distribution into your haraka config dir and modify it: + +```sh +cp node_modules/haraka-plugin-dns-list/config/dns-list.ini config/dns-list.ini +$EDITOR config/dns-list.ini +``` + +dns-lists.ini - INI format with options described below: + +#### [main] periodic_checks=30 + +Check every DNS zone every `N` minutes. When the value is less than 5, checks will only be run at start-up. + +The checks confirm that lists are responding correctly. When errors are detected, the zone is disabled and will be checked at the next interval. When a zone resumes working correctly it will be enabled. + + +#### [main] zones + +An array or comma separated list of zones to query. + + +#### [main] search: (default: all) + +- first: consider first DNSBL response conclusive. End processing. +- all: process all DNSBL results + + +#### [stats] enable=true + + To use this feature you must have installed and configured the 'redis' plugin. + + When enabled, this will record several list statistics to redis. + + It will track the total number of queries (TOTAL) and the average response time (AVG\_RT) and the return type (e.g. LISTED or ERROR) to a redis hash where the key is 'dns-list-stat:zone' and the hash field is the response type. + + It will also track the positive response overlap between the lists in another redis hash where the key is 'dns-list-overlap:zone' and the hash field is the other list names. + + Example: +
redis 127.0.0.1:6379> hgetall dns-list-stat:zen.spamhaus.org
+    1) "TOTAL"
+    2) "23"
+    3) "ENOTFOUND"
+    4) "11"
+    5) "LISTED"
+    6) "12"
+    7) "AVG_RT"
+    8) "45.5"
+    redis 127.0.0.1:6379> hgetall dns-list-overlap:zen.spamhaus.org
+    1) "b.barracudacentral.org"
+    2) "1"
+    3) "bl.spamcop.net"
+    4) "1"
+    5) "TOTAL"
+    6) "1"
+    
+ +#### [stats] redis\_host + +In the form of `host:port` this option allows you to specify a different host on which redis runs. + + +### Per-Zone DNS list settings + +The exact name of the DNS zone (as specified above in main.zones) may contain settings about that DNS list. + +* type=[ block, allow, karma ] +* reject (default: true) +Reject connections from IPs on block lists. Setting this to false makes dnsbl informational. reject=false is best used in conjunction with plugins like [karma](/manual/plugins/karma.html) that employ a scoring engine to make choices about message delivery. +* ipv6=true | false + + + +[ci-img]: https://github.com/haraka/haraka-plugin-dns-list/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-dns-list/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-dns-list/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-dns-list +[npm-img]: https://nodei.co/npm/haraka-plugin-dns-list.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-dns-list + diff --git a/config/dns-list.ini b/config/dns-list.ini new file mode 100644 index 0000000..b6b8eba --- /dev/null +++ b/config/dns-list.ini @@ -0,0 +1,122 @@ + +[main] + +; periodically check each DNS list, disabling ones that fail checks +periodic_checks = 30 + +; zones: an array or a comma separated list of DNS zones +; +zones[]=b.barracudacentral.org +zones[]=truncate.gbudb.net +zones[]=psbl.surriel.com +zones[]=bl.spamcop.net +zones[]=dnsbl-1.uceprotect.net +zones[]=zen.spamhaus.org +zones[]=dnsbl.sorbs.net +zones[]=dnsbl.justspam.org +zones[]=list.dnswl.org +zones[]=hostkarma.junkemailfilter.com + + +; search: Default (first) +; first: consider first response conclusive. End processing. +; all: process all list results +search=all + + +[stats] + +; enable (Default: false) +; stores stats in a Redis DB (see dns_list_base) +;enable=true + +;redis_host=127.0.0.1:6379 + + + +; Per-Zone DNS list settings +; =================================== +; type=block Default: listings are spammy, block 'em +; type=allow DNSWLs, give them some more benefit of doubt +; type=karma Results vary +; +; ipv6=true DNS list supports IPv6 +; reject=true Default: true. If list recomments blocking, reject the connection + + +[zen.spamhaus.org] +ipv6=false + +127.0.0.2=SBL +127.0.0.3=CSS +127.0.0.4=XBL +127.0.0.5=XBL +127.0.0.6=XBL +127.0.0.7=XBL +127.0.0.10=PBL +127.0.0.11=PBL + + +[b.barracudacentral.org] +ipv6=false + +[truncate.gbudb.net] + +[psbl.surriel.com] + +[bl.spamcop.net] +ipv6=true + +[dnsbl-1.uceprotect.net] + +[dnsbl.sorbs.net] + +[dnsbl.justspam.org] + +[hostkarma.junkemailfilter.com] +type=karma +ipv6=true +loopback_is_rejected=true + +127.0.0.1=whilelist +127.0.0.2=blacklist +127.0.0.3=yellowlist +127.0.0.4=brownlist +127.0.0.5=NOBL +127.0.1.1=USES_QUIT +127.0.1.2=NO_QUIT +127.0.1.3=MIXED_QUIT +127.0.2.1=DAYS_2 +127.0.2.2=DAYS_10 +127.0.2.3=DAYS_11 + + +[list.dnswl.org] +; https://www.dnswl.org/?page_id=15 +type=allow + + +; 127.0.{2-20}.{0-3} +; 3rd octet +; ------------------ +; 2 – Financial services +; 3 – Email Service Providers +; 4 – Organisations (both for-profit [ie companies] and non-profit) +; 5 – Service/network providers +; 6 – Personal/private servers +; 7 – Travel/leisure industry +; 8 – Public sector/governments +; 9 – Media and Tech companies +; 10 – some special cases +; 11 – Education, academic +; 12 – Healthcare +; 13 – Manufacturing/Industrial +; 14 – Retail/Wholesale/Services +; 15 – Email Marketing Providers +; 20 – Added through Self Service without specific category +; +; 4th octet +; 0 = none – only avoid outright blocking (eg large ESP mailservers, -0.1) +; 1 = low – reduce chance of false positives (-1.0) +; 2 = medium – make sure to avoid false positives but allow override for clear cases (-10.0) +; 3 = high – avoid override (-100.0) diff --git a/index.js b/index.js new file mode 100644 index 0000000..2c54d8e --- /dev/null +++ b/index.js @@ -0,0 +1,389 @@ +// dns-lists plugin + +const dns = require('dns').promises; +const net = require('net'); +const net_utils = require('haraka-net-utils'); + +exports.disable_allowed = false; +let redis_client; + +exports.register = function () { + + this.load_config(); + + if (this.cfg.main.periodic_checks) { + this.check_zones(this.cfg.main.periodic_checks); + } + + this.register_hook('connect', 'onConnect'); + + // IMPORTANT: don't run this on hook_rcpt otherwise we're an open relay... + ['ehlo','helo','mail'].forEach(hook => { + this.register_hook(hook, 'check_dnswl'); + }); +} + +exports.load_config = function () { + + this.cfg = this.config.get('dns-list.ini', { + booleans: [ + '-stats.enable', + '*.reject', + '*.ipv6', + '*.loopback_is_rejected', + ], + }, () => { + this.load_config(); + }); + + if (Array.isArray(this.cfg.main.zones)) { + this.cfg.main.zones = new Set(this.cfg.main.zones); + } + else { + this.cfg.main.zones = new Set(this.cfg.main.zones.split(/[\s,;]+/)); + } + + // Compatibility with old-plugin + for (const z in this.config.get('dnsbl.zones', 'list')) { + this.cfg.main.zones.add(z) + } + for (const z in this.config.get('dnswl.zones', 'list')) { + this.cfg.main.zones.add(z) + if (this.cfg[z] === undefined) this.cfg[z] = {} + this.cfg[z].type='allow' + } + + // active zones + this.zones = new Set() +} + +exports.should_skip = function (connection) { + + if (!connection) return true; + + if (connection.remote.is_private) { + connection.logdebug(this, `skip private: ${connection.remote.ip}`); + return true; + } + + if (this.zones.length === 0) { + connection.logerror(this, "no zones"); + return true; + } + + return false; +} + +exports.eachActiveDnsList = async function (connection, zone, nextOnce) { + const type = this.getListType(zone) + + const ips = await this.lookup(connection.remote.ip, zone) + // console.log(`eachActiveDnsList ip ${connection.remote.ip} zone ${zone} type ${type} ips ${ips}`) + + if (!ips) { + if (type === 'block') connection.results.add(this, { pass: zone }) + return + } + + for (const i of ips) { + if (this.cfg[zone] && this.cfg[zone][i]) { + // console.log(`zone: ${zone} i: ${this.cfg[zone][i]}`) + connection.results.add(this, { msg: this.cfg[zone][i] }) + } + } + + if (type === 'allow') { + connection.notes.dnswl = true + connection.results.add(this, { pass: zone }) + return nextOnce(OK, [zone]) + } + + if (type === 'karma') { + if (ips.includes('127.0.0.1')) { + connection.results.add(this, { pass: zone }) + } + else if (ips.includes('127.0.0.2')) { + connection.results.add(this, { fail: zone }) + if (this.cfg.main.search === 'first') nextOnce(DENY, [zone]) + } + else { + connection.results.add(this, { msg: zone }) + } + return + } + + // type=block + connection.results.add(this, { fail: zone }) + if (this.cfg.main.search === 'first') nextOnce(DENY, [zone]) + return ips +} + +exports.onConnect = function (next, connection) { + // console.log(`onConnect`) + + if (this.should_skip(connection)) return next(); + + let calledNext = false + function nextOnce (code, zones) { + // console.log(`nextOnce: ${code} : ${zones}`) + if (calledNext) return + calledNext = true + if (code === undefined || zones === undefined) return next() + next(code, `host [${connection.remote.ip}] is listed on ${zones.join(', ')}`) + } + + const promises = [] + for (const zone of this.zones) { + // console.log(`promise zone: ${zone}`) + promises.push(this.eachActiveDnsList(connection, zone, nextOnce)) + } + + Promise.all(promises).then(() => { + // console.log(`Promise.all`) + if (connection.results.get(this).fail?.length) { + nextOnce(DENY, connection.results.get(this).fail) + return + } + nextOnce() + }) +} + +exports.check_dnswl = (next, connection) => connection.notes.dnswl ? next(OK) : next() + +function ipQuery (ip, zone) { + // 1.2.3.4 -> 4.3.2.1.$zone. + if (net.isIPv6(ip)) return [ net_utils.ipv6_reverse(ip), zone, '' ].join('.') + + // ::FFFF:7F00:2 -> 2.0.0.0.0.0.f.7.f.f.f.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.$zone. + if (net.isIPv4(ip)) return [ ip.split('.').reverse().join('.'), zone, '' ].join('.') + + throw new Error('invalid IP: ${ip}') +} + +exports.lookup = async function (ip, zone) { + + if (!ip || !zone) throw new Error(`lookup: invalid request`); + + let start; + if (this.cfg.stats.enable) { + this.init_redis(); + start = new Date().getTime(); + } + + try { + const query = ipQuery(ip, zone) + const a = await dns.resolve4(query, 'A') + // console.log(`lookup ${query} -> a: ${a}`) + + this.stats_incr_zone(null, zone, start); // Statistics + + if (this.hasSpecialResults(zone, a)) return + + return a + } + catch (err) { + this.stats_incr_zone(err, zone, start); // Statistics + + if (err.code === dns.NOTFOUND) return; // unlisted, not an error + + if (err.code === dns.TIMEOUT) { // list timed out + this.disable_zone(zone, err.code); // disable it + return + } + + console.error(`err: ${err}`) + // throw err + } +} + +exports.hasSpecialResults = function (zone, a) { + // Check for a result outside 127/8 + // This should *never* happen on a proper DNS list + if (a && a.find((rec) => { return rec.split('.')[0] !== '127' })) { + this.disable_zone(zone, a); + return true; + } + + if (/spamhaus/.test(zone)) { + // https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now + if (a?.includes('127.255.255.252') || a?.includes('127.255.255.254') || a?.includes('127.255.255.255')) { + this.disable_zone(zone, a); + return true; + } + } + + // https://www.dnswl.org/?page_id=15 + if ('list.dnswl.org' === zone && a?.includes('127.0.0.255')) { + this.disable_zone(zone, a); + return true; + } + + return false +} + +exports.stats_incr_zone = function (err, zone, start) { + if (!this.cfg.stats.enable) return; + + const rkey = `dns-list-stat:${zone}`; + const elapsed = new Date().getTime() - start; + redis_client.hIncrBy(rkey, 'TOTAL', 1); + const foo = (err) ? err.code : 'LISTED'; + redis_client.hIncrBy(rkey, foo, 1); + redis_client.hGet(rkey, 'AVG_RT').then(rt => { + const avg = parseInt(rt) ? (parseInt(elapsed) + parseInt(rt))/2 : parseInt(elapsed); + redis_client.hSet(rkey, 'AVG_RT', avg); + }); +} + +exports.init_redis = function () { + if (redis_client) return; + + const redis = require('redis'); + const host_port = this.cfg.stats.redis_host.split(':'); + const host = host_port[0] || '127.0.0.1'; + const port = parseInt(host_port[1], 10) || 6379; + + redis_client = redis.createClient(port, host); + redis_client.connect().then(() => { + redis_client.on('error', err => { + this.logerror(`Redis error: ${err}`); + redis_client.quit(); + redis_client = null; // should force a reconnect + // not sure if that's the right thing but better than nothing... + }) + }) +} + +exports.getListType = function (zone) { + if (this.cfg[zone] === undefined) return 'block' + return this.cfg[zone]?.type || 'block' +} + +exports.getListReject = function (zone) { + if (this.cfg[zone]?.reject === undefined) return true // default: true + return this.cfg[zone].reject +} + +exports.checkZonePositive = async function (zone, ip) { + + // RFC 5782 § 5 + // IPv4-based DNSxLs MUST contain an entry for 127.0.0.2 for testing purposes. + + const query = ipQuery(ip, zone) + try { + const a = await dns.resolve4(query, 'A') + if (a) { + // const txt = await dns.resolve4(query, 'TXT') + // console.log(`${query} -> ${a}\t${txt}`) + for (const e of a) { + if (this.cfg[zone] && this.cfg[zone][e]) { + // console.log(this.cfg[zone][e]); // + } + } + return true + } + else { + this.logwarn(`${query}\tno response`); + this.disable_zone(zone, a); + } + } + catch (err) { + console.error(`${query} -> ${err}`) + } + return false +} + +exports.checkZoneNegative = async function (zone, ip) { + + // RFC 5782 § 5 + // IPv4-based DNSxLs MUST NOT contain an entry for 127.0.0.1. + + // skip this test for DNS lists that don't follow the RFC + if (this.cfg[zone].loopback_is_rejected) return true + + const query = ipQuery(ip, zone) + try { + const a = await dns.resolve4(query, 'A') + if (a) { + // results here are invalid + // const txt = await dns.resolve4(query, 'TXT') + // if (txt && txt !== a) console.warn(`${query} -> ${a}\t${txt}`) + this.disable_zone(zone, a); + } + } + catch (err) { + switch (err.code) { + case dns.NOTFOUND: // IP not listed + return true + case dns.TIMEOUT: // list timed out + this.disable_zone(zone, err.code) + } + console.error(`${query} -> got err ${err}`) + } + return false +} + +exports.check_zone = async function (zone) { + + if (!await this.checkZonePositive(zone, '127.0.0.2')) return false + if (!await this.checkZoneNegative(zone, '127.0.0.1')) return false + + this.enable_zone(zone) // both tests passed + + if (this.cfg[zone].ipv6 === true) { + await this.checkZonePositive(zone, '::FFFF:7F00:2') + await this.checkZoneNegative(zone, '::FFFF:7F00:1') + } + + return true +} + +exports.check_zones = async function (interval, done) { + + if (interval) interval = parseInt(interval); + + for (const zone of this.cfg.main.zones) { + try { + await this.check_zone(zone); + } + catch (err) { + console.error(`zone ${zone} err: ${err}`) + } + } + + // Set a timer to re-test + if (interval && interval >= 5 && !this._interval) { + this.logdebug(`will re-test list zones every ${interval} minutes`); + this._interval = setInterval(() => { + this.check_zones(); + }, (interval * 60) * 1000); + + this._interval.unref() // don't block node process from exiting + } +} + +exports.shutdown = function () { + clearInterval(this._interval); + if (redis_client) redis_client.quit(); +} + +exports.enable_zone = function (zone, result) { + if (!zone) return false; + const type = this.getListType(zone) + + if (!this.zones.has(zone)) { + this.loginfo(`enabling ${type} zone ${zone}`); + this.zones.add(zone, true) + } +} + +exports.disable_zone = function (zone, result) { + if (!zone) return false; + const type = this.getListType(zone) + + if (!this.zones.has(zone)) return false + + this.logwarn(`disabling ${type} zone '${zone}' ${result ? result : ''}`); + this.zones.delete(zone) + return true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ecbc2f --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "haraka-plugin-dns-list", + "version": "1.0.0", + "description": "Haraka plugin for DNS lists (DNSBL, DNSWL)", + "main": "index.js", + "scripts": { + "lint": "npx eslint *.js test", + "lintfix": "npx eslint --fix *.js test", + "versions": "npx dependency-version-checker check", + "test": "npx mocha" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/haraka/haraka-plugin-dns-list.git" + }, + "keywords": [ + "haraka", + "plugin", + "dns-list", + "DNSBL", + "DNSWL" + ], + "author": "Haraka Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/haraka/haraka-plugin-dns-list/issues" + }, + "homepage": "https://github.com/haraka/haraka-plugin-dns-list#readme", + "devDependencies": { + "eslint": "^8.55.0", + "eslint-plugin-haraka": "*", + "haraka-test-fixtures": "*", + "mocha": "^10.2.0" + }, + "dependencies": { + "haraka-net-utils": "^1.5.3" + } +} diff --git a/test/dns-list.js b/test/dns-list.js new file mode 100644 index 0000000..51e7afe --- /dev/null +++ b/test/dns-list.js @@ -0,0 +1,218 @@ + +// node.js built-in modules +const assert = require('assert') + +// npm modules +const fixtures = require('haraka-test-fixtures') + +beforeEach(function () { + this.plugin = new fixtures.plugin('index') + this.plugin.load_config() + // this.plugin.register() +}) + +describe('dns-list', function () { + + it('plugin loads', function () { + assert.ok(this.plugin) + }) + + it('loads config/dns-list.ini', function () { + this.plugin.load_config() + // console.log(this.plugin.cfg) + assert.ok(this.plugin.cfg) + }) + + it('config initializes a boolean', function () { + assert.equal(this.plugin.cfg.stats.enable, false, this.plugin.cfg) + }) + + it('sets up a connection', function () { + this.connection = fixtures.connection.createConnection({}) + assert.ok(this.connection.server) + }) + + it('sets up a transaction', function () { + this.connection = fixtures.connection.createConnection({}) + this.connection.transaction = fixtures.transaction.createTransaction({}) + // console.log(this.connection.transaction) + assert.ok(this.connection.transaction.header) + }) +}) + +describe('lookup', function () { + + it('Spamcop, test IPv4', async function () { + const a = await this.plugin.lookup('127.0.0.2', 'bl.spamcop.net') + assert.deepStrictEqual(['127.0.0.2'], a); + }) + + it('Spamcop, unlisted IPv6', async function () { + const r = await this.plugin.lookup('::1', 'bl.spamcop.net') + assert.deepStrictEqual(undefined, r); + }) + + it('b.barracudacentral.org, unlisted IPv6', async function () { + const r = await this.plugin.lookup('::1', 'b.barracudacentral.org') + assert.deepStrictEqual(undefined, r); + }) + + it('Spamcop, unlisted IPv4', async function () { + const a = await this.plugin.lookup('127.0.0.1', 'bl.spamcop.net') + assert.deepStrictEqual(undefined, a); + }) + + it('CBL', async function () { + const a = await this.plugin.lookup('127.0.0.2', 'xbl.spamhaus.org') + assert.deepStrictEqual(a, [ '127.0.0.4' ]); + }) +}) + +describe('check_zone', function () { + it('tests DNS list bl.spamcop.net', async function () { + const r = await this.plugin.check_zone('bl.spamcop.net'); + assert.deepStrictEqual(r, true) + }) + + it('tests DNS list zen.spamhaus.org', async function () { + const r = await this.plugin.check_zone('zen.spamhaus.org'); + assert.deepStrictEqual(r, true) + }) + + it('tests DNS list hostkarma.junkemailfilter.com', async function () { + const r = await this.plugin.check_zone('hostkarma.junkemailfilter.com'); + assert.deepStrictEqual(r, true) + }) +}) + +describe('check_zones', function () { + this.timeout(20000) + + it('tests each block list', async function () { + await this.plugin.check_zones(6000); + }) +}) + +describe('onConnect', function () { + + beforeEach(function () { + this.connection = fixtures.connection.createConnection() + }) + + it('onConnect 127.0.0.1', function (done) { + this.connection.set('remote.ip', '127.0.0.1') + this.plugin.zones = new Set([ 'bl.spamcop.net', 'list.dnswl.org' ]) + this.plugin.onConnect((code, msg) => { + assert.strictEqual(code, undefined); + assert.strictEqual(msg, undefined); + done() + }, + this.connection) + }) + + it('onConnect 127.0.0.2', function (done) { + this.connection.set('remote.ip', '127.0.0.2') + this.plugin.zones = new Set([ 'bl.spamcop.net', 'list.dnswl.org' ]) + this.plugin.onConnect((code, msg) => { + // console.log(`code: ${code}, ${msg}`) + if (code === OK) { + assert.strictEqual(code, OK); + assert.strictEqual(msg, 'host [127.0.0.2] is listed on list.dnswl.org'); + } + else { + assert.strictEqual(code, DENY); + assert.strictEqual(msg, 'host [127.0.0.2] is listed on bl.spamcop.net'); + } + done() + }, + this.connection) + }) + + it('Spamcop + CBL', function (done) { + this.connection.set('remote.ip', '127.0.0.2') + this.plugin.zones = new Set([ 'bl.spamcop.net', 'xbl.spamhaus.org' ]) + this.plugin.onConnect((code, msg) => { + // console.log(`code: ${code}, ${msg}`) + assert.strictEqual(code, DENY); + assert.ok(/is listed on/.test(msg)); + done() + }, + this.connection) + }) + + it('Spamcop + CBL + negative result', function (done) { + this.connection.set('remote.ip', '127.0.0.1') + this.plugin.zones = new Set([ 'bl.spamcop.net', 'xbl.spamhaus.org' ]) + this.plugin.onConnect((code, msg) => { + // console.log(`test return ${code} ${msg}`) + assert.strictEqual(code, undefined); + assert.strictEqual(msg, undefined); + done() + }, this.connection) + }) + + it('IPv6 addresses supported', function (done) { + this.connection.set('remote.ip', '::1') + this.plugin.zones = new Set(['bl.spamcop.net','xbl.spamhaus.org']) + this.plugin.onConnect((code, msg) => { + assert.strictEqual(code, undefined); + assert.strictEqual(msg, undefined); + done(); + }, this.connection) + }) +}) + +describe('first', function () { + + beforeEach(function () { + this.plugin.cfg.main.search='first' + this.plugin.zones = new Set([ 'xbl.spamhaus.org', 'bl.spamcop.net' ]); + this.connection = fixtures.connection.createConnection() + }) + + it('positive result', function (done) { + this.connection.set('remote.ip', '127.0.0.2') + this.plugin.onConnect((code, msg) => { + // console.log(`onConnect return ${code} ${msg}`) + assert.strictEqual(code, DENY); + assert.ok(/is listed on/.test(msg)); + done(); + }, + this.connection) + }) + + it('negative result', function (done) { + this.connection.set('remote.ip', '127.0.0.1') + this.plugin.onConnect((code, msg) => { + // console.log(`test return ${code} ${msg}`) + assert.strictEqual(code, undefined) + assert.strictEqual(msg, undefined) + done() + }, + this.connection) + }) +}) + +describe('disable_zone', function () { + + it('empty request', function () { + assert.strictEqual(this.plugin.disable_zone(), false); + }) + + it('testbl1, no zones', function () { + this.plugin.zones=new Set() + assert.strictEqual(this.plugin.disable_zone('testbl1', 'test result'), false); + }) + + it('testbl1, zones miss', function () { + this.plugin.zones = new Set([ 'testbl2' ]) + assert.strictEqual(this.plugin.disable_zone('testbl1', 'test result'), false); + assert.strictEqual(this.plugin.zones.size, 1); + }) + + it('testbl1, zones hit', function () { + this.plugin.zones = new Set([ 'testbl1' ]); + assert.strictEqual(this.plugin.disable_zone('testbl1', 'test result'), true); + assert.strictEqual(this.plugin.zones.size, 0); + }) +})