Files
op-packages/librespeed-common/files/librespeed.uc
T

323 lines
7.9 KiB
Ucode

#!/usr/bin/env ucode
// ubus interface for LibreSpeed measurements.
//
// Runs inside rpcd, which is exactly why it never measures anything itself: a
// measurement takes tens of seconds and would stall rpcd's event loop -- and
// with it every rpcd consumer on the system. Anything long-lived is handed to
// librespeed-run as a detached process.
'use strict';
import { open, readfile, stat } from 'fs';
import { cursor } from 'uci';
const STATE_DIR = '/tmp/librespeed';
const STATE = `${STATE_DIR}/state.json`;
const RESULT = `${STATE_DIR}/result.json`;
const LOCK = '/var/lock/librespeed.lock';
const RUN = '/usr/libexec/librespeed-run';
function read_json(path) {
const text = readfile(path);
if (text == null)
return null;
let value = null;
try {
value = json(text);
}
catch (e) {
value = null;
}
return value;
}
// The process group of a pid, read from /proc: the runner is spawned detached
// (start-stop-daemon -b or setsid), so its group holds the whole measurement
// tree and nothing else. Parsed after the comm field's closing parenthesis,
// the one place a process can put spaces.
function pgid_of(pid) {
const st = readfile(`/proc/${pid}/stat`);
if (!st)
return 0;
const f = split(trim(substr(st, rindex(st, ')') + 1)), ' ');
return int(f[2] ?? 0);
}
// The lock says whether a measurement runs, not a field in a file: a process
// that dies takes its lock with it, so there is no stale state to age out.
function is_running() {
const f = open(LOCK, 'r');
if (!f)
return false;
const acquired = f.lock('xn');
if (acquired)
f.lock('u');
f.close();
return !acquired;
}
function config_get(uci, section, option, fallback) {
const v = uci.get('librespeed', section, option);
return (v == null || v == '') ? fallback : v;
}
// Next occurrences of the drawn cron line, computed here rather than in the
// browser: the schedule fires in the router's timezone, and the browser may
// well sit in another one. Understands only the shapes librespeed.init
// emits: numbers, ranges, ranges with a step, star, and comma lists.
function cron_next(line, count) {
const f = split(trim(line ?? ''), /\s+/);
if (length(f) < 5)
return [];
const match_field = function(pat, val) {
for (let part in split(pat, ',')) {
let step = 1;
let m = match(part, /^(.+)\/([0-9]+)$/);
if (m) {
part = m[1];
step = int(m[2]);
}
let a, b;
if (part == '*') {
a = 0;
b = 59;
}
else {
m = match(part, /^([0-9]+)-([0-9]+)$/);
if (m) {
a = int(m[1]);
b = int(m[2]);
}
else {
a = int(part);
b = a;
}
}
if (val >= a && val <= b && (val - a) % step == 0)
return true;
}
return false;
};
const out = [];
let t = time();
t -= t % 60;
for (let i = 0; i < 8 * 24 * 60 && length(out) < count; i++) {
t += 60;
const lt = localtime(t);
// % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0.
if (match_field(f[0], lt.min) && match_field(f[1], lt.hour) &&
match_field(f[4], lt.wday % 7))
push(out, t);
}
return out;
}
const methods = {
start: {
call: function() {
if (is_running())
return { error: 'already running' };
if (!stat(RUN))
return { error: 'not installed' };
// Detached: the frontend polls status instead of waiting here.
system(`start-stop-daemon -S -b -x ${RUN} >/dev/null 2>&1 || ( setsid ${RUN} >/dev/null 2>&1 & )`);
return { started: true };
}
},
stop: {
call: function() {
const st = read_json(STATE);
// Kill the whole process group, not just the wrapper shell:
// librespeed-cli and the sampler must die with it, or a "stopped"
// answer would leave the measurement running on inherited fds.
// busybox kill takes the negative pgid without `--`.
if (is_running() && st?.pid) {
const pg = pgid_of(int(st.pid));
if (pg > 0)
system(`kill -TERM -${pg} 2>/dev/null`);
else
system(`kill -TERM ${int(st.pid)} 2>/dev/null`);
}
return { stopped: true };
}
},
status: {
call: function() {
const st = read_json(STATE) ?? {};
if (is_running()) {
const out = { running: true, phase: st.phase ?? '' };
if (st.pid)
out.pid = int(st.pid);
if (st.started)
out.started = int(st.started);
if (st.mbps != null)
out.mbps = st.mbps + 0.0;
if (st.progress != null)
out.progress = int(st.progress);
return out;
}
const out = { running: false, last_error: st.last_error ?? '' };
if (st.last_finished)
out.last_finished = int(st.last_finished);
return out;
}
},
result: {
call: function() {
return read_json(RESULT) ?? {};
}
},
// Contract: one response comes from exactly one source. A range that fits
// the raw retention window returns raw measurements; an older range is
// served from the daily archive at 1d resolution (completed days only, so
// today is absent there). The two never mix in one response --
// `resolution` names the source, and a consumer comparing two windows
// must compare like with like.
history: {
args: { from: 0, to: 0, limit: 0 },
call: function(request) {
const from = int(request.args?.from ?? 0);
const to = int(request.args?.to ?? 0);
const limit = int(request.args?.limit ?? 0);
const uci = cursor();
const raw_path = config_get(uci, 'history', 'path',
`${STATE_DIR}/history.jsonl`);
const archive_path = config_get(uci, 'history', 'archive_path', '');
const raw_days = int(config_get(uci, 'history', 'retention', '30d')) || 30;
uci.unload('librespeed');
// Ranges the raw window can answer come from raw; anything reaching
// further back is served from the daily archive when one is kept.
// The hour of slack keeps the boundary request -- "the last 30
// days" against a 30-day window -- from flapping between sources
// over clock skew.
let resolution = 'raw';
let path = raw_path;
if (archive_path != '' && stat(archive_path) &&
(from == 0 || from < time() - raw_days * 86400 - 3600)) {
resolution = '1d';
path = archive_path;
}
const entries = [];
const f = open(path, 'r');
if (f) {
for (let line = f.read('line'); length(line); line = f.read('line')) {
let e = null;
try {
e = json(line);
}
catch (err) {
continue;
}
if (from > 0 && int(e?.epoch ?? 0) < from)
continue;
if (to > 0 && int(e?.epoch ?? 0) > to)
continue;
push(entries, e);
}
f.close();
}
// Newest N, still oldest first.
const kept = (limit > 0 && length(entries) > limit)
? slice(entries, -limit) : entries;
return { resolution: resolution, entries: kept };
}
},
config: {
call: function() {
const uci = cursor();
// The drawn schedule lives in the crontab, not in UCI: for a daily
// interval the time is picked at sync. Handing the line out lets
// the frontend show when measurements will actually run.
let cron = '';
const cf = open('/etc/crontabs/root', 'r');
if (cf) {
for (let line = cf.read('line'); length(line); line = cf.read('line'))
if (index(line, '/usr/libexec/librespeed-run') >= 0)
cron = trim(line);
cf.close();
}
const out = {
interface: config_get(uci, 'main', 'interface', 'wan'),
server: config_get(uci, 'main', 'server', 'auto'),
scheme: config_get(uci, 'main', 'scheme', 'auto'),
server_list: config_get(uci, 'main', 'server_list', ''),
schedule: {
enabled: config_get(uci, 'schedule', 'enabled', '0') == '1',
interval: config_get(uci, 'schedule', 'interval', '1d'),
days: config_get(uci, 'schedule', 'days', '*'),
hours: config_get(uci, 'schedule', 'hours', ''),
cron: cron,
next_runs: cron != '' ? cron_next(cron, 3) : []
},
history: {
enabled: config_get(uci, 'history', 'enabled', '1') != '0',
path: config_get(uci, 'history', 'path',
`${STATE_DIR}/history.jsonl`),
retention: config_get(uci, 'history', 'retention', '30d')
}
};
uci.unload('librespeed');
return out;
}
}
};
return { librespeed: methods };