mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 19:05:11 +08:00
397 lines
13 KiB
Bash
Executable File
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
|