diff --git a/librespeed-common/LICENSE b/librespeed-common/LICENSE deleted file mode 100644 index 4d90dc142..000000000 --- a/librespeed-common/LICENSE +++ /dev/null @@ -1,355 +0,0 @@ -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/librespeed-common/Makefile b/librespeed-common/Makefile deleted file mode 100644 index bd989e676..000000000 --- a/librespeed-common/Makefile +++ /dev/null @@ -1,78 +0,0 @@ -# -# 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:=3 - -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 +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)) diff --git a/librespeed-common/files/librespeed-aggregate b/librespeed-common/files/librespeed-aggregate deleted file mode 100755 index 2c3a078b8..000000000 --- a/librespeed-common/files/librespeed-aggregate +++ /dev/null @@ -1,200 +0,0 @@ -#!/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); -} diff --git a/librespeed-common/files/librespeed-run b/librespeed-common/files/librespeed-run deleted file mode 100755 index 49cc20669..000000000 --- a/librespeed-common/files/librespeed-run +++ /dev/null @@ -1,414 +0,0 @@ -#!/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" - 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 '[ -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]*) ;; - *) 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/librespeed-common/files/librespeed.config b/librespeed-common/files/librespeed.config deleted file mode 100644 index 3fa1afa0b..000000000 --- a/librespeed-common/files/librespeed.config +++ /dev/null @@ -1,18 +0,0 @@ -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/librespeed-common/files/librespeed.init b/librespeed-common/files/librespeed.init deleted file mode 100644 index a1a87d60e..000000000 --- a/librespeed-common/files/librespeed.init +++ /dev/null @@ -1,148 +0,0 @@ -#!/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 -} diff --git a/librespeed-common/files/librespeed.uc b/librespeed-common/files/librespeed.uc deleted file mode 100644 index fee8b9751..000000000 --- a/librespeed-common/files/librespeed.uc +++ /dev/null @@ -1,357 +0,0 @@ -#!/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 }; diff --git a/librespeed-common/test.sh b/librespeed-common/test.sh deleted file mode 100755 index bc33849d6..000000000 --- a/librespeed-common/test.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/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" diff --git a/luci-app-librespeed/Makefile b/luci-app-librespeed/Makefile deleted file mode 100644 index 74dad2146..000000000 --- a/luci-app-librespeed/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -include $(TOPDIR)/rules.mk - -LUCI_TITLE:=LibreSpeed internet speed measurements -LUCI_DEPENDS:=+luci-base +librespeed-common - -PKG_LICENSE:=Apache-2.0 -PKG_MAINTAINER:=Josef Schlehofer - -include $(TOPDIR)/feeds/luci/luci.mk - -# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js deleted file mode 100644 index d1eb02edc..000000000 --- a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js +++ /dev/null @@ -1,537 +0,0 @@ -'use strict'; -'require baseclass'; -'require ui'; - -/* Shared between the Test page's recent-history block and the full History - * page, so the two can never draw the same data differently. */ - -/* Labels are translated here, where the extractor can see them; consumers - * take them display-ready, the way RANGES and PHASES do. */ -const METRICS = [ - [ 'download_mbps', _('Download'), 'Mbps' ], - [ 'upload_mbps', _('Upload'), 'Mbps' ], - [ 'ping_ms', _('Ping'), 'ms' ], - [ 'jitter_ms', _('Jitter'), 'ms' ] -]; - -/* Series that share a unit share an axis; mixing Mbps and ms on one scale - * would flatten the milliseconds into the floor. */ -const GROUPS = { - speed: [ 'download_mbps', 'upload_mbps' ], - latency: [ 'ping_ms', 'jitter_ms' ] -}; - -/* Labels are translated here, where the extractor can see them: switcher() - * takes them display-ready. The key is the seconds, never the label. */ -const RANGES = [ - [ _('24h'), 86400 ], - [ _('7d'), 604800 ], - [ _('30d'), 2592000 ], - [ _('1y'), 31536000 ], - [ _('All'), 0 ] -]; - -function esc(s) { - return String(s == null ? '' : s).replace(/"/g, '""'); -} - -function download(name, mime, text) { - const blob = new Blob([ text ], { type: mime }), - url = window.URL.createObjectURL(blob), - link = E('a', { 'style': 'display:none', 'href': url, 'download': name }); - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); -} - -function metricOf(key) { - return METRICS.find(m => m[0] == key); -} - -/* A metric keeps one color everywhere: the first of its group wears the - * theme's primary color, the second the fixed companion shade. */ -function colorIndexOf(key) { - for (const g in GROUPS) { - const i = GROUPS[g].indexOf(key); - if (i >= 0) - return i; - } - return 0; -} - -/* One scale shared by the renderer and the hover layer: both must agree on - * where a sample sits, or the tooltip would point beside the line. Spans - * every requested series, and the day's min/max spread when aggregated. */ -function chartScale(entries, metrics, resolution) { - const W = 600, H = 240, L = 46, R = 10, T = 14, B = 24; - - if (!entries || entries.length < 1) - return null; - - const vals = []; - - entries.forEach(e => { - metrics.forEach(k => { - if (typeof e[k] == 'number') - vals.push(e[k]); - if (resolution == '1d') { - if (typeof e[k + '_min'] == 'number') - vals.push(e[k + '_min']); - if (typeof e[k + '_max'] == 'number') - vals.push(e[k + '_max']); - } - }); - }); - - if (!vals.length) - return null; - - let min = Math.min.apply(null, vals), - max = Math.max.apply(null, vals); - - /* Vertical padding: 10% of the spread, but never less than 2% of the - * value itself -- a connection steady within tenths of a Mbps must not - * be zoomed into dramatic noise. A flat zero line still needs a - * nonzero span to be drawable. */ - const pad = Math.max((max - min) * 0.1, Math.abs(max) * 0.02) || 1; - min -= pad; max += pad; - - return { - W: W, H: H, L: L, R: R, T: T, B: B, - min: min, max: max, - xs: i => entries.length > 1 - ? L + i * (W - L - R) / (entries.length - 1) - : (L + W - R) / 2, - ys: v => T + (H - T - B) * (1 - (v - min) / (max - min)) - }; -} - -/* Timestamps for humans: daily aggregates carry a plain date, everything else - * becomes the locale's date and time; garbage shows as itself. */ -function chartStampFull(e, resolution) { - if (!e || !e.timestamp) - return '?'; - if (resolution == '1d' || e.timestamp.indexOf('T') < 0) - return e.timestamp; - const d = new Date(e.timestamp); - return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString(); -} - -function chartStampShort(e, resolution) { - if (!e || !e.timestamp) - return ''; - if (resolution == '1d' || e.timestamp.indexOf('T') < 0) - return e.timestamp; - const d = new Date(e.timestamp); - return isNaN(d.getTime()) ? e.timestamp : d.toLocaleDateString(); -} - -function fmtVal(v, unit) { - return (typeof v == 'number') - ? (v.toFixed(unit == 'Mbps' ? 2 : 1) + ' ' + unit) : '–'; -} - -return baseclass.extend({ - METRICS: METRICS, - GROUPS: GROUPS, - RANGES: RANGES, - - /* Linked plainly, the way the other applications do: uhttpd serves - * static resources with ETag revalidation, so an upgraded stylesheet - * reaches the browser without a cache-busting version to maintain. */ - cssLink() { - return E('link', { - 'rel': 'stylesheet', - 'href': L.resource('librespeed/librespeed.css') - }); - }, - - /* One token->label table for the schedule intervals: Settings builds - * its choices from it and the Test page looks the status label up, so - * the two cannot drift. The init script accepts more shapes than these - * six, so readers must fall back to the raw token. */ - INTERVALS: { - '15m': _('Every 15 minutes'), - '30m': _('Every 30 minutes'), - '1h': _('Hourly'), - '6h': _('Every 6 hours'), - '12h': _('Every 12 hours'), - '1d': _('Daily') - }, - - /* Exported alongside the chart: every timestamp a page prints should - * come through here, or the date-only guard gets lost in a copy. */ - chartStampFull: chartStampFull, - - /* Plain figures of one series: what is normal, how much it wobbles, and - * where it stands now. The story sentences are built from these. On - * daily aggregates the extremes come from the _min/_max columns the - * chart also draws -- min/max of the daily means would understate the - * spread and contradict the band directly above the sentence. */ - seriesStats(entries, metric, resolution) { - const vals = (entries || []).map(e => e[metric]).filter(v => typeof v == 'number'); - - if (!vals.length) - return null; - - const avg = vals.reduce((a, b) => a + b, 0) / vals.length; - const lows = (resolution == '1d') - ? (entries || []).map(e => typeof e[metric + '_min'] == 'number' - ? e[metric + '_min'] : e[metric]).filter(v => typeof v == 'number') - : vals; - const highs = (resolution == '1d') - ? (entries || []).map(e => typeof e[metric + '_max'] == 'number' - ? e[metric + '_max'] : e[metric]).filter(v => typeof v == 'number') - : vals; - - return { - count: vals.length, - avg: avg, - min: Math.min.apply(null, lows), - max: Math.max.apply(null, highs), - current: vals[vals.length - 1] - }; - }, - - /* A row of toggle buttons; the active one is highlighted. The state is - * also spoken: colour alone says nothing to a screen reader, so each - * button carries aria-pressed and the group a name for what it picks. */ - switcher(view, items, current, onpick, label) { - return E('div', { 'role': 'group', 'aria-label': label || null }, - items.map(it => - E('button', { - 'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button', - 'style': 'margin-right:.3em', - 'aria-pressed': it[0] == current ? 'true' : 'false', - 'click': ui.createHandlerFn(view, function() { - return onpick.call(view, it[0]); - }) - /* Callers hand in display-ready labels: a runtime _() here - * would both miss the extractor and translate twice. */ - }, [ it[1] != null ? it[1] : it[0] ]))); - }, - - /* The one chart component: hand-built SVG, the way the realtime status - * graphs do it, colored by the stylesheet so it wears the active theme. - * - * opts.series keys drawn as lines (one unit family per chart) - * opts.resolution 'raw' or '1d'; aggregates draw their min-max band - * opts.hover guide line + a full-measurement tooltip - * opts.legend { all, onToggle }: checkbox legend that switches - * series on and off -- a control, not a caption - * - * The x axis is always time: a time series must never be reordered, or - * the story it tells dissolves. Sorting belongs to the table. - * Returns false when there is nothing to draw. */ - renderChart(container, entries, opts) { - container.innerHTML = ''; - - const series = (opts.series || []).filter(k => metricOf(k)); - const sc = chartScale(entries, series, opts.resolution); - - if (!sc) { - if (opts.legend) - this.renderLegend(container, entries, opts); - return false; - } - - const W = sc.W, H = sc.H, L = sc.L, R = sc.R, T = sc.T, B = sc.B; - - const grid = [ 0.25, 0.5, 0.75 ].map(f => { - const y = T + (H - T - B) * f, - v = sc.max - (sc.max - sc.min) * f; - return '' - .format(L, y, W - R, y) + - '%s' - .format(L - 4, y + 3, v.toFixed(1)); - }).join(''); - - let body = ''; - - series.forEach(k => { - const ci = colorIndexOf(k); - const valid = entries - .map((e, i) => (typeof e[k] == 'number') ? [ sc.xs(i), sc.ys(e[k]), e[k] ] : null) - .filter(p => p != null); - - if (!valid.length) - return; - - const pts = valid.map(p => '%f,%f'.format(p[0], p[1])).join(' '); - - /* Aggregated entries carry the day's spread beside the mean, - * drawn as a band under the mean line. */ - if (opts.resolution == '1d') { - const lo = entries.map((e, i) => (typeof e[k + '_min'] == 'number') - ? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_min'])) : null).filter(p => p != null); - const hi = entries.map((e, i) => (typeof e[k + '_max'] == 'number') - ? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_max'])) : null).filter(p => p != null); - - if (lo.length) - body += '' - .format(ci, lo.join(' '), hi.reverse().join(' ')); - } - - /* The soft fill reads well under one line; under two it is mud. */ - if (series.length == 1 && valid.length > 1) - body += '' - .format(ci, pts, valid[valid.length - 1][0], H - B, valid[0][0], H - B); - - /* The dashed average is the series' "what is normal" reference. */ - const avg = valid.reduce((a, p) => a + p[2], 0) / valid.length; - body += '' - .format(ci, L, sc.ys(avg), W - R, sc.ys(avg)); - body += '%h %s' - .format(W - R, sc.ys(avg) - 4, _('avg'), - avg.toFixed(avg >= 100 ? 0 : 1)); - - body += (valid.length > 1) - ? ''.format(ci, pts) - : '' - .format(ci, valid[0][0], valid[0][1]); - - /* Dots make a sparse series readable; with hundreds of points - * they would just thicken the line, so they bow out. */ - if (valid.length > 1 && valid.length <= 60) - body += valid.map(p => - '' - .format(ci, p[0], p[1])).join(''); - }); - - /* A date-only timestamp names a local calendar day; new Date() would - * read it as UTC midnight and shift it in negative offsets. */ - const first = entries[0], - last = entries[entries.length - 1], - mid = entries[Math.floor((entries.length - 1) / 2)]; - const s1 = chartStampShort(first, opts.resolution), - s2 = chartStampShort(mid, opts.resolution), - s3 = chartStampShort(last, opts.resolution); - const midLabel = (entries.length > 2 && s2 != s1 && s2 != s3) - ? '%h' - .format((L + W - R) / 2, H - 6, s2) - : ''; - - const unit = metricOf(series[0]) ? metricOf(series[0])[2] : ''; - - const chart = E('div', {}); - chart.innerHTML = - '' - .format(W, H) + - grid + body + - '%h' - .format(L, H - 6, s1) + - midLabel + - '%h (%s)' - .format(W - R, H - 6, s3, unit) + - ''; - container.appendChild(chart); - - if (opts.hover !== false) - this.attachChartHover(chart, entries, series, opts.resolution, sc); - - if (opts.legend) - this.renderLegend(container, entries, opts); - - return true; - }, - - /* The legend doubles as the series switch: each entry is a checkbox with - * the series' color and its average over the visible range. */ - renderLegend(container, entries, opts) { - const leg = E('div', { 'class': 'librespeed-legend' }); - - opts.legend.all.forEach(k => { - const m = metricOf(k); - const st = this.seriesStats(entries, k, opts.resolution); - const cb = E('input', { 'type': 'checkbox' }); - - cb.checked = opts.series.indexOf(k) >= 0; - cb.addEventListener('change', () => opts.legend.onToggle(k, cb.checked)); - - leg.appendChild(E('label', { 'class': 'librespeed-legend-item' }, [ - cb, - E('span', { 'class': 'librespeed-swatch librespeed-bg-%d'.format(colorIndexOf(k)) }), - E('span', {}, [ m[1] ]), - st ? E('span', { 'class': 'librespeed-muted' }, - [ ' ' + fmtVal(st.avg, m[2]) + ' ' + _('avg') ]) : '' - ])); - }); - - container.appendChild(leg); - }, - - /* Hover layer: a guide line, a highlighted sample per series, and an HTML - * tooltip carrying the whole measurement -- every metric, the server and - * how the test travelled -- because whoever inspects one point wants to - * know about that test, not one number of it. Daily aggregates show the - * day's spread of the drawn series instead. */ - attachChartHover(container, entries, series, resolution, sc) { - const svg = container.querySelector('svg'); - - if (!svg || !sc) - return; - - container.classList.add('librespeed-chart-holder'); - - const NS = 'http://www.w3.org/2000/svg'; - const guide = document.createElementNS(NS, 'line'); - guide.setAttribute('class', 'librespeed-guide'); - guide.setAttribute('y1', sc.T); - guide.setAttribute('y2', sc.H - sc.B); - guide.style.display = 'none'; - - const dots = series.map(k => { - const d = document.createElementNS(NS, 'circle'); - d.setAttribute('class', 'librespeed-dot librespeed-fill-%d'.format(colorIndexOf(k))); - d.setAttribute('r', '3.5'); - d.style.display = 'none'; - return d; - }); - - const hit = document.createElementNS(NS, 'rect'); - hit.setAttribute('x', sc.L); - hit.setAttribute('y', sc.T); - hit.setAttribute('width', sc.W - sc.L - sc.R); - hit.setAttribute('height', sc.H - sc.T - sc.B); - hit.setAttribute('fill', 'transparent'); - - svg.appendChild(guide); - dots.forEach(d => svg.appendChild(d)); - svg.appendChild(hit); - - const tip = E('div', { 'class': 'librespeed-tooltip' }); - container.appendChild(tip); - - const row = (lab, txt) => E('div', { 'class': 'librespeed-tooltip-row' }, [ - E('span', { 'class': 'librespeed-muted' }, [ lab ]), - E('span', {}, [ txt ]) - ]); - - const hide = () => { - guide.style.display = 'none'; - dots.forEach(d => (d.style.display = 'none')); - tip.style.display = 'none'; - }; - - hit.addEventListener('mousemove', ev => { - const rect = svg.getBoundingClientRect(); - const x = (ev.clientX - rect.left) * sc.W / rect.width; - const step = entries.length > 1 - ? (sc.W - sc.L - sc.R) / (entries.length - 1) : 1; - const i = Math.max(0, Math.min(entries.length - 1, - Math.round((x - sc.L) / step))); - const e = entries[i]; - - const px = sc.xs(i); - let anchorY = null; - - guide.setAttribute('x1', px); - guide.setAttribute('x2', px); - guide.style.display = ''; - - series.forEach((k, si) => { - if (typeof e[k] == 'number') { - const py = sc.ys(e[k]); - dots[si].setAttribute('cx', px); - dots[si].setAttribute('cy', py); - dots[si].style.display = ''; - anchorY = (anchorY == null) ? py : Math.min(anchorY, py); - } - else - dots[si].style.display = 'none'; - }); - - if (anchorY == null) - return hide(); - - tip.innerHTML = ''; - tip.appendChild(E('div', { 'style': 'font-weight:600; margin-bottom:.2em' }, - [ chartStampFull(e, resolution) ])); - - if (resolution == '1d') { - series.forEach(k => { - const m = metricOf(k); - if (typeof e[k] != 'number') - return; - let txt = fmtVal(e[k], m[2]); - if (typeof e[k + '_min'] == 'number' && - typeof e[k + '_max'] == 'number') - txt += ' (%s – %s)'.format( - e[k + '_min'].toFixed(m[2] == 'Mbps' ? 0 : 1), - e[k + '_max'].toFixed(m[2] == 'Mbps' ? 0 : 1)); - tip.appendChild(row(m[1], txt)); - }); - } - else { - METRICS.forEach(m => { - if (typeof e[m[0]] == 'number') - tip.appendChild(row(m[1], fmtVal(e[m[0]], m[2]))); - }); - - if (e.server && e.server.name) - tip.appendChild(E('div', { 'style': 'margin-top:.2em' }, - [ e.server.name ])); - - const meta = []; - if (e.interface) - meta.push(e.interface); - if (e.family) - meta.push(e.family == 'ipv6' ? 'IPv6' : 'IPv4'); - if (e.proto) - meta.push(e.proto.toUpperCase()); - if (meta.length) - tip.appendChild(E('div', { 'class': 'librespeed-muted' }, - [ meta.join(' · ') ])); - } - - tip.style.display = 'block'; - - const cr = container.getBoundingClientRect(); - const pxs = rect.left - cr.left + px * rect.width / sc.W; - const pys = rect.top - cr.top + anchorY * rect.height / sc.H; - let left = pxs - tip.offsetWidth / 2; - left = Math.max(0, Math.min(cr.width - tip.offsetWidth, left)); - tip.style.left = left + 'px'; - tip.style.top = Math.max(0, pys - tip.offsetHeight - 14) + 'px'; - }); - hit.addEventListener('mouseleave', hide); - }, - - /* Daily aggregates carry each metric's spread beside the mean; the CSV - * must not lose it, so aggregated exports gain the _min/_max columns. */ - exportCSV(entries, resolution) { - const agg = (resolution == '1d'); - const keys = METRICS.map(m => m[0]); - const n = v => (typeof v == 'number') ? v : ''; - - let head = 'timestamp,resolution,interface,family,proto,server,' + keys.join(','); - - if (agg) - head += ',' + keys.map(k => k + '_min,' + k + '_max').join(','); - - const lines = [ head ]; - - (entries || []).forEach(e => { - /* Every text field is quoted, so esc()'s quote-doubling is correct - * everywhere and embedded commas cannot shift columns. */ - let row = '"%s","%s","%s","%s","%s","%s",'.format( - esc(e.timestamp), agg ? '1d' : 'raw', esc(e.interface), - esc(e.family), esc(e.proto), esc(e.server && e.server.name)) + - keys.map(k => n(e[k])).join(','); - - if (agg) - row += ',' + keys.map(k => n(e[k + '_min']) + ',' + n(e[k + '_max'])).join(','); - - lines.push(row); - }); - - download('librespeed-history.csv', 'text/csv', lines.join('\n') + '\n'); - }, - - exportJSON(entries, resolution) { - download('librespeed-history.json', 'application/json', - JSON.stringify({ resolution: resolution || 'raw', entries: entries || [] }, null, '\t') + '\n'); - } -}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css deleted file mode 100644 index 75b1f0de6..000000000 --- a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css +++ /dev/null @@ -1,344 +0,0 @@ -/* The theme's accent where the theme publishes one: --primary-color-medium - * exists only in luci-theme-bootstrap. Every other in-tree theme sees the - * fallback, which must clear 3:1 against a light page -- the arc and the - * history line are the only graphical encoding of the data. */ -:root { - --librespeed-accent: var(--primary-color-medium, #3c8dbc); -} - -/* Dark is an attribute in LuCI, not a media preference: bootstrap stamps - * data-darkmode on the root and pins it for its light/dark variants, while - * the themes that actually reach the fallback have no dark mode at all -- - * an OS-level preference must not darken their light pages. */ -:root[data-darkmode="true"] { - --librespeed-accent: var(--primary-color-medium, #6ab0de); -} - -/* The one piece of CSS this application owns: layout and type scale. Colors, - * borders, buttons and spacing inside sections stay with the theme. */ - -.librespeed-columns { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr); - gap: 1.5rem; - align-items: start; -} - -@media (max-width: 768px) { - .librespeed-columns { - grid-template-columns: 1fr; - } -} - -.librespeed-metrics { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); - gap: 1rem; - text-align: center; - margin: 1rem 0; -} - -.librespeed-value { - font-size: 2.2em; - font-weight: 500; - line-height: 1.2; -} - -.librespeed-value-sm { - font-size: 1.5em; - line-height: 1.2; -} - -/* Muted through opacity rather than a hardcoded grey, so it dims whatever - * foreground color the active theme uses. */ -.librespeed-muted { - opacity: .65; -} - -.librespeed-caps { - font-size: .85em; - letter-spacing: .08em; - text-transform: uppercase; -} - -.librespeed-center { - text-align: center; - padding: 1.5em 1em; -} - -.librespeed-progress { - width: 60%; - max-width: 20em; - display: inline-block; - vertical-align: middle; - accent-color: var(--librespeed-accent); -} - -/* The chart takes its color from the theme where one is exposed, and falls - * back to a neutral accent where none is. */ -.librespeed-chart-line { - stroke: var(--librespeed-accent); - fill: none; - stroke-width: 1.5; -} - -.librespeed-chart-fill { - fill: var(--librespeed-accent); - fill-opacity: .1; - stroke: none; -} - -.librespeed-chart-band { - fill: var(--librespeed-accent); - fill-opacity: .18; - stroke: none; -} - -.librespeed-chart-dot { - fill: var(--librespeed-accent); -} - -.librespeed-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: .5em; - margin: .4em 0; -} - -.librespeed-push { - margin-left: auto; -} - -.librespeed-footer { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: .5em; - margin-top: .75em; -} - -.librespeed-steps { - display: flex; - gap: 1.75em; - justify-content: center; - margin: .5em 0 0; -} - -.librespeed-step { opacity: .35; } -.librespeed-step-active { opacity: 1; font-weight: 600; } -.librespeed-step-done { opacity: .65; } - -.librespeed-spark { - width: 100%; - height: 72px; - margin: .75em 0 0; - display: block; -} - -.librespeed-gauge { - width: 250px; - max-width: 100%; - margin: .5em auto 0; - display: block; -} - -.librespeed-gauge-bg { - fill: none; - stroke: currentColor; - stroke-opacity: .12; - stroke-width: 12; - stroke-linecap: round; -} - -.librespeed-gauge-fill { - fill: none; - stroke: var(--librespeed-accent); - stroke-width: 12; - stroke-linecap: round; -} - -.librespeed-gauge text { fill: currentColor; } - -.librespeed-gauge-tick { - stroke: currentColor; - stroke-opacity: .25; - stroke-width: 1.5; -} - -.librespeed-gauge-needle { - stroke: currentColor; - stroke-width: 2.5; - stroke-linecap: round; - opacity: .85; -} - -.librespeed-gauge-hub { fill: currentColor; } - -.librespeed-chart-holder { position: relative; } - -.librespeed-guide { - stroke: currentColor; - stroke-opacity: .3; - stroke-width: 1; -} - -.librespeed-avg { - stroke: currentColor; - stroke-opacity: .45; - stroke-width: 1; - stroke-dasharray: 4 3; -} - -.librespeed-tooltip { - position: absolute; - display: none; - pointer-events: none; - background: var(--background-color-high, #fff); - color: var(--text-color-high, #333); - border: 1px solid rgba(128, 128, 128, .4); - border-radius: 3px; - padding: .3em .6em; - font-size: .85em; - white-space: nowrap; - box-shadow: 0 1px 4px rgba(0, 0, 0, .2); - z-index: 50; -} - -.librespeed-tooltip-row { - display: flex; - justify-content: space-between; - gap: 1.25em; -} - -.librespeed-stats { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 1.5em; - margin-top: .5em; -} - -.librespeed-line { fill: none; stroke-width: 1.5; } -.librespeed-area { stroke: none; fill-opacity: .1; } -.librespeed-band { stroke: none; fill-opacity: .16; } -.librespeed-dot { stroke: none; } - -.librespeed-avgline { - fill: none; - stroke-width: 1; - stroke-dasharray: 4 3; - stroke-opacity: .55; -} - -/* One theme color for every series: the second one differs by pattern, not - * by a hardcoded hue -- a dashed line and a striped swatch survive any theme - * and read for color-blind users too. */ -.librespeed-stroke-0 { stroke: var(--librespeed-accent); } -.librespeed-stroke-1 { stroke: var(--librespeed-accent); } -polyline.librespeed-stroke-1 { stroke-dasharray: 6 3; } -.librespeed-avgline.librespeed-stroke-1 { stroke-dasharray: 2 4; } -.librespeed-fill-0 { fill: var(--librespeed-accent); } -.librespeed-fill-1 { fill: var(--librespeed-accent); } -circle.librespeed-fill-1 { fill-opacity: .55; } -.librespeed-bg-0 { background: var(--librespeed-accent); } -.librespeed-bg-1 { - background: repeating-linear-gradient(90deg, - var(--librespeed-accent) 0 4px, transparent 4px 7px); -} - -.librespeed-legend { - display: flex; - flex-wrap: wrap; - gap: 1.5em; - margin: .35em 0 0; -} - -.librespeed-legend-item { - display: inline-flex; - align-items: center; - gap: .4em; - cursor: pointer; -} - -.librespeed-swatch { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 2px; -} - -.librespeed-result-gauges { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 1.5em; - margin: .5em 0 0; -} - -.librespeed-result-gauges .librespeed-gauge { - width: 200px; - margin: 0 auto; -} - -.librespeed-fg-0 { color: var(--librespeed-accent); } -.librespeed-fg-1 { color: var(--librespeed-accent); } - -.librespeed-cards { - display: flex; - flex-wrap: wrap; - gap: .75em; - margin: .35em 0 1em; -} - -.librespeed-metric-card { - border: 1px solid rgba(128, 128, 128, .25); - border-top-width: 3px; - border-radius: 4px; - padding: .45em .9em; - min-width: 11em; - cursor: pointer; - /* Dim, still legible: .5 with the muted name inside multiplied down to - * an unreadable third. */ - opacity: .78; - user-select: none; -} - -.librespeed-metric-card-on { opacity: 1; } -.librespeed-metric-card:hover { border-color: rgba(128, 128, 128, .45); } - -.librespeed-accent-0 { border-top-color: var(--librespeed-accent); } -.librespeed-accent-1 { border-top-color: var(--librespeed-accent); border-top-style: dashed; } - -.librespeed-card-value { - font-size: 1.3em; - font-weight: 600; - line-height: 1.35; -} - -.librespeed-hint { - border-bottom: 1px dotted currentColor; - cursor: help; -} - -.librespeed-meta { - display: grid; - grid-template-columns: auto 1fr; - gap: .15em 1.25em; - width: fit-content; - margin: 1em auto 0; - text-align: left; - font-size: .95em; -} - -/* Off-screen but reachable: the live region announcing the running phase, - * and the gauge figures repeated as plain text for browse mode. */ -.librespeed-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js deleted file mode 100644 index 5a87c7c7b..000000000 --- a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js +++ /dev/null @@ -1,465 +0,0 @@ -'use strict'; -'require view'; -'require rpc'; -'require ui'; -'require librespeed.common as lscommon'; - -/* The alias comes from the require above; the repo eslint config only - * knows the stock module names. */ -/* global lscommon */ - -/* The chart wants every point in the range, but the table must not put - * thousands of rows into the DOM: a 15-minute schedule kept for 30 days is - * nearly 3000 measurements. Paging keeps the page responsive without a - * second fetch, since the entries are in memory anyway. */ -const pageSize = 50; - -const callConfig = rpc.declare({ - object: 'librespeed', - method: 'config', - expect: { '': {} } -}); - -const callHistory = rpc.declare({ - object: 'librespeed', - method: 'history', - params: [ 'from', 'to', 'limit' ], - expect: { '': {} } -}); - -return view.extend({ - entries: [], - prevEntries: [], - resolution: 'raw', - group: 'speed', - active: null, - range: 86400, - server: '', - iface: '', - page: 0, - - /* Which entries come back -- and at which resolution -- is the server's - * decision, so every range switch is a fetch rather than a client-side - * filter over one download. The previous window of the same length is - * fetched alongside, so the summary can say how this period compares. */ - fetch() { - const now = Math.floor(Date.now() / 1000); - /* Zero, not undefined: the rpcd argument is typed as an integer and a - * null would fail validation rather than mean "everything". */ - const from = this.range ? now - this.range : 0; - - return Promise.all([ - callHistory(from).catch(() => ({})), - this.range - ? callHistory(now - 2 * this.range, now - this.range).catch(() => ({})) - : Promise.resolve({}) - ]).then(L.bind(function(data) { - this.entries = Array.isArray(data[0].entries) ? data[0].entries : []; - this.resolution = data[0].resolution || 'raw'; - this.prevEntries = Array.isArray(data[1].entries) ? data[1].entries : []; - this.prevResolution = data[1].resolution || 'raw'; - }, this)); - }, - - load() { - /* Both series of a group start visible; the legend narrows it. */ - this.active = { - speed: lscommon.GROUPS.speed.slice(), - latency: lscommon.GROUPS.latency.slice() - }; - /* The config rides along for one purpose: with history keeping off, - * the empty state must not send the user to run a test that will - * never produce an entry. */ - return Promise.all([ - this.fetch(), - callConfig().then(c => { this.config = c; }, () => {}) - ]); - }, - - /* The server/interface filters narrow the fetched range on the client; - * daily aggregates carry neither field, so there the filters offer only - * "All" and match everything. */ - applyFilters(entries) { - return (entries || []).filter(e => - (!this.server || (e.server && e.server.name) == this.server) && - (!this.iface || e.interface == this.iface)); - }, - - visible() { - return this.applyFilters(this.entries); - }, - - filterSelect(title, values, current, onpick) { - const sel = E('select', { 'class': 'cbi-input-select' }, - [ E('option', { 'value': '' }, [ _('All') ]) ].concat( - values.map(v => E('option', { 'value': v }, [ v ])))); - - sel.value = values.indexOf(current) >= 0 ? current : ''; - sel.addEventListener('change', ui.createHandlerFn(this, function() { - return onpick.call(this, sel.value); - })); - - return E('label', {}, [ - E('span', { 'class': 'librespeed-muted' }, [ title + ' ' ]), sel ]); - }, - - /* The header above the chart doubles as the legend: per series a checkbox - * with its color, its average, and the change against the previous period - * -- one place to read the numbers and switch the lines, right where the - * eye already is. Below it, one sentence on how the period behaved. */ - renderSummary() { - const entries = this.visible(), - prev = this.applyFilters(this.prevEntries), - act = this.active[this.group], - states = []; - - this.summaryNode.innerHTML = ''; - - const row = E('div', { 'class': 'librespeed-cards' }); - - lscommon.GROUPS[this.group].forEach(L.bind(function(k, ki) { - const m = lscommon.METRICS.find(x => x[0] == k), - st = lscommon.seriesStats(entries, k, this.resolution), - ps = lscommon.seriesStats(prev, k, this.prevResolution), - on = act.indexOf(k) >= 0; - - let diff = ' '; - - /* Only comparable windows get compared: an average of raw samples - * against an average of daily aggregates is not the same number, - * and the two fetches may straddle the raw/archive boundary. */ - if (on && st && ps && ps.avg > 0 && this.prevResolution == this.resolution) { - const pct = (st.avg - ps.avg) / ps.avg * 100; - diff = '%s%d %% %s'.format(pct >= 0 ? '+' : '−', - Math.abs(Math.round(pct)), _('vs previous period')); - } - - /* The card is a label around a real checkbox, so it works from the - * keyboard exactly like the control it is; the dimming and the - * accent stripe say what is drawn. */ - const cb = E('input', { 'type': 'checkbox' }); - - cb.checked = on; - cb.addEventListener('change', L.bind(function() { - if (cb.checked && act.indexOf(k) < 0) - act.push(k); - else if (!cb.checked) { - if (act.length < 2) { - cb.checked = true; - return; - } - act.splice(act.indexOf(k), 1); - } - this.redraw(); - }, this)); - - row.appendChild(E('label', { - 'class': 'librespeed-metric-card librespeed-accent-%d'.format(ki) + - (on ? ' librespeed-metric-card-on' : ''), - 'title': on ? _('Hide this series') : _('Show this series') - }, [ - /* Not muted: the card's own off-state opacity already dims - * it, and two factors multiply into illegibility. */ - E('div', { 'class': 'librespeed-caps' }, - [ cb, ' ', m[1] ]), - E('div', { 'class': 'librespeed-card-value' }, [ - st ? '%.1f'.format(st.avg) + ' ' + m[2] : '–', - E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.6em; font-weight:400' }, - [ ' ' + _('avg') ]) - ]), - E('div', { 'class': 'librespeed-muted', 'style': 'font-size:.85em' }, [ diff ]) - ])); - - if (on && st) { - const varPct = (st.max - st.min) / st.avg * 100, - offPct = (st.current - st.avg) / st.avg * 100; - const bad = (k == 'ping_ms' || k == 'jitter_ms') ? offPct > 15 : offPct < -15; - - states.push([ m[1], - bad ? _('declining') : (varPct > 30 ? _('highly variable') : _('stable')) ]); - } - }, this)); - - this.summaryNode.appendChild(row); - - if (states.length) { - const allStable = states.every(s => s[1] == _('stable')); - this.summaryNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:0 0 .25em' }, [ - allStable - ? _('Stable over the selected period.') - : states.map(s => '%s: %s'.format(s[0], s[1])).join(' · ') - ])); - } - }, - - redraw() { - const entries = this.visible(); - - /* Without data the empty state IS the page: everything that only - * makes sense with measurements -- cards, chart, table, export -- - * stays out of the way entirely, and the one useful action is a - * button to go run a test. Filters that merely exclude everything - * get their own wording and no button: data exists, go widen them. */ - const noneAtAll = !this.entries.length; - - if (!entries.length) { - /* Strict, matching test.js: the backend emits a real boolean - * (derived from UCI, never its raw '0'/'1'), and an absent or - * failed config must read as "history on", not off. */ - const histOff = noneAtAll && this.config && - this.config.history && this.config.history.enabled === false; - - /* With history keeping off, a test would never write an entry: - * the way out is Settings, not the Start button. */ - this.emptyText.textContent = histOff - ? _('History keeping is switched off, so measurements are not recorded.') - : (noneAtAll - ? _('Run a speed test to start building your connection history.') - : _('No measurements match the current filters.')); - this.emptyStart.style.display = noneAtAll ? '' : 'none'; - this.emptyStart.href = histOff - ? L.url('admin', 'network', 'librespeed', 'settings') - : L.url('admin', 'network', 'librespeed', 'test'); - this.emptyStart.textContent = histOff ? _('Open settings') : _('Start test'); - this.emptyNode.style.display = ''; - this.dataNode.style.display = 'none'; - return; - } - - this.emptyNode.style.display = 'none'; - this.dataNode.style.display = ''; - - this.renderSummary(); - - /* The legend lives in the summary header above; the chart itself has - * nothing below it that could be mistaken for table furniture. */ - const drew = lscommon.renderChart(this.chartNode, entries, { - series: this.active[this.group], - resolution: this.resolution, - hover: true - }); - - /* Entries exist -- the empty state above handles the case where they - * do not -- so reaching here means this metric has no numbers. */ - if (!drew) - this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' }, - [ _('No data for the selected metric.') ])); - else if (this.resolution == '1d') - this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' }, - [ _('Daily minimum, average and maximum.') ])); - - /* The sort key carries the same decimal count as the display half: - * ui.Table stringifies the whole cell and compares digit runs one by - * one, so ragged fractions would put 94.4 above 94.35. */ - const num = (v, d) => (typeof v == 'number') ? v.toFixed(d) : ''; - const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–'; - const when = e => lscommon.chartStampFull(e, this.resolution); - - /* One column carries both what the packets were (IPv4/IPv6) and how - * they travelled (HTTPS/HTTP); either half may be unknown. */ - const protoCell = e => { - const fam = e.family == 'ipv6' ? 'IPv6' : (e.family == 'ipv4' ? 'IPv4' : null), - pr = e.proto ? e.proto.toUpperCase() : null; - return [ fam, pr ].filter(x => x).join(' · ') || '–'; - }; - - /* Build the cells first, then sort them: the sort key comes out of a - * cell, and ui.Table only ever orders the rows it was handed, so - * paging first would turn the headers into a per-page sort while the - * count below still speaks for every measurement. */ - /* Every display half is a node: ui.Table writes bare strings into - * innerHTML, and the server name comes from a downloaded list -- - * text must stay text. The sort key stays the first element. */ - const cell = t => E('span', {}, [ t ]); - let rows = entries.slice().reverse().map(e => [ - [ e.epoch ?? 0, cell(when(e)) ], - cell((e.server && e.server.name) || '–'), - cell(e.interface || '–'), - cell(protoCell(e)), - [ num(e.download_mbps, 2), cell(fmtNum(e.download_mbps, 2)) ], - [ num(e.upload_mbps, 2), cell(fmtNum(e.upload_mbps, 2)) ], - [ num(e.ping_ms, 1), cell(fmtNum(e.ping_ms, 1)) ], - [ num(e.jitter_ms, 1), cell(fmtNum(e.jitter_ms, 1)) ] - ]); - - const sorting = this.table.getActiveSortState(); - - if (sorting) - rows = rows - .map(L.bind(function(r) { - return [ this.table.deriveSortKey(r[sorting[0]], sorting[0]), r ]; - }, this)) - .sort((a, b) => sorting[1] - ? -L.naturalCompare(a[0], b[0]) - : L.naturalCompare(a[0], b[0])) - .map(x => x[1]); - - const pages = Math.max(1, Math.ceil(rows.length / pageSize)); - - if (this.page >= pages) - this.page = pages - 1; - - this.renderPager(rows.length, pages); - this.table.update(rows.slice(this.page * pageSize, (this.page + 1) * pageSize)); - }, - - /* Shown only when there is more than one page; the count is always - * worth having, so it stays either way. */ - renderPager(total, pages) { - const step = L.bind(function(delta) { - this.page = Math.min(pages - 1, Math.max(0, this.page + delta)); - this.redraw(); - }, this); - - const nav = [ E('span', { 'class': 'librespeed-muted' }, - [ N_(total, '%d measurement', '%d measurements').format(total) ]) ]; - - if (pages > 1) { - nav.push(E('button', { - 'class': 'cbi-button', - 'disabled': this.page > 0 ? null : '', - 'click': ui.createHandlerFn(this, function() { step(-1); }) - }, [ '\u2039 ' + _('Previous') ])); - nav.push(E('span', { 'class': 'librespeed-muted' }, - [ _('Page %d of %d').format(this.page + 1, pages) ])); - nav.push(E('button', { - 'class': 'cbi-button', - 'disabled': this.page < pages - 1 ? null : '', - 'click': ui.createHandlerFn(this, function() { step(1); }) - }, [ _('Next') + ' \u203a' ])); - } - - this.pagerNode.replaceChildren(...nav); - }, - - handleExportCSV() { - lscommon.exportCSV(this.visible(), this.resolution); - }, - - handleExportJSON() { - lscommon.exportJSON(this.visible(), this.resolution); - }, - - render() { - this.summaryNode = E('div', {}); - this.chartNode = E('div', {}); - this.controls = E('div', {}); - this.pagerNode = E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0' }); - - /* ui.Table gives sortable headers for free -- every cell below is a - * [sort key, display] pair, so Time orders by epoch even though it - * shows a locale string, and numbers order numerically. The table is - * where sorting belongs; the chart above never reorders time. */ - this.table = new ui.Table([ - _('Time'), _('Server'), _('Interface'), _('Protocol'), - _('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]') - ], { id: 'librespeed-history' }); - this.tableNode = this.table.render(); - /* The table's own handler re-sorts the page it holds; this runs - * after it and re-sorts the range, from the first page. */ - this.tableNode.addEventListener('click', L.bind(function(ev) { - if (ev.target.closest('th[data-sortable-row], .th[data-sortable-row]')) { - this.page = 0; - this.redraw(); - } - }, this)); - - const renderControls = L.bind(function() { - const groups = lscommon.switcher(this, - [ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group, - function(v) { this.group = v; renderControls(); this.redraw(); }, _('Series')); - const ranges = lscommon.switcher(this, - lscommon.RANGES.map(r => [ String(r[1]), r[0] ]), String(this.range), - function(v) { - this.range = +v; - this.page = 0; - renderControls(); - return this.fetch().then(L.bind(function() { - renderControls(); - this.redraw(); - }, this)); - }, _('Range')); - - ranges.classList.add('librespeed-push'); - - /* Filter choices are whatever the fetched range actually contains. */ - const servers = [], ifaces = []; - - this.entries.forEach(e => { - const s = e.server && e.server.name; - if (s && servers.indexOf(s) < 0) - servers.push(s); - if (e.interface && ifaces.indexOf(e.interface) < 0) - ifaces.push(e.interface); - }); - - /* The freshly fetched window may no longer contain the chosen - * server or interface -- daily aggregates carry neither field at - * all. The widget would then quietly repaint as "All" while the - * model kept filtering everything out, with no control on screen - * able to clear it; reconcile the model here, where the choice - * lists are built. */ - if (this.server && servers.indexOf(this.server) < 0) - this.server = ''; - if (this.iface && ifaces.indexOf(this.iface) < 0) - this.iface = ''; - - const filters = E('div', { 'class': 'librespeed-toolbar' }, [ - this.filterSelect(_('Server'), servers.sort(), this.server, - function(v) { this.server = v; this.page = 0; this.redraw(); }), - this.filterSelect(_('Interface'), ifaces.sort(), this.iface, - function(v) { this.iface = v; this.page = 0; this.redraw(); }) - ]); - - this.controls.innerHTML = ''; - this.controls.appendChild(E('div', { 'class': 'librespeed-toolbar' }, - [ groups, ranges ])); - this.controls.appendChild(filters); - }, this); - - this.emptyText = E('p', { 'class': 'librespeed-muted', 'style': 'margin:.5em 0 1em' }); - this.emptyStart = E('a', { - 'class': 'cbi-button cbi-button-action', - 'href': L.url('admin', 'network', 'librespeed', 'test') - }, [ _('Start test') ]); - this.emptyNode = E('div', { - 'class': 'cbi-section librespeed-center', - 'style': 'display:none; margin-top:1em' - }, [ - E('div', { 'style': 'font-size:1.4em; font-weight:600' }, - [ _('No measurements yet') ]), - this.emptyText, - this.emptyStart - ]); - - this.dataNode = E('div', {}, [ - this.chartNode, - this.summaryNode, - E('h3', { 'style': 'margin-top:.75em' }, [ _('Measurements') ]), - this.tableNode, - this.pagerNode, - E('div', { 'class': 'cbi-page-actions' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportCSV') }, - [ _('Export CSV') ]), - ' ', - E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportJSON') }, - [ _('Export JSON') ]) - ]) - ]); - - renderControls(); - this.redraw(); - - return E([], [ - lscommon.cssLink(), - E('h2', [ _('LibreSpeed – History') ]), - this.controls, - this.emptyNode, - this.dataNode - ]); - }, - - handleSaveApply: null, - handleSave: null, - handleReset: null -}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js deleted file mode 100644 index 4f75b6705..000000000 --- a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js +++ /dev/null @@ -1,172 +0,0 @@ -'use strict'; -'require view'; -'require form'; -'require rpc'; -'require tools.widgets as widgets'; -'require librespeed.common as lscommon'; - -const callConfig = rpc.declare({ - object: 'librespeed', - method: 'config', - expect: { '': {} } -}); - -return view.extend({ - load() { - return callConfig().catch(() => ({})); - }, - - render(config) { - let m, s, o; - - m = new form.Map('librespeed', _('LibreSpeed – Settings')); - - s = m.section(form.NamedSection, 'main', 'librespeed', _('Measurement')); - - o = s.option(widgets.NetworkSelect, 'interface', _('Interface'), - _('Logical interface the measurement is bound to.')); - o.default = 'wan'; - o.nocreate = true; - - o = s.option(form.Value, 'server', _('Server'), - _("Numeric server id from the LibreSpeed server list, or 'auto' to pick the closest.")); - o.default = 'auto'; - o.validate = function(section_id, value) { - if (value == '' || value == 'auto' || /^[0-9]+$/.test(value)) - return true; - return _("Must be a server id or 'auto'"); - }; - - o = s.option(form.Value, 'server_list', _('Server list URL'), - _('URL of a LibreSpeed server list in JSON, from a self-hosted deployment: either a static file such as https://example.org/servers.json or a generator such as https://example.org/backend/servers.php. Leave empty for the official list.')); - o.optional = true; - o.placeholder = 'https://librespeed.org/backend-servers/servers.php'; - o.validate = function(section_id, value) { - if (value == '' || /^https?:\/\/.+/.test(value)) - return true; - return _('Must be an http(s) URL'); - }; - - o = s.option(form.ListValue, 'scheme', _('Protocol'), - _('On routers without AES acceleration, TLS itself can bound the result — forcing plain HTTP then measures the line rather than the cipher.')); - o.value('auto', _('server default')); - o.value('https', _('force HTTPS')); - o.value('http', _('force HTTP')); - o.default = 'auto'; - - s = m.section(form.NamedSection, 'schedule', 'librespeed', _('Scheduled measurements')); - - o = s.option(form.Flag, 'enabled', _('Enable'), - _('Runs a measurement from cron at the chosen interval. Note that a measurement saturates the connection while it runs.')); - - o = s.option(form.ListValue, 'interval', _('Interval')); - /* From the shared table, so the Test page's status label and these - * choices cannot drift apart. */ - Object.keys(lscommon.INTERVALS).forEach(k => - o.value(k, lscommon.INTERVALS[k])); - o.default = '1d'; - o.depends('enabled', '1'); - - o = s.option(form.MultiValue, 'days', _('Days'), - _('Leave empty to run every day.')); - o.value('1', _('Monday')); - o.value('2', _('Tuesday')); - o.value('3', _('Wednesday')); - o.value('4', _('Thursday')); - o.value('5', _('Friday')); - o.value('6', _('Saturday')); - o.value('0', _('Sunday')); - o.optional = true; - o.depends('enabled', '1'); - /* The init script hands this straight to cron, which wants commas. */ - o.cfgvalue = function(section_id) { - const v = form.MultiValue.prototype.cfgvalue.apply(this, arguments); - return (v == null || v == '*') ? [] : String(v).split(','); - }; - o.write = function(section_id, value) { - const list = Array.isArray(value) ? value.filter(v => v !== '') : []; - if (!list.length) - return this.remove(section_id); - return this.super('write', [ section_id, list.join(',') ]); - }; - - o = s.option(form.Value, 'hours', _('Hours'), - _('An hour window on the 24-hour clock, such as 2-5 for 02:00–05:59. A daily measurement runs at a random time inside it, drawn when the schedule is applied; shorter intervals only run within it. Leave empty for any time. A window across midnight is not supported.')); - o.placeholder = '2-5'; - o.optional = true; - o.depends('enabled', '1'); - o.validate = function(section_id, value) { - if (value == '') - return true; - const m = value.match(/^([01]?[0-9]|2[0-3])(-([01]?[0-9]|2[0-3]))?$/); - if (!m) - return _('Use an hour (0-23) or a range like 2-5'); - if (m[3] != null && +m[3] < +m[1]) - return _('The window must not cross midnight'); - return true; - }; - - s = m.section(form.NamedSection, 'history', 'librespeed', _('History')); - - o = s.option(form.Flag, 'enabled', _('Keep history')); - o.default = '1'; - - o = s.option(form.Value, 'path', _('Path'), - _('History stored in /tmp is lost when the router reboots.')); - o.default = '/tmp/librespeed/history.jsonl'; - o.depends('enabled', '1'); - - o = s.option(form.ListValue, 'retention', _('Retention')); - o.value('7d', _('7 days')); - o.value('30d', _('30 days')); - o.value('90d', _('90 days')); - o.value('365d', _('1 year')); - o.default = '30d'; - o.depends('enabled', '1'); - - o = s.option(form.Value, 'archive_path', _('Archive path'), - _('Completed days are reduced to daily minimum, average and maximum and appended here once a day, so measurements since the last flush may be lost after an unexpected power loss. Leave empty to keep no persistent history.')); - o.placeholder = '/etc/librespeed/history-daily.jsonl'; - o.optional = true; - o.depends('enabled', '1'); - - o = s.option(form.ListValue, 'archive_retention', _('Archive retention')); - o.value('90d', _('90 days')); - o.value('365d', _('1 year')); - o.value('730d', _('2 years')); - o.default = '365d'; - o.depends('enabled', '1'); - - return m.render().then(node => { - const cron = config && config.schedule && config.schedule.cron; - const box = E('div', { 'style': 'margin-top:1em' }); - - if (cron) { - /* Epochs computed by the backend in the router's timezone. */ - const runs = (config && config.schedule && config.schedule.next_runs || []) - .map(e => new Date(e * 1000)); - - box.appendChild(E('h4', {}, [ _('Upcoming measurements') ])); - box.appendChild(E('table', { 'class': 'table', 'style': 'max-width:24em' }, - runs.map(d => E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, [ d.toLocaleDateString() ]), - E('td', { 'class': 'td' }, [ d.toLocaleTimeString() ]) - ])))); - box.appendChild(E('p', { 'style': 'color:#888' }, [ - _('A daily measurement runs at a time drawn when the schedule is applied; saving draws it anew.') - ])); - } - else { - box.appendChild(E('p', { 'style': 'color:#888' }, [ - _('No schedule is active. Enable it above and apply to draw the measurement time.') - ])); - } - - /* A sibling of the form, not a child: Map.render() replaces its - * own root on every Save and Reset, and would take the schedule - * preview with it. The preview reads the applied crontab, so it - * is correct to keep it as it is until Apply. */ - return E([], [ node, box ]); - }); - } -}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js deleted file mode 100644 index 36bd6bedf..000000000 --- a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js +++ /dev/null @@ -1,752 +0,0 @@ -'use strict'; -'require view'; -'require ui'; -'require poll'; -'require rpc'; -'require librespeed.common as lscommon'; - -/* The alias comes from the require above; the repo eslint config only - * knows the stock module names. */ -/* global lscommon */ - -const callStart = rpc.declare({ - object: 'librespeed', - method: 'start', - expect: { '': {} } -}); - -const callStop = rpc.declare({ - object: 'librespeed', - method: 'stop', - expect: { '': {} } -}); - -const callStatus = rpc.declare({ - object: 'librespeed', - method: 'status', - expect: { '': {} } -}); - -const callResult = rpc.declare({ - object: 'librespeed', - method: 'result', - expect: { '': {} } -}); - -const callConfig = rpc.declare({ - object: 'librespeed', - method: 'config', - expect: { '': {} } -}); - -const callHistory = rpc.declare({ - object: 'librespeed', - method: 'history', - params: [ 'from', 'to', 'limit' ], - expect: { '': {} } -}); - -/* The Test page is an overview, not analysis: a fixed last-24h download - * chart. No entry limit -- a 15-minute schedule makes 96 points a day, which - * is nothing for the SVG, and a capped fetch would quietly turn "last 24 - * hours" into "last few hours". The History page fetches whatever range the - * user asks for; this page never should. */ -const recentSeconds = 86400; -const recentRows = 5; - -/* A measurement takes tens of seconds, so nothing waits on it: start returns as - * soon as the job is detached and this is how often we ask how it is going. */ -const pollInterval = 2; - -function fmt(value, digits) { - return (typeof value == 'number' && isFinite(value)) - ? value.toFixed(digits != null ? digits : 2) - : '–'; -} - -function fmtTime(epochOrIso) { - if (!epochOrIso) - return ''; - - const d = (typeof epochOrIso == 'number') - ? new Date(epochOrIso * 1000) - : new Date(epochOrIso); - - return isNaN(d.getTime()) ? String(epochOrIso) : d.toLocaleString(); -} - -function fmtElapsed(seconds) { - if (!(seconds >= 0)) - return ''; - - const m = Math.floor(seconds / 60), - s = Math.floor(seconds % 60); - - return '%d:%02d'.format(m, s); -} - -/* The three stages a measurement walks through, with the share of the overall - * bar each one gets. Ping streams no percent, so it holds a token slice. */ -const PHASES = [ - [ 'ping', 0, 10, _('Ping') ], - [ 'download', 10, 55, _('Download') ], - [ 'upload', 55, 100, _('Upload') ] -]; - -/* Maps the streamed per-phase percent onto one overall run percent, so the - * bar moves forward through the whole run instead of refilling per phase. - * Null means there is nothing honest to show and the bar stays indeterminate. */ -function overallProgress(status) { - const span = PHASES.find(p => p[0] == status.phase); - - if (!span) - return null; - if (typeof status.progress != 'number') - return (status.phase == 'ping') ? 5 : null; - - return Math.round(span[1] + (span[2] - span[1]) * Math.min(status.progress, 100) / 100); -} - -function polar(cx, cy, r, deg) { - const a = deg * Math.PI / 180; - return [ cx + r * Math.cos(a), cy - r * Math.sin(a) ]; -} - -/* The dial's top end: the smallest "nice" ceiling above the fastest rate seen, - * so a 550 Mbps line gets a 600 dial rather than pinning the needle at an - * arbitrary full stop. Grows during the run when the rate outruns it. */ -function niceTop(mbps) { - /* Each of these divides cleanly by 5 and 10, so the dial's labels and - * ticks always land on round numbers: a ~560 Mbps line gets a 0-1000 - * dial labelled every 200 with ticks every 100. */ - const steps = [ 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000 ]; - - for (let i = 0; i < steps.length; i++) - if (mbps <= steps[i]) - return steps[i]; - - return Math.ceil(mbps / 10000) * 10000; -} - -function gaugeLabel(v) { - return v >= 1000 ? (v / 1000) + ' G' : String(v); -} - -/* The running test's centrepiece: a circular speedometer with a needle. The - * dial is the current rate against the niceTop() range -- never the test's - * completion, which is a different number and reads as a small percentage - * below the circle. */ -function gauge(mbps, top, ci) { - ci = ci || 0; - const cx = 100, cy = 95, r = 80, span = 216, start = 198; - const p1 = polar(cx, cy, r, start), - p2 = polar(cx, cy, r, start - span); - const len = span / 360 * 2 * Math.PI * r; - const path = 'M %f %f A %f %f 0 1 1 %f %f'.format(p1[0], p1[1], r, r, p2[0], p2[1]); - const amt = (typeof mbps == 'number') - ? Math.max(0, Math.min(1, mbps / top)) : 0; - - /* Ticks every tenth of the range, longer ones with a label every fifth: - * a 0-1000 dial reads 0/200/400/600/800/1000 with marks each 100. */ - let marks = ''; - - for (let i = 0; i <= 10; i++) { - const a = start - i / 10 * span, - major = (i % 2 == 0), - t1 = polar(cx, cy, r - 10, a), - t2 = polar(cx, cy, r - (major ? 18 : 14), a); - marks += '' - .format(t1[0], t1[1], t2[0], t2[1]); - - if (major) { - const p = polar(cx, cy, r - 28, a); - marks += '%s' - .format(p[0], p[1] + 3, gaugeLabel(top * i / 10)); - } - } - - const tip = polar(cx, cy, r - 20, start - amt * span); - - return '' + - ''.format(path) + - '' - .format(ci, path, amt * len, len) + - marks + - '' - .format(cx, cy, tip[0], tip[1]) + - ''.format(cx, cy) + - '%s' - .format((typeof mbps == 'number') ? '%.2f'.format(mbps) : '–') + - 'Mbps' + - ''; -} - -/* One value with its small label underneath; the figures are the point of the - * page, so they get the size. */ -function figure(value, unit, label, big) { - return E('div', {}, [ - E('div', { 'class': big ? 'librespeed-value' : 'librespeed-value-sm' }, [ - value, - E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.45em' }, [ ' ' + unit ]) - ]), - E('div', { 'class': 'librespeed-muted librespeed-caps' }, [ label ]) - ]); -} - -return view.extend({ - alertNode: null, - statusNode: null, - resultHeading: null, - resultNode: null, - button: null, - lastSeen: null, - config: {}, - - load() { - return Promise.all([ - callStatus().catch(() => ({})), - callResult().catch(() => ({})), - callConfig().catch(() => ({})), - callHistory(Math.floor(Date.now() / 1000 - recentSeconds), - undefined, 0).catch(() => ({})) - ]); - }, - - renderRecent(data) { - const entries = Array.isArray(data && data.entries) ? data.entries : []; - - /* Kept so switching the metric can redraw without refetching. */ - this.recentData = data; - - this.recentNode.innerHTML = ''; - this.recentCount.textContent = entries.length - ? N_(entries.length, '%d measurement', '%d measurements') - .format(entries.length) : ''; - - /* The active button names the metric, so the subtitle only carries - * the window. */ - this.recentSwitch.replaceChildren(...lscommon.switcher(this, - lscommon.GROUPS.speed.map(k => { - const m = lscommon.METRICS.find(x => x[0] == k); - return [ k, m[1] ]; - }), this.recentMetric, - function(v) { - this.recentMetric = v; - this.renderRecent(this.recentData); - }, _('Metric')).childNodes); - - /* The stability story of the last day, told in one sentence and four - * figures: what is normal, how much it wobbles, where it is now. */ - const metric = this.recentMetric || 'download_mbps'; - const st = lscommon.seriesStats(entries, metric, data && data.resolution); - - if (st && st.count > 1) { - const varPct = (st.max - st.min) / st.avg * 100, - offPct = (st.current - st.avg) / st.avg * 100; - let story; - - /* The heading above names the metric, so the sentence does not - * repeat it -- and a word like "download" cannot be spliced into - * a translated sentence anyway. */ - if (offPct < -15) - story = _('Declining — the last measurement is %d%% below the 24-hour average.') - .format(Math.round(-offPct)); - else if (varPct > 30) - story = _('Variable — ranged from %d to %d Mbps.') - .format(Math.round(st.min), Math.round(st.max)); - else - story = _('Stable — averaged %d Mbps, ranging from %d to %d Mbps.') - .format(Math.round(st.avg), Math.round(st.min), Math.round(st.max)); - - this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:.25em 0 .5em' }, - [ story ])); - } - - const chart = E('div', {}); - this.recentNode.appendChild(chart); - - const drew = lscommon.renderChart(chart, entries, { - series: [ metric ], - resolution: data && data.resolution, - hover: true - }); - - /* Entries may well exist -- the count above says so -- while none of - * them carries a download figure, so name the window rather than - * claiming there is nothing at all. */ - if (!drew) { - this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted' }, - [ _('No data in the last 24 hours.') ])); - return; - } - - if (st && st.count > 1) { - const stat = (label, v) => E('span', {}, [ - E('strong', {}, [ '%d'.format(Math.round(v)) ]), - E('span', { 'class': 'librespeed-muted' }, [ ' ' + label ]) - ]); - - this.recentNode.appendChild(E('div', { - 'class': 'librespeed-muted librespeed-stats', - 'style': 'margin:1em 0 1.5em' - }, [ - stat(_('current'), st.current), - stat(_('average'), st.avg), - stat(_('best'), st.max), - stat(_('worst'), st.min) - ])); - } - - const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–'; - /* The shared formatter carries the date-only guard: a daily - * aggregate's bare date fed to new Date() is read as UTC midnight - * and shifts a calendar day in negative offsets. */ - const when = e => lscommon.chartStampFull(e, data && data.resolution); - - /* The sort key carries the same decimal count as the display half: - * ui.Table stringifies the whole cell and compares digit runs one by - * one, so ragged fractions would put 94.4 above 94.35. */ - const num = (v, d) => (typeof v == 'number') ? v.toFixed(d) : ''; - - /* Nodes, not strings: ui.Table writes bare strings into innerHTML - * and the server name comes from a downloaded list. */ - const cell = t => E('span', {}, [ t ]); - this.recentTable.update(entries.slice(-recentRows).reverse().map(e => [ - [ e.epoch ?? 0, cell(when(e)) ], - cell((e.server && e.server.name) || '–'), - [ num(e.download_mbps, 2), cell(fmtNum(e.download_mbps, 2)) ], - [ num(e.upload_mbps, 2), cell(fmtNum(e.upload_mbps, 2)) ], - [ num(e.ping_ms, 1), cell(fmtNum(e.ping_ms, 1)) ], - [ num(e.jitter_ms, 1), cell(fmtNum(e.jitter_ms, 1)) ] - ])); - this.recentNode.appendChild(this.recentTableNode); - - this.recentNode.appendChild(E('div', { 'style': 'text-align:right; margin-top:.75em' }, [ - E('a', { 'href': L.url('admin', 'network', 'librespeed', 'history') }, - [ _('View full history »') ]) - ])); - }, - - handleStart(ev) { - return callStart().then(L.bind(function(res) { - if (res && res.error) { - ui.addNotification(null, E('p', [ - _('Could not start the measurement: %s').format(res.error) - ])); - return; - } - - /* Show the running state immediately rather than waiting up to - * pollInterval for the next status. */ - this.renderStatus({ running: true, started: Date.now() / 1000 }); - this.setBusy(true); - }, this)); - }, - - handleStop(ev) { - return callStop().then(L.bind(function() { - this.setBusy(false); - }, this)); - }, - - setBusy(running) { - if (!this.button) - return; - - this.button.textContent = running ? _('Stop test') : _('Start test'); - this.button.className = running ? 'cbi-button cbi-button-reset' - : 'cbi-button cbi-button-action'; - this.button.onclick = ui.createHandlerFn(this, - running ? 'handleStop' : 'handleStart'); - }, - - runContext() { - const parts = []; - - if (this.config.server && this.config.server != 'auto') - parts.push(_('server %s').format(this.config.server)); - if (this.config.interface) - parts.push(this.config.interface); - - return parts.join(' · '); - }, - - renderStatus(status) { - this.alertNode.innerHTML = ''; - this.statusNode.innerHTML = ''; - - /* The result block steps aside while a measurement runs -- the page - * narrates one thing at a time. */ - this.resultNode.style.display = status.running ? 'none' : ''; - this.resultHeading.textContent = (!status.running && status.last_error) - ? _('Last successful measurement') : ''; - - /* One quiet live region, outside the tree that is rebuilt every - * poll: announcing the rebuilt gauge SVG would read the whole dial - * out several times a run. Set only on change, so it speaks once - * per phase step. */ - const announce = status.running - ? (PHASES.find(p => p[0] == status.phase) || [])[3] || _('Connecting to the test server…') - : ''; - - if (this.liveNode.textContent != announce) - this.liveNode.textContent = announce; - - if (status.running) { - const rows = []; - - /* The walk through the stages, with the current one highlighted -- - * this is what says why nothing moves yet while connecting. */ - const phaseIdx = PHASES.findIndex(p => p[0] == status.phase); - - rows.push(E('div', { 'class': 'librespeed-steps librespeed-caps' }, - PHASES.map((p, i) => E('span', { - 'class': 'librespeed-step' + - (i == phaseIdx ? ' librespeed-step-active' : - (phaseIdx >= 0 && i < phaseIdx ? ' librespeed-step-done' : '')), - /* Spoken as well as dimmed: opacity is invisible to a - * screen reader. */ - 'aria-current': i == phaseIdx ? 'step' : null - }, [ p[3] ])))); - - /* The dial range is frozen at the start of the run -- a familiar - * line gets a familiar dial -- and only ever expands, once the - * rate actually exceeds it. Recomputing it downwards mid-run - * would pull the needle back while the speed grows. */ - if (!this.wasRunning) - this.runTop = niceTop(Math.max(this.lastPeak || 0, 50) * 1.02); - if (typeof status.mbps == 'number' && status.mbps > this.runTop) - this.runTop = niceTop(status.mbps * 1.02); - this.wasRunning = true; - - /* The spinner is LuCI's own; while the client still negotiates - * with the server there is nothing to point the gauge at. */ - if (phaseIdx < 0) - rows.push(E('p', { 'class': 'spinning', 'style': 'margin-top:.75em' }, - [ _('Connecting to the test server…') ])); - else { - /* The dial is hidden from assistive tech -- its ten tick - * labels are noise there -- so the one figure it carries, - * the running rate, is repeated as plain hidden text that - * browse mode can still reach. */ - const holder = E('div', { 'aria-hidden': 'true' }); - holder.innerHTML = gauge( - (typeof status.mbps == 'number' && status.mbps > 0) - ? status.mbps : null, - this.runTop, - status.phase == 'upload' ? 1 : 0); - rows.push(holder); - if (typeof status.mbps == 'number' && status.mbps > 0) - rows.push(E('span', { 'class': 'librespeed-visually-hidden' }, - [ '%.2f Mbps'.format(status.mbps) ])); - } - - /* Completion is a different number than the rate on the arc, so - * it stays a small figure below the circle rather than feeding - * the gauge and pretending to be a speed. */ - const overall = overallProgress(status); - - if (overall != null) - rows.push(E('div', { 'class': 'librespeed-muted' }, - [ overall + ' %' ])); - - /* The backend counts on the clock that stamped the start; the - * difference of two unrelated clocks can be negative. The old - * subtraction stays as a fallback for an updating router. */ - const elapsed = typeof status.elapsed == 'number' - ? fmtElapsed(status.elapsed) - : (status.started - ? fmtElapsed(Date.now() / 1000 - status.started) : ''); - - if (elapsed) - rows.push(E('div', { 'class': 'librespeed-muted' }, - [ _('%s elapsed').format(elapsed) ])); - - const ctx = this.runContext(); - - if (ctx) - rows.push(E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [ ctx ])); - - rows.forEach(r => this.statusNode.appendChild(r)); - } - else - this.wasRunning = false; - - if (!status.running && status.last_error == 'stopped') { - /* Stopping is something the user did, not something that went - * wrong; a red banner would turn their own click into an error. */ - this.alertNode.appendChild(E('p', { 'class': 'librespeed-muted' }, [ - _('The last measurement was stopped.'), - status.last_finished ? ' · ' + fmtTime(status.last_finished) : '' - ])); - } - else if (!status.running && status.last_error) { - /* Compact, and above the figures: the failure is news, the last - * good result is still the substance of the page. */ - this.alertNode.appendChild(E('div', { - 'class': 'alert-message warning', - 'style': 'display:inline-block; text-align:left' - }, [ - E('strong', {}, [ _('Last measurement failed') ]), - E('br'), - status.last_error, - status.last_finished ? ' · ' + fmtTime(status.last_finished) : '' - ])); - } - }, - - renderResult(result) { - this.resultNode.innerHTML = ''; - - /* Remembered as the dial-range seed for the next run. */ - if (result && (typeof result.download_mbps == 'number' || typeof result.upload_mbps == 'number')) - this.lastPeak = Math.max(result.download_mbps || 0, result.upload_mbps || 0); - - if (!result || result.download_mbps == null) { - this.resultNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [ - _('No measurement yet.') - ])); - return; - } - - /* The result in the same visual language the run used: one dial per - * direction, both on one shared scale so they compare at a glance, - * with the latency figures small beside them. */ - const top = niceTop(Math.max(result.download_mbps || 0, - result.upload_mbps || 0, 50) * 1.02); - /* Same color language as the History chart -- download is always the - * theme color, upload the companion shade -- plus the direction arrow, - * so the two dials cannot be mistaken for one another. */ - const dial = (v, label, ci, arrow) => { - const holder = E('div', { 'class': 'librespeed-result-gauge' }); - holder.innerHTML = gauge((typeof v == 'number') ? v : null, top, ci); - holder.appendChild(E('div', { 'class': 'librespeed-caps' }, [ - E('span', { 'class': 'librespeed-fg-%d'.format(ci), 'style': 'font-weight:700' }, - [ arrow + ' ' ]), - E('span', { 'class': 'librespeed-muted' }, [ label ]) - ])); - return holder; - }; - - this.resultNode.appendChild(E('div', { 'class': 'librespeed-result-gauges' }, [ - dial(result.download_mbps, _('Download'), 0, '\u2193'), - dial(result.upload_mbps, _('Upload'), 1, '\u2191') - ])); - - this.resultNode.appendChild(E('div', { - 'style': 'display:flex; justify-content:center; gap:3.5em; margin-top:.5em' - }, [ - figure(fmt(result.ping_ms, 1), 'ms', _('Ping'), false), - figure(fmt(result.jitter_ms, 1), 'ms', _('Jitter'), false) - ])); - - /* One fact per row rather than one long sentence: each has its own - * meaning and the eye can scan for the one it wants. */ - const meta = []; - - if (result.server && result.server.name) - meta.push([ _('Server'), result.server.name ]); - if (result.interface) - meta.push([ _('Interface'), result.interface ]); - - const proto = []; - - if (result.family) - proto.push(result.family == 'ipv6' ? 'IPv6' : 'IPv4'); - if (result.proto) - proto.push(result.proto == 'https' ? _('encrypted (HTTPS)') : _('plain HTTP')); - if (proto.length) - meta.push([ _('Protocol'), proto.join(' \u00b7 ') ]); - - if (result.timestamp) - meta.push([ _('Completed'), fmtTime(result.timestamp) ]); - - const grid = E('div', { 'class': 'librespeed-meta' }); - - meta.forEach(m => { - grid.appendChild(E('span', { 'class': 'librespeed-muted' }, [ m[0] ])); - grid.appendChild(E('span', {}, [ m[1] ])); - }); - - this.resultNode.appendChild(grid); - - /* The link comes from the telemetry server's reply -- from outside - * the router -- so only plain web schemes may pass into the href. */ - if (result.share && /^https?:\/\//i.test(result.share)) - this.resultNode.appendChild(E('p', { 'style': 'margin:.5em 0 0' }, [ - E('a', { - 'href': result.share, - 'target': '_blank', - 'rel': 'noreferrer' - }, [ _('View result') ]) - ])); - }, - - render(data) { - const status = data[0] || {}, - result = data[1] || {}, - config = data[2] || {}, - recent = data[3] || {}; - - this.config = config; - this.alertNode = E('div', {}); - this.statusNode = E('div', {}); - this.resultHeading = E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.35em' }); - this.resultNode = E('div', {}); - this.recentNode = E('div', {}); - this.liveNode = E('div', { - 'role': 'status', - 'aria-live': 'polite', - 'class': 'librespeed-visually-hidden' - }); - this.recentCount = E('span', { 'class': 'librespeed-muted' }); - this.recentSubtitle = E('div', { 'class': 'librespeed-muted' }, - [ _('last 24 hours') ]); - /* Built once and refilled: ui.Table gives sortable headers, the same - * as the History page's table. */ - this.recentTable = new ui.Table([ - _('Time'), _('Server'), - _('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]') - ], { id: 'librespeed-recent' }); - this.recentTableNode = this.recentTable.render(); - this.recentMetric = 'download_mbps'; - /* Two directions, one chart at a time: the page answers "how am I - * doing" at a glance, and mixing both series would make that a - * comparison instead. History is where series get combined. The - * buttons themselves are filled by renderRecent, first call included. */ - this.recentSwitch = E('div', {}); - this.button = E('button', { 'class': 'cbi-button cbi-button-action' }, - [ _('Start test') ]); - - this.lastSeen = status.last_finished; - - this.renderStatus(status); - this.renderResult(result); - this.renderRecent(recent); - this.setBusy(!!status.running); - - poll.add(L.bind(function() { - return callStatus().then(L.bind(function(st) { - st = st || {}; - this.renderStatus(st); - this.setBusy(!!st.running); - - /* Only refetch the result and the recent chart when a run has - * actually finished, rather than on every tick. */ - if (!st.running && st.last_finished !== this.lastSeen) { - this.lastSeen = st.last_finished; - return Promise.all([ - callResult().then(L.bind(this.renderResult, this)), - callHistory(Math.floor(Date.now() / 1000 - recentSeconds), - undefined, 0) - .then(L.bind(this.renderRecent, this)) - .catch(() => {}) - ]); - } - }, this)); - }, this), pollInterval); - - /* A third element, where a row has one, explains the value on hover - * or focus. data-tooltip is LuCI's own mechanism -- a plain title - * attribute leaves it to the browser, which shows nothing on some -- - * and the dotted underline is the affordance, since the theme styles - * tooltips only inside form fields. The panel stays a compact status - * list, and the reasoning still lives where the choice is made. */ - const kv = rows => E('table', { 'class': 'table' }, - rows.map(r => E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td librespeed-muted', 'style': 'width:45%' }, [ r[0] ]), - E('td', { 'class': 'td' }, [ - r[2] ? E('span', { - /* tabindex, or the focus half of "hover or focus" - * never fires: a bare span is not in the tab order. */ - 'class': 'librespeed-hint', - 'tabindex': '0', - 'data-tooltip': r[2] - }, [ r[1] ]) : r[1] - ]) - ]))); - - /* Computed by the backend in the router's timezone; the browser may - * well sit in another one, so it only formats the epoch. */ - const sched = config.schedule || {}; - const next = sched.next_runs || []; - /* Strict, matching history.js: the backend derives this from the - * crontab and emits a real boolean, never UCI's '0'/'1'. */ - const schedRows = [ [ _('Enabled'), sched.enabled === true ? _('Yes') : _('No') ] ]; - - if (sched.enabled === true) { - /* The shared table translates the six tokens the UI offers; the - * init script accepts more, so the raw token is the fallback. */ - schedRows.push([ _('Interval'), - lscommon.INTERVALS[sched.interval || '1d'] || sched.interval ]); - if (next.length) - schedRows.push([ _('Next run'), new Date(next[0] * 1000).toLocaleString() ]); - } - - /* The main card carries the result; the aside carries what the next - * run will do. Plain sections and tables, so every theme renders it. */ - const aside = E('div', {}, [ - E('div', { 'class': 'cbi-section', 'style': 'margin:0 0 1em 0' }, [ - E('h3', [ _('Test configuration') ]), - kv([ - [ _('Interface'), config.interface || 'wan' ], - [ _('Server'), (config.server == 'auto' || !config.server) - ? _('automatic (nearest)') : config.server ], - [ _('Protocol'), - config.scheme == 'https' ? _('HTTPS (forced)') - : (config.scheme == 'http' ? _('HTTP (forced)') - : _('server default')), - config.scheme == 'http' - ? _('Unencrypted transfers, so the line is measured rather than the cipher.') - : (config.scheme == 'https' - ? _('Encrypted transfers, which on hardware without AES acceleration may bound the result.') - : _('Whichever scheme the server list provides.')) ] - ]) - ]), - E('div', { 'class': 'cbi-section', 'style': 'margin:0' }, [ - E('h3', [ _('Schedule status') ]), - kv(schedRows) - ]) - ]); - - const main = E('div', { 'class': 'cbi-section librespeed-center', 'style': 'margin:0' }, [ - this.alertNode, - this.liveNode, - this.statusNode, - this.resultHeading, - this.resultNode, - E('div', { 'style': 'margin-top:1em' }, [ this.button ]) - ]); - - const recentSection = E('div', { 'class': 'cbi-section', 'style': 'margin:1.5em 0 0 0' }, [ - /* Heading and count on one line, the controls on their own below: - * the metric buttons and the window they cover belong together, - * the way the History toolbar pairs series with range. */ - E('div', { 'class': 'librespeed-footer', 'style': 'margin:0; align-items:baseline' }, [ - E('h3', { 'style': 'margin:0' }, [ _('Recent history') ]), - this.recentCount - ]), - E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0 .75em' }, [ - this.recentSwitch, - this.recentSubtitle - ]), - this.recentNode - ]); - - return E([], [ - lscommon.cssLink(), - E('h2', [ _('LibreSpeed') ]), - E('h3', [ _('Router speed test') ]), - E('p', { 'class': 'librespeed-muted' }, - [ _('Measures the internet connection from this router.') ]), - E('div', { 'class': 'librespeed-columns' }, [ - E('div', {}, [ main, recentSection ]), - aside - ]) - ]); - }, - - handleSaveApply: null, - handleSave: null, - handleReset: null -}); diff --git a/luci-app-librespeed/po/templates/librespeed.pot b/luci-app-librespeed/po/templates/librespeed.pot deleted file mode 100644 index d5a6568e2..000000000 --- a/luci-app-librespeed/po/templates/librespeed.pot +++ /dev/null @@ -1,622 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:315 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:223 -msgid "%d measurement" -msgid_plural "%d measurements" -msgstr[0] "" -msgstr[1] "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:463 -msgid "%s elapsed" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:123 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:135 -msgid "1 year" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:30 -msgid "1y" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:136 -msgid "2 years" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:27 -msgid "24h" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:121 -msgid "30 days" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:29 -msgid "30d" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:120 -msgid "7 days" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:28 -msgid "7d" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:122 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:134 -msgid "90 days" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:156 -msgid "" -"A daily measurement runs at a time drawn when the schedule is applied; " -"saving draws it anew." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:31 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:94 -msgid "All" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:94 -msgid "" -"An hour window on the 24-hour clock, such as 2-5 for 02:00–05:59. A daily " -"measurement runs at a random time inside it, drawn when the schedule is " -"applied; shorter intervals only run within it. Leave empty for any time. A " -"window across midnight is not supported." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:127 -msgid "Archive path" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:133 -msgid "Archive retention" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:562 -msgid "Completed" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:128 -msgid "" -"Completed days are reduced to daily minimum, average and maximum and " -"appended here once a day, so measurements since the last flush may be lost " -"after an unexpected power loss. Leave empty to keep no persistent history." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:389 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:426 -msgid "Connecting to the test server…" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:334 -msgid "Could not start the measurement: %s" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:161 -msgid "Daily" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:249 -msgid "Daily minimum, average and maximum." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:70 -msgid "Days" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:252 -msgid "Declining — the last measurement is %d%% below the 24-hour average." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:11 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:92 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:532 -msgid "Download" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:355 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:609 -msgid "Download [Mbps]" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:59 -msgid "Enable" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:675 -msgid "Enabled" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:702 -msgid "" -"Encrypted transfers, which on hardware without AES acceleration may bound " -"the result." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:160 -msgid "Every 12 hours" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:156 -msgid "Every 15 minutes" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:157 -msgid "Every 30 minutes" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:159 -msgid "Every 6 hours" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:443 -msgid "Export CSV" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:446 -msgid "Export JSON" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:76 -msgid "Friday" -msgstr "" - -#: applications/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json:3 -msgid "Grant access to LibreSpeed measurements" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:697 -msgid "HTTP (forced)" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:696 -msgid "HTTPS (forced)" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:159 -msgid "Hide this series" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:109 -#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:25 -msgid "History" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:215 -msgid "History keeping is switched off, so measurements are not recorded." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:115 -msgid "History stored in /tmp is lost when the router reboots." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:158 -msgid "Hourly" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:93 -msgid "Hours" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:354 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:410 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:26 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:550 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:692 -msgid "Interface" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:62 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:680 -msgid "Interval" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:14 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:540 -msgid "Jitter" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:355 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:609 -msgid "Jitter [ms]" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:111 -msgid "Keep history" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:490 -msgid "Last measurement failed" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:382 -msgid "Last successful measurement" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:369 -msgid "Latency" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:71 -msgid "Leave empty to run every day." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:738 -#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:3 -msgid "LibreSpeed" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:455 -msgid "LibreSpeed – History" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:22 -msgid "LibreSpeed – Settings" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:27 -msgid "Logical interface the measurement is bound to." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:24 -msgid "Measurement" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:438 -msgid "Measurements" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:741 -msgid "Measures the internet connection from this router." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:236 -msgid "Metric" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:72 -msgid "Monday" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:37 -msgid "Must be a server id or 'auto'" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:47 -msgid "Must be an http(s) URL" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:329 -msgid "Next" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:683 -msgid "Next run" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:675 -msgid "No" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:246 -msgid "No data for the selected metric." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:279 -msgid "No data in the last 24 hours." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:507 -msgid "No measurement yet." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:218 -msgid "No measurements match the current filters." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:430 -msgid "No measurements yet" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:161 -msgid "" -"No schedule is active. Enable it above and apply to draw the measurement " -"time." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:32 -msgid "" -"Numeric server id from the LibreSpeed server list, or 'auto' to pick the " -"closest." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:51 -msgid "" -"On routers without AES acceleration, TLS itself can bound the result — " -"forcing plain HTTP then measures the line rather than the cipher." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:223 -msgid "Open settings" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:324 -msgid "Page %d of %d" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:114 -msgid "Path" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:13 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:91 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:539 -msgid "Ping" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:355 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:609 -msgid "Ping [ms]" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:322 -msgid "Previous" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:354 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:50 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:559 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:695 -msgid "Protocol" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:381 -msgid "Range" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:726 -msgid "Recent history" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:119 -msgid "Retention" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:739 -msgid "Router speed test" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:217 -msgid "Run a speed test to start building your connection history." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:60 -msgid "" -"Runs a measurement from cron at the chosen interval. Note that a measurement " -"saturates the connection while it runs." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:77 -msgid "Saturday" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:707 -msgid "Schedule status" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:57 -msgid "Scheduled measurements" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:370 -msgid "Series" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:354 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:408 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:31 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:548 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:608 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:693 -msgid "Server" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:40 -msgid "Server list URL" -msgstr "" - -#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:34 -msgid "Settings" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:159 -msgid "Show this series" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:369 -msgid "Speed" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:189 -msgid "Stable over the selected period." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:258 -msgid "Stable — averaged %d Mbps, ranging from %d to %d Mbps." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:223 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:424 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:356 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:619 -msgid "Start test" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:356 -msgid "Stop test" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:78 -msgid "Sunday" -msgstr "" - -#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:16 -msgid "Test" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:690 -msgid "Test configuration" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:479 -msgid "The last measurement was stopped." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:105 -msgid "The window must not cross midnight" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:75 -msgid "Thursday" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:354 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:608 -msgid "Time" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:73 -msgid "Tuesday" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:41 -msgid "" -"URL of a LibreSpeed server list in JSON, from a self-hosted deployment: " -"either a static file such as https://example.org/servers.json or a generator " -"such as https://example.org/backend/servers.php. Leave empty for the " -"official list." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:700 -msgid "Unencrypted transfers, so the line is measured rather than the cipher." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:149 -msgid "Upcoming measurements" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:12 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:93 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:533 -msgid "Upload" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:355 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:609 -msgid "Upload [Mbps]" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:103 -msgid "Use an hour (0-23) or a range like 2-5" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:255 -msgid "Variable — ranged from %d to %d Mbps." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:326 -msgid "View full history »" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:581 -msgid "View result" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:74 -msgid "Wednesday" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:703 -msgid "Whichever scheme the server list provides." -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:675 -msgid "Yes" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:694 -msgid "automatic (nearest)" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:294 -msgid "average" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:287 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:358 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:168 -msgid "avg" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:295 -msgid "best" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:293 -msgid "current" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:179 -msgid "declining" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:557 -msgid "encrypted (HTTPS)" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:54 -msgid "force HTTP" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:53 -msgid "force HTTPS" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:179 -msgid "highly variable" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:604 -msgid "last 24 hours" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:557 -msgid "plain HTTP" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:367 -msgid "server %s" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:52 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:698 -msgid "server default" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:179 -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:186 -msgid "stable" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:134 -msgid "vs previous period" -msgstr "" - -#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:296 -msgid "worst" -msgstr "" diff --git a/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json b/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json deleted file mode 100644 index d29f6be04..000000000 --- a/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "admin/network/librespeed": { - "title": "LibreSpeed", - "order": 70, - "action": { - "type": "alias", - "path": "admin/network/librespeed/test" - }, - "depends": { - "acl": [ "luci-app-librespeed" ], - "uci": { "librespeed": true } - } - }, - - "admin/network/librespeed/test": { - "title": "Test", - "order": 10, - "action": { - "type": "view", - "path": "librespeed/test" - } - }, - - "admin/network/librespeed/history": { - "title": "History", - "order": 20, - "action": { - "type": "view", - "path": "librespeed/history" - } - }, - - "admin/network/librespeed/settings": { - "title": "Settings", - "order": 30, - "action": { - "type": "view", - "path": "librespeed/settings" - } - } -} diff --git a/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json b/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json deleted file mode 100644 index 1126662cd..000000000 --- a/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "luci-app-librespeed": { - "description": "Grant access to LibreSpeed measurements", - "read": { - "ubus": { - "librespeed": [ - "status", - "result", - "history", - "config" - ], - "network.interface": [ - "dump" - ], - "network": [ - "get_proto_handlers" - ], - "luci-rpc": [ - "getBoardJSON", - "getNetworkDevices", - "getWirelessDevices" - ] - }, - "uci": [ - "librespeed", - "network", - "wireless" - ] - }, - "write": { - "ubus": { - "librespeed": [ - "start", - "stop" - ] - }, - "uci": [ - "librespeed" - ] - } - } -} diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 55ecece18..a890f2017 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.8.19 -PKG_RELEASE:=240 +PKG_RELEASE:=241 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/passwall/server_app.lua b/luci-app-passwall/luasrc/passwall/server_app.lua index 3c2d01977..2d0b5757a 100644 --- a/luci-app-passwall/luasrc/passwall/server_app.lua +++ b/luci-app-passwall/luasrc/passwall/server_app.lua @@ -161,6 +161,7 @@ local function start() end) if firewall_num > 0 then api.uci_save(uci, "firewall", true, true) + cmd("/etc/init.d/firewall reload >/dev/null 2>&1") end end @@ -176,6 +177,7 @@ local function stop() end) if num > 0 then api.uci_save(uci, "firewall", true, true) + cmd("/etc/init.d/firewall reload >/dev/null 2>&1") end end cmd(string.format("rm -rf %s %s", CONFIG_PATH, LOG_APP_FILE)) diff --git a/luci-app-shunt/Makefile b/luci-app-shunt/Makefile new file mode 100644 index 000000000..50201edd4 --- /dev/null +++ b/luci-app-shunt/Makefile @@ -0,0 +1,16 @@ +# Copyright 2026 Dirk Brenken (dev@brenken.org) +# This is free software, licensed under the Apache License, Version 2.0 + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for shunt +LUCI_DEPENDS:=+luci-base +shunt + +PKG_VERSION:=0.1.5 +PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Dirk Brenken + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js new file mode 100644 index 000000000..6f714d78a --- /dev/null +++ b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js @@ -0,0 +1,4 @@ +'use strict'; +'require view.shunt.logtemplate as LogTemplate'; + +return LogTemplate.Logview(/\bshunt(\[\d+\])?:/, 'shunt'); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js new file mode 100644 index 000000000..70e876c61 --- /dev/null +++ b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js @@ -0,0 +1,68 @@ +'use strict'; +'require rpc'; + +const callLogRead = rpc.declare({ + object: 'log', + method: 'read', + params: ['lines', 'stream', 'oneshot'], + expect: {} +}); + +function Logview(logtag, name) { + return L.view.extend({ + load: () => Promise.resolve(), + + render: function () { + const pollFn = () => { + return callLogRead(1000, false, true).then(res => { + const logEl = document.getElementById('logfile'); + if (!logEl) return; + const filtered = (res?.log ?? []) + .filter(entry => !logtag || (logtag instanceof RegExp + ? logtag.test(entry.msg) + : entry.msg.includes(logtag))) + .map(entry => { + const d = new Date(entry.time); + const pad = n => String(n).padStart(2, '0'); + const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`; + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return `[${date}-${time}] ${entry.msg}`; + }); + logEl.value = filtered.length > 0 + ? filtered.join('\n') + : _('No %s related logs yet!').format(name); + logEl.scrollTop = logEl.scrollHeight; + }); + }; + + this._pollFn = pollFn; + L.Poll.add(pollFn); + + return E('div', { class: 'cbi-map' }, [ + E('div', { class: 'cbi-section' }, [ + E('div', { class: 'cbi-section-descr' }, + _('The syslog output, pre-filtered for messages related to: %s').format(name)), + E('textarea', { + id: 'logfile', + style: 'min-height: 500px; max-height: 90vh; width: 100%; padding: 5px; font-family: monospace; resize: vertical;', + readonly: 'readonly', + wrap: 'off' + }) + ]) + ]); + }, + + unload: function () { + if (this._pollFn) { + L.Poll.remove(this._pollFn); + this._pollFn = null; + } + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null + }); +} + +return L.Class.extend({ Logview }); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js new file mode 100644 index 000000000..7ae3d2606 --- /dev/null +++ b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js @@ -0,0 +1,441 @@ +'use strict'; +'require dom'; +'require view'; +'require poll'; +'require fs'; +'require ui'; +'require uci'; +'require rpc'; +'require form'; +'require tools.widgets as widgets'; + +const getStatus = rpc.declare({ + object: 'luci.shunt', + method: 'status' +}); + +const getPackages = rpc.declare({ + object: 'rpc-sys', + method: 'packagelist', + params: ['all'], + expect: { packages: {} } +}); + +function handleAction(ev) { + if (ev === 'restart') { + const map = document.querySelector('.cbi-map'); + return dom.callClassMethod(map, 'save') + .then(L.bind(ui.changes.apply, ui.changes)) + .then(function () { + return fs.exec_direct('/etc/init.d/shunt', [ev]); + }); + } + return fs.exec_direct('/etc/init.d/shunt', [ev]); +} + +function fmtMark(mark) { + if (mark == null) { + return '-'; + } + return '0x%08x'.format(mark); +} + +function fmtCount(n) { + if (n == null) { + return '?'; + } + return '%d'.format(n); +} + +function card(label, value, sub, cls) { + return E('div', { 'class': 'shunt-card' }, [ + E('div', { 'class': 'shunt-label' }, label), + E('div', { 'class': 'shunt-value ' + (cls || '') }, value), + sub ? E('div', { 'class': 'shunt-sub' }, sub) : '' + ]); +} + +function dot(state) { + return E('span', { 'class': 'shunt-dot shunt-dot-' + state }); +} + +function renderState(st) { + if (!st || st.running == null) { + return E('div', { 'class': 'shunt-state' }, [ + dot('off'), E('span', {}, _('no answer from the backend')) + ]); + } + + if (st.running && st.applied) { + return E('div', { 'class': 'shunt-state' }, [ + dot('ok'), E('span', {}, _('running, policy applied')) + ]); + } + + if (st.running && !st.applied) { + return E('div', { 'class': 'shunt-state' }, [ + dot('warn'), E('span', {}, _('running, but no ruleset in the kernel')) + ]); + } + + if (!st.running && st.applied) { + return E('div', { 'class': 'shunt-state' }, [ + dot('warn'), E('span', {}, _('ruleset present, service not running')) + ]); + } + + return E('div', { 'class': 'shunt-state' }, [ + dot('off'), E('span', {}, _('stopped')) + ]); +} + +// The observer's verdict identifiers are module contract - translated for +// display only, with the raw key kept beside each label. +const DROP_LABEL = { + 'nomatch': _('Domain is in no policy'), + 'qtype': _('Not an address query'), + 'noaddr': _('Answer carried no usable address'), + + 'dns:E_RCODE': _('Error reply, e.g. NXDOMAIN'), + 'dns:E_NOTRESP': _('Not a response'), + 'dns:E_TRUNC': _('Truncated response'), + 'dns:E_QDCOUNT': _('Not exactly one question'), + 'dns:E_SHORT': _('Message ends mid-structure'), + 'dns:E_MSGLEN': _('Message too long'), + 'dns:E_QPTR': _('Compression pointer in the question'), + 'dns:E_LABEL': _('Reserved label type'), + 'dns:E_NAMELEN': _('Name too long'), + 'dns:E_CHARSET': _('Name has a byte outside a-z 0-9 - _'), + 'dns:E_RDLEN': _('Record length does not fit the type'), + 'dns:E_ANSMAX': _('Too many answers'), + + 'frame:E_SHORT': _('Frame ends mid-structure'), + 'frame:E_ETHER': _('Neither IPv4 nor IPv6'), + 'frame:E_VLAN': _('Too many stacked VLAN tags'), + 'frame:E_IPLEN': _('Inconsistent IP header length'), + 'frame:E_FRAG': _('IP fragment'), + 'frame:E_EXTHDR': _('Extension header chain too long'), + 'frame:E_PROTO': _('Not UDP'), + 'frame:E_UDPLEN': _('Inconsistent UDP length') +}; + +function dropLabel(key) { + return DROP_LABEL[key] || key; +} + +function renderSnoop(svc) { + if (!svc || !svc.snoop) { + return ''; + } + + const drops = svc.snoop.drops || {}; + const matched = svc.snoop.matched || 0; + const keys = Object.keys(drops).sort(function (a, b) { + return drops[b] - drops[a] || a.localeCompare(b); + }); + + let total = 0; + + keys.forEach(function (k) { + total += drops[k]; + }); + + const rows = keys.map(function (k) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, [ + dropLabel(k), + E('span', { 'class': 'shunt-key' }, k) + ]), + E('td', { 'class': 'td right' }, ['%d'.format(drops[k])]) + ]); + }); + + return E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, + [_('DNS responses observed on %s since the service started') + .format((svc.snoop.devices || []).join(', ') || '-')]), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, + E('strong', {}, _('Answers used for a policy'))), + E('td', { 'class': 'td right' }, + E('strong', { 'class': 'shunt-hit' }, '%d'.format(matched))) + ]) + ].concat(rows)), + E('div', { 'class': 'shunt-sub' }, _('%d of %d observed responses were not used. That is normal: the observer sees every answer on the network, and only the ones for a domain you routed are of any interest.') + .format(total, total + matched)) + ]); +} + +function renderPolicies(st) { + if (!st || !st.policies || !st.policies.length) { + return E('div', { 'class': 'shunt-sub' }, _('No policy is active.')); + } + + const rows = [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Policy')), + E('th', { 'class': 'th' }, _('Interface')), + E('th', { 'class': 'th' }, _('Mark')), + E('th', { 'class': 'th' }, _('Table')), + E('th', { 'class': 'th' }, _('Rules')), + E('th', { 'class': 'th' }, _('Routes')), + E('th', { 'class': 'th' }, _('Fallback')) + ]) + ]; + + st.policies.forEach(function (p) { + rows.push(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, [p.name]), + E('td', { 'class': 'td' }, [p.interface || '-']), + E('td', { 'class': 'td' }, [fmtMark(p.mark)]), + E('td', { 'class': 'td' }, [fmtCount(p.rt_table)]), + E('td', { 'class': 'td' }, [fmtCount(p.rules)]), + E('td', { 'class': 'td' }, [fmtCount(p.routes)]), + E('td', { 'class': 'td' }, [p.fallback || 'main']) + ])); + }); + + return E('table', { 'class': 'table' }, rows); +} + +function renderIssues(st) { + if (!st || !st.issues || !st.issues.length) { + return ''; + } + + const items = st.issues.map(function (i) { + const where = i.entry ? '%s: %s'.format(i.policy, i.entry) : i.policy; + return E('li', {}, ['%s - %s'.format(where, i.reason)]); + }); + + return E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, _('Rejected settings')), + E('ul', { 'class': 'shunt-issues' }, items), + E('div', { 'class': 'shunt-sub' }, _('These entries were skipped. Everything else was applied - a rejected entry never takes the service down.')) + ]); +} + +return view.extend({ + load: function () { + return Promise.all([ + L.resolveDefault(getStatus(), {}), + uci.load('shunt').catch(() => 0), + L.resolveDefault(getPackages(true), {}) + ]); + }, + + render: function (result) { + const pkgs = result[2] || {}; + + if (!uci.sections('shunt').length) { + ui.addNotification(null, E('p', _('No shunt config found!')), 'error'); + return; + } + + let m, s, o; + + m = new form.Map('shunt', 'shunt', + _('Policy based routing by mac, source, destination and domain. For further information please check the %s.') + .format(`${_('online documentation')}`)); + const style = E('style', { 'type': 'text/css' }, + '#shunt-status {' + + '--shunt-card-bg: rgba(128,128,128,.07);' + + '--shunt-card-border: rgba(128,128,128,.28);' + + '--shunt-muted: GrayText;' + + '--shunt-ok: #1f8a5f;' + + '--shunt-warn: #b8860b;' + + '--shunt-off: #808080;' + + '}' + + '@media (prefers-color-scheme: dark) {' + + '#shunt-status {' + + '--shunt-ok: #63c79b;' + + '--shunt-warn: #e0b458;' + + '}}' + + '#shunt-status .shunt-grid { display: grid; gap: .75em; ' + + 'grid-template-columns: repeat(auto-fit, minmax(min(12em, 100%), 1fr)); ' + + 'margin-bottom: .75em; }' + + '#shunt-status .shunt-card { background: var(--shunt-card-bg); ' + + 'border: 1px solid var(--shunt-card-border); border-radius: 8px; ' + + 'padding: .7em .9em; min-width: 0; overflow-wrap: break-word; }' + + '#shunt-status .shunt-block { margin-bottom: .75em; }' + + '#shunt-status .shunt-label { font-size: .85em; ' + + 'color: var(--shunt-muted); margin-bottom: .3em; }' + + '#shunt-status .shunt-sub { font-size: .8em; ' + + 'color: var(--shunt-muted); margin-top: .3em; }' + + '#shunt-status .shunt-value { font-size: 1.5em; line-height: 1.3; ' + + 'font-variant-numeric: tabular-nums; }' + + '#shunt-status .shunt-state { display: flex; align-items: center; gap: .5em; }' + + '#shunt-status .shunt-dot { width: .6em; height: .6em; border-radius: 50%; ' + + 'flex: 0 0 auto; background: var(--shunt-muted); }' + + '#shunt-status .shunt-dot-ok { background: var(--shunt-ok); }' + + '#shunt-status .shunt-dot-warn { background: var(--shunt-warn); }' + + '#shunt-status .shunt-dot-off { background: var(--shunt-off); }' + + '#shunt-status .shunt-issues { margin: 0; padding-left: 1.2em; }' + + '#shunt-status .shunt-key { color: var(--shunt-muted); ' + + 'font-family: monospace; font-size: .8em; margin-left: .6em; }' + + '#shunt-status .shunt-hit { color: var(--shunt-ok); }'); + + const setNodes = (id, nodes) => { + const el = document.getElementById(id); + if (el) { + dom.content(el, nodes); + } + }; + + // Shown once, not on every poll tick. rp_filter is a box-wide security + // setting shunt does not change; a strict value silently drops its + // traffic, so the UI names it where the log would otherwise be the + // only place. Points at the docs rather than offering a button, + // because the change belongs to the administrator. + let rpWarned = false; + + const update = (st) => { + const svc = st ? st.service : null; + // Devices the kernel would drop marked traffic on, on the status + // root. Empty when all is loose, when each policy device is loose + // itself, or when rp_filter_manage has set them - the daemon reads + // the live value, so an enabled switch simply yields an empty list. + const blocked = (st && st.rp_filter_blocked) || []; + + if (blocked.length && !rpWarned) { + rpWarned = true; + ui.addNotification( + _('Reverse path filtering is strict'), + E('p', {}, [ + _('Strict rp_filter will drop shunt\'s marked traffic on %s. Set rp_filter to 2 on the policy interface, enable rp_filter_manage to have shunt do it, or loosen it box-wide; see the README.').format( + blocked.join(', ')) + ]), + 'warning'); + } + + setNodes('shunt-state', renderState(st)); + setNodes('shunt-version', E('span', {}, ['%s / %s'.format( + pkgs['luci-app-shunt'] || _('n/a'), pkgs['shunt'] || _('n/a'))])); + setNodes('shunt-learned', E('span', {}, [ + svc ? fmtCount(svc.dedupe) : '-'])); + setNodes('shunt-poll', E('span', {}, [svc?.poll + ? (svc.poll.resolv + ? _('%d name(s) every %ds').format(svc.poll.names, svc.poll.interval) + : _('unavailable - ucode-mod-resolv missing')) + : '-'])); + setNodes('shunt-policies', renderPolicies(st)); + setNodes('shunt-snoop', renderSnoop(svc)); + setNodes('shunt-issues', renderIssues(st)); + }; + + // TypedSection: `config global` is anonymous, so there is no section + // named 'global' to bind to. + o = m.section(form.TypedSection, 'global'); + o.anonymous = true; + o.addremove = false; + o.render = L.bind(function () { + return E('div', { 'id': 'shunt-status' }, [ + style, + E('div', { 'class': 'shunt-grid' }, [ + card(_('Service'), E('span', { 'id': 'shunt-state' }, '-'), + E('span', {}, [ + _('Version'), ': ', + E('span', { 'id': 'shunt-version' }, '-') + ])), + card(_('Learned addresses'), + E('span', { 'id': 'shunt-learned' }, '-'), + _('across all policies')), + card(_('Poll'), E('span', { 'id': 'shunt-poll' }, '-'), + _('wildcards are covered by the observer only')) + ]), + E('div', { 'id': 'shunt-policies' }, ''), + E('div', { 'id': 'shunt-snoop' }, ''), + E('div', { 'id': 'shunt-issues' }, '') + ]); + }, this); + + poll.add(function () { + return L.resolveDefault(getStatus(), null).then(update); + }, 2); + + // The status subtree only exists once m.render() has resolved and + // View.__init__ has attached the nodes; a direct call here would find + // no ids and leave the cards on '-' until the first poll tick. + requestAnimationFrame(function () { + update(result[0]); + }); + + s = m.section(form.TypedSection, 'global', _('Settings')); + s.anonymous = true; + s.addremove = false; + s.tab('general', _('General Settings')); + s.tab('snoop', _('DNS Observer Settings')); + + o = s.taboption('general', form.Flag, 'enabled', _('Enabled'), + _('Enable the shunt service.')); + o.rmempty = false; + + o = s.taboption('general', form.Flag, 'debug', _('Debug Logging'), + _('Log every observed DNS answer and every set write. Useful for a bug report, noisy in normal operation - on a router running adblock roughly half of all answers are error replies, and each one gets a line.')); + o.rmempty = false; + + o = s.taboption('general', form.Flag, 'rp_filter_manage', _('Manage rp_filter'), + _('Set rp_filter to 2 on shunt\'s own policy interfaces, at start and when one comes up.')); + o.rmempty = false; + + o = s.taboption('general', form.Value, 'poll_interval', _('Poll Interval'), + _('Seconds between poll cycles.')); + o.datatype = 'and(uinteger,min(30))'; + o.placeholder = '300'; + + o = s.taboption('general', form.Value, 'entry_ttl', _('Entry Lifetime'), + _('Seconds a learned address stays in its Set. Keep this well above the poll interval.')); + o.datatype = 'and(uinteger,min(60))'; + o.placeholder = '1200'; + o.validate = function (section_id, value) { + const iv = this.map.lookupOption('poll_interval', section_id); + const interval = (iv && iv[0]) ? (iv[0].formvalue(section_id) || 300) : 300; + + if (value && +value < 2 * +interval) { + return _('Should be at least twice the poll interval (%d), otherwise entries expire between cycles.').format(2 * interval); + } + + return true; + }; + + o = s.taboption('snoop', form.Flag, 'snoop', _('Passive DNS Observer'), + _('Read DNS answers as they pass the LAN device, whichever resolver produced them. Required for wildcard domains, which cannot be resolved ahead of time.')); + o.rmempty = false; + + o = s.taboption('snoop', widgets.DeviceSelect, 'snoop_device', + _('Observed Devices'), + _('The LAN device the DNS answers cross on their way to the clients, normally br-lan. One entry per network segment, see the README.')); + o.default = 'br-lan'; + o.multiple = true; + o.noaliases = true; + + s = m.section(form.TypedSection, 'global'); + s.anonymous = true; + s.addremove = false; + s.render = L.bind(function () { + return E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-negative important', + 'style': 'float:none;margin-right:.4em;', + 'click': ui.createHandlerFn(this, function () { + return handleAction('stop'); + }) + }, [_('Stop')]), + E('button', { + 'class': 'btn cbi-button cbi-button-positive important', + 'style': 'float:none', + 'click': ui.createHandlerFn(this, function () { + return handleAction('restart'); + }) + }, [_('Save & Restart')]) + ]); + }); + + return m.render(); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js new file mode 100644 index 000000000..95beb139c --- /dev/null +++ b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js @@ -0,0 +1,143 @@ +'use strict'; +'require dom'; +'require view'; +'require fs'; +'require ui'; +'require uci'; +'require form'; + +function handleAction(ev) { + if (ev === 'restart') { + const map = document.querySelector('.cbi-map'); + return dom.callClassMethod(map, 'save') + .then(L.bind(ui.changes.apply, ui.changes)) + .then(function () { + return fs.exec_direct('/etc/init.d/shunt', [ev]); + }); + } + return fs.exec_direct('/etc/init.d/shunt', [ev]); +} + +return view.extend({ + load: function () { + return Promise.all([ + uci.load('shunt').catch(() => 0), + uci.load('network').catch(() => 0) + ]); + }, + + render: function () { + if (!uci.sections('shunt').length) { + ui.addNotification(null, E('p', _('No shunt config found!')), 'error'); + return; + } + + let m, s, o; + + m = new form.Map('shunt', _('Policies'), + _('Evaluated top to bottom - the first policy a packet matches \ + wins. Within one policy the selectors are ANDed: source plus domain means only that client, and only to those domains.')); + s = m.section(form.GridSection, 'policy'); + s.addremove = true; + s.anonymous = false; + s.sortable = true; + s.nodescriptions = true; + s.addbtntitle = _('Add policy'); + + // The section name becomes an nftables identifier, so it is validated + // where it is typed rather than silently skipped later. + s.renderSectionAdd = function (extra_class) { + const el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments); + const nameEl = el.querySelector('.cbi-section-create-name'); + + if (nameEl) { + ui.addValidator(nameEl, 'and(uciname,maxlength(24))', true); + } + + return el; + }; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.rmempty = false; + o.default = '1'; + o.editable = true; + + o = s.option(form.Value, 'interface', _('Interface'), + _('The device or logical interface this policy routes into. A \ + netifd name is resolved to its device; any other device name is used as entered.')); + o.rmempty = false; + + uci.sections('network', 'interface').forEach(function (n) { + if (n['.name'] !== 'loopback') { + o.value(n['.name'], '%s (%s)'.format(n['.name'], _('interface'))); + } + }); + + o = s.option(form.ListValue, 'fallback', _('Fallback Behavior')); + o.value('main', _('Fall through to the normal uplink')); + o.value('block', _('Block the traffic (killswitch)')); + o.default = 'main'; + + o = s.option(form.DynamicList, 'src', _('Source Addresses'), + _('Client addresses or prefixes this policy applies to. Leave empty to apply to every client.')); + o.datatype = 'ipaddr'; + o.modalonly = true; + + o = s.option(form.DynamicList, 'src_mac', _('Source MAC Addresses'), + _('Client MACs this policy applies to, ORed with the addresses above.')); + o.datatype = 'macaddr'; + o.modalonly = true; + + o = s.option(form.MultiValue, 'proto', _('Protocols'), + _('Restrict to tcp, udp or both. A port without a protocol covers both.')); + o.value('tcp', 'tcp'); + o.value('udp', 'udp'); + o.modalonly = true; + + o = s.option(form.DynamicList, 'dport', _('Destination Ports'), + _('Single ports or ranges like 8000-8080, ANDed with the addresses below.')); + o.datatype = 'or(port, portrange)'; + o.modalonly = true; + + o = s.option(form.DynamicList, 'dst', _('Destination Addresses'), + _('Destination addresses or prefixes to route into this policy.')); + o.datatype = 'ipaddr'; + o.modalonly = true; + + o = s.option(form.DynamicList, 'domain', _('Domains'), + _('example.com matches that name only, *.example.com matches its \ + subdomains but not the apex - list both to cover both.')); + o.modalonly = true; + + o = s.option(form.Value, 'gw4', _('IPv4 Gateway Override'), + _('Only needed when the gateway discovered from netifd is wrong. \ + Point to point interfaces need no gateway at all.')); + o.datatype = 'ip4addr'; + o.modalonly = true; + + o = s.option(form.Value, 'gw6', _('IPv6 Gateway Override')); + o.datatype = 'ip6addr'; + o.modalonly = true; + + s = m.section(form.TypedSection, 'global'); + s.anonymous = true; + s.addremove = false; + s.render = L.bind(function () { + return E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive important', + 'style': 'float:none', + 'click': ui.createHandlerFn(this, function () { + return handleAction('restart'); + }) + }, [_('Save & Restart')]) + ]); + }); + + return m.render(); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js new file mode 100644 index 000000000..f4422361e --- /dev/null +++ b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js @@ -0,0 +1,263 @@ +'use strict'; +'require view'; +'require dom'; +'require ui'; +'require uci'; +'require rpc'; + +const getSets = rpc.declare({ + object: 'luci.shunt', + method: 'sets', + params: ['policy'] +}); + +function setKind(name) { + return name.substring(0, 1); +} + +function setPolicy(name) { + return name.substring(name.substring(0, 1) === 'm' ? 2 : 3); +} + +function fmtExpiry(sec) { + if (sec == null) { + return '-'; + } + if (sec >= 3600) { + return _('%dh %dm').format(Math.floor(sec / 3600), + Math.floor((sec % 3600) / 60)); + } + if (sec >= 60) { + return _('%dm %ds').format(Math.floor(sec / 60), sec % 60); + } + return _('%ds').format(sec); +} + +function fmtNum(n) { + if (n == null) { + return '-'; + } + return '%d'.format(n); +} + +function renderCards(sets, kinds, title, empty_hint) { + const byPolicy = {}; + + Object.keys(sets).forEach(function (name) { + if (kinds.indexOf(setKind(name)) < 0) { + return; + } + + const policy = setPolicy(name); + + if (!byPolicy[policy]) { + byPolicy[policy] = []; + } + + sets[name].forEach(function (e) { + byPolicy[policy].push(e); + }); + }); + + const policies = Object.keys(byPolicy).filter(function (p) { + return byPolicy[p].length > 0; + }).sort(); + + if (!policies.length) { + return empty_hint + ? E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, title), + E('div', { 'class': 'shunt-sub' }, empty_hint) + ]) + : ''; + } + + const cards = policies.map(function (policy) { + return E('div', { 'class': 'shunt-card' }, [ + E('div', { 'class': 'shunt-card-title' }, [policy]), + E('div', { 'class': 'shunt-addrs' }, + byPolicy[policy].sort(function (a, b) { + return String(a.addr).localeCompare(String(b.addr)); + }).map(function (e) { + return E('div', {}, [ + e.addr, + e.packets ? E('span', { 'class': 'shunt-hits' }, + _('%d pkt matched').format(e.packets)) : '' + ]); + })) + ]); + }); + + return E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, title), + E('div', { 'class': 'shunt-grid' }, cards) + ]); +} + +function renderLearned(sets) { + const rows = []; + + Object.keys(sets).forEach(function (name) { + if (setKind(name) !== 'd') { + return; + } + + const policy = setPolicy(name); + + sets[name].forEach(function (e) { + rows.push({ + policy: policy, + addr: e.addr, + expires: e.expires, + packets: e.packets, + bytes: e.bytes + }); + }); + }); + + rows.sort(function (a, b) { + return a.policy.localeCompare(b.policy) + || (b.packets || 0) - (a.packets || 0) + || String(a.addr).localeCompare(String(b.addr)); + }); + + if (!rows.length) { + return E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, _('Learned addresses')), + E('div', { 'class': 'shunt-sub' }, _('Nothing learned yet. The service fills these from its poll cycle and from observed DNS answers.')) + ]); + } + + const tbl = E('table', { 'class': 'table shunt-table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Policy')), + E('th', { 'class': 'th' }, _('Address')), + E('th', { 'class': 'th' }, _('Expires')), + E('th', { 'class': 'th' }, _('Packets routed')), + E('th', { 'class': 'th' }, _('Bytes routed')) + ]) + ]); + + cbi_update_table(tbl, rows.map(function (r) { + return [ + r.policy, + r.addr, + fmtExpiry(r.expires), + fmtNum(r.packets), + fmtNum(r.bytes) + ]; + })); + + return E('div', { 'class': 'shunt-block' }, [ + E('div', { 'class': 'shunt-label' }, + _('Learned addresses (%d)').format(rows.length)), + tbl + ]); +} + +return view.extend({ + load: function () { + return Promise.all([ + uci.load('shunt').catch(() => 0), + L.resolveDefault(getSets(''), {}) + ]); + }, + + render: function (result) { + const self = this; + + const render_sets = function (data) { + const sets = (data && data.sets) || {}; + const target = document.getElementById('shunt-sets'); + + if (!target) { + return; + } + + dom.content(target, [ + renderCards(sets, ['c', 'm'], _('Client Selectors'), + _('No client is selected, so every client is covered.')), + renderCards(sets, ['s'], _('Static Destinations'), null), + renderLearned(sets) + ]); + }; + + const reload = function () { + const sel = document.getElementById('shunt-policy'); + + return L.resolveDefault(getSets(sel ? sel.value : ''), {}) + .then(render_sets); + }; + + const options = [E('option', { 'value': '' }, _('all policies'))] + .concat(uci.sections('shunt', 'policy').map(function (p) { + return E('option', { 'value': p['.name'] }, [p['.name']]); + })); + + const style = E('style', { 'type': 'text/css' }, + '#shunt-sets {' + + '--shunt-card-bg: rgba(128,128,128,.07);' + + '--shunt-card-border: rgba(128,128,128,.28);' + + '--shunt-muted: GrayText;' + + '}' + + '#shunt-sets .shunt-block { margin-bottom: 1.2em; }' + + '#shunt-sets .shunt-label { font-size: .85em; ' + + 'color: var(--shunt-muted); margin-bottom: .4em; }' + + '#shunt-sets .shunt-sub { font-size: .85em; color: var(--shunt-muted); }' + + '#shunt-sets .shunt-grid { display: grid; gap: .75em; ' + + 'grid-template-columns: repeat(auto-fit, minmax(min(16em, 100%), 1fr)); }' + + '#shunt-sets .shunt-card { background: var(--shunt-card-bg); ' + + 'border: 1px solid var(--shunt-card-border); border-radius: 8px; ' + + 'padding: .7em .9em; min-width: 0; }' + + '#shunt-sets .shunt-card-title { font-weight: bold; margin-bottom: .3em; }' + + '#shunt-sets .shunt-addrs { font-family: monospace; font-size: .9em; ' + + 'overflow-wrap: anywhere; }' + + '#shunt-sets .shunt-hits { color: var(--shunt-muted); ' + + 'font-size: .85em; margin-left: .6em; }' + + '#shunt-sets .shunt-table { table-layout: fixed; width: 100%; }' + + '#shunt-sets .shunt-table th:nth-child(1),' + + '#shunt-sets .shunt-table td:nth-child(1) { width: 15%; }' + + '#shunt-sets .shunt-table th:nth-child(2),' + + '#shunt-sets .shunt-table td:nth-child(2) { width: 37%; ' + + 'overflow-wrap: anywhere; }' + + '#shunt-sets .shunt-table th:nth-child(3),' + + '#shunt-sets .shunt-table td:nth-child(3) { width: 16%; }' + + '#shunt-sets .shunt-table th:nth-child(4),' + + '#shunt-sets .shunt-table td:nth-child(4) { width: 16%; }' + + '#shunt-sets .shunt-table th:nth-child(5),' + + '#shunt-sets .shunt-table td:nth-child(5) { width: 16%; }'); + + const page = E('div', { 'class': 'cbi-map' }, [ + style, + E('h2', {}, _('Set Reporting')), + E('div', { 'class': 'cbi-section' }, [ + E('div', { 'class': 'cbi-section-descr' }, + _('What the nftables Sets currently hold. Counters are reset whenever an entry is refreshed, so they show recent activity, not a lifetime total.')) + ]), + E('div', { 'id': 'shunt-sets' }, ''), + E('div', { 'class': 'cbi-page-actions' }, [ + E('select', { + 'id': 'shunt-policy', + 'class': 'cbi-input-select', + 'style': 'float:none;margin-right:.4em;width:auto;', + 'change': ui.createHandlerFn(self, reload) + }, options), + E('button', { + 'class': 'btn cbi-button cbi-button-action important', + 'style': 'float:none', + 'click': ui.createHandlerFn(self, reload) + }, [_('Refresh')]) + ]) + ]); + + requestAnimationFrame(function () { + render_sets(result[1]); + }); + + return page; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-shunt/po/templates/shunt.pot b/luci-app-shunt/po/templates/shunt.pot new file mode 100644 index 000000000..ef1a74aea --- /dev/null +++ b/luci-app-shunt/po/templates/shunt.pot @@ -0,0 +1,532 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:319 +msgid "%d name(s) every %ds" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:165 +msgid "" +"%d of %d observed responses were not used. That is normal: the observer sees " +"every answer on the network, and only the ones for a domain you routed are " +"of any interest." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:85 +msgid "%d pkt matched" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:27 +msgid "%dh %dm" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:31 +msgid "%dm %ds" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:33 +msgid "%ds" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:45 +msgid "Add policy" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:134 +msgid "Address" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:97 +msgid "Answer carried no usable address" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:160 +msgid "Answers used for a policy" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:78 +msgid "Block the traffic (killswitch)" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:137 +msgid "Bytes routed" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:87 +msgid "Client MACs this policy applies to, ORed with the addresses above." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:178 +msgid "Client Selectors" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:82 +msgid "" +"Client addresses or prefixes this policy applies to. Leave empty to apply to " +"every client." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:105 +msgid "Compression pointer in the question" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:368 +msgid "DNS Observer Settings" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:155 +msgid "DNS responses observed on %s since the service started" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:374 +msgid "Debug Logging" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:102 +msgid "Destination Addresses" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:97 +msgid "Destination Ports" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:103 +msgid "Destination addresses or prefixes to route into this policy." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:95 +msgid "Domain is in no policy" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:107 +msgid "Domains" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:371 +msgid "Enable the shunt service." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:370 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:60 +msgid "Enabled" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:387 +msgid "Entry Lifetime" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:99 +msgid "Error reply, e.g. NXDOMAIN" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:38 +msgid "" +"Evaluated top to bottom - the first policy a packet matches wins. Within one " +"policy the selectors are ANDed: source plus domain means only that client, " +"and only to those domains." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:135 +msgid "Expires" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:117 +msgid "Extension header chain too long" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:77 +msgid "Fall through to the normal uplink" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:183 +msgid "Fallback" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:76 +msgid "Fallback Behavior" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:112 +msgid "Frame ends mid-structure" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:367 +msgid "General Settings" +msgstr "" + +#: applications/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json:3 +msgid "Grant access to LuCI app shunt" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:116 +msgid "IP fragment" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:112 +msgid "IPv4 Gateway Override" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:118 +msgid "IPv6 Gateway Override" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:115 +msgid "Inconsistent IP header length" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:119 +msgid "Inconsistent UDP length" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:178 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:65 +msgid "Interface" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:341 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:126 +msgid "Learned addresses" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:153 +msgid "Learned addresses (%d)" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:375 +msgid "" +"Log every observed DNS answer and every set write. Useful for a bug report, " +"noisy in normal operation - on a router running adblock roughly half of all " +"answers are error replies, and each one gets a line." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:378 +msgid "Manage rp_filter" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:179 +msgid "Mark" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:103 +msgid "Message ends mid-structure" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:104 +msgid "Message too long" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:108 +msgid "Name has a byte outside a-z 0-9 - _" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:107 +msgid "Name too long" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:113 +msgid "Neither IPv4 nor IPv6" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js:33 +msgid "No %s related logs yet!" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:179 +msgid "No client is selected, so every client is covered." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:172 +msgid "No policy is active." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:232 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:31 +msgid "No shunt config found!" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:118 +msgid "Not UDP" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:100 +msgid "Not a response" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:96 +msgid "Not an address query" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:102 +msgid "Not exactly one question" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:127 +msgid "" +"Nothing learned yet. The service fills these from its poll cycle and from " +"observed DNS answers." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:407 +msgid "Observed Devices" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:113 +msgid "" +"Only needed when the gateway discovered from netifd is wrong. Point to point " +"interfaces need no gateway at all." +msgstr "" + +#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:23 +msgid "Overview" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:136 +msgid "Packets routed" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:402 +msgid "Passive DNS Observer" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:37 +#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:31 +msgid "Policies" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:177 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:133 +msgid "Policy" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:239 +msgid "" +"Policy based routing by mac, source, destination and domain. For further " +"information please check the %s." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:344 +msgid "Poll" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:382 +msgid "Poll Interval" +msgstr "" + +#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:47 +msgid "Processing Log" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:91 +msgid "Protocols" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:403 +msgid "" +"Read DNS answers as they pass the LAN device, whichever resolver produced " +"them. Required for wildcard domains, which cannot be resolved ahead of time." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:109 +msgid "Record length does not fit the type" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:249 +msgid "Refresh" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:213 +msgid "Rejected settings" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:106 +msgid "Reserved label type" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:92 +msgid "Restrict to tcp, udp or both. A port without a protocol covers both." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:304 +msgid "Reverse path filtering is strict" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:182 +msgid "Routes" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:181 +msgid "Rules" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:431 +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:133 +msgid "Save & Restart" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:388 +msgid "" +"Seconds a learned address stays in its Set. Keep this well above the poll " +"interval." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:383 +msgid "Seconds between poll cycles." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:336 +msgid "Service" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:232 +#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:39 +msgid "Set Reporting" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:379 +msgid "" +"Set rp_filter to 2 on shunt's own policy interfaces, at start and when one " +"comes up." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:364 +msgid "Settings" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:396 +msgid "" +"Should be at least twice the poll interval (%d), otherwise entries expire " +"between cycles." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:98 +msgid "Single ports or ranges like 8000-8080, ANDed with the addresses below." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:81 +msgid "Source Addresses" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:86 +msgid "Source MAC Addresses" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:180 +msgid "Static Destinations" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:424 +msgid "Stop" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:306 +msgid "" +"Strict rp_filter will drop shunt's marked traffic on %s. Set rp_filter to 2 " +"on the policy interface, enable rp_filter_manage to have shunt do it, or " +"loosen it box-wide; see the README." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:180 +msgid "Table" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:408 +msgid "" +"The LAN device the DNS answers cross on their way to the clients, normally " +"br-lan. One entry per network segment, see the README." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:66 +msgid "" +"The device or logical interface this policy routes into. A netifd name is " +"resolved to its device; any other device name is used as entered." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js:44 +msgid "The syslog output, pre-filtered for messages related to: %s" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:215 +msgid "" +"These entries were skipped. Everything else was applied - a rejected entry " +"never takes the service down." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:110 +msgid "Too many answers" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:114 +msgid "Too many stacked VLAN tags" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:101 +msgid "Truncated response" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:338 +msgid "Version" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:235 +msgid "" +"What the nftables Sets currently hold. Counters are reset whenever an entry " +"is refreshed, so they show recent activity, not a lifetime total." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:343 +msgid "across all policies" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:192 +msgid "all policies" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:108 +msgid "" +"example.com matches that name only, *.example.com matches its subdomains but " +"not the apex - list both to cover both." +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:72 +msgid "interface" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:314 +msgid "n/a" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:65 +msgid "no answer from the backend" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:240 +msgid "online documentation" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:83 +msgid "ruleset present, service not running" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:77 +msgid "running, but no ruleset in the kernel" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:71 +msgid "running, policy applied" +msgstr "" + +#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:3 +msgid "shunt" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:88 +msgid "stopped" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:320 +msgid "unavailable - ucode-mod-resolv missing" +msgstr "" + +#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:345 +msgid "wildcards are covered by the observer only" +msgstr "" diff --git a/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping b/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping new file mode 100644 index 000000000..e6e19f9f2 --- /dev/null +++ b/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping @@ -0,0 +1,4 @@ +#!/bin/sh +rm -f /var/luci-indexcache.*.json +[ -x "/etc/init.d/rpcd" ] && /etc/init.d/rpcd reload +exit 0 diff --git a/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json b/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json new file mode 100644 index 000000000..db0664cb1 --- /dev/null +++ b/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json @@ -0,0 +1,54 @@ +{ + "admin/services/shunt": { + "title": "shunt", + "order": "65", + "action": { + "type": "alias", + "path": "admin/services/shunt/overview" + }, + "depends": { + "acl": [ + "luci-app-shunt" + ], + "fs": { + "/usr/sbin/shunt": "executable", + "/etc/init.d/shunt": "executable" + }, + "uci": { + "shunt": true + } + } + }, + "admin/services/shunt/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "shunt/overview" + } + }, + "admin/services/shunt/policies": { + "title": "Policies", + "order": 20, + "action": { + "type": "view", + "path": "shunt/policies" + } + }, + "admin/services/shunt/setreport": { + "title": "Set Reporting", + "order": 30, + "action": { + "type": "view", + "path": "shunt/setreport" + } + }, + "admin/services/shunt/logread": { + "title": "Processing Log", + "order": 40, + "action": { + "type": "view", + "path": "shunt/logread" + } + } +} diff --git a/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json b/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json new file mode 100644 index 000000000..87b404a5e --- /dev/null +++ b/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json @@ -0,0 +1,41 @@ +{ + "luci-app-shunt": { + "description": "Grant access to LuCI app shunt", + "write": { + "uci": [ + "shunt" + ] + }, + "read": { + "ubus": { + "luci.shunt": [ + "status", + "sets" + ], + "rpc-sys": [ + "packagelist" + ] + }, + "uci": [ + "shunt" + ], + "file": { + "/etc/init.d/shunt stop": [ + "exec" + ], + "/etc/init.d/shunt start": [ + "exec" + ], + "/etc/init.d/shunt restart": [ + "exec" + ] + }, + "cgi-io": [ + "exec" + ], + "log": [ + "read" + ] + } + } +} diff --git a/shunt/Makefile b/shunt/Makefile new file mode 100644 index 000000000..c2862e7ab --- /dev/null +++ b/shunt/Makefile @@ -0,0 +1,67 @@ +# shunt - policy based routing via passive DNS observation +# Copyright (c) 2026 Dirk Brenken (dev@brenken.org) +# This is free software, licensed under the GNU General Public License v3. + +include $(TOPDIR)/rules.mk + +PKG_NAME:=shunt +PKG_VERSION:=0.1.5 +PKG_RELEASE:=1 +PKG_LICENSE:=GPL-3.0-or-later +PKG_LICENSE_FILES:= +PKG_MAINTAINER:=Dirk Brenken + +include $(INCLUDE_DIR)/package.mk + +define Package/shunt + SECTION:=net + CATEGORY:=Network + TITLE:=Policy based routing via passive DNS observation + DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-socket +ucode-mod-uci \ + +ucode-mod-uloop +ucode-mod-resolv +ucode-mod-ubus \ + +ucode-mod-rtnl +ucode-mod-log +rpcd-mod-ucode +nftables-json +ip + PKGARCH:=all +endef + +define Package/shunt/description + Routes traffic by source, destination and domain into policy interfaces. + Domain addresses are learned resolver-independently: a poll loop resolves the + configured names and a passive AF_PACKET observer picks every DNS answer off + the wire, so shunt works unchanged with dnsmasq, unbound or any other backend. + Marks are applied via an own nftables table with per-element + counters, routing via fwmark rules and per-policy tables, with gateway + discovery and interface events from netifd. +endef + +define Package/shunt/conffiles +/etc/config/shunt +endef + +define Build/Prepare +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/shunt/install + $(INSTALL_DIR) $(1)/usr/share/ucode/shunt + $(INSTALL_DATA) ./src/*.uc $(1)/usr/share/ucode/shunt/ + + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/shunt.uc $(1)/usr/sbin/shunt + + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/shunt.init $(1)/etc/init.d/shunt + + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/shunt.config $(1)/etc/config/shunt + + $(INSTALL_DIR) $(1)/usr/share/rpcd/ucode + $(INSTALL_DATA) ./files/shunt.rpcd $(1)/usr/share/rpcd/ucode/shunt + +endef + +$(eval $(call BuildPackage,shunt)) diff --git a/shunt/README.md b/shunt/README.md new file mode 100644 index 000000000..5f8c4eb19 --- /dev/null +++ b/shunt/README.md @@ -0,0 +1,777 @@ + + +# shunt - policy based routing by mac, source, destination and domain + +## Table of Contents +* [Description](#description) +* [Quick Start](#quick-start) +* [Main Features](#main-features) +* [Prerequisites](#prerequisites) + * [Which tunnels work](#which-tunnels-work) +* [Installation and Usage](#installation-and-usage) +* [shunt CLI interface](#shunt-cli-interface) +* [shunt config options](#shunt-config-options) +* [How addresses are learned](#how-addresses-are-learned) + * [What polling costs](#what-polling-costs) +* [Examples](#examples) +* [What it shells out to](#what-it-shells-out-to) +* [What shunt creates on the system](#what-shunt-creates-on-the-system) +* [Coexistence with pbr and mwan3](#coexistence-with-pbr-and-mwan3) +* [Troubleshooting & debug options](#troubleshooting-and-debug-options) +* [Known limitations](#known-limitations) +* [Support](#support) +* [Removal](#removal) +* [Donations](#donations) + + +## Description +shunt routes selected traffic into a policy interface - a VPN tunnel, a second +uplink, a mobile connection - chosen by client address, client MAC, +destination or domain. It keeps its own nftables table and one routing table +per policy, so it coexists with fw4 and with other routing tools instead of +competing with them. + +The one thing that defines the project: **shunt is not bound to any DNS +backend.** It works unchanged with dnsmasq, unbound, smartdns, AdGuard Home or +anything else, because it never asks the resolver for anything and never sits +in the DNS path. Domain to address mapping comes from two sources shunt owns +itself, described under [How addresses are learned](#how-addresses-are-learned). + + +## Quick Start +For a typical setup these few steps are enough - see the sections below for +details: +1. Install the LuCI companion package: `apk update && apk add luci-app-shunt` + (this pulls in the `shunt` backend as a dependency). +2. Make reverse path filtering loose and give the policy interface a + masquerading firewall zone - both once, both shown under + [Prerequisites](#prerequisites). Without the first, marked traffic is + dropped; without the second it is marked and routed and then goes nowhere, + which looks exactly like shunt not working. +4. Open LuCI under `Services -> shunt`, add a policy on the `Policies` tab: pick + the `Interface`, name the clients under `Source addresses` or + `Source MAC addresses`, and list the `Domains` you want routed. +5. Start and verify: + +```sh +/etc/init.d/shunt enable +/etc/init.d/shunt start +shunt check +``` + +**Please note:** a domain policy only takes effect for a client's *next* +connection to an address that has just been learned - see +[Known limitations](#known-limitations). + + +## Main Features +* Routes by client address, client MAC, destination CIDR and domain, in any + combination +* Resolver independent: works with any DNS backend, and with an encrypted + upstream, because it reads the plaintext leg between client and resolver +* Wildcard domains (`*.example.com`), learned passively as clients use them +* Per-policy killswitch: hold the traffic when the interface drops, instead of + leaking it out of the normal uplink +* Own nftables table and routing tables, disjoint mark range - runs beside + `pbr` and `mwan3` +* IPv4 and IPv6 throughout, with a MAC selecting a host in both at once +* Per-element counters on every set, a ubus status object and a LuCI frontend +* No dependency on a specific DNS backend, no resolver configuration, no + include files, no hooks into fw4's ruleset + + +## Prerequisites +* OpenWrt with fw4/nftables +* `ucode` plus `ucode-mod-fs`, `ucode-mod-socket`, `ucode-mod-uci`, + `ucode-mod-uloop`, `ucode-mod-resolv`, `ucode-mod-ubus`, `ucode-mod-rtnl`, + `ucode-mod-log` and `rpcd-mod-ucode` - all pulled in by the package + +`ucode-mod-resolv` and `ucode-mod-ubus` are soft at runtime: without resolv, +poll is skipped and the observer carries the service alone; without ubus, +gateway discovery and interface events are skipped and the config's own values +are used. Both cost one warning in the log, not a failed start. + + +### Which tunnels work + +Any of them, and there is no supported-protocols list to check against, because +shunt never asks what protocol an interface speaks. It consumes two things: the +device to route into, and a gateway if one is needed. Both come from netifd, +and a device netifd does not manage is taken as given. + +* **Point to point tunnels** - wireguard, OpenVPN `tun*`, L2TP, PPTP, + Tailscale, NetBird and the like - need no gateway at all. The route is + `default dev table `. +* **Ethernet style interfaces** - OpenVPN `tap*`, a second wired uplink, a + mobile connection - use the gateway discovered from netifd, or `gw4`/`gw6` if + you set them. +* **Interfaces netifd does not manage** work by name too. On OpenWrt this is + the exception rather than the rule - wireguard, OpenVPN, L2TP and the rest + all have netifd protocols and are managed like any other interface. It + applies to a tunnel brought up outside netifd, by `wg-quick` or a script of + your own. If such an interface does need a gateway, discovery cannot find one + and you have to set `gw4`/`gw6` yourself. + +The one thing that does not work is anything that is not a routable interface. +Tor is the usual example: it normally offers a SOCKS port, and sending traffic +there is a redirect, not a route. shunt marks a packet and looks up a routing +table; without a device to put a default route on, there is nothing for it to +do. Transparent proxying is out of scope by design, not for want of a special +case. + +Two kernel-side prerequisites that shunt does **not** configure for you: + +**`rp_filter` must be loose on the policy interface.** Marked traffic takes an +asymmetric path, so strict reverse path filtering drops it. The kernel decides +per incoming packet using `max(net.ipv4.conf.all.rp_filter, +net.ipv4.conf..rp_filter)`, where `2` is loose - so setting the policy +interface alone to `2` suffices even while `all` stays strict. Prefer this: it +leaves reverse path filtering intact on every other interface. + +```sh +cat > /etc/sysctl.d/99-shunt.conf <<'EOF' +net.ipv4.conf.phy0-sta0.rp_filter=2 +EOF +sysctl -p /etc/sysctl.d/99-shunt.conf +``` + +Replace `phy0-sta0` with your policy interface's device - the `Interface` +column on the overview shows it - one line per policy device. + +There is a boot-order catch. A device that does not exist yet - a tunnel, or a +wifi client interface brought up late - has no `conf/` entry at boot, so +`sysctl -p` cannot set it and skips the line. When the device finally appears +it inherits `net.ipv4.conf.default.rp_filter`, and if that is strict the device +comes up strict and stays that way until the next `sysctl -p` - which for most +setups means until the next reboot, i.e. never in practice. Two ways around it: +set `net.ipv4.conf.default.rp_filter=2` as well, which makes every +later-appearing interface inherit loose (a little broader, but far short of +`all`), or let shunt handle it with the option below. + +**`rp_filter_manage` (optional, off by default).** With it set, shunt itself +sets `rp_filter=2` on its own policy devices - at start and again whenever one +comes up, which is exactly the boot-order moment a static file misses. It only +ever touches the devices shunt routes into, never `all` or `default`, and only +while the service runs. It is off by default because changing a security +setting should be a deliberate choice: + +```sh +uci set shunt.@global[0].rp_filter_manage='1' +uci commit shunt +/etc/init.d/shunt restart +``` + +Whichever way you choose, the daemon checks the live per-device value and warns +- naming the device - only for a policy device that is still strict, so with +`rp_filter_manage` on the warning simply does not appear. The package +deliberately ships no box-wide sysctl file: `rp_filter` on `all`/`default` is a +distribution default OpenWrt sets strict in `/etc/sysctl.d/10-default.conf`, and +loosening it there weakens anti-spoofing on every interface, well beyond +shunt's own traffic. + +**Masquerading stays fw4's job.** shunt marks and routes; it does not touch +the firewall's NAT. The policy interface needs a zone with `masq` enabled and +forwarding from `lan`, exactly as any other uplink. If the interface is a +netifd one - say a wireguard interface named `vpn`: + +```sh +uci add firewall zone +uci set firewall.@zone[-1].name='vpn' +uci set firewall.@zone[-1].input='REJECT' +uci set firewall.@zone[-1].output='ACCEPT' +uci set firewall.@zone[-1].forward='REJECT' +uci set firewall.@zone[-1].masq='1' +uci set firewall.@zone[-1].mtu_fix='1' +uci add_list firewall.@zone[-1].network='vpn' + +uci add firewall forwarding +uci set firewall.@forwarding[-1].src='lan' +uci set firewall.@forwarding[-1].dest='vpn' + +uci commit firewall +/etc/init.d/firewall reload +``` + +`network` names a **logical interface**, not a device. For a device netifd does +not manage - a tunnel brought up outside netifd - use +`uci add_list firewall.@zone[-1].device='wg0'` instead. And if the policy +interface already has a zone, because it is an ordinary second uplink, there is +nothing to do here. + +Symptoms of getting this wrong are worth knowing, because they do not look like +a firewall problem: the prerouting counters rise, `nft list set` shows the +learned address being hit, and the client's connection simply times out. + + +## Installation and Usage +* Update your router's apk repository (`apk update`) +* Install the LuCI companion package `luci-app-shunt`, which also installs the + main `shunt` package as a dependency +* Make `rp_filter` loose and give the policy interface a masquerading firewall + zone - both are one-time steps with copy-paste commands under + [Prerequisites](#prerequisites) +* Configure at least one policy, either in LuCI under `Services -> shunt` or by + editing `/etc/config/shunt` +* Enable and start the service, then run `shunt check` - it prints the mark, + routing table and rule priority of every accepted policy, and every rejected + value with its reason +* Check the `Set Reporting` tab to see which addresses were learned, and the + `Processing Log` tab for the service's own messages + + +## shunt CLI interface +All functions are available from the command line, and the config file can be +edited directly if you prefer that to LuCI. + +```sh +shunt check # render everything, print marks and issues, change nothing +shunt run # foreground, the procd service entry point +shunt flush # tear down table, rules, routes and the mapping file +shunt -v # echo every message to the terminal as well +``` + +`shunt check` is safe at any time, including while the service runs, because it +only renders - it never touches the kernel. Run it after every config change. +Note that it says nothing about whether the service is *running*; that is what +`/etc/init.d/shunt status` and the LuCI overview are for. + +Exit codes: 0 ok, 1 runtime failure, 2 usage or unusable config. + +Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows +everything - the daemon's own lines carry its pid, `shunt[1234]:`. Debug lines +stay off unless `-v` is given or `option debug '1'` is set - under procd there +is no command line, so a bug report needs the config switch. Expect volume: on +a router running adblock roughly half of all observed answers are error +replies, and debug gives each one a line. + +`shunt flush` is the escape hatch if the daemon ever dies without tearing +down. It is idempotent and safe on a box that never ran shunt. + + +## shunt config options + +### Global section + +| Option | Default | Description | +| :--- | :--- | :--- | +| enabled | `1` | master switch; `0` means the service starts and exits | +| debug | `0` | log every observed answer and every set write | +| rp_filter_manage | `0` | set rp_filter=2 on shunt's own policy devices, at start and on ifup | +| poll_interval | `300` | seconds between poll cycles, at least 30 | +| entry_ttl | `1200` | nftables timeout on learned elements, at least 60 | +| snoop | `1` | enable the passive DNS observer | +| snoop_device | `br-lan` | LAN devices to observe, a list, one entry per segment | + +Values below the minimum are clamped, not rejected, and the clamp is logged. +`entry_ttl` should stay well above `poll_interval` - an element is rewritten +once its remaining timeout drops below half of `entry_ttl`, so the default pair +refreshes comfortably within two poll cycles. + +### Policy sections + +Each `config policy` section is one routing policy. **The section must be +named, and the name must match `[A-Za-z0-9_]{1,24}`** - it becomes an nftables +identifier, so a section without a name, or one with a hyphen or a dot in it, +is rejected as an issue and never rendered. LuCI enforces the same pattern +when a policy is added. + +| Option | Description | +| :--- | :--- | +| enabled | `0` skips the section entirely | +| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`) | +| fallback | `main` (default) or `block`, see below | +| gw4 / gw6 | gateway override; normally unnecessary | +| src | client addresses or CIDRs whose traffic this policy owns | +| src_mac | client MAC addresses, ORed with `src` | +| proto | `tcp`, `udp`, or both; a port without one covers both | +| dport | destination ports, single or a range like `8000-8080` | +| dst | destination addresses or CIDRs | +| domain | domain patterns, see below | + +`src`, `src_mac`, `dst` and `domain` are lists and may repeat. + +Interfaces are resolved through netifd: a logical name resolves to its +`l3_device`, a raw netdev is adopted if netifd knows it, and a device netifd +knows nothing about passes through as given - which on OpenWrt means a tunnel +started outside netifd, since wireguard and the other tunnel types have netifd +protocols of their own. Gateways are discovered from the same dump, merged +across sibling entries, because netifd splits families. `gw4`/`gw6` override +discovery and always win; on a point to point interface no gateway is needed +at all. + +There is deliberately no list of supported tunnel protocols. shunt asks netifd +for the device and the gateway and renders a default route into the policy +table - `default via dev ` when a gateway is known, +`default dev ` when none is needed. Wireguard, OpenVPN in both `tun` +and `tap` mode, L2TP, PPTP, Tailscale, NetBird, a second physical uplink or a +mobile connection all reduce to those two shapes, so none of them needs a case +of its own. See [Which tunnels work](#which-tunnels-work) for the one thing +that genuinely does not fit. + +### Selectors are ANDed, client selectors OR each other + +* `src` or `src_mac` alone marks everything from those clients +* `dst`, `domain`, `dport` or `proto` alone marks that traffic from everyone +* clients plus destinations marks only those clients' traffic to those + destinations + +`dport` and `proto` AND with everything else, so a policy with a client, a +domain and `dport 443` covers that client's HTTPS traffic to that domain and +nothing more. A port without a protocol matches **both** tcp and udp - "port +443" almost always means QUIC too, and requiring the protocol would let it +slip through unnoticed. If ports or protocols were configured and none of them +is usable, the policy is skipped rather than rendered without the narrowing. + +This is the single most common source of "it did not work" reports: with a +client and a domain both set, a generic `curl ifconfig.me` from that client +correctly takes the normal uplink, because `ifconfig.me` is not in the domain +list. That is the policy working, not failing. + +A client MAC and a client address OR each other, so a host may be named either +way. If client selectors were configured and **none** of them is usable - a +typo in the only address, say - the policy is skipped with an issue rather +than falling back to "every client", which is what an absent client selector +otherwise means. + +### Selecting clients by MAC + +`src_mac` exists mainly for IPv6. Clients prefer rotating privacy addresses +for outgoing traffic, so a single IPv6 address is not a usable selector and the +LAN prefix covers every host in the segment. A MAC picks exactly one host, in +both address families, and keeps doing so when the addresses change. A policy +with only `src_mac` therefore needs no v6 address to route v6. + +Three limits, none of them guessable: + +* **Same layer 2 segment only.** Anything behind another router arrives with + that router's MAC. +* **Never the router itself.** The `output` chain sees traffic the router + generated, which has no ethernet sender, so MAC rules are not installed + there. An address based policy does cover the router; a MAC-only one does not. +* **Phones randomise their MAC**, though usually stable per network. Use the + address the client shows in your DHCP leases, not the one on the label. + +### Domain patterns + +``` +example.com matches the apex only +*.example.com matches subdomains only, at any depth, NOT the apex +``` + +List both to cover both. This is more typing than dnsmasq's implicit subdomain +inclusion, and it is deliberate: dnsmasq's behaviour surprises people +regularly, this one does not. + +Precedence, in order: + +1. an exact match always beats any wildcard +2. among wildcards the longest suffix wins, so `*.cdn.example.com` beats + `*.example.com` regardless of which policy declared them +3. the same pattern in two policies belongs to **both** + +Rule 3 is what makes one domain usable by two client groups over two different +uplinks: the address is written into each policy's set, and each policy's rule +matches only its own clients, so they stay apart. Rules 1 and 2 still decide +specificity - a shared `*.example.com` never overrides somebody's exact +`www.example.com`. + +Matching is label aligned, never string suffix: `evilexample.com` does not +match `*.example.com`. A bad pattern is collected as an issue, never fatal. + +### Policy precedence + +Section order in `/etc/config/shunt`, top to bottom. There is no `priority` +option - one less value to set wrong. A packet matching two policies takes the +earlier one; rule evaluation ends at the first match. + +Note that domain precedence is resolved *before* this, at the matcher: the most +specific pattern wins even if it sits in a later section. + +### Fallback: main or block + +`fallback 'main'` (default) renders no default route into the policy table, so +when the policy interface is down the table is empty and marked traffic falls +through to `main` - the normal uplink. Traffic keeps flowing, unpolicied. + +`fallback 'block'` adds a blackhole default at metric 9999 to the policy table. +While the interface is up its own default has the lower metric and wins; when +the interface drops, the kernel withdraws that route and the blackhole catches +everything. That is the killswitch: traffic belonging to the policy stops +rather than leaking out of the wrong interface. + + +## How addresses are learned +Two sources feed the same nftables sets, union with an element timeout. They +are complementary, not alternative modes. + +* **poll** resolves the configured names through whatever system resolver + exists, on a fixed interval. It warms the sets before the first client + packet, so first contact does not race. Wildcards are not names and cannot be + polled. +* **snoop** passively observes DNS responses on the LAN side via AF_PACKET with + a BPF filter matching **UDP source port 53** - answers, not questions - + including one level of VLAN tagging. It covers CDN variance and wildcards, + which poll cannot. It reads; it never writes anything back onto the wire and + never sits between a client and its resolver. If it dies, DNS keeps working + and only the policy stops applying. + + +### What polling costs + +"Polling" invites the assumption of waste, so here is the arithmetic. One cycle +is a single call asking for A and AAAA of every listed name: two lookups per +name per interval, against the **local** resolver. Ten names at the default 300 +seconds is 240 lookups an hour - about what a dozen web page loads cost, on a +network whose own DNS traffic runs to hundreds of answers in a few minutes. +There is no polling of anything else: no interface scanning, no ruleset +re-rendering, no periodic writes. An element is only rewritten when its +remaining lifetime has dropped below half. + +Two costs worth knowing: + +* The query is synchronous. A name that does not resolve blocks the cycle until + it times out (2s, one retry), which delays the service start noticeably if + several are wrong. This is why an unresolvable name is reported by name. +* For names with a TTL shorter than the interval the local cache has expired, + so poll does refetch upstream rather than answering from cache. + +What that means in practice: + +* **Pick the device the answers cross on their way to the clients**, normally + `br-lan`. It must be an **Ethernet type** device - a bridge, a VLAN device, a + physical port, a wireless interface. A tunnel or PPP interface has no + ethernet header, so neither the packet filter nor the decoder can read it, + and the failure is silent: nothing matches, nothing is logged. +* **Only the client-to-resolver leg matters, and only whether *it* is + encrypted.** What the resolver does upstream is irrelevant: the usual OpenWrt + setup - unbound or dnsmasq on the router, forwarding upstream over DoT or DoH + - is fully covered, because the client asked in plain text over the LAN and + the answer comes back the same way. +* **A client that speaks DoH or DoT itself is invisible**, because it bypasses + the local resolver. That is the one encryption case that costs coverage. +* **One entry per layer 2 segment.** A guest or IoT VLAN on its own device never + carries the answers of the main LAN, so it needs its own `snoop_device` entry. + A device that cannot be opened costs one warning; the others keep running. +* **The router's own lookups are not seen.** poll's queries leave through the + uplink, not the LAN device. +* Not seen either: DNS over TCP, DNS on a port other than 53, and a second + stacked VLAN tag. + +A name in a `domain` list that never resolves is reported once, by name and +policy: + +``` +poll: www.example.com (policy vpn) has no address - the policy entry has no effect until it resolves +``` + +Once on the way in and once on recovery, never in between. The first cycle runs +immediately at start, so a typo shows up within seconds. Wildcards cannot +produce this message - nothing can tell whether `*.example.com` was ever meant +to match anything. + + +## Examples + +**One client, one domain family, over a wireguard tunnel** + +``` +config policy 'vpn' + option enabled '1' + option interface 'wg0' + option fallback 'main' + list src '192.168.1.50' + list domain 'example.com' + list domain '*.example.com' +``` + +**A whole IoT VLAN over a mobile uplink, killswitch on** + +The client is named by MAC, so it is covered in both address families without +listing a rotating IPv6 address: + +``` +config global + list snoop_device 'br-lan' + list snoop_device 'br-iot' + +config policy 'iot' + option enabled '1' + option interface 'trm_wwan' + option fallback 'block' + list src_mac 'aa:bb:cc:dd:ee:ff' + list domain '*.vendor-cloud.com' +``` + +**The same domain for two client groups over two uplinks** + +Both policies claim `www.example.com`; each routes only its own clients: + +``` +config policy 'wwan' + option interface 'trm_wwan' + list src '10.168.30.70' + list domain 'www.example.com' + +config policy 'vpn' + option interface 'wg0' + list src '10.168.1.20' + list domain 'www.example.com' +``` + +**A destination range without any domain** + +``` +config policy 'office' + option interface 'wg0' + list src '192.168.1.0/24' + list dst '10.0.0.0/8' +``` + + +### What it shells out to + +Almost nothing. The daemon and the rpcd backend work through ucode's native +bindings - `fs`, `socket` for the AF_PACKET observer, `uci`, `uloop`, `ubus`, +`resolv`, `rtnl` and `log` - and rpcd carries the LuCI side, so there is no +shell glue, no `awk`, no temporary state files. Logging goes to syslog through +the binding, not through a `logger` process per line. + +Two external commands remain: + +| Command | Why | +| :--- | :--- | +| `nft` | the ruleset is applied and read as one atomic batch; ucode has no nftables binding | +| `ip` | routes and rules are written this way, although `rtnl` already reads them - replaceable | + +Nothing is ever handed to a shell for parsing: `system()` and `popen()` take an +argument array, and where stderr has to be captured the wrapper is +`sh -c 'exec "$0" "$@"'`, which passes arguments through untouched. + + +## What shunt creates on the system + +``` +table inet shunt own table, survives fw4 reloads + chain prerouting filter hook prerouting, priority mangle + chain output route hook output, priority mangle + set d4_ / d6_ learned, flags timeout, per-element counter + set s4_ / s6_ static dst, flags interval, counter + set c4_ / c6_ client src selectors, interval, counter + set m_ client MACs, no family digit, counter + +fwmark << 24, mask 0xff000000 +ip rule pref 31000 + +routing table 8000 + +/etc/iproute2/rt_tables.d/shunt.conf the table name mapping +``` + +The mark mask is fixed at `0xff000000`, which allows 255 policies. The `output` +chain is `type route` so the router's own marked traffic is re-routed after the +mark is set. + +Every set carries per-element counters, so "is this element ever hit" is one +look at `nft list set inet shunt ` rather than a tcpdump session. The two +kinds count different things: nftables tests a rule left to right, so a +**client** set counts every packet that matched the selector, whether or not +the destination matched afterwards; a **learned** set is the last lookup in the +rule, so a hit there means the packet really was marked. A busy client beside +learned addresses at zero is a client that has not visited any of the routed +domains, not a fault. + +**Writes are batched, and the interval adapts.** `nft -f` reads the entire +ruleset from the kernel before it resolves a single name, so on a box that +also runs a tool with very large sets - banIP with 238k elements, measured - +one `add element` costs seconds of CPU, and `nft --check` alone costs the +same. That is a known bug in nftables (netfilter bugzilla #1735, open +since 2024), not something shunt can fix, so observed addresses are collected +and applied together by a timer. + +The interval follows what the last write actually cost, between 2 and 60 +seconds: on an ordinary box a write takes milliseconds and the interval stays +at its floor, where the batching is invisible. Where it is expensive the +interval grows until nftables takes a bounded share of the machine instead of +all of it, at the price of a learned address reaching its set later. Both +numbers show up under `debug`. + +**A reload wipes learned state.** Applying the configuration destroys and +re-creates the table atomically, so the learned sets start empty. poll rewarms +them within one interval and snoop refills from live traffic; expect a short +window after a restart where domain policies do not apply yet. + + +## Coexistence with pbr and mwan3 +shunt is an independent implementation, not a fork of `pbr` and not a drop-in +for it - there is no config migration and no attempt at feature parity. Within +its scope it is a full alternative. + +Running both at once during a migration is safe by construction: + +| | pbr | mwan3 | shunt | +| :--- | :--- | :--- | :--- | +| fwmark mask | `0x00ff0000` | `0x00003f00` | `0xff000000` | +| ip rule pref | 30000 counting down | ~1001-3250 | 31000 counting up | +| routing tables | dynamic from ~256 | 1-250 | 8000+n | +| nft | chains in fw4's table | | own `inet shunt` table | + +The mark bits are disjoint and all three mask their writes. Where pbr and shunt +both match, pbr's lower rule priority wins, deterministically. So move policies +over one at a time and retire pbr once its config is empty. + +Anything shunt cannot see is worth knowing about: marks set via `SO_MARK` on a +daemon socket (OpenVPN's `--mark`) or by an eBPF program are invisible to any +inspection. If a box uses those, check the mark ranges by hand. + + +## Troubleshooting & debug options + +### Did the policy actually match? + +The authoritative check needs no route lookups at all: + +```sh +nft reset counters table inet shunt +# generate traffic from the client +nft list chain inet shunt prerouting # rule counters moved? +tcpdump -ni host # the flow leaves where it should +``` + +The wire capture is ground truth - but capture a **learned address**, not +everything: on a router whose policy interface is also a normal uplink, an +unfiltered capture shows traffic that has nothing to do with shunt. An IP echo +service is a convenient confirmation, but it only discriminates uplinks that +actually have different exits. + +The route lookup variant asks the kernel directly: + +```sh +ip route get mark 0x1000000 +ip route get +``` + +The first answer must name the policy table, the second the normal uplink. + +**The first line needs iproute2's `ip`** (`ip-full`), because BusyBox's +`route get` does not understand `mark` - one build rejects it outright, the +OpenWrt one sends an incomplete netlink request that the kernel answers with +`EINVAL`. The second line, without a mark, works with either. Check which one +you have with `readlink -f $(command -v ip)`. + +Adding `from iif br-lan` makes the lookup more precise, with one +further catch worth a confused test session: **`iif` is not optional** there. +Without it, `from` a non-local address makes the kernel validate a locally +originated lookup and answer `ENETUNREACH` regardless of any table's content, +which reads like broken routing and is not. + +### What the observer discards, and why + +Most DNS answers on a network are of no use to a routing policy, so snoop +counts what it discarded and why. `ubus call shunt status` reports those +counters, and the LuCI overview shows them with readable labels. + +| Verdict | Meaning | +| :--- | :--- | +| qtype | the question was not for an address at all | +| noaddr | an address was asked for, the answer carried none | +| nomatch | the name belongs to no policy | +| dns:E_* | the message did not parse, e.g. `E_RCODE` for NXDOMAIN | +| frame:E_* | the packet did not decode, e.g. `E_FRAG` for a fragment | + +`qtype` is usually the largest category and that is expected: current browsers +and operating systems ask for **HTTPS records (type 65)** alongside every A and +AAAA. `noaddr` is the other half of that distinction: the question *was* A or +AAAA, the reply is well formed, and the answer section still holds no address - +NODATA. + +The checks run in order and the **first** one wins, so these are "first reason +to discard" rather than independent counters: a PTR query for a name you route +counts as `qtype`, never as `nomatch`. + +A high discard count is therefore not a fault. The one number that says whether +the observer is doing its job is the matched count next to them. + +### When a policy stops applying after a reconnect + +The kernel removes routes from a policy table when the interface goes down, +after which the fwmark rule falls through to `main` while every counter keeps +counting. shunt handles this on two levels: a ubus listener on +`network.interface` rebuilds the route half on ifup/ifdown, and every poll tick +replays the route commands as a keeper. Learned sets survive both. If ubus is +unavailable, only the keeper remains, so recovery takes up to one +`poll_interval`. + +### Debug logging + +```sh +uci set shunt.@global[0].debug='1' +uci commit shunt +/etc/init.d/shunt restart +logread -e shunt +``` + +Set it back to `0` afterwards. Every observed answer and every set write gets a +line, which on a busy network is a lot. + + +## Known limitations +These are consequences of the design, stated rather than worked around: + +* **Clients that speak DoH or DoT themselves are invisible to snoop.** poll + still covers the names you list explicitly; wildcards do not work for those + clients. A *resolver* forwarding upstream over DoT or DoH changes nothing. +* **Wildcards require snoop.** poll can only resolve names it was given, and + `*.example.com` is not a name. +* **One CDN address serves many domains.** If a policy routes `example.com` and + the address behind it also serves a thousand other sites, those sites follow + the same policy. This is unsolvable at layer 3 by anything that routes on + addresses. +* **The first connection to a newly seen address takes the old path.** snoop + learns from the response the client is reading at that moment, so the client's + SYN is usually out before the element reaches the set. Measured on a live + router: the entire first connection stayed on the normal uplink, and the next + connection to the same host started on the policy interface. The switch + happens at a connection boundary; shunt does not touch conntrack, so no + established flow is ever yanked to a different exit mid-stream. Listing the + entry point explicitly closes the gap, because poll warms it before any client + asks. +* **DNS over TCP is not observed.** Port 53 over TCP needs reassembly, which is + out of scope; answers large enough to force TCP are rare in the traffic shunt + cares about. +* **Route and rule application is best effort.** At boot a tunnel interface may + not exist yet. A rule over an empty table falls through to `main`, so the + failure mode is "policy not applied yet", never "traffic broken". Each + distinct reason is one warning line. +* **No interface hotplug.** A device that appears later is picked up on the next + `ifup` event or within one poll interval, not immediately. + +**Out of scope permanently:** resolver-integrated set population (dnsmasq +`nftset`, AdGuard Home etc.). Being independent of the DNS backend is the entire +point of the project, so adopting a backend-specific mechanism would give up +the one property that distinguishes it. Also out: DSCP tagging and user +include files. + + +## Support +Please report issues with as much detail as possible - the output of +`shunt check`, the relevant part of `logread -e shunt` with `debug` enabled, +your `/etc/config/shunt`, and the OpenWrt version of the device. + + +## Removal +Stop the service with `/etc/init.d/shunt stop`, which also tears down the +nftables table, the routing tables and the ip rules, then remove the `shunt` +and `luci-app-shunt` packages if necessary. `shunt flush` does the teardown +alone, should anything be left behind. + + +## Donations +You like this project - is there a way to donate? Generally speaking "No" - I have a well-paying full-time job and my OpenWrt projects are just a hobby of mine in my spare time. + +If you still insist to donate some bucks ... +* I would be happy if you put your money in kind into other, social projects in your area, e.g. a children's hospice +* Let's meet and invite me for a coffee if you are in my area, the “Markgräfler Land” in southern Germany or in Switzerland (Basel) +* Send your money to my [PayPal account](https://www.paypal.me/DirkBrenken) and I will collect your donations over the year to support various social projects in my area + +No matter what you decide - thank you very much for your support! + +Have fun! +Dirk diff --git a/shunt/files/shunt.config b/shunt/files/shunt.config new file mode 100644 index 000000000..bfd376b91 --- /dev/null +++ b/shunt/files/shunt.config @@ -0,0 +1,16 @@ +config global + option enabled '1' + option poll_interval '300' + option entry_ttl '1200' + option snoop '1' + option debug '0' + option rp_filter_manage '0' + list snoop_device 'br-lan' + +config policy 'vpn' + option enabled '0' + option interface 'wg0' + option fallback 'main' + list src '192.168.1.50' + list domain 'example.com' + list domain '*.example.com' diff --git a/shunt/files/shunt.init b/shunt/files/shunt.init new file mode 100644 index 000000000..b5fa29277 --- /dev/null +++ b/shunt/files/shunt.init @@ -0,0 +1,39 @@ +#!/bin/sh /etc/rc.common +# shunt - policy based routing +# +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (c) 2026 Dirk Brenken (dev@brenken.org) + +START=95 +USE_PROCD=1 + +shunt_init="/etc/init.d/shunt" + +if [ -z "${IPKG_INSTROOT}" ]; then + case "${action}" in + "stop") + "${shunt_init}" running || exit 0 + ;; + esac +fi + +start_service() { + procd_open_instance "shunt" + procd_set_param command /usr/sbin/shunt run + procd_set_param respawn 300 5 3 + procd_set_param stdout 0 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + /usr/sbin/shunt flush 2>/dev/null +} + +service_triggers() { + procd_add_reload_trigger "shunt" +} + +reload_service() { + restart +} diff --git a/shunt/files/shunt.rpcd b/shunt/files/shunt.rpcd new file mode 100644 index 000000000..c4d511792 --- /dev/null +++ b/shunt/files/shunt.rpcd @@ -0,0 +1,309 @@ +// shunt - rpcd backend for the LuCI frontend +// +// Stateless: renders the configuration through the same modules the daemon +// uses and reads the rest from the kernel. The daemon is asked only for the +// facts nothing outside its process can see. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +import { popen, readfile } from 'fs'; + +const ubus = require('ubus'); +const rtnl = require('rtnl'); + +// The rtnl constants hang off a `const` sub-object, not off the module - the +// example in lib/rtnl.c's own header says otherwise and yields null. +const RT = rtnl.const; + +import { load as config_load, parse as config_parse } from 'shunt.config'; +import { resolve as netifd_resolve } from 'shunt.netifd'; +import { compile as match_compile } from 'shunt.match'; +import { compile as nft_compile } from 'shunt.nft'; +import { compile as route_compile } from 'shunt.route'; +import { names as poll_names } from 'shunt.poll'; + +const TABLE_FAMILY = 'inet'; +const TABLE_NAME = 'shunt'; + +function daemon_status() { + let conn = ubus.connect(); + + if (!conn) + return null; + + let r = conn.call('shunt', 'status'); + + return r ?? null; +} + +// The same sequence as the daemon's build_state(), minus the logging: two +// readings of one config file are how a status view starts to lie. +function render() { + let sections = config_load(); + + if (sections == null) + return null; + + let cfg = config_parse(sections); + + if (!cfg) + return null; + + let dump = null; + let conn = ubus.connect(); + + if (conn) + dump = conn.call('network.interface', 'dump'); + + cfg.policies = netifd_resolve(cfg.policies, dump); + + let m = match_compile(cfg.policies); + let n = nft_compile(cfg.policies); + let r = route_compile(cfg.policies, n.marks); + + return { cfg, matcher: m, nft: n, route: r }; +} + +// Policy devices whose marked traffic the kernel would drop: max(all, ), +// blocked only when all is strict (1) and the device is not loose itself. Same +// logic as the daemon. When rp_filter_manage is on the daemon has already set +// these to 2, so this reads back empty on its own. +function rp_filter_blocked(policies) { + let rp = (k) => trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? ''); + + if (rp('all') != '1') + return []; + + let seen = {}, blocked = []; + + for (let p in (policies ?? [])) { + let dev = p.interface; + + if (!length(dev ?? '') || seen[dev]) + continue; + + seen[dev] = true; + + let v = rp(dev); + + // Absent device: no traffic, nothing dropped - not blocked. The + // daemon re-checks on ifup when it appears. + if (v == '') + continue; + + if (v != '2') + push(blocked, dev); + } + + return blocked; +} + +// Same check the daemon logs, surfaced for the UI: which of all/default carry +// strict reverse path filtering, which drops shunt's asymmetric traffic. +function rp_filter_strict() { + let strict = []; + + for (let key in [ 'all', 'default' ]) + if (trim(readfile(`/proc/sys/net/ipv4/conf/${key}/rp_filter`) ?? '') == '1') + push(strict, key); + + return strict; +} + +function nft_table() { + let fh = popen(sprintf('nft -j list table %s %s 2>/dev/null', + TABLE_FAMILY, TABLE_NAME), 'r'); + + if (!fh) + return null; + + let out = fh.read('all'); + + fh.close(); + + if (!length(out ?? '')) + return null; + + let j = json(out); + + return j?.nftables ? j : null; +} + +function nft_sets(table) { + let out = {}; + + for (let item in (table?.nftables ?? [])) { + let s = item?.set; + + if (!s?.name) + continue; + + let elems = []; + + for (let e in (s.elem ?? [])) { + let v = e?.elem ?? e; + let val = v?.val ?? v; + + push(elems, { + addr: (type(val) == 'object') ? (val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) : null) : val, + expires: v?.expires, + packets: v?.counter?.packets, + bytes: v?.counter?.bytes + }); + } + + out[s.name] = elems; + } + + return out; +} + +function kernel_rules(marks) { + let want = {}; + + for (let m in (marks ?? [])) + want[sprintf('%d', m.mark)] = m.name; + + let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP, + { family: RT.AF_UNSPEC }); + + if (res == null) + return null; + + let out = {}; + + for (let r in res) { + if (r?.fwmark == null) + continue; + + let name = want[sprintf('%d', r.fwmark)]; + + if (!name) + continue; + + if (!out[name]) + out[name] = []; + + push(out[name], { + family: r.family, + priority: r.priority, + table: r.table, + fwmark: r.fwmark, + fwmask: r.fwmask + }); + } + + return out; +} + +function kernel_routes(marks) { + let out = {}; + + for (let m in (marks ?? [])) { + let n = 0; + + for (let fam in [ RT.AF_INET, RT.AF_INET6 ]) { + let res = rtnl.request(RT.RTM_GETROUTE, RT.NLM_F_DUMP, + { family: fam, table: m.rt_table }); + + if (res == null) { + n = null; + break; + } + + for (let r in res) + if (r?.table == m.rt_table) + n++; + } + + out[m.name] = n; + } + + return out; +} + +return { + 'luci.shunt': { + + status: { + args: {}, + call: function(req) { + let st = render(); + + if (!st) + return { error: 'cannot read /etc/config/shunt' }; + + let svc = daemon_status(); + let table = nft_table(); + let rules = kernel_rules(st.nft.marks); + let routes = kernel_routes(st.nft.marks); + let policies = []; + + for (let m in st.nft.marks) { + let p = null; + + for (let c in st.cfg.policies) + if (c.name == m.name) + p = c; + + push(policies, { + name: m.name, + mark: m.mark, + rt_table: m.rt_table, + rt_prio: m.rt_prio, + interface: p?.interface, + fallback: p?.fallback, + domains: length(p?.domains ?? []), + rules: rules ? length(rules[m.name] ?? []) : null, + routes: routes[m.name] + }); + } + + return { + running: (svc != null), + applied: (table != null), + service: svc, + global: st.cfg.global, + policies, + poll_names: length(poll_names(st.cfg.policies)), + rp_filter_blocked: rp_filter_blocked(st.cfg.policies), + rp_filter_strict: rp_filter_strict(), + issues: [ + ...st.cfg.issues, + ...st.matcher.issues, + ...st.nft.issues, + ...st.route.issues + ] + }; + } + }, + + sets: { + args: { policy: '' }, + call: function(req) { + let table = nft_table(); + + if (!table) + return { sets: {} }; + + let all = nft_sets(table); + let want = req.args?.policy; + + if (!length(want ?? '')) + return { sets: all }; + + let out = {}; + + for (let name in all) { + let at = (substr(name, 0, 1) == 'm') ? 2 : 3; + + if (substr(name, at) == want) + out[name] = all[name]; + } + + return { sets: out }; + } + } + } +}; diff --git a/shunt/files/shunt.uc b/shunt/files/shunt.uc new file mode 100755 index 000000000..9444d0527 --- /dev/null +++ b/shunt/files/shunt.uc @@ -0,0 +1,646 @@ +#!/usr/bin/ucode +// shunt - policy based routing daemon +// +// Reads the config, renders the ruleset and the routes, applies them, then +// keeps the learned sets fed from a poll cycle and a passive DNS observer. +// All decisions live in the modules; this file is wiring. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +import { popen, writefile, readfile, unlink, mkdir, lstat, error as fs_error } from 'fs'; +import { openlog, syslog, LOG_PID, LOG_DAEMON, LOG_ERR, LOG_WARNING, + LOG_NOTICE, LOG_INFO, LOG_DEBUG } from 'log'; +import { load as cfg_load, parse as cfg_parse } from 'shunt.config'; +import { compile as match_compile } from 'shunt.match'; +import { compile as nft_compile, refresh, teardown } from 'shunt.nft'; +import { compile as route_compile } from 'shunt.route'; +import { open as snoop_open, observe, RECV_LEN } from 'shunt.snoop'; +import { names as poll_names, plan as poll_plan, + addresses as poll_addresses, + index_results as poll_index } from 'shunt.poll'; +import { resolve as netifd_resolve } from 'shunt.netifd'; +import { create as dedupe_create } from 'shunt.dedupe'; + +const RT_TABLES = '/etc/iproute2/rt_tables.d/shunt.conf'; +const TAG = 'shunt'; + +let verbose = false; + +let dbg = false; + +// syslog(3) through the binding, not a `logger` process per line. '%s' as the +// format because syslog() runs sprintf over its arguments, and an ip error +// text can contain a percent sign. +const PRIO = { err: LOG_ERR, warn: LOG_WARNING, notice: LOG_NOTICE, + info: LOG_INFO, debug: LOG_DEBUG }; + +openlog(TAG, LOG_PID, LOG_DAEMON); + +function log(prio, msg) { + syslog(PRIO[prio] ?? LOG_NOTICE, '%s', msg); + + if (verbose) + warn(sprintf('[%s] %s\n', prio, msg)); +} + +function debug(msg) { + if (dbg) + log('debug', msg); +} + +let ubus_conn = null; + +function ubus() { + if (ubus_conn != null) + return ubus_conn; + + try { + ubus_conn = require('ubus').connect(); + } + catch (e) { + ubus_conn = false; + } + + if (!ubus_conn) + log('warn', 'ubus unavailable - gateway discovery and interface events disabled'); + + return ubus_conn; +} + +function netifd_dump() { + let c = ubus(); + return c ? c.call('network.interface', 'dump') : null; +} + +function load_config() { + let sections = cfg_load(); + + if (sections == null) { + log('err', 'ucode-mod-uci missing'); + return null; + } + + return cfg_parse(sections); +} + +function report(kind, issues) { + for (let i in issues) + log('warn', sprintf('%s: %J', kind, i)); +} + +const RUN_DIR = '/tmp/.shunt'; +const RUN_ERR = RUN_DIR + '/cmd.err'; + +function capture_ok() { + mkdir(RUN_DIR, 0o700); + + let st = lstat(RUN_DIR); + + return st != null && st.type == 'directory' && st.uid == 0 && + !st.perm.group_write && !st.perm.other_write && + !st.perm.group_read && !st.perm.other_read; +} + +function loud(argv) { + if (!capture_ok()) + return { rc: quiet(argv), err: '' }; + + let rc = system([ '/bin/sh', '-c', + sprintf('exec "$0" "$@" 2>%s', RUN_ERR), ...argv ]); + let err = ''; + + if (rc != 0) + err = replace(trim(readfile(RUN_ERR) ?? ''), /\s*\n\s*/g, '; '); + + unlink(RUN_ERR); + + return { rc, err }; +} + +// quiet() drops the child's stderr, loud() keeps it for the warning. Neither +// may be called `run` - that name is the daemon's own entry point. +function quiet(argv) { + return system([ '/bin/sh', '-c', 'exec "$0" "$@" 2>/dev/null', ...argv ]); +} + +function nft_pipe(batch, what) { + let fh = popen('nft -f -', 'w'); + + if (!fh) { + log('err', sprintf('%s: cannot spawn nft: %s', what, fs_error())); + return false; + } + + fh.write(batch); + + let rc = fh.close(); + if (rc != 0) { + log('err', sprintf('%s: nft exited %d', what, rc)); + return false; + } + + return true; +} + +function apply(state) { + if (!nft_pipe(state.nft.setup, 'setup')) + return false; + + if (length(state.route.rt_tables)) { + mkdir('/etc/iproute2/rt_tables.d', 0o755); + if (!writefile(RT_TABLES, state.route.rt_tables)) + log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error())); + } + + for (let argv in state.route.del) + quiet(argv); + + let failed = 0; + let reasons = {}; + + for (let argv in state.route.add) { + let r = loud(argv); + + if (r.rc != 0) { + let why = length(r.err) ? r.err : sprintf('exit %d', r.rc); + + failed++; + reasons[why] = (reasons[why] ?? 0) + 1; + debug(sprintf('not applied: %s - %s', join(' ', argv), why)); + } + } + + for (let why in reasons) + log('warn', sprintf('%d of %d route/rule command(s) not applied - %s', + reasons[why], length(state.route.add), why)); + + if (failed) + log('warn', 'policy not applied yet - traffic falls through to main; restart once the interface is up'); + + return true; +} + +function flush(state) { + if (state) + for (let argv in state.route.del) + quiet(argv); + + nft_pipe(teardown(), 'teardown'); + + if (readfile(RT_TABLES) != null) + unlink(RT_TABLES); +} + +function rp_read(k) { + return trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? ''); +} + +// Distinct, existing policy devices. Deduped so a device shared by several +// policies is set or reported once. +function policy_devices(policies) { + let seen = {}, out = []; + + for (let p in (policies ?? [])) { + let dev = p.interface; + + if (length(dev ?? '') && !seen[dev]) { + seen[dev] = true; + push(out, dev); + } + } + + return out; +} + +// Which policy devices the kernel would drop marked traffic on. rp_filter +// takes max(conf.all, conf.), so a device is blocked only when all is +// strict (1 - 0 is off, 2 is loose) AND the device is not loosened itself. A +// device with no /proc entry does not exist yet and inherits default. +function rp_filter_blocked(policies) { + if (rp_read('all') != '1') + return []; + + let blocked = []; + + for (let dev in policy_devices(policies)) { + let v = rp_read(dev); + + if (v == '') + continue; + + if (v != '2') + push(blocked, dev); + } + + return blocked; +} + +// With rp_filter_manage set, shunt loosens rp_filter on its own policy devices +// - the per-interface fix the README documents, done automatically. Bounded to +// exactly the devices shunt routes into, never all/default, and only on a +// device that exists. Off by default: changing a security setting is opt-in. +function rp_filter_apply(policies) { + for (let dev in policy_devices(policies)) + if (rp_read(dev) != '' && rp_read(dev) != '2') + loud([ 'sysctl', '-w', sprintf('net.ipv4.conf.%s.rp_filter=2', dev) ]); +} + +// Reads the live /proc value, so when rp_filter_apply has done its job the +// list is empty on its own - no need to consult the switch a second time. +function check_rp_filter(policies) { + for (let dev in rp_filter_blocked(policies)) + log('warn', sprintf('rp_filter is strict on %s - shunt\'s marked traffic will be dropped there; set net.ipv4.conf.%s.rp_filter=2 or enable rp_filter_manage, see the README', + dev, dev)); +} + +// silent: build only what a teardown consumes and say nothing about the +// configuration - flush() needs route.del and nothing else. +function build_state(silent) { + let cfg = load_config(); + if (!cfg) + return null; + + if (!silent) + report('config', cfg.issues); + + cfg.policies = netifd_resolve(cfg.policies, netifd_dump()); + + let matcher = null; + + if (!silent) { + matcher = match_compile(cfg.policies); + report('domain', matcher.issues); + } + + let n = nft_compile(cfg.policies); + if (!silent) + report('nft', n.issues); + + let r = route_compile(cfg.policies, n.marks); + if (!silent) + report('route', r.issues); + + return { cfg, matcher, nft: n, route: r }; +} + +// nft -f reads the entire ruleset before resolving a single name, so on a box +// with large sets from another tool one add element costs seconds of CPU. +// Writes are collected and applied by a timer whose interval follows the +// measured cost: an ordinary box stays at the floor, an expensive one backs +// off. +const WRITE_MIN = 2; +const WRITE_MAX = 60; +const WRITE_FACTOR = 3; + +function queue_writes(st, writes, now) { + for (let w in writes) + if (st.state.nft.learn[w.set] && st.cache.due(w.set, w.addr, now)) + st.pending[`${w.set}/${w.addr}`] = w; +} + +function drain_writes(st) { + let due = values(st.pending); + + st.pending = {}; + + if (!length(due)) + return; + + let r = refresh(due, st.state.cfg.global.entry_ttl); + report('refresh', r.issues); + + if (!length(r.batch)) + return; + + let t0 = time(); + let ok = nft_pipe(r.batch, 'refresh'); + let cost = time() - t0; + + if (ok) + debug(sprintf('%d element(s) written in %ds', length(due), cost)); + + let want = cost * WRITE_FACTOR; + + if (want < WRITE_MIN) + want = WRITE_MIN; + if (want > WRITE_MAX) + want = WRITE_MAX; + + if (want != st.interval) { + debug(sprintf('write interval %ds -> %ds (last write %ds)', + st.interval, want, cost)); + st.interval = want; + st.timer.set(want * 1000); + } +} + +function run() { + let uloop, resolv; + + try { + uloop = require('uloop'); + } + catch (e) { + log('err', 'ucode-mod-uloop missing'); + return 1; + } + + let state = build_state(); + if (!state) + return 2; + + dbg = verbose || state.cfg.global.debug; + + if (!state.cfg.global.enabled) { + log('notice', 'disabled in config'); + return 0; + } + + if (!length(state.nft.marks)) { + log('err', 'no usable policy - not starting, run `shunt check` for the reasons'); + return 2; + } + + if (state.cfg.global.rp_filter_manage) + rp_filter_apply(state.cfg.policies); + + check_rp_filter(state.cfg.policies); + + if (!apply(state)) { + flush(state); + return 1; + } + + let cache = dedupe_create(state.cfg.global.entry_ttl); + let targets = poll_names(state.cfg.policies); + + let stats = { started: time(), resolv: false, snoop: [], + matched: 0, drops: {} }; + + try { + resolv = require('resolv'); + } + catch (e) { + resolv = null; + if (length(targets)) + log('warn', 'ucode-mod-resolv missing - poll disabled, snoop only'); + } + + stats.resolv = (resolv != null); + + let unresolved = {}; + + let wq = { state, cache, pending: {}, interval: WRITE_MIN, timer: null }; + + function poll_cycle() { + if (!resolv || !length(targets)) + return; + + let res = resolv.query(targets, { type: [ 'A', 'AAAA' ], + timeout: 2000, retries: 1 }); + if (!res) { + log('warn', 'poll: query failed'); + return; + } + + let by = poll_index(res); + + for (let name in targets) { + let got = poll_addresses(by, name); + let n = length(got.a) + length(got.aaaa); + + if (n && unresolved[name]) { + unresolved[name] = false; + log('info', sprintf('poll: %s resolves again', name)); + } + else if (!n && !unresolved[name]) { + let owners = state.matcher.test(name); + + unresolved[name] = true; + log('warn', sprintf('poll: %s%s has no address - the policy entry has no effect until it resolves', + name, owners != null + ? sprintf(' (policy %s)', join(', ', owners)) : '')); + } + } + + queue_writes(wq, poll_plan(res, state.matcher, targets), time()); + } + + // The cache is pruned here and not at the tail of poll_cycle(): + // poll_cycle() returns early without resolv, without pollable names + // and on a failed query, while snoop keeps feeding queue_writes() in + // all three cases. + function tick() { + for (let argv in state.route.add) + quiet(argv); + + poll_cycle(); + cache.prune(time()); + } + + wq.timer = uloop.interval(WRITE_MIN * 1000, () => drain_writes(wq)); + + poll_cycle(); + uloop.interval(state.cfg.global.poll_interval * 1000, tick); + + if (resolv && length(targets)) + log('info', sprintf('poll: %d name(s) every %ds', length(targets), + state.cfg.global.poll_interval)); + + let c = ubus(); + + if (c) { + let pending = null; + + function rebuild_routes() { + pending = null; + + let resolved = netifd_resolve(state.cfg.policies, netifd_dump()); + let r = route_compile(resolved, state.nft.marks); + + report('route', r.issues); + + // A policy device may have just appeared - the boot-time case a + // static sysctl.d file misses, since /proc/ did not exist + // yet. Re-apply so it is loose from the moment it comes up, then + // re-check: with manage on the check reads the value just set + // and stays silent, without it this is the moment to warn. + if (state.cfg.global.rp_filter_manage) + rp_filter_apply(resolved); + + check_rp_filter(resolved); + + for (let argv in state.route.del) + quiet(argv); + for (let argv in r.add) + quiet(argv); + + state.route = r; + } + + c.listener('network.interface', (type, msg) => { + if (msg?.action != 'ifup' && msg?.action != 'ifdown') + return; + + log('info', sprintf('%s %s - rebuilding routes', + msg.action, msg.interface ?? '?')); + + if (pending) + pending.set(500); + else + pending = uloop.timer(500, rebuild_routes); + }); + + // Must be total: an exception in a ubus handler halts uloop and takes + // snoop, poll and the keeper down with the reply. + function status_reply() { + let names = []; + + for (let m in state.nft.marks) + push(names, m.name); + + return { + started: stats.started, + policies: names, + poll: { + resolv: stats.resolv, + names: length(targets), + interval: state.cfg.global.poll_interval + }, + snoop: { + devices: stats.snoop, + matched: stats.matched, + drops: stats.drops + }, + dedupe: cache.size() + }; + } + + let obj = c.publish('shunt', { status: { call: () => status_reply() } }); + + if (!obj) + log('warn', sprintf('cannot publish ubus object: %s', + require('ubus').error() ?? 'unknown')); + } + + let socks = []; + + if (state.cfg.global.snoop) { + let socket = null; + + for (let dev in state.cfg.global.snoop_devices) { + let s = snoop_open(dev); + + if (!s.ok) { + log('err', sprintf('snoop %s: %s', dev, s.err)); + continue; + } + + if (socket == null) + socket = require('socket'); + + let sock = s.sock; + + push(socks, sock); + push(stats.snoop, dev); + + // Each handler closes over its own socket; binding the loop + // variable would leave them all reading the last one opened. + uloop.handle(sock, () => { + let frame; + + while ((frame = sock.recv(RECV_LEN, socket.MSG_DONTWAIT)) != null) { + let v = observe(frame, state.matcher); + + if (v.drop != null) { + stats.drops[v.drop] = (stats.drops[v.drop] ?? 0) + 1; + continue; + } + + stats.matched++; + + let writes = []; + for (let policy in v.policies) { + for (let a in v.a) + push(writes, { set: `d4_${policy}`, addr: a }); + for (let a in v.aaaa) + push(writes, { set: `d6_${policy}`, addr: a }); + } + + debug(sprintf('snoop: %s -> %s (%d addr)', + v.qname, join(', ', v.policies), length(writes))); + queue_writes(wq, writes, time()); + } + }, uloop.ULOOP_READ); + } + + if (length(socks)) + log('info', sprintf('snoop: listening on %s', + join(', ', stats.snoop))); + else if (!resolv || !length(targets)) { + log('err', 'neither snoop nor poll available - nothing to do'); + flush(state); + return 1; + } + } + + log('notice', sprintf('started: %d polic%s, mask 0x%08x', + length(state.nft.marks), length(state.nft.marks) == 1 ? 'y' : 'ies', + 0xff000000)); + + uloop.run(); + + log('notice', 'stopping'); + for (let sock in socks) + sock.close(); + flush(state); + + return 0; +} + +function check() { + verbose = true; + + let state = build_state(); + if (!state) + return 2; + + printf('global: %.2J\n', state.cfg.global); + printf('policies: %d accepted, %d mark(s)\n', + length(state.cfg.policies), length(state.nft.marks)); + + for (let m in state.nft.marks) + printf(' %-16s mark 0x%08x table %d pref %d\n', + m.name, m.mark, m.rt_table, m.rt_prio); + + let total = length(state.matcher.issues) + length(state.nft.issues) + + length(state.route.issues); + + printf('issues: %d (see above)\n', total); + printf('poll: %d name(s)\n', length(poll_names(state.cfg.policies))); + + return length(state.nft.marks) ? 0 : 2; +} + +let cmd = null; + +for (let a in ARGV) { + if (a == '-v') + verbose = dbg = true; + else if (cmd == null) + cmd = a; +} + +switch (cmd) { +case 'run': + exit(run()); +case 'check': + exit(check()); +case 'flush': + flush(build_state(true)); + exit(0); +default: + warn('usage: shunt [-v] run|check|flush\n'); + exit(2); +} diff --git a/shunt/src/config.uc b/shunt/src/config.uc new file mode 100644 index 000000000..b3c402649 --- /dev/null +++ b/shunt/src/config.uc @@ -0,0 +1,177 @@ +// shunt - configuration +// +// Turns UCI shaped sections into the structures the other modules consume. +// load() reads UCI, parse() is pure and fed literals by the tests. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +export const DEFAULTS = { + enabled: true, + poll_interval: 300, + entry_ttl: 1200, + snoop: true, + snoop_devices: [ 'br-lan' ], + debug: false, + rp_filter_manage: false +}; + +export const MIN = { + poll_interval: 30, + entry_ttl: 60 +}; + +function to_bool(v, dflt) { + if (v == null) + return dflt; + if (v === true || v === false) + return v; + if (v == '1' || v == 1) + return true; + if (v == '0' || v == 0) + return false; + return null; +} + +function uniq(list) { + let seen = {}; + let out = []; + + for (let v in list) { + if (!seen[v]) { + seen[v] = true; + push(out, v); + } + } + + return out; +} + +function to_list(v) { + if (v == null) + return []; + if (type(v) == 'array') + return v; + return [ v ]; +} + +// require('uci') sits inside so the module stays importable without it, and +// parse() stays pure for the tests. +export function load() { + let uci; + + try { + uci = require('uci'); + } + catch (e) { + return null; + } + + let sections = []; + + uci.cursor().foreach('shunt', null, (s) => { + let values = {}; + + for (let k in s) + if (substr(k, 0, 1) != '.') + values[k] = s[k]; + + push(sections, { type: s['.type'], name: s['.name'], values }); + }); + + return sections; +}; + +export function parse(sections) { + let g = { ...DEFAULTS }; + let policies = [], issues = []; + + function reject(section, option, reason) { + push(issues, { section, option, reason }); + } + + function num_opt(section, values, key) { + let v = values[key]; + if (v == null) + return; + + let n = +v; + if (type(v) == 'string' && match(v, /^[0-9]+$/) == null || n != n) { + reject(section, key, sprintf('not a number: %J, default %d kept', + v, g[key])); + return; + } + if (n < MIN[key]) { + reject(section, key, sprintf('%d below minimum, clamped to %d', + n, MIN[key])); + n = MIN[key]; + } + g[key] = n; + } + + function bool_opt(section, values, key) { + let b = to_bool(values[key], g[key]); + if (b === null) { + reject(section, key, sprintf('not a boolean: %J, default kept', + values[key])); + return; + } + g[key] = b; + } + + for (let s in (sections ?? [])) { + if (s?.type == 'global') { + let v = s.values ?? {}; + + bool_opt(s.name ?? 'global', v, 'enabled'); + bool_opt(s.name ?? 'global', v, 'snoop'); + bool_opt(s.name ?? 'global', v, 'debug'); + bool_opt(s.name ?? 'global', v, 'rp_filter_manage'); + num_opt(s.name ?? 'global', v, 'poll_interval'); + num_opt(s.name ?? 'global', v, 'entry_ttl'); + + if (v.snoop_device != null) { + let devs = []; + + for (let d in to_list(v.snoop_device)) { + if (type(d) == 'string' && length(d)) + push(devs, d); + else + reject(s.name ?? 'global', 'snoop_device', + sprintf('not a device name: %J, entry dropped', d)); + } + + if (length(devs)) + g.snoop_devices = uniq(devs); + else + reject(s.name ?? 'global', 'snoop_device', + 'no usable device, default kept'); + } + continue; + } + + if (s?.type != 'policy') + continue; + + let v = s.values ?? {}; + + if (to_bool(v.enabled, true) !== true) + continue; + + push(policies, { + name: s.name, + interface: v.interface, + fallback: v.fallback, + gw4: v.gw4, + gw6: v.gw6, + src: to_list(v.src), + src_mac: to_list(v.src_mac), + dport: to_list(v.dport), + proto: to_list(v.proto), + dst: to_list(v.dst), + domains: to_list(v.domain) + }); + } + + return { global: g, policies, issues }; +}; diff --git a/shunt/src/dedupe.uc b/shunt/src/dedupe.uc new file mode 100644 index 000000000..5b515baeb --- /dev/null +++ b/shunt/src/dedupe.uc @@ -0,0 +1,41 @@ +// shunt - write suppression +// +// Remembers which (set, address) pairs were written recently so a repeated +// DNS answer does not rewrite an element that is still fresh. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +export function create(entry_ttl) { + let last = {}; + + function due(set, addr, now) { + let k = `${set}/${addr}`; + let t = last[k]; + + if (t != null && (now - t) * 2 < entry_ttl) + return false; + + last[k] = now; + return true; + } + + function prune(now) { + let n = 0; + + for (let k in last) { + if (now - last[k] >= entry_ttl) { + delete last[k]; + n++; + } + } + + return n; + } + + function size() { + return length(keys(last)); + } + + return { due, prune, size }; +}; diff --git a/shunt/src/dns.uc b/shunt/src/dns.uc new file mode 100644 index 000000000..517cb817a --- /dev/null +++ b/shunt/src/dns.uc @@ -0,0 +1,249 @@ +// shunt - DNS message parser +// +// Parses a response far enough to answer: which name was asked for, and +// which A/AAAA addresses came back. Never trusts a length off the wire. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +// Hard limits: message size, name length, answers processed per message. +export const LIM = { + msg: 4096, + labels: 63, + name: 255, + answers: 64 +}; + +export const TYPE = { + A: 1, + NS: 2, + CNAME: 5, + SOA: 6, + TXT: 16, + AAAA: 28, + OPT: 41 +}; + +// Parser verdicts. These identifiers are contract - fixtures and the LuCI +// labels compare them verbatim. +export const ERR = { + SHORT: 'E_SHORT', + MSGLEN: 'E_MSGLEN', + NOTRESP: 'E_NOTRESP', + TRUNC: 'E_TRUNC', + RCODE: 'E_RCODE', + QDCOUNT: 'E_QDCOUNT', + QPTR: 'E_QPTR', + LABEL: 'E_LABEL', + NAMELEN: 'E_NAMELEN', + CHARSET: 'E_CHARSET', + RDLEN: 'E_RDLEN', + ANSMAX: 'E_ANSMAX' +}; + +const HDR_LEN = 12; +const RR_FIXED = 10; + +const F_QR = 0x8000; +const F_TC = 0x0200; +const M_RCODE = 0x000f; + +const LBL_MASK = 0xc0; +const LBL_PTR = 0xc0; + +function u16at(buf, off) { + return (ord(buf, off) << 8) | ord(buf, off + 1); +} + +function fmt4(buf, off) { + return sprintf('%d.%d.%d.%d', + ord(buf, off), ord(buf, off + 1), + ord(buf, off + 2), ord(buf, off + 3)); +} + +function fmt6(buf, off) { + let g = []; + for (let i = 0; i < 8; i++) + push(g, u16at(buf, off + i * 2)); + + let bs = -1, bl = 0, cs = -1, cl = 0; + for (let i = 0; i < 8; i++) { + if (g[i] != 0) { + cs = -1; + cl = 0; + continue; + } + if (cs < 0) + cs = i; + cl++; + if (cl > bl) { + bs = cs; + bl = cl; + } + } + + if (bl < 2) { + bs = -1; + bl = 0; + } + + let parts = [], i = 0; + while (i < 8) { + if (i == bs) { + push(parts, ''); + i += bl; + continue; + } + push(parts, sprintf('%x', g[i])); + i++; + } + + let out = join(':', parts); + + if (bs == 0) + out = ':' + out; + if (bs >= 0 && bs + bl == 8) + out = out + ':'; + + return out; +} + +export function decode_name(buf, off) { + let blen = length(buf), labels = [], total = 1; + + while (true) { + if (off >= blen) + return { err: ERR.SHORT }; + + let len = ord(buf, off); + + if ((len & LBL_MASK) == LBL_PTR) + return { err: ERR.QPTR }; + if (len & LBL_MASK) + return { err: ERR.LABEL }; + + off++; + if (!len) + break; + + total += len + 1; + if (total > LIM.name) + return { err: ERR.NAMELEN }; + if (off + len > blen) + return { err: ERR.SHORT }; + + let lbl = lc(substr(buf, off, len)); + for (let i = 0; i < len; i++) { + let c = ord(lbl, i); + if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) || + c == 0x2d || c == 0x5f) + continue; + return { err: ERR.CHARSET }; + } + + push(labels, lbl); + off += len; + } + + return { name: join('.', labels), next: off }; +}; + +export function skip_name(buf, off) { + let blen = length(buf); + + while (true) { + if (off >= blen) + return null; + + let len = ord(buf, off); + + if ((len & LBL_MASK) == LBL_PTR) + return (off + 2 <= blen) ? off + 2 : null; + if (len & LBL_MASK) + return null; + + off++; + if (!len) + return off; + + off += len; + if (off > blen) + return null; + } +}; + +export function parse(buf) { + let blen = length(buf ?? ''); + + if (blen > LIM.msg) + return { ok: false, err: ERR.MSGLEN }; + if (blen < HDR_LEN) + return { ok: false, err: ERR.SHORT }; + + let flags = u16at(buf, 2); + + if (!(flags & F_QR)) + return { ok: false, err: ERR.NOTRESP }; + if (flags & F_TC) + return { ok: false, err: ERR.TRUNC }; + if (flags & M_RCODE) + return { ok: false, err: ERR.RCODE }; + + if (u16at(buf, 4) != 1) + return { ok: false, err: ERR.QDCOUNT }; + + let ancount = u16at(buf, 6); + if (ancount > LIM.answers) + return { ok: false, err: ERR.ANSMAX }; + + let q = decode_name(buf, HDR_LEN); + if (q.err) + return { ok: false, err: q.err }; + + let off = q.next; + if (off + 4 > blen) + return { ok: false, err: ERR.SHORT }; + + let qtype = u16at(buf, off); + off += 4; + + let a = [], aaaa = []; + + for (let i = 0; i < ancount; i++) { + off = skip_name(buf, off); + if (off === null) + return { ok: false, err: ERR.SHORT }; + + if (off + RR_FIXED > blen) + return { ok: false, err: ERR.SHORT }; + + let rtype = u16at(buf, off); + let rdlen = u16at(buf, off + 8); + off += RR_FIXED; + + if (off + rdlen > blen) + return { ok: false, err: ERR.RDLEN }; + + if (rtype == TYPE.A) { + if (rdlen != 4) + return { ok: false, err: ERR.RDLEN }; + push(a, fmt4(buf, off)); + } + else if (rtype == TYPE.AAAA) { + if (rdlen != 16) + return { ok: false, err: ERR.RDLEN }; + push(aaaa, fmt6(buf, off)); + } + + off += rdlen; + } + + return { + ok: true, + id: u16at(buf, 0), + qname: q.name, + qtype, + a, + aaaa + }; +}; diff --git a/shunt/src/frame.uc b/shunt/src/frame.uc new file mode 100644 index 000000000..34bcfb1f0 --- /dev/null +++ b/shunt/src/frame.uc @@ -0,0 +1,154 @@ +// shunt - link layer decoder +// +// Ethernet, optional VLAN tag, IPv4/IPv6, UDP - down to the DNS payload. +// Rejects anything malformed rather than guessing. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +// How far to walk before giving up: stacked VLAN tags, IPv6 extension headers. +export const LIM = { + vlan: 3, + ext: 8 +}; + +// Decoder verdicts, contract like the parser's. +export const ERR = { + SHORT: 'E_SHORT', + ETHER: 'E_ETHER', + VLAN: 'E_VLAN', + IPLEN: 'E_IPLEN', + FRAG: 'E_FRAG', + EXTHDR: 'E_EXTHDR', + PROTO: 'E_PROTO', + UDPLEN: 'E_UDPLEN' +}; + +const ETH_HDR = 14; +const ETYPE_OFF = 12; +const VLAN_TAG = 4; + +const ET_IPV4 = 0x0800; +const ET_IPV6 = 0x86dd; +const ET_VLAN = 0x8100; +const ET_QINQ = 0x88a8; + +const IP4_MIN = 20; +const IP6_HDR = 40; +const EXT_MIN = 8; +const UDP_HDR = 8; + +const IP_UDP = 17; +const IP_FRAG = 44; +const IP_AH = 51; + +const IP4_FRAG_MASK = 0x3fff; + +function u16(buf, off) { + return (ord(buf, off) << 8) | ord(buf, off + 1); +} + +function is_ext(proto) { + return proto == 0 || proto == 43 || proto == 60 || proto == IP_AH; +} + +export function decap(buf) { + let len = length(buf ?? ''); + + if (len < ETH_HDR) + return { ok: false, err: ERR.SHORT }; + + let off = ETYPE_OFF, et = u16(buf, off), tags = 0; + + while (et == ET_VLAN || et == ET_QINQ) { + if (++tags > LIM.vlan) + return { ok: false, err: ERR.VLAN }; + + off += VLAN_TAG; + if (off + 2 > len) + return { ok: false, err: ERR.SHORT }; + + et = u16(buf, off); + } + + off += 2; + + let af, proto; + + if (et == ET_IPV4) { + if (off + IP4_MIN > len) + return { ok: false, err: ERR.SHORT }; + + let ihl = (ord(buf, off) & 0x0f) * 4; + if (ihl < IP4_MIN) + return { ok: false, err: ERR.IPLEN }; + if (off + ihl > len) + return { ok: false, err: ERR.SHORT }; + + let tot = u16(buf, off + 2); + if (tot < ihl) + return { ok: false, err: ERR.IPLEN }; + if (off + tot > len) + return { ok: false, err: ERR.SHORT }; + + len = off + tot; + + if (u16(buf, off + 6) & IP4_FRAG_MASK) + return { ok: false, err: ERR.FRAG }; + + proto = ord(buf, off + 9); + af = 4; + off += ihl; + } + else if (et == ET_IPV6) { + if (off + IP6_HDR > len) + return { ok: false, err: ERR.SHORT }; + + let plen = u16(buf, off + 4); + if (off + IP6_HDR + plen > len) + return { ok: false, err: ERR.SHORT }; + + len = off + IP6_HDR + plen; + + proto = ord(buf, off + 6); + af = 6; + off += IP6_HDR; + + for (let i = 0; i < LIM.ext && is_ext(proto); i++) { + if (off + EXT_MIN > len) + return { ok: false, err: ERR.SHORT }; + + let hlen = (proto == IP_AH) + ? (ord(buf, off + 1) + 2) * 4 + : (ord(buf, off + 1) + 1) * 8; + + proto = ord(buf, off); + off += hlen; + } + + if (proto == IP_FRAG) + return { ok: false, err: ERR.FRAG }; + if (is_ext(proto)) + return { ok: false, err: ERR.EXTHDR }; + } + else + return { ok: false, err: ERR.ETHER }; + + if (proto != IP_UDP) + return { ok: false, err: ERR.PROTO }; + if (off + UDP_HDR > len) + return { ok: false, err: ERR.SHORT }; + + let sport = u16(buf, off); + let ulen = u16(buf, off + 4); + + if (ulen < UDP_HDR || off + ulen > len) + return { ok: false, err: ERR.UDPLEN }; + + return { + ok: true, + af, + sport, + payload: substr(buf, off + UDP_HDR, ulen - UDP_HDR) + }; +}; diff --git a/shunt/src/match.uc b/shunt/src/match.uc new file mode 100644 index 000000000..165c8b6bb --- /dev/null +++ b/shunt/src/match.uc @@ -0,0 +1,129 @@ +// shunt - domain matcher +// +// Compiles the policies' domain patterns into an exact and a wildcard map +// and answers which policies claim a queried name. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +export const LIM = { + name: 253, + label: 63 +}; + +function ok_label(s) { + if (!length(s) || length(s) > LIM.label) + return false; + + for (let i = 0; i < length(s); i++) { + let c = ord(s, i); + if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) || + c == 0x2d || c == 0x5f) + continue; + return false; + } + + return true; +} + +export function normalize(s) { + s = lc(trim(s ?? '')); + + while (length(s) && substr(s, -1) == '.') + s = substr(s, 0, length(s) - 1); + + return s; +}; + +function validate(name) { + if (!length(name)) + return 'empty'; + if (length(name) > LIM.name) + return 'too long'; + + for (let l in split(name, '.')) + if (!ok_label(l)) + return `bad label '${l}'`; + + return null; +} + +export function compile(policies) { + let exact = {}, wild = {}, issues = []; + + function reject(policy, pattern, reason) { + push(issues, { policy, pattern, reason }); + } + + for (let pi = 0; pi < length(policies ?? []); pi++) { + let p = policies[pi]; + let pname = p?.name ?? `#${pi}`; + + for (let raw in (p?.domains ?? [])) { + let pat = normalize(raw); + let is_wild = false; + + if (substr(pat, 0, 2) == '*.') { + is_wild = true; + pat = substr(pat, 2); + } + + if (index(pat, '*') >= 0) { + reject(pname, raw, 'wildcard only allowed as leading *. label'); + continue; + } + + let bad = validate(pat); + if (bad) { + reject(pname, raw, bad); + continue; + } + + let map = is_wild ? wild : exact; + + if (!map[pat]) + map[pat] = []; + + let dup = false; + + for (let owner in map[pat]) + if (owner == pname) + dup = true; + + if (!dup) + push(map[pat], pname); + } + } + + // A list even for one element - a caller that has to distinguish shapes + // gets it wrong exactly once, in the rare case, in production. + function test(qname) { + let q = normalize(qname); + + if (!length(q) || length(q) > LIM.name) + return null; + + if (exists(exact, q)) + return exact[q]; + + let off = index(q, '.'); + + while (off >= 0) { + let sfx = substr(q, off + 1); + + if (exists(wild, sfx)) + return wild[sfx]; + + let nxt = index(sfx, '.'); + off = (nxt < 0) ? -1 : off + 1 + nxt; + } + + return null; + } + + return { + test, + issues, + size: { exact: length(keys(exact)), wild: length(keys(wild)) } + }; +}; diff --git a/shunt/src/netifd.uc b/shunt/src/netifd.uc new file mode 100644 index 000000000..f4d758d21 --- /dev/null +++ b/shunt/src/netifd.uc @@ -0,0 +1,64 @@ +// shunt - interface resolution +// +// Turns a configured interface name into its device and gateways from a +// netifd dump. Explicit config values always win. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +function nexthop(entry, fam) { + let dflt = (fam == 4) ? '0.0.0.0' : '::'; + + for (let r in (entry?.route ?? [])) + if (r?.target == dflt && r?.mask == 0 && length(r?.nexthop ?? '')) + return r.nexthop; + + return null; +} + +export function resolve(policies, dump) { + let entries = dump?.interface ?? []; + let out = []; + + for (let p in (policies ?? [])) { + let device = null; + + for (let e in entries) { + if (e?.interface == p?.interface && length(e?.l3_device ?? '')) { + device = e.l3_device; + break; + } + } + + if (device == null) + for (let e in entries) + if (e?.l3_device == p?.interface) { + device = p.interface; + break; + } + + if (device == null) { + push(out, p); + continue; + } + + let gw4 = null, gw6 = null; + + for (let e in entries) { + if (e?.l3_device != device) + continue; + + gw4 ??= nexthop(e, 4); + gw6 ??= nexthop(e, 6); + } + + push(out, { + ...p, + interface: device, + gw4: p.gw4 ?? gw4, + gw6: p.gw6 ?? gw6 + }); + } + + return out; +}; diff --git a/shunt/src/nft.uc b/shunt/src/nft.uc new file mode 100644 index 000000000..86dc18bbf --- /dev/null +++ b/shunt/src/nft.uc @@ -0,0 +1,372 @@ +// shunt - nftables renderer +// +// Renders the whole ruleset: one table, two chains, per policy sets and +// marks. Pure string building, no kernel access. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +export const TABLE = 'inet shunt'; + +// The mask must stay a contiguous block: shift, capacity and mark are all +// derived from it. That is why it is a constant and not a UCI option. +export const DEFAULTS = { + mask: 0xff000000, + entry_ttl: 1200 +}; + +const RE_NAME = /^[A-Za-z0-9_]{1,24}$/; +const RE_V4 = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})(\/([0-9]{1,2}))?$/; +const RE_V6 = /^[0-9A-Fa-f:]{2,45}(\/([0-9]{1,3}))?$/; + +export function set_name(kind, family, policy) { + return `${kind}${family}_${policy}`; +}; + +function valid_name(s) { + return type(s) == 'string' && match(s, RE_NAME) != null; +} + +export function mac_addr(s) { + if (type(s) != 'string') + return null; + + let m = trim(lc(s)); + + return match(m, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/) ? m : null; +}; + +// A destination port or an inclusive range, normalised to nft syntax. Ports +// are 1-65535; 0 is reserved and never a destination. +export function port_spec(s) { + let v = trim(`${s ?? ''}`); + let m = match(v, /^([0-9]{1,5})(-([0-9]{1,5}))?$/); + + if (!m) + return null; + + let lo = +m[1]; + let hi = m[3] != null ? +m[3] : lo; + + if (lo < 1 || hi > 65535 || lo > hi) + return null; + + return lo == hi ? `${lo}` : `${lo}-${hi}`; +}; + +// tcp or udp only - nothing else carries a destination port, and naming a +// protocol that cannot be filtered by port is a configuration error worth +// reporting rather than silently rendering. +export function proto_name(s) { + let v = lc(trim(`${s ?? ''}`)); + + return (v == 'tcp' || v == 'udp') ? v : null; +}; + +export function addr_family(s) { + if (type(s) != 'string') + return null; + + let m = match(s, RE_V4); + if (m) { + for (let i = 1; i <= 4; i++) + if (+m[i] > 255) + return null; + if (m[6] != null && +m[6] > 32) + return null; + return 4; + } + + m = match(s, RE_V6); + if (m) { + let body = split(s, '/')[0]; + if (m[2] != null && +m[2] > 128) + return null; + if (index(body, ':::') >= 0) + return null; + if (length(split(body, '::')) > 2) + return null; + if (substr(body, 0, 1) == ':' && substr(body, 0, 2) != '::') + return null; + if (substr(body, -1) == ':' && substr(body, -2) != '::') + return null; + let groups = filter(split(body, ':'), (g) => g != ''); + if (length(groups) > 8 || (length(groups) == 8 && index(body, '::') >= 0)) + return null; + for (let g in groups) + if (length(g) > 4 || !match(g, /^[0-9A-Fa-f]+$/)) + return null; + if (index(body, '::') < 0 && length(groups) != 8) + return null; + return 6; + } + + return null; +}; + +function mask_shift(mask) { + let n = 0; + while (n < 32 && !((mask >> n) & 1)) + n++; + return n; +} + +export function compile(policies, opts) { + let mask = opts?.mask ?? DEFAULTS.mask; + let shift = mask_shift(mask); + let capacity = mask >> shift; + // Rule records, not strings: a MAC rule belongs in prerouting only, and + // both chains must render from one ordered list or precedence breaks. + let issues = [], marks = [], sets = [], rules4 = [], rules6 = []; + let idx = 0; + let learn = {}; + + function reject(policy, entry, reason) { + push(issues, { policy, entry, reason }); + } + + for (let pi = 0; pi < length(policies ?? []); pi++) { + let p = policies[pi]; + let pname = p?.name; + + if (!valid_name(pname)) { + reject(pname ?? `#${pi}`, null, + 'invalid policy name - must match [A-Za-z0-9_]{1,24}'); + continue; + } + + let src = { '4': [], '6': [] }, dst = { '4': [], '6': [] }; + + for (let a in (p.src ?? [])) { + let fam = addr_family(a); + if (fam) + push(src[sprintf('%d', fam)], a); + else + reject(pname, a, 'invalid src address'); + } + + for (let a in (p.dst ?? [])) { + let fam = addr_family(a); + if (fam) + push(dst[sprintf('%d', fam)], a); + else + reject(pname, a, 'invalid dst address'); + } + + let macs = []; + + for (let a in (p.src_mac ?? [])) { + let m = mac_addr(a); + if (m) + push(macs, m); + else + reject(pname, a, 'invalid src_mac address'); + } + + let ports = [], protos = []; + + for (let v in (p.dport ?? [])) { + let q = port_spec(v); + if (q) + push(ports, q); + else + reject(pname, v, 'invalid dport - expected 1-65535 or a range'); + } + + for (let v in (p.proto ?? [])) { + let q = proto_name(v); + if (q) + push(protos, q); + else + reject(pname, v, 'invalid proto - only tcp and udp carry ports'); + } + + // A port with no protocol means both, as banIP does it: "port 443 of + // this client" almost always includes QUIC, and requiring the + // protocol would let it slip through unnoticed. + if (length(ports) && !length(protos)) + protos = [ 'tcp', 'udp' ]; + + let has_dom = length(p.domains ?? []) > 0; + let has_dst_any = length(dst['4']) || length(dst['6']); + + // Ports and protocols were asked for and none survived validation. + // Rendering the policy anyway would drop the narrowing and mark + // everything the client sends - the same widening a mistyped client + // selector gets refused for. + if (length(p.dport ?? []) + length(p.proto ?? []) > 0 && + !length(ports) && !length(protos)) { + reject(pname, null, + 'no usable port or protocol - policy skipped rather than widened to all traffic'); + continue; + } + let has_ipsrc = length(src['4']) || length(src['6']); + let has_mac = length(macs) > 0; + let has_src = has_ipsrc || has_mac; + let has_any = has_src || length(dst['4']) || length(dst['6']) || has_dom; + + if (!has_any) { + reject(pname, null, 'policy selects nothing'); + continue; + } + + if (length(p.src ?? []) + length(p.src_mac ?? []) > 0 && !has_src) { + reject(pname, null, + 'no usable client selector - policy skipped rather than widened to every client'); + continue; + } + + if (++idx > capacity) { + reject(pname, null, + sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x)', + capacity, mask)); + continue; + } + + let mark = idx << shift; + // One transport term for all three rule shapes. `th dport` reads the + // port at the transport header offset, which works for tcp and udp + // alike, so a port without a protocol needs no rule per protocol. + let l4 = ''; + + if (length(protos)) + l4 = length(protos) == 1 + ? sprintf('meta l4proto %s ', protos[0]) + : sprintf('meta l4proto { %s } ', join(', ', protos)); + + if (length(ports)) + l4 += length(ports) == 1 + ? sprintf('th dport %s ', ports[0]) + : sprintf('th dport { %s } ', join(', ', ports)); + + let stmt = sprintf('%smeta mark set (meta mark & 0x%08x) | 0x%08x counter return', + l4, ~mask & 0xffffffff, mark); + + push(marks, { name: pname, index: idx, mark, + rt_table: 8000 + idx, rt_prio: 31000 + idx }); + + if (has_mac) + push(sets, sprintf( + '\tset %s { type ether_addr; counter; elements = { %s }; }', + set_name('m', '', pname), join(', ', macs))); + + for (let fam in [ '4', '6' ]) { + let ip = (fam == '4') ? 'ip' : 'ip6'; + let rules = (fam == '4') ? rules4 : rules6; + let atype = (fam == '4') ? 'ipv4_addr' : 'ipv6_addr'; + + if (has_dom) + push(sets, sprintf( + '\tset %s { type %s; flags timeout; counter; }', + set_name('d', fam, pname), atype)); + + let prefixes = []; + + if (length(src[fam])) + prefixes = [ ...prefixes, { + pre: sprintf('%s saddr @%s ', ip, set_name('c', fam, pname)), + out: true, per_family: true + } ]; + + if (has_mac) + prefixes = [ ...prefixes, { + pre: sprintf('ether saddr @%s ', set_name('m', '', pname)), + out: false, per_family: false + } ]; + + if (!has_src) + prefixes = [ { pre: '', out: true, per_family: false } ]; + + if (!length(prefixes)) { + if (length(dst[fam]) || has_dom) + reject(pname, null, sprintf( + 'src has no v%s entry - v%s rules skipped to avoid over-marking', + fam, fam)); + continue; + } + + if (length(src[fam])) + push(sets, sprintf( + '\tset %s { type %s; flags interval; counter; elements = { %s }; }', + set_name('c', fam, pname), atype, + join(', ', src[fam]))); + + if (length(dst[fam])) + push(sets, sprintf( + '\tset %s { type %s; flags interval; counter; elements = { %s }; }', + set_name('s', fam, pname), atype, + join(', ', dst[fam]))); + + for (let px in prefixes) + if (length(dst[fam])) + push(rules, { out: px.out, + text: sprintf('\t\t%s%s daddr @%s %s', + px.pre, ip, set_name('s', fam, pname), stmt) }); + + if (has_dom) + learn[set_name('d', fam, pname)] = true; + + // Destinations exist, but all in the other family: nothing for + // this one to route. A port or protocol alone does not have a + // family, so it does not trigger this. + if (!length(dst[fam]) && !has_dom && has_dst_any) + continue; + + for (let px in prefixes) { + if (has_dom) + push(rules, { out: px.out, + text: sprintf('\t\t%s%s daddr @%s %s', + px.pre, ip, set_name('d', fam, pname), stmt) }); + + if (!has_dst_any && !has_dom && (px.per_family || fam == '4')) + push(rules, { out: px.out, + text: sprintf('\t\t%s%s', px.pre, stmt) }); + } + } + } + + let setup = join('\n', [ + `destroy table ${TABLE}`, + `table ${TABLE} {`, + ...sets, + '\tchain prerouting {', + '\t\ttype filter hook prerouting priority mangle; policy accept;', + ...map(rules4, (r) => r.text), ...map(rules6, (r) => r.text), + '\t}', + '\tchain output {', + '\t\ttype route hook output priority mangle; policy accept;', + ...map(filter(rules4, (r) => r.out), (r) => r.text), + ...map(filter(rules6, (r) => r.out), (r) => r.text), + '\t}', + '}', + '' + ]); + + return { setup, marks, issues, learn }; +}; + +export function refresh(writes, entry_ttl) { + let ttl = entry_ttl ?? DEFAULTS.entry_ttl; + let out = [], issues = []; + + for (let w in (writes ?? [])) { + let m = match(w?.set ?? '', /^[csd]([46])_[A-Za-z0-9_]{1,24}$/); + let fam = addr_family(w?.addr ?? ''); + + if (!m || fam == null || sprintf('%d', fam) != m[1] || + index(w.addr, '/') >= 0) { + push(issues, { entry: w, reason: 'rejected, not rendered' }); + continue; + } + + push(out, sprintf('destroy element %s %s { %s }', TABLE, w.set, w.addr)); + push(out, sprintf('add element %s %s { %s timeout %ds }', + TABLE, w.set, w.addr, ttl)); + } + + return { batch: length(out) ? join('\n', out) + '\n' : '', issues }; +}; + +export function teardown() { + return `destroy table ${TABLE}\n`; +}; diff --git a/shunt/src/poll.uc b/shunt/src/poll.uc new file mode 100644 index 000000000..83677918c --- /dev/null +++ b/shunt/src/poll.uc @@ -0,0 +1,98 @@ +// shunt - active resolution +// +// Collects the resolvable names from the policies and turns query results +// into set writes, following CNAME chains from the queried name. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +import { normalize } from 'shunt.match'; +import { set_name } from 'shunt.nft'; + +export function names(policies) { + let seen = {}, out = []; + + for (let p in (policies ?? [])) { + for (let raw in (p?.domains ?? [])) { + let n = normalize(raw); + + if (!length(n) || index(n, '*') >= 0) + continue; + if (seen[n]) + continue; + + seen[n] = true; + push(out, n); + } + } + + return out; +}; + +const CHAIN_MAX = 8; + +// resolv keys records by their own owner name, so a CNAME answer hides the +// address under the canonical name. Walk from the name that was asked for. +export function addresses(by_name, name) { + let seen = {}; + let cur = normalize(name); + let a = [], aaaa = []; + + for (let hop = 0; hop < CHAIN_MAX; hop++) { + if (!length(cur ?? '') || seen[cur]) + break; + + seen[cur] = true; + + let e = by_name[cur]; + if (!e) + break; + + for (let v in (e.A ?? [])) + push(a, v); + for (let v in (e.AAAA ?? [])) + push(aaaa, v); + + cur = normalize((e.CNAME ?? [])[0] ?? ''); + } + + return { a, aaaa }; +}; + +export function index_results(results) { + let by = {}; + + for (let k in (results ?? {})) { + let n = normalize(k); + + if (length(n)) + by[n] = results[k]; + } + + return by; +}; + +export function plan(results, matcher, names) { + let writes = []; + let by = index_results(results); + + for (let raw in (names ?? [])) { + let name = normalize(raw); + + let policies = matcher.test(name); + if (policies == null) + continue; + + let got = addresses(by, name); + + for (let policy in policies) { + for (let a in got.a) + push(writes, { set: set_name('d', 4, policy), addr: a }); + + for (let a in got.aaaa) + push(writes, { set: set_name('d', 6, policy), addr: a }); + } + } + + return writes; +}; diff --git a/shunt/src/route.uc b/shunt/src/route.uc new file mode 100644 index 000000000..f2b03b277 --- /dev/null +++ b/shunt/src/route.uc @@ -0,0 +1,97 @@ +// shunt - ip rule and route renderer +// +// Renders the argv arrays for the policy routing tables and their rules. +// Pure, like nft.uc - nothing here talks to the kernel. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +import { addr_family, DEFAULTS } from 'shunt.nft'; + +const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/; + +const BLACKHOLE_METRIC = 9999; + +export function compile(policies, marks, opts) { + let mask = opts?.mask ?? DEFAULTS.mask; + let add = [], del = [], tables = [], issues = []; + + let by_name = {}; + for (let m in (marks ?? [])) + by_name[m.name] = m; + + function reject(policy, entry, reason) { + push(issues, { policy, entry, reason }); + } + + for (let p in (policies ?? [])) { + let m = by_name[p?.name]; + if (!m) + continue; + + let iface = p.interface; + if (type(iface) != 'string' || match(iface, RE_IFACE) == null) { + reject(p.name, iface, 'invalid or missing interface'); + continue; + } + + let fb = p.fallback ?? 'main'; + if (fb != 'main' && fb != 'block') { + reject(p.name, p.fallback, "fallback must be 'main' or 'block'"); + continue; + } + + let gw = { '4': null, '6': null }; + let gw_bad = false; + + for (let fam in [ '4', '6' ]) { + let g = p[`gw${fam}`]; + if (g == null) + continue; + if (sprintf('%d', addr_family(g)) == fam && index(g, '/') < 0) + gw[fam] = g; + else { + reject(p.name, g, `invalid gw${fam}`); + gw_bad = true; + } + } + + if (gw_bad) + continue; + + let fwmark = sprintf('0x%x/0x%x', m.mark, mask); + let table = sprintf('%d', m.rt_table); + let pref = sprintf('%d', m.rt_prio); + + push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name)); + + for (let fam in [ '4', '6' ]) { + let v = `-${fam}`; + + let route = [ 'ip', v, 'route', 'replace', 'default' ]; + if (gw[fam]) + push(route, 'via', gw[fam]); + push(route, 'dev', iface, 'table', table); + push(add, route); + + if (fb == 'block') + push(add, [ 'ip', v, 'route', 'replace', 'blackhole', + 'default', 'metric', + sprintf('%d', BLACKHOLE_METRIC), + 'table', table ]); + + push(add, [ 'ip', v, 'rule', 'add', 'pref', pref, + 'fwmark', fwmark, 'lookup', table ]); + + unshift(del, [ 'ip', v, 'route', 'flush', 'table', table ]); + unshift(del, [ 'ip', v, 'rule', 'del', 'pref', pref ]); + } + } + + return { + add, + del, + rt_tables: length(tables) ? join('\n', tables) + '\n' : '', + issues + }; +}; diff --git a/shunt/src/snoop.uc b/shunt/src/snoop.uc new file mode 100644 index 000000000..5d8a38575 --- /dev/null +++ b/shunt/src/snoop.uc @@ -0,0 +1,75 @@ +// shunt - passive DNS observer +// +// Opens an AF_PACKET socket with a BPF filter on DNS answers and turns a +// captured frame into a verdict: which policies want it, or why not. +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken + +import { decap } from 'shunt.frame'; +import { parse, TYPE } from 'shunt.dns'; + +export const RECV_LEN = 4160; + +export function open(dev) { + let sock, bpf; + + try { + sock = require('socket'); + } + catch (e) { + return { ok: false, err: 'socket module missing - install ucode-mod-socket' }; + } + + try { + bpf = require('shunt.snoop_bpf').BPF; + } + catch (e) { + return { ok: false, + err: 'shunt.snoop_bpf missing - reinstall the shunt package' }; + } + + let s = sock.create(sock.AF_PACKET, sock.SOCK_RAW, 0); + if (!s) + return { ok: false, err: `create: ${sock.error()}` }; + + if (!s.setopt(sock.SOL_SOCKET, sock.SO_ATTACH_FILTER, + { len: length(bpf), filter: bpf })) { + let err = `SO_ATTACH_FILTER: ${sock.error()}`; + s.close(); + return { ok: false, err }; + } + + if (!s.bind({ family: sock.AF_PACKET, interface: dev, + protocol: 0x0003, address: '00:00:00:00:00:00' })) { + let err = `bind: ${sock.error()}`; + s.close(); + return { ok: false, err }; + } + + return { ok: true, sock: s }; +}; + +// Returns { policies, qname, a, aaaa } or { drop: }. The verdict +// strings are contract; the fixtures compare them verbatim. +export function observe(frame, matcher) { + let f = decap(frame); + if (!f.ok) + return { drop: `frame:${f.err}` }; + + let r = parse(f.payload); + if (!r.ok) + return { drop: `dns:${r.err}` }; + + if (r.qtype != TYPE.A && r.qtype != TYPE.AAAA) + return { drop: 'qtype' }; + + if (!length(r.a) && !length(r.aaaa)) + return { drop: 'noaddr' }; + + let policies = matcher.test(r.qname); + if (policies == null) + return { drop: 'nomatch' }; + + return { policies, qname: r.qname, a: r.a, aaaa: r.aaaa }; +}; diff --git a/shunt/src/snoop_bpf.uc b/shunt/src/snoop_bpf.uc new file mode 100644 index 000000000..b31ffdb90 --- /dev/null +++ b/shunt/src/snoop_bpf.uc @@ -0,0 +1,82 @@ +// shunt - BPF program for the snoop socket +// +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (c) 2026 Dirk Brenken (dev@brenken.org) +// +// GENERATED - do not edit. +// +// Expression: udp src port 53 or (vlan and udp src port 53) +// Link type: EN10MB (br-lan) +// Instructions: 52 +// tcpdump version 4.99.6 +// libpcap version 1.10.6 (64-bit time_t, with TPACKET_V3) +// 64-bit build, 64-bit time_t +// +// Take it whole. The vlan primitive prefixes `ld #0; st M[0]; +// st M[1]` and the later branches read those scratch slots, so +// dropping the preamble or splicing the two halves breaks the +// tagged path silently. Without `vlan` the program would start +// at `ldh [12]` and be 16 instructions instead of 52. +// +// Return style, not export style, and that is load bearing: +// snoop.uc loads this with require() at open() time so the +// module itself stays loadable without the constant, and +// require() only accepts return style - export syntax fails to +// compile outside an import. + +return { + BPF: [ + [ 0, 0, 0, 0 ], + [ 2, 0, 0, 0 ], + [ 2, 0, 0, 1 ], + [ 40, 0, 0, 12 ], + [ 21, 0, 4, 34525 ], + [ 48, 0, 0, 20 ], + [ 21, 0, 10, 17 ], + [ 40, 0, 0, 54 ], + [ 21, 41, 8, 53 ], + [ 21, 0, 7, 2048 ], + [ 48, 0, 0, 23 ], + [ 21, 0, 5, 17 ], + [ 40, 0, 0, 20 ], + [ 69, 3, 0, 8191 ], + [ 177, 0, 0, 14 ], + [ 72, 0, 0, 14 ], + [ 21, 33, 0, 53 ], + [ 48, 0, 0, 4294963248 ], + [ 21, 7, 0, 1 ], + [ 0, 0, 0, 4 ], + [ 2, 0, 0, 0 ], + [ 2, 0, 0, 1 ], + [ 40, 0, 0, 12 ], + [ 21, 2, 0, 33024 ], + [ 21, 1, 0, 34984 ], + [ 21, 0, 25, 37120 ], + [ 97, 0, 0, 1 ], + [ 72, 0, 0, 12 ], + [ 21, 0, 6, 34525 ], + [ 97, 0, 0, 0 ], + [ 80, 0, 0, 20 ], + [ 21, 0, 19, 17 ], + [ 97, 0, 0, 0 ], + [ 72, 0, 0, 54 ], + [ 21, 15, 16, 53 ], + [ 21, 0, 15, 2048 ], + [ 97, 0, 0, 0 ], + [ 80, 0, 0, 23 ], + [ 21, 0, 12, 17 ], + [ 97, 0, 0, 0 ], + [ 72, 0, 0, 20 ], + [ 69, 9, 0, 8191 ], + [ 97, 0, 0, 0 ], + [ 80, 0, 0, 14 ], + [ 84, 0, 0, 15 ], + [ 100, 0, 0, 2 ], + [ 12, 0, 0, 0 ], + [ 7, 0, 0, 0 ], + [ 72, 0, 0, 14 ], + [ 21, 0, 1, 53 ], + [ 6, 0, 0, 262144 ], + [ 6, 0, 0, 0 ], + ] +}; diff --git a/shunt/test-version.sh b/shunt/test-version.sh new file mode 100755 index 000000000..d34b27b2f --- /dev/null +++ b/shunt/test-version.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# shunt has no version output by design: the runtime version comes from +# rpc-sys packagelist via ubus, which is not available in the CI +# container. The forced generic version check can therefore never match +# PKG_VERSION in the output of the daemon. + +[ "$1" = "shunt" ] || exit 1 + +exit 0 diff --git a/shunt/test.sh b/shunt/test.sh new file mode 100755 index 000000000..394ae21e3 --- /dev/null +++ b/shunt/test.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# compile and load with the shipped ucode. +# + +shunt 2>&1 | grep 'usage: shunt' || exit 1 + +ucode -e 'import * as a from "shunt.config"; import * as b from "shunt.nft"; + import * as c from "shunt.dns"; import * as d from "shunt.frame"; + import * as e from "shunt.match"; print("modules-ok\n");' | + grep 'modules-ok'