🎨 Sync 2026-08-19 20:43:52

This commit is contained in:
github-actions[bot] committed 2026-08-19 20:43:52 +08:00
1 parent 78fa165967
commit c6bde98f6f
17 files changed
+4255

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env ucode
// Reduces completed days of raw measurement history to one line per day --
// min/avg/max over that day's samples, not a measurement itself -- and
// appends them to the archive on persistent storage. Runs from cron shortly
// after midnight; librespeed.init keeps that entry in step with UCI.
//
// Only days strictly before today are archived: a day's aggregate is written
// once and never revisited, which is what makes reruns idempotent without any
// marker file -- a day already present in the archive is simply skipped.
// Today's raw measurements stay in RAM only; if power is lost they are gone,
// which the Settings page says out loud.
'use strict';
import { open, readfile, writefile, rename, mkdir } from 'fs';
// Packaging checks probe every executable for these.
if (length(ARGV) > 0) {
if (ARGV[0] == '--version') {
print("librespeed-common %%VERSION%%\n");
exit(0);
}
print("Usage: librespeed-aggregate\n" +
"Reduces completed days of measurement history to daily min/avg/max\n" +
"aggregates. Runs from cron; takes no arguments.\n");
exit(0);
}
import { cursor } from 'uci';
const METRICS = [ 'download_mbps', 'upload_mbps', 'ping_ms', 'jitter_ms' ];
const uci = cursor();
function conf(section, option, fallback) {
const v = uci.get('librespeed', section, option);
return (v == null || v == '') ? fallback : v;
}
if (conf('history', 'enabled', '1') == '0')
exit(0);
const raw_path = conf('history', 'path', '/tmp/librespeed/history.jsonl');
const archive_path = conf('history', 'archive_path', '');
const archive_days = int(conf('history', 'archive_retention', '365d')) || 365;
if (archive_path == '')
exit(0);
function read_lines(path) {
const out = [];
const f = open(path, 'r');
if (!f)
return out;
for (let line = f.read('line'); length(line); line = f.read('line')) {
try {
push(out, json(line));
}
catch (e) {
continue;
}
}
f.close();
return out;
}
function day_key(epoch) {
const lt = localtime(epoch);
return sprintf('%04d-%02d-%02d', lt.year, lt.mon, lt.mday);
}
function day_start(key) {
const p = split(key, '-');
return timelocal({
year: int(p[0]), mon: int(p[1]), mday: int(p[2]),
hour: 0, min: 0, sec: 0
});
}
function round2(v) {
return int(v * 100 + 0.5) / 100.0;
}
const today = day_key(time());
// Which days the archive already holds. Entries carry the day in `timestamp`.
const archive = read_lines(archive_path);
const have = {};
for (let e in archive)
have[e.timestamp] = true;
// Group raw lines by local calendar day, completed days only.
const days = {};
for (let e in read_lines(raw_path)) {
const epoch = int(e?.epoch ?? 0);
if (!epoch)
continue;
const key = day_key(epoch);
if (key >= today || have[key])
continue;
days[key] = days[key] ?? [];
push(days[key], e);
}
let changed = false;
for (let key in sort(keys(days))) {
const entry = {
timestamp: key,
epoch: day_start(key),
samples: length(days[key])
};
for (let m in METRICS) {
let lo = null, hi = null, sum = 0.0, n = 0;
for (let e in days[key]) {
const v = e[m];
if (type(v) != 'double' && type(v) != 'int')
continue;
lo = (lo == null || v < lo) ? v : lo;
hi = (hi == null || v > hi) ? v : hi;
sum += v;
n++;
}
if (n > 0) {
// The mean lives in the plain field so a consumer that only knows
// raw entries keeps working; min and max sit beside it.
entry[m] = round2(sum / n);
entry[`${m}_min`] = lo;
entry[`${m}_max`] = hi;
}
}
push(archive, entry);
changed = true;
}
// Archive retention: integer comparison on the day-start epoch.
const cutoff = time() - archive_days * 86400;
const kept = filter(archive, e => int(e?.epoch ?? 0) >= cutoff);
if (length(kept) != length(archive))
changed = true;
if (!changed)
exit(0);
let tmp = `${archive_path}.tmp`;
let out = '';
for (let e in sort(kept, (a, b) => int(a.epoch) - int(b.epoch)))
out += sprintf('%J\n', e);
// The last component only, never the whole tree: archive_path commonly
// points at external storage, and with the mount down a recursive mkdir
// would build the path on the overlay and write every night's aggregate to
// internal flash, to be shadowed once the disk is back. Failing here leaves
// the location as the user prepared it.
const dir = replace(archive_path, /\/[^\/]+$/, '');
if (dir != '' && dir != archive_path)
mkdir(dir, 0o755);
// Atomic: a reader never sees a half-written archive. A failed write goes to
// syslog: this runs from cron, where stderr has nowhere to go.
if (writefile(tmp, out) != null)
rename(tmp, archive_path);
else
system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}`]);
+396
View File
@@ -0,0 +1,396 @@
#!/bin/sh
#
# Runs one LibreSpeed measurement and records the result.
#
# The only thing that starts a measurement: LuCI reaches it through rpcd, the
# scheduler calls it directly. Holding the lock for the whole run means a second
# invocation from either side fails instead of measuring against the first.
# Packaging checks probe every executable for these; a runner that ignored
# them would start a measurement instead of answering.
case "$1" in
--version)
echo "librespeed-common %%VERSION%%"
exit 0
;;
--help|-h)
cat <<'EOF'
Usage: librespeed-run
Runs one LibreSpeed measurement according to /etc/config/librespeed and
records the result for the ubus interface. Started by LuCI or cron; takes
no arguments.
EOF
exit 0
;;
esac
. /lib/functions.sh
. /lib/functions/network.sh
. /usr/share/libubox/jshn.sh
CLI=/usr/bin/librespeed-cli
STATE_DIR=/tmp/librespeed
STATE="$STATE_DIR/state.json"
RESULT="$STATE_DIR/result.json"
LOCK=/var/lock/librespeed.lock
mkdir -p "$STATE_DIR"
# Writes $2 to $1 without ever leaving a half-written file for a reader.
atomic_write() {
local target="$1" tmp="$1.$$"
printf '%s\n' "$2" > "$tmp" && mv "$tmp" "$target"
}
write_state() {
json_init
json_add_boolean running "$1"
[ -n "$2" ] && json_add_int pid "$2"
[ -n "$3" ] && json_add_int started "$3"
[ -n "$4" ] && json_add_int last_finished "$4"
json_add_string last_error "${5:-}"
json_add_string phase "${6:-}"
[ -n "$7" ] && json_add_double mbps "$7"
[ -n "$8" ] && json_add_int progress "$8"
atomic_write "$STATE" "$(json_dump)"
}
# Approximates progress from the interface byte counters, one sample a second.
#
# The fallback narrator for clients that cannot stream: it reads the whole
# interface, not the test, so it is honest only while the test dominates the
# link -- which is exactly the situation a progress line describes. Whichever
# direction carries the traffic names the phase.
sampler() {
local dev="$1" rx tx prx ptx phase mbps
[ -r "/sys/class/net/$dev/statistics/rx_bytes" ] || return 0
prx=$(cat "/sys/class/net/$dev/statistics/rx_bytes")
ptx=$(cat "/sys/class/net/$dev/statistics/tx_bytes")
while :; do
sleep 1
rx=$(cat "/sys/class/net/$dev/statistics/rx_bytes" 2>/dev/null) || return 0
tx=$(cat "/sys/class/net/$dev/statistics/tx_bytes" 2>/dev/null) || return 0
set -- $(awk -v rx="$rx" -v prx="$prx" -v tx="$tx" -v ptx="$ptx" 'BEGIN {
drx = (rx - prx) * 8 / 1000000
dtx = (tx - ptx) * 8 / 1000000
if (drx >= dtx && drx > 1) printf "download %.1f", drx
else if (dtx > 1) printf "upload %.1f", dtx
else printf "- -"
}')
prx=$rx; ptx=$tx
if [ "$1" = "-" ]; then
write_state 1 $$ "$started" "" "" "" ""
else
write_state 1 $$ "$started" "" "" "$1" "$2"
fi
done
}
fail() {
# A run that failed with the cached server drops the cache: the next run
# rediscovers instead of failing against the same dead choice forever.
[ "${used_cache:-0}" = 1 ] && rm -f "$LIST_CACHE" "$LIST_CACHE.src" "$CHOICE_CACHE"
write_state 0 "" "" "$(date +%s)" "$1"
logger -t librespeed "measurement failed: $1"
exit 1
}
# One measurement at a time. The descriptor stays open for the whole run, so the
# kernel releases the lock even if this script is killed -- there is no stale
# state to time out.
exec 9>"$LOCK"
flock -n 9 || {
echo "already running" >&2
exit 3
}
config_load librespeed
config_get iface main interface wan
config_get server main server auto
config_get scheme main scheme auto
config_get server_list main server_list ''
config_get_bool hist_enabled history enabled 1
config_get hist_path history path "$STATE_DIR/history.jsonl"
config_get hist_retention history retention 30d
# --interface takes a device, and UCI carries a logical interface name.
dev=""
network_get_device dev "$iface" 2>/dev/null
[ -x "$CLI" ] || fail "librespeed-cli is not installed"
# The Rust client reports progress as NDJSON under --json-stream; the Go one
# does not have the flag yet. Asking --help is one extra exec per measurement
# and keeps one script driving either client.
stream=0
if "$CLI" --help 2>&1 | grep -q -- '--json-stream'; then
stream=1
set -- "$CLI" --json-stream
else
set -- "$CLI" --json
fi
# The router-side counterpart of the web UI remembering its chosen server:
# after an automatic run the picked server's id and the downloaded list are
# cached, and later runs go straight to the same server with --server and
# --local-json instead of fetching the list and pinging everything on it.
LIST_CACHE="$STATE_DIR/servers.json"
CHOICE_CACHE="$STATE_DIR/server-choice"
CACHE_TTL=86400
# Where the list comes from: a self-hosted deployment -- Turris runs
# https://librespeed.turris.cz/servers.json -- replaces the official one.
LIST_URL="${server_list:-https://librespeed.org/backend-servers/servers.php}"
cache_fresh=0
c_id=""
if [ -s "$LIST_CACHE" ] && [ -s "$CHOICE_CACHE" ]; then
read -r c_epoch c_id < "$CHOICE_CACHE"
case "$c_id" in *[!0-9]*) c_id="" ;; esac
[ -n "$c_epoch" ] && [ $(( $(date +%s) - c_epoch )) -lt "$CACHE_TTL" ] || c_id=""
# A cache fetched from another list is no cache at all.
[ "$(cat "$LIST_CACHE.src" 2>/dev/null)" = "$LIST_URL" ] || c_id=""
fi
[ -n "$c_id" ] && cache_fresh=1
used_cache=0
if [ "$server" != "auto" ]; then
set -- "$@" --server "$server"
# An explicit id still needs the list to resolve it; the cached copy
# saves that download too.
if [ "$cache_fresh" = 1 ]; then
set -- "$@" --local-json "$LIST_CACHE"
used_cache=1
elif [ -n "$server_list" ]; then
set -- "$@" --server-json "$LIST_URL"
fi
elif [ "$cache_fresh" = 1 ]; then
set -- "$@" --server "$c_id" --local-json "$LIST_CACHE"
used_cache=1
elif [ -n "$server_list" ]; then
set -- "$@" --server-json "$LIST_URL"
fi
[ -n "$dev" ] && set -- "$@" --interface "$dev"
# TLS itself can bound the result on routers without AES acceleration, so the
# scheme is a measurement setting, not just a transport detail.
case "$scheme" in
https) set -- "$@" --secure ;;
http) set -- "$@" --insecure ;;
esac
# Never --bytes: it switches the report to MB/s and the history would end up
# holding two units that cannot be told apart afterwards.
started=$(date +%s)
write_state 1 $$ "$started"
# stop kills our whole process group, so librespeed-cli dies with us; ash runs
# this trap once the foreground child has exited, and it records that the run
# was stopped rather than pretending the measurement failed.
trap 'write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM
if [ "$stream" = 1 ]; then
# The while runs in a subshell, so the reports cannot come back in a
# variable; they land in a file instead. State updates are throttled to
# nothing -- one arrives a second and state.json lives in tmpfs.
rm -f "$STATE_DIR/reports.json"
"$@" 2>"$STATE_DIR/stderr.log" < /dev/null | while IFS= read -r line; do
case "$line" in
*'"event":"result"'*)
printf '%s' "$line" | jsonfilter -e '@.reports' \
> "$STATE_DIR/reports.json" 2>/dev/null
;;
*'"event":"progress"'*)
write_state 1 $$ "$started" "" "" \
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" \
"$(printf '%s' "$line" | jsonfilter -e '@.mbps' 2>/dev/null)" \
"$(printf '%s' "$line" | jsonfilter -e '@.progress' 2>/dev/null)"
;;
*'"event":"phase"'*)
write_state 1 $$ "$started" "" "" \
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" ""
;;
esac
done
# The pipeline's status is the reader's, so success is judged by what the
# stream delivered: a client that failed never emitted a result event.
out=$(cat "$STATE_DIR/reports.json" 2>/dev/null)
rm -f "$STATE_DIR/reports.json"
[ -n "$out" ] || \
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
else
sampler_pid=""
if [ -n "$dev" ]; then
sampler "$dev" &
sampler_pid=$!
fi
out=$("$@" 2>"$STATE_DIR/stderr.log" < /dev/null); rc=$?
[ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
[ "$rc" = 0 ] || \
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
fi
finished=$(date +%s)
[ -n "$out" ] || fail "no output from librespeed-cli"
# Both clients print an array of reports, one per server tested: the Go
# client marshals []report.JSONReport, and the Rust port mirrors that shape.
# jshn cannot load a bare array -- blobmsg wants an object at the top -- so the
# report array is wrapped before parsing. Found the hard way on a router: this
# is exactly the step no macOS test could reach.
json_load "{ \"reports\": $out }" 2>/dev/null || fail "unparseable output from librespeed-cli"
json_select reports 2>/dev/null || fail "unparseable output from librespeed-cli"
# The report is an array with one entry per server tested.
json_select 1 2>/dev/null || fail "empty report"
json_get_var ts timestamp
json_get_var ping ping
json_get_var jitter jitter
json_get_var download download
json_get_var upload upload
json_get_var bsent bytes_sent
json_get_var brecv bytes_received
json_get_var share share
srv_id=""; srv_name=""; srv_url=""
if json_select server 2>/dev/null; then
json_get_var srv_id id
json_get_var srv_name name
json_get_var srv_url url
json_select ..
fi
cli_ip=""; cli_org=""
if json_select client 2>/dev/null; then
json_get_var cli_ip ip
json_get_var cli_org org
json_select ..
fi
# The family of the address the backend saw is the family the test ran over.
# Derived here rather than asked of the CLI, so it works with any client; a
# redacted or missing address simply leaves the field out.
family=""
case "$cli_ip" in
*:*) family="ipv6" ;;
*.*) family="ipv4" ;;
esac
# Whether the run was encrypted is visible from the URL the CLI settled on;
# stored per measurement because the scheme option can change between runs.
proto=""
case "$srv_url" in
https:*) proto="https" ;;
http:*) proto="http" ;;
esac
# result.json -- the last completed measurement, not a database.
json_init
json_add_string timestamp "$ts"
json_add_int started "$started"
json_add_int finished "$finished"
json_add_string interface "$iface"
json_add_object server
# Absent until the CLI reports it; consumers treat null as unknown.
[ -n "$srv_id" ] && json_add_int id "$srv_id"
json_add_string name "$srv_name"
json_add_string url "$srv_url"
json_close_object
json_add_object client
json_add_string ip "$cli_ip"
json_add_string org "$cli_org"
json_close_object
[ -n "$family" ] && json_add_string family "$family"
[ -n "$proto" ] && json_add_string proto "$proto"
json_add_double download_mbps "$download"
json_add_double upload_mbps "$upload"
json_add_double ping_ms "$ping"
json_add_double jitter_ms "$jitter"
json_add_int bytes_sent "$bsent"
json_add_int bytes_received "$brecv"
json_add_string share "$share"
result="$(json_dump)"
atomic_write "$RESULT" "$result"
if [ "$hist_enabled" = "1" ]; then
mkdir -p "$(dirname "$hist_path")"
json_init
json_add_string timestamp "$ts"
json_add_int epoch "$finished"
json_add_string interface "$iface"
json_add_object server
[ -n "$srv_id" ] && json_add_int id "$srv_id"
json_add_string name "$srv_name"
json_add_string url "$srv_url"
json_close_object
[ -n "$family" ] && json_add_string family "$family"
[ -n "$proto" ] && json_add_string proto "$proto"
json_add_double download_mbps "$download"
json_add_double upload_mbps "$upload"
json_add_double ping_ms "$ping"
json_add_double jitter_ms "$jitter"
printf '%s\n' "$(json_dump)" >> "$hist_path"
# Retention. The epoch above makes this an integer comparison, so no date(1)
# runs here -- a year of history is thousands of lines and forking once per
# line is not something a router should be asked to do.
days=${hist_retention%d}
case "$days" in
''|*[!0-9]*) days=0 ;;
esac
if [ "$days" -gt 0 ]; then
cutoff=$(( finished - days * 86400 ))
# Compacting rewrites the whole file, so it happens in batches rather
# than whenever a single line falls out. Past the retention window every
# run expires something, and rewriting on each of them would push about
# a gigabyte a year through the flash instead of a few megabytes. The
# file therefore holds somewhat more than the window, and is trimmed
# once enough has accumulated to be worth the write.
expired=$(awk -v c="$cutoff" '
match($0, /"epoch":[0-9]+/) {
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) n++
}
END { print n + 0 }
' "$hist_path")
total=$(wc -l < "$hist_path")
if [ "$expired" -ge 50 ] || [ "$expired" -ge $(( total / 2 )) ] && [ "$expired" -gt 0 ]; then
tmp="$hist_path.$$"
awk -v c="$cutoff" '
match($0, /"epoch":[0-9]+/) {
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) next
}
{ print }
' "$hist_path" > "$tmp" && mv "$tmp" "$hist_path"
fi
fi
fi
write_state 0 "" "" "$finished" ""
# Refresh the server cache after the run, so the next one starts instantly:
# at most one list download a day, and the choice is the server this run
# actually used, looked up by the name the report carries.
if [ "$cache_fresh" = 0 ]; then
if uclient-fetch -q -T 15 -O "$LIST_CACHE.tmp" "$LIST_URL" 2>/dev/null \
&& [ -s "$LIST_CACHE.tmp" ]; then
mv "$LIST_CACHE.tmp" "$LIST_CACHE"
printf '%s\n' "$LIST_URL" > "$LIST_CACHE.src"
else
rm -f "$LIST_CACHE.tmp"
fi
if [ -s "$LIST_CACHE" ] && [ -n "$srv_name" ]; then
new_id=$(LIST="$LIST_CACHE" NAME="$srv_name" ucode -e '
let fs = require("fs");
let list = json(fs.readfile(getenv("LIST")) || "[]");
for (s in list)
if (s.name == getenv("NAME")) { print(s.id); break; }
' 2>/dev/null)
case "$new_id" in
''|*[!0-9]*) ;;
*) printf '%s %s\n' "$(date +%s)" "$new_id" > "$CHOICE_CACHE" ;;
esac
fi
fi
logger -t librespeed "measurement done: ${download} Mbps down, ${upload} Mbps up"
exit 0
+18
View File
@@ -0,0 +1,18 @@
config librespeed 'main'
option interface 'wan'
option server 'auto'
option scheme 'auto'
option server_list ''
config librespeed 'schedule'
option enabled '0'
option interval '1d'
option days '*'
option hours '2-5'
config librespeed 'history'
option enabled '1'
option path '/tmp/librespeed/history.jsonl'
option retention '30d'
option archive_path ''
option archive_retention '365d'
+120
View File
@@ -0,0 +1,120 @@
#!/bin/sh /etc/rc.common
# Keeps the scheduled-measurement cron entry in step with UCI. There is no
# daemon here: measurements are one-shot runs of librespeed-run, and the rpcd
# interface needs no service of its own.
START=95
USE_PROCD=1
CRONTAB=/etc/crontabs/root
RUN=/usr/libexec/librespeed-run
AGG=/usr/libexec/librespeed-aggregate
# Builds the cron time fields from UCI. For sub-daily intervals `hours`
# restricts when measurements may run; for a daily one it is the window a
# random time is drawn from, drawn here at sync time. That keeps the herd
# apart -- every router lands on its own minute -- without any process
# sleeping through the night, at the cost of re-drawing on every reload.
cron_expr() {
local interval="$1" days="$2" hours="$3"
local start end m h v
case "$interval" in
*m)
v=${interval%m}
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
[ "$v" -le 59 ] || return 1
echo "*/$v ${hours:-*} * * $days"
;;
*h)
v=${interval%h}
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
[ "$v" -le 23 ] || return 1
if [ -n "$hours" ]; then
echo "0 $hours/$v * * $days"
else
echo "0 */$v * * $days"
fi
;;
1d|24h)
start=${hours%-*}; end=${hours#*-}
[ -n "$hours" ] || { start=0; end=23; }
# A window wrapping midnight (22-4) is not supported; treat it as the
# single starting hour rather than guessing.
[ "$end" -ge "$start" ] 2>/dev/null || end=$start
# Time xor pid as the seed: seconds-granular srand() alone would give
# two syncs in the same second the same draw.
set -- $(awk -v a="$start" -v b="$end" -v s="$(( $(date +%s) ^ $$ ))" 'BEGIN {
srand(s)
printf "%d %d", int(rand() * 60), a + int(rand() * (b - a + 1))
}')
echo "$1 $2 * * $days"
;;
*)
return 1
;;
esac
}
restart_cron() {
# Unconditional: on a stock system the crontab dir starts empty, so crond
# is not running at all -- restart starts a stopped procd service, and a
# guard on "running" would mean the schedule never fires until reboot.
/etc/init.d/cron restart >/dev/null 2>&1
return 0
}
sync_cron() {
local enabled interval days hours expr hist_enabled archive_path
config_load librespeed
config_get_bool enabled schedule enabled 0
config_get interval schedule interval 1d
config_get days schedule days '*'
config_get hours schedule hours ''
config_get_bool hist_enabled history enabled 1
config_get archive_path history archive_path ''
# These end up verbatim in root's crontab, so anything unexpected becomes
# the harmless default rather than a cron field it was not meant to be.
case "$days" in *[!0-9,-]*) days='*' ;; '') days='*' ;; esac
case "$hours" in *[!0-9-]*) hours='' ;; esac
mkdir -p "${CRONTAB%/*}"
touch "$CRONTAB"
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
# The day completes at midnight; five past, it is reduced to one archive
# line. A missed run costs nothing to catch up: raw lives in RAM, so after
# a power cut there is nothing left to aggregate anyway.
if [ "$hist_enabled" = 1 ] && [ -n "$archive_path" ]; then
echo "5 0 * * * $AGG" >> "$CRONTAB"
fi
if [ "$enabled" = 1 ]; then
if expr=$(cron_expr "$interval" "$days" "$hours"); then
echo "$expr $RUN" >> "$CRONTAB"
else
logger -t librespeed "unsupported schedule interval '$interval'"
fi
fi
restart_cron
}
start_service() {
sync_cron
}
stop_service() {
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
restart_cron
}
service_triggers() {
procd_add_reload_trigger "librespeed"
}
reload_service() {
sync_cron
}
+322
View File
@@ -0,0 +1,322 @@
#!/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 };