⛅ Sync 2026-08-24 23:49:27

This commit is contained in:
github-actions[bot] committed 2026-08-24 23:49:27 +08:00
1 parent 52b7e7c7ca
commit 354510445a
48 files changed
+4980 -4592

No files matched your search

-355
View File
@@ -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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
-78
View File
@@ -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 <pepe.schlehofer@gmail.com>
PKG_LICENSE:=GPL-2.0-only
PKG_LICENSE_FILES:=LICENSE
include $(INCLUDE_DIR)/package.mk
define Package/librespeed-common
SECTION:=utils
CATEGORY:=Utilities
TITLE:=LibreSpeed measurement orchestration
DEPENDS:=+librespeed-cli +rpcd-mod-ucode +jsonfilter +ucode +ucode-mod-fs \
+ucode-mod-uci +jshn
PKGARCH:=all
endef
define Package/librespeed-common/description
Runs LibreSpeed measurements from UCI configuration: a single entry point
used both interactively and from cron, a schedule kept in step with UCI by
the init script, and results recorded as JSON for anything that wants to
read them, such as luci-app-librespeed, and a ubus interface for anything else.
endef
define Package/librespeed-common/conffiles
/etc/config/librespeed
endef
# rpcd only enumerates its plugin directory at startup, so the librespeed ubus
# object appears (and disappears) with a reload, not with the file.
define Package/librespeed-common/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
define Package/librespeed-common/postrm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
exit 0
endef
# No source archive to unpack: the license text ships beside the Makefile and
# is staged so PKG_LICENSE_FILES points at a real file.
define Build/Prepare
$(INSTALL_DATA) ./LICENSE $(PKG_BUILD_DIR)/
endef
define Build/Compile
endef
define Package/librespeed-common/install
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./files/librespeed-run $(1)/usr/libexec/librespeed-run
$(INSTALL_BIN) ./files/librespeed-aggregate $(1)/usr/libexec/librespeed-aggregate
$(SED) 's/%%VERSION%%/$(PKG_VERSION)-$(PKG_RELEASE)/g' \
$(1)/usr/libexec/librespeed-run \
$(1)/usr/libexec/librespeed-aggregate
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
$(INSTALL_DATA) ./files/librespeed.uc $(1)/usr/share/rpcd/ucode/librespeed.uc
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/librespeed.config $(1)/etc/config/librespeed
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/librespeed.init $(1)/etc/init.d/librespeed
endef
$(eval $(call BuildPackage,librespeed-common))
@@ -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);
}
-414
View File
@@ -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
-18
View File
@@ -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'
-148
View File
@@ -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
}
-357
View File
@@ -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 };
-35
View File
@@ -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"
-11
View File
@@ -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 <pepe.schlehofer@gmail.com>
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -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 '<line x1="%d" y1="%f" x2="%d" y2="%f" stroke="currentColor" stroke-opacity="0.15"/>'
.format(L, y, W - R, y) +
'<text x="%d" y="%f" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%s</text>'
.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 += '<polygon class="librespeed-band librespeed-fill-%d" points="%s %s"/>'
.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 += '<polygon class="librespeed-area librespeed-fill-%d" points="%s %f,%f %f,%f"/>'
.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 += '<line class="librespeed-avgline librespeed-stroke-%d" x1="%d" y1="%f" x2="%d" y2="%f"/>'
.format(ci, L, sc.ys(avg), W - R, sc.ys(avg));
body += '<text x="%d" y="%f" font-size="9" fill="currentColor" fill-opacity=".55" text-anchor="end">%h %s</text>'
.format(W - R, sc.ys(avg) - 4, _('avg'),
avg.toFixed(avg >= 100 ? 0 : 1));
body += (valid.length > 1)
? '<polyline class="librespeed-line librespeed-stroke-%d" points="%s"/>'.format(ci, pts)
: '<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="3"/>'
.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 =>
'<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="2"/>'
.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)
? '<text x="%f" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="middle">%h</text>'
.format((L + W - R) / 2, H - 6, s2)
: '';
const unit = metricOf(series[0]) ? metricOf(series[0])[2] : '';
const chart = E('div', {});
chart.innerHTML =
'<svg viewBox="0 0 %d %d" style="width:100%%; max-height:280px" xmlns="http://www.w3.org/2000/svg">'
.format(W, H) +
grid + body +
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6">%h</text>'
.format(L, H - 6, s1) +
midLabel +
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%h (%s)</text>'
.format(W - R, H - 6, s3, unit) +
'</svg>';
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');
}
});
@@ -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;
}
@@ -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
});
@@ -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 ]);
});
}
});
@@ -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 += '<line class="librespeed-gauge-tick" x1="%f" y1="%f" x2="%f" y2="%f"/>'
.format(t1[0], t1[1], t2[0], t2[1]);
if (major) {
const p = polar(cx, cy, r - 28, a);
marks += '<text x="%f" y="%f" text-anchor="middle" font-size="8.5" fill-opacity=".55">%s</text>'
.format(p[0], p[1] + 3, gaugeLabel(top * i / 10));
}
}
const tip = polar(cx, cy, r - 20, start - amt * span);
return '<svg class="librespeed-gauge" viewBox="0 0 200 150" xmlns="http://www.w3.org/2000/svg">' +
'<path class="librespeed-gauge-bg" d="%s"/>'.format(path) +
'<path class="librespeed-gauge-fill librespeed-stroke-%d" d="%s" stroke-dasharray="%f %f"/>'
.format(ci, path, amt * len, len) +
marks +
'<line class="librespeed-gauge-needle" x1="%f" y1="%f" x2="%f" y2="%f"/>'
.format(cx, cy, tip[0], tip[1]) +
'<circle class="librespeed-gauge-hub" cx="%f" cy="%f" r="4"/>'.format(cx, cy) +
'<text x="100" y="133" text-anchor="middle" font-size="26" font-weight="500">%s</text>'
.format((typeof mbps == 'number') ? '%.2f'.format(mbps) : '–') +
'<text x="100" y="147" text-anchor="middle" font-size="11" fill-opacity=".6">Mbps</text>' +
'</svg>';
}
/* 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
});
@@ -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 ""
@@ -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"
}
}
}
@@ -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"
]
}
}
}
+1 -1
View File
@@ -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:= \
@@ -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))
+16
View File
@@ -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 <dev@brenken.org>
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,4 @@
'use strict';
'require view.shunt.logtemplate as LogTemplate';
return LogTemplate.Logview(/\bshunt(\[\d+\])?:/, 'shunt');
@@ -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 });
@@ -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(`<a style="color:#37c;font-weight:bold;" href="https://github.com/openwrt/packages/blob/master/net/shunt/files/README.md" target="_blank" rel="noreferrer noopener" >${_('online documentation')}</a>`));
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
});
@@ -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
});
@@ -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
});
+532
View File
@@ -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 ""
@@ -0,0 +1,4 @@
#!/bin/sh
rm -f /var/luci-indexcache.*.json
[ -x "/etc/init.d/rpcd" ] && /etc/init.d/rpcd reload
exit 0
@@ -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"
}
}
}
@@ -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"
]
}
}
}
+67
View File
@@ -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 <dev@brenken.org>
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))
+777
View File
@@ -0,0 +1,777 @@
<!-- markdownlint-disable -->
# 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)
<a id="description"></a>
## 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).
<a id="quick-start"></a>
## 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).
<a id="main-features"></a>
## 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
<a id="prerequisites"></a>
## 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.
<a id="which-tunnels-work"></a>
### 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 <device> table <n>`.
* **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.<dev>.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/<dev>` 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.
<a id="installation-and-usage"></a>
## 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
<a id="shunt-cli-interface"></a>
## 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 <cmd> # 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.
<a id="shunt-config-options"></a>
## 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 <gw> dev <device>` when a gateway is known,
`default dev <device>` 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.
<a id="how-addresses-are-learned"></a>
## 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.
<a id="what-polling-costs"></a>
### 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.
<a id="examples"></a>
## 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'
```
<a id="what-it-shells-out-to"></a>
### 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.
<a id="what-shunt-creates-on-the-system"></a>
## 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_<policy> / d6_<policy> learned, flags timeout, per-element counter
set s4_<policy> / s6_<policy> static dst, flags interval, counter
set c4_<policy> / c6_<policy> client src selectors, interval, counter
set m_<policy> client MACs, no family digit, counter
fwmark <index> << 24, mask 0xff000000
ip rule pref 31000 + <index>
routing table 8000 + <index>
/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 <set>` 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.
<a id="coexistence-with-pbr-and-mwan3"></a>
## 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.
<a id="troubleshooting-and-debug-options"></a>
## 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 <policy-interface> host <addr> # 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 <addr> mark 0x1000000
ip route get <addr>
```
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 <client> 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.
<a id="known-limitations"></a>
## 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.
<a id="support"></a>
## 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.
<a id="removal"></a>
## 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.
<a id="donations"></a>
## 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
+16
View File
@@ -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'
+39
View File
@@ -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
}
+309
View File
@@ -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 <dev@brenken.org>
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, <dev>),
// 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 };
}
}
}
};
+646
View File
@@ -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 <dev@brenken.org>
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.<dev>), 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/<dev> 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);
}
+177
View File
@@ -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 <dev@brenken.org>
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 };
};
+41
View File
@@ -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 <dev@brenken.org>
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 };
};
+249
View File
@@ -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 <dev@brenken.org>
// 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
};
};
+154
View File
@@ -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 <dev@brenken.org>
// 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)
};
};
+129
View File
@@ -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 <dev@brenken.org>
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)) }
};
};
+64
View File
@@ -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 <dev@brenken.org>
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;
};
+372
View File
@@ -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 <dev@brenken.org>
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`;
};
+98
View File
@@ -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 <dev@brenken.org>
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;
};
+97
View File
@@ -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 <dev@brenken.org>
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
};
};
+75
View File
@@ -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 <dev@brenken.org>
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: <verdict> }. 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 };
};
+82
View File
@@ -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 ],
]
};
+9
View File
@@ -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
Executable
+10
View File
@@ -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'