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