mirror of
https://github.com/openwrt/packages.git
synced 2026-10-12 05:57:04 +04:00
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:
1 parent
6b1b273e32
commit
08da28b3e8
8 files changed
+1608
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Executable
+417
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
Executable
+35
@@ -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"
|
||||
Reference in new issue
Block a user