OverviewFilesCommitsIssuesDocsReleasesPeers

host-services / lib / collect.js

338 lines · javascript ref:
// Everything this host serves, in one snapshot: pgh apps, systemd units
// (system and user) and every listening TCP port, each port tied to whoever
// owns it, each service given a state:
//
//   active    running, and its ports (if any) answer
//   degraded  running, but none of its ports answer
//   dormant   installed / enabled but not running
//   failed    systemd says failed, or a pgh app exited with an error
//
// Ownership of a socket: inode -> pid through /proc/<pid>/fd, which only works
// for our own processes. Sockets of other users (jellyfin, plex, caddy...) are
// tied by uid -> user -> the running unit with that User=.

import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import process from 'bare-process'
import { run } from './run.js'
import { probeWeb } from './probe.js'
import { parseProcNetTcp, bindScope, parseSystemctlShow, isVendorUnit, parsePghStatus } from './parse.js'

const UNIT_PROPS = 'Id,Description,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,MainPID,User,ActiveEnterTimestamp'

function readText (p) {
  try {
    return fs.readFileSync(p).toString()
  } catch {
    return ''
  }
}

// The listening sockets, one entry per port+scope (a port bound on both
// 0.0.0.0 and :: is one service, not two).
export function readListeners (procRoot = '/proc') {
  const all = [
    ...parseProcNetTcp(readText(path.join(procRoot, 'net/tcp')), 4),
    ...parseProcNetTcp(readText(path.join(procRoot, 'net/tcp6')), 6)
  ]
  const byPort = new Map()
  for (const l of all) {
    const scope = bindScope(l.addr)
    const key = l.port + '/' + (scope === 'addr' ? l.addr : scope)
    const prev = byPort.get(key)
    if (prev) {
      prev.inodes.push(l.inode)
      continue
    }
    byPort.set(key, { port: l.port, addr: l.addr, scope, uid: l.uid, inodes: [l.inode] })
  }
  // A port open on all addresses also covers loopback: drop the 'local' twin.
  const out = []
  for (const l of byPort.values()) {
    if (l.scope === 'local' && byPort.has(l.port + '/all')) {
      byPort.get(l.port + '/all').inodes.push(...l.inodes)
      continue
    }
    out.push(l)
  }
  return out.sort((a, b) => a.port - b.port)
}

// socket inode -> pid, for every process we may look into.
export function socketOwners (procRoot = '/proc') {
  const owners = new Map()
  let pids = []
  try {
    pids = fs.readdirSync(procRoot).filter((d) => /^\d+$/.test(d))
  } catch {}
  for (const pid of pids) {
    let fds
    try {
      fds = fs.readdirSync(path.join(procRoot, pid, 'fd'))
    } catch {
      continue
    }
    for (const fd of fds) {
      let target
      try {
        target = fs.readlinkSync(path.join(procRoot, pid, 'fd', fd))
      } catch {
        continue
      }
      const m = /^socket:\[(\d+)\]$/.exec(target)
      if (m) owners.set(Number(m[1]), Number(pid))
    }
  }
  return owners
}

// What a pid is: a pgh app (by its install path), a systemd unit (by cgroup),
// or just a command.
export function describePid (pid, procRoot = '/proc') {
  const cmdline = readText(path.join(procRoot, String(pid), 'cmdline')).split('\0').filter(Boolean)
  const cgroup = readText(path.join(procRoot, String(pid), 'cgroup'))
  const unit = /\/([^/]+\.service)\s*$/m.exec(cgroup)
  const user = /user@\d+\.service/.test(cgroup)
  return {
    pid,
    cmd: cmdline.slice(0, 3).join(' ').slice(0, 200),
    exe: cmdline.length ? path.basename(cmdline[0]) : '',
    pghApp: pghAppOf(cmdline),
    unit: unit && !/^user@\d+\.service$/.test(unit[1]) ? unit[1] : null,
    userUnit: user
  }
}

// Every pgh app runs as `<store>/apps/pgh/runtime/bare <store>/apps/<app>/...`:
// the app is the script's path, not the runtime's (which is always pgh's).
export function pghAppOf (cmdline) {
  let app = null
  for (const arg of cmdline) {
    const m = /\/store\/apps\/([^/]+)\/(.*)$/.exec(arg)
    if (m && !m[2].startsWith('runtime/')) app = m[1]
  }
  return app
}

export function readUsers (passwd = readText('/etc/passwd')) {
  const users = new Map()
  for (const line of passwd.split('\n')) {
    const f = line.split(':')
    if (f.length > 2) users.set(Number(f[2]), f[0])
  }
  return users
}

// Assemble the snapshot from raw inputs. Pure, so it is tested without a host.
export function buildServices ({ listeners, owners, pidInfo, users, units, userUnits, pghApps, probes, labels = {} }) {
  const portLabels = labels.ports || {}
  const unitLabels = labels.units || {}
  const services = new Map()
  const add = (id, base) => {
    if (!services.has(id)) services.set(id, { id, ports: [], ...base })
    return services.get(id)
  }

  for (const a of pghApps) {
    add('pgh:' + a.name, {
      kind: 'pgh',
      name: a.name,
      label: unitLabels['pgh:' + a.name] || null,
      running: a.running,
      version: a.version,
      autostart: a.autostart,
      since: a.running ? a.startedAt : a.lastExit,
      lastExitCode: a.lastExitCode,
      pid: a.pid
    })
  }

  const unitById = new Map()
  for (const [scope, list] of [['system', units], ['user', userUnits]]) {
    for (const u of list) {
      if (u.LoadState && u.LoadState !== 'loaded') continue
      const id = (scope === 'user' ? 'user:' : 'unit:') + u.Id
      unitById.set(id, { scope, u })
    }
  }

  // Which running unit runs as which user, for sockets we cannot look into.
  const unitsByUser = new Map()
  for (const [id, { u }] of unitById) {
    if (u.ActiveState !== 'active' || !u.User) continue
    if (!unitsByUser.has(u.User)) unitsByUser.set(u.User, [])
    unitsByUser.get(u.User).push(id)
  }

  const ensureUnit = (id) => {
    const rec = unitById.get(id)
    if (!rec) return null
    const { scope, u } = rec
    return add(id, {
      kind: scope === 'user' ? 'user-unit' : 'unit',
      name: u.Id.replace(/\.service$/, ''),
      label: unitLabels[u.Id] || null,
      description: u.Description && u.Description !== u.Id ? u.Description : null,
      running: u.ActiveState === 'active' || u.ActiveState === 'reloading',
      activeState: u.ActiveState,
      subState: u.SubState,
      enabled: u.UnitFileState,
      since: u.ActiveEnterTimestamp || null,
      user: u.User || null,
      vendor: isVendorUnit(u)
    })
  }

  for (const l of listeners) {
    const probe = probes.get(l.port + '/' + l.addr) || null
    const port = {
      port: l.port,
      addr: l.addr,
      scope: l.scope,
      label: portLabels[String(l.port)] || null,
      open: probe ? probe.open : null,
      http: probe ? probe.http : null,
      banner: probe ? probe.banner : null,
      owner: null
    }
    let svc = null
    const pid = l.inodes.map((i) => owners.get(i)).find(Boolean)
    const info = pid ? (pidInfo.get(pid) || { pid, cmd: '', exe: '', pghApp: null, unit: null, userUnit: false }) : null
    if (info) {
      port.owner = { pid, cmd: info.cmd }
      if (info.pghApp && services.has('pgh:' + info.pghApp)) svc = services.get('pgh:' + info.pghApp)
      else if (info.unit) svc = ensureUnit((info.userUnit ? 'user:' : 'unit:') + info.unit)
    } else {
      port.owner = { user: users.get(l.uid) || String(l.uid) }
    }
    // labels.json may name the unit a well-known port belongs to: root's
    // services, and services whose /proc/<pid>/fd we cannot read.
    if (!svc && !info && port.label && port.label.unit && unitById.has('unit:' + port.label.unit)) svc = ensureUnit('unit:' + port.label.unit)
    // Otherwise the one running unit with that User=. Never for root: dozens
    // of root services run with an empty User=, and the one that sets it
    // explicitly (ModemManager) would collect every root socket.
    if (!svc && !info && l.uid !== 0) {
      const cands = unitsByUser.get(port.owner.user) || []
      if (cands.length === 1) svc = ensureUnit(cands[0])
    }
    // A plain process of ours (no unit, no pgh app): its ports belong together.
    if (!svc && info) {
      svc = add('proc:' + pid, {
        kind: 'process',
        name: info.exe || 'pid ' + pid,
        label: null,
        running: true,
        owner: port.owner
      })
    }
    if (!svc) {
      svc = add('port:' + l.port + '/' + l.addr, {
        kind: 'port',
        name: (port.label && port.label.name) || (port.http && port.http.title) || 'port ' + l.port,
        label: port.label,
        running: true,
        owner: port.owner
      })
    }
    svc.ports.push(port)
  }

  // Units nobody's port pulled in: hand-installed ones, labelled ones and
  // failed ones are services; the rest is the OS.
  const system = []
  for (const [id, { u }] of unitById) {
    if (services.has(id)) continue
    const keep = !isVendorUnit(u) || unitLabels[u.Id] || u.ActiveState === 'failed'
    if (keep && (u.UnitFileState !== 'static' || u.ActiveState !== 'inactive')) ensureUnit(id)
    else system.push({ id, name: u.Id, state: u.ActiveState, sub: u.SubState, enabled: u.UnitFileState })
  }

  const list = [...services.values()]
  for (const s of list) s.state = stateOf(s)
  return { services: list, system }
}

export function stateOf (s) {
  if (s.kind === 'unit' || s.kind === 'user-unit') {
    if (s.activeState === 'failed') return 'failed'
    if (!s.running) return 'dormant'
  } else if (s.kind === 'pgh') {
    // 75 is pgh's "restart into the new release" exit, not a failure.
    if (!s.running) return s.lastExitCode && s.lastExitCode !== 75 ? 'failed' : 'dormant'
  }
  if (s.ports.length && !s.ports.some((p) => p.open)) return 'degraded'
  return 'active'
}

export class HostCollector {
  constructor ({ labels = {}, pghBin = path.join(os.homedir(), '.local', 'bin', 'pgh'), ttlMs = 15000 } = {}) {
    this.labels = labels
    this.pghBin = pghBin
    this.ttlMs = ttlMs
    this._cache = null
    this._at = 0
    this._inflight = null
  }

  snapshot () {
    if (this._cache && Date.now() - this._at < this.ttlMs) return Promise.resolve(this._cache)
    if (!this._inflight) {
      this._inflight = this._collect().then((s) => {
        this._cache = s
        this._at = Date.now()
        return s
      }).finally(() => {
        this._inflight = null
      })
    }
    return this._inflight
  }

  async _collect () {
    const uid = process.getuid ? process.getuid() : 1000
    // systemctl --user needs the session bus; a daemon started over ssh or by
    // pgh's supervisor may not have it in its environment.
    const userEnv = { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || '/run/user/' + uid }
    const [sys, usr, pgh] = await Promise.all([
      run('systemctl', ['show', '--type=service', '--all', '-p', UNIT_PROPS]),
      run('systemctl', ['--user', 'show', '--type=service', '--all', '-p', UNIT_PROPS], { env: userEnv }),
      run(this.pghBin, ['app', 'status'], { timeoutMs: 20000 })
    ])
    const listeners = readListeners()
    const owners = socketOwners()
    const pidInfo = new Map()
    for (const pid of new Set(owners.values())) pidInfo.set(pid, describePid(pid))

    const probes = new Map()
    await Promise.all(listeners.map(async (l) => {
      // Probe the address it listens on: a loopback listener may be on
      // 127.0.0.53, not 127.0.0.1. Wildcard listeners answer on 127.0.0.1.
      const host = l.scope === 'all' ? '127.0.0.1' : l.addr.replace(/^::ffff:/, '')
      probes.set(l.port + '/' + l.addr, await probeWeb(host, l.port))
    }))

    const built = buildServices({
      listeners,
      owners,
      pidInfo,
      users: readUsers(),
      units: parseSystemctlShow(sys.stdout),
      userUnits: parseSystemctlShow(usr.stdout),
      pghApps: parsePghStatus(pgh.stdout),
      probes,
      labels: this.labels
    })
    return {
      host: os.hostname(),
      at: new Date().toISOString(),
      errors: [
        sys.code !== 0 && 'systemctl: ' + (sys.stderr.trim() || 'exit ' + sys.code),
        usr.code !== 0 && 'systemctl --user: ' + (usr.stderr.trim() || 'exit ' + usr.code),
        pgh.code !== 0 && 'pgh app status: ' + (pgh.stderr.trim() || 'exit ' + pgh.code)
      ].filter(Boolean),
      ...built
    }
  }
}