Packages: rpcd (profile-rooted plugin/ACL dirs, iwinfo plugin, headers), ustream-ssl (OpenSSL), uhttpd (ubus + ucode handlers), iwinfo, lucihttp, cgi-io, rpcd-mod-luci and luci (luci-base, status/system/network, Bootstrap, luci-app-firewall) from one LuCI checkout, with server-side FHS paths rewritten to the system profile. Services: rpcd-service-type, uhttpd-service-type (self-signed cert at first start), luci-service-type (dispatcher at /cgi-bin/luci, UCI seeds and uci-defaults, symlinks for the OpenWrt paths browser views exec and ACLs name). uci-service-type takes extensions. netifd gains ifup/ifdown; firewall4 the fw3 link LuCI's menu looks for. procd-compat: methods declare ubus_rpc_session (ucode rejects undeclared arguments, PITFALLS #41); config.change refreshes reload_config's digest so rpcd-applied changes don't hide later ones. Checked in headless Chromium: all menu pages load without errors; Save & Apply with confirm and with automatic rollback reloads the firewall (PITFALLS #42). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
7.4 KiB
Ucode
256 lines
7.4 KiB
Ucode
// 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();
|