haraka/haraka-net-utils

View on GitHub
index.js

Summary

Maintainability
B
6 hrs
Test Coverage
'use strict'

const { Resolver } = require('node:dns').promises
const dns = new Resolver({ timeout: 25000, tries: 1 })
const net = require('node:net')
const os = require('node:os')

// npm modules
const ipaddr = require('ipaddr.js')
const sprintf = require('sprintf-js').sprintf
const tlds = require('haraka-tld')

const locallyBoundIPs = []

// export config, so config base path can be overloaded by tests
exports.config = require('haraka-config')

exports.long_to_ip = function (n) {
  let d = n % 256
  for (let i = 3; i > 0; i--) {
    n = Math.floor(n / 256)
    d = `${n % 256}.${d}`
  }
  return d
}

exports.dec_to_hex = function (d) {
  return d.toString(16)
}

exports.hex_to_dec = function (h) {
  return parseInt(h, 16)
}

exports.ip_to_long = function (ip) {
  if (!net.isIPv4(ip)) return false

  const d = ip.split('.')
  return ((+d[0] * 256 + +d[1]) * 256 + +d[2]) * 256 + +d[3]
}

exports.octets_in_string = function (str, oct1, oct2) {
  let oct1_idx
  let oct2_idx

  // test the largest of the two octets first
  if (oct2.length >= oct1.length) {
    oct2_idx = str.lastIndexOf(oct2)
    if (oct2_idx === -1) return false

    oct1_idx = (
      str.substring(0, oct2_idx) + str.substring(oct2_idx + oct2.length)
    ).lastIndexOf(oct1)
    if (oct1_idx === -1) return false

    return true // both were found
  }

  oct1_idx = str.indexOf(oct1)
  if (oct1_idx === -1) return false

  oct2_idx = (
    str.substring(0, oct1_idx) + str.substring(oct1_idx + oct1.length)
  ).lastIndexOf(oct2)
  if (oct2_idx === -1) return false

  return true
}

exports.is_ip_in_str = function (ip, str) {
  if (!str) return false
  if (!ip) return false
  if (!net.isIPv4(ip)) return false // IPv4 only, for now

  const host_part = tlds.split_hostname(str, 1)[0].toString()
  const octets = ip.split('.')

  // See if the 3rd and 4th octets appear in the string
  if (this.octets_in_string(host_part, octets[2], octets[3])) {
    return true
  }
  // then the 1st and 2nd octets
  if (this.octets_in_string(host_part, octets[0], octets[1])) {
    return true
  }

  // Whole IP in hex
  let host_part_copy = host_part
  const ip_hex = this.dec_to_hex(this.ip_to_long(ip))
  for (let i = 0; i < 4; i++) {
    const part = host_part_copy.indexOf(ip_hex.substring(i * 2, i * 2 + 2))
    if (part === -1) break
    if (i === 3) return true
    host_part_copy =
      host_part_copy.substring(0, part) + host_part_copy.substring(part + 2)
  }
  return false
}

const re_ipv4 = {
  loopback: /^127\./,
  link_local: /^169\.254\./,

  private10: /^10\./, // 10/8
  private192: /^192\.168\./, // 192.168/16
  // 172.16/16 .. 172.31/16
  private172: /^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16/12

  // RFC 5735
  testnet1: /^192\.0\.2\./, // 192.0.2.0/24
  testnet2: /^198\.51\.100\./, // 198.51.100.0/24
  testnet3: /^203\.0\.113\./, // 203.0.113.0/24
}

exports.is_private_ipv4 = function (ip) {
  // RFC 1918, reserved as "private" IP space
  if (re_ipv4.private10.test(ip)) return true
  if (re_ipv4.private192.test(ip)) return true
  if (re_ipv4.private172.test(ip)) return true

  if (re_ipv4.testnet1.test(ip)) return true
  if (re_ipv4.testnet2.test(ip)) return true
  if (re_ipv4.testnet3.test(ip)) return true

  return false
}

exports.on_local_interface = function (ip) {
  if (locallyBoundIPs.length === 0) {
    const ifList = os.networkInterfaces()
    for (const ifName of Object.keys(ifList)) {
      for (const addr of ifList[ifName]) {
        locallyBoundIPs.push(addr.address)
      }
    }
  }

  return locallyBoundIPs.includes(ip)
}

exports.is_local_host = async function (dst_host) {
  // Is the destination hostname/IP delivered to a hostname or IP
  // that's local to _this_ mail server?
  const local_ips = []
  const dest_ips = []

  try {
    const public_ip = await this.get_public_ip()
    if (public_ip) local_ips.push(public_ip)

    const local_hostname = this.get_primary_host_name()
    local_ips.push(...(await this.get_ips_by_host(local_hostname)))

    if (net.isIP(dst_host)) {
      // an IP address
      dest_ips.push(dst_host)
    } else {
      // a hostname
      if (dst_host === local_hostname) return true
      dest_ips.push(...(await this.get_ips_by_host(dst_host)))
    }
  } catch (e) {
    // console.error(e)
    return false
  }

  for (const ip of dest_ips) {
    if (this.is_local_ip(ip)) return true
    if (local_ips.includes(ip)) return true
  }
  return false
}

exports.is_local_ip = function (ip) {
  if (this.on_local_interface(ip)) return true

  if (net.isIPv4(ip)) return this.is_local_ipv4(ip)
  if (net.isIPv6(ip)) return this.is_local_ipv6(ip)

  // console.error(`invalid IP address: ${ip}`);
  return false
}

exports.is_local_ipv4 = function (ip) {
  if ('0.0.0.0' === ip) return true // RFC 5735

  // 127/8 (loopback)   # RFC 1122
  if (re_ipv4.loopback.test(ip)) return true

  // link local: 169.254/16 RFC 3927
  if (re_ipv4.link_local.test(ip)) return true

  return false
}

const re_ipv6 = {
  loopback: /^(0{1,4}:){7}0{0,3}1$/,
  link_local: /^fe80::/i,
  unique_local: /^f(c|d)[a-f0-9]{2}:/i,
}

exports.is_local_ipv6 = function (ip) {
  if (ip === '::') return true // RFC 5735
  if (ip === '::1') return true // RFC 4291

  // 2 more IPv6 notations for ::1
  // 0:0:0:0:0:0:0:1 or 0000:0000:0000:0000:0000:0000:0000:0001
  if (re_ipv6.loopback.test(ip)) return true

  // link local: fe80::/10, RFC 4862
  if (re_ipv6.link_local.test(ip)) return true

  // unique local (fc00::/7)   -> fc00: - fd00:
  if (re_ipv6.unique_local.test(ip)) return true

  return false
}

exports.is_private_ip = function (ip) {
  if (net.isIPv4(ip)) return this.is_local_ipv4(ip) || this.is_private_ipv4(ip)
  if (net.isIPv6(ip)) return this.is_local_ipv6(ip)
  return false
}

// backwards compatibility for non-public modules. Sunset: v3.0
exports.is_rfc1918 = exports.is_private_ip

exports.is_ip_literal = function (host) {
  return exports.get_ipany_re('^\\[(IPv6:)?', '\\]$', '').test(host)
    ? true
    : false
}

exports.is_ipv4_literal = function (host) {
  return /^\[(\d{1,3}\.){3}\d{1,3}\]$/.test(host) ? true : false
}

exports.same_ipv4_network = function (ip, ipList) {
  if (!ipList || !ipList.length) {
    console.error('same_ipv4_network, no ip list!')
    return false
  }
  if (!net.isIPv4(ip)) {
    console.error('same_ipv4_network, IP is not IPv4!')
    return false
  }

  const first3 = ip.split('.').slice(0, 3).join('.')

  for (let i = 0; i < ipList.length; i++) {
    if (!net.isIPv4(ipList[i])) {
      console.error('same_ipv4_network, IP in list is not IPv4!')
      continue
    }
    if (first3 === ipList[i].split('.').slice(0, 3).join('.')) return true
  }
  return false
}

exports.get_ipany_re = function (prefix = '', suffix = '', modifier = 'mg') {
  return new RegExp(
    prefix +
      `(` + // capture group
      `(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))|(?:(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-fA-F]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,1}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,2}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,3}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:[0-9a-fA-F]{1,4})):)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,4}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,5}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,6}(?:(?:[0-9a-fA-F]{1,4})))?::))))` + // complex ipv4 + ipv6
      `)` + // end capture
      `${suffix}`,
    modifier,
  )
}

exports.get_ips_by_host = function (hostname, done) {
  const ips = new Set()
  const errors = []

  return Promise.allSettled([
    dns.resolve6(hostname),
    dns.resolve4(hostname),
  ]).then((res) => {
    res.filter((a) => a.status === 'rejected').map((a) => errors.push(a.reason))

    res
      .filter((a) => a.status === 'fulfilled')
      .map((a) => a.value.map((ip) => ips.add(ip)))

    if (done) done(errors, Array.from(ips))
    return Array.from(ips)
  })
}

exports.ipv6_reverse = function (ipv6) {
  ipv6 = ipaddr.parse(ipv6)
  return ipv6
    .toNormalizedString()
    .split(':')
    .map(function (n) {
      return sprintf('%04x', parseInt(n, 16))
    })
    .join('')
    .split('')
    .reverse()
    .join('.')
}

exports.ipv6_bogus = function (ipv6) {
  try {
    const ipCheck = ipaddr.parse(ipv6)
    if (ipCheck.range() !== 'unicast') return true
    return false
  } catch (e) {
    // If we get an error from parsing, return true for bogus.
    console.error(e)
    return true
  }
}

exports.ip_in_list = function (list, ip) {
  if (list === undefined) return false

  const isHostname = !net.isIP(ip)
  const isArray = Array.isArray(list)

  // Quick lookup
  if (!isArray) {
    if (ip in list) return true // domain or literal IP
    if (isHostname) return false // skip CIDR match
  }

  // Iterate: arrays and CIDR matches
  for (let item in list) {
    if (isArray) {
      item = list[item] // item is index
      if (item === ip) return true // exact match
    }
    if (isHostname) continue // skip CIDR match

    const cidr = item.split('/')
    const c_net = cidr[0]

    if (!net.isIP(c_net)) continue // bad config entry
    if (net.isIPv4(ip) && net.isIPv6(c_net)) continue
    if (net.isIPv6(ip) && net.isIPv4(c_net)) continue

    const c_mask = parseInt(cidr[1], 10) || (net.isIPv6(c_net) ? 128 : 32)

    if (ipaddr.parse(ip).match(ipaddr.parse(c_net), c_mask)) {
      return true
    }
  }

  return false
}

exports.get_primary_host_name = function () {
  return exports.config.get('me') || os.hostname()
}

for (const l of ['get_mx', 'get_implicit_mx', 'resolve_mx_hosts']) {
  exports[l] = require('./lib/get_mx')[l]
}

exports.get_public_ip = require('./lib/get_public_ip').get_public_ip

exports.get_public_ip_async = require('./lib/get_public_ip').get_public_ip_async

exports.HarakaMx = require('./lib/HarakaMx')

exports.add_line_processor = (socket) => {
  const line_regexp = /^([^\n]*\n)/ // utils.line_regexp
  let current_data = ''

  socket.on('data', (data) => {
    current_data += data
    let results
    while ((results = line_regexp.exec(current_data))) {
      const this_line = results[1]
      current_data = current_data.slice(this_line.length)
      socket.emit('line', this_line)
    }
  })

  socket.on('end', () => {
    if (current_data.length) {
      socket.emit('line', current_data)
    }
    current_data = ''
  })
}