diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..0e443ca --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,10 @@ +engines: + eslint: + enabled: true + channel: "eslint-8" + config: + config: ".eslintrc.yaml" + +ratings: + paths: + - "**.js" 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..a701be1 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +[![CI Test Status][ci-img]][ci-url] +[![Code Climate][clim-img]][clim-url] + +[![NPM][npm-img]][npm-url] + +# haraka-plugin-dns-lists + +## dns block lists + +Looks up the connecting IP address in an IP list. Remote hosts found in the list are rejected. + + +## dns allow lists + +Looks up the connecting IP address in an IP list. When an IP matches, this plugin returns OK for all hooks up to hook\_data. + +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 + + If enabled, this will check all the zones every n minutes. The minimum value that will be accepted here is 5. Any value less than 5 will cause the checks to be run at start-up only. + + The checks confirm that the list is responding and that it is not listing the world. If any errors are detected, then the zone is disabled and will be re-checked on the next test. If a zone subsequently starts working correctly then it will be re-enabled. + + +* [block] zones + + A comma or semi-colon list of zones to query. + +* search: (default: first) + + first: consider first DNSBL response conclusive. End processing. + all: process all DNSBL results + + +* reject (default: true) + + Reject connections from IPs that are blacklisted. 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. + + + +#### [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. + + + +[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..2c2a772 --- /dev/null +++ b/config/dns-list.ini @@ -0,0 +1,112 @@ + +[main] + +; periodically check each DNS list, disabling ones that fail checks +periodic_checks = 30 + +; zones: a comma separated list of DNSBL zones +; +zones=b.barracudacentral.org, truncate.gbudb.net, psbl.surriel.com, bl.spamcop.net, dnsbl-1.uceprotect.net, zen.spamhaus.org, dnsbl.sorbs.net, dnsbl.justspam.org, list.dnswl.org, hostkarma.junkemailfilter.com + +; search: Default (first) +; first: consider first DNSBL response conclusive. End processing. +; all: process all DNSBL results +search=first + + +[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..1933aa4 --- /dev/null +++ b/index.js @@ -0,0 +1,425 @@ +// dns-lists plugin + +const dns = require('dns').promises; +const net = require('net'); +const net_utils = require('haraka-net-utils'); +const async = require('async'); + +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'); + + // DNS allow + // 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(); + }); + + 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.onConnect = function (next, connection) { + // console.log(`onConnect`) + + const remote_ip = connection.remote.ip; + // console.log(`onConnect ${remote_ip}`) + + 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 [${remote_ip}] is listed on ${zones.join(', ')}`) + } + + const plugin = this + + async function eachActiveDnsList (ip, zone) { + // console.log(`eachActiveDnsList ip ${ip} zone ${zone}`) + const type = plugin.getListType(zone) + // console.log(`eachActiveDnsList ip ${ip} zone ${zone} type ${type}`) + + const ips = await plugin.lookup(ip, zone) + // console.log(`eachActiveDnsList ip ${ip} zone ${zone} type ${type} ips ${ips}`) + + if (!ips) { + if (type === 'block') connection.results.add(plugin, { pass: zone }) + return + } + + for (const i of ips) { + // console.log(`zone: ${zone} i: ${plugin.cfg[zone][i]}`) + if (plugin.cfg[zone][i]) connection.results.add(plugin, { msg: plugin.cfg[zone][i] }) + } + + switch (type) { + case 'allow': + connection.results.add(plugin, { pass: zone }) + return nextOnce(OK, [zone]) + case 'karma': + plugin.karmaResults(zone, ips, connection) + case 'block': + default: + connection.results.add(plugin, { fail: zone }) + if (plugin.cfg.main.search === 'first') nextOnce(DENY, [zone]) + } + return ips + } + + const promises = [] + for (const zone of this.zones) { + // console.log(`promise zone: ${zone}`) + promises.push(eachActiveDnsList(remote_ip, zone)) + } + + Promise.all(promises).then(() => { + // console.log(`Promise.all`) + if (connection.results.get(plugin).fail.length) { + nextOnce(DENY, connection.results.get(plugin).fail) + return + } + nextOnce() + }) +} + +exports.karmaResults = function (zone, ips, connection) { + + if (ips.includes('127.0.0.1')) { + connection.results.add(plugin, { pass: zone }) + } + else if (ips.includes('127.0.0.2')) { + connection.results.add(plugin, { fail: zone }) + if (plugin.cfg.main.search === 'first') nextOnce(DENY, [zone]) + } + else { + connection.results.add(plugin, { msg: zone }) + } +} + +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) + // console.log(`lookup query ${query}`) + const a = await dns.resolve4(query, 'A') + // console.log(`lookup ${query} -> a: ${a}`) + + // this.stats_incr_zone(err, zone, start); // Statistics + + // 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; + } + + 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; + } + } + + // https://www.dnswl.org/?page_id=15 + if ('list.dnswl.org' === zone && a?.includes('127.0.0.255')) { + this.disable_zone(zone, a); + return; + } + + return a + } + catch (err) { + // console.log(`lookup err ${err}`) + 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.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 () { + console.log(`init_redis`) + 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.multi = function (lookup, zones, cb) { + if (!lookup || !zones) return cb(); + if (typeof zones === 'string') zones = [ `${zones}` ]; + const self = this; + const listed = []; + + function redis_incr (zone) { + if (!self.cfg.stats.enable) return; + + // Statistics: check hit overlap + for (const element of listed) { + const foo = (element === zone) ? 'TOTAL' : element; + redis_client.hIncrBy(`dns-list-overlap:${zone}`, foo, 1); + } + } + + function zoneIter (zone, done) { + self.lookup(lookup, zone, (err, a) => { + if (a) { + listed.push(zone); + redis_incr(zone); + } + cb(err, zone, a, true); + done(); + }) + } + function zonesDone (err) { + cb(err, null, null, false); + } + async.each(zones, zoneIter, zonesDone); +} + +exports.getListType = function (zone) { + if (this.cfg[zone] === undefined) return 'block' + return this.cfg[zone]?.type || 'block' // default: 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) { + // console.log(`${query} -> ${a}`) // success + // const txt = await dns.resolve4(query, 'TXT') + // console.log(`${query} -> ${a}\t${txt}`) + for (const e of a) { + if (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) + default: + 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..3927645 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "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": { + "async": "^3.2.5", + "haraka-net-utils": "^1.5.3" + } +} diff --git a/test/dns-list.js b/test/dns-list.js new file mode 100644 index 0000000..2520fc5 --- /dev/null +++ b/test/dns-list.js @@ -0,0 +1,213 @@ + +// node.js built-in modules +const assert = require('assert') + +// npm modules +const fixtures = require('haraka-test-fixtures') +const mocha = require('mocha') + +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.ok(a); + assert.deepEqual(['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') + // console.log(a) + 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 () { + it('tests each block list', async function () { + await this.plugin.check_zones(6000); + }) +}) + +describe('onConnect', function () { + + beforeEach(function () { + // this.plugin = new fixtures.plugin('index') + // this.plugin.load_config() + 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=[ '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=[ 'bl.spamcop.net', 'list.dnswl.org' ] + this.plugin.onConnect((code, msg) => { + 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 = [ 'bl.spamcop.net', 'xbl.spamhaus.org' ] + this.plugin.onConnect((code, msg) => { + assert.strictEqual(code, DENY); + assert.strictEqual(msg, 'host [127.0.0.2] is listed on bl.spamcop.net'); + done() + }, + this.connection) + }) + + it('Spamcop + CBL + negative result', function (done) { + this.connection.set('remote.ip', '127.0.0.1') + this.plugin.zones = [ '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 = ['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 = [ '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(`test return ${code} ${msg}`) + assert.strictEqual(code, DENY); + assert.strictEqual(msg, 'host [127.0.0.2] is listed on bl.spamcop.net'); + 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); + }) +})