haraka/haraka-plugin-asn

View on GitHub
index.js

Summary

Maintainability
B
6 hrs
Test Coverage
// determine the ASN of the connecting IP

const dns = require('node:dns').promises
const fs = require('node:fs/promises')
const path = require('node:path')

let test_ip = '66.128.51.163'
const providers = []
let conf_providers = [
  'origin.asn.cymru.com',
  'asn.routeviews.org',
  'asn.rspamd.com',
]

exports.register = async function () {
  this.registered = false

  this.load_asn_ini()

  await this.test_and_register_dns_providers()
  await this.test_and_register_geoip()

  if (this.cfg.header.asn) {
    this.register_hook('data_post', 'add_header_asn')
  }
  if (this.cfg.header.provider) {
    this.register_hook('data_post', 'add_header_provider')
  }
}

exports.test_and_register_dns_providers = async function () {
  if (!this.cfg.protocols.dns) return // disabled in config

  for (const zone of conf_providers) {
    try {
      const res = await this.get_dns_results(zone, test_ip)
      if (!res) {
        this.logerror(this, `${zone} failed`)
        continue
      }

      this.logdebug(this, `${zone} succeeded`)

      if (!providers.includes(zone)) providers.push(zone)
      if (this.registered) continue
      this.registered = true
      this.register_hook('lookup_rdns', 'lookup_via_dns')
    } catch (err) {
      this.logerror(this, `zone ${zone} encountered ${err.message}`)
    }
  }
  return providers
}

exports.load_asn_ini = function () {
  const plugin = this
  plugin.cfg = plugin.config.get(
    'asn.ini',
    {
      booleans: [
        '+header.asn',
        '-header.provider',
        '+protocols.dns',
        '+protocols.geoip',
      ],
    },
    function () {
      plugin.load_asn_ini()
    },
  )

  const c = plugin.cfg
  if (c.main.providers !== undefined) {
    if (c.main.providers === '') {
      conf_providers = []
    } else {
      conf_providers = c.main.providers.split(/[\s,;]+/)
    }
  }

  if (c.main.test_ip) test_ip = c.main.test_ip
}

exports.get_dns_results = async function (zone, ip) {
  const query = `${ip.split('.').reverse().join('.')}.${zone}`

  const timeout = (prom, time, exception) => {
    let timer
    return Promise.race([
      prom,
      new Promise((_r, rej) => (timer = setTimeout(rej, time, exception))),
    ]).finally(() => clearTimeout(timer))
  }

  try {
    const addrs = await timeout(
      dns.resolveTxt(query),
      (this.cfg.main.timeout || 4) * 1000,
      new Error(`${zone} timeout`),
    )

    if (!addrs || !addrs[0]) {
      this.logerror(this, `no results for ${query}`)
      return
    }

    const first = addrs[0]

    this.logdebug(this, `${zone} answers: ${first}`)

    return this.get_result(zone, first)
  } catch (err) {
    this.logerror(this, `error: ${err} running: ${query}`)
  }
}

exports.get_result = function (zone, first) {
  switch (zone) {
    case 'origin.asn.cymru.com':
      return this.parse_cymru(first.join(''))
    case 'asn.routeviews.org':
      return this.parse_routeviews(first)
    case 'asn.rspamd.com':
      return this.parse_rspamd(first.join(''))
    case 'origin.asn.spameatingmonkey.net':
      return this.parse_monkey(first.join(''))
  }

  this.logerror(this, `unrecognized ASN provider: ${zone}`)
  return
}

exports.lookup_via_dns = function (next, connection) {
  if (connection.remote.is_private) return next()

  const promises = []

  for (const zone of providers) {
    promises.push(
      new Promise((resolve) => {
        // connection.logdebug(plugin, `zone: ${zone}`);

        try {
          this.get_dns_results(zone, connection.remote.ip).then((r) => {
            if (!r) return resolve()

            const results = { emit: true }

            // store asn & net from any source
            if (r.asn) results.asn = r.asn
            if (r.net) results.net = r.net

            // store provider specific results
            switch (zone) {
              case 'origin.asn.cymru.com':
                results.cymru = r
                break
              case 'asn.routeviews.org':
                results.routeviews = r
                break
              case 'origin.asn.spameatingmonkey.net':
                results.monkey = r
                break
              case 'asn.rspamd.com':
                results.rspamd = r
                break
            }

            connection.results.add(this, results)
            resolve(results)
          })
        } catch (err) {
          connection.results.add(this, { err })
          resolve()
        }
      }),
    )
  }

  Promise.all(promises).then(next)
}

exports.parse_routeviews = function (thing) {
  let labels

  if (typeof thing === 'string' && /,/.test(thing)) {
    labels = thing.split(',')
    return { asn: labels[0], net: `${labels[1]}/${labels[2]}` }
  }

  // this is a correct result (node >= 0.10.26)
  // 99.177.75.208.asn.routeviews.org. IN TXT "40431" "208.75.176.0" "21"
  if (Array.isArray(thing)) {
    labels = thing
  } else {
    // this is what node (< 0.10.26) returns
    // 99.177.75.208.asn.routeviews.org. IN TXT "40431208.75.176.021"
    labels = thing.split(/ /)
  }

  if (labels.length !== 3) {
    this.logerror(
      this,
      `result length not 3: ${labels.length} string="${thing}"`,
    )
    return
  }

  return { asn: labels[0], net: `${labels[1]}/${labels[2]}` }
}

exports.parse_cymru = function (str) {
  const r = str.split(/\s+\|\s*/)
  //  99.177.75.208.origin.asn.cymru.com. 14350 IN TXT
  //        "40431 | 208.75.176.0/21 | US | arin | 2007-03-02"
  //        "10290 | 12.129.48.0/24  | US | arin |"
  if (r.length < 4) {
    this.logerror(this, `cymru: bad result length ${r.length} string="${str}"`)
    return
  }
  return { asn: r[0], net: r[1], country: r[2], assignor: r[3], date: r[4] }
}

exports.parse_monkey = function (str) {
  const plugin = this
  const r = str.split(/\s+\|\s+/)
  // "74.125.44.0/23 | AS15169 | Google Inc. | 2000-03-30"
  // "74.125.0.0/16 | AS15169 | Google Inc. | 2000-03-30 | US"
  if (r.length < 3) {
    plugin.logerror(
      plugin,
      `monkey: bad result length ${r.length} string="${str}"`,
    )
    return
  }
  return {
    asn: r[1].substring(2),
    net: r[0],
    org: r[2],
    date: r[3],
    country: r[4],
  }
}

exports.parse_rspamd = function (str) {
  const plugin = this
  const r = str.split(/\s*\|\s*/)
  //  8.8.8.8.asn.rspamd.com. 14350 IN TXT
  //        "15169|8.8.8.0/24|US|arin|"

  if (r.length < 4) {
    plugin.logerror(
      plugin,
      `rspamd: bad result length ${r.length} string="${str}"`,
    )
    return
  }
  return { asn: r[0], net: r[1], country: r[2], assignor: r[3], date: r[4] }
}

exports.add_header_asn = function (next, connection) {
  const asn = connection.results.get('asn')
  if (!asn?.asn) return next()
  if (!connection.transaction) return next()

  if (asn.net) {
    connection.transaction.add_header('X-Haraka-ASN', `${asn.asn} ${asn.net}`)
  } else {
    connection.transaction.add_header('X-Haraka-ASN', `${asn.asn}`)
  }
  if (asn.org) {
    connection.transaction.add_header('X-Haraka-ASN-Org', `${asn.org}`)
  }

  next()
}

exports.add_header_provider = function (next, connection) {
  const asn = connection.results.get('asn')
  if (!asn?.asn) return next()

  for (const p in asn) {
    if (!asn[p].asn) continue // ignore non-object results

    const name = `X-Haraka-ASN-${p.toUpperCase()}`
    const values = []
    for (const k in asn[p]) {
      values.push(`${k}=${asn[p][k]}`)
    }
    if (values.length === 0) continue
    connection.transaction.add_header(name, values.join(' '))
  }

  next()
}

exports.test_and_register_geoip = async function () {
  if (!this.cfg.protocols.geoip) return // disabled in config

  try {
    this.maxmind = require('maxmind')
    if (await this.load_dbs()) {
      this.register_hook('connect', 'lookup_via_maxmind')
    }
  } catch (e) {
    this.logerror(e)
    this.logerror(
      "unable to load maxmind, try\n\n\t'npm install -g maxmind@0.6'\n\n",
    )
  }
}

exports.load_dbs = async function () {
  this.dbsLoaded = 0
  const dbdir = this.cfg.main.dbdir || '/usr/local/share/GeoIP/'
  const dbPath = path.join(dbdir, `GeoLite2-ASN.mmdb`)

  try {
    await fs.access(dbPath)

    this.lookup = await this.maxmind.open(dbPath, {
      // this causes tests to hang, which is why mocha runs with --exit
      watchForUpdates: true,
      cache: {
        max: 1000, // max items in cache
        maxAge: 1000 * 60 * 60, // life time in milliseconds
      },
    })

    this.loginfo(`loaded maxmind db ${dbPath}`)
    this.dbsLoaded++
  } catch (e) {
    console.error(e)
    this.loginfo(`missing [access to] DB ${dbPath}`)
  }

  return this.dbsLoaded
}

exports.lookup_via_maxmind = function (next, connection) {
  if (!this.maxmind || !this.dbsLoaded) return next()

  const asn = this.lookup.get(connection.remote.ip)
  if (asn?.autonomous_system_number || asn?.autonomous_system_organization) {
    connection.results.add(this, {
      ...(asn.autonomous_system_number
        ? { asn: asn.autonomous_system_number }
        : {}),
      ...(asn.autonomous_system_organization
        ? { org: asn.autonomous_system_organization }
        : {}),
    })
  }

  next()
}