mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 13:03:56 +08:00
⛅ Sync 2026-08-24 23:49:27
This commit is contained in:
1 parent
e187a41f8d
commit
1bd7b2c053
48 files changed
+4980
-4592
No files matched your search
@@ -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.
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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'
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-passwall
|
PKG_NAME:=luci-app-passwall
|
||||||
PKG_VERSION:=26.8.19
|
PKG_VERSION:=26.8.19
|
||||||
PKG_RELEASE:=240
|
PKG_RELEASE:=241
|
||||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||||
|
|
||||||
PKG_CONFIG_DEPENDS:= \
|
PKG_CONFIG_DEPENDS:= \
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ local function start()
|
|||||||
end)
|
end)
|
||||||
if firewall_num > 0 then
|
if firewall_num > 0 then
|
||||||
api.uci_save(uci, "firewall", true, true)
|
api.uci_save(uci, "firewall", true, true)
|
||||||
|
cmd("/etc/init.d/firewall reload >/dev/null 2>&1")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ local function stop()
|
|||||||
end)
|
end)
|
||||||
if num > 0 then
|
if num > 0 then
|
||||||
api.uci_save(uci, "firewall", true, true)
|
api.uci_save(uci, "firewall", true, true)
|
||||||
|
cmd("/etc/init.d/firewall reload >/dev/null 2>&1")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
cmd(string.format("rm -rf %s %s", CONFIG_PATH, LOG_APP_FILE))
|
cmd(string.format("rm -rf %s %s", CONFIG_PATH, LOG_APP_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
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Executable
+646
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)) }
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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`;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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 ],
|
||||||
|
]
|
||||||
|
};
|
||||||
Executable
+9
@@ -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
@@ -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'
|
||||||
Reference in new issue
Block a user