haraka/haraka-plugin-watch

View on GitHub
index.js

Summary

Maintainability
F
5 days
Test Coverage
'use strict'

const path = require('path')
const redis = require('redis')
const WebSocket = require('ws')

let wss = { broadcast() {} }
let watchers = 0

exports.register = function () {
  this.inherits('haraka-plugin-redis')

  this.load_watch_ini()

  this.register_hook('init_master', 'redis_subscribe_all_results')
  this.register_hook('init_child', 'redis_subscribe_all_results')

  this.register_hook('deny', 'w_deny')
  this.register_hook('queue_ok', 'queue_ok')
}

exports.load_watch_ini = function () {
  this.cfg = this.config.get(
    'watch.ini',
    {
      booleans: ['-main.sampling'],
    },
    () => {
      this.load_watch_ini()
    },
  )

  if (this.cfg.ignore === undefined) this.cfg.ignore = {}
}

exports.hook_init_http = function (next, server) {
  server.http.app.use('/watch/wss_conf', (req, res) => {
    // app.use args: request, response, app_next
    // pass config information to the WS client
    const client = { sampling: this.cfg.main.sampling }
    if (this.cfg.wss && this.cfg.wss.url) {
      client.wss_url = this.cfg.wss.url
    }
    res.end(JSON.stringify(client))
  })

  let htdocs = path.join(__dirname, 'html')
  if (this.cfg.wss && this.cfg.wss.htdocs) {
    htdocs = this.cfg.wss.htdocs
  }
  server.http.app.use('/watch/', server.http.express.static(htdocs))

  this.loginfo('watch init_http done')
  next()
}

exports.hook_init_wss = function (next, server) {
  const plugin = this
  plugin.loginfo('watch init_wss')

  wss = server.http.wss

  wss.on('error', (error) => {
    plugin.loginfo(`server error: ${error}`)
  })

  wss.on('connection', (ws) => {
    watchers++

    // broadcast updated watcher count
    wss.broadcast({ watchers })

    plugin.logdebug(`wss client connected: ${Object.keys(ws)}`)

    // send message to just this websocket
    // ws.send(JSON.stringify({ msg: 'welcome!' });

    ws.on('error', (error) => {
      plugin.logerror(`client error: ${error}`)
    })

    ws.on('close', (code, message) => {
      plugin.loginfo(`client closed: ${message.toString()} (${code})`)
      watchers--
    })

    ws.on('message', (message, isBinary) => {
      plugin.logdebug(`from client: ${isBinary ? message.toString() : message}`)
    })
  })

  wss.broadcast = function broadcast(data) {
    const msg = JSON.stringify(data)
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(msg)
      }
    })
  }

  plugin.loginfo('watch init_wss done')
  next()
}

exports.w_deny = function (next, connection, params) {
  const pi_code = params[0]
  // let pi_msg    = params[1];
  const pi_name = params[2]
  // let pi_function = params[3];
  // let pi_params   = params[4];
  const pi_hook = params[5]

  connection.logdebug(this, `watch deny saw: ${pi_name} deny from ${pi_hook}`)

  const req = {
    uuid: connection.transaction
      ? connection.transaction.uuid
      : connection.uuid,
    local_port: { classy: 'bg_white', title: 'disconnected' },
    remote_host: get_remote_host(connection),
  }

  connection.logdebug(this, `watch sending dark red to ${pi_name}`)
  const bg_class = pi_code === DENYSOFT ? 'bg_dyellow' : 'bg_dred'
  const report_as = this.get_plugin_name(pi_name)
  if (req[report_as]) req[report_as].classy = bg_class
  if (!req[report_as]) req[report_as] = { classy: bg_class }

  wss.broadcast(req)
  next()
}

exports.queue_ok = function (next, connection, msg) {
  // ok 1390590369 qp 634 (F82E2DD5-9238-41DC-BC95-9C3A02716AD2.1)
  // required b/c outbound doesn't emit results - 2017-03
  wss.broadcast({
    uuid: connection.transaction.uuid,
    queue: {
      classy: 'bg_green',
      title: msg,
    },
  })
  next()
}

exports.redis_subscribe_all_results = async function (next) {
  const plugin = this

  if (this.pubsub) return // already subscribed?

  this.pubsub = redis.createClient(this.redisCfg.pubsub)
  this.pubsub.on('error', (err) => {
    this.logerror(err.message)
  })
  await this.pubsub.connect()

  await this.pubsub.pSubscribe('result-*', (message, channel) => {
    const match = /result-([A-F0-9\-.]+)$/.exec(channel) // uuid
    if (!match) {
      plugin.logerror('pattern: result-*')
    }

    const m = JSON.parse(message)

    if (typeof m.result !== 'object') {
      plugin.logerror(`garbage was published on ${channel}: ${m.result}`)
      return
    }

    if (this.cfg.ignore[m.result.ip] !== undefined) return

    switch (m.plugin) {
      case 'local':
        if (m.result.port) {
          wss.broadcast({
            uuid: match[1],
            local_port: { newval: m.result.port },
          })
          if (m.result.port === 465) {
            wss.broadcast({ uuid: match[1], tls: { classy: 'bg_green' } })
          }
          return
        }
        break
      case 'remote':
        if (m.result.ip) {
          wss.broadcast(exports.format_remote_host(match[1], m.result))
          return
        }
        break
      case 'helo':
        if (m.result.host) {
          wss.broadcast(exports.format_helo(match[1], m.result))
          return
        }
        break
      case 'reset':
        if (m.result.duration) {
          wss.broadcast({
            uuid: match[1],
            queue: {
              newval: m.result.duration.toFixed(1),
            },
          })
          return
        }
        break
      case 'disconnect':
        if (m.result.duration) {
          wss.broadcast({
            uuid: match[1],
            queue: { newval: m.result.duration.toFixed(1) },
            local_port: { classy: 'bg_white', title: 'disconnected' },
          })
          return
        }
        break
      case 'access':
      case 'tls':
        if (m.result.msg) return
        break
      case 'dnsbl':
      case 'dns-list':
        if (m.result.emit) return
        if (m.result.pass) return
        break
      case 'early_talker':
      case 'helo.checks':
        if (m.result.pass) return
        if (m.result.skip) return
        if (m.result.ips) return
        if (m.result.multi) return
        if (m.result.helo_host) return
        break
      case 'data.uribl':
      case 'uribl':
        if (m.result.pass) return
        if (m.result.skip) return
        break
      case 'karma':
        if (m.result.awards) return
        if (m.result.msg) return
        if (m.result.todo) return
        // if (m.result.emit) return;
        break
      case 'mail_from.is_resolvable':
        if (m.result.msg) return
        break
      case 'known-senders':
        if (m.result.rcpt_ods) return
        if (m.result.sender) return
        break
      case 'rcpt_to.in_host_list':
      case 'rcpt_to.qmail_deliverable':
      case 'qmail-deliverable':
        if (m.result.msg) return
        if (m.result.skip) return
        break
      case 'limit':
        if (m.result.concurrent_count !== undefined) return
        if (m.result.rate_rcpt) return
        if (m.result.rate_rcpt_sender) return
        if (m.result.concurrent) return
        if (m.result.rate_conn) return
        if (m.result.msg) return
        break
      case 'relay':
        if (m.result.skip) return
        break
      case 'headers':
      case 'data.headers':
        if (m.result.pass) return
        if (m.result.msg) return
        if (m.result.skip) return
        if (m.result.fail) {
          if (m.result.fail == 'UA') return
        }
        break
      case 'queue/smtp_forward':
        if (m.result.pass)
          wss.broadcast({
            uuid: match[1],
            'queue/smtp_forward': { classy: 'bg_green' },
          })
        return
      case 'outbound':
        wss.broadcast({ uuid: match[1], queue: { classy: 'bg_green' } })
        return
    }

    const req = { uuid: match[1] }
    req[plugin.get_plugin_name(m.plugin)] = plugin.format_any(
      m.plugin,
      m.result,
    )
    wss.broadcast(req)
  })

  this.logdebug(this, `pSubscribed to result-*`)
  next()
}

exports.get_plugin_name = function (pi_name) {
  // coalesce auth/* and queue/* plugins to 'auth' and 'queue'
  if (/^(queue|auth)\//.test(pi_name)) {
    return pi_name.split('/').shift()
  }

  switch (pi_name) {
    case 'connect.fcrdns':
      return 'fcrdns'
    case 'connect.p0f':
      return 'p0f'
    case 'dkim_verify':
    case 'dkim_sign':
      return 'dkim'
    case 'dmarc-perl':
    case 'data.dmarc':
      return 'dmarc'
    case 'data.headers':
      return 'headers'
    case 'outbound':
      return 'queue'
  }

  return pi_name
}

exports.format_any = function (pi_name, r) {
  // title: the value shown in the HTML tooltip
  // classy: color of the square
  switch (pi_name) {
    case 'access':
      if (r.whitelist) return { classy: 'bg_green', title: r.pass }
      if (r.fail) return { classy: 'bg_red', title: r.fail }
      if (r.skip) return {}
      break
    case 'bounce':
      return this.format_bounce(r)
    case 'connect.fcrdns':
    case 'fcrdns':
      return this.format_fcrdns(r)
    case 'connect.asn':
    case 'asn':
      return this.format_asn(r)
    case 'connect.geoip':
    case 'geoip': {
      const f = {}
      if (r.human) {
        f.title = r.human
        f.newval = r.human.substring(0, 6)
      }
      if (r.distance) {
        if (parseInt(r.distance, 10) > 4000) {
          f.classy = 'bg_red'
        } else {
          f.classy = 'bg_green'
        }
      }
      return f
    }
    case 'connect.p0f':
    case 'p0f':
      return this.format_p0f(r)
    case 'tls':
      if (r.enabled) {
        if (r.verified) {
          return { classy: 'bg_green', title: JSON.stringify(r) }
        }
        return { classy: 'bg_lgreen', title: JSON.stringify(r) }
      }
      break
    case 'data.uribl':
    case 'uribl':
    case 'dnsbl':
    case 'dns-list':
      if (r.fail) return { title: r.fail, classy: 'bg_lred' }
      break
    case 'karma':
      if (r.score !== undefined) {
        if (r.score < -8) return { classy: 'bg_red', title: r.score }
        if (r.score < -3) return { classy: 'bg_lred', title: r.score }
        if (r.score < 0) return { classy: 'bg_yellow', title: r.score }
        if (r.score > 3) return { classy: 'bg_green', title: r.score }
        if (r.score >= 0) return { classy: 'bg_lgreen', title: r.score }
      }
      if (r.fail) return { title: r.fail }

      if (r.err) return { title: r.err, classy: 'bg_yellow' }
      if (r.emit) return {}
      if (r.pass) return { classy: 'bg_green' }
      break
    case 'mail_from':
      if (r.address)
        return {
          newval:
            r.address && r.address.length > 22
              ? `..${r.address.substring(r.address.length - 22)}`
              : r.address,
          classy: 'black',
          title: r.address,
        }
      break
    case 'spf':
      if (r.scope) {
        const res = { title: r.result, scope: r.scope }
        switch (r.result) {
          case 'None':
            res.classy = 'bg_lgrey'
            break
          case 'Pass':
            res.classy = 'bg_green'
            break
          case 'Fail':
            res.classy = 'bg_red'
            break
          case 'SoftFail':
            res.classy = 'bg_yellow'
            break
        }
        return res
      }
      if (r.skip) return { classy: 'bg_yellow' }
      break
    case 'recipient':
    case 'rcpt_to':
      if (r.recipient) {
        return exports.format_recipient(r.recipient)
      }
      break
    case 'auth':
    case 'auth/auth_vpopmaild':
    case 'helo.checks':
    case 'mail_from.is_resolvable':
    case 'rcpt_to.in_host_list':
    case 'rcpt_to.qmail_deliverable':
    case 'qmail-deliverable':
    case 'avg':
    case 'clamd':
    case 'relay':
    case 'known-senders':
    case 'limit':
      if (r.pass || r.fail) return this.format_default(r)
      if (r.skip) return {}
      break
    case 'headers':
    case 'data.headers':
      if (r.fail) {
        if (/^direct/.test(r.fail)) return { classy: 'bg_lred' }
        if (/^from_match/.test(r.fail)) return { classy: 'bg_yellow' }
      }
      break
    case 'dkim_sign':
    case 'dkim_verify':
      if (r.pass || r.fail) {
        return this.format_default(r)
      }
      if (r.err) {
        return { classy: 'bg_yellow', title: r.err }
      }
      break
    case 'rspamd':
      if (r.score !== undefined)
        return {
          classy:
            r.is_spam === true
              ? 'bg_red'
              : r.action === 'greylist'
                ? 'bg_grey'
                : r.is_skipped === true
                  ? ''
                  : r.score > 5
                    ? 'bg_lred'
                    : r.score < 0
                      ? 'bg_green'
                      : r.score < 3
                        ? 'bg_lgreen'
                        : 'bg_yellow',
          title: JSON.stringify(r),
        }
      break
    case 'spamassassin':
      if (r.hits !== undefined) {
        const hits = parseFloat(r.hits)
        return {
          classy:
            hits > 5
              ? 'bg_red'
              : hits > 2
                ? 'bg_yellow'
                : hits < 0
                  ? 'bg_green'
                  : 'bg_lgreen',
          title: JSON.stringify(r),
          // title: `${r.flag}, ${hits} hits, time: ${r.time}`,
        }
      }
      break
    case 'dmarc':
    case 'data.dmarc':
    case 'dmarc-perl':
      if (r.pass) return { classy: 'bg_green', title: r.pass }
      if (r.fail) return { classy: 'bg_red', title: r.fail }
      if (r.dmarc === 'none') return { classy: 'bg_grey', title: r.dmarc }
      if (r.dmarc === 'other') return {}
      break
    case 'queue':
      if (r.pass) return { classy: 'bg_green', title: r.pass }
      if (r.fail) return { classy: 'bg_red', title: r.fail }
      if (r.msg === '') return {}
      break
  }

  this.logdebug(pi_name)
  this.logdebug(r)

  return {
    title: this.get_title(pi_name, r),
    classy: this.get_class(pi_name, r),
  }
}

exports.format_recipient = function (r) {
  const rcpt =
    r.address.length > 22
      ? `..${r.address.substring(r.address.length - 22)}`
      : r.address

  if (r.action === 'reject')
    return { newval: rcpt, classy: 'bg_red', title: r.address }
  if (r.action === 'accept')
    return { newval: rcpt, classy: 'bg_green', title: r.address }
  return { newval: rcpt, classy: 'black', title: r.address }
}

exports.format_default = function (r) {
  if (r.pass) return { classy: 'bg_green', title: r.pass }
  if (r.fail) return { classy: 'bg_red', title: r.fail }
  if (r.err) return { classy: 'bg_yellow', title: r.err }
}

exports.format_fcrdns = function (r) {
  if (r.pass) return { classy: 'bg_green' }
  if (r.fail) return { title: r.fail, classy: 'bg_lred' }
  if (r.fcrdns) {
    if (typeof r.fcrdns === 'string') return { title: r.fcrdns }
    if (Array.isArray(r.fcrdns) && r.fcrdns.length) {
      return { title: r.fcrdns.join(' ') }
    }
  }
  // this.loginfo(r);
  return {}
}

exports.format_asn = function (r) {
  if (r.pass) return { classy: 'bg_green' }
  if (r.fail) return { title: r.fail, classy: 'bg_lred' }
  if (r.asn) return { newval: r.asn }
  if (r.asn_score) return {} // extra
  this.loginfo(r)
  return {}
}

exports.format_p0f = function (r) {
  if (!r || !r.os_name) return {}
  const f = {
    title: `${r.os_name} ${r.os_flavor}, ${r.distance} hops`,
    newval: r.os_name,
  }
  if (r.os_name) {
    if (/freebsd|mac|ios/i.test(r.os_name)) r.classy = 'bg_green'
    if (/windows/i.test(r.os_name)) r.classy = 'bg_red'
  }
  return f
}

exports.format_bounce = function (r) {
  if (!r) return {}
  if (r.isa === 'no') return { classy: 'bg_lgreen', title: 'not a bounce' }
  if (r.fail && r.fail.length) return { classy: 'bg_red', title: r.human }
  return { classy: 'bg_green' }
}

exports.format_results = function (pi_name, r) {
  const s = {
    title: this.get_title(pi_name, r),
    classy: this.get_class(pi_name, r),
  }

  if (pi_name === 'spf') {
    s.scope = r.scope
  }
  return s
}

exports.format_helo = function (uuid, r) {
  return {
    uuid,
    helo: {
      newval:
        r.host && r.host.length > 22
          ? `...${r.host.substring(r.host.length - 22)}`
          : r.host,
      title: r.host,
      classy: 'bg_white',
    },
  }
}

exports.get_class = function (pi_name, r) {
  if (!r.pass) r.pass = []
  if (!r.fail) r.fail = []
  if (!r.err) r.err = []

  switch (pi_name) {
    case 'dmarc':
    case 'dmarc-perl':
    case 'data.dmarc': {
      if (!r.result) return 'got'
      const comment = r.reason && r.reason.length ? r.reason[0].comment : ''
      return r.result === 'pass'
        ? 'bg_green'
        : comment === 'no policy'
          ? 'bg_yellow'
          : 'bg_red'
    }
    case 'karma': {
      if (r.score === undefined) {
        const history = parseFloat(r.history) || 0
        return history > 2 ? 'bg_green' : history < -1 ? 'bg_red' : 'bg_yellow'
      }
      const score = parseFloat(r.score) || 0
      return score > 3
        ? 'bg_green'
        : score > 0
          ? 'bg_lgreen'
          : score < -3
            ? 'bg_red'
            : score < 0
              ? 'bg_lred'
              : 'bg_yellow'
    }
    case 'relay':
      return r.pass.length && r.fail.length === 0
        ? 'bg_green'
        : r.pass.length
          ? 'bg_lgreen'
          : r.fail.length
            ? 'bg_red'
            : r.err.length
              ? 'bg_yellow'
              : ''
    case 'rcpt_to.in_host_list':
      return r.pass.length && r.fail.length === 0
        ? 'bg_green'
        : r.pass.length
          ? 'bg_lgreen'
          : ''
    case 'spf':
      return r.result === 'Pass'
        ? 'bg_green'
        : r.result === 'Neutral'
          ? 'bg_lgreen'
          : /fail/i.test(r.result)
            ? 'bg_red'
            : /error/i.test(r.result)
              ? 'bg_yellow'
              : ''
    default:
      return r.pass.length && r.fail.length === 0
        ? 'bg_green'
        : r.pass.length
          ? 'bg_lgreen'
          : r.fail.length
            ? 'bg_red'
            : r.err.length
              ? 'bg_yellow'
              : 'bg_lgrey'
  }
}

exports.get_title = function (pi_name, r) {
  // title: the value shown in the HTML tooltip
  switch (pi_name) {
    case 'dmarc':
    case 'dmarc-perl':
    case 'data.dmarc': {
      const comment = r.reason && r.reason.length ? r.reason[0].comment : ''
      return r.result === 'pass'
        ? r.result
        : [r.result, r.disposition, comment].join(', ')
    }
    case 'queue':
      return r.human
    default:
      return r.human_html
  }
}

exports.format_remote_host = function (uuid, r) {
  let host = r.host || ''
  const ip = r.ip || ''
  let hostShort = host

  if (host) {
    switch (host) {
      case 'DNSERROR':
      case 'Unknown':
        host = ''
        break
    }
    if (host.length > 22) {
      hostShort = `...${host.substring(host.length - 20)}`
    }
  }

  return {
    uuid,
    remote_host: {
      newval: host ? `${hostShort} / ${ip}` : ip,
      title: host ? `${host} / ${ip}` : ip,
    },
  }
}

function get_remote_host(connection) {
  let host = ''
  let ip = ''
  if (connection.remote) {
    if (connection.remote.host) host = connection.remote.host
    if (connection.remote.ip) ip = connection.remote.ip
  }
  let hostShort = host

  if (host) {
    switch (host) {
      case 'DNSERROR':
      case 'Unknown':
        host = ''
        break
    }
    if (host.length > 22) {
      hostShort = `...${host.substring(host.length - 20)}`
    }
  }

  return {
    newval: host ? `${hostShort} / ${ip}` : ip,
    title: host ? `${host} / ${ip}` : ip,
  }
}