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