Files
op-packages/librespeed-common/files/librespeed-run
T

397 lines
13 KiB
Bash
Executable File

#!/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