mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 11:55:13 +08:00
🎨 Sync 2026-08-19 20:43:52
This commit is contained in:
1 parent
78fa165967
commit
c6bde98f6f
17 files changed
+4255
No files matched your search
Executable
+184
@@ -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}`]);
|
||||
Executable
+396
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user