mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 10:54:46 +08:00
323 lines
7.9 KiB
Ucode
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 };
|