// procd-compat: the parts of procd's ubus API that OpenWrt tools call, for // a system where Shepherd, not procd, is init. // // - `service' object, method `event': {type: "config.change", data: // {package: P}} -- sent by reload_config and by rpcd after `uci commit' // (LuCI's Save & Apply) -- runs `herd reload S' for each Shepherd service // S registered for config P. procd does this with the reload triggers // init scripts declare (procd_add_reload_trigger); here they come from // the Guix services, in the config file. Method `get_data' answers // with no data (fw4 asks it for service-provided rules). // - `system' object: `board', `info' (LuCI's status page, `ubus call system // board'), `reboot'. Same reply shapes as procd's system.c. // // Usage: procd-compat CONFIG.json // {"herd": "/.../herd", "reboot": "/.../reboot", "uci": "/.../uci", // "triggers": {"firewall": ["firewall"], ...}, // "release": {"distribution": ..., "version": ..., ...}} 'use strict'; import { connect } from 'ubus'; import * as uloop from 'uloop'; import { readfile, writefile, popen, access, statvfs } from 'fs'; import { md5 } from 'digest'; const config = json(readfile(ARGV[0]) ?? die(`cannot read ${ARGV[0]}\n`)); const triggers = config.triggers ?? {}; function log(msg) { warn(`procd-compat: ${msg}\n`); } // --- config.change -> herd reload --------------------------------------- // procd queues triggered actions and runs them after a short delay; do the // same, so a burst of commits (LuCI applies several configs at once) reloads // each service once. herd runs as a child process, one at a time, never // blocking the loop: a reload may call back into this daemon (fw4 asks // `service get_data'). const pending = {}; let running = false; function run_pending() { const service = sort(keys(pending))[0]; if (running || service == null) return; delete pending[service]; running = true; log(`herd reload ${service}`); uloop.process(config.herd, [ 'reload', service ], {}, (rc) => { if (rc != 0) log(`herd reload ${service} failed (${rc})`); running = false; run_pending(); }); } const reloader = uloop.timer(-1, run_pending); // reload_config compares `uci show' digests with those of its last run, // kept in /var/run/config.md5. rpcd (LuCI's apply and rollback) sends // config.change itself, so that baseline would go stale and a later // `uci commit; reload_config' could miss a change back: refresh the // changed config's line. const md5file = '/var/run/config.md5'; function refresh_baseline(pkg) { const sums = readfile(md5file); if (!sums || !match(pkg, /^[A-Za-z0-9_-]+$/) || !access(`/etc/config/${pkg}`)) return; const p = popen(`${config.uci} show ${pkg} 2>/dev/null`); const sum = md5(p.read('all') ?? ''); p.close(); const path = `/var/run/config.check/${pkg}`; const lines = filter(split(trim(sums), '\n'), (l) => length(l) && !match(l, regexp(` ${path}$`))); push(lines, `${sum} ${path}`); writefile(md5file, join('\n', lines) + '\n'); } function config_changed(conn, pkg) { refresh_baseline(pkg); for (let service in triggers[pkg] ?? []) pending[service] = true; if (length(pending) && !running) reloader.set(1000); // Like procd: broadcast it for anyone listening. conn.event('config.change', { config: pkg }); } // --- system board / info ------------------------------------------------ function readline(path) { const s = readfile(path); return s ? trim(split(s, '\n')[0]) : null; } function kv_file(path, sep) { const res = {}; for (let line in split(readfile(path) ?? '', '\n')) { const i = index(line, sep); if (i > 0) res[trim(substr(line, 0, i))] = trim(substr(line, i + 1)); } return res; } function rootfs_type() { let type = null; for (let line in split(readfile('/proc/mounts') ?? '', '\n')) { const f = split(line, ' '); if (f[1] == '/' && f[0] != 'rootfs') type = f[2]; } return type; } function board() { const cpu = kv_file('/proc/cpuinfo', ':'); const res = { kernel: readline('/proc/sys/kernel/osrelease'), hostname: readline('/proc/sys/kernel/hostname'), // procd's aarch64 string; "model name" etc. elsewhere. system: cpu['CPU revision'] != null ? sprintf('ARMv8 Processor rev %d', hex(cpu['CPU revision'])) : cpu['model name'] ?? cpu['system type'] ?? 'unknown', }; // Device tree, as procd: the model string, and the first compatible // entry with commas as dashes ("raspberrypi-4-model-b"). const model = readline('/proc/device-tree/model'); if (model) res.model = replace(model, /\0+$/, ''); const compat = readfile('/proc/device-tree/compatible'); if (compat) res.board_name = replace(split(compat, '\0')[0], /,/g, '-'); res.rootfs_type = rootfs_type(); res.release = config.release ?? {}; return res; } function meminfo() { const m = kv_file('/proc/meminfo', ':'); const bytes = (k) => +split(m[k] ?? '0', ' ')[0] * 1024; return { memory: { total: bytes('MemTotal'), free: bytes('MemFree'), shared: bytes('Shmem'), buffered: bytes('Buffers'), available: bytes('MemAvailable'), cached: bytes('Cached'), }, swap: { total: bytes('SwapTotal'), free: bytes('SwapFree'), }, }; } function fsinfo(path) { const s = statvfs(path); if (!s) return null; const k = (n) => int(n * s.frsize / 1024); return { total: k(s.blocks), free: k(s.bfree), used: k(s.blocks - s.bfree), avail: k(s.bavail), }; } function info() { const now = time(); const mem = meminfo(); // sysinfo(2) loads: fixed point, 1 << 16 = 1.0. const load = map(slice(split(readline('/proc/loadavg'), ' '), 0, 3), (l) => int(+l * 65536)); const res = { // Seconds since the epoch, shifted by the UTC offset (procd: // now + tm_gmtoff). localtime: now + (now - timelocal(gmtime(now))), uptime: int(+split(readline('/proc/uptime'), ' ')[0]), load, memory: mem.memory, }; for (let fs in [ [ '/', 'root' ], [ '/tmp', 'tmp' ] ]) { const i = fsinfo(fs[0]); if (i) res[fs[1]] = i; } res.swap = mem.swap; return res; } // --- ubus --------------------------------------------------------------- uloop.init(); const conn = connect() ?? die('cannot connect to ubus\n'); // ucode rejects arguments a method doesn't declare (INVALID_ARGUMENT), and // calls through uhttpd/rpcd (LuCI) carry the caller's session id: every // method declares it. const session = { ubus_rpc_session: '' }; const service_obj = conn.publish('service', { event: { args: { ...session, type: '', data: {} }, call: (req) => { const ev = req.args.type, data = req.args.data; if (ev == null || data == null) return req.reply(null, 2); // UBUS_STATUS_INVALID_ARGUMENT if (ev == 'config.change' && type(data.package) == 'string') config_changed(conn, data.package); req.reply(null, 0); }, }, // Data that procd services register (procd_open_data), e.g. firewall // rules for fw4; none here. fw4 calls it on every start and reload. get_data: { args: { ...session, name: '', instance: '', type: '' }, call: () => ({}), }, }); const system_obj = conn.publish('system', { board: { args: session, call: () => board() }, info: { args: session, call: () => info() }, reboot: { args: session, call: (req) => { req.reply(null, 0); log('reboot requested over ubus'); uloop.timer(1000, () => system([ config.reboot ])); }, }, }); if (!service_obj || !system_obj) die(`cannot publish objects: ${conn.error()}\n`); log(`running; triggers for: ${join(' ', sort(keys(triggers)))}`); uloop.run();