OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / lib / parse.js

214 lines · javascript ref:
// Pure parsers for what the host tells us: /proc/net, systemctl, pgh, avahi,
// HTTP. No I/O here, so every one of them is tested against captured output.

// /proc/net/tcp and tcp6: LISTEN rows only (st 0A). Addresses are hex in host
// byte order per 32-bit word, so 0100007F is 127.0.0.1.
export function parseProcNetTcp (text, family = 4) {
  const out = []
  for (const line of text.split('\n').slice(1)) {
    const f = line.trim().split(/\s+/)
    if (f.length < 10 || f[3] !== '0A') continue
    const [addrHex, portHex] = f[1].split(':')
    out.push({
      family,
      addr: family === 4 ? ipv4FromHex(addrHex) : ipv6FromHex(addrHex),
      port: parseInt(portHex, 16),
      uid: Number(f[7]),
      inode: Number(f[9])
    })
  }
  return out
}

function ipv4FromHex (hex) {
  const n = parseInt(hex, 16)
  return [n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255].join('.')
}

function ipv6FromHex (hex) {
  // Four little-endian 32-bit words.
  const bytes = []
  for (let w = 0; w < 4; w++) {
    const word = hex.slice(w * 8, w * 8 + 8)
    for (let b = 3; b >= 0; b--) bytes.push(word.slice(b * 2, b * 2 + 2))
  }
  const groups = []
  for (let i = 0; i < 16; i += 2) groups.push(parseInt(bytes[i] + bytes[i + 1], 16).toString(16))
  const full = groups.join(':')
  if (full === '0:0:0:0:0:0:0:0') return '::'
  if (full === '0:0:0:0:0:0:0:1') return '::1'
  if (full.startsWith('0:0:0:0:0:ffff:')) {
    const v4 = bytes.slice(12).map((h) => parseInt(h, 16)).join('.')
    return '::ffff:' + v4
  }
  return full
}

// Listening on every address, loopback only, or one specific address.
export function bindScope (addr) {
  if (addr === '0.0.0.0' || addr === '::') return 'all'
  if (addr.startsWith('127.') || addr === '::1') return 'local'
  return 'addr'
}

// `systemctl show -p A,B,...` for many units: blank-line-separated blocks of
// Key=Value.
export function parseSystemctlShow (text) {
  const units = []
  let cur = {}
  for (const line of text.split('\n')) {
    if (line.trim() === '') {
      if (cur.Id) units.push(cur)
      cur = {}
      continue
    }
    const i = line.indexOf('=')
    if (i > 0) cur[line.slice(0, i)] = line.slice(i + 1)
  }
  if (cur.Id) units.push(cur)
  return units
}

// A unit the distribution shipped (under /usr/lib or /lib) vs one somebody
// installed by hand (/etc/systemd, ~/.config/systemd). Hand-made ones are
// always services worth listing; shipped ones only when they do something
// visible (own a port) or are named in labels.json.
export function isVendorUnit (u) {
  const p = u.FragmentPath || ''
  return p.startsWith('/usr/lib/') || p.startsWith('/lib/') || p.startsWith('/usr/share/')
}

// `pgh app status` prints a fixed-width table: header, a dashes row, then one
// row per app. Column edges come from the dashes row, so values with spaces
// (none today) would still split correctly.
export function parsePghStatus (text) {
  const lines = text.split('\n')
  const di = lines.findIndex((l) => /^-+(\s+-+)+\s*$/.test(l))
  if (di < 1) return []
  const header = lines[di - 1]
  const cols = []
  const re = /-+/g
  let m
  while ((m = re.exec(lines[di])) !== null) cols.push([m.index, m.index + m[0].length])
  const names = cols.map(([a, b], i) => header.slice(a, i + 1 < cols.length ? cols[i + 1][0] : undefined).trim())
  const apps = []
  for (const line of lines.slice(di + 1)) {
    if (!line.trim()) continue
    const row = {}
    cols.forEach(([a], i) => {
      const end = i + 1 < cols.length ? cols[i + 1][0] : undefined
      row[names[i]] = line.slice(a, end).trim()
    })
    if (!row.name) continue
    apps.push({
      name: row.name,
      running: row.running === 'true',
      pid: Number(row.childPid) || null,
      version: row.version || '',
      startedAt: row.startedAt || null,
      lastExit: row.lastExit || null,
      lastExitCode: row.lastExitCode === '' ? null : Number(row.lastExitCode),
      autostart: row.autostart === 'true'
    })
  }
  return apps
}

// The first lines of a raw HTTP/1.x response: status code, Location, <title>.
// null when the bytes are not HTTP at all (an SSH banner, a binary protocol).
export function parseHttpHead (text) {
  const m = /^HTTP\/\d(?:\.\d)?\s+(\d{3})/.exec(text)
  if (!m) return null
  const loc = /\r?\nlocation:\s*([^\r\n]+)/i.exec(text)
  const server = /\r?\nserver:\s*([^\r\n]+)/i.exec(text)
  return {
    code: Number(m[1]),
    location: loc ? loc[1].trim() : null,
    server: server ? server[1].trim() : null,
    title: extractTitle(text)
  }
}

export function extractTitle (html) {
  const m = /<title[^>]*>([^<]*)/i.exec(html)
  if (!m) return null
  const t = decodeEntities(m[1]).replace(/\s+/g, ' ').trim()
  return t || null
}

// A redirect we may follow: one that stays on the port we probed (most web UIs
// answer / with a 30x to /login or /web/). Returns the path to request, or
// null for anything that leaves (another port, https, another scheme).
export function sameOriginPath (location, port) {
  if (!location) return null
  if (location.startsWith('//')) return null
  if (location.startsWith('/')) return location
  const m = /^(https?):\/\/[^/:?#]+(?::(\d+))?([/?#].*)?$/i.exec(location)
  if (!m) return /^[a-z]+:/i.test(location) ? null : '/' + location
  if (m[1].toLowerCase() !== 'http') return null
  if (Number(m[2] || 80) !== port) return null
  const rest = m[3] || '/'
  return rest.startsWith('/') ? rest : '/' + rest
}

// The title worth showing as "what this page is". Error pages name the error,
// not the service.
export function pageTitle (http) {
  if (!http || !http.title || http.code >= 400) return null
  if (/^(\d{3}\b|unauthori[sz]ed$|forbidden$|not found$|directory listing for\b)/i.test(http.title)) return null
  return http.title
}

function decodeEntities (s) {
  return s.replace(/&(amp|lt|gt|quot|#39|#x27|middot|nbsp);/g, (_, e) => ({
    amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", '#x27': "'", middot: '·', nbsp: ' '
  })[e])
}

// /proc/net/arp: complete entries only (flags 0x2); 0x0 rows are hosts that
// never answered.
export function parseArp (text) {
  const out = []
  for (const line of text.split('\n').slice(1)) {
    const f = line.trim().split(/\s+/)
    if (f.length < 6) continue
    if (f[2] !== '0x2' && f[2] !== '0x6') continue
    if (f[3] === '00:00:00:00:00:00') continue
    out.push({ ip: f[0], mac: f[3].toLowerCase(), dev: f[5] })
  }
  return out
}

// avahi's parsable output escapes bytes as \DDD (decimal) and specials as \c.
function avahiUnescape (s) {
  return s.replace(/\\(\d{3}|.)/g, (_, e) => (e.length === 3 ? String.fromCharCode(Number(e)) : e))
}

// `avahi-browse -atrp`: resolved ('=') IPv4 rows -> one service each.
export function parseAvahiBrowse (text) {
  const out = []
  for (const line of text.split('\n')) {
    const f = line.split(';')
    if (f[0] !== '=' || f.length < 10 || f[2] !== 'IPv4') continue
    out.push({
      name: avahiUnescape(f[3]),
      type: f[4],
      host: avahiUnescape(f[6]),
      ip: f[7],
      port: Number(f[8]),
      txt: f.slice(9).join(';')
    })
  }
  return out
}

// `ip -4 -o addr show scope global`: the host's own LAN addresses + prefix.
export function parseIpAddr (text) {
  const out = []
  for (const line of text.split('\n')) {
    const m = /^\d+:\s+(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)\/(\d+)/.exec(line)
    if (m) out.push({ dev: m[1], ip: m[2], prefix: Number(m[3]) })
  }
  return out
}