// buildServices / stateOf: attribution and states, without a host.
import test from 'brittle'
import { buildServices, stateOf, readListeners, pghAppOf } from '../lib/collect.js'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
const unit = (Id, extra = {}) => ({
Id, Description: Id, LoadState: 'loaded', ActiveState: 'active', SubState: 'running',
UnitFileState: 'enabled', FragmentPath: '/usr/lib/systemd/system/' + Id, User: '', ...extra
})
function build (over = {}) {
return buildServices({
listeners: [],
owners: new Map(),
pidInfo: new Map(),
users: new Map([[1000, 'fleeky'], [122, 'jellyfin']]),
units: [],
userUnits: [],
pghApps: [],
probes: new Map(),
...over
})
}
test('a port owned by our own pgh app attaches to that app', (t) => {
const { services } = build({
listeners: [{ port: 4050, addr: '0.0.0.0', scope: 'all', uid: 1000, inodes: [11] }],
owners: new Map([[11, 500]]),
pidInfo: new Map([[500, { pid: 500, cmd: 'bare x', pghApp: 'cthuloid-web', unit: null, userUnit: false }]]),
pghApps: [{ name: 'cthuloid-web', running: true, version: '0.2.1', autostart: true, lastExitCode: 0 }],
probes: new Map([['4050/0.0.0.0', { open: true, http: { code: 200, title: 'Cthuloid Lights' }, banner: null }]])
})
t.is(services.length, 1)
t.is(services[0].id, 'pgh:cthuloid-web')
t.is(services[0].ports[0].http.title, 'Cthuloid Lights')
t.is(services[0].state, 'active')
})
test('another user\'s socket attaches to the running unit with that User=', (t) => {
const { services } = build({
listeners: [{ port: 8096, addr: '0.0.0.0', scope: 'all', uid: 122, inodes: [9] }],
units: [unit('jellyfin.service', { User: 'jellyfin' })],
probes: new Map([['8096/0.0.0.0', { open: true, http: { code: 302 }, banner: null }]])
})
const j = services.find((s) => s.id === 'unit:jellyfin.service')
t.ok(j)
t.is(j.ports[0].port, 8096)
t.is(j.state, 'active')
})
test('a socket nobody can be tied to is its own "port" service', (t) => {
const { services } = build({
listeners: [{ port: 80, addr: '::', scope: 'all', uid: 0, inodes: [1] }],
probes: new Map([['80/::', { open: true, http: { code: 308 }, banner: null }]])
})
t.is(services[0].kind, 'port')
t.is(services[0].ports[0].owner.user, '0')
})
test('vendor units without ports go to the system list; hand-made ones stay', (t) => {
const { services, system } = build({
units: [unit('cron.service'), unit('easynews-indexer.service', { FragmentPath: '/etc/systemd/system/easynews-indexer.service' })],
userUnits: [unit('kodi-repo.service', { FragmentPath: '/home/u/.config/systemd/user/kodi-repo.service' })]
})
t.alike(services.map((s) => s.id).sort(), ['unit:easynews-indexer.service', 'user:kodi-repo.service'])
t.alike(system.map((s) => s.name), ['cron.service'])
})
test('a failed vendor unit is a service, not hidden in the system list', (t) => {
const { services } = build({ units: [unit('xdg-desktop-portal.service', { ActiveState: 'failed', SubState: 'failed' })] })
t.is(services[0].state, 'failed')
})
test('pghAppOf: the app is the script, not pgh\'s runtime', (t) => {
t.is(pghAppOf(['/h/.local/share/store/apps/pgh/runtime/bare', '/h/.local/share/store/apps/favela-web/index.js']), 'favela-web')
t.is(pghAppOf(['/h/.local/share/store/apps/pgh/runtime/bare', '/h/.local/share/store/apps/pgh/lib/supervisor.js']), 'pgh')
t.is(pghAppOf(['/usr/bin/python3', '-m', 'http.server']), null)
})
test('a plain process\'s ports are one service named after it', (t) => {
const { services } = build({
listeners: [17001, 18080].map((port, i) => ({ port, addr: '::', scope: 'all', uid: 1000, inodes: [i + 1] })),
owners: new Map([[1, 7684], [2, 7684]]),
pidInfo: new Map([[7684, { pid: 7684, cmd: 'cogserver', exe: 'cogserver', pghApp: null, unit: null, userUnit: false }]])
})
t.is(services.length, 1)
t.is(services[0].kind, 'process')
t.is(services[0].name, 'cogserver')
t.is(services[0].ports.length, 2)
})
test('a root socket goes to the unit labels.json names for its port', (t) => {
const { services } = build({
listeners: [{ port: 22, addr: '0.0.0.0', scope: 'all', uid: 0, inodes: [1] }],
units: [unit('ssh.service')],
labels: { ports: { 22: { name: 'SSH', unit: 'ssh.service' } } }
})
t.is(services.length, 1)
t.is(services[0].id, 'unit:ssh.service')
t.is(services[0].ports[0].port, 22)
})
test('root sockets never go to a unit just because it says User=root', (t) => {
const { services } = build({
listeners: [{ port: 631, addr: '127.0.0.1', scope: 'local', uid: 0, inodes: [1] }],
users: new Map([[0, 'root']]),
units: [unit('ModemManager.service', { User: 'root' })]
})
t.is(services.length, 1)
t.is(services[0].kind, 'port')
})
test('the label hint wins when several units run as the same user', (t) => {
const { services } = build({
listeners: [{ port: 8989, addr: '::', scope: 'all', uid: 1000, inodes: [1] }],
units: ['sonarr.service', 'radarr.service'].map((id) => unit(id, { User: 'fleeky', FragmentPath: '/etc/systemd/system/' + id })),
labels: { ports: { 8989: { name: 'Sonarr', unit: 'sonarr.service' } } }
})
const s = services.find((x) => x.id === 'unit:sonarr.service')
t.is(s.ports.length, 1)
t.absent(services.some((x) => x.kind === 'port'))
})
test('states', (t) => {
t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 0, ports: [] }), 'dormant')
t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 75, ports: [] }), 'dormant', '75 = OTA restart')
t.is(stateOf({ kind: 'pgh', running: false, lastExitCode: 1, ports: [] }), 'failed')
t.is(stateOf({ kind: 'pgh', running: true, ports: [{ open: false }] }), 'degraded')
t.is(stateOf({ kind: 'unit', running: false, activeState: 'inactive', ports: [] }), 'dormant')
t.is(stateOf({ kind: 'unit', running: true, activeState: 'active', ports: [{ open: true }, { open: false }] }), 'active')
})
test('readListeners: v4+v6 on the same port and scope are one entry; loopback twin folds into "all"', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-'))
fs.mkdirSync(path.join(dir, 'net'))
const head = ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n'
fs.writeFileSync(path.join(dir, 'net/tcp'), head +
' 0: 00000000:1FA0 00000000:0000 0A 00000000:00000000 00:00000000 00000000 122 0 101 1\n' +
' 1: 0100007F:1FA0 00000000:0000 0A 00000000:00000000 00:00000000 00000000 122 0 102 1\n')
fs.writeFileSync(path.join(dir, 'net/tcp6'), head +
' 0: 00000000000000000000000000000000:1FA0 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 122 0 103 1\n')
const ls = readListeners(dir)
t.is(ls.length, 1, '0.0.0.0, :: and 127.0.0.1 on 8096 are one service')
t.is(ls[0].scope, 'all')
t.alike(ls[0].inodes.sort(), [101, 102, 103])
})