diff --git a/utils/librespeed-common/LICENSE b/utils/librespeed-common/LICENSE new file mode 100644 index 0000000000000..4d90dc142dc6e --- /dev/null +++ b/utils/librespeed-common/LICENSE @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/utils/librespeed-common/Makefile b/utils/librespeed-common/Makefile new file mode 100644 index 0000000000000..e595271079dc7 --- /dev/null +++ b/utils/librespeed-common/Makefile @@ -0,0 +1,77 @@ +# +# 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 +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 + 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)) diff --git a/utils/librespeed-common/files/librespeed-aggregate b/utils/librespeed-common/files/librespeed-aggregate new file mode 100755 index 0000000000000..15cd753c40746 --- /dev/null +++ b/utils/librespeed-common/files/librespeed-aggregate @@ -0,0 +1,187 @@ +#!/usr/bin/env ucode +// Reduces completed days of raw measurement history to one line per day -- +// min/avg/max over that day's samples, not a measurement itself -- and +// appends them to the archive on persistent storage. Runs from cron shortly +// after midnight; librespeed.init keeps that entry in step with UCI. +// +// Only days strictly before today are archived: a day's aggregate is written +// once and never revisited, which is what makes reruns idempotent without any +// marker file -- a day already present in the archive is simply skipped. +// Today's raw measurements stay in RAM only; if power is lost they are gone, +// which the Settings page says out loud. + +'use strict'; + +import { open, readfile, writefile, rename, mkdir, 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. 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. +if (writefile(tmp, out) != null) + rename(tmp, archive_path); +else + system(['logger', '-t', 'librespeed', + `aggregate: cannot write ${tmp}: ${error()}`]); diff --git a/utils/librespeed-common/files/librespeed-run b/utils/librespeed-common/files/librespeed-run new file mode 100755 index 0000000000000..8de9059df9b39 --- /dev/null +++ b/utils/librespeed-common/files/librespeed-run @@ -0,0 +1,396 @@ +#!/bin/sh +# +# Runs one LibreSpeed measurement and records the result. +# +# The only thing that starts a measurement: LuCI reaches it through rpcd, the +# scheduler calls it directly. Holding the lock for the whole run means a second +# invocation from either side fails instead of measuring against the first. + +# Packaging checks probe every executable for these; a runner that ignored +# them would start a measurement instead of answering. +case "$1" in +--version) + echo "librespeed-common %%VERSION%%" + exit 0 + ;; +--help|-h) + cat <<'EOF' +Usage: librespeed-run +Runs one LibreSpeed measurement according to /etc/config/librespeed and +records the result for the ubus interface. Started by LuCI or cron; takes +no arguments. +EOF + exit 0 + ;; +esac + +. /lib/functions.sh +. /lib/functions/network.sh +. /usr/share/libubox/jshn.sh + +CLI=/usr/bin/librespeed-cli +STATE_DIR=/tmp/librespeed +STATE="$STATE_DIR/state.json" +RESULT="$STATE_DIR/result.json" +LOCK=/var/lock/librespeed.lock + +mkdir -p "$STATE_DIR" + +# Writes $2 to $1 without ever leaving a half-written file for a reader. +atomic_write() { + local target="$1" tmp="$1.$$" + printf '%s\n' "$2" > "$tmp" && mv "$tmp" "$target" +} + +write_state() { + json_init + json_add_boolean running "$1" + [ -n "$2" ] && json_add_int pid "$2" + [ -n "$3" ] && json_add_int started "$3" + [ -n "$4" ] && json_add_int last_finished "$4" + json_add_string last_error "${5:-}" + json_add_string phase "${6:-}" + [ -n "$7" ] && json_add_double mbps "$7" + [ -n "$8" ] && json_add_int progress "$8" + atomic_write "$STATE" "$(json_dump)" +} + +# Approximates progress from the interface byte counters, one sample a second. +# +# The fallback narrator for clients that cannot stream: it reads the whole +# interface, not the test, so it is honest only while the test dominates the +# link -- which is exactly the situation a progress line describes. Whichever +# direction carries the traffic names the phase. +sampler() { + local dev="$1" rx tx prx ptx phase mbps + [ -r "/sys/class/net/$dev/statistics/rx_bytes" ] || return 0 + prx=$(cat "/sys/class/net/$dev/statistics/rx_bytes") + ptx=$(cat "/sys/class/net/$dev/statistics/tx_bytes") + while :; do + sleep 1 + rx=$(cat "/sys/class/net/$dev/statistics/rx_bytes" 2>/dev/null) || return 0 + tx=$(cat "/sys/class/net/$dev/statistics/tx_bytes" 2>/dev/null) || return 0 + set -- $(awk -v rx="$rx" -v prx="$prx" -v tx="$tx" -v ptx="$ptx" 'BEGIN { + drx = (rx - prx) * 8 / 1000000 + dtx = (tx - ptx) * 8 / 1000000 + if (drx >= dtx && drx > 1) printf "download %.1f", drx + else if (dtx > 1) printf "upload %.1f", dtx + else printf "- -" + }') + prx=$rx; ptx=$tx + if [ "$1" = "-" ]; then + write_state 1 $$ "$started" "" "" "" "" + else + write_state 1 $$ "$started" "" "" "$1" "$2" + fi + done +} + +fail() { + # A run that failed with the cached server drops the cache: the next run + # rediscovers instead of failing against the same dead choice forever. + [ "${used_cache:-0}" = 1 ] && rm -f "$LIST_CACHE" "$LIST_CACHE.src" "$CHOICE_CACHE" + write_state 0 "" "" "$(date +%s)" "$1" + logger -t librespeed "measurement failed: $1" + exit 1 +} + +# One measurement at a time. The descriptor stays open for the whole run, so the +# kernel releases the lock even if this script is killed -- there is no stale +# state to time out. +exec 9>"$LOCK" +flock -n 9 || { + echo "already running" >&2 + exit 3 +} + +config_load librespeed +config_get iface main interface wan +config_get server main server auto +config_get scheme main scheme auto +config_get server_list main server_list '' +config_get_bool hist_enabled history enabled 1 +config_get hist_path history path "$STATE_DIR/history.jsonl" +config_get hist_retention history retention 30d + +# --interface takes a device, and UCI carries a logical interface name. +dev="" +network_get_device dev "$iface" 2>/dev/null + +[ -x "$CLI" ] || fail "librespeed-cli is not installed" + +# The Rust client reports progress as NDJSON under --json-stream; the Go one +# does not have the flag yet. Asking --help is one extra exec per measurement +# and keeps one script driving either client. +stream=0 +if "$CLI" --help 2>&1 | grep -q -- '--json-stream'; then + stream=1 + set -- "$CLI" --json-stream +else + set -- "$CLI" --json +fi +# The router-side counterpart of the web UI remembering its chosen server: +# after an automatic run the picked server's id and the downloaded list are +# cached, and later runs go straight to the same server with --server and +# --local-json instead of fetching the list and pinging everything on it. +LIST_CACHE="$STATE_DIR/servers.json" +CHOICE_CACHE="$STATE_DIR/server-choice" +CACHE_TTL=86400 +# Where the list comes from: a self-hosted deployment -- Turris runs +# https://librespeed.turris.cz/servers.json -- replaces the official one. +LIST_URL="${server_list:-https://librespeed.org/backend-servers/servers.php}" + +cache_fresh=0 +c_id="" +if [ -s "$LIST_CACHE" ] && [ -s "$CHOICE_CACHE" ]; then + read -r c_epoch c_id < "$CHOICE_CACHE" + case "$c_id" in *[!0-9]*) c_id="" ;; esac + [ -n "$c_epoch" ] && [ $(( $(date +%s) - c_epoch )) -lt "$CACHE_TTL" ] || c_id="" + # A cache fetched from another list is no cache at all. + [ "$(cat "$LIST_CACHE.src" 2>/dev/null)" = "$LIST_URL" ] || c_id="" +fi +[ -n "$c_id" ] && cache_fresh=1 + +used_cache=0 +if [ "$server" != "auto" ]; then + set -- "$@" --server "$server" + # An explicit id still needs the list to resolve it; the cached copy + # saves that download too. + if [ "$cache_fresh" = 1 ]; then + set -- "$@" --local-json "$LIST_CACHE" + used_cache=1 + elif [ -n "$server_list" ]; then + set -- "$@" --server-json "$LIST_URL" + fi +elif [ "$cache_fresh" = 1 ]; then + set -- "$@" --server "$c_id" --local-json "$LIST_CACHE" + used_cache=1 +elif [ -n "$server_list" ]; then + set -- "$@" --server-json "$LIST_URL" +fi +[ -n "$dev" ] && set -- "$@" --interface "$dev" +# TLS itself can bound the result on routers without AES acceleration, so the +# scheme is a measurement setting, not just a transport detail. +case "$scheme" in +https) set -- "$@" --secure ;; +http) set -- "$@" --insecure ;; +esac +# Never --bytes: it switches the report to MB/s and the history would end up +# holding two units that cannot be told apart afterwards. + +started=$(date +%s) +write_state 1 $$ "$started" + +# stop kills our whole process group, so librespeed-cli dies with us; ash runs +# this trap once the foreground child has exited, and it records that the run +# was stopped rather than pretending the measurement failed. +trap 'write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM + +if [ "$stream" = 1 ]; then + # The while runs in a subshell, so the reports cannot come back in a + # variable; they land in a file instead. State updates are throttled to + # nothing -- one arrives a second and state.json lives in tmpfs. + rm -f "$STATE_DIR/reports.json" + "$@" 2>"$STATE_DIR/stderr.log" < /dev/null | while IFS= read -r line; do + case "$line" in + *'"event":"result"'*) + printf '%s' "$line" | jsonfilter -e '@.reports' \ + > "$STATE_DIR/reports.json" 2>/dev/null + ;; + *'"event":"progress"'*) + write_state 1 $$ "$started" "" "" \ + "$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" \ + "$(printf '%s' "$line" | jsonfilter -e '@.mbps' 2>/dev/null)" \ + "$(printf '%s' "$line" | jsonfilter -e '@.progress' 2>/dev/null)" + ;; + *'"event":"phase"'*) + write_state 1 $$ "$started" "" "" \ + "$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" "" + ;; + esac + done + # The pipeline's status is the reader's, so success is judged by what the + # stream delivered: a client that failed never emitted a result event. + out=$(cat "$STATE_DIR/reports.json" 2>/dev/null) + rm -f "$STATE_DIR/reports.json" + [ -n "$out" ] || \ + fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')" +else + sampler_pid="" + if [ -n "$dev" ]; then + sampler "$dev" & + sampler_pid=$! + fi + out=$("$@" 2>"$STATE_DIR/stderr.log" < /dev/null); rc=$? + [ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null + [ "$rc" = 0 ] || \ + fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')" +fi + +finished=$(date +%s) + +[ -n "$out" ] || fail "no output from librespeed-cli" +# Both clients print an array of reports, one per server tested: the Go +# client marshals []report.JSONReport, and the Rust port mirrors that shape. +# jshn cannot load a bare array -- blobmsg wants an object at the top -- so the +# report array is wrapped before parsing. Found the hard way on a router: this +# is exactly the step no macOS test could reach. +json_load "{ \"reports\": $out }" 2>/dev/null || fail "unparseable output from librespeed-cli" +json_select reports 2>/dev/null || fail "unparseable output from librespeed-cli" + +# The report is an array with one entry per server tested. +json_select 1 2>/dev/null || fail "empty report" + +json_get_var ts timestamp +json_get_var ping ping +json_get_var jitter jitter +json_get_var download download +json_get_var upload upload +json_get_var bsent bytes_sent +json_get_var brecv bytes_received +json_get_var share share + +srv_id=""; srv_name=""; srv_url="" +if json_select server 2>/dev/null; then + json_get_var srv_id id + json_get_var srv_name name + json_get_var srv_url url + json_select .. +fi + +cli_ip=""; cli_org="" +if json_select client 2>/dev/null; then + json_get_var cli_ip ip + json_get_var cli_org org + json_select .. +fi + +# The family of the address the backend saw is the family the test ran over. +# Derived here rather than asked of the CLI, so it works with any client; a +# redacted or missing address simply leaves the field out. +family="" +case "$cli_ip" in +*:*) family="ipv6" ;; +*.*) family="ipv4" ;; +esac + +# Whether the run was encrypted is visible from the URL the CLI settled on; +# stored per measurement because the scheme option can change between runs. +proto="" +case "$srv_url" in +https:*) proto="https" ;; +http:*) proto="http" ;; +esac + +# result.json -- the last completed measurement, not a database. +json_init +json_add_string timestamp "$ts" +json_add_int started "$started" +json_add_int finished "$finished" +json_add_string interface "$iface" +json_add_object server + # Absent until the CLI reports it; consumers treat null as unknown. + [ -n "$srv_id" ] && json_add_int id "$srv_id" + json_add_string name "$srv_name" + json_add_string url "$srv_url" +json_close_object +json_add_object client + json_add_string ip "$cli_ip" + json_add_string org "$cli_org" +json_close_object +[ -n "$family" ] && json_add_string family "$family" +[ -n "$proto" ] && json_add_string proto "$proto" +json_add_double download_mbps "$download" +json_add_double upload_mbps "$upload" +json_add_double ping_ms "$ping" +json_add_double jitter_ms "$jitter" +json_add_int bytes_sent "$bsent" +json_add_int bytes_received "$brecv" +json_add_string share "$share" +result="$(json_dump)" +atomic_write "$RESULT" "$result" + +if [ "$hist_enabled" = "1" ]; then + mkdir -p "$(dirname "$hist_path")" + + json_init + json_add_string timestamp "$ts" + json_add_int epoch "$finished" + json_add_string interface "$iface" + json_add_object server + [ -n "$srv_id" ] && json_add_int id "$srv_id" + json_add_string name "$srv_name" + json_add_string url "$srv_url" + json_close_object + [ -n "$family" ] && json_add_string family "$family" + [ -n "$proto" ] && json_add_string proto "$proto" + json_add_double download_mbps "$download" + json_add_double upload_mbps "$upload" + json_add_double ping_ms "$ping" + json_add_double jitter_ms "$jitter" + printf '%s\n' "$(json_dump)" >> "$hist_path" + + # Retention. The epoch above makes this an integer comparison, so no date(1) + # runs here -- a year of history is thousands of lines and forking once per + # line is not something a router should be asked to do. + days=${hist_retention%d} + case "$days" in + ''|*[!0-9]*) days=0 ;; + esac + if [ "$days" -gt 0 ]; then + cutoff=$(( finished - days * 86400 )) + + # Compacting rewrites the whole file, so it happens in batches rather + # than whenever a single line falls out. Past the retention window every + # run expires something, and rewriting on each of them would push about + # a gigabyte a year through the flash instead of a few megabytes. The + # file therefore holds somewhat more than the window, and is trimmed + # once enough has accumulated to be worth the write. + expired=$(awk -v c="$cutoff" ' + match($0, /"epoch":[0-9]+/) { + if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) n++ + } + END { print n + 0 } + ' "$hist_path") + total=$(wc -l < "$hist_path") + + if [ "$expired" -ge 50 ] || [ "$expired" -ge $(( total / 2 )) ] && [ "$expired" -gt 0 ]; then + tmp="$hist_path.$$" + awk -v c="$cutoff" ' + match($0, /"epoch":[0-9]+/) { + if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) next + } + { print } + ' "$hist_path" > "$tmp" && mv "$tmp" "$hist_path" + fi + fi +fi + +write_state 0 "" "" "$finished" "" + +# Refresh the server cache after the run, so the next one starts instantly: +# at most one list download a day, and the choice is the server this run +# actually used, looked up by the name the report carries. +if [ "$cache_fresh" = 0 ]; then + if uclient-fetch -q -T 15 -O "$LIST_CACHE.tmp" "$LIST_URL" 2>/dev/null \ + && [ -s "$LIST_CACHE.tmp" ]; then + mv "$LIST_CACHE.tmp" "$LIST_CACHE" + printf '%s\n' "$LIST_URL" > "$LIST_CACHE.src" + else + rm -f "$LIST_CACHE.tmp" + fi + if [ -s "$LIST_CACHE" ] && [ -n "$srv_name" ]; then + new_id=$(LIST="$LIST_CACHE" NAME="$srv_name" ucode -e ' + let fs = require("fs"); + let list = json(fs.readfile(getenv("LIST")) || "[]"); + for (s in list) + if (s.name == getenv("NAME")) { print(s.id); break; } + ' 2>/dev/null) + case "$new_id" in + ''|*[!0-9]*) ;; + *) printf '%s %s\n' "$(date +%s)" "$new_id" > "$CHOICE_CACHE" ;; + esac + fi +fi +logger -t librespeed "measurement done: ${download} Mbps down, ${upload} Mbps up" +exit 0 diff --git a/utils/librespeed-common/files/librespeed.config b/utils/librespeed-common/files/librespeed.config new file mode 100644 index 0000000000000..3fa1afa0bf9bb --- /dev/null +++ b/utils/librespeed-common/files/librespeed.config @@ -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' diff --git a/utils/librespeed-common/files/librespeed.init b/utils/librespeed-common/files/librespeed.init new file mode 100644 index 0000000000000..8b9cb959f17c5 --- /dev/null +++ b/utils/librespeed-common/files/librespeed.init @@ -0,0 +1,120 @@ +#!/bin/sh /etc/rc.common +# Keeps the scheduled-measurement cron entry in step with UCI. There is no +# daemon here: measurements are one-shot runs of librespeed-run, and the rpcd +# interface needs no service of its own. + +START=95 +USE_PROCD=1 + +CRONTAB=/etc/crontabs/root +RUN=/usr/libexec/librespeed-run +AGG=/usr/libexec/librespeed-aggregate + +# Builds the cron time fields from UCI. For sub-daily intervals `hours` +# restricts when measurements may run; for a daily one it is the window a +# random time is drawn from, drawn here at sync time. That keeps the herd +# apart -- every router lands on its own minute -- without any process +# sleeping through the night, at the cost of re-drawing on every reload. +cron_expr() { + local interval="$1" days="$2" hours="$3" + local start end m h v + + case "$interval" in + *m) + v=${interval%m} + case "$v" in ''|0|*[!0-9]*) return 1 ;; esac + [ "$v" -le 59 ] || return 1 + echo "*/$v ${hours:-*} * * $days" + ;; + *h) + v=${interval%h} + case "$v" in ''|0|*[!0-9]*) return 1 ;; esac + [ "$v" -le 23 ] || return 1 + if [ -n "$hours" ]; then + echo "0 $hours/$v * * $days" + else + echo "0 */$v * * $days" + fi + ;; + 1d|24h) + start=${hours%-*}; end=${hours#*-} + [ -n "$hours" ] || { start=0; end=23; } + # A window wrapping midnight (22-4) is not supported; treat it as the + # single starting hour rather than guessing. + [ "$end" -ge "$start" ] 2>/dev/null || end=$start + # Time xor pid as the seed: seconds-granular srand() alone would give + # two syncs in the same second the same draw. + set -- $(awk -v a="$start" -v b="$end" -v s="$(( $(date +%s) ^ $$ ))" 'BEGIN { + srand(s) + printf "%d %d", int(rand() * 60), a + int(rand() * (b - a + 1)) + }') + echo "$1 $2 * * $days" + ;; + *) + return 1 + ;; + esac +} + +restart_cron() { + # Unconditional: on a stock system the crontab dir starts empty, so crond + # is not running at all -- restart starts a stopped procd service, and a + # guard on "running" would mean the schedule never fires until reboot. + /etc/init.d/cron restart >/dev/null 2>&1 + return 0 +} + +sync_cron() { + local enabled interval days hours expr hist_enabled archive_path + + config_load librespeed + config_get_bool enabled schedule enabled 0 + config_get interval schedule interval 1d + config_get days schedule days '*' + config_get hours schedule hours '' + config_get_bool hist_enabled history enabled 1 + config_get archive_path history archive_path '' + + # These end up verbatim in root's crontab, so anything unexpected becomes + # the harmless default rather than a cron field it was not meant to be. + case "$days" in *[!0-9,-]*) days='*' ;; '') days='*' ;; esac + case "$hours" in *[!0-9-]*) hours='' ;; esac + + mkdir -p "${CRONTAB%/*}" + touch "$CRONTAB" + sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB" + + # The day completes at midnight; five past, it is reduced to one archive + # line. A missed run costs nothing to catch up: raw lives in RAM, so after + # a power cut there is nothing left to aggregate anyway. + if [ "$hist_enabled" = 1 ] && [ -n "$archive_path" ]; then + echo "5 0 * * * $AGG" >> "$CRONTAB" + fi + + if [ "$enabled" = 1 ]; then + if expr=$(cron_expr "$interval" "$days" "$hours"); then + echo "$expr $RUN" >> "$CRONTAB" + else + logger -t librespeed "unsupported schedule interval '$interval'" + fi + fi + + restart_cron +} + +start_service() { + sync_cron +} + +stop_service() { + sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB" + restart_cron +} + +service_triggers() { + procd_add_reload_trigger "librespeed" +} + +reload_service() { + sync_cron +} diff --git a/utils/librespeed-common/files/librespeed.uc b/utils/librespeed-common/files/librespeed.uc new file mode 100644 index 0000000000000..261d0d693417d --- /dev/null +++ b/utils/librespeed-common/files/librespeed.uc @@ -0,0 +1,322 @@ +#!/usr/bin/env ucode +// ubus interface for LibreSpeed measurements. +// +// Runs inside rpcd, which is exactly why it never measures anything itself: a +// measurement takes tens of seconds and would stall rpcd's event loop -- and +// with it every rpcd consumer on the system. Anything long-lived is handed to +// librespeed-run as a detached process. + +'use strict'; + +import { open, readfile, stat } from 'fs'; +import { cursor } from 'uci'; + +const STATE_DIR = '/tmp/librespeed'; +const STATE = `${STATE_DIR}/state.json`; +const RESULT = `${STATE_DIR}/result.json`; +const LOCK = '/var/lock/librespeed.lock'; +const RUN = '/usr/libexec/librespeed-run'; + +function read_json(path) { + const text = readfile(path); + + if (text == null) + return null; + + let value = null; + + try { + value = json(text); + } + catch (e) { + value = null; + } + + return value; +} + +// The process group of a pid, read from /proc: the runner is spawned detached +// (start-stop-daemon -b or setsid), so its group holds the whole measurement +// tree and nothing else. Parsed after the comm field's closing parenthesis, +// the one place a process can put spaces. +function pgid_of(pid) { + const st = readfile(`/proc/${pid}/stat`); + + if (!st) + return 0; + + const f = split(trim(substr(st, rindex(st, ')') + 1)), ' '); + + return int(f[2] ?? 0); +} + +// The lock says whether a measurement runs, not a field in a file: a process +// that dies takes its lock with it, so there is no stale state to age out. +function is_running() { + const f = open(LOCK, 'r'); + + if (!f) + return false; + + const acquired = f.lock('xn'); + + if (acquired) + f.lock('u'); + + f.close(); + + return !acquired; +} + +function config_get(uci, section, option, fallback) { + const v = uci.get('librespeed', section, option); + + return (v == null || v == '') ? fallback : v; +} + +// Next occurrences of the drawn cron line, computed here rather than in the +// browser: the schedule fires in the router's timezone, and the browser may +// well sit in another one. Understands only the shapes librespeed.init +// emits: numbers, ranges, ranges with a step, star, and comma lists. +function cron_next(line, count) { + const f = split(trim(line ?? ''), /\s+/); + + if (length(f) < 5) + return []; + + const match_field = function(pat, val) { + for (let part in split(pat, ',')) { + let step = 1; + let m = match(part, /^(.+)\/([0-9]+)$/); + + if (m) { + part = m[1]; + step = int(m[2]); + } + + let a, b; + + if (part == '*') { + a = 0; + b = 59; + } + else { + m = match(part, /^([0-9]+)-([0-9]+)$/); + if (m) { + a = int(m[1]); + b = int(m[2]); + } + else { + a = int(part); + b = a; + } + } + + if (val >= a && val <= b && (val - a) % step == 0) + return true; + } + + return false; + }; + + const out = []; + let t = time(); + t -= t % 60; + + for (let i = 0; i < 8 * 24 * 60 && length(out) < count; i++) { + t += 60; + const lt = localtime(t); + + // % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0. + if (match_field(f[0], lt.min) && match_field(f[1], lt.hour) && + match_field(f[4], lt.wday % 7)) + push(out, t); + } + + return out; +} + +const methods = { + start: { + call: function() { + if (is_running()) + return { error: 'already running' }; + + if (!stat(RUN)) + return { error: 'not installed' }; + + // Detached: the frontend polls status instead of waiting here. + system(`start-stop-daemon -S -b -x ${RUN} >/dev/null 2>&1 || ( setsid ${RUN} >/dev/null 2>&1 & )`); + + return { started: true }; + } + }, + + stop: { + call: function() { + const st = read_json(STATE); + + // Kill the whole process group, not just the wrapper shell: + // librespeed-cli and the sampler must die with it, or a "stopped" + // answer would leave the measurement running on inherited fds. + // busybox kill takes the negative pgid without `--`. + if (is_running() && st?.pid) { + const pg = pgid_of(int(st.pid)); + + if (pg > 0) + system(`kill -TERM -${pg} 2>/dev/null`); + else + system(`kill -TERM ${int(st.pid)} 2>/dev/null`); + } + + return { stopped: true }; + } + }, + + status: { + call: function() { + const st = read_json(STATE) ?? {}; + + if (is_running()) { + const out = { running: true, phase: st.phase ?? '' }; + + if (st.pid) + out.pid = int(st.pid); + if (st.started) + out.started = int(st.started); + if (st.mbps != null) + out.mbps = st.mbps + 0.0; + if (st.progress != null) + out.progress = int(st.progress); + + return out; + } + + const out = { running: false, last_error: st.last_error ?? '' }; + + if (st.last_finished) + out.last_finished = int(st.last_finished); + + return out; + } + }, + + result: { + call: function() { + return read_json(RESULT) ?? {}; + } + }, + + // Contract: one response comes from exactly one source. A range that fits + // the raw retention window returns raw measurements; an older range is + // served from the daily archive at 1d resolution (completed days only, so + // today is absent there). The two never mix in one response -- + // `resolution` names the source, and a consumer comparing two windows + // must compare like with like. + history: { + args: { from: 0, to: 0, limit: 0 }, + call: function(request) { + const from = int(request.args?.from ?? 0); + const to = int(request.args?.to ?? 0); + const limit = int(request.args?.limit ?? 0); + + const uci = cursor(); + const raw_path = config_get(uci, 'history', 'path', + `${STATE_DIR}/history.jsonl`); + const archive_path = config_get(uci, 'history', 'archive_path', ''); + const raw_days = int(config_get(uci, 'history', 'retention', '30d')) || 30; + uci.unload('librespeed'); + + // Ranges the raw window can answer come from raw; anything reaching + // further back is served from the daily archive when one is kept. + // The hour of slack keeps the boundary request -- "the last 30 + // days" against a 30-day window -- from flapping between sources + // over clock skew. + let resolution = 'raw'; + let path = raw_path; + + if (archive_path != '' && stat(archive_path) && + (from == 0 || from < time() - raw_days * 86400 - 3600)) { + resolution = '1d'; + path = archive_path; + } + + const entries = []; + const f = open(path, 'r'); + + if (f) { + for (let line = f.read('line'); length(line); line = f.read('line')) { + let e = null; + + try { + e = json(line); + } + catch (err) { + continue; + } + + if (from > 0 && int(e?.epoch ?? 0) < from) + continue; + if (to > 0 && int(e?.epoch ?? 0) > to) + continue; + + push(entries, e); + } + + f.close(); + } + + // Newest N, still oldest first. + const kept = (limit > 0 && length(entries) > limit) + ? slice(entries, -limit) : entries; + + return { resolution: resolution, entries: kept }; + } + }, + + config: { + call: function() { + const uci = cursor(); + + // The drawn schedule lives in the crontab, not in UCI: for a daily + // interval the time is picked at sync. Handing the line out lets + // the frontend show when measurements will actually run. + let cron = ''; + const cf = open('/etc/crontabs/root', 'r'); + + if (cf) { + for (let line = cf.read('line'); length(line); line = cf.read('line')) + if (index(line, '/usr/libexec/librespeed-run') >= 0) + cron = trim(line); + cf.close(); + } + + const out = { + interface: config_get(uci, 'main', 'interface', 'wan'), + server: config_get(uci, 'main', 'server', 'auto'), + scheme: config_get(uci, 'main', 'scheme', 'auto'), + server_list: config_get(uci, 'main', 'server_list', ''), + schedule: { + enabled: config_get(uci, 'schedule', 'enabled', '0') == '1', + interval: config_get(uci, 'schedule', 'interval', '1d'), + days: config_get(uci, 'schedule', 'days', '*'), + hours: config_get(uci, 'schedule', 'hours', ''), + cron: cron, + next_runs: cron != '' ? cron_next(cron, 3) : [] + }, + history: { + enabled: config_get(uci, 'history', 'enabled', '1') != '0', + path: config_get(uci, 'history', 'path', + `${STATE_DIR}/history.jsonl`), + retention: config_get(uci, 'history', 'retention', '30d') + } + }; + + uci.unload('librespeed'); + + return out; + } + } +}; + +return { librespeed: methods }; diff --git a/utils/librespeed-common/test.sh b/utils/librespeed-common/test.sh new file mode 100755 index 0000000000000..a081e813891e4 --- /dev/null +++ b/utils/librespeed-common/test.sh @@ -0,0 +1,26 @@ +#!/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" + +echo "librespeed-common: installed files OK"