librespeed-common: add measurement backend

Add a common backend for running librespeed-cli measurements on a router.

The package provides a locked measurement runner, live progress state, and
an rpcd ucode plugin exposing start/stop/status/result/history/config over
ubus. Scheduled measurements are managed through cron and can run within a
configurable time window. Optional daily aggregation keeps longer-term
history compact.

Raw measurements are kept in JSONL for a configurable retention period.
When persistent archive storage is configured, completed days are reduced
to daily min/avg/max values.

Signed-off-by: Josef Schlehofer <pepe.schlehofer@gmail.com>
This commit is contained in:
Josef Schlehofer committed 2026-09-03 14:53:08 +02:00
1 parent 6b1b273e32
commit 08da28b3e8
8 files changed
+1608

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
Valid-License-Identifier: GPL-2.0-only
Valid-License-Identifier: GPL-2.0
Valid-License-Identifier: GPL-2.0-or-later
Valid-License-Identifier: GPL-2.0+
SPDX-URL: https://spdx.org/licenses/GPL-2.0.html
Usage-Guide:
To use this license in source code, put one of the following SPDX
tag/value pairs into a comment according to the placement
guidelines in the licensing rules documentation.
For 'GNU General Public License (GPL) version 2 only' use:
SPDX-License-Identifier: GPL-2.0-only
For 'GNU General Public License (GPL) version 2 or any later version' use:
SPDX-License-Identifier: GPL-2.0-or-later
License-Text:
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
+78
View File
@@ -0,0 +1,78 @@
#
# Copyright (C) 2026 Josef Schlehofer
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=librespeed-common
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
PKG_LICENSE:=GPL-2.0-only
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/librespeed-common
SECTION:=utils
CATEGORY:=Utilities
TITLE:=LibreSpeed measurement orchestration
DEPENDS:=+librespeed-cli +rpcd-mod-ucode +jsonfilter +ucode +ucode-mod-fs \
+ucode-mod-uci +jshn
PKGARCH:=all
endef
define Package/librespeed-common/description
Runs LibreSpeed measurements from UCI configuration: a single entry point
used both interactively and from cron, a schedule kept in step with UCI by
the init script, and results recorded as JSON for anything that wants to
read them, such as luci-app-librespeed, and a ubus interface for anything else.
endef
define Package/librespeed-common/conffiles
/etc/config/librespeed
endef
# rpcd only enumerates its plugin directory at startup, so the librespeed ubus
# object appears (and disappears) with a reload, not with the file.
define Package/librespeed-common/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
define Package/librespeed-common/postrm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
# No source archive to unpack: the license text ships beside the Makefile and
# is staged so PKG_LICENSE_FILES points at a real file.
define Build/Prepare
$(INSTALL_DATA) ./LICENSE $(PKG_BUILD_DIR)/
endef
define Build/Compile
endef
define Package/librespeed-common/install
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./files/librespeed-run $(1)/usr/libexec/librespeed-run
$(INSTALL_BIN) ./files/librespeed-aggregate $(1)/usr/libexec/librespeed-aggregate
$(SED) 's/%%VERSION%%/$(PKG_VERSION)-$(PKG_RELEASE)/g' \
$(1)/usr/libexec/librespeed-run \
$(1)/usr/libexec/librespeed-aggregate
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
$(INSTALL_DATA) ./files/librespeed.uc $(1)/usr/share/rpcd/ucode/librespeed.uc
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/librespeed.config $(1)/etc/config/librespeed
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/librespeed.init $(1)/etc/init.d/librespeed
endef
$(eval $(call BuildPackage,librespeed-common))
+200
View File
@@ -0,0 +1,200 @@
#!/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, rename, mkdir, unlink, error } 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. Written by hand
// rather than writefile(), which drops fclose()'s status and would let a
// full disk truncate the archive in silence -- flush() is where a short
// write surfaces. A failed write goes to syslog: this runs from cron,
// where stderr has nowhere to go. The reason comes along, since a missing
// mount, a read-only filesystem and a full disk each want something
// different from whoever reads that log.
const af = open(tmp, 'w');
let wrote = af != null && af.write(out) == length(out);
if (af) {
wrote = af.flush() != null && wrote;
af.close();
}
if (wrote)
rename(tmp, archive_path);
else {
system(['logger', '-t', 'librespeed',
`aggregate: cannot write ${tmp}: ${error()}`]);
unlink(tmp);
}
+417
View File
@@ -0,0 +1,417 @@
#!/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
# The runner may die without reaching its kill (SIGKILL, a trap that
# never ran); without this check the sampler would rewrite state.json
# forever and mask every later run's result.
kill -0 $$ 2>/dev/null || return 0
# An idle second keeps the last phase: the frontend reads an empty
# phase as "not started yet" and would collapse mid-run.
if [ "$1" = "-" ]; then
write_state 1 $$ "$started" "" "" "$phase" ""
else
phase=$1
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"
# Both halves are checked before the arithmetic: a non-numeric value there
# is a fatal error in ash, and the run would die before writing any state.
case "$c_id" in ""|*[!0-9]*) c_id="" ;; esac
case "$c_epoch" in ""|*[!0-9]*) c_id="" ;; esac
[ -n "$c_id" ] && [ $(( $(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 '[ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM
sampler_pid=
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"
if [ -z "$out" ]; then
# tail succeeds on an empty file, so the substitution guards the
# value, not the command: an empty log still reports a failure.
err=$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null)
fail "${err:-measurement failed}"
fi
else
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
if [ "$rc" != 0 ]; then
err=$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null)
fail "${err:-measurement failed (exit $rc)}"
fi
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.
# jshn writes '"epoch": 175...' with a space, so the match is
# tolerant and the digits are extracted rather than offset-counted.
expired=$(awk -v c="$cutoff" '
match($0, /"epoch":[[:space:]]*[0-9]+/) {
v = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", v)
if (v + 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":[[:space:]]*[0-9]+/) {
v = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", v)
if (v + 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]*) ;;
*) atomic_write "$CHOICE_CACHE" "$(date +%s) $new_id" ;;
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,148 @@
#!/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.
# Every comma-separated part must be a number or an ascending range within
# [0, max]; the caller already reduced the alphabet to digits, commas and
# dashes.
valid_field() {
local val="$1" max="$2" part a b oifs="$IFS"
IFS=','
for part in $val; do
IFS="$oifs"
a=${part%-*}; b=${part#*-}
# Each half on its own: concatenated, '5-' and '-5' hide their empty
# side and the arithmetic below would print a shell error where the
# caller promises a silent fallback.
case "$a" in ''|*[!0-9]*) return 1 ;; esac
case "$b" in ''|*[!0-9]*) return 1 ;; esac
[ "$a" -le "$max" ] && [ "$b" -le "$max" ] && [ "$a" -le "$b" ] || return 1
done
IFS="$oifs"
return 0
}
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.
# Values are checked as well as characters: '99' is made of digits, but
# as an hour it would turn a daily schedule into an hourly one.
case "$days" in *[!0-9,-]*|'') days='*' ;; esac
if [ "$days" != '*' ] && ! valid_field "$days" 6; then
days='*'
fi
case "$hours" in *[!0-9-]*) hours='' ;; esac
if [ -n "$hours" ] && ! valid_field "$hours" 23; then
hours=''
fi
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
}
+357
View File
@@ -0,0 +1,357 @@
#!/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.
// Hoisted: ucode recompiles a regex literal on every evaluation, and these
// two dominate cron_next's cost inside rpcd, which must never stall.
const CRON_STEP = /^(.+)\/([0-9]+)$/;
const CRON_RANGE = /^([0-9]+)-([0-9]+)$/;
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, CRON_STEP);
if (m) {
part = m[1];
step = int(m[2]);
}
let a, b;
if (part == '*') {
a = 0;
b = 59;
}
else {
m = match(part, CRON_RANGE);
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;
// A miss skips the rest of the day or hour instead of walking its
// minutes; that keeps this cheap inside rpcd's event loop, and cheap
// enough for a five-week horizon, which a weekly schedule needs to
// fill three rows where eight days could not. The day jump aims at
// 23:00, not midnight: it is computed in local minutes but applied as
// real ones, and across a spring-forward that lands an hour long --
// from 23:00 the hour branch walks the last hour and cannot overshoot
// into minutes that were never examined.
for (let i = 0; i < 35 * 24 * 60 && length(out) < count; ) {
t += 60;
const lt = localtime(t);
let skip;
// % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0.
if (!match_field(f[4], lt.wday % 7)) {
skip = (24 - lt.hour) * 60 - lt.min - 60;
if (skip <= 0)
skip = 60 - lt.min;
}
else if (!match_field(f[1], lt.hour))
skip = 60 - lt.min;
else {
if (match_field(f[0], lt.min))
push(out, t);
i++;
continue;
}
t += (skip - 1) * 60;
i += skip;
}
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);
// Elapsed is computed here, on the clock that stamped
// started: the browser's clock may sit anywhere.
out.elapsed = time() - 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: {
// What the crontab holds, not what UCI intends: a
// hand-set 'true' satisfies the init script's bool but
// not a string compare, and the page would say No while
// cron fires. The line is the one source of truth.
enabled: cron != '',
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 };
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# The package ships scripts and an rpcd plugin, no binary of its own, so the
# generic --version probe cannot apply; check the installed pieces instead.
fail() { echo "FAIL: $1"; exit 1; }
[ -x /usr/libexec/librespeed-run ] || fail "librespeed-run not installed"
[ -x /usr/libexec/librespeed-aggregate ] || fail "librespeed-aggregate not installed"
[ -f /usr/share/rpcd/ucode/librespeed.uc ] || fail "rpcd plugin not installed"
[ -f /etc/config/librespeed ] || fail "UCI config not installed"
[ -x /etc/init.d/librespeed ] || fail "init script not installed"
sh -n /usr/libexec/librespeed-run || fail "librespeed-run does not parse"
/usr/libexec/librespeed-run --version | grep librespeed-common \
|| fail "librespeed-run --version"
/usr/libexec/librespeed-aggregate --version | grep librespeed-common \
|| fail "librespeed-aggregate --version"
sh -n /etc/init.d/librespeed || fail "init script does not parse"
# The plugin file has no side effects at load time: it defines its methods
# and returns them. Run as a file, the way rpcd loads it -- include() cannot
# be used here, it rejects the module import statements the plugin needs.
ucode /usr/share/rpcd/ucode/librespeed.uc >/dev/null \
|| fail "rpcd plugin does not load"
# Retention must recognise epoch as jshn actually writes it -- with a space
# after the colon. The fixture comes from json_dump itself, so the check
# breaks if either side changes shape.
line=$(. /usr/share/libubox/jshn.sh; json_init; json_add_int epoch 1; json_dump) \
|| fail "jshn not usable"
echo "$line" | awk 'match($0, /"epoch":[[:space:]]*[0-9]+/) { ok = 1 }
{ print }
END { exit !ok }' || fail "retention regex does not match jshn output"
echo "librespeed-common: installed files OK"