Files
ssmithxandClaude Opus 5.5 8b935de6ca LuCI: rpcd, uhttpd (HTTPS), and the web interface on the Pi
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>
2026-10-15 10:27:12 -04:00

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();