// 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
}
}
}