UNPKG

haraka-plugin-geoip

Version:

provide geographic information about mail senders.

439 lines (354 loc) 11.7 kB
const fs = require('node:fs') const net = require('node:net') const path = require('node:path') const net_utils = require('haraka-net-utils') const plugin_name = 'geoip' function ucFirst(string) { return string.charAt(0).toUpperCase() + string.slice(1) } exports.register = async function () { this.load_geoip_ini() if (plugin_name === 'geoip-lite') return this.register_geolite() try { this.maxmind = require('maxmind') } catch (e) { this.logerror(e.message) this.logerror( `unable to load maxmind, try\n\n\t'npm install -g maxmind'\n\n`, ) } await this.load_dbs() if (this.dbsLoaded) { this.register_hook('connect', 'lookup_maxmind') this.register_hook('data_post', 'add_headers') } } exports.register_geolite = function () { try { this.geoip = require('geoip-lite') } catch (ignore) { this.logerror( `unable to load geoip-lite, try\n\n\t'npm install -g geoip-lite'\n\n`, ) return } if (!this.geoip) { // geoip-lite dropped node 0.8 support, it may not have loaded this.logerror('unable to load geoip-lite') return } if (this.geoip) { this.loginfo('provider geoip-lite') this.register_hook('connect', 'lookup_geoip_lite') this.register_hook('data_post', 'add_headers') } } exports.load_geoip_ini = function () { const plugin = this plugin.cfg = plugin.config.get( 'geoip.ini', { booleans: ['-main.calc_distance', '+show.city', '+show.region'], }, function () { plugin.load_geoip_ini() }, ) // legacy settings const m = plugin.cfg.main if (m.show_city) plugin.cfg.show.city = m.show_city if (m.show_region) plugin.cfg.show.region = m.show_region } exports.load_dbs = async function () { if (!this.maxmind) return this.dbsLoaded = 0 const dbdir = this.cfg.main.dbdir || '/usr/local/share/GeoIP/' for (const db of ['city', 'country', 'ASN']) { const dbPath = path.join(dbdir, `GeoLite2-${ucFirst(db)}.mmdb`) if (!fs.existsSync(dbPath)) { this.logdebug(`missing DB ${dbPath}`) continue } this[`${db}Lookup`] = await this.maxmind.open(dbPath, { // watchForUpdates causes tests to hang unless mocha runs with --exit watchForUpdates: false, cache: { max: 1000, // max items in cache maxAge: 1000 * 60 * 60, // lifetime in milliseconds }, }) this.logdebug(`loaded maxmind db ${dbPath}`) this.dbsLoaded++ } this.logdebug(`loaded maxmind with ${this.dbsLoaded} DBs`) } exports.get_locales = function (loc) { const show = [] const agg_res = { emit: true } if (loc.continent && loc.continent.code && loc.continent.code !== '--') { agg_res.continent = loc.continent.code show.push(loc.continent.code) } if (loc.country && loc.country.iso_code && loc.country.iso_code !== '--') { agg_res.country = loc.country.iso_code show.push(loc.country.iso_code) } if (loc.subdivisions && loc.subdivisions[0].iso_code) { agg_res.region = loc.subdivisions[0].iso_code if (this.cfg.show.region) show.push(loc.subdivisions[0].iso_code) } if (loc.city && loc.city.names) { agg_res.city = loc.city.names.en if (this.cfg.show.city) show.push(loc.city.names.en) } if (loc.location && isFinite(loc.location.latitude)) { agg_res.ll = [loc.location.latitude, loc.location.longitude] agg_res.geo = { lat: loc.location.latitude, lon: loc.location.longitude } } return [show, agg_res] } exports.lookup = function (next, connection) { if (plugin_name === 'geoip') return this.lookup_maxmind(next, connection) return this.lookup_geoip_lite(next, connection) } exports.lookup_geoip_lite = function (next, connection) { // geoip results look like this: // range: [ 3479299040, 3479299071 ], // country: 'US', // region: 'CA', // city: 'San Francisco', // ll: [37.7484, -122.4156] if (!this.geoip) { connection.logerror(this, 'geoip-lite not loaded') return next() } const r = this.get_geoip_lite(connection.remote.ip) if (!r) return next() connection.results.add(this, r) const show = [] if (r.country && r.country !== '--') show.push(r.country) if (r.region && this.cfg.main.show_region) show.push(r.region) if (r.city && this.cfg.main.show_city) show.push(r.city) if (show.length === 0) return next() if (!this.cfg.main.calc_distance) { connection.results.add(this, { human: show.join(', '), emit: true }) return next() } this.calculate_distance(connection, r.ll, (err, distance) => { if (distance) show.push(`${distance}km`) connection.results.add(this, { human: show.join(', '), emit: true }) next() }) } exports.lookup_maxmind = function (next, connection) { const loc = this.get_geoip_maxmind(connection.remote.ip) if (!loc) return next() const [show, agg_res] = this.get_locales(loc) if (show.length === 0) return next() agg_res.human = show.join(', ') if (!this.cfg.main.calc_distance || !loc.location) { connection.results.add(this, agg_res) return next() } this.calculate_distance(connection, agg_res.ll, (err, distance) => { if (err) connection.results.add(this, { err }) if (distance) { agg_res.distance = distance show.push(`${distance}km`) agg_res.human = show.join(', ') } connection.results.add(this, agg_res) next() }) } exports.get_geoip = function (ip) { switch (true) { case !ip: case !net.isIPv4(ip) && !net.isIPv6(ip): case net_utils.is_private_ip(ip): return } let res if (plugin_name === 'geoip') res = this.get_geoip_maxmind(ip) if (plugin_name === 'geoip-lite') res = this.get_geoip_lite(ip) if (!res) return // console.log(res); const show = [] if (plugin_name === 'geoip-lite') { if (res.continentCode) show.push(res.continentCode) if (res.countryCode || res.code) show.push(res.countryCode || res.code) if (res.region) show.push(res.region) if (res.city) show.push(res.city) } if (plugin_name === 'geoip') { // maxmind if (res.continent && res.continent.code) show.push(res.continent.code) if (res.country && res.country.iso_code) show.push(res.country.iso_code) if (res.subdivisions && res.subdivisions[0]) show.push(res.subdivisions[0].iso_code) if (res.city && res.city.names) show.push(res.city.names.en) } res.human = show.join(', ') return res } exports.get_geoip_lite = function (ip) { if (!this.geoip) return if (!net.isIPv4(ip)) return const result = this.geoip.lookup(ip) if (result && result.ll) { result.latitude = result.ll[0] result.longitude = result.ll[1] } return result } exports.get_geoip_maxmind = function (ip) { if (!this.maxmind) return if (!this.dbsLoaded) return if (this.cityLookup) { try { return this.cityLookup.get(ip) } catch (ignore) {} } if (this.countryLookup) { try { return this.countryLookup.get(ip) } catch (ignore) {} } } exports.add_headers = function (next, connection) { const txn = connection.transaction if (!txn) return txn.remove_header('X-Haraka-GeoIP') txn.remove_header('X-Haraka-GeoIP-Received') const r = connection.results.get(plugin_name) if (r) { if (r.country) txn.add_header('X-Haraka-GeoIP', r.human) if (r.asn) txn.add_header('X-Haraka-ASN', r.asn) if (r.asn_org) txn.add_header('X-Haraka-ASN-Org', r.asn_org) } const received = [] const rh = this.received_headers(connection) if (rh && rh.length) received.push(rh) const oh = this.originating_headers(connection) if (oh) received.push(oh) // Add any received results to a trace header if (received.length) { txn.add_header('X-Haraka-GeoIP-Received', received.join(' ')) } next() } exports.get_local_geo = function (ip, connection) { if (this.local_geoip) return // cached if (!this.local_ip) this.local_ip = ip if (!this.local_ip) this.local_ip = this.cfg.main.public_ip if (!this.local_ip) { connection.logerror( this, "can't calculate distance, set public_ip in smtp.ini", ) return } if (!this.local_geoip) { this.local_geoip = this.get_geoip(this.local_ip) } if (!this.local_geoip) { connection.logerror(this, 'no GeoIP results for local_ip!') } } exports.calculate_distance = function (connection, rll, done) { const plugin = this function cb(err, l_ip) { if (err) { connection.results.add(plugin, { err }) connection.logerror(plugin, err) } plugin.get_local_geo(l_ip, connection) if (!plugin.local_ip || !plugin.local_geoip) return done() // maxmind has 'location' property, geoip-lite doesn't const gl = plugin.local_geoip.location ? plugin.local_geoip.location : plugin.local_geoip const gcd = plugin.haversine(gl.latitude, gl.longitude, rll[0], rll[1]) if (gcd && isNaN(gcd)) return done() connection.results.add(plugin, { distance: gcd }) if ( plugin.cfg.main.too_far && parseFloat(plugin.cfg.main.too_far) < parseFloat(gcd) ) { connection.results.add(plugin, { too_far: true }) } done(err, gcd) } if (plugin.local_ip) return cb(null, plugin.local_ip) net_utils.get_public_ip(cb) } exports.haversine = function (lat1, lon1, lat2, lon2) { // calculate the great circle distance using the haversine formula // found here: http://www.movable-type.co.uk/scripts/latlong.html const EARTH_RADIUS = 6371 // km function toRadians(v) { return (v * Math.PI) / 180 } const deltaLat = toRadians(lat2 - lat1) const deltaLon = toRadians(lon2 - lon1) lat1 = toRadians(lat1) lat2 = toRadians(lat2) const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2) * Math.cos(lat1) * Math.cos(lat2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return (EARTH_RADIUS * c).toFixed(0) } exports.received_headers = function (connection) { const received = connection.transaction.header.get_all('received') if (!received.length) return [] const results = [] const ipany_re = net_utils.get_ipany_re('[\\[\\(](?:IPv6:)?', '[\\]\\)]') // Try and parse each received header for (const header of received) { for (const match of [...header.matchAll(ipany_re)]) { if (net_utils.is_private_ip(match[1])) continue // exclude private IP const gi = this.get_geoip(match[1]) const country = get_country(gi) let logmsg = `received=${match[1]}` if (country) { logmsg += ` country=${country}` results.push(`${match[1]}:${country}`) } connection.loginfo(this, logmsg) } } return results } function get_country(gi) { if (plugin_name === 'geoip-lite') return get_country_lite(gi) if (!gi) return '' if (!gi.country) return '' if (!gi.country.iso_code) return '' return gi.country.iso_code } function get_country_lite(gi) { if (gi.countryCode) return gi.countryCode if (gi.code) return gi.code return '' } exports.originating_headers = function (connection) { const txn = connection.transaction // Try and parse any originating IP headers const orig = txn.header.get('x-originating-ip') || txn.header.get('x-ip') || txn.header.get('x-remote-ip') if (!orig) return const match = net_utils.get_ipany_re('(?:IPv6:)?').exec(orig) if (!match) return const found_ip = match[1] if (net_utils.is_private_ip(found_ip)) return const gi = this.get_geoip(found_ip) if (!gi) return connection.loginfo(this, `originating=${found_ip} ${gi.human}`) return `${found_ip}:${get_country(gi)}` }