From 87101cc7f2ef3c5d1833d6844902fd12879b6703 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Sep 2026 15:19:50 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=88=20Sync=202026-09-24=2015:19:50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agentflow/Makefile | 16 +- agentflow/files/agentflow-plugin.json | 2 +- .../tests/test_desktop_manifest_contract.py | 2 +- baidudrive/Makefile | 10 +- baidudrive/files/baidudrive-plugin.json | 3 +- dae/Makefile | 6 +- daed/Makefile | 6 +- ...1-detach-bpf-hooks-on-dashboard-stop.patch | 23 - ...-fast-probe-for-manual-latency-tests.patch | 18 - .../0005-bound-startup-network-wait.patch | 62 +- ...ictly-reconcile-subscription-updates.patch | 45 +- ...ription-cron-changes-without-restart.patch | 44 - ...1-preserve-reload-safety-on-new-wing.patch | 28 + .../patches/0012-adapt-wing-to-new-core.patch | 837 ------------------ .../0012-export-config-marshaller-bytes.patch | 21 + dockermanager/Makefile | 10 +- fastnet/Makefile | 22 +- fastnet/files/fastnet-plugin.json | 44 + headscale/Makefile | 4 +- istoreenhance/Makefile | 10 +- kai-agent/Makefile | 43 + kai/Makefile | 27 +- kai/files/kai-plugin.json | 59 ++ kai/files/kai-session-launch | 10 + kai/files/kai.config | 1 + kai/files/kai.init | 85 +- kai/files/logo.png | Bin 0 -> 20731 bytes kai_session/Makefile | 7 +- kaiplus/Makefile | 9 +- linkease-app-entry/Makefile | 76 ++ .../files/linkease-app-entry.config | 7 + .../files/linkease-app-entry.init | 115 +++ .../files/linkease-app-entry.uci-default | 29 + linkease-common-bin/Makefile | 4 +- linkease/Makefile | 4 +- linkeasefull/Makefile | 8 +- linkeasefull/files/linkeasefull.init | 162 ++-- linkeasefull/files/linkeasefull.uci-default | 5 +- luci-app-agentflow/Makefile | 4 +- .../luasrc/controller/agentflow.lua | 291 +----- .../test_agentflow_luci_open_contract.py | 18 +- luci-app-baidudrive/Makefile | 4 +- .../luasrc/controller/baidudrive.lua | 44 +- .../view/baidudrive/baidudrive_status.htm | 2 +- luci-app-dockermanager/Makefile | 4 +- .../luasrc/controller/dockermanager.lua | 194 +--- .../luasrc/view/dockermanager_status.htm | 2 +- luci-app-fastnet/Makefile | 7 +- .../luasrc/controller/fastnet.lua | 52 +- luci-app-fastnet/luasrc/model/cbi/fastnet.lua | 19 +- luci-app-fwlive/Makefile | 2 +- .../luci-static/resources/fwlive/chips.js | 4 +- .../luci-static/resources/fwlive/css.js | 2 +- .../luci-static/resources/fwlive/fwlive.css | 1 + .../luci-static/resources/fwlive/log.js | 177 ++-- .../luci-static/resources/fwlive/logging.js | 2 +- .../resources/view/status/fwlive.js | 1 - luci-app-fwlive/po/de/luci-app-fwlive.po | 8 + luci-app-fwlive/po/ru/luci-app-fwlive.po | 8 + .../po/templates/luci-app-fwlive.pot | 661 +++++++------- luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po | 8 + .../usr/libexec/fwlive-is-firewall-event.awk | 4 + .../root/usr/libexec/fwlive-logging.sh | 104 ++- luci-app-fwlive/root/usr/libexec/rpcd/fwlive | 303 +++++-- luci-app-istoreenhance/Makefile | 7 +- .../luasrc/controller/istoreenhance.lua | 38 +- .../luasrc/view/istoreenhance_status.htm | 2 +- luci-app-kai/Makefile | 4 +- luci-app-kai/luasrc/controller/kai.lua | 10 +- luci-app-kai/luasrc/view/kai/kai_status.htm | 7 +- luci-app-kaiplus/Makefile | 4 +- .../luasrc/controller/kaiplus.lua | 194 +--- .../luasrc/view/kaiplus/kaiplus_status.htm | 2 +- luci-app-linkeasefull-embed/Makefile | 74 ++ luci-app-linkeasefull-embed/README.md | 30 + .../files/openwrt-luci-maintain.sh | 43 + .../files/openwrt-luci/openwrt-luci.json | 31 + .../files/openwrt-luci/openwrt.svg | 7 + .../files/protocol/bonding.js | 24 + .../files/protocol/directip.js | 24 + .../files/protocol/wwan.js | 24 + .../files/www/embed-prelude.js | 403 +++++++++ .../files/www/embed.css | 52 ++ luci-app-linkeasefull/Makefile | 2 +- .../luasrc/model/cbi/linkeasefull.lua | 19 +- luci-app-passwall/Makefile | 2 +- .../passwall/client/node_subscribe_config.lua | 5 +- .../passwall/node_config/link_share_man.htm | 86 +- .../root/usr/share/passwall/iptables.sh | 52 +- .../root/usr/share/passwall/nftables.sh | 46 +- .../root/usr/share/passwall/subscribe.lua | 53 +- luci-app-wwand/Makefile | 6 +- luci-lib-linkeaseauth/Makefile | 6 +- .../luasrc/controller/linkease_apps.lua | 31 + .../luasrc/controller/linkease_auth.lua | 201 +---- .../luasrc/model/linkease/apps.lua | 113 +++ .../luasrc/model/linkease/apps_compat.lua | 38 + .../luasrc/model/linkease/apps_http.lua | 49 + .../luasrc/model/linkease/apps_openwrt.lua | 189 ++++ .../luasrc/model/linkease/auth.lua | 191 ++++ .../tests/test_linkease_apps.lua | 94 ++ .../tests/test_linkease_apps_compat.lua | 36 + .../tests/test_linkease_apps_http.lua | 64 ++ .../tests/test_linkease_apps_openwrt.lua | 158 ++++ .../tests/test_linkease_auth.lua | 177 ++++ .../view/dashboard/include/30_wifi.js | 10 +- luci-proto-wwand/Makefile | 6 +- luci-theme-argon/Makefile | 2 +- luci-theme-argon/README.md | 9 + luci-theme-argon/README_ZH.md | 9 + .../luci-static/argon/background/README.md | 2 +- .../htdocs/luci-static/argon/css/cascade.css | 64 +- .../luci-static/resources/menu-argon.js | 37 +- luci-theme-argon/less/cascade.less | 26 +- luci-theme-argon/less/layout.less | 10 +- luci-theme-argon/less/sysauth.less | 36 +- .../usr/libexec/rpcd/luci.argon_wallpaper | 50 +- luci-theme-argon/tests/theme-behavior.test.js | 249 ++++++ .../ucode/template/themes/argon/footer.ut | 20 +- .../template/themes/argon/footer_login.ut | 20 +- .../ucode/template/themes/argon/header.ut | 59 +- .../template/themes/argon/header_login.ut | 57 +- .../ucode/template/themes/argon/sysauth.ut | 93 +- mise/Makefile | 2 +- mise/files/mise.sh | 45 + wwand/Makefile | 88 +- 126 files changed, 4403 insertions(+), 2879 deletions(-) delete mode 100644 daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch delete mode 100644 daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch delete mode 100644 daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch create mode 100644 daed/patches/0011-preserve-reload-safety-on-new-wing.patch delete mode 100644 daed/patches/0012-adapt-wing-to-new-core.patch create mode 100644 daed/patches/0012-export-config-marshaller-bytes.patch create mode 100644 fastnet/files/fastnet-plugin.json create mode 100644 kai-agent/Makefile create mode 100644 kai/files/kai-plugin.json create mode 100755 kai/files/kai-session-launch create mode 100644 kai/files/logo.png create mode 100644 linkease-app-entry/Makefile create mode 100644 linkease-app-entry/files/linkease-app-entry.config create mode 100644 linkease-app-entry/files/linkease-app-entry.init create mode 100644 linkease-app-entry/files/linkease-app-entry.uci-default create mode 100644 luci-app-linkeasefull-embed/Makefile create mode 100644 luci-app-linkeasefull-embed/README.md create mode 100644 luci-app-linkeasefull-embed/files/openwrt-luci-maintain.sh create mode 100644 luci-app-linkeasefull-embed/files/openwrt-luci/openwrt-luci.json create mode 100644 luci-app-linkeasefull-embed/files/openwrt-luci/openwrt.svg create mode 100644 luci-app-linkeasefull-embed/files/protocol/bonding.js create mode 100644 luci-app-linkeasefull-embed/files/protocol/directip.js create mode 100644 luci-app-linkeasefull-embed/files/protocol/wwan.js create mode 100644 luci-app-linkeasefull-embed/files/www/embed-prelude.js create mode 100644 luci-app-linkeasefull-embed/files/www/embed.css create mode 100644 luci-lib-linkeaseauth/luasrc/controller/linkease_apps.lua create mode 100644 luci-lib-linkeaseauth/luasrc/model/linkease/apps.lua create mode 100644 luci-lib-linkeaseauth/luasrc/model/linkease/apps_compat.lua create mode 100644 luci-lib-linkeaseauth/luasrc/model/linkease/apps_http.lua create mode 100644 luci-lib-linkeaseauth/luasrc/model/linkease/apps_openwrt.lua create mode 100644 luci-lib-linkeaseauth/luasrc/model/linkease/auth.lua create mode 100644 luci-lib-linkeaseauth/tests/test_linkease_apps.lua create mode 100644 luci-lib-linkeaseauth/tests/test_linkease_apps_compat.lua create mode 100644 luci-lib-linkeaseauth/tests/test_linkease_apps_http.lua create mode 100644 luci-lib-linkeaseauth/tests/test_linkease_apps_openwrt.lua create mode 100644 luci-lib-linkeaseauth/tests/test_linkease_auth.lua create mode 100644 luci-theme-argon/tests/theme-behavior.test.js diff --git a/agentflow/Makefile b/agentflow/Makefile index 826eefe17..39bcb087f 100644 --- a/agentflow/Makefile +++ b/agentflow/Makefile @@ -10,12 +10,12 @@ AGENTFLOW_ARCH_x86_64:=amd64 AGENTFLOW_ARCH_aarch64:=arm64 AGENTFLOW_ARCH:=$(AGENTFLOW_ARCH_$(ARCH)) -AGENTFLOW_HASH_x86_64:=9174fec77dc835640f94ea8bef84b580b84f947823f4dd63fc495c4c58a68297 -AGENTFLOW_HASH_aarch64:=256adf7bcb836259dacbf583c52af04f0134587328689a49811c135260fa5aed +AGENTFLOW_HASH_x86_64:=3bdd9b3af7a95c78d6fb6589117931e2be3f2800056fc7836aa95abe48d0a33d +AGENTFLOW_HASH_aarch64:=8962a1ec6f57ee394ddfdf61abd72191f5378644c5ab1ce85040b132b757cc43 PKG_NAME:=agentflow -PKG_VERSION:=0.3.3 -PKG_RELEASE:=13 +PKG_VERSION:=0.3.4 +PKG_RELEASE:=14 AGENTFLOW_URL_FILE:=agentflow-linux-$(AGENTFLOW_ARCH) AGENTFLOW_DOWNLOAD:=$(AGENTFLOW_URL_FILE)-$(AGENTFLOW_HASH_$(ARCH)) @@ -40,7 +40,7 @@ define Package/$(PKG_NAME) CATEGORY:=Network SUBMENU:=Web Servers/Proxies TITLE:=AgentFlow - DEPENDS:=@(x86_64||aarch64) +git +git-http +mise + DEPENDS:=@(x86_64||aarch64) +git +git-http +mise +linkease-app-entry URL:=https://agentflow.geili.ai/ endef @@ -58,6 +58,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/30-agentflow.json 2>/dev/null)" = "/usr/share/agentflow/agentflow-plugin.json" ]; then rm -f /usr/share/linkeasefull/desktop-apps.d/30-agentflow.json fi + if [ "$$(readlink /usr/share/linkease/apps.d/30-agentflow.json 2>/dev/null)" = "/usr/share/agentflow/agentflow-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/30-agentflow.json + fi fi exit 0 endef @@ -74,7 +77,7 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config $(1)/usr/share/agentflow/www $(1)/usr/share/linkeasefull/desktop-apps.d + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config $(1)/usr/share/agentflow/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(INSTALL_BIN) $(PKG_BUILD_DIR)/agentflow $(1)/usr/sbin/agentflow $(INSTALL_BIN) ./files/agentflow.init $(1)/etc/init.d/agentflow $(INSTALL_BIN) ./files/agentflow.uci-default $(1)/etc/uci-defaults/09-agentflow @@ -82,6 +85,7 @@ define Package/$(PKG_NAME)/install $(INSTALL_DATA) ./files/agentflow-plugin.json $(1)/usr/share/agentflow/agentflow-plugin.json $(INSTALL_DATA) ./files/www/logo.svg $(1)/usr/share/agentflow/www/logo.svg ln -sf /usr/share/agentflow/agentflow-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/30-agentflow.json + ln -sf /usr/share/agentflow/agentflow-plugin.json $(1)/usr/share/linkease/apps.d/30-agentflow.json endef $(eval $(call Download,agentflow)) diff --git a/agentflow/files/agentflow-plugin.json b/agentflow/files/agentflow-plugin.json index 12fbfa58c..25287cea3 100644 --- a/agentflow/files/agentflow-plugin.json +++ b/agentflow/files/agentflow-plugin.json @@ -12,7 +12,7 @@ "standalone": { "entry": "index.html", "basePath": "/apps/agentflow/", - "url": "/cgi-bin/luci/admin/services/agentflow/open", + "url": "/apps/agentflow/", "externalOpen": { "enabled": true, "label": "Open AgentFlow" diff --git a/agentflow/tests/test_desktop_manifest_contract.py b/agentflow/tests/test_desktop_manifest_contract.py index f5bec8f40..fe2ee0340 100644 --- a/agentflow/tests/test_desktop_manifest_contract.py +++ b/agentflow/tests/test_desktop_manifest_contract.py @@ -19,7 +19,7 @@ class DesktopManifestContractTest(unittest.TestCase): self.assertEqual(self.manifest["standalone"]["basePath"], "/apps/agentflow/") self.assertEqual( self.manifest["standalone"]["url"], - "/cgi-bin/luci/admin/services/agentflow/open", + "/apps/agentflow/", ) self.assertEqual( self.manifest["standalone"]["externalOpen"], diff --git a/baidudrive/Makefile b/baidudrive/Makefile index 8db018485..cf1049b9d 100644 --- a/baidudrive/Makefile +++ b/baidudrive/Makefile @@ -11,7 +11,7 @@ PKG_ARCH_BAIDUDRIVE:=$(ARCH) PKG_NAME:=baidudrive PKG_VERSION:=1.0.6 -PKG_RELEASE:=21 +PKG_RELEASE:=22 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/baidudrive-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip @@ -29,7 +29,7 @@ define Package/$(PKG_NAME) CATEGORY:=Network SUBMENU:=Web Servers/Proxies TITLE:=BaiduDrive - DEPENDS:=@(x86_64||aarch64) +curl + DEPENDS:=@(x86_64||aarch64) +curl +linkease-app-entry URL:=https://github.com/linkease/istore-packages endef @@ -47,6 +47,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json 2>/dev/null)" = "/usr/share/baidudrive/baidudrive-plugin.json" ]; then rm -f /usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json fi + if [ "$$(readlink /usr/share/linkease/apps.d/10-baidudrive-plugin.json 2>/dev/null)" = "/usr/share/baidudrive/baidudrive-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/10-baidudrive-plugin.json + fi fi exit 0 endef @@ -58,11 +61,12 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec/baidudrive $(1)/usr/share/baidudrive/www $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/opt/baidunas-sdk $(1)/opt/baidunas-glibc $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec/baidudrive $(1)/usr/share/baidudrive/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/opt/baidunas-sdk $(1)/opt/baidunas-glibc $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config $(INSTALL_BIN) $(PKG_BUILD_DIR)/baidudrive.$(PKG_ARCH_BAIDUDRIVE) $(1)/usr/sbin/baidudrive $(CP) $(PKG_BUILD_DIR)/web/dist/. $(1)/usr/share/baidudrive/www/ $(INSTALL_DATA) ./files/baidudrive-plugin.json $(1)/usr/share/baidudrive/baidudrive-plugin.json ln -sf /usr/share/baidudrive/baidudrive-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json + ln -sf /usr/share/baidudrive/baidudrive-plugin.json $(1)/usr/share/linkease/apps.d/10-baidudrive-plugin.json $(INSTALL_BIN) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/baiduNas $(1)/opt/baidunas-sdk/baiduNas $(INSTALL_BIN) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/P2PClient.bin $(1)/opt/baidunas-sdk/P2PClient.bin $(INSTALL_DATA) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/libkernel.so $(1)/opt/baidunas-sdk/libkernel.so diff --git a/baidudrive/files/baidudrive-plugin.json b/baidudrive/files/baidudrive-plugin.json index 7d9ff4769..188186c36 100644 --- a/baidudrive/files/baidudrive-plugin.json +++ b/baidudrive/files/baidudrive-plugin.json @@ -21,7 +21,8 @@ "defaultPort": 10780, "upstreamBasePath": "/apps/baidudrive/", "apiPath": "api/", - "pathMode": "preserve" + "pathMode": "preserve", + "externalBasePath": "/" }, "window": { "width": 1180, diff --git a/dae/Makefile b/dae/Makefile index 7f4f58d5f..92afa9fb2 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -5,10 +5,10 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae -PKG_VERSION:=2026.09.20 -PKG_RELEASE:=46 +PKG_VERSION:=2026.09.23 +PKG_RELEASE:=47 -PKG_SOURCE:=dae-src-2026.09.20-2a846ee5a7e4.tar.gz +PKG_SOURCE:=dae-src-2026.09.23-c9f580bcaa8a.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/daed/Makefile b/daed/Makefile index 554d378ce..6bf264cdc 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -5,10 +5,10 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed -PKG_VERSION:=2026.09.20 -PKG_RELEASE:=57 +PKG_VERSION:=2026.09.23 +PKG_RELEASE:=58 -PKG_SOURCE:=daed-src-2026.09.20-d00211e69e94.tar.gz +PKG_SOURCE:=daed-src-2026.09.23-fcc80bac4542.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch b/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch deleted file mode 100644 index 51c9395a8..000000000 --- a/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch +++ /dev/null @@ -1,23 +0,0 @@ -From: kenzok8 -Date: 2026-06-23 -Subject: [PATCH] daed: detach BPF hooks when stopping from dashboard - -Detach BPF hooks when the dashboard stops dae through EmptyConfig. -Reported: https://github.com/kenzok8/openwrt-daede/issues/13 ---- ---- a/dae/run.go -+++ b/dae/run.go -@@ -138,6 +138,13 @@ - /* dae-wing start */ - newConf := newReloadMsg.Config - /* dae-wing end */ -+ -+ // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks. -+ if newConf == EmptyConfig { -+ if e := c.DetachBpfHooks(); e != nil { -+ log.Errorln("[Stop] DetachBpfHooks:", e) -+ } -+ } - // New logger. - oldLogOutput := log.Out - log = logrus.New() diff --git a/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch b/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch deleted file mode 100644 index 64b4b168e..000000000 --- a/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch +++ /dev/null @@ -1,18 +0,0 @@ -From: kenzok8 -Date: 2026-06-29 -Subject: [PATCH] daed: use bounded probes for manual latency tests - -Use the existing bounded probe for manual dashboard latency tests. -Reported: https://github.com/kenzok8/openwrt-daede/issues/20 ---- ---- a/graphql/service/node/latency_mutation_utils.go -+++ b/graphql/service/node/latency_mutation_utils.go -@@ -108,7 +108,7 @@ func testSingleNodeLatency(option *diale - } - defer d.Close() - -- result, err := d.ProbeLatency() -+ result, err := d.ProbeLatencyFast() - if err != nil { - msg := err.Error() - resolver.MessageV = &msg diff --git a/daed/patches/0005-bound-startup-network-wait.patch b/daed/patches/0005-bound-startup-network-wait.patch index 07add31b6..b97e040ee 100644 --- a/daed/patches/0005-bound-startup-network-wait.patch +++ b/daed/patches/0005-bound-startup-network-wait.patch @@ -1,76 +1,80 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: bound startup network wait on new wing + +Return an error after 60 seconds and retry on the next generation. +--- diff --git a/dae/run.go b/dae/run.go -index d8f86c2..a0a665d 100644 +index 7c9a5cc..7f00748 100644 --- a/dae/run.go +++ b/dae/run.go -@@ -32,7 +32,8 @@ var ChReloadConfigs = make(chan *ReloadMessage) - var GracefullyExit = make(chan struct{}) - var EmptyConfig *daeConfig.Config - var c *control.ControlPlane +@@ -19,6 +19,7 @@ import ( + "github.com/daeuniverse/dae/control" + "github.com/daeuniverse/dae/pkg/config_parser" + "github.com/daeuniverse/dae/pkg/logger" ++ "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/protocol/direct" + "github.com/mohae/deepcopy" + "github.com/sirupsen/logrus" +@@ -43,7 +44,8 @@ var EmptyConfig *daeConfig.Config + // active is the generation currently serving traffic. It is nil before the + // first generation is ready and while a reload has retired the old one. + var active atomic.Pointer[control.ControlPlane] -var onceWaitingNetwork sync.Once +var waitingNetworkMu sync.Mutex +var networkReady bool func init() { sections, err := config_parser.Parse(`global{} routing{}`) -@@ -52,6 +53,19 @@ func ControlPlane() (*control.ControlPlane, error) { +@@ -64,6 +66,19 @@ func ControlPlane() (*control.ControlPlane, error) { return c, nil } -+func waitForNetwork(log *logrus.Logger) error { ++func waitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error { + waitingNetworkMu.Lock() + defer waitingNetworkMu.Unlock() + if networkReady { + return nil + } -+ if err := WaitForNetwork(log); err != nil { ++ if err := WaitForNetwork(log, directDialer); err != nil { + return err + } + networkReady = true + return nil +} + - func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, disableTimestamp bool, dry bool) (err error) { - defer close(GracefullyExit) - // Not really run dae. -@@ -221,6 +235,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - - // Deep copy to prevent modification. - conf = deepcopy.Copy(conf).(*daeConfig.Config) -+ if bpf == nil { -+ control.PurgeStaleTCFilters(log) -+ } - - // Init Direct Dialers. - direct.InitDirectDialers(conf.Global.FallbackResolver) -@@ -228,9 +245,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c + // generation is one control plane with its listener. retired is set before + // the plane is torn down so a Serve error from a plane that is being replaced + // is not mistaken for the active datapath dying. +@@ -345,9 +360,9 @@ func newControlPlane(log *logrus.Logger, dnsCache map[string]*control.DnsCache, if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 { // Wait for network for WAN ready. - onceWaitingNetwork.Do(func() { -- WaitForNetwork(log) +- WaitForNetwork(log, directDialers.Symmetric) - }) -+ if err = waitForNetwork(log); err != nil { ++ if err = waitForNetwork(log, directDialers.Symmetric); err != nil { + return nil, err + } } /// Get subscription -> nodeList mapping. diff --git a/dae/utils.go b/dae/utils.go -index 65965a2..dc43cbc 100644 +index 2b4f6bf..93efb7e 100644 --- a/dae/utils.go +++ b/dae/utils.go @@ -94,8 +94,9 @@ func preprocessWanInterfaceAuto(params *daeConfig.Config) error { return nil } --func WaitForNetwork(log *logrus.Logger) { -+func WaitForNetwork(log *logrus.Logger) error { +-func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { ++func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error { epo := 5 * time.Second + deadline := time.Now().Add(60 * time.Second) client := http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (c net.Conn, err error) { -@@ -115,6 +116,9 @@ func WaitForNetwork(log *logrus.Logger) { +@@ -114,6 +115,9 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { } log.Infoln("Waiting for network...") for i := 0; ; i++ { @@ -80,7 +84,7 @@ index 65965a2..dc43cbc 100644 resp, err := client.Get(cmd.CheckNetworkLinks[i%len(cmd.CheckNetworkLinks)]) if err != nil { log.Debugln("CheckNetwork:", err) -@@ -134,4 +138,5 @@ func WaitForNetwork(log *logrus.Logger) { +@@ -133,4 +137,5 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { time.Sleep(epo) } log.Infoln("Network online.") diff --git a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch index 985a832e0..fa0203ef2 100644 --- a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch +++ b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch @@ -1,22 +1,9 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: kenzok8 +Date: 2026-09-23 Subject: [PATCH] daed: strictly reconcile subscription updates -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit -订阅更新按节点链接精确对账,普通组里的失效节点删除,fixed 组固定节点转成独立节点保留。 - -数据库提交后按运行状态应用配置,补齐并发地址校验、导入失败回滚和 reload 超时恢复。 +Reconcile nodes by link, preserve fixed-group nodes, and apply committed changes. --- - cmd/run.go | 6 +- - graphql/mutation.go | 9 +- - graphql/root_schema.go | 2 +- - graphql/service/config/mutation_utils.go | 59 ++++- - .../service/subscription/mutation_utils.go | 181 +++++++++++---- - .../subscription/mutation_utils_test.go | 212 ++++++++++++++++++ - 6 files changed, 408 insertions(+), 61 deletions(-) - create mode 100644 graphql/service/subscription/mutation_utils_test.go - diff --git a/cmd/run.go b/cmd/run.go index 7dce577..978f46c 100644 --- a/cmd/run.go @@ -43,10 +30,10 @@ index 7dce577..978f46c 100644 } diff --git a/graphql/mutation.go b/graphql/mutation.go -index fcbf440..adb747c 100644 +index af065fd..d462ecc 100644 --- a/graphql/mutation.go +++ b/graphql/mutation.go -@@ -256,14 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct { +@@ -256,16 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct { func (r *MutationResolver) Run(args *struct { Dry bool }) (int32, error) { @@ -56,7 +43,9 @@ index fcbf440..adb747c 100644 - tx.Rollback() - return 0, err - } -- tx.Commit() +- if err = tx.Commit().Error; err != nil { +- return 0, fmt.Errorf("commit run: %w", err) +- } - return ret, nil + return config.Run(context.TODO(), args.Dry) } @@ -76,7 +65,7 @@ index 8b82db6..a96cace 100644 # updateSubscriptionLink is to update the subscription link without re-fetching nodes. diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go -index fd61554..feb94d3 100644 +index e6bf9da..8ab756b 100644 --- a/graphql/service/config/mutation_utils.go +++ b/graphql/service/config/mutation_utils.go @@ -21,6 +21,7 @@ import ( @@ -163,7 +152,7 @@ index fd61554..feb94d3 100644 // Running -> false diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go -index e55e65b..e01a342 100644 +index 7e6dc08..e87bf78 100644 --- a/graphql/service/subscription/mutation_utils.go +++ b/graphql/service/subscription/mutation_utils.go @@ -18,6 +18,7 @@ import ( @@ -174,7 +163,7 @@ index e55e65b..e01a342 100644 "github.com/daeuniverse/dae-wing/graphql/service/node" "github.com/daeuniverse/dae/common/subscription" "github.com/go-co-op/gocron" -@@ -171,8 +172,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) { +@@ -175,8 +176,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) { } var ( @@ -186,7 +175,7 @@ index e55e65b..e01a342 100644 ) func UpdateAll(ctx context.Context) { -@@ -258,68 +260,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) { +@@ -262,68 +264,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) { return &Resolver{Subscription: m}, nil } @@ -212,7 +201,7 @@ index e55e65b..e01a342 100644 - tx := db.BeginTx(ctx) - defer func() { - if err == nil { -- tx.Commit() +- err = tx.Commit().Error + current := make(map[string]struct{}, len(existing)) + var staleIds []uint + for _, n := range existing { @@ -303,7 +292,7 @@ index e55e65b..e01a342 100644 } - hasAnyCandidate := false for _, r := range result { -- if r.Error == nil { +- if r.Error == nil || *r.Error == node.DuplicatedError.Error() { - hasAnyCandidate = true - break + if r.Error != nil { @@ -388,7 +377,7 @@ index e55e65b..e01a342 100644 return &m, nil } -@@ -399,6 +494,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver, +@@ -406,6 +501,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver, if err != nil { return nil, err } @@ -397,11 +386,11 @@ index e55e65b..e01a342 100644 tx := db.BeginTx(ctx) defer func() { -diff --git a/graphql/service/subscription/mutation_utils_test.go b/graphql/service/subscription/mutation_utils_test.go +diff --git a/graphql/service/subscription/reconcile_patch_test.go b/graphql/service/subscription/reconcile_patch_test.go new file mode 100644 index 0000000..18ff9b9 --- /dev/null -+++ b/graphql/service/subscription/mutation_utils_test.go ++++ b/graphql/service/subscription/reconcile_patch_test.go @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-only diff --git a/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch b/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch deleted file mode 100644 index fe7d04cec..000000000 --- a/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch +++ /dev/null @@ -1,44 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -Subject: [PATCH] daed: apply subscription cron changes without restart -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -改 cron 后先提交事务再重建调度器。 - -原来在事务里就去重建,调度器另开连接读到的还是旧表达式,必须重启 daed 才生效。 ---- - graphql/service/subscription/mutation_utils.go | 12 +++++++++--- - 1 file changed, 9 insertions(+), 3 deletions(-) - -diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go -index e2aec32..4f6bcfc 100644 ---- a/graphql/service/subscription/mutation_utils.go -+++ b/graphql/service/subscription/mutation_utils.go -@@ -561,10 +561,12 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable - } - - tx := db.BeginTx(ctx) -+ if tx.Error != nil { -+ return nil, tx.Error -+ } -+ committed := false - defer func() { -- if err == nil { -- tx.Commit() -- } else { -+ if !committed { - tx.Rollback() - } - }() -@@ -583,6 +585,10 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable - }).Error; err != nil { - return nil, err - } -+ if err = tx.Commit().Error; err != nil { -+ return nil, err -+ } -+ committed = true - - // Update scheduler - RemoveUpdateScheduler(id) diff --git a/daed/patches/0011-preserve-reload-safety-on-new-wing.patch b/daed/patches/0011-preserve-reload-safety-on-new-wing.patch new file mode 100644 index 000000000..69299183a --- /dev/null +++ b/daed/patches/0011-preserve-reload-safety-on-new-wing.patch @@ -0,0 +1,28 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: keep reload callbacks nonblocking + +A timed-out request must not block the daemon when reload later finishes. +--- +diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go +index 5996eaa..64229e8 100644 +--- a/graphql/service/config/mutation_utils.go ++++ b/graphql/service/config/mutation_utils.go +@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error { + func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { + //// Dry run. + if noLoad { +- ch := make(chan error) ++ ch := make(chan error, 1) + reloadMsg := &dae.ReloadMessage{ + Config: dae.EmptyConfig, + Callback: ch, +@@ -523,7 +523,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { + readTx = nil + + /// Reload with current config. +- chReloadCallback := make(chan error) ++ chReloadCallback := make(chan error, 1) + reloadMsg := &dae.ReloadMessage{ + Config: c, + Callback: chReloadCallback, diff --git a/daed/patches/0012-adapt-wing-to-new-core.patch b/daed/patches/0012-adapt-wing-to-new-core.patch deleted file mode 100644 index ec5af516d..000000000 --- a/daed/patches/0012-adapt-wing-to-new-core.patch +++ /dev/null @@ -1,837 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: kenzok8 -Date: Sun, 20 Sep 2026 16:59:23 +0800 -Subject: [PATCH] Adapt wing to new dae core -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -改动:适配新核心生命周期,并修复空配置停止和重载回滚。 - -原因:旧接口删除且新控制面交接流程已改变。 ---- - dae-core/config/marshal.go | 5 + - dae/run.go | 402 +++++++++++++----- - dae/run_handoff_test.go | 33 ++ - dae/runtime.go | 6 +- - dae/utils.go | 9 +- - db/node_utils.go | 3 +- - graphql/service/config/mutation_utils.go | 9 +- - graphql/service/node/latency_cache.go | 2 +- - .../service/node/latency_mutation_utils.go | 59 ++- - graphql/service/routing/resolver.go | 2 +- - 10 files changed, 415 insertions(+), 115 deletions(-) - create mode 100644 dae/run_handoff_test.go - -diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go -index f68006a..69a5325 100644 ---- a/dae-core/config/marshal.go -+++ b/dae-core/config/marshal.go -@@ -42,6 +42,11 @@ type Marshaller struct { - buf bytes.Buffer - } - -+// Bytes returns the serialized section buffer without exposing mutable state. -+func (m *Marshaller) Bytes() []byte { -+ return m.buf.Bytes() -+} -+ - func (m *Marshaller) writeLine(depth int, line string) { - if depth < 0 { - depth = 0 -diff --git a/dae/run.go b/dae/run.go -index a0a665d..96b579c 100644 ---- a/dae/run.go -+++ b/dae/run.go -@@ -6,6 +6,8 @@ - package dae - - import ( -+ "context" -+ "errors" - "fmt" - "net/netip" - "runtime" -@@ -28,6 +30,22 @@ type ReloadMessage struct { - Callback chan<- error - } - -+func closeReloadResources(closers ...func() error) error { -+ var errs error -+ for _, closeResource := range closers { -+ if closeResource != nil { -+ errs = errors.Join(errs, closeResource()) -+ } -+ } -+ return errs -+} -+ -+func notifyReloadCallback(callback chan<- error, err error) { -+ if callback != nil { -+ callback <- err -+ } -+} -+ - var ChReloadConfigs = make(chan *ReloadMessage) - var GracefullyExit = make(chan struct{}) - var EmptyConfig *daeConfig.Config -@@ -77,88 +95,115 @@ func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, - case nil: - break dryLoop - default: -- newConf.Callback <- nil -+ notifyReloadCallback(newConf.Callback, nil) - } - } - return nil - } - - // New c. -- c, err = newControlPlane(log, nil, nil, conf, externGeoDataDirs) -+ processSessions := control.NewSessionManager(context.Background()) -+ defer func() { _ = processSessions.Close() }() -+ c, err = newControlPlane(context.Background(), log, nil, nil, conf, externGeoDataDirs, false) - if err != nil { - return err - } -+ if err = c.AttachSessionManager(processSessions); err != nil { -+ _ = c.Close() -+ return err -+ } -+ datapathDetached := false -+ if conf == EmptyConfig { -+ if err = c.DetachBpfHooks(); err != nil { -+ _ = c.Close() -+ return fmt.Errorf("detach initial empty datapath: %w", err) -+ } -+ datapathDetached = true -+ } - -- // Serve tproxy TCP/UDP server util signals. - var listener *control.Listener -+ if err = control.GetDaeNetns().With(func() error { -+ var listenErr error -+ listener, listenErr = c.Listen(conf.Global.TproxyPort) -+ if listenErr != nil { -+ return listenErr -+ } -+ return listener.ValidateCurrentNetns() -+ }); err != nil { -+ if listener != nil { -+ _ = listener.Close() -+ } -+ _ = c.Close() -+ return fmt.Errorf("listen in dae netns: %w", err) -+ } -+ readyChan := make(chan bool, 1) -+ serveDone := make(chan error, 1) -+ go func() { serveDone <- c.Serve(readyChan, listener) }() -+ if !<-readyChan { -+ serveErr := <-serveDone -+ _ = closeReloadResources(listener.Close, c.Close) -+ if serveErr == nil { -+ serveErr = fmt.Errorf("initial control plane was not ready") -+ } -+ return serveErr -+ } -+ log.Infoln("Ready") - go func() { -- readyChan := make(chan bool, 1) -- go func() { -- <-readyChan -- log.Infoln("Ready") -- }() -- control.GetDaeNetns().With(func() error { -- if listener, err = c.ListenAndServe(readyChan, conf.Global.TproxyPort); err != nil { -- log.Errorln("ListenAndServe:", err) -- } -- return err -- }) -- // Exit -+ if serveErr := <-serveDone; serveErr != nil { -+ log.WithError(serveErr).Errorln("control plane serve stopped") -+ } - ChReloadConfigs <- nil - }() - reloading := false -- /* dae-wing start */ -- var errReload error -- var chCallback chan<- error -- /* dae-wing end */ - loop: - for newReloadMsg := range ChReloadConfigs { - switch newReloadMsg { - case nil: -- /* dae-wing start */ -- // We will receive nil after control plane being Closed. -- // We'll judge if we are in a reloading. -- /* dae-wing end */ -- - if reloading { -- if listener == nil { -- // Failed to listen. Exit. -- break loop -- } -- // Serve. -+ // The old generation's Serve loop reports its shutdown after the -+ // candidate has already taken over the cloned listener. - reloading = false -- log.Warnln("[Reload] Serve") -- readyChan := make(chan bool, 1) -- go func() { -- if err := c.Serve(readyChan, listener); err != nil { -- log.Errorln("ListenAndServe:", err) -- } -- // Exit -- ChReloadConfigs <- nil -- }() -- <-readyChan -- log.Warnln("[Reload] Finished") -- /* dae-wing start */ -- chCallback <- errReload -- /* dae-wing end */ -+ continue - } else { -- // Listening error. - break loop - } - default: - // Reload signal. - log.Warnln("[Reload] Received reload signal; prepare to reload") - -- /* dae-wing start */ - newConf := newReloadMsg.Config -- /* dae-wing end */ -- -- // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks. -- if newConf == EmptyConfig { -- if e := c.DetachBpfHooks(); e != nil { -- log.Errorln("[Stop] DetachBpfHooks:", e) -+ if datapathDetached && newConf == EmptyConfig { -+ notifyReloadCallback(newReloadMsg.Callback, nil) -+ continue -+ } -+ oldC := c -+ wasStopped := datapathDetached -+ failReload := func(cause, cleanupErr error) error { -+ cause = errors.Join(cause, cleanupErr) -+ if cleanupErr == nil && wasStopped { -+ if detachErr := oldC.DetachBpfHooks(); detachErr != nil { -+ cause = errors.Join(cause, fmt.Errorf("restore stopped datapath: %w", detachErr)) -+ cleanupErr = detachErr -+ } else { -+ datapathDetached = true -+ } - } -+ notifyReloadCallback(newReloadMsg.Callback, cause) -+ if cleanupErr != nil { -+ return cause -+ } -+ return nil - } -+ if wasStopped { -+ if restoreErr := oldC.RestoreDatapathForReloadRollback(); restoreErr != nil { -+ if fatalErr := failReload(fmt.Errorf("prepare stopped datapath: %w", restoreErr), nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ datapathDetached = false -+ } -+ - // New logger. - oldLogOutput := log.Out - log = logrus.New() -@@ -167,65 +212,223 @@ loop: - log.SetOutput(oldLogOutput) // FIXME: THIS IS A HACK. - logrus.SetOutput(oldLogOutput) - -- // New control plane. -- obj := c.EjectBpf() - var dnsCache map[string]*control.DnsCache - if conf.Dns.IpVersionPrefer == newConf.Dns.IpVersionPrefer { - // Only keep dns cache when ip version preference not change. - dnsCache = c.CloneDnsCache() - } -- log.Warnln("[Reload] Load new control plane") -- newC, err := newControlPlane(log, obj, dnsCache, newConf, externGeoDataDirs) -+ log.Warnln("[Reload] Prepare new control plane") -+ freshState, err := c.SnapshotFreshDatapathState() - if err != nil { -- /* dae-wing start */ -- errReload = err -- /* dae-wing end */ -- -- log.WithFields(logrus.Fields{ -- "err": err, -- }).Errorln("[Reload] Failed to reload; try to roll back configuration") -- // Load last config back. -- newC, err = newControlPlane(log, obj, dnsCache, conf, externGeoDataDirs) -- if err != nil { -- obj.Close() -- c.Close() -- log.WithFields(logrus.Fields{ -- "err": err, -- }).Fatalln("[Reload] Failed to roll back configuration") -+ if fatalErr := failReload(err, nil); fatalErr != nil { -+ return fatalErr - } -- newConf = conf -- log.Errorln("[Reload] Last reload failed; rolled back configuration") -- } else { -- log.Warnln("[Reload] Stopped old control plane") -- -- /* dae-wing start */ -- errReload = nil -- /* dae-wing end */ -+ continue - } -+ newC, err := newControlPlane(context.Background(), log, freshState, dnsCache, newConf, externGeoDataDirs, true) -+ if err == nil { -+ err = newC.AttachSessionManager(processSessions) -+ } -+ if err != nil { -+ var cleanupErr error -+ if newC != nil { -+ cleanupErr = newC.Close() -+ } -+ if fatalErr := failReload(err, cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ stagedListener, err := listener.Clone() -+ if err != nil { -+ if fatalErr := failReload(fmt.Errorf("clone listener: %w", err), newC.Close()); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ if err = oldC.StopDNSListener(); err != nil { -+ cleanupErr := closeReloadResources(stagedListener.Close, newC.Close, oldC.RestartDNSListener) -+ if fatalErr := failReload(fmt.Errorf("stop old DNS listener: %w", err), cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ dnsStopped := true -+ readyChan := make(chan bool, 1) -+ serveDone := make(chan error, 1) -+ go func() { serveDone <- newC.Serve(readyChan, stagedListener) }() -+ ready := <-readyChan -+ if !ready { -+ serveErr := <-serveDone -+ if serveErr == nil { -+ serveErr = fmt.Errorf("prepared control plane was not ready") -+ } -+ cleanupErr := closeReloadResources( -+ stagedListener.Close, -+ newC.Close, -+ oldC.RestartDNSListener, -+ ) -+ dnsStopped = false -+ if fatalErr := failReload(serveErr, cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookPrepared := false -+ provisionalOwner := false -+ hookFlipCommitted := false -+ hookSetAdopted := false -+ flowDatapathAdopted := false -+ rollbackCleanupFailed := false -+ rollbackCandidate := func(cause error) error { -+ var rollbackErr error -+ addRollbackErr := func(err error) { -+ if err != nil { -+ rollbackCleanupFailed = true -+ rollbackErr = errors.Join(rollbackErr, err) -+ } -+ } -+ if flowDatapathAdopted { -+ addRollbackErr(oldC.AdoptProcessFlowDatapath(newC)) -+ } -+ if hookSetAdopted { -+ if ownerErr := newC.RestorePreparedTCHookSet(oldC); ownerErr != nil { -+ rollbackCleanupFailed = true -+ return errors.Join(cause, rollbackErr, fmt.Errorf("restore TC HookSet owner: %w", ownerErr)) -+ } -+ } -+ if hookFlipCommitted { -+ addRollbackErr(newC.RollbackPreparedBpfHookFlip()) -+ } -+ if hookPrepared && !hookSetAdopted && !hookFlipCommitted { -+ addRollbackErr(newC.ClearPreparedTCHookHandoff()) -+ } -+ if provisionalOwner { -+ newC.UnregisterProvisionalRoutingEpochExecutionOwner() -+ } -+ addRollbackErr(newC.DetachBpfHooks()) -+ addRollbackErr(stagedListener.Close()) -+ addRollbackErr(newC.Close()) -+ if dnsStopped { -+ addRollbackErr(oldC.RestartDNSListener()) -+ dnsStopped = false -+ } -+ return errors.Join(cause, rollbackErr) -+ } -+ if err = newC.PrepareTCHookHandoff(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookPrepared = true -+ if err = newC.RegisterProvisionalRoutingEpochExecutionOwner(); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ provisionalOwner = true -+ if err = newC.CommitPreparedBpfHookFlip(); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookFlipCommitted = true -+ if err = newC.AdoptPreparedTCHookSet(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookSetAdopted = true -+ if err = newC.AdoptProcessFlowDatapath(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ flowDatapathAdopted = true -+ newC.UnregisterProvisionalRoutingEpochExecutionOwner() -+ provisionalOwner = false -+ if err = newC.FinalizePreparedTCHooks(); err != nil { -+ fatalErr := errors.Join(fmt.Errorf("finalize prepared TC hooks: %w", err), closeReloadResources( -+ listener.Close, -+ stagedListener.Close, -+ newC.Close, -+ oldC.Close, -+ )) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ hookPrepared = false -+ hookFlipCommitted = false -+ hookSetAdopted = false - -- // Inject bpf objects into the new control plane life-cycle. -- newC.InjectBpf(obj) -- -- // Prepare new context. -- oldC := c -+ oldListener := listener - c = newC - conf = newConf -+ listener = stagedListener -+ datapathDetached = false -+ retireErr := closeReloadResources(oldListener.Close, oldC.Close) -+ if newConf == EmptyConfig { -+ if retireErr != nil { -+ fatalErr := errors.Join(fmt.Errorf("retire previous control plane: %w", retireErr), closeReloadResources(stagedListener.Close, newC.Close)) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ if detachErr := newC.DetachBpfHooks(); detachErr != nil { -+ fatalErr := errors.Join(fmt.Errorf("detach stopped datapath: %w", detachErr), closeReloadResources(stagedListener.Close, newC.Close)) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ datapathDetached = true -+ } else if retireErr != nil { -+ log.WithError(retireErr).Warnln("[Reload] Failed to close old control plane") -+ } - reloading = true -- /* dae-wing start */ -- chCallback = newReloadMsg.Callback -- /* dae-wing end */ -- -- // Ready to close. -- oldC.Close() -+ notifyReloadCallback(newReloadMsg.Callback, nil) -+ go func(done <-chan error) { -+ if serveErr := <-done; serveErr != nil { -+ log.WithError(serveErr).Errorln("control plane serve stopped") -+ } -+ ChReloadConfigs <- nil -+ }(serveDone) - } - } -- if e := c.Close(); e != nil { -+ if e := closeReloadResources(listener.Close, c.Close); e != nil { - return fmt.Errorf("close control plane: %w", e) - } - return nil - } - --func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string) (c *control.ControlPlane, err error) { -+func newControlPlane(ctx context.Context, log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string, prepared bool) (c *control.ControlPlane, err error) { - - // Print configuration. - if log.IsLevelEnabled(logrus.DebugLevel) { -@@ -239,10 +442,6 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - control.PurgeStaleTCFilters(log) - } - -- // Init Direct Dialers. -- direct.InitDirectDialers(conf.Global.FallbackResolver) -- netutils.FallbackDns = netip.MustParseAddrPort(conf.Global.FallbackResolver) -- - if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 { - // Wait for network for WAN ready. - if err = waitForNetwork(log); err != nil { -@@ -266,7 +465,10 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - } - - // New dae control plane. -- c, err = control.NewControlPlane( -+ directDialers := direct.NewDirectDialers(conf.Global.FallbackResolver) -+ systemDNSResolver := netutils.NewSystemDNSResolver(netip.MustParseAddrPort(conf.Global.FallbackResolver)) -+ c, err = control.NewControlPlaneWithContextOptions( -+ ctx, - log, - bpf, - dnsCache, -@@ -276,6 +478,14 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - &conf.Global, - &conf.Dns, - externGeoDataDirs, -+ control.ControlPlaneBuildOptions{ -+ DelayDatapathCommit: prepared, -+ DelayDNSListenerStart: prepared, -+ IsReload: prepared || bpf != nil, -+ DirectDialer: directDialers.Symmetric, -+ FullconeDirectDialer: directDialers.Fullcone, -+ SystemDNSResolver: systemDNSResolver, -+ }, - ) - if err != nil { - return nil, err -diff --git a/dae/run_handoff_test.go b/dae/run_handoff_test.go -new file mode 100644 -index 0000000..0ffb2ec ---- /dev/null -+++ b/dae/run_handoff_test.go -@@ -0,0 +1,33 @@ -+package dae -+ -+import ( -+ "errors" -+ "reflect" -+ "testing" -+) -+ -+func TestCloseReloadResourcesOrder(t *testing.T) { -+ var got []string -+ err := closeReloadResources( -+ func() error { got = append(got, "old-listener"); return nil }, -+ func() error { got = append(got, "staged-listener"); return nil }, -+ func() error { got = append(got, "new-control-plane"); return nil }, -+ func() error { got = append(got, "old-control-plane"); return nil }, -+ ) -+ if err != nil { -+ t.Fatalf("closeReloadResources() error = %v", err) -+ } -+ want := []string{"old-listener", "staged-listener", "new-control-plane", "old-control-plane"} -+ if !reflect.DeepEqual(got, want) { -+ t.Fatalf("close order = %v, want %v", got, want) -+ } -+} -+ -+func TestCloseReloadResourcesJoinsErrors(t *testing.T) { -+ one := errors.New("old listener") -+ two := errors.New("old control plane") -+ err := closeReloadResources(func() error { return one }, func() error { return two }) -+ if !errors.Is(err, one) || !errors.Is(err, two) { -+ t.Fatalf("joined error = %v, want both close errors", err) -+ } -+} -diff --git a/dae/runtime.go b/dae/runtime.go -index 2ab0d68..af46089 100644 ---- a/dae/runtime.go -+++ b/dae/runtime.go -@@ -30,18 +30,16 @@ type RuntimeOverview struct { - } - - func GetRuntimeOverview(windowSec int, maxPoints int) (*RuntimeOverview, error) { -- activeTCPConnections := 0 - ctl, err := ControlPlane() -+ var snapshot control.RuntimeStatsSnapshot - if err != nil { - if !errors.Is(err, ErrControlPlaneNotInit) { - return nil, err - } - } else { -- activeTCPConnections = ctl.ActiveTCPConnections() -+ snapshot = ctl.SnapshotRuntimeStats(windowSec, maxPoints) - } - -- snapshot := control.SnapshotRuntimeStats(activeTCPConnections, control.DefaultUdpEndpointPool.Count(), windowSec, maxPoints) -- - samples := make([]RuntimeTrafficSample, 0, len(snapshot.Samples)) - for _, sample := range snapshot.Samples { - samples = append(samples, RuntimeTrafficSample{ -diff --git a/dae/utils.go b/dae/utils.go -index dc43cbc..ca61c85 100644 ---- a/dae/utils.go -+++ b/dae/utils.go -@@ -32,8 +32,11 @@ var ( - EmptyGlobalSection = `global {}` - ) - --func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) { -- f := daeConfig.FunctionOrStringToFunction(routing.Fallback) -+func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string, err error) { -+ f, err := daeConfig.ParseFunctionOrString(routing.Fallback) -+ if err != nil { -+ return nil, err -+ } - outbounds = append(outbounds, f.Name) - for _, r := range routing.Rules { - outbound := r.Outbound.Name -@@ -42,7 +45,7 @@ func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) { - } - outbounds = append(outbounds, outbound) - } -- return daeCommon.Deduplicate(outbounds) -+ return daeCommon.Deduplicate(outbounds), nil - } - - func ParseConfig(globalSection *string, dnsSection *string, routingSection *string) (*daeConfig.Config, error) { -diff --git a/db/node_utils.go b/db/node_utils.go -index b7b43b8..7ca8fc3 100644 ---- a/db/node_utils.go -+++ b/db/node_utils.go -@@ -6,6 +6,7 @@ - package db - - import ( -+ "context" - "strings" - - "github.com/daeuniverse/dae-wing/common" -@@ -25,7 +26,7 @@ func NewNodeModel(link string, tag *string, subscriptionId *uint) (*Node, error) - } - _tag = *tag - } -- d, err := dialer.NewFromLink(&dialer.GlobalOption{ -+ d, err := dialer.NewFromLinkContext(context.Background(), &dialer.GlobalOption{ - Log: logrus.StandardLogger(), - }, dialer.InstanceOption{DisableCheck: false}, link, _tag) - if err != nil { -diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go -index 59f0ebd..c7d6b78 100644 ---- a/graphql/service/config/mutation_utils.go -+++ b/graphql/service/config/mutation_utils.go -@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error { - func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - //// Dry run. - if noLoad { -- ch := make(chan error) -+ ch := make(chan error, 1) - reloadMsg := &dae.ReloadMessage{ - Config: dae.EmptyConfig, - Callback: ch, -@@ -384,7 +384,10 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - } - /// Fill in necessary groups and nodes. - // Find groups needed by routing. -- outbounds := dae.NecessaryOutbounds(&c.Routing) -+ outbounds, err := dae.NecessaryOutbounds(&c.Routing) -+ if err != nil { -+ return 0, err -+ } - var groups []db.Group - q = d.Model(&db.Group{}). - Where("name in ?", outbounds). -@@ -523,7 +526,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - readTx = nil - - /// Reload with current config. -- chReloadCallback := make(chan error) -+ chReloadCallback := make(chan error, 1) - reloadMsg := &dae.ReloadMessage{ - Config: c, - Callback: chReloadCallback, -diff --git a/graphql/service/node/latency_cache.go b/graphql/service/node/latency_cache.go -index ba66794..e0ec91e 100644 ---- a/graphql/service/node/latency_cache.go -+++ b/graphql/service/node/latency_cache.go -@@ -162,7 +162,7 @@ func refreshLatencyCache(ctx context.Context) error { - return err - } - -- results := testLatencyResultsForNodes(option, nodes) -+ results := testLatencyResultsForNodes(ctx, option, nodes) - storeLatencyResults(results) - - if ctl, err := dae.ControlPlane(); err == nil { -diff --git a/graphql/service/node/latency_mutation_utils.go b/graphql/service/node/latency_mutation_utils.go -index 056f9fa..5026b33 100644 ---- a/graphql/service/node/latency_mutation_utils.go -+++ b/graphql/service/node/latency_mutation_utils.go -@@ -8,6 +8,8 @@ package node - import ( - "context" - "io" -+ "net/netip" -+ "strings" - "sync" - "time" - -@@ -15,6 +17,7 @@ import ( - "github.com/daeuniverse/dae-wing/dae" - "github.com/daeuniverse/dae-wing/db" - dialer "github.com/daeuniverse/dae/component/outbound/dialer" -+ "github.com/daeuniverse/outbound/netproxy" - "github.com/graph-gophers/graphql-go" - "github.com/sirupsen/logrus" - ) -@@ -32,12 +35,12 @@ func TestLatencies(ctx context.Context, ids *[]graphql.ID) ([]*LatencyResolver, - return nil, err - } - -- results := testLatencyResultsForNodes(option, nodes) -+ results := testLatencyResultsForNodes(ctx, option, nodes) - storeLatencyResults(results) - return results, nil - } - --func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver { -+func testLatencyResultsForNodes(ctx context.Context, option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver { - results := make([]*LatencyResolver, len(nodes)) - sem := make(chan struct{}, latencyProbeConcurrency) - var wg sync.WaitGroup -@@ -51,7 +54,7 @@ func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) [] - defer func() { <-sem }() - - node := nodes[index] -- results[index] = testSingleNodeLatency(option, &node) -+ results[index] = testSingleNodeLatency(ctx, option, &node) - }() - } - -@@ -93,14 +96,14 @@ func latencyProbeNodes(ctx context.Context, ids *[]graphql.ID) ([]db.Node, error - return nodes, nil - } - --func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyResolver { -+func testSingleNodeLatency(ctx context.Context, option *dialer.GlobalOption, node *db.Node) *LatencyResolver { - resolver := &LatencyResolver{ - NodeID: node.ID, - AliveVal: false, - TestedAtV: time.Now(), - } - -- d, err := dialer.NewFromLink(option, dialer.InstanceOption{DisableCheck: false}, node.Link, "") -+ d, err := dialer.NewFromLinkContext(ctx, option, dialer.InstanceOption{DisableCheck: false}, node.Link, "") - if err != nil { - msg := err.Error() - resolver.MessageV = &msg -@@ -108,7 +111,7 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR - } - defer d.Close() - -- result, err := d.ProbeLatencyFast() -+ result, err := probeLatencyFast(ctx, d) - if err != nil { - msg := err.Error() - resolver.MessageV = &msg -@@ -128,3 +131,47 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR - } - return resolver - } -+ -+func probeLatencyFast(ctx context.Context, d *dialer.Dialer) (*dialer.LatencyProbeResult, error) { -+ opt, err := d.TcpCheckOptionRaw.Option() -+ if err != nil { -+ return nil, err -+ } -+ network, err := netproxy.ParseMagicNetwork(d.TcpCheckOptionRaw.ResolverNetwork) -+ if err != nil { -+ return nil, err -+ } -+ result := &dialer.LatencyProbeResult{CheckedAt: time.Now()} -+ var messages []string -+ for _, probe := range []struct { -+ index int -+ ip netip.Addr -+ }{ -+ {index: dialer.IdxTcp4, ip: opt.Ip4}, -+ {index: dialer.IdxTcp6, ip: opt.Ip6}, -+ } { -+ if !probe.ip.IsValid() { -+ continue -+ } -+ probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) -+ started := time.Now() -+ ok, probeErr := d.HttpCheck(probeCtx, probe.index, opt.Url, probe.ip, opt.Method, network.Mark, network.Mptcp) -+ cancel() -+ if ok { -+ latency := time.Since(started) -+ if !result.Alive || latency < result.Latency { -+ result.Latency = latency -+ } -+ result.Alive = true -+ } else if probeErr != nil { -+ messages = append(messages, probeErr.Error()) -+ } -+ } -+ if !result.Alive { -+ result.Message = strings.Join(messages, "; ") -+ if result.Message == "" { -+ result.Message = "no latency result" -+ } -+ } -+ return result, nil -+} -diff --git a/graphql/service/routing/resolver.go b/graphql/service/routing/resolver.go -index 7822d64..3ed61aa 100644 ---- a/graphql/service/routing/resolver.go -+++ b/graphql/service/routing/resolver.go -@@ -41,7 +41,7 @@ func (r *Resolver) Selected() bool { - return r.Model.Selected - } - --func (r *Resolver) ReferenceGroups() (outbounds []string) { -+func (r *Resolver) ReferenceGroups() (outbounds []string, err error) { - return dae.NecessaryOutbounds(r.DaeRouting) - } - diff --git a/daed/patches/0012-export-config-marshaller-bytes.patch b/daed/patches/0012-export-config-marshaller-bytes.patch new file mode 100644 index 000000000..115d5745d --- /dev/null +++ b/daed/patches/0012-export-config-marshaller-bytes.patch @@ -0,0 +1,21 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: expose config marshaller bytes + +The current wing generator calls Marshaller.Bytes when updating global config. +--- +diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go +index f68006a..dfd6777 100644 +--- a/dae-core/config/marshal.go ++++ b/dae-core/config/marshal.go +@@ -42,6 +42,10 @@ type Marshaller struct { + buf bytes.Buffer + } + ++func (m *Marshaller) Bytes() []byte { ++ return m.buf.Bytes() ++} ++ + func (m *Marshaller) writeLine(depth int, line string) { + if depth < 0 { + depth = 0 diff --git a/dockermanager/Makefile b/dockermanager/Makefile index a7010347b..7ceb10825 100644 --- a/dockermanager/Makefile +++ b/dockermanager/Makefile @@ -10,7 +10,7 @@ PKG_ARCH_DOCKERMANAGER:=$(ARCH) PKG_NAME:=dockermanager PKG_VERSION:=0.1.1 -PKG_RELEASE:=7 +PKG_RELEASE:=8 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/dockermanager-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip @@ -26,7 +26,7 @@ define Package/$(PKG_NAME) CATEGORY:=System TITLE:=Docker Manager URL:=https://github.com/linkease/linkease-docker-manager - DEPENDS:=+docker +dockerd +ca-bundle + DEPENDS:=+docker +dockerd +ca-bundle +linkease-app-entry endef define Package/$(PKG_NAME)/description @@ -43,6 +43,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json 2>/dev/null)" = "/usr/share/dockermanager/dockermanager-plugin.json" ]; then rm -f /usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json fi + if [ "$$(readlink /usr/share/linkease/apps.d/20-dockermanager-plugin.json 2>/dev/null)" = "/usr/share/dockermanager/dockermanager-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/20-dockermanager-plugin.json + fi fi exit 0 endef @@ -54,12 +57,13 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/dockermanager/www $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/dockermanager/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config $(INSTALL_BIN) $(PKG_BUILD_DIR)/docker-manager.$(PKG_ARCH_DOCKERMANAGER) $(1)/usr/sbin/docker-manager $(CP) $(PKG_BUILD_DIR)/www/* $(1)/usr/share/dockermanager/www/ $(INSTALL_DATA) ./files/logo.svg $(1)/usr/share/dockermanager/www/logo.svg $(INSTALL_DATA) ./files/dockermanager-plugin.json $(1)/usr/share/dockermanager/dockermanager-plugin.json ln -sf /usr/share/dockermanager/dockermanager-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json + ln -sf /usr/share/dockermanager/dockermanager-plugin.json $(1)/usr/share/linkease/apps.d/20-dockermanager-plugin.json $(INSTALL_BIN) ./files/dockermanager.init $(1)/etc/init.d/dockermanager $(INSTALL_CONF) ./files/dockermanager.config $(1)/etc/config/dockermanager endef diff --git a/fastnet/Makefile b/fastnet/Makefile index 75d2ab1d9..a029f7111 100644 --- a/fastnet/Makefile +++ b/fastnet/Makefile @@ -10,7 +10,7 @@ PKG_ARCH_FASTNET:=$(ARCH) PKG_NAME:=fastnet PKG_VERSION:=0.7.7 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/fastnet-runtime-v$(PKG_VERSION)/ @@ -28,7 +28,20 @@ define Package/$(PKG_NAME) CATEGORY:=Network SUBMENU:=Web Servers/Proxies TITLE:=FastNet - network test Web UI - DEPENDS:=@(arm||x86_64||aarch64) + DEPENDS:=@(arm||x86_64||aarch64) +linkease-app-entry +endef + +define Package/$(PKG_NAME)/prerm +#!/bin/sh +if [ "$${IPKG_INSTROOT}" = "" ]; then + if [ "$$(readlink /usr/share/linkease/apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/17-fastnet.json + fi + if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then + rm -f /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json + fi +fi +exit 0 endef define Package/$(PKG_NAME)/description @@ -54,11 +67,14 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/fastnet $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(INSTALL_BIN) $(PKG_BUILD_DIR)/FastNet.$(PKG_ARCH_FASTNET) $(1)/usr/sbin/FastNet $(INSTALL_CONF) ./files/fastnet.config $(1)/etc/config/fastnet $(INSTALL_BIN) ./files/fastnet.init $(1)/etc/init.d/fastnet $(INSTALL_BIN) ./files/fastnet.uci-default $(1)/etc/uci-defaults/fastnet + $(INSTALL_DATA) ./files/fastnet-plugin.json $(1)/usr/share/fastnet/fastnet-plugin.json + ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkease/apps.d/17-fastnet.json + ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/17-fastnet.json endef $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/fastnet/files/fastnet-plugin.json b/fastnet/files/fastnet-plugin.json new file mode 100644 index 000000000..87466e0aa --- /dev/null +++ b/fastnet/files/fastnet-plugin.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "id": "fastnet", + "name": "FastNet", + "desktop": { + "mode": "iframe", + "target": { + "scheme": "http", + "hostMode": "request-host", + "port": { + "default": 3200, + "keys": { + "uci": "fastnet.@fastnet[0].port" + } + }, + "path": "/" + }, + "access": { + "scope": "lan", + "blockedTitle": "请在局域网内打开 FastNet", + "blockedMessage": "浏览器需要直接连接设备才能准确测量局域网速率。请连接设备所在局域网后,使用设备的局域网地址重新打开。" + } + }, + "standalone": { + "basePath": "/apps/fastnet/", + "externalOpen": { + "enabled": true, + "label": "在浏览器中打开 FastNet" + } + }, + "config": { + "providerOrder": ["uci"], + "providers": { + "uci": {"type": "uci"} + } + }, + "window": { + "width": 1180, + "height": 760, + "singleton": true + }, + "capabilities": ["speed-test", "diagnostics", "network"], + "categories": ["network", "service"] +} diff --git a/headscale/Makefile b/headscale/Makefile index 05593d22b..8dc96de30 100644 --- a/headscale/Makefile +++ b/headscale/Makefile @@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=headscale -PKG_VERSION:=0.29.3 -PKG_RELEASE:=8 +PKG_VERSION:=0.29.4 +PKG_RELEASE:=9 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)? diff --git a/istoreenhance/Makefile b/istoreenhance/Makefile index bffcea907..a20680056 100644 --- a/istoreenhance/Makefile +++ b/istoreenhance/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH) PKG_NAME:=istoreenhance # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=0.8.0 -PKG_RELEASE:=8 +PKG_RELEASE:=9 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) ARCH_HEXCODE=8664 @@ -41,7 +41,7 @@ define Package/$(PKG_NAME) CATEGORY:=Network SUBMENU:=Web Servers/Proxies TITLE:=iStoreEnhance - Fix some net issues for istore - DEPENDS:=@(arm||x86_64||aarch64) + DEPENDS:=@(arm||x86_64||aarch64) +linkease-app-entry PKGARCH:=all URL:=https://www.kspeeder.com/ endef @@ -68,6 +68,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json 2>/dev/null)" = "/usr/share/kspeeder/kspeeder-plugin.json" ]; then rm -f /usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json fi + if [ "$$(readlink /usr/share/linkease/apps.d/20-kspeeder.json 2>/dev/null)" = "/usr/share/kspeeder/kspeeder-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/20-kspeeder.json + fi fi exit 0 endef @@ -79,7 +82,7 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/kspeeder/www $(1)/usr/share/linkeasefull/desktop-apps.d + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/kspeeder/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(INSTALL_BIN) $(PKG_BUILD_DIR)/iStoreEnhance.$(PKG_ARCH_ISTOREENHANCE) $(1)/usr/sbin/iStoreEnhance $(INSTALL_CONF) ./files/istoreenhance.config $(1)/etc/config/istoreenhance $(INSTALL_BIN) ./files/istoreenhance.init $(1)/etc/init.d/istoreenhance @@ -93,6 +96,7 @@ define Package/$(PKG_NAME)/install fi $(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kspeeder/www/logo.png ln -sf /usr/share/kspeeder/kspeeder-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json + ln -sf /usr/share/kspeeder/kspeeder-plugin.json $(1)/usr/share/linkease/apps.d/20-kspeeder.json endef $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/kai-agent/Makefile b/kai-agent/Makefile new file mode 100644 index 000000000..de0876320 --- /dev/null +++ b/kai-agent/Makefile @@ -0,0 +1,43 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=kai-agent +PKG_VERSION:=linkease-app-entry-runtime-v3.0.22 +PKG_RELEASE:=1 +PKG_SOURCE:=kai-agent-bundle-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/ +PKG_HASH:=skip +PKG_BUILD_DIR:=$(BUILD_DIR)/kai-agent-bundle-$(PKG_VERSION) + +PKG_BUILD_PARALLEL:=1 +PKG_USE_MIPS16:=0 + +include $(INCLUDE_DIR)/package.mk + +define Package/$(PKG_NAME) + SECTION:=net + CATEGORY:=System + TITLE:=KAI OpenCode Agent bundle + URL:=https://github.com/linkease/linkease-skills +endef + +define Package/$(PKG_NAME)/description + Versioned local OpenCode instructions and skills for KAI on iStoreOS. +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION) + $(CP) $(PKG_BUILD_DIR)/agents $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/ + $(CP) $(PKG_BUILD_DIR)/skills $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/ + $(INSTALL_DATA) $(PKG_BUILD_DIR)/home-prompts.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/ + $(INSTALL_DATA) $(PKG_BUILD_DIR)/manifest.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/ + $(INSTALL_DATA) $(PKG_BUILD_DIR)/opencode.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/ + $(LN) agent-bundles/$(PKG_VERSION) $(1)/usr/share/kai/agent +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/kai/Makefile b/kai/Makefile index 96eecf73b..253581d62 100644 --- a/kai/Makefile +++ b/kai/Makefile @@ -10,10 +10,10 @@ include $(TOPDIR)/rules.mk PKG_ARCH_kai:=$(ARCH) PKG_NAME:=kai -PKG_VERSION:=0.0.6 -PKG_RELEASE:=5 +PKG_VERSION:=linkease-app-entry-runtime-v3.0.22 +PKG_RELEASE:=6 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz -PKG_SOURCE_URL:=https://github.com/linkease/istore-packages/releases/download/prebuilt/ +PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-binary-$(PKG_VERSION) @@ -27,6 +27,7 @@ define Package/$(PKG_NAME) CATEGORY:=System SUBMENU:= TITLE:=KAI + DEPENDS:=+kai_session +kai-agent +luci-lib-linkeaseauth +linkease-app-entry URL:=https://github.com/Carseason/openwrt-packages endef @@ -38,6 +39,19 @@ define Package/$(PKG_NAME)/conffiles /etc/config/kai endef +define Package/$(PKG_NAME)/prerm +#!/bin/sh +if [ "$${IPKG_INSTROOT}" = "" ]; then + if [ "$$(readlink /usr/share/linkease/apps.d/05-kai-plugin.json 2>/dev/null)" = "/usr/share/kai/kai-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/05-kai-plugin.json + fi + if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json 2>/dev/null)" = "/usr/share/kai/kai-plugin.json" ]; then + rm -f /usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json + fi +fi +exit 0 +endef + define Build/Configure endef @@ -45,10 +59,15 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec $(1)/etc/init.d $(1)/etc/config $(1)/usr/share/kai/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(INSTALL_BIN) $(PKG_BUILD_DIR)/kai.$(PKG_ARCH_kai) $(1)/usr/sbin/kai_bin + $(INSTALL_BIN) ./files/kai-session-launch $(1)/usr/libexec/kai-session-launch $(INSTALL_BIN) ./files/kai.init $(1)/etc/init.d/kai $(INSTALL_CONF) ./files/kai.config $(1)/etc/config/kai + $(INSTALL_DATA) ./files/kai-plugin.json $(1)/usr/share/kai/kai-plugin.json + $(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kai/www/logo.png + ln -sf /usr/share/kai/kai-plugin.json $(1)/usr/share/linkease/apps.d/05-kai-plugin.json + ln -sf /usr/share/kai/kai-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json endef $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/kai/files/kai-plugin.json b/kai/files/kai-plugin.json new file mode 100644 index 000000000..c0d30d58b --- /dev/null +++ b/kai/files/kai-plugin.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "id": "kai", + "name": "KAI", + "icon": "logo.png", + "staticRoot": "/usr/share/kai/www", + "desktop": { + "mode": "iframe", + "target": { + "scheme": "http", + "hostMode": "request-host", + "port": { + "default": 8197, + "keys": { + "uci": "kai.@kai[0].port" + } + }, + "path": "/apps/kai/" + }, + "access": { + "scope": "lan", + "blockedTitle": "请在局域网内打开 KAI", + "blockedMessage": "KAI 运行在设备的独立服务端口,请连接设备所在局域网后再打开。" + } + }, + "standalone": { + "basePath": "/apps/kai/", + "externalOpen": { + "enabled": true, + "label": "在浏览器中打开 KAI" + } + }, + "config": { + "providerOrder": ["uci"], + "providers": { + "uci": { + "type": "uci" + } + } + }, + "backend": { + "type": "http", + "transport": "tcp", + "scheme": "http", + "host": "127.0.0.1", + "portFromUci": "kai.@kai[0].port", + "defaultPort": 8197, + "upstreamBasePath": "/apps/kai/", + "pathMode": "preserve", + "proxyMode": "app-base" + }, + "window": { + "width": 1180, + "height": 760, + "singleton": true + }, + "capabilities": ["assistant", "chat"], + "categories": ["other"] +} diff --git a/kai/files/kai-session-launch b/kai/files/kai-session-launch new file mode 100755 index 000000000..8f8dabe6d --- /dev/null +++ b/kai/files/kai-session-launch @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ] || [ -z "$1" ]; then + echo "usage: kai-session-launch WORKING_DIRECTORY" >&2 + exit 2 +fi + +cd "$1" +exec /usr/sbin/kai_session serve --port 8196 --hostname 127.0.0.1 diff --git a/kai/files/kai.config b/kai/files/kai.config index 38b8756a1..acc62eb98 100644 --- a/kai/files/kai.config +++ b/kai/files/kai.config @@ -2,3 +2,4 @@ config kai option 'enabled' '0' option 'data_dir' '' option 'port' '8197' + option 'auth_mode' 'openwrt_luci' diff --git a/kai/files/kai.init b/kai/files/kai.init index f6ba508ff..d79e5ed49 100755 --- a/kai/files/kai.init +++ b/kai/files/kai.init @@ -7,74 +7,12 @@ get_config() { config_get_bool enabled $1 enabled 1 config_get data_dir $1 data_dir "" config_get port $1 port "8197" -} - -http_get() { - local url="$1" - if command -v uclient-fetch >/dev/null 2>&1; then - uclient-fetch -O /dev/stdout "$url" 2>/dev/null - return $? - fi - if command -v wget >/dev/null 2>&1; then - wget -qO- "$url" 2>/dev/null - return $? - fi - if command -v curl >/dev/null 2>&1; then - curl -fsL "$url" 2>/dev/null - return $? - fi - return 1 -} - -maybe_refresh_opencode_skills_cache() { - [ -n "$data_dir" ] || return 0 - [ -n "$port" ] || return 0 - command -v jsonfilter >/dev/null 2>&1 || return 0 - - local cache_root="${data_dir}/cache/opencode/skills" - local state_root="${data_dir}/state/opencode" - local version_file="${state_root}/skills.manifest.version" - local managed_file="${state_root}/skills.managed.list" - local manifest_url="http://127.0.0.1:${port}/agentconf/skills/manifest.json" - - [ -d "$cache_root" ] || mkdir -p "$cache_root" - [ -d "$state_root" ] || mkdir -p "$state_root" - - local manifest version prev_version skills - manifest="$(http_get "$manifest_url")" || return 0 - version="$(printf '%s' "$manifest" | jsonfilter -e '@.version' 2>/dev/null)" - [ -n "$version" ] || return 0 - - prev_version="" - [ -f "$version_file" ] && prev_version="$(cat "$version_file" 2>/dev/null)" - [ "$version" = "$prev_version" ] && return 0 - - logger -t kai "opencode skills manifest changed; refreshing managed skill cache" - - if [ -f "$managed_file" ]; then - while IFS= read -r s; do - [ -n "$s" ] || continue - rm -rf "${cache_root}/${s}" - done <"$managed_file" - fi - - skills="$(printf '%s' "$manifest" | jsonfilter -e '@.skills[*]' 2>/dev/null)" - if [ -n "$skills" ]; then - printf '%s\n' "$skills" >"$managed_file" - printf '%s\n' "$skills" | while IFS= read -r s; do - [ -n "$s" ] || continue - rm -rf "${cache_root}/${s}" - done - else - : >"$managed_file" - fi - - printf '%s\n' "$version" >"$version_file" - return 0 + config_get auth_mode $1 auth_mode "openwrt_luci" } start_kai_bin(){ procd_open_instance kai_bin + procd_set_param env KAI_AUTH_MODE="$auth_mode" procd_set_param command /usr/sbin/kai_bin -a 0.0.0.0 -p "$port" procd_set_param stderr 1 procd_set_param respawn @@ -87,15 +25,21 @@ start_kai_session(){ procd_open_instance kai_session local root="${data_dir}" local cwd="${data_dir}/cwd" + local agent_root="/usr/share/kai/agent" + if [ ! -f "${agent_root}/opencode.json" ] || [ ! -d "${agent_root}/skills" ]; then + logger -t kai "local OpenCode Agent bundle is missing under ${agent_root}" + return 1 + fi procd_set_param env \ - OPENCODE_CWD="${cwd}" \ - OPENCODE_CONFIG="http://127.0.0.1:${port}/agentconf/opencode.json" \ + OPENCODE_CONFIG="${agent_root}/opencode.json" \ + KAIPLUS_SKILLS_DIR="${agent_root}/skills" \ + ISTORE_AI_HELPER_BASE="http://127.0.0.1:${port}" \ XDG_CACHE_HOME="${root}/cache" \ XDG_DATA_HOME="${root}/data" \ XDG_CONFIG_HOME="${root}/config" \ XDG_STATE_HOME="${root}/state" - procd_set_param command /usr/sbin/kai_session - procd_append_param command serve --port "8196" --hostname "127.0.0.1" + procd_set_param command /usr/libexec/kai-session-launch "${cwd}" + procd_set_param file "${agent_root}/opencode.json" "${agent_root}/manifest.json" procd_set_param stderr 1 procd_set_param respawn procd_close_instance @@ -103,7 +47,7 @@ start_kai_session(){ start_service() { config_load kai config_foreach get_config kai - if [ $enabled != 1 ]; then + if [ "$enabled" != 1 ]; then return 1 fi if [ -z "${data_dir}" ]; then @@ -119,8 +63,7 @@ start_service() { mkdir_cwd "${data_dir}/data" mkdir_cwd "${data_dir}/config" mkdir_cwd "${data_dir}/state" - maybe_refresh_opencode_skills_cache - start_kai_session + start_kai_session || return 1 logger -t kai "Starting KAI Service Completed" } diff --git a/kai/files/logo.png b/kai/files/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4a7fa7b63efe01205f2f638fe211591ad40bf289 GIT binary patch literal 20731 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/dev/null)" = "/usr/share/kaiplus/kaiplus-plugin.json" ]; then rm -f /usr/share/linkeasefull/desktop-apps.d/00-kaiplus-plugin.json fi + if [ "$$(readlink /usr/share/linkease/apps.d/00-kaiplus-plugin.json 2>/dev/null)" = "/usr/share/kaiplus/kaiplus-plugin.json" ]; then + rm -f /usr/share/linkease/apps.d/00-kaiplus-plugin.json + fi fi exit 0 endef @@ -54,7 +58,7 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/kaiplus/www $(1)/usr/share/kaiplus/defaults $(1)/usr/share/kaiplus/helpers $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config + $(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/kaiplus/www $(1)/usr/share/kaiplus/defaults $(1)/usr/share/kaiplus/helpers $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config $(INSTALL_BIN) $(PKG_BUILD_DIR)/kaiplus_bin.$(PKG_ARCH_kaiplus) $(1)/usr/sbin/kaiplus_bin $(CP) $(PKG_BUILD_DIR)/www/* $(1)/usr/share/kaiplus/www/ $(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kaiplus/www/logo.png @@ -62,6 +66,7 @@ define Package/$(PKG_NAME)/install $(INSTALL_BIN) $(PKG_BUILD_DIR)/helpers/kaiplus_workspace_tool.* $(1)/usr/share/kaiplus/helpers/ $(INSTALL_DATA) ./files/kaiplus-plugin.json $(1)/usr/share/kaiplus/kaiplus-plugin.json ln -sf /usr/share/kaiplus/kaiplus-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/00-kaiplus-plugin.json + ln -sf /usr/share/kaiplus/kaiplus-plugin.json $(1)/usr/share/linkease/apps.d/00-kaiplus-plugin.json $(INSTALL_BIN) ./files/kaiplus.init $(1)/etc/init.d/kaiplus $(INSTALL_CONF) ./files/kaiplus.config $(1)/etc/config/kaiplus endef diff --git a/linkease-app-entry/Makefile b/linkease-app-entry/Makefile new file mode 100644 index 000000000..92387f1c2 --- /dev/null +++ b/linkease-app-entry/Makefile @@ -0,0 +1,76 @@ +# +# Copyright (C) 2026 iStoreOS +# +# This is free software, licensed under the GNU General Public License v3. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=linkease-app-entry +# This lightweight runtime is released independently from the full desktop. +PKG_VERSION:=linkease-app-entry-runtime-v3.0.22 +PKG_RELEASE:=1 + +ARCH_HEXCODE:= +ifeq ($(ARCH),x86_64) +ARCH_HEXCODE=8664 +LINKEASE_RUNTIME_ARCH:=amd64 +PKG_HASH:=skip +else ifeq ($(ARCH),aarch64) +ARCH_HEXCODE=aa64 +LINKEASE_RUNTIME_ARCH:=arm64 +PKG_HASH:=skip +endif + +PKG_SOURCE_VERSION:=d3d23965623e4182de554dde64418bb9a20c9078 +PKG_SOURCE:=linkease-app-entry-runtime-$(PKG_VERSION)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz +PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-app-entry-runtime-v$(PKG_VERSION)/ +PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-app-entry-runtime-$(PKG_VERSION)-linux-$(LINKEASE_RUNTIME_ARCH) + +PKG_BUILD_PARALLEL:=1 +PKG_USE_MIPS16:=0 + +include $(INCLUDE_DIR)/package.mk + +define Package/$(PKG_NAME) + SECTION:=net + CATEGORY:=Network + SUBMENU:=Web Servers/Proxies + TITLE:=LinkEase application entry arbiter and fallback gateway + DEPENDS:=@(x86_64||aarch64) +ca-bundle +luci-lib-linkeaseauth + PKGARCH:=all + URL:=https://www.linkease.com/ +endef + +define Package/$(PKG_NAME)/description + Owns the shared /apps listener, launches LinkEaseFull directly when healthy, + and runs the manifest-driven fallback gateway only when LinkEaseFull is not available. +endef + +define Package/$(PKG_NAME)/conffiles +/etc/config/linkease_app_entry +endef + +define Package/$(PKG_NAME)/postinst +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + [ -f /etc/uci-defaults/linkease-app-entry ] && /etc/uci-defaults/linkease-app-entry && rm -f /etc/uci-defaults/linkease-app-entry +fi +exit 0 +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults + $(INSTALL_BIN) $(PKG_BUILD_DIR)/bin/linkease-app-entry $(1)/usr/sbin/linkease-app-entry + $(INSTALL_CONF) ./files/linkease-app-entry.config $(1)/etc/config/linkease_app_entry + $(INSTALL_BIN) ./files/linkease-app-entry.init $(1)/etc/init.d/linkease-app-entry + $(INSTALL_BIN) ./files/linkease-app-entry.uci-default $(1)/etc/uci-defaults/linkease-app-entry +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/linkease-app-entry/files/linkease-app-entry.config b/linkease-app-entry/files/linkease-app-entry.config new file mode 100644 index 000000000..67b2d6921 --- /dev/null +++ b/linkease-app-entry/files/linkease-app-entry.config @@ -0,0 +1,7 @@ +config entry 'main' + option enabled '1' + option listen '127.0.0.1:19290' + option ready_timeout '75s' + option max_primary_failures '3' + option crash_window '60s' + option state_file '/var/run/linkease-app-entry/state.json' diff --git a/linkease-app-entry/files/linkease-app-entry.init b/linkease-app-entry/files/linkease-app-entry.init new file mode 100644 index 000000000..675d8200f --- /dev/null +++ b/linkease-app-entry/files/linkease-app-entry.init @@ -0,0 +1,115 @@ +#!/bin/sh /etc/rc.common + +START=99 +STOP=11 +USE_PROCD=1 + +ENTRY=/usr/sbin/linkease-app-entry +PRIMARY=/usr/bin/linkease-full +FORCE_GATEWAY=/var/run/linkease-app-entry/force-gateway +DATA_ROOT_WAIT_SECONDS=${LINKEASEFULL_DATA_ROOT_WAIT_SECONDS:-60} + +append_primary_arg() { + procd_append_param command --primary-arg "$1" +} + +primary_enabled() { + [ -x "$PRIMARY" ] || return 1 + [ ! -e "$FORCE_GATEWAY" ] || return 1 + enabled="$(uci -q get linkeasefull.@linkeasefull[0].enabled 2>/dev/null || echo 1)" + [ "$enabled" = 1 ] +} + +ensure_linkease_config() { + if [ ! -f /etc/config/linkease ]; then + touch /etc/config/linkease || return 1 + fi + if ! uci -q get linkease.@linkease[0] >/dev/null; then + uci -q add linkease linkease >/dev/null || return 1 + uci -q set linkease.@linkease[0].enabled='0' || return 1 + uci -q set linkease.@linkease[0].port='8897' || return 1 + uci -q set linkease.@linkease[0].allowPublic='0' || return 1 + uci -q commit linkease || return 1 + fi +} + +prepare_linkeasefull_runtime() { + local data_root_parent data_root recycle_root wait_left + ensure_linkease_config || true + data_root_parent="$(uci -q get linkease.@linkease[0].local_home 2>/dev/null || true)" + case "$data_root_parent" in + ""|/tmp|/tmp/*|/var|/var/*|/run|/run/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*|/rom|/rom/*|/overlay|/overlay/*) data_root_parent="" ;; + esac + if [ -n "$data_root_parent" ]; then + wait_left="$DATA_ROOT_WAIT_SECONDS" + case "$wait_left" in ""|*[!0-9]*) wait_left=60 ;; esac + while [ "$wait_left" -gt 0 ] && [ ! -d "$data_root_parent" ]; do + sleep 1 + wait_left=$((wait_left - 1)) + done + [ -d "$data_root_parent" ] || data_root_parent="" + fi + if [ -n "$data_root_parent" ]; then + data_root="$data_root_parent/.linkease_data" + recycle_root="$data_root_parent/.linkease_recycle" + mkdir -p "$data_root/users/admin" "$data_root/system" "$data_root/tmp" "$data_root/config" "$data_root/apptunnel" "$data_root/mountremote" "$recycle_root" + fi + mkdir -p /tmp/linkeasefull/logs + rm -f /var/run/linkease.sock +} + +start_service() { + config_load linkease_app_entry + config_get_bool entry_enabled main enabled 1 + [ "$entry_enabled" = 1 ] || return 1 + config_get listen main listen "127.0.0.1:19290" + config_get ready_timeout main ready_timeout "75s" + config_get max_failures main max_primary_failures "3" + config_get crash_window main crash_window "60s" + config_get state_file main state_file "/var/run/linkease-app-entry/state.json" + + mkdir -p /var/run/linkease-app-entry + primary=0 + primary_enabled && primary=1 + [ "$primary" = 1 ] && prepare_linkeasefull_runtime + + procd_open_instance entry + procd_set_param limits nofile="65535 65535" + procd_set_param command "$ENTRY" supervisor \ + --listen "$listen" \ + --primary "$PRIMARY" \ + --primary-enabled="$primary" \ + --fallback "$ENTRY" \ + --fallback-arg gateway \ + --state-file "$state_file" \ + --ready-timeout "$ready_timeout" \ + --max-primary-failures "$max_failures" \ + --crash-window "$crash_window" + + append_primary_arg --host + append_primary_arg 127.0.0.1 + append_primary_arg --port + append_primary_arg 19290 + append_primary_arg --mode + append_primary_arg release + append_primary_arg --basePath + append_primary_arg /apps/ + append_primary_arg --deviceAddr + append_primary_arg 0.0.0.0:8897 + append_primary_arg --localApi + append_primary_arg /var/run/linkease.sock + append_primary_arg --supplierCode + append_primary_arg linkease + + procd_set_param env \ + "LINKEASE_AUTH_PROVIDER=openwrt" \ + "KAIPLUS_ENABLED=0" + procd_set_param respawn 3600 5 0 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "linkease_app_entry" "linkeasefull" +} diff --git a/linkease-app-entry/files/linkease-app-entry.uci-default b/linkease-app-entry/files/linkease-app-entry.uci-default new file mode 100644 index 000000000..d956bae4a --- /dev/null +++ b/linkease-app-entry/files/linkease-app-entry.uci-default @@ -0,0 +1,29 @@ +#!/bin/sh + +. /lib/functions.sh + +mapping='/apps=http://127.0.0.1:19290' +found=0 + +# During upgrade the old linkeasefull init may still own 19290. Stop that +# legacy procd instance before starting the single-owner entry module. +if grep -q 'procd_open_instance linkeasefull' /etc/init.d/linkeasefull 2>/dev/null; then + /etc/init.d/linkeasefull stop >/dev/null 2>&1 || true +fi + +check_mapping() { + [ "$1" = "$mapping" ] && found=1 +} + +config_load uhttpd +config_list_foreach main proxy_prefix check_mapping +uci -q show uhttpd.main.proxy_prefix 2>/dev/null | grep -Fq "$mapping" && found=1 +if [ "$found" != 1 ]; then + uci -q add_list "uhttpd.main.proxy_prefix=$mapping" + uci -q commit uhttpd + /etc/init.d/uhttpd reload >/dev/null 2>&1 || true +fi + +/etc/init.d/linkease-app-entry enable +/etc/init.d/linkease-app-entry restart >/dev/null 2>&1 || true +exit 0 diff --git a/linkease-common-bin/Makefile b/linkease-common-bin/Makefile index 51dc343fd..f4e91b98f 100644 --- a/linkease-common-bin/Makefile +++ b/linkease-common-bin/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease-common-bin # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=33 +PKG_RELEASE:=34 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877 +PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9 PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkease/Makefile b/linkease/Makefile index 9f68bf883..00683922f 100644 --- a/linkease/Makefile +++ b/linkease/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=36 +PKG_RELEASE:=37 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877 +PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9 PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile index bed4d5a92..37b47bd48 100644 --- a/linkeasefull/Makefile +++ b/linkeasefull/Makefile @@ -10,8 +10,8 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkeasefull # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable -PKG_SOURCE_DATE:=3.0.20 -PKG_RELEASE:=35 +PKG_SOURCE_DATE:=3.0.22 +PKG_RELEASE:=36 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64 PKG_HASH:=skip endif -PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877 +PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9 PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH) @@ -39,7 +39,7 @@ define Package/$(PKG_NAME) CATEGORY:=Network SUBMENU:=Web Servers/Proxies TITLE:=LinkEase Full - the local web desktop - DEPENDS:=@(x86_64||aarch64) +linkease-common-bin +ca-bundle +cifsmount +kmod-fs-cifs + DEPENDS:=@(x86_64||aarch64) +linkease-app-entry +linkease-common-bin +ca-bundle +cifsmount +kmod-fs-cifs PKGARCH:=all URL:=https://www.linkease.com/ endef diff --git a/linkeasefull/files/linkeasefull.init b/linkeasefull/files/linkeasefull.init index 7208bc488..c3ad80ce4 100644 --- a/linkeasefull/files/linkeasefull.init +++ b/linkeasefull/files/linkeasefull.init @@ -1,123 +1,61 @@ #!/bin/sh /etc/rc.common +# Compatibility control shim. linkease-app-entry is the only owner of port +# 19290 and the only procd instance allowed to launch linkease-full. + START=100 STOP=10 -USE_PROCD=1 -PROG=/usr/bin/linkease-full -LINKEASE_FULL_PORT=19290 -LINKEASE_BASE_PATH=/apps/ -LINKEASE_LEGACY_ADDR=0.0.0.0:8897 -LOCAL_API=/var/run/linkease.sock -DATA_ROOT_WAIT_SECONDS=${LINKEASEFULL_DATA_ROOT_WAIT_SECONDS:-60} +ENTRY_INIT=/etc/init.d/linkease-app-entry +FORCE_GATEWAY=/var/run/linkease-app-entry/force-gateway +STATE_FILE=/var/run/linkease-app-entry/state.json -ensure_linkease_config() { - if [ ! -f /etc/config/linkease ]; then - touch /etc/config/linkease || return 1 - fi - if ! uci -q get linkease.@linkease[0] >/dev/null; then - uci -q add linkease linkease >/dev/null || return 1 - uci -q set linkease.@linkease[0].enabled='0' || return 1 - uci -q set linkease.@linkease[0].port='8897' || return 1 - uci -q set linkease.@linkease[0].allowPublic='0' || return 1 - uci -q commit linkease || return 1 - fi -} - -is_allowed_data_root_parent() { - [ -n "$1" ] || return 1 - case "$1" in - /tmp|/tmp/*|/var|/var/*|/run|/run/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*|/rom|/rom/*|/overlay|/overlay/*) return 1 ;; - esac - return 0 -} - -wait_for_data_root_parent() { - is_allowed_data_root_parent "$1" || return 1 - wait_left="$DATA_ROOT_WAIT_SECONDS" - case "$wait_left" in - ""|*[!0-9]*) wait_left=60 ;; - esac - while [ "$wait_left" -gt 0 ]; do - [ -d "$1" ] && return 0 - sleep 1 - wait_left=$((wait_left - 1)) - done - [ -d "$1" ] -} - -is_persistent_data_root_parent() { - is_allowed_data_root_parent "$1" || return 1 - [ -d "$1" ] || return 1 - return 0 -} - -read_config() { - section="$1" - config_get_bool enabled "$section" enabled 1 - ensure_linkease_config || true - data_root_parent="$(uci -q get linkease.@linkease[0].local_home 2>/dev/null || true)" - - if [ -n "$data_root_parent" ] && ! wait_for_data_root_parent "$data_root_parent"; then - data_root_parent="" - fi - if [ -n "$data_root_parent" ] && ! is_persistent_data_root_parent "$data_root_parent"; then - data_root_parent="" - fi - if [ -n "$data_root_parent" ]; then - data_root="$data_root_parent/.linkease_data" - recycle_root="$data_root_parent/.linkease_recycle" - fi - log_root="/tmp/linkeasefull/logs" -} - -start_service() { - config_load linkeasefull - config_foreach read_config linkeasefull - [ "$enabled" = "1" ] || return 1 - [ -x "$PROG" ] || return 1 - - rm -f "$LOCAL_API" - if [ -n "$data_root_parent" ]; then - mkdir -p "$data_root/users/admin" "$data_root/system" "$data_root/tmp" "$recycle_root" - mkdir -p "$data_root/config" "$data_root/apptunnel" "$data_root/mountremote" +sync_force_gateway() { + mkdir -p "$(dirname "$FORCE_GATEWAY")" + enabled="$(uci -q get linkeasefull.@linkeasefull[0].enabled 2>/dev/null || echo 0)" + if [ "$enabled" = 1 ]; then + rm -f "$FORCE_GATEWAY" else - echo "linkeasefull: data root is not initialized; starting for first-run setup" >&2 + : > "$FORCE_GATEWAY" fi - mkdir -p "$log_root" - - # Do not set LINKEASE_CONFIG on OpenWrt. That variable belongs to - # Docker/file-backend deployments where users explicitly mount - # LINKEASE_CONFIG/preconfig.data. OpenWrt binding state is authoritative in - # UCI and must be accessed through /usr/sbin/linkease-config.sh. This is - # intentional: the user's data disk may be replaced, and the device must - # still be able to recover its binding from flash-backed OpenWrt config. - # - # Do not export LINKEASE_APPTUNNEL_PRECONFIG_SCRIPT or - # LINKEASE_APPTUNNEL_DEFAULT_ROOT_DIR here. Different devices have different - # storage roots, and hard-coded env wiring can pin apptunnel to a removed - # disk. The embedded clientruntime must discover linkease-config.sh and call - # local_load/local_save-compatible business APIs for the current root. - - procd_open_instance linkeasefull - procd_set_param limits nofile="65535 65535" - procd_set_param command "$PROG" \ - --host "0.0.0.0" \ - --port "$LINKEASE_FULL_PORT" \ - --mode "release" \ - --basePath "$LINKEASE_BASE_PATH" \ - --deviceAddr "$LINKEASE_LEGACY_ADDR" \ - --localApi "$LOCAL_API" \ - --supplierCode "linkease" - procd_set_param env \ - "LINKEASE_AUTH_PROVIDER=openwrt" \ - "KAIPLUS_ENABLED=0" - procd_set_param respawn - procd_set_param stdout 1 - procd_set_param stderr 1 - procd_close_instance } -service_triggers() { - procd_add_reload_trigger "linkeasefull" +start() { + mkdir -p "$(dirname "$FORCE_GATEWAY")" + rm -f "$FORCE_GATEWAY" + uci -q set linkeasefull.@linkeasefull[0].enabled='1' + uci -q commit linkeasefull + "$ENTRY_INIT" restart +} + +stop() { + mkdir -p "$(dirname "$FORCE_GATEWAY")" + : > "$FORCE_GATEWAY" + "$ENTRY_INIT" restart +} + +restart() { + start +} + +reload() { + sync_force_gateway + "$ENTRY_INIT" restart +} + +running() { + [ -r "$STATE_FILE" ] && grep -q '"active":"linkeasefull"' "$STATE_FILE" +} + +status() { + if running; then + echo "running" + return 0 + fi + if [ -r "$STATE_FILE" ]; then + cat "$STATE_FILE" + else + echo "stopped" + fi + return 1 } diff --git a/linkeasefull/files/linkeasefull.uci-default b/linkeasefull/files/linkeasefull.uci-default index 0d116b3da..bc834b8cb 100644 --- a/linkeasefull/files/linkeasefull.uci-default +++ b/linkeasefull/files/linkeasefull.uci-default @@ -30,6 +30,9 @@ uci -q batch <<-EOF >/dev/null commit ucitrack EOF -/etc/init.d/linkeasefull enable +/etc/init.d/linkeasefull disable +/etc/init.d/linkease-app-entry enable +rm -f /var/run/linkease-app-entry/force-gateway +/etc/init.d/linkease-app-entry restart >/dev/null 2>&1 || true exit 0 diff --git a/luci-app-agentflow/Makefile b/luci-app-agentflow/Makefile index 1620a22c8..ac3583060 100644 --- a/luci-app-agentflow/Makefile +++ b/luci-app-agentflow/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=AgentFlow PKG_VERSION:=1.0.1 -PKG_RELEASE:=7 -LUCI_DEPENDS:=+agentflow +luci-compat +luci-lib-taskd +PKG_RELEASE:=8 +LUCI_DEPENDS:=+agentflow +luci-compat +luci-lib-taskd +luci-lib-linkeaseauth +linkease-app-entry LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-agentflow/luasrc/controller/agentflow.lua b/luci-app-agentflow/luasrc/controller/agentflow.lua index 9c4b25543..ed5a8f1d7 100644 --- a/luci-app-agentflow/luasrc/controller/agentflow.lua +++ b/luci-app-agentflow/luasrc/controller/agentflow.lua @@ -1,18 +1,5 @@ -local http = require "luci.http" - module("luci.controller.agentflow", package.seeall) -local APPS_PROXY_PREFIX = "/apps=http://127.0.0.1:19290" -local DEFAULT_BASE_PATH = "/apps/agentflow/" -local DEFAULT_PORT = 9000 -local AGENTS = { - { id = "codexcli", package = "@openai/codex" }, - { id = "claude-code", package = "@anthropic-ai/claude-code" }, - { id = "opencode", package = "opencode-ai" }, - { id = "kimi", package = "@moonshot-ai/kimi-code" }, - { id = "reasonix", package = "reasonix" } -} - function index() entry({"admin", "services", "agentflow_status"}, call("agentflow_status")) local open = entry({"admin", "services", "agentflow", "open"}, call("agentflow_open")) @@ -20,277 +7,29 @@ function index() open.dependent = false open.sysauth = false - if not nixio.fs.access("/etc/config/agentflow") then - return - end - - local install = entry({"admin", "services", "agentflow", "agent_install"}, call("agentflow_agent_install")) - install.leaf = true - - local page = entry({"admin", "services", "agentflow"}, cbi("agentflow"), _("AgentFlow"), 100) - page.dependent = true + if not nixio.fs.access("/etc/config/agentflow") then return end + entry({"admin", "services", "agentflow"}, cbi("agentflow"), _("AgentFlow"), 100).dependent = true end -local function write_json(obj) - http.prepare_content("application/json") - http.write_json(obj) -end - -local function require_post_csrf() +local function compat() local dispatcher = require "luci.dispatcher" - local context = dispatcher.context or {} - local expected = context.authtoken or context.token - - if (http.getenv("REQUEST_METHOD") or "") ~= "POST" then - http.status(405, "Method Not Allowed") - write_json({ ok = false, error = "method not allowed" }) - return false - end - - if not context.authsession or not expected or http.formvalue("token") ~= expected then - http.status(403, "Forbidden") - write_json({ ok = false, error = "invalid csrf token" }) - return false - end - - return true -end - -local function uhttpd_has_apps_proxy_prefix() - local uci = require "luci.model.uci".cursor() - local mappings = uci:get_list("uhttpd", "main", "proxy_prefix") or {} - - for _, mapping in ipairs(mappings) do - if mapping == APPS_PROXY_PREFIX then - return true - end - end - return false -end - -local function uhttpd_supports_proxy_prefix() - local sys = require "luci.sys" - return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0 -end - -local function uhttpd_apps_proxy_available() - return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix() -end - -local function linkeasefull_running() - local sys = require "luci.sys" - return sys.call("[ -x /etc/init.d/linkeasefull ] && /etc/init.d/linkeasefull running >/dev/null 2>&1") == 0 -end - -local function normalized_base_path(path) - path = path or DEFAULT_BASE_PATH - if path:sub(1, 1) ~= "/" then - path = "/" .. path - end - if path:sub(-1) ~= "/" then - path = path .. "/" - end - return path -end - -local function authority_host(authority) - if not authority or authority == "" then - return "" - end - if authority:sub(1, 1) == "[" then - return authority:match("^%[([^%]]+)%]") or "" - end - return authority:match("^([^:]+)") or authority -end - -local function url_authority(host, port) - if not host or host == "" then - host = "127.0.0.1" - end - if host:find(":") and host:sub(1, 1) ~= "[" then - host = "[" .. host .. "]" - end - return host .. ":" .. tostring(port) -end - -local function request_or_lan_host() - local uci = require "luci.model.uci".cursor() - local host = authority_host(http.getenv("HTTP_HOST") or "") - if host ~= "" then - return host - end - return uci:get("network", "lan", "ipaddr") or "127.0.0.1" -end - -local function agentflow_config() - local uci = require "luci.model.uci".cursor() - local port = tonumber(uci:get_first("agentflow", "agentflow", "port")) or DEFAULT_PORT - if port < 1 or port > 65535 then - port = DEFAULT_PORT - end - local base_path = normalized_base_path(uci:get_first("agentflow", "agentflow", "base_path")) - return port, base_path -end - -local function agentflow_entry_url() - local port, base_path = agentflow_config() - if linkeasefull_running() and uhttpd_apps_proxy_available() then - return base_path - end - return "http://" .. url_authority(request_or_lan_host(), port) .. base_path -end - -local function node_modules_root() - local sys = require "luci.sys" - local util = require "luci.util" - local script = table.concat({ - '. /lib/functions/mise.sh 2>/dev/null || exit 1', - 'istore_runtime_env >/dev/null 2>&1 || exit 1', - 'mise_bin="$(command -v mise-istore || command -v mise)"', - '[ -n "$mise_bin" ] || exit 1', - 'node_dir="$("$mise_bin" where node@lts 2>/dev/null)"', - '[ -n "$node_dir" ] || exit 1', - 'printf "%s/lib/node_modules" "$node_dir"' - }, "; ") - local root = sys.exec("/bin/sh -c " .. util.shellquote(script)) or "" - root = root:match("^%s*(.-)%s*$") - if root == "" or root:sub(1, 1) ~= "/" then - return nil - end - return root -end - -local function agent_statuses() - local fs = require "nixio.fs" - local jsonc = require "luci.jsonc" - local root = node_modules_root() - local statuses = {} - - for _, agent in ipairs(AGENTS) do - local status = { id = agent.id, installed = false } - if root then - local package_file = root .. "/" .. agent.package .. "/package.json" - local package_data = fs.readfile(package_file) - local package_json = package_data and jsonc.parse(package_data) or nil - if type(package_json) == "table" then - status.installed = true - status.version = package_json.version - end - end - statuses[#statuses + 1] = status - end - - return statuses, root ~= nil + return require("luci.model.linkease.apps_compat").new({ + http = require "luci.http", + resolver = require("luci.model.linkease.apps_openwrt").new(), + auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth") + }) end function agentflow_status() local sys = require "luci.sys" - local port, base_path = agentflow_config() - local entry_url = agentflow_entry_url() - local agents, agents_available = agent_statuses() - - local status = { - running = (sys.call("pidof agentflow >/dev/null") == 0), - port = port, - base_path = base_path, - entry_url = entry_url, - proxy_prefix_supported = uhttpd_supports_proxy_prefix(), - proxy_prefix_enabled = uhttpd_apps_proxy_available(), - linkeasefull_running = linkeasefull_running(), - agents_available = agents_available, - agents = agents - } - write_json(status) -end - -function agentflow_agent_install() - local fs = require "nixio.fs" - local jsonc = require "luci.jsonc" - local sys = require "luci.sys" - local util = require "luci.util" - local task_id = "agentflow-agent-install" - local task_script = "/tmp/agentflow-agent-install.sh" - local installer_url = "https://fw.koolcenter.com/binary/geili/agentflow/releases/installapp/installapp-mise.sh" - local supported_agents = {} - for _, supported_agent in ipairs(AGENTS) do - supported_agents[supported_agent.id] = true - end - - if not require_post_csrf() then - return - end - - local agent = http.formvalue("agent") or "" - if not supported_agents[agent] then - write_json({ ok = false, error = "unsupported agent" }) - return - end - if not fs.access("/etc/init.d/tasks") then - write_json({ ok = false, error = "taskd is not available" }) - return - end - if not fs.access("/lib/functions/mise.sh") then - write_json({ ok = false, error = "mise environment helper is not available" }) - return - end - - local task_status = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null") - local status = jsonc.parse(task_status) or {} - local running = type(status) == "table" and status.running - if running == true or running == 1 or running == "1" or running == "true" then - write_json({ ok = true, busy = true, task_id = task_id }) - return - end - - local install_script = table.concat({ - "set -e", - "agent=" .. util.shellquote(agent), - "installer_url=" .. util.shellquote(installer_url), - 'installer="/tmp/agentflow-installapp-mise.$$"', - 'cleanup() { rm -f "$installer" "$0"; }', - "trap cleanup 0 HUP INT TERM", - '. /lib/functions/mise.sh', - 'if ! istore_runtime_env; then echo "[agentflow] Failed to initialize the shared runtime environment" >&2; exit 1; fi', - "set -u", - "export MISE_YES=1", - 'echo "[agentflow] Downloading installer: $installer_url"', - 'if command -v wget >/dev/null 2>&1; then', - '\twget -O "$installer" "$installer_url"', - 'elif command -v curl >/dev/null 2>&1; then', - '\tcurl -fL -o "$installer" "$installer_url"', - 'elif command -v uclient-fetch >/dev/null 2>&1; then', - '\tuclient-fetch -O "$installer" "$installer_url"', - "else", - '\techo "[agentflow] No HTTPS download tool is available" >&2', - "\texit 1", - "fi", - 'if [ ! -s "$installer" ]; then echo "[agentflow] Failed to download the agent installer" >&2; exit 1; fi', - 'chmod 0700 "$installer"', - 'echo "[agentflow] Running installapp-mise.sh for $agent in $HOME"', - '/bin/sh "$installer" "$agent"' - }, "\n") - if not fs.writefile(task_script, install_script .. "\n") then - write_json({ ok = false, error = "failed to create install task", task_id = task_id }) - return - end - if sys.call("chmod 0600 " .. util.shellquote(task_script)) ~= 0 then - fs.unlink(task_script) - write_json({ ok = false, error = "failed to secure install task", task_id = task_id }) - return - end - - local command = "/bin/sh " .. util.shellquote(task_script) - local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(command) .. " >/dev/null 2>&1") - if rc ~= 0 then - fs.unlink(task_script) - write_json({ ok = false, error = "failed to start install task", task_id = task_id }) - return - end - - write_json({ ok = true, task_id = task_id }) + local uci = require "luci.model.uci".cursor() + compat():legacy_status("agentflow", { + running = sys.call("pidof agentflow >/dev/null") == 0, + port = uci:get_first("agentflow", "agentflow", "port") or "9000", + base_path = uci:get_first("agentflow", "agentflow", "base_path") or "/apps/agentflow/" + }) end function agentflow_open() - local entry_url = agentflow_entry_url() - http.redirect(entry_url) + compat():open("agentflow") end diff --git a/luci-app-agentflow/tests/test_agentflow_luci_open_contract.py b/luci-app-agentflow/tests/test_agentflow_luci_open_contract.py index 61e62da98..3567d3e6b 100644 --- a/luci-app-agentflow/tests/test_agentflow_luci_open_contract.py +++ b/luci-app-agentflow/tests/test_agentflow_luci_open_contract.py @@ -9,21 +9,23 @@ class AgentFlowLuciOpenContractTest(unittest.TestCase): def read(self, relative): return (APP_DIR / relative).read_text(encoding="utf-8") - def test_luci_open_matches_linkease_proxy_pattern(self): + def test_luci_open_delegates_to_shared_apps_entry(self): makefile = self.read("Makefile") controller = self.read("luasrc/controller/agentflow.lua") status = self.read("luasrc/view/agentflow/status.htm") - self.assertNotIn("+luci-lib-linkeaseauth", makefile) + self.assertIn("+luci-lib-linkeaseauth", makefile) + self.assertIn("+linkease-app-entry", makefile) self.assertIn('entry({"admin", "services", "agentflow", "open"}', controller) self.assertIn("open.sysauth = false", controller) self.assertIn("function agentflow_open()", controller) - self.assertIn("uhttpd_apps_proxy_available()", controller) - self.assertIn("linkeasefull_running()", controller) - self.assertIn("return base_path", controller) - self.assertIn("url_authority(request_or_lan_host(), port)", controller) - self.assertIn("http.redirect(entry_url)", controller) - self.assertNotIn('linkease_auth_url("auth")', controller) + self.assertIn('compat():open("agentflow")', controller) + self.assertIn('compat():legacy_status("agentflow"', controller) + self.assertIn('http = require "luci.http"', controller) + self.assertIn('resolver = require("luci.model.linkease.apps_openwrt").new()', controller) + self.assertIn('auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")', controller) + self.assertNotIn("uhttpd_apps_proxy_available", controller) + self.assertNotIn("linkeasefull_running", controller) self.assertIn('url("admin/services/agentflow/open")', status) self.assertNotIn('window.location.hostname + ":" + st.port', status) diff --git a/luci-app-baidudrive/Makefile b/luci-app-baidudrive/Makefile index 0aa2243ab..e3c15bb18 100644 --- a/luci-app-baidudrive/Makefile +++ b/luci-app-baidudrive/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=baidudrive PKG_VERSION:=1.0.2 -PKG_RELEASE:=3 -LUCI_DEPENDS:=+baidudrive +luci-compat +PKG_RELEASE:=4 +LUCI_DEPENDS:=+baidudrive +luci-compat +luci-lib-linkeaseauth +linkease-app-entry LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-baidudrive/luasrc/controller/baidudrive.lua b/luci-app-baidudrive/luasrc/controller/baidudrive.lua index 5a18a97bf..120f58fe0 100644 --- a/luci-app-baidudrive/luasrc/controller/baidudrive.lua +++ b/luci-app-baidudrive/luasrc/controller/baidudrive.lua @@ -1,29 +1,39 @@ -local http = require "luci.http" - module("luci.controller.baidudrive", package.seeall) function index() - if not nixio.fs.access("/etc/config/baidudrive") then - return - end - local page - page = entry({"admin", "services", "baidudrive"}, cbi("baidudrive"), _("BaiduDrive"), 100) - page.dependent = true + if not nixio.fs.access("/etc/config/baidudrive") then return end + entry({"admin", "services", "baidudrive"}, cbi("baidudrive"), _("BaiduDrive"), 100).dependent = true entry({"admin", "services", "baidudrive_status"}, call("baidudrive_status")) + + local open = entry({"admin", "services", "baidudrive", "open"}, call("baidudrive_open")) + open.leaf = true + open.dependent = false + open.sysauth = "root" + open.sysauth_authenticator = "htmlauth" +end + +local function compat() + local dispatcher = require "luci.dispatcher" + return require("luci.model.linkease.apps_compat").new({ + http = require "luci.http", + resolver = require("luci.model.linkease.apps_openwrt").new(), + auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth") + }) end function baidudrive_status() local sys = require "luci.sys" - local uci = require "luci.model.uci".cursor() - local port = uci:get_first("baidudrive", "baidudrive", "port") or "10780" - local app_running = (sys.call("pidof baidudrive >/dev/null") == 0) - local sdk_running = (sys.call("pidof baiduNas >/dev/null") == 0) - local status = { + local uci = require("luci.model.uci").cursor() + local app_running = sys.call("pidof baidudrive >/dev/null") == 0 + local sdk_running = sys.call("pidof baiduNas >/dev/null") == 0 + compat():legacy_status("baidudrive", { running = app_running and sdk_running, app_running = app_running, sdk_running = sdk_running, - port = port - } - luci.http.prepare_content("application/json") - luci.http.write_json(status) + port = uci:get_first("baidudrive", "baidudrive", "port") or "10780" + }) +end + +function baidudrive_open() + compat():open("baidudrive") end diff --git a/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm b/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm index ecbde20ea..695e96bcc 100644 --- a/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm +++ b/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm @@ -9,7 +9,7 @@ XHR.poll(5, '<%=url("admin/services/baidudrive_status")%>', null, function(x, st } if (st.running) { el.innerHTML = '
<%:The BaiduDrive service is running.%>' - + "

\" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>"; + + "

\" onclick=\"window.open('<%=url("admin/services/baidudrive/open")%>')\"/>"; } } }); diff --git a/luci-app-dockermanager/Makefile b/luci-app-dockermanager/Makefile index 07da90b97..b2290a00a 100644 --- a/luci-app-dockermanager/Makefile +++ b/luci-app-dockermanager/Makefile @@ -7,11 +7,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for Docker Manager -LUCI_DEPENDS:=+dockermanager +luci-lib-linkeaseauth +LUCI_DEPENDS:=+dockermanager +luci-lib-linkeaseauth +linkease-app-entry LUCI_PKGARCH:=all PKG_VERSION:=0.1.1 -PKG_RELEASE:=2 +PKG_RELEASE:=3 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-dockermanager/luasrc/controller/dockermanager.lua b/luci-app-dockermanager/luasrc/controller/dockermanager.lua index 114878475..61720fd31 100644 --- a/luci-app-dockermanager/luasrc/controller/dockermanager.lua +++ b/luci-app-dockermanager/luasrc/controller/dockermanager.lua @@ -1,7 +1,5 @@ module("luci.controller.dockermanager", package.seeall) -local APPS_PROXY_PREFIX = "/apps=http://127.0.0.1:19290" - function index() entry({"admin", "services", "dockermanager_status"}, call("dockermanager_status")) local open = entry({"admin", "services", "dockermanager", "open"}, call("dockermanager_open")) @@ -10,189 +8,33 @@ function index() open.sysauth = "root" open.sysauth_authenticator = "htmlauth" - if not nixio.fs.access("/etc/config/dockermanager") then - return - end - + if not nixio.fs.access("/etc/config/dockermanager") then return end entry({"admin", "services", "dockermanager"}, cbi("dockermanager"), _("Docker Manager"), 22).dependent = true end -local function uhttpd_has_apps_proxy_prefix() - local uci = require "luci.model.uci".cursor() - local mappings = uci:get_list("uhttpd", "main", "proxy_prefix") or {} - - for _, mapping in ipairs(mappings) do - if mapping == APPS_PROXY_PREFIX then - return true - end - end - return false -end - -local function uhttpd_supports_proxy_prefix() - local sys = require "luci.sys" - return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0 -end - -local function uhttpd_apps_proxy_available() - return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix() -end - -local function linkeasefull_running() - local sys = require "luci.sys" - return sys.call("[ -x /etc/init.d/linkeasefull ] && /etc/init.d/linkeasefull running >/dev/null 2>&1") == 0 -end - -local function normalized_base_path(path) - path = path or "/apps/dockermanager/" - if path:sub(1, 1) ~= "/" then - path = "/" .. path - end - if path:sub(-1) ~= "/" then - path = path .. "/" - end - return path -end - -local function cookie_encode(value) - return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char) - return string.format("%%%02X", char:byte()) - end) -end - -local function authority_host(authority) - if not authority or authority == "" then - return "" - end - if authority:sub(1, 1) == "[" then - return authority:match("^%[([^%]]+)%]") or "" - end - return authority:match("^([^:]+)") or authority -end - -local function url_authority(host, port) - if not host or host == "" then - host = "127.0.0.1" - end - if host:find(":") and host:sub(1, 1) ~= "[" then - host = "[" .. host .. "]" - end - return host .. ":" .. tostring(port) -end - -local function request_or_lan_host() - local http = require "luci.http" - local uci = require "luci.model.uci".cursor() - local host = authority_host(http.getenv("HTTP_HOST") or "") - if host ~= "" then - return host - end - return uci:get("network", "lan", "ipaddr") or "127.0.0.1" -end - -local function linkease_auth_url(name) +local function compat() local dispatcher = require "luci.dispatcher" - return dispatcher.build_url("admin", "services", "linkease_auth", name) -end - -local function dockermanager_config() - local uci = require "luci.model.uci".cursor() - local port = uci:get_first("dockermanager", "dockermanager", "port") or "8192" - local base_path = normalized_base_path(uci:get_first("dockermanager", "dockermanager", "base_path")) - local external_port_enabled = uci:get_first("dockermanager", "dockermanager", "external_port_enabled") == "1" - local listen_mode = uci:get_first("dockermanager", "dockermanager", "listen_mode") or "auto" - local socket_path = uci:get_first("dockermanager", "dockermanager", "socket_path") or "/var/run/dockermanager.sock" - return port, base_path, external_port_enabled, listen_mode, socket_path -end - -local function dockermanager_entry_url() - local port, base_path, external_port_enabled = dockermanager_config() - if linkeasefull_running() and uhttpd_apps_proxy_available() then - return base_path - end - if not external_port_enabled then - return nil - end - return "http://" .. url_authority(request_or_lan_host(), port) .. base_path -end - -local function enable_external_port() - local uci = require "luci.model.uci".cursor() - local section = nil - uci:foreach("dockermanager", "dockermanager", function(s) - if not section then - section = s[".name"] - end - end) - if section then - uci:set("dockermanager", section, "external_port_enabled", "1") - uci:set("dockermanager", section, "listen_mode", "tcp") - uci:commit("dockermanager") - end - local sys = require "luci.sys" - sys.call("/etc/init.d/dockermanager restart >/dev/null 2>&1; sleep 1") -end - -local function render_enable_port_confirm() - local dispatcher = require "luci.dispatcher" - local confirm_url = dispatcher.build_url("admin", "services", "dockermanager", "open") .. "?enable_port=1" - luci.http.prepare_content("text/html; charset=utf-8") - luci.http.write("Docker Manager") - luci.http.write("

Docker Manager

") - luci.http.write("

当前系统无法通过 LinkEase 桌面代理打开 Docker Manager。

") - luci.http.write("

可以启用局域网端口 8192 后继续打开。

") - luci.http.write("

启用 8192 并打开

") - luci.http.write("") -end - -local function render_open_failed() - luci.http.prepare_content("text/html; charset=utf-8") - luci.http.write("Docker Manager") - luci.http.write("

Docker Manager

") - luci.http.write("

Docker Manager 已安装,但当前没有可用访问入口。

") - luci.http.write("

请启用 LinkEaseFull 的 /apps 代理,或在 Docker Manager 设置中开放 8192 端口。

") - luci.http.write("") + return require("luci.model.linkease.apps_compat").new({ + http = require "luci.http", + resolver = require("luci.model.linkease.apps_openwrt").new(), + auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth") + }) end function dockermanager_status() local sys = require "luci.sys" - local uci = require "luci.model.uci".cursor() - local port, base_path, external_port_enabled, listen_mode, socket_path = dockermanager_config() - local entry_url = dockermanager_entry_url() - - local status = { - running = (sys.call("pidof docker-manager >/dev/null") == 0), - port = port, - base_path = base_path, - listen_mode = listen_mode, - socket_path = socket_path, - external_port_enabled = external_port_enabled, - entry_url = entry_url or "", - lan_ip = uci:get("network", "lan", "ipaddr") or "", - proxy_prefix_supported = uhttpd_supports_proxy_prefix(), - proxy_prefix_enabled = uhttpd_apps_proxy_available(), - linkeasefull_running = linkeasefull_running() - } - - luci.http.prepare_content("application/json") - luci.http.write_json(status) + local uci = require("luci.model.uci").cursor() + compat():legacy_status("dockermanager", { + running = sys.call("pidof docker-manager >/dev/null") == 0, + port = uci:get_first("dockermanager", "dockermanager", "port") or "8192", + base_path = uci:get_first("dockermanager", "dockermanager", "base_path") or "/apps/dockermanager/", + listen_mode = uci:get_first("dockermanager", "dockermanager", "listen_mode") or "auto", + socket_path = uci:get_first("dockermanager", "dockermanager", "socket_path") or "/var/run/dockermanager.sock", + external_port_enabled = uci:get_first("dockermanager", "dockermanager", "external_port_enabled") == "1", + lan_ip = uci:get("network", "lan", "ipaddr") or "" + }) end function dockermanager_open() - local http = require "luci.http" - if http.formvalue("enable_port") == "1" then - enable_external_port() - end - - local entry_url = dockermanager_entry_url() - if entry_url then - http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(entry_url)) - return - end - - if http.formvalue("enable_port") == "1" then - render_open_failed() - return - end - render_enable_port_confirm() + compat():open("dockermanager") end diff --git a/luci-app-dockermanager/luasrc/view/dockermanager_status.htm b/luci-app-dockermanager/luasrc/view/dockermanager_status.htm index 84598187e..8c4833cb8 100644 --- a/luci-app-dockermanager/luasrc/view/dockermanager_status.htm +++ b/luci-app-dockermanager/luasrc/view/dockermanager_status.htm @@ -8,7 +8,7 @@ var status = st.running ? '
<%:The Docker Manager service is running.%>' : '
<%:The Docker Manager service is not running.%>'; - if (st.proxy_prefix_enabled && st.linkeasefull_running) + if (st.proxy_prefix_enabled && st.app_entry_running) { status = status + '
<%:Access mode: LinkEase Desktop proxy.%>'; } diff --git a/luci-app-fastnet/Makefile b/luci-app-fastnet/Makefile index 788289695..2e2cfcfe1 100644 --- a/luci-app-fastnet/Makefile +++ b/luci-app-fastnet/Makefile @@ -6,11 +6,10 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for fastnet -LUCI_DEPENDS:=+fastnet +LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry LUCI_PKGARCH:=all -PKG_VERSION:=0.7.7-r1 -# PKG_RELEASE MUST be empty for luci.mk -PKG_RELEASE:=2 +PKG_VERSION:=0.7.7 +PKG_RELEASE:=3 LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-fastnet/luasrc/controller/fastnet.lua b/luci-app-fastnet/luasrc/controller/fastnet.lua index 6a66d1113..00bb349b8 100644 --- a/luci-app-fastnet/luasrc/controller/fastnet.lua +++ b/luci-app-fastnet/luasrc/controller/fastnet.lua @@ -1,44 +1,34 @@ module("luci.controller.fastnet", package.seeall) function index() - local fs = require "nixio.fs" - if not fs.access("/etc/config/fastnet") then - return - end + if not nixio.fs.access("/etc/config/fastnet") then return end entry({"admin", "services", "fastnet"}, cbi("fastnet"), _("FastNet"), 50).dependent = true entry({"admin", "services", "fastnet", "status"}, call("action_status")).leaf = true + local open = entry({"admin", "services", "fastnet", "open"}, call("action_open")) + open.leaf = true + open.dependent = false + open.sysauth = false +end + +local function compat() + local dispatcher = require "luci.dispatcher" + return require("luci.model.linkease.apps_compat").new({ + http = require "luci.http", + resolver = require("luci.model.linkease.apps_openwrt").new(), + auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth") + }) end function action_status() local sys = require "luci.sys" local uci = require "luci.model.uci".cursor() - local http = require "luci.http" - - local function get_host() - local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or "" - host = host:gsub(":%d+$", "") - if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then - host = http.getenv("SERVER_ADDR") or "localhost" - end - return host - end - - local running = (sys.call("pidof FastNet >/dev/null") == 0) - local host = get_host() - local port = uci:get_first("fastnet", "fastnet", "port") or "3200" - local token = uci:get_first("fastnet", "fastnet", "token") or "" - - local url = "http://" .. host .. ":" .. port .. "/" - if token ~= "" then - url = url .. "?token=" .. token - end - - http.prepare_content("application/json") - http.write_json({ - running = running, - host = host, - port = port, - url = url + compat():legacy_status("fastnet", { + running = sys.call("pidof FastNet >/dev/null") == 0, + port = uci:get_first("fastnet", "fastnet", "port") or "3200" }) end + +function action_open() + compat():open("fastnet") +end diff --git a/luci-app-fastnet/luasrc/model/cbi/fastnet.lua b/luci-app-fastnet/luasrc/model/cbi/fastnet.lua index defa1a9eb..628a08086 100644 --- a/luci-app-fastnet/luasrc/model/cbi/fastnet.lua +++ b/luci-app-fastnet/luasrc/model/cbi/fastnet.lua @@ -1,27 +1,12 @@ local sys = require "luci.sys" -local uci = require "luci.model.uci".cursor() -local http = require "luci.http" +local dispatcher = require "luci.dispatcher" local m = Map("fastnet", translate("FastNet")) m.description = translate("FastNet provides network testing tools and a Web UI.") -local function get_host() - local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or "" - host = host:gsub(":%d+$", "") - if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then - host = http.getenv("SERVER_ADDR") or "localhost" - end - return host -end - local st = m:section(SimpleSection, translate("Status")) local running = (sys.call("pidof FastNet >/dev/null") == 0) -local listen_port = uci:get_first("fastnet", "fastnet", "port") or "3200" -local token = uci:get_first("fastnet", "fastnet", "token") or "" -local url = "http://" .. get_host() .. ":" .. listen_port .. "/" -if token ~= "" then - url = url .. "?token=" .. token -end +local url = dispatcher.build_url("admin", "services", "linkease_apps", "open") .. "?id=fastnet" st.template = "fastnet/status" st.running = running diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index 99e388620..832a4c7b0 100644 --- a/luci-app-fwlive/Makefile +++ b/luci-app-fwlive/Makefile @@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter LUCI_PKGARCH:=all PKG_VERSION:=0.1.45 -PKG_RELEASE:=34 +PKG_RELEASE:=35 # Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI). ifdef SOURCE_DATE_EPOCH diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js index 24f436907..643e0df90 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js @@ -36,7 +36,9 @@ function chipValueNodes(spec, val) { return [ label + ': ', E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]), - ' contains ', + ' ', + _('contains'), + ' ', valueNode ]; diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js index 650e52b8e..279bf5119 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/css.js @@ -11,5 +11,5 @@ * GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js */ return baseclass.extend({ - styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfont-weight: 600;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-high);\n\twhite-space: nowrap;\n}\n.fwlive-display-controls {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmin-width: 0;\n\tflex: 1 1 auto;\n}\n.fwlive-display-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0;\n\tfont-size: 0.92em;\n\twhite-space: nowrap;\n}\n.fwlive-display-ctl input[type=\"checkbox\"] {\n\tmargin: 0;\n\tflex-shrink: 0;\n}\n.fwlive-display-ctl select {\n\tmargin-left: 0;\n}\n.fwlive-display-ctl.fwlive-hidden {\n\tdisplay: none;\n}\n/* Draw our own disclosure arrow — themes that restyle summary drop the native marker. */\n.fwlive-more-filters > summary,\n.fwlive-help > summary {\n\tcursor: pointer;\n\tlist-style: none;\n}\n.fwlive-more-filters > summary::-webkit-details-marker,\n.fwlive-help > summary::-webkit-details-marker { display: none; }\n.fwlive-more-filters > summary::before,\n.fwlive-help > summary::before {\n\tcontent: \"▸ \";\n\tcolor: var(--text-color-medium);\n}\n.fwlive-more-filters[open] > summary::before,\n.fwlive-help[open] > summary::before { content: \"▾ \"; }\n.fwlive-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tfont-size: 0.92em;\n\tflex-wrap: wrap;\n}\n.fwlive-ctl select { margin-left: 4px; }\n#fwlive-limit { width: auto; min-width: 4.5em; }\n.fwlive-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; }\n.fwlive-status { margin: 0; color: var(--text-color-medium); font-size: 0.9em; }\n.fwlive-status-paused { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-status-error { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-hint-line {\n\tmargin: 0 0 10px;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-empty-title {\n\tfont-weight: 600;\n\tmargin: 0 0 6px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-find-row .fwlive-grid-core { margin-bottom: 0; }\n.fwlive-proto-pair {\n\tdisplay: grid;\n\tgrid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);\n\tgap: 6px;\n\talign-items: stretch;\n\tmin-width: 0;\n}\n.fwlive-proto-pair select,\n.fwlive-proto-pair input {\n\twidth: 100%;\n\tmin-width: 0;\n}\n@media (max-width: 520px) {\n\t.fwlive-proto-pair { grid-template-columns: 1fr; }\n}\n.fwlive-scroll {\n\tmax-height: 78vh;\n\tmax-height: 78dvh;\n\toverflow: auto;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n\twidth: 100%;\n\tscrollbar-gutter: stable;\n}\n#fwlive-table { margin: 0; width: 100%; table-layout: auto; border-collapse: collapse; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table { table-layout: fixed; width: 100%; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-action { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-time { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-iface { width: 5.5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-proto { width: 4rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-rule { width: 12rem; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-scroll #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-time { width: 11rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-action { width: 4.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-rule { width: 8rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-in,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-out { width: 3.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dir { width: 3rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-proto { width: 3.25rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-src,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dst { width: 9rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-sport,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dport { width: 3.75rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-flags { width: 5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-len { width: 3.25rem; }\n.fwlive-scroll.fwlive-msg-oneline #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-scroll.fwlive-msg-oneline tbody td { vertical-align: middle; }\n#fwlive-table thead th {\n\tposition: sticky;\n\ttop: 0;\n\tz-index: 2;\n\tbackground: var(--background-color-low);\n\tborder-bottom: 2px solid var(--border-color-high);\n\twhite-space: nowrap;\n\tpadding: 5px 8px;\n\tfont-size: 0.9em;\n\tfont-weight: 600;\n\tvertical-align: top;\n\ttext-align: left;\n}\n#fwlive-table thead th.fwlive-port,\n#fwlive-table thead th.fwlive-len {\n\ttext-align: right;\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table thead th,\n.fwlive-map[data-view=\"simple\"] #fwlive-table tbody td {\n\tvertical-align: top;\n}\n#fwlive-table thead th.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n}\n#fwlive-table tbody td {\n\tpadding: 5px 8px;\n\tborder-bottom: 1px solid var(--border-color-low);\n\tvertical-align: top;\n\tfont-size: 0.92em;\n}\n.fwlive-row-alt td {\n\tbackground: #f9f9f9;\n\tbackground: var(--fwlive-bg-medium);\n}\n.fwlive-time, .fwlive-addr, .fwlive-port, .fwlive-len {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\twhite-space: nowrap;\n}\n.fwlive-port, .fwlive-len { text-align: right; }\n.fwlive-rule, .fwlive-iface, .fwlive-proto, .fwlive-dir, .fwlive-flags { white-space: nowrap; }\n.fwlive-rule { font-size: 0.88em; }\n.fwlive-rule-link {\n\tcolor: var(--primary-color-high);\n\ttext-decoration: none;\n\tfont-weight: 500;\n}\n.fwlive-rule-link:hover { text-decoration: underline; }\n.fwlive-iface-badge {\n\tdisplay: inline-block;\n\tpadding: 1px 6px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.85em;\n\ttext-decoration: none;\n}\n.fwlive-iface-badge:hover {\n\tbackground: var(--background-color-medium);\n\tborder-color: var(--primary-color-medium);\n}\n.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n\twhite-space: nowrap;\n}\n.fwlive-deny { color: var(--fwlive-deny-color); }\n.fwlive-pass { color: var(--fwlive-pass-color); }\n#fwlive-table td.fwlive-action.fwlive-pass,\n#fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n#fwlive-table td.fwlive-action.fwlive-deny,\n#fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n}\n.fwlive-unknown { color: var(--text-color-medium); font-weight: 500; }\n.fwlive-message {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-scroll.fwlive-msg-wrap .fwlive-message-wrap {\n\tdisplay: block;\n\tmin-width: 16em;\n\tmax-width: 42em;\n\twhite-space: normal;\n\tword-break: break-word;\n\tword-wrap: break-word;\n}\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twhite-space: nowrap;\n\tmax-width: none;\n\tmin-width: 32em;\n}\n#fwlive-table th.fwlive-th-message,\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twidth: 99%;\n}\n.fwlive-empty {\n\tmargin: 12px 0;\n\tpadding: 10px;\n\tbackground: var(--background-color-medium);\n\tborder: 1px dashed var(--border-color-high);\n}\n.fwlive-empty-muted {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin: 0 0 8px;\n}\n.fwlive-consent {\n\tmargin: 0 0 12px;\n\tpadding: 10px 12px;\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n}\n.fwlive-consent-list {\n\tmargin: 0 0 10px;\n\tpadding-left: 1.25em;\n}\n.fwlive-consent-list li {\n\tmargin: 0 0 4px;\n}\n.fwlive-consent-check {\n\tmargin: 0 0 10px;\n\tfont-size: 0.92em;\n}\n.fwlive-consent-actions {\n\tmargin: 0;\n}\n.fwlive-logging-bar {\n\tdisplay: contents;\n}\n.fwlive-logging-status {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin-right: 4px;\n}\n.fwlive-logging-notice {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-filter-link {\n\tcolor: inherit;\n\ttext-decoration: underline;\n\ttext-decoration-style: dotted;\n\tcursor: pointer;\n}\n.fwlive-filter-link:hover { color: var(--primary-color-high); }\n.fwlive-chips {\n\tdisplay: none;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0 0 10px;\n}\n.fwlive-chip-label {\n\tline-height: 1.3;\n}\n.fwlive-chip-not {\n\tfont-weight: 700;\n}\n.fwlive-chip {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 4px;\n\tpadding: 2px 8px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.88em;\n}\n.fwlive-chip-negated {\n\tborder-style: dashed;\n\tbackground: var(--background-color-medium);\n}\n.fwlive-chip-include {\n}\n.fwlive-chip-polarity {\n\tfont-weight: 700;\n\tfont-size: 0.78em;\n\ttext-transform: uppercase;\n\tletter-spacing: 0.03em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-chip-strike {\n\ttext-decoration: line-through;\n\ttext-decoration-thickness: 1.5px;\n}\n.fwlive-chip-sym {\n\tfont-weight: 700;\n\tline-height: 1;\n\tmin-width: 0.9em;\n\ttext-align: center;\n}\n.fwlive-chip-sym-light {\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.95em;\n}\n#fwlive-row-tint { width: auto; min-width: 9.5em; }\n/* labels (default) */\n.fwlive-chips-labels .fwlive-chip-include {\n\tborder-left: 3px solid var(--primary-color-medium, var(--primary-color-high, var(--border-color-high)));\n}\n.fwlive-chips-labels .fwlive-chip-negated .fwlive-chip-not {\n\tcolor: var(--text-color-high);\n}\n.fwlive-chip-invert-wrap {\n\tposition: relative;\n\tdisplay: inline-flex;\n}\n.fwlive-chip-invert-wrap::before {\n\tcontent: attr(data-tip);\n\tposition: absolute;\n\tbottom: calc(100% + 6px);\n\tleft: 50%;\n\ttransform: translateX(-50%);\n\twhite-space: nowrap;\n\tpadding: 4px 8px;\n\tfont-size: 0.85em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-high);\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbox-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);\n\topacity: 0;\n\tvisibility: hidden;\n\tpointer-events: none;\n\tz-index: 20;\n\ttransition: opacity 0.15s ease 1.5s, visibility 0s linear 2s;\n}\n.fwlive-chip-invert-wrap:hover::before,\n.fwlive-chip-invert-wrap:focus-within::before {\n\topacity: 1;\n\tvisibility: visible;\n\ttransition: opacity 0.12s ease 0.35s, visibility 0s;\n}\n.fwlive-chip-invert {\n\tcolor: var(--text-color-medium);\n\tbackground: none;\n\tborder: none;\n\tpadding: 0;\n\tfont: inherit;\n\tfont-weight: 700;\n\tline-height: 1;\n\tcursor: pointer;\n}\n.fwlive-chip-invert:hover { color: var(--primary-color-high); }\n.fwlive-chip-remove {\n\tcolor: var(--text-color-medium);\n\ttext-decoration: none;\n\tfont-weight: 700;\n\tline-height: 1;\n}\n.fwlive-chip-remove:hover { color: var(--fwlive-deny-color); }\n.fwlive-chip-clear {\n\tfont-size: 0.88em;\n\tmargin-left: 4px;\n}\n.fwlive-flood {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-adaptive {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-summary {\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-summary-head {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 10px;\n}\n.fwlive-summary-body {\n\tmargin: 8px 0 0;\n\tfont: inherit;\n\twhite-space: pre-wrap;\n}\n.fwlive-tint-warn {\n\tdisplay: none;\n\tcolor: var(--warn-color-high);\n\tfont-size: 0.78em;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tpadding: 2px 8px;\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 999px;\n\tbackground: color-mix(in srgb, var(--warn-color-high) 10%, transparent);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-msg-group { display: none; }\n@media (max-width: 850px) {\n\t.fwlive-title-status {\n\t\tflex-basis: 100%;\n\t\tmargin-left: 0;\n\t}\n\t.fwlive-watch-group {\n\t\tborder-left: 0;\n\t\tpadding-left: 0;\n\t\tpadding-right: 10px;\n\t}\n\t.fwlive-display-bar {\n\t\talign-items: flex-start;\n\t}\n}\n.fwlive-help-row {\n\tdisplay: flex;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 12px;\n\tmargin: 10px 0 0;\n}\n.fwlive-help {\n\tmargin: 0;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmin-width: 0;\n\tflex: 1;\n}\n.fwlive-help ul { margin: 6px 0 0 1.2em; padding: 0; }\n.fwlive-help li { margin: 4px 0; }\n.fwlive-build {\n\tflex-shrink: 0;\n\tfont-size: 0.78em;\n\tcolor: var(--text-color-low, var(--text-color-medium));\n\tfont-variant-numeric: tabular-nums;\n\tuser-select: all;\n\twhite-space: nowrap;\n}\n.fwlive-backend {\n\tfont-size: 0.65em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-medium);\n\tmargin-left: 8px;\n\tvertical-align: middle;\n}\n.fwlive-backend.fwlive-backend-warn {\n\tcolor: var(--warn-color-high);\n\tfont-weight: 600;\n}\n.fwlive-filter-panel { margin-bottom: 12px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-core {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"simple\"] .fwlive-more-filters { margin-top: 8px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-extra {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n\tmargin-top: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters > summary { display: none; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-filter-panel {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-core,\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-extra,\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters { display: contents; }\n.fwlive-row-clickable { cursor: pointer; }\n.fwlive-row-expanded td { border-bottom-color: transparent; }\n.fwlive-msg-expand td {\n\tbackground: var(--background-color-low);\n\tpadding: 8px 12px 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n}\n.fwlive-msg-expand-label {\n\tfont-size: 0.82em;\n\tcolor: var(--text-color-medium);\n\tmargin-bottom: 4px;\n\tfont-weight: 600;\n}\n.fwlive-msg-expand-body {\n\tmargin: 0;\n\twhite-space: pre-wrap;\n\tword-break: break-word;\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-flow-arrow {\n\tcolor: var(--text-color-low);\n\tfont-weight: 700;\n}\n.fwlive-flow-cell { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-iface-badge {\n\tborder-radius: 10px;\n\tpadding: 2px 8px;\n}\n" + styleText: "\n/* SPDX-License-Identifier: Apache-2.0 */\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfont-weight: 600;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-high);\n\twhite-space: nowrap;\n}\n.fwlive-display-controls {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmin-width: 0;\n\tflex: 1 1 auto;\n}\n.fwlive-display-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0;\n\tfont-size: 0.92em;\n\twhite-space: nowrap;\n}\n.fwlive-display-ctl input[type=\"checkbox\"] {\n\tmargin: 0;\n\tflex-shrink: 0;\n}\n.fwlive-display-ctl select {\n\tmargin-left: 0;\n}\n.fwlive-display-ctl.fwlive-hidden {\n\tdisplay: none;\n}\n/* Draw our own disclosure arrow — themes that restyle summary drop the native marker. */\n.fwlive-more-filters > summary,\n.fwlive-help > summary {\n\tcursor: pointer;\n\tlist-style: none;\n}\n.fwlive-more-filters > summary::-webkit-details-marker,\n.fwlive-help > summary::-webkit-details-marker { display: none; }\n.fwlive-more-filters > summary::before,\n.fwlive-help > summary::before {\n\tcontent: \"▸ \";\n\tcolor: var(--text-color-medium);\n}\n.fwlive-more-filters[open] > summary::before,\n.fwlive-help[open] > summary::before { content: \"▾ \"; }\n.fwlive-ctl {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tfont-size: 0.92em;\n\tflex-wrap: wrap;\n}\n.fwlive-ctl select { margin-left: 4px; }\n#fwlive-limit { width: auto; min-width: 4.5em; }\n.fwlive-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 8px; margin-bottom: 12px; }\n.fwlive-status { margin: 0; color: var(--text-color-medium); font-size: 0.9em; }\n.fwlive-status-paused { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-status-error { color: var(--warn-color-high); font-weight: 600; }\n.fwlive-hint-line {\n\tmargin: 0 0 10px;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-empty-title {\n\tfont-weight: 600;\n\tmargin: 0 0 6px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-find-row .fwlive-grid-core { margin-bottom: 0; }\n.fwlive-proto-pair {\n\tdisplay: grid;\n\tgrid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);\n\tgap: 6px;\n\talign-items: stretch;\n\tmin-width: 0;\n}\n.fwlive-proto-pair select,\n.fwlive-proto-pair input {\n\twidth: 100%;\n\tmin-width: 0;\n}\n@media (max-width: 520px) {\n\t.fwlive-proto-pair { grid-template-columns: 1fr; }\n}\n.fwlive-scroll {\n\tmax-height: 78vh;\n\tmax-height: 78dvh;\n\toverflow: auto;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n\twidth: 100%;\n\tscrollbar-gutter: stable;\n}\n#fwlive-table { margin: 0; width: 100%; table-layout: auto; border-collapse: collapse; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table { table-layout: fixed; width: 100%; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-action { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-time { width: 5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-iface { width: 5.5rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-proto { width: 4rem; }\n.fwlive-map[data-view=\"simple\"] col.fwlive-col-rule { width: 12rem; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-scroll #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-time { width: 11rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-action { width: 4.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-rule { width: 8rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-in,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-iface-out { width: 3.5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dir { width: 3rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-proto { width: 3.25rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-src,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dst { width: 9rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-sport,\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-dport { width: 3.75rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-flags { width: 5rem; }\n.fwlive-map[data-view=\"detailed\"] col.fwlive-col-len { width: 3.25rem; }\n.fwlive-scroll.fwlive-msg-oneline #fwlive-table {\n\twidth: max-content;\n\tmin-width: 100%;\n}\n.fwlive-scroll.fwlive-msg-oneline tbody td { vertical-align: middle; }\n#fwlive-table thead th {\n\tposition: sticky;\n\ttop: 0;\n\tz-index: 2;\n\tbackground: var(--background-color-low);\n\tborder-bottom: 2px solid var(--border-color-high);\n\twhite-space: nowrap;\n\tpadding: 5px 8px;\n\tfont-size: 0.9em;\n\tfont-weight: 600;\n\tvertical-align: top;\n\ttext-align: left;\n}\n#fwlive-table thead th.fwlive-port,\n#fwlive-table thead th.fwlive-len {\n\ttext-align: right;\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table thead th,\n.fwlive-map[data-view=\"simple\"] #fwlive-table tbody td {\n\tvertical-align: top;\n}\n#fwlive-table thead th.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n}\n#fwlive-table tbody td {\n\tpadding: 5px 8px;\n\tborder-bottom: 1px solid var(--border-color-low);\n\tvertical-align: top;\n\tfont-size: 0.92em;\n}\n.fwlive-row-alt td {\n\tbackground: #f9f9f9;\n\tbackground: var(--fwlive-bg-medium);\n}\n.fwlive-time, .fwlive-addr, .fwlive-port, .fwlive-len {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\twhite-space: nowrap;\n}\n.fwlive-port, .fwlive-len { text-align: right; }\n.fwlive-rule, .fwlive-iface, .fwlive-proto, .fwlive-dir, .fwlive-flags { white-space: nowrap; }\n.fwlive-rule { font-size: 0.88em; }\n.fwlive-rule-link {\n\tcolor: var(--primary-color-high);\n\ttext-decoration: none;\n\tfont-weight: 500;\n}\n.fwlive-rule-link:hover { text-decoration: underline; }\n.fwlive-iface-badge {\n\tdisplay: inline-block;\n\tpadding: 1px 6px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.85em;\n\ttext-decoration: none;\n}\n.fwlive-iface-badge:hover {\n\tbackground: var(--background-color-medium);\n\tborder-color: var(--primary-color-medium);\n}\n.fwlive-action {\n\tfont-weight: 700;\n\ttext-transform: lowercase;\n\twhite-space: nowrap;\n}\n.fwlive-deny { color: var(--fwlive-deny-color); }\n.fwlive-pass { color: var(--fwlive-pass-color); }\n#fwlive-table td.fwlive-action.fwlive-pass,\n#fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n#fwlive-table td.fwlive-action.fwlive-deny,\n#fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(70, 165, 70, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(70, 165, 70, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(202, 60, 60, 0.12);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(202, 60, 60, 0.18);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(70, 165, 70, 0.20);\n}\n.fwlive-map[data-row-tint=\"classic\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(202, 60, 60, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 12%, var(--fwlive-bg-medium));\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-pass-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n\tbackground: color-mix(in srgb, var(--fwlive-deny-color) 20%, transparent);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass td {\n\tbackground: rgba(13, 148, 136, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass.fwlive-row-alt td {\n\tbackground: rgba(13, 148, 136, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny td {\n\tbackground: rgba(194, 65, 12, 0.12);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny.fwlive-row-alt td {\n\tbackground: rgba(194, 65, 12, 0.18);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-pass:hover td {\n\tbackground: rgba(13, 148, 136, 0.20);\n}\n.fwlive-map[data-row-tint=\"accessible\"][data-tint-fallback=\"1\"] #fwlive-table tbody tr.fwlive-row-deny:hover td {\n\tbackground: rgba(194, 65, 12, 0.20);\n}\n.fwlive-unknown { color: var(--text-color-medium); font-weight: 500; }\n.fwlive-message {\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-scroll.fwlive-msg-wrap .fwlive-message-wrap {\n\tdisplay: block;\n\tmin-width: 16em;\n\tmax-width: 42em;\n\twhite-space: normal;\n\tword-break: break-word;\n\tword-wrap: break-word;\n}\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twhite-space: nowrap;\n\tmax-width: none;\n\tmin-width: 32em;\n}\n#fwlive-table th.fwlive-th-message,\n.fwlive-scroll.fwlive-msg-oneline .fwlive-message {\n\twidth: 99%;\n}\n.fwlive-empty {\n\tmargin: 12px 0;\n\tpadding: 10px;\n\tbackground: var(--background-color-medium);\n\tborder: 1px dashed var(--border-color-high);\n}\n.fwlive-empty-muted {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin: 0 0 8px;\n}\n.fwlive-consent {\n\tmargin: 0 0 12px;\n\tpadding: 10px 12px;\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n}\n.fwlive-consent-list {\n\tmargin: 0 0 10px;\n\tpadding-left: 1.25em;\n}\n.fwlive-consent-list li {\n\tmargin: 0 0 4px;\n}\n.fwlive-consent-check {\n\tmargin: 0 0 10px;\n\tfont-size: 0.92em;\n}\n.fwlive-consent-actions {\n\tmargin: 0;\n}\n.fwlive-logging-bar {\n\tdisplay: contents;\n}\n.fwlive-logging-status {\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmargin-right: 4px;\n}\n.fwlive-logging-notice {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-filter-link {\n\tcolor: inherit;\n\ttext-decoration: underline;\n\ttext-decoration-style: dotted;\n\tcursor: pointer;\n}\n.fwlive-filter-link:hover { color: var(--primary-color-high); }\n.fwlive-chips {\n\tdisplay: none;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 6px;\n\tmargin: 0 0 10px;\n}\n.fwlive-chip-label {\n\tline-height: 1.3;\n}\n.fwlive-chip-not {\n\tfont-weight: 700;\n}\n.fwlive-chip {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 4px;\n\tpadding: 2px 8px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tfont-size: 0.88em;\n}\n.fwlive-chip-negated {\n\tborder-style: dashed;\n\tbackground: var(--background-color-medium);\n}\n.fwlive-chip-include {\n}\n.fwlive-chip-polarity {\n\tfont-weight: 700;\n\tfont-size: 0.78em;\n\ttext-transform: uppercase;\n\tletter-spacing: 0.03em;\n\tcolor: var(--text-color-medium);\n}\n.fwlive-chip-strike {\n\ttext-decoration: line-through;\n\ttext-decoration-thickness: 1.5px;\n}\n.fwlive-chip-sym {\n\tfont-weight: 700;\n\tline-height: 1;\n\tmin-width: 0.9em;\n\ttext-align: center;\n}\n.fwlive-chip-sym-light {\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.95em;\n}\n#fwlive-row-tint { width: auto; min-width: 9.5em; }\n/* labels (default) */\n.fwlive-chips-labels .fwlive-chip-include {\n\tborder-left: 3px solid var(--primary-color-medium, var(--primary-color-high, var(--border-color-high)));\n}\n.fwlive-chips-labels .fwlive-chip-negated .fwlive-chip-not {\n\tcolor: var(--text-color-high);\n}\n.fwlive-chip-invert-wrap {\n\tposition: relative;\n\tdisplay: inline-flex;\n}\n.fwlive-chip-invert-wrap::before {\n\tcontent: attr(data-tip);\n\tposition: absolute;\n\tbottom: calc(100% + 6px);\n\tleft: 50%;\n\ttransform: translateX(-50%);\n\twhite-space: nowrap;\n\tpadding: 4px 8px;\n\tfont-size: 0.85em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-high);\n\tbackground: var(--background-color-high);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbox-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);\n\topacity: 0;\n\tvisibility: hidden;\n\tpointer-events: none;\n\tz-index: 20;\n\ttransition: opacity 0.15s ease 1.5s, visibility 0s linear 2s;\n}\n.fwlive-chip-invert-wrap:hover::before,\n.fwlive-chip-invert-wrap:focus-within::before {\n\topacity: 1;\n\tvisibility: visible;\n\ttransition: opacity 0.12s ease 0.35s, visibility 0s;\n}\n.fwlive-chip-invert {\n\tcolor: var(--text-color-medium);\n\tbackground: none;\n\tborder: none;\n\tpadding: 0;\n\tfont: inherit;\n\tfont-weight: 700;\n\tline-height: 1;\n\tcursor: pointer;\n}\n.fwlive-chip-invert:hover { color: var(--primary-color-high); }\n.fwlive-chip-remove {\n\tcolor: var(--text-color-medium);\n\ttext-decoration: none;\n\tfont-weight: 700;\n\tline-height: 1;\n}\n.fwlive-chip-remove:hover { color: var(--fwlive-deny-color); }\n.fwlive-chip-clear {\n\tfont-size: 0.88em;\n\tmargin-left: 4px;\n}\n.fwlive-flood {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-adaptive {\n\tdisplay: none;\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n\tfont-size: 0.92em;\n}\n.fwlive-summary {\n\tmargin: 0 0 10px;\n\tpadding: 8px 12px;\n\tbackground: var(--background-color-low);\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tcolor: var(--text-color-high);\n}\n.fwlive-summary-head {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 10px;\n}\n.fwlive-summary-body {\n\tmargin: 8px 0 0;\n\tfont: inherit;\n\twhite-space: pre-wrap;\n}\n.fwlive-tint-warn {\n\tdisplay: none;\n\tcolor: var(--warn-color-high);\n\tfont-size: 0.78em;\n\tfont-weight: 600;\n\twhite-space: nowrap;\n\tpadding: 2px 8px;\n\tborder: 1px solid var(--warn-color-high);\n\tborder-radius: 999px;\n\tbackground: color-mix(in srgb, var(--warn-color-high) 10%, transparent);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-msg-group { display: none; }\n@media (max-width: 850px) {\n\t.fwlive-title-status {\n\t\tflex-basis: 100%;\n\t\tmargin-left: 0;\n\t}\n\t.fwlive-watch-group {\n\t\tborder-left: 0;\n\t\tpadding-left: 0;\n\t\tpadding-right: 10px;\n\t}\n\t.fwlive-display-bar {\n\t\talign-items: flex-start;\n\t}\n}\n.fwlive-help-row {\n\tdisplay: flex;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 12px;\n\tmargin: 10px 0 0;\n}\n.fwlive-help {\n\tmargin: 0;\n\tfont-size: 0.92em;\n\tcolor: var(--text-color-medium);\n\tmin-width: 0;\n\tflex: 1;\n}\n.fwlive-help ul { margin: 6px 0 0 1.2em; padding: 0; }\n.fwlive-help li { margin: 4px 0; }\n.fwlive-build {\n\tflex-shrink: 0;\n\tfont-size: 0.78em;\n\tcolor: var(--text-color-low, var(--text-color-medium));\n\tfont-variant-numeric: tabular-nums;\n\tuser-select: all;\n\twhite-space: nowrap;\n}\n.fwlive-backend {\n\tfont-size: 0.65em;\n\tfont-weight: normal;\n\tcolor: var(--text-color-medium);\n\tmargin-left: 8px;\n\tvertical-align: middle;\n}\n.fwlive-backend.fwlive-backend-warn {\n\tcolor: var(--warn-color-high);\n\tfont-weight: 600;\n}\n.fwlive-filter-panel { margin-bottom: 12px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-core {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"simple\"] .fwlive-more-filters { margin-top: 8px; }\n.fwlive-map[data-view=\"simple\"] .fwlive-grid-extra {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n\tmargin-top: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters > summary { display: none; }\n.fwlive-map[data-view=\"detailed\"] .fwlive-filter-panel {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(4, minmax(140px, 1fr));\n\tgap: 8px;\n}\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-core,\n.fwlive-map[data-view=\"detailed\"] .fwlive-grid-extra,\n.fwlive-map[data-view=\"detailed\"] .fwlive-more-filters { display: contents; }\n.fwlive-row-clickable { cursor: pointer; }\n.fwlive-row-expanded td { border-bottom-color: transparent; }\n.fwlive-msg-expand td {\n\tbackground: var(--background-color-low);\n\tpadding: 8px 12px 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n}\n.fwlive-msg-expand-label {\n\tfont-size: 0.82em;\n\tcolor: var(--text-color-medium);\n\tmargin-bottom: 4px;\n\tfont-weight: 600;\n}\n.fwlive-msg-expand-body {\n\tmargin: 0;\n\twhite-space: pre-wrap;\n\tword-break: break-word;\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n\tfont-size: 0.85em;\n\tcolor: var(--text-color-high);\n}\n.fwlive-flow-arrow {\n\tcolor: var(--text-color-low);\n\tfont-weight: 700;\n}\n.fwlive-flow-cell { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; }\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-pass a.fwlive-filter-link {\n\tcolor: var(--fwlive-pass-color);\n}\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny,\n.fwlive-map[data-view=\"simple\"] #fwlive-table td.fwlive-action.fwlive-deny a.fwlive-filter-link {\n\tcolor: var(--fwlive-deny-color);\n}\n.fwlive-iface-badge {\n\tborder-radius: 10px;\n\tpadding: 2px 8px;\n}\n" }); diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css index 59b19aab0..bbab162d4 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/fwlive.css @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: Apache-2.0 */ .fwlive-map { max-width: none; width: 100%; diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js index 4edef030f..a7663ab55 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js @@ -5,81 +5,103 @@ /** * Shared classify logic mirrors core/fwlive-log.js CLASSIFY_SPEC — keep in sync - * (gen-luci-wrapper.js gates full-spec drift; ./scripts/gen-all.sh verifies). + * (gen-luci-wrapper.js gates full-spec + regex drift; ./scripts/gen-all.sh verifies). * LuCI-only helpers live in the @fwlive-codegen:luci-preserve region. */ +const CLASSIFY_SPEC = { + glueKeys: [ + 'IN', + 'OUT', + 'SRC', + 'DST', + 'PROTO', + 'SPT', + 'DPT', + 'LEN', + 'MAC', + 'TYPE', + 'CODE', + 'TTL', + 'TOS', + 'PREC', + 'DF' + ], + nonFirewallPrefixes: [ + 'dnsmasq', + 'procd', + 'ubusd', + 'netifd', + 'odhcpd', + 'logd', + 'dropbear', + 'uhttpd', + 'hostapd', + 'wpad' + ], + nonFirewallPrefixHyphenContinuation: true, + firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'], + actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'], + rules: [ + { + or: [ + { and: [{ kv: ['SRC'] }, { kv: ['DST'] }] }, + { + and: [ + { kvAny: ['IN', 'OUT'] }, + { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } + ] + }, + { and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] } + ] + }, + { and: [{ hint: true }, { action: 'known' }] }, + { and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] } + ] +}; + +function wordPattern(words) { + const alt = words.join('|'); + return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i'); +} + +/* ---- spec-derived classification regexes (mirror core/fwlive-log.js) ---- */ +const DENY_CLASS_WORDS = CLASSIFY_SPEC.actionWords.slice(3); +const NON_FIREWALL_PREFIX = new RegExp( + '^(' + + CLASSIFY_SPEC.nonFirewallPrefixes.join('|') + + ')(' + + (CLASSIFY_SPEC.nonFirewallPrefixHyphenContinuation ? '[^A-Za-z0-9_-]' : '[^A-Za-z0-9_]') + + '|$)', + 'i' +); +const FIREWALL_HINT = wordPattern(CLASSIFY_SPEC.firewallHints); +const ACTION_RE = wordPattern(CLASSIFY_SPEC.actionWords); +const DENY_ACTION = wordPattern(DENY_CLASS_WORDS); +const DENY_ACTION_UNDERSCORE = new RegExp( + '(?:^|[^A-Za-z0-9])(?:' + DENY_CLASS_WORDS.join('|') + ')(?:[^A-Za-z0-9]|$)', + 'i' +); +const NETFILTER_KV_GLUE = new RegExp( + '([^\\s])(?=(' + CLASSIFY_SPEC.glueKeys.join('|') + ')=)', + 'g' +); + return baseclass.extend({ - CLASSIFY_SPEC: { - glueKeys: [ - 'IN', - 'OUT', - 'SRC', - 'DST', - 'PROTO', - 'SPT', - 'DPT', - 'LEN', - 'MAC', - 'TYPE', - 'CODE', - 'TTL', - 'TOS', - 'PREC', - 'DF' - ], - nonFirewallPrefixes: [ - 'dnsmasq', - 'procd', - 'ubusd', - 'netifd', - 'odhcpd', - 'logd', - 'dropbear', - 'uhttpd', - 'hostapd', - 'wpad' - ], - nonFirewallPrefixHyphenContinuation: true, - firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'], - actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'], - rules: [ - { - or: [ - { and: [{ kv: ['SRC'] }, { kv: ['DST'] }] }, - { - and: [ - { kvAny: ['IN', 'OUT'] }, - { kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] } - ] - }, - { and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] } - ] - }, - { and: [{ hint: true }, { action: 'known' }] }, - { and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] } - ] - }, + CLASSIFY_SPEC: CLASSIFY_SPEC, TCP_FLAG_TAIL: /\b((?:SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*)(?:\s+[A-Z][A-Z0-9_]*=[^\s]+)*\s*$/i, - NETFILTER_KV_GLUE: - /([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g, - - wordPattern: function (words) { - const alt = words.join('|'); - return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i'); - }, + NETFILTER_KV_GLUE: NETFILTER_KV_GLUE, kvHas: function (msg, key) { return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg); }, - NON_FIREWALL_PREFIX: - /^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_-]|$)/i, - FIREWALL_HINT: /(^|[^A-Za-z0-9_])(fw4|nft|iptables|kernel|firewall)([^A-Za-z0-9_]|$)/i, - ACTION_RE: /(^|[^A-Za-z0-9_])(ACCEPT|ALLOW|PASS|DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, - DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i, - DENY_ACTION_UNDERSCORE: /(?:^|[^A-Za-z0-9])(?:DROP|REJECT|DENY|BLOCK)(?:[^A-Za-z0-9]|$)/i, + NON_FIREWALL_PREFIX: NON_FIREWALL_PREFIX, + FIREWALL_HINT: FIREWALL_HINT, + ACTION_RE: ACTION_RE, + DENY_ACTION: DENY_ACTION, + DENY_ACTION_UNDERSCORE: DENY_ACTION_UNDERSCORE, MAX_DATE_SECONDS: 8640000000000, normalizeNetfilterMessage: function (message) { @@ -288,24 +310,6 @@ return baseclass.extend({ return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())); }, - formatFlowDisplay: function (row) { - const src = row && row.src ? String(row.src) : ''; - const dst = row && row.dst ? String(row.dst) : ''; - const sport = row && row.sport ? String(row.sport) : ''; - const dport = row && row.dport ? String(row.dport) : ''; - let left = src; - let right = dst; - - if (sport) left = left ? left + ':' + sport : ':' + sport; - if (dport) right = right ? right + ':' + dport : ':' + dport; - - if (!left && !right) return '—'; - if (!right) return left; - if (!left) return '→ ' + right; - - return left + ' → ' + right; - }, - formatCell: function (value) { if (value == null || value === '') return ''; @@ -459,14 +463,7 @@ return baseclass.extend({ const p = this.parseFilterValue(val); if (!p.value) return ''; - if (p.negate) { - if (field === 'q' || field === 'src' || field === 'dst') - return '%s: not contains %s'.format(field, p.value); - - return '%s: not %s'.format(field, p.value); - } - - return '%s: %s'.format(field, val); + return _('%s: %s').format(field, val); }, matchesTextField: function (haystack, spec) { diff --git a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js index a9c181f0a..2a4a077df 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js @@ -9,7 +9,7 @@ * * renderToolbar(host, state, callbacks) → void * host - #fwlive-logging-bar strip slot (cleared and rebuilt; element kept) - * state - { loggingStatus, loggingBusy, entriesLength, loggingNotice } + * state - { loggingStatus, loggingBusy, loggingNotice } * callbacks - { onEnable(), onDisable() } * * G Hybrid chrome: when WAN logging is on, one merged control carries status + diff --git a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js index cee5052f4..68cd2ff0a 100644 --- a/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js +++ b/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js @@ -854,7 +854,6 @@ return view.extend({ return { loggingStatus: this.loggingStatus, loggingBusy: this.loggingBusy, - entriesLength: this.entries.length, loggingNotice: this.loggingNotice, showConsent: this.shouldShowLoggingConsent() }; diff --git a/luci-app-fwlive/po/de/luci-app-fwlive.po b/luci-app-fwlive/po/de/luci-app-fwlive.po index 280518e80..017de635e 100644 --- a/luci-app-fwlive/po/de/luci-app-fwlive.po +++ b/luci-app-fwlive/po/de/luci-app-fwlive.po @@ -16,6 +16,10 @@ msgstr "" msgid "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 +msgid "%s: %s" +msgstr "%s: %s" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 msgid "0 matching · %d/%d stored" msgstr "0 matching · %d/%d stored" @@ -643,6 +647,10 @@ msgstr "allow/deny rules, LAN logging, or anything else." msgid "buffer full" msgstr "Puffer voll" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 +msgid "contains" +msgstr "enthält" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81 msgid "default 10/minute" msgstr "Standard 10/Minute" diff --git a/luci-app-fwlive/po/ru/luci-app-fwlive.po b/luci-app-fwlive/po/ru/luci-app-fwlive.po index c2a533646..c984489d3 100644 --- a/luci-app-fwlive/po/ru/luci-app-fwlive.po +++ b/luci-app-fwlive/po/ru/luci-app-fwlive.po @@ -17,6 +17,10 @@ msgstr "" msgid "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 +msgid "%s: %s" +msgstr "%s: %s" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 msgid "0 matching · %d/%d stored" msgstr "0 matching · %d/%d stored" @@ -648,6 +652,10 @@ msgstr "allow/deny rules, LAN logging, or anything else." msgid "buffer full" msgstr "буфер заполнен" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 +msgid "contains" +msgstr "содержит" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81 msgid "default 10/minute" msgstr "по умолчанию 10/мин" diff --git a/luci-app-fwlive/po/templates/luci-app-fwlive.pot b/luci-app-fwlive/po/templates/luci-app-fwlive.pot index d9bd28fec..a6bc4b5f3 100644 --- a/luci-app-fwlive/po/templates/luci-app-fwlive.pot +++ b/luci-app-fwlive/po/templates/luci-app-fwlive.pot @@ -1,791 +1,808 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:926 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1463 msgid "%d matching · %d/%d stored" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1270 +msgid "%s · requested up to %d raw lines · %d firewall messages returned" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1260 +msgid "" +"%s · requested up to %d raw lines · server limited fetch to %d · %d firewall " +"messages returned" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 +msgid "%s: %s" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1465 msgid "0 matching · %d/%d stored" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:251 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:429 msgid "Accessible (teal/orange)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:32 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:35 msgid "Action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:618 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:832 msgid "Administrator access is required to disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:586 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:815 msgid "Administrator access is required to enable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1092 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1651 msgid "Also seen" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:571 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:607 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:804 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:827 msgid "Another change is staged for the firewall; apply or revert it first." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1658 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2439 msgid "Any action" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1085 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1644 msgid "Any protocol" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:113 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1251 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1311 +msgid "Auto" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:179 msgid "Before you enable logging" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:569 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:801 msgid "Cannot enable logging until kernel log modules are installed." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:116 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:182 msgid "Changes:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:250 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:428 msgid "Classic (green/red)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1642 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2170 msgid "Classic uses green/red; Accessible uses teal/orange" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:116 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:146 msgid "Clear all" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1698 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2511 msgid "" "Click a cell to filter · ≠ on a chip to exclude · Ctrl+click a rule for " "firewall settings · in Simple view, click a row for the full message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1719 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2238 msgid "" "Click a row (Time or other non-link cells) to see the full log line (Simple " "view)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:113 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:139 msgid "Click a row for the full message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1720 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2243 msgid "" "Click an IP, action, or protocol to filter; use the Protocol menu (or ≠ on a " "chip) to exclude." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1086 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1645 msgid "Common" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:944 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1479 msgid "Connection lost — retrying…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:609 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:829 msgid "Could not disable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:690 -msgid "Could not refresh logging status; showing the last known state." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:573 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:806 msgid "Could not enable logging." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1680 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:749 +msgid "Could not refresh logging status; showing the last known state." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2466 msgid "Custom protocol (prefix ! to exclude). Overrides the menu when set." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:42 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:45 msgid "DPort" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:814 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1112 msgid "Degraded — sampling" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:847 -msgid "Display limited to %d rows on this device" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:977 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1282 msgid "Degraded — sampling (slow poll RTT; cadence reduced)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:40 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:43 msgid "Destination" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1691 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2496 msgid "Destination IP contains (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1692 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2501 msgid "Destination port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1583 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2354 msgid "Detail" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:37 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:40 msgid "Dir" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:85 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:133 msgid "Disabling…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1616 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1108 +msgid "Display limited to %d rows on this device" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2111 msgid "Display options" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1716 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2203 msgid "Display options on the bar set Limit, row tint, palette, and hostnames." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2240 -msgid "For a responsive table on a weak device, keep Limit at 250 rows or below. The weak-device cap affects rendered rows; the buffer can still retain more." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2245 -msgid "Switching tabs pauses polling; returning performs one catch-up poll. Hostnames are off by default because lookups add work." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250 -msgid "The router log ring may evict older events before fwlive reads them. fwlive cannot recover evicted entries or change forwarding behavior." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:121 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:187 msgid "Does not change:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:138 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:204 msgid "Don’t show this again" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:150 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:230 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:60 msgid "Enable WAN drop/reject logging" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:104 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:164 msgid "Enable WAN zone drop/reject logging (same as Network → Firewall)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:107 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:170 msgid "Enable logging" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1715 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2199 msgid "" "Enable logging turns on WAN zone drop/reject logging only (same as Network → " "Firewall). It does not add rules or log normal LAN browsing." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:107 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:150 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:230 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:60 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:170 msgid "Enabling…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1099 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1658 msgid "Exclude" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:82 msgid "Exclude instead" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:53 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:71 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2125 +msgid "Fetch budget" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2207 +msgid "" +"Fetch budget controls raw log lines per poll. Auto derives from Limit; " +"Manual selects a bounded maximum." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:60 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:88 msgid "Filter by %s" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:136 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:153 msgid "Filter by interface" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:112 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:126 msgid "Filter logs by rule (hint: %s). Ctrl+click to open firewall settings." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1540 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2281 #: openwrt-feed/luci-app-fwlive/root/usr/share/luci/menu.d/luci-app-fwlive.json:3 msgid "Firewall Live View" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:43 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:46 msgid "Flags" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:45 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:48 msgid "Flow" msgstr "" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2217 +msgid "" +"For a responsive table on a weak device, keep Limit at 250 rows or below. " +"The weak-device cap affects rendered rows; the buffer can still retain more." +msgstr "" + #: openwrt-feed/luci-app-fwlive/root/usr/share/rpcd/acl.d/luci-app-fwlive.json:3 msgid "Grant access to firewall live log view" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1712 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2190 msgid "Help" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:895 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1384 +msgid "Hide rows" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1431 msgid "" "High event rate — table refresh is throttled to protect the browser. The " "buffer still updates; refresh will resume automatically." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:984 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1289 msgid "Hostname resolve paused while the router is under load." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1090 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1649 msgid "ICMPv6" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:35 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:38 msgid "IN" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1723 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2254 msgid "" "If Row tint looks missing, the active LuCI theme may omit success/error or " "info/warn CSS variables; fwlive falls back to local colors (air-gapped, no " "data leaves the device)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:205 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:292 msgid "" "If the WAN is quiet, wait for probes or use the optional ping check in " "Help / the enabling-logs guide." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:80 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:82 msgid "Include instead" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:34 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:37 msgid "Interface" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1688 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2481 msgid "Interface (prefix ! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:165 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:232 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:225 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:329 msgid "I’ll configure this under Network → Firewall" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:194 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:264 msgid "Kernel log modules missing" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:195 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:268 msgid "" "Kernel netfilter log modules are missing. Install kmod-nf-log-ipv4 and kmod-" "nf-log-ipv6 (or kmod-nf-log / kmod-nf-log6), then reload the firewall." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:44 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:47 msgid "Len" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1619 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2114 msgid "Limit" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:536 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:718 msgid "Limited diagnostics — timeout command missing" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:210 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:724 +msgid "Live view may be incomplete — a legacy iptables table is registered" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:300 msgid "Logging is off on this router" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:252 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:256 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1251 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1312 +msgid "Manual" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2143 +msgid "Manual still uses server protection and poll cadence" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2212 +msgid "" +"Manual still uses server protection and poll cadence; it changes the fetch " +"budget, not the polling interval." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:348 msgid "Manual test (System → Terminal):" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:46 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:221 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1590 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1595 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2137 +msgid "Maximum raw lines" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:49 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:313 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2366 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2373 msgid "Message" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1686 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2476 msgid "More filters" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:37 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/links.js:42 msgid "Network → Firewall" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:187 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:256 msgid "" "No WAN firewall zone found in /etc/config/firewall. Configure zones under" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:185 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:251 msgid "No WAN zone found" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:163 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:222 msgid "Not now" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:220 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:323 msgid "Nothing changes until you click Enable." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:36 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:39 msgid "OUT" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1610 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2396 msgid "One line" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:206 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:296 msgid "Open firewall zone settings" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:211 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:304 msgid "" "OpenWrt does not write firewall events to the log until you turn logging on. " "Live View only shows what the firewall already logs — it does not add allow/" "deny rules." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1638 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2164 msgid "Palette" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:995 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1560 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1527 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2320 msgid "Pause" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:993 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1526 msgid "Paused" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:38 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:41 msgid "Proto" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1674 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2457 msgid "Protocol — common values" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1656 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2436 msgid "Quick search" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:91 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:105 msgid "Remove filter" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:995 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1527 msgid "Resume" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1631 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2154 msgid "Row tint" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1721 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2248 msgid "" "Row tint shows pass/deny row backgrounds when checked. Choose Classic (green/" "red, default) or Accessible (teal/orange). Action text stays colored either " "way." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1550 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2304 msgid "" "Row tint used a local color fallback because the active LuCI theme did not " "apply pass/deny backgrounds." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:33 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:36 msgid "Rule" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:527 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:709 msgid "Rule labels incomplete — map truncated" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:531 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:712 msgid "Rule labels unavailable" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:529 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:711 msgid "Rule labels unavailable — temp file failed" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:980 -msgid "Server shedding — at most %d log lines per poll." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:982 -msgid "Server truncated this poll (adaptive cap)." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:41 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:44 msgid "SPort" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1650 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1280 +msgid "Server adaptive protection is disabled." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1278 +msgid "Server protection state is unknown." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1285 +msgid "Server shedding — at most %d log lines per poll." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1287 +msgid "Server truncated this poll (adaptive cap)." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2181 msgid "Show hostnames" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1629 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2152 msgid "Show pass/deny row background colors" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1576 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1384 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2419 +msgid "Show rows" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1346 +msgid "Showing a compact summary of the top of the shown sample." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2343 msgid "Simple" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:39 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:42 msgid "Source" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1689 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2486 msgid "Source IP contains (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1690 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2491 msgid "Source port (! to exclude)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1717 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1348 +msgid "Summary data is unavailable; show rows to inspect the sample." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2410 +msgid "Summary mode" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1350 +msgid "Summary was bounded before all categories could be included." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2222 +msgid "" +"Switching tabs pauses polling; returning performs one catch-up poll. " +"Hostnames are off by default because lookups add work." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2232 msgid "" "The rate shown for WAN logging is the firewall zone log_limit. OpenWrt " "defaults to 10/minute when no explicit limit is configured; fwlive does not " "impose this cap." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1714 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2227 +msgid "" +"The router log ring may evict older events before fwlive reads them. fwlive " +"cannot recover evicted entries or change forwarding behavior." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2194 msgid "" "The table updates automatically when your firewall logs traffic. Use Pause " "if it moves too fast." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1551 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2307 msgid "Theme tint fallback" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:31 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/table.js:34 msgid "Time" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:219 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1355 +msgid "Top drops" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1358 +msgid "Top rules" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1352 +msgid "Top talkers" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:318 msgid "" "Turns on WAN zone drop/reject logging (same as Network → Firewall → wan → " "Log). Rate-limited by the zone log_limit (OpenWrt default 10/minute). Normal " "LAN browsing is not logged." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:126 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:192 msgid "Undo:" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1722 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2251 msgid "Use Detail for all columns (flags, length, raw message)." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1564 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1568 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2325 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2331 msgid "View" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:615 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:831 msgid "WAN drop/reject logging is off." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:204 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:285 msgid "" "WAN drop/reject logging is on. Blocked inbound WAN traffic will show up " "here. Normal LAN browsing will not." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:579 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:811 msgid "" "WAN drop/reject logging is on. Blocked inbound traffic should appear here as " "it happens — not normal LAN browsing." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:581 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:813 msgid "WAN logging is already enabled." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:88 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:136 msgid "WAN logging on" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:94 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:145 msgid "WAN logging on (%s). Click to disable." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:77 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:122 msgid "WAN logging unavailable: missing kernel log modules" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:70 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:111 msgid "WAN logging unavailable: no WAN zone" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:203 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:281 msgid "Waiting for firewall events" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:993 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1545 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1526 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2295 msgid "Watching" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1603 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2385 msgid "Wrap" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:123 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:189 msgid "allow/deny rules, LAN logging, or anything else." msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:828 -msgid "buffer full" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81 -msgid "default 10/minute" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:28 -msgid "is" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:823 -msgid "loading buffer" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:923 -msgid "loading…" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:37 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:44 -msgid "not" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1107 -msgid "not AH" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1106 -msgid "not ESP" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1105 -msgid "not GRE" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1102 -msgid "not ICMP" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1099 +msgid "buffer filling" msgstr "" #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1103 -msgid "not ICMPv6" +msgid "buffer full" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1104 -msgid "not IGMP" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 +msgid "contains" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1108 -msgid "not SCTP" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:129 +msgid "default 10/minute" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1100 -msgid "not TCP" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:29 +msgid "is" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1101 -msgid "not UDP" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1098 +msgid "loading buffer" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1666 -msgid "not block" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1461 +msgid "loading…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1665 -msgid "not drop" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1664 -msgid "not pass" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1667 -msgid "not reject" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1668 -msgid "not unknown" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1679 -msgid "or type…" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:830 -msgid "render paused (high rate)" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:817 -msgid "resolve paused (load)" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:832 -msgid "scroll frozen — scroll to top to follow live" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:118 -msgid "sets log on the WAN firewall zone and reloads the firewall." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:254 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:258 -msgid "then ping the router." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:815 -msgid "truncated" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:128 -msgid "turn it back off with the WAN logging on control on the watch strip." -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:512 -msgid "using fw4" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js -msgid "Live view may be incomplete — a legacy iptables table is registered" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:89 -msgid "· %s" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063 -msgid "Hide rows" -msgstr "" - -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1012 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1328 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1337 msgid "none" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1039 -msgid "Top drops" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:38 +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:45 +msgid "not" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1042 -msgid "Top rules" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1666 +msgid "not AH" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1036 -msgid "Top talkers" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1665 +msgid "not ESP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1030 -msgid "Showing a compact summary of the top of the shown sample." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1664 +msgid "not GRE" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1032 -msgid "Summary data is unavailable; show rows to inspect the sample." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1661 +msgid "not ICMP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1950 -msgid "Summary mode" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1662 +msgid "not ICMPv6" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1034 -msgid "Summary was bounded before all categories could be included." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1663 +msgid "not IGMP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1063 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1959 -msgid "Show rows" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1667 +msgid "not SCTP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1134 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1182 -msgid "Auto" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1659 +msgid "not TCP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2283 -msgid "Fetch budget" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1660 +msgid "not UDP" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2295 -msgid "Maximum raw lines" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2447 +msgid "not block" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1134 -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1183 -msgid "Manual" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2446 +msgid "not drop" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2301 -msgid "Manual still uses server protection and poll cadence" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2445 +msgid "not pass" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1151 -msgid "%s · requested up to %d raw lines · %d firewall messages returned" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2448 +msgid "not reject" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1142 -msgid "%s · requested up to %d raw lines · server limited fetch to %d · %d firewall messages returned" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2449 +msgid "not unknown" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1160 -msgid "Server adaptive protection is disabled." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2464 +msgid "or type…" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1159 -msgid "Server protection state is unknown." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1105 +msgid "render paused (high rate)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2250 -msgid "Fetch budget controls raw log lines per poll. Auto derives from Limit; Manual selects a bounded maximum." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1115 +msgid "resolve paused (load)" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:2255 -msgid "Manual still uses server protection and poll cadence; it changes the fetch budget, not the polling interval." +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1117 +msgid "scroll frozen — scroll to top to follow live" msgstr "" -#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:997 -msgid "buffer filling" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:184 +msgid "sets log on the WAN firewall zone and reloads the firewall." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:354 +msgid "then ping the router." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:1113 +msgid "truncated" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:194 +msgid "turn it back off with the WAN logging on control on the watch strip." +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:694 +msgid "using fw4" +msgstr "" + +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:137 +msgid "· %s" msgstr "" diff --git a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po index 919f8d562..46e761537 100644 --- a/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po +++ b/luci-app-fwlive/po/zh_Hans/luci-app-fwlive.po @@ -16,6 +16,10 @@ msgstr "" msgid "%d matching · %d/%d stored" msgstr "%d matching · %d/%d stored" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466 +msgid "%s: %s" +msgstr "%s: %s" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929 msgid "0 matching · %d/%d stored" msgstr "0 matching · %d/%d stored" @@ -612,6 +616,10 @@ msgstr "allow/deny rules, LAN logging, or anything else." msgid "buffer full" msgstr "缓冲区已满" +#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40 +msgid "contains" +msgstr "包含" + #: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81 msgid "default 10/minute" msgstr "默认 10 条/分钟" diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk index c3eedf6f6..8193b3bd8 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk +++ b/luci-app-fwlive/root/usr/libexec/fwlive-is-firewall-event.awk @@ -64,6 +64,8 @@ function json_unhex4(h, n, i, c, v) { return n } function json_get_msg(obj, s, i, c, esc, out, hex, n) { + # Summary decoder is ASCII/Latin-1 oriented. \uXXXX outside 1..255 is + # collapsed (not UTF-8). Invalid \u appends u. NUL is dropped. if (!match(obj, /"msg"[[:space:]]*:[[:space:]]*"/)) return "" s = substr(obj, RSTART + RLENGTH) out = "" @@ -79,6 +81,8 @@ function json_get_msg(obj, s, i, c, esc, out, hex, n) { else if (c == "u") { hex = substr(s, i + 1, 4) n = (length(hex) == 4) ? json_unhex4(hex) : -1 + # 1..255: Latin-1 %c. n>255: collapse. n<0: append u. + # n==0 (NUL): BusyBox awk strings cannot hold NUL; drop it. if (n >= 1 && n <= 255) out = out sprintf("%c", n) else if (n < 0) out = out "u" if (n >= 0) i += 4 diff --git a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index 421a2bf32..db09abc1c 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -28,12 +28,15 @@ NF_LOG_IPV6_READY=false # firewall..log bit, computes a target, then uci set + uci commit. Two # concurrent callers could otherwise interleave and last-commit-wins. # -# BusyBox flock constraint: it has NO -w timeout. A stuck lock holder blocks -# any waiter until the holder exits or the device reboots. The critical -# section MUST stay SHORT (a few uci commands). Do NOT hold the lock across -# the /etc/init.d/firewall reload (can take seconds); the lock is released -# before reload, and reload-failure rollback is a best-effort UCI write -# outside the lock. +# BusyBox flock constraint: it has NO -w timeout. The lock is opened with +# `exec 9>>` + `flock 9`. In ash/dash, `>>` clears close-on-exec, so a live +# child keeps fd 9 after this holder dies. A stuck lock therefore blocks any +# waiter until the last fd-9 inheritor exits or the device reboots (killing +# the holder alone may not release it). The critical section MUST stay SHORT +# (a few uci commands). Do NOT hold the lock across the /etc/init.d/firewall +# reload (can take seconds); the lock is released before reload. +# Reload-failure rollback re-acquires the lock so the read-compare-restore +# is atomic against concurrent writers; do not drop that re-acquire. # Overridable for tests/containers (default is root-only /etc/fwlive). WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}" WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}" @@ -435,7 +438,17 @@ restore_wan_log_baseline() { logger -t fwlive "WAN log baseline restore skipped: firewall changes pending" 2>/dev/null || true return 1 fi - if ! commit_wan_log_change "$zone" "$zone_json" "$baseline"; then + _commit_rc=0 + commit_wan_log_change "$zone" "$zone_json" "$baseline" || _commit_rc=$? + if [ "$_commit_rc" -eq 2 ]; then + # Commit landed; live fw4 still needs a reload. Baseline file + # stays so a later restore can retry — read-back was not ours. + release_wan_log_lock + reload_firewall || logger -t fwlive "WAN log baseline restore: firewall reload failed after raced commit" 2>/dev/null || true + logger -t fwlive "WAN log baseline restore: post-commit verify raced" 2>/dev/null || true + return 1 + fi + if [ "$_commit_rc" -ne 0 ]; then release_wan_log_lock logger -t fwlive "WAN log baseline restore: commit gate failed" 2>/dev/null || true return 1 @@ -701,7 +714,19 @@ restore_wan_zone_log() { logger -t fwlive "WAN log rollback skipped after stage: firewall changes staged by another writer" 2>/dev/null || true return 1 fi - uci commit firewall 2>/dev/null || true + if ! uci commit firewall 2>/dev/null; then + # Drop our staged rollback delta so a later toggle is not stuck + # on firewall_changes_pending from this package's own orphaned + # write. Mirror commit_wan_log_change: revert only when the + # remaining staging is entirely our log option. + _staged=$(uci -q changes firewall 2>/dev/null || true) + _total=$(printf '%s\n' "$_staged" | grep -c . 2>/dev/null || true) + _ours=$(wan_log_count_our_staged_lines "$zone" "$_staged") + if [ "${_total:-0}" -gt 0 ] && [ "${_total:-0}" -eq "${_ours:-0}" ]; then + uci -q revert firewall 2>/dev/null || true + fi + return 1 + fi } # Stage + commit the WAN log bit. Caller MUST hold the logging lock; this @@ -721,10 +746,11 @@ restore_wan_zone_log() { # for a failed reload. # Residual window: a writer that stages between the post-stage check and # `uci commit` can still ride along; post-commit verification detects a -# mismatched log bit. Same-option races (another writer also staging -# firewall..log) are not distinguishable in the changes list. -# The caller-reported error is firewall_changes_pending: the LuCI view already -# maps it to the accurate "another change is staged" notice. +# mismatched log bit and reports firewall_commit_raced (no blind-revert). +# Same-option races (another writer also staging firewall..log) are +# not distinguishable in the changes list. Pre-stage races report +# firewall_changes_pending: the LuCI view already maps it to the accurate +# "another change is staged" notice. # # target: value to stage; EMPTY means delete the option (bit fully cleared). # Prints the failure JSON and returns 1 on abort or failure. @@ -807,14 +833,44 @@ commit_wan_log_change() { # Post-commit verification: confirm the committed config really # carries what we wrote (empty target = option must now be gone/empty). # A mismatch means another writer overtook the commit: warn loudly but do - # NOT blind-revert (that would destroy unrelated committed data); the - # reload-failure rollback path still guards the ordinary failure case. + # NOT blind-revert (that would destroy unrelated committed data). Return 2 + # so the caller still reloads (live fw4 must track committed UCI) and then + # reports firewall_commit_raced instead of ok:true. Do not print JSON here: + # reload may still fail. Return 1 is reserved for pre-commit aborts that + # already printed an error body. if ! verify_wan_log_commit "$zone" "$target"; then logger -t fwlive "WAN log post-commit verify FAILED: wrote '${target:-}', read back differs" 2>/dev/null || true + return 2 fi return 0 } +# Caller MUST have released the logging lock. rc from commit_wan_log_change: +# 0 — our target is committed; reload and report success. +# 1 — abort JSON already printed; no commit, skip reload. +# 2 — commit succeeded but verify raced; still reload, then report +# firewall_commit_raced (or firewall_reload_failed). Do not use +# reload_and_report_wan_log: a reload failure must not restore +# `previous` over a foreign committed value. +report_wan_log_after_commit() { + _rc="$1" + zone_json="$2" + if [ "$_rc" -eq 1 ]; then + return 0 + fi + if [ "$_rc" -eq 2 ]; then + if ! reload_firewall; then + logger -t fwlive "Firewall reload failed after raced WAN log commit" 2>/dev/null || true + printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_reload_failed"}' "$zone_json" + return 0 + fi + printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_commit_raced"}' "$zone_json" + return 0 + fi + shift 2 + reload_and_report_wan_log "$@" +} + # Firewall reload + best-effort UCI rollback on reload failure. The reload # itself runs WITHOUT the logging lock (it can take seconds and a held lock # would block a concurrent toggle until the holder exits — BusyBox flock has @@ -918,13 +974,11 @@ enable_wan_logging() { fi target=$(wan_filter_log_target_value "$current") - if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then - release_wan_log_lock - return 0 - fi + _commit_rc=0 + commit_wan_log_change "$zone" "$zone_json" "$target" || _commit_rc=$? release_wan_log_lock - - reload_and_report_wan_log "$zone" "$current" "$target" \ + report_wan_log_after_commit "$_commit_rc" "$zone_json" \ + "$zone" "$current" "$target" \ 'Firewall reload failed after enable; reverted UCI WAN log' \ 'WAN zone logging enabled' \ "$zone_json" @@ -971,13 +1025,11 @@ disable_wan_logging() { # not restore that bit. target=$(wan_filter_log_clear_value "$current") - if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then - release_wan_log_lock - return 0 - fi + _commit_rc=0 + commit_wan_log_change "$zone" "$zone_json" "$target" || _commit_rc=$? release_wan_log_lock - - reload_and_report_wan_log "$zone" "$current" "$target" \ + report_wan_log_after_commit "$_commit_rc" "$zone_json" \ + "$zone" "$current" "$target" \ 'Firewall reload failed after disable; reverted UCI WAN log' \ 'WAN zone logging disabled' \ "$zone_json" diff --git a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive index ed0e0953b..c55458bcb 100755 --- a/luci-app-fwlive/root/usr/libexec/rpcd/fwlive +++ b/luci-app-fwlive/root/usr/libexec/rpcd/fwlive @@ -39,6 +39,11 @@ RESOLVE_BUDGET=5 POLL_LINES_MAX=2000 # Bound nft ruleset dumps (corrupted/hung nft must not block LuCI rules load). NFT_TIMEOUT=5 +# Bound poll log.read (wedged logd must not hold an rpcd worker past the UI cadence). +# Integer seconds — BusyBox timeout truncates fractions to 0 (see RESOLVE_TIMEOUT). +POLL_TIMEOUT=5 +# Host tests override this to a pinned libubox helper; devices keep the package path. +: "${FWLIVE_JSHN_SH:=/usr/share/libubox/jshn.sh}" # json_escape lives in fwlive-logging.sh so prerm can source that file alone. # shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh @@ -170,15 +175,70 @@ $(uci_rule_names) EOF } +# One awk pass: prefixcomment. Last quoted log prefix / comment on +# the line, including \" units (same capture as the former sed). +nft_dump_fields() { + LC_ALL=C awk ' + function last_at(s, needle, p, i, found) { + found = 0 + p = 1 + while ((i = index(substr(s, p), needle)) > 0) { + found = p + i - 1 + p = found + 1 + } + return found + } + function quoted_at(s, needle, i, rest, out, c, n, esc, j) { + i = last_at(s, needle) + if (i == 0) + return "" + rest = substr(s, i + length(needle)) + n = length(rest) + out = "" + esc = 0 + for (j = 1; j <= n; j++) { + c = substr(rest, j, 1) + if (esc) { + out = out c + esc = 0 + continue + } + if (c == "\\") { + out = out c + esc = 1 + continue + } + if (c == "\"") + return out + out = out c + } + return "" + } + { + prefix = quoted_at($0, "log prefix \"") + comment = quoted_at($0, "comment \"") + if (prefix == "" && comment == "") + next + print prefix "\t" comment + } + ' +} + # pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats # an earlier cosmetic for the same prefix under first-wins. +# Reads TSV from nft_dump_fields; stops once the map cap trips. map_from_nft_stream() { pass="${1:-all}" - while IFS= read -r line; do - # Capture [^"]* plus escaped-quote units (\"...) so a log prefix or - # comment containing an escaped quote is not truncated at the quote. - prefix=$(echo "$line" | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p') - comment=$(echo "$line" | sed -n 's/.*comment "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p') + _tab=$(printf '\t') + # IFS= keeps a leading tab (empty prefix + comment-only !fw4: line). + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$_map_overflow" ] && break + [ -n "$line" ] || continue + prefix="${line%%${_tab}*}" + case "$line" in + *"$_tab"*) comment="${line#*"$_tab"}" ;; + *) comment="" ;; + esac label='' case "$comment" in @@ -209,14 +269,6 @@ nft_list_ruleset() { run_with_timeout "$NFT_TIMEOUT" nft list ruleset } -detect_firewall_backend() { - if command -v nft >/dev/null 2>&1 && nft_list_ruleset >/dev/null; then - printf '%s' nft - return 0 - fi - printf '%s' unknown -} - _fwlive_tmp_dir_ok() { # Fail closed unless the dump dir is a real directory with the sticky # bit. Without sticky, another uid can unlink a just-created mktemp @@ -251,15 +303,29 @@ enrich_rules_dump() { _rules_dump=$(_fwlive_mktemp "$_dump_prefix") || _rules_dump='' if [ -n "$_rules_dump" ]; then + # One dump serves detect + parse. Empty/failed dump is not nft + # (unknown/no_backend, same as a failed detect probe). if nft_list_ruleset >"$_rules_dump" 2>/dev/null; then - map_from_nft_stream labeled <"$_rules_dump" - map_from_nft_stream unlabeled <"$_rules_dump" - elif [ -z "$_enrich_error" ]; then - _enrich_error='nft_failed' + backend=nft + # Stream awk output to a second temp so a huge dump is + # not materialized in the rpcd worker's shell heap. + _nft_tsv=$(_fwlive_mktemp "fwlive-nft-tsv") + if [ -z "$_nft_tsv" ]; then + _enrich_error='mktemp_failed' + else + nft_dump_fields <"$_rules_dump" >"$_nft_tsv" + map_from_nft_stream labeled <"$_nft_tsv" + if [ -z "$_map_overflow" ]; then + map_from_nft_stream unlabeled <"$_nft_tsv" + fi + rm -f "$_nft_tsv" + fi fi rm -f "$_rules_dump" else + # nft is on PATH (caller checked); skip enrichment without a dump. _enrich_error='mktemp_failed' + backend=nft fi } @@ -268,13 +334,14 @@ build_rules_map() { _map_keys=0 _map_overflow='' _enrich_error='' - backend=$(detect_firewall_backend) + backend=unknown # UCI first, then labeled stream prefixes, then unlabeled cosmetics # First-wins keeps UCI over nft and !fw4: over cosmetic. map_uci_rule_names - if [ "$backend" = nft ]; then + # Temp file first, then a single nft_list_ruleset; backend from success. + if command -v nft >/dev/null 2>&1; then enrich_rules_dump fwlive-nft fi @@ -301,7 +368,8 @@ poll_clamp_lines() { return 0 ;; esac - _digits=$(printf '%s' "$_val" | sed 's/^0*//') + # Strip leading zeros in-shell so default-50 poll does not pay for sed. + _digits=${_val#${_val%%[!0]*}} if [ -z "$_digits" ]; then printf '%s' "50" return 0 @@ -317,33 +385,52 @@ poll_clamp_lines() { fi } +# Failed jshn reads still default to 50 (silent numeric contract; syslog named cause only). poll_lines_from_input() { input="$1" lines=50 + _cause= if ! command -v jshn >/dev/null 2>&1; then - printf '%s' "$lines" - return 0 + _cause=jshn_missing + else + _rc=0 + # jshn uses intentionally unset state variables. Keep nounset disabled in + # this subshell so strict mode is restored automatically on every exit. + # exit 2: library missing or source failed; exit 1: JSON parse failed. + # Missing addresses[0] is an intentional silent default, not a parse error. + _first=$( ( + set +u + # shellcheck disable=SC1090,SC1091 + . "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 2 + json_init >/dev/null 2>&1 || exit 2 + # json_load masks a failing jshn exit through eval. Check the real + # parser once, then evaluate only its generated shell assignments. + _json_no_warning=1 + _json_code=$(jshn -r "$input" 2>/dev/null) || exit 1 + eval "$_json_code" || exit 1 + first= + if json_select addresses 2>/dev/null; then + json_get_var first 1 || first= + fi + printf '%s' "${first:-}" + ) ) || _rc=$? + if [ "$_rc" -eq 2 ]; then + _cause=jshn_lib_missing + elif [ "$_rc" -ne 0 ]; then + _cause=invalid_input + else + case "${_first:-}" in + ''|*[!0-9]*) ;; + *) lines="$_first" ;; + esac + fi fi - # jshn uses intentionally unset state variables. Keep nounset disabled in - # this subshell so strict mode is restored automatically on every exit. - _first=$( ( - set +u - . /usr/share/libubox/jshn.sh - # json_load masks a failing jshn exit through eval. Check the real - # parser once, then evaluate only its generated shell assignments. - _json_no_warning=1 - _json_code=$(jshn -r "$input" 2>/dev/null) || exit 1 - eval "$_json_code" || exit 1 - if ! json_select addresses 2>/dev/null; then exit 1; fi - json_get_var first 1 || exit 1 - printf '%s' "${first:-}" - ) ) || _first= - case "${_first:-}" in - ''|*[!0-9]*) ;; - *) lines="$_first" ;; - esac + # Named cause goes to syslog only: poll JSON "error" is connection-lost in the view. + if [ -n "$_cause" ]; then + logger -t fwlive "$_cause" 2>/dev/null || true + fi lines=$(poll_clamp_lines "$lines") @@ -360,7 +447,7 @@ fetch_firewall_logs() { # Adaptive plan prints: lines shed bucket start_cs=$(fwlive_adaptive_clock_cs) # Drop dead oneshot flag — only consumed on logd's stream branch. - if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then + if ! raw=$(run_with_timeout "$POLL_TIMEOUT" ubus call log read "{\"lines\":$lines,\"stream\":false}"); then # Do not record ~0 ms as cold: failure is not healthy fast processing # and must not clear an existing hot/shed cap. out='{"log":[],"error":"log_read_failed"}' @@ -383,7 +470,7 @@ fetch_firewall_logs() { # Empty success is not valid poll JSON — the shipped filter always # prints {"log":[...]}, but a stub/regression must not emit a blank body. _filter_ok=1 - if ! out=$(printf '%s' "$raw" | "$FILTER_SH" /tmp); then + if ! out=$(printf '%s' "$raw" | run_with_timeout "$POLL_TIMEOUT" "$FILTER_SH" /tmp); then _filter_ok=0 if [ -z "$out" ]; then out='{"log":[],"error":"filter_failed"}' @@ -423,16 +510,29 @@ poll_logs() { # " name = "; fall back to "Address N: ". # Trailing dots are stripped. Empty / server-only output is not a name. parse_nslookup_name() { - # Prefer " name = ". The "Address N: ip host" fallback - # must ignore the Server block (BusyBox mini nslookup prints the - # resolver via the same Address N: format first). Only accept - # Address N: after a blank line (the answer section). + # Prefer " name = ". "name =" must be a whole token + # (not a suffix of hostname, not the phrase "domain name ="). + # The "Address N: ip host" fallback must ignore the Server + # block (BusyBox mini nslookup prints the resolver via the + # same Address N: format first). Only accept Address N: after + # a blank line (the answer section). printf '%s\n' "$1" | awk ' - tolower($0) ~ /name[ \t]*=/ { - sub(/.*[Nn][Aa][Mm][Ee][ \t]*=[ \t]*/, "") - gsub(/[ \t].*$/, "") - gsub(/\.$/, "") - if ($0 != "") { print; exit } + { + low = tolower($0) + host = "" + if (match(low, /^name[ \t]*=[ \t]*/)) { + host = substr($0, RLENGTH + 1) + } else if (match(low, /[^a-z0-9_]name[ \t]*=[ \t]*/)) { + pre = substr(low, 1, RSTART - 1) + sub(/[ \t]+$/, "", pre) + if (pre != "domain" && pre !~ /[^a-z0-9_]domain$/) + host = substr($0, RSTART + RLENGTH) + } + if (host != "") { + gsub(/[ \t].*$/, "", host) + gsub(/\.$/, "", host) + if (host != "") { print host; exit } + } } NF == 0 { in_answer = 1; next } in_answer && $1 == "Address" && $2 ~ /^[0-9]+:/ && NF >= 4 { @@ -575,7 +675,8 @@ resolve_addresses() { _addresses=$( ( set +u - . /usr/share/libubox/jshn.sh + # shellcheck disable=SC1090,SC1091 + . "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" # json_load masks a failing jshn exit through eval. Check the real # parser once, then evaluate only its generated shell assignments. _json_no_warning=1 @@ -752,19 +853,23 @@ run_selftest() { # Escaped-quote capture: a log prefix containing \" must not be truncated # at the escaped quote (nft dumps strings with backslash escapes). - got=$(printf '%s\n' 'log prefix "My \" Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p') + _tab=$(printf '\t') + got=$(printf '%s\n' 'log prefix "My \" Rule"' | nft_dump_fields) + got="${got%%${_tab}*}" if [ "$got" != 'My \" Rule' ]; then echo "escaped-quote prefix capture: expected 'My \\\" Rule' got '$got'" >&2 return 1 fi # Plain prefix still captures unchanged. - got=$(printf '%s\n' 'log prefix "Plain Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p') + got=$(printf '%s\n' 'log prefix "Plain Rule"' | nft_dump_fields) + got="${got%%${_tab}*}" if [ "$got" != 'Plain Rule' ]; then echo "plain prefix capture: expected 'Plain Rule' got '$got'" >&2 return 1 fi # Empty prefix edge. - got=$(printf '%s\n' 'log prefix ""' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p') + got=$(printf '%s\n' 'log prefix ""' | nft_dump_fields) + got="${got%%${_tab}*}" if [ "$got" != '' ]; then echo "empty prefix capture: expected '' got '$got'" >&2 return 1 @@ -774,6 +879,10 @@ run_selftest() { check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "over-long expected $POLL_LINES_MAX got $got" || return 1 got=$(poll_clamp_lines "0") check_eq poll_clamp_lines "$got" "50" "zero expected 50 got $got" || return 1 + got=$(poll_clamp_lines "000") + check_eq poll_clamp_lines "$got" "50" "all-zeros expected 50 got $got" || return 1 + got=$(poll_clamp_lines "050") + check_eq poll_clamp_lines "$got" "50" "leading-zero expected 50 got $got" || return 1 got=$(poll_clamp_lines "2001") check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "2001 expected $POLL_LINES_MAX got $got" || return 1 got=$(poll_clamp_lines "500") @@ -816,6 +925,16 @@ Address 1: 8.8.8.8 dns.google") got=$(parse_nslookup_name "Server: 127.0.0.1 Address: 127.0.0.1:53") check_eq parse_nslookup_name "$got" '' "server-only expected empty got '$got'" || return 1 + got=$(parse_nslookup_name "hostname = evil.example.") + check_eq parse_nslookup_name "$got" '' "hostname = must not parse as name got '$got'" || return 1 + got=$(parse_nslookup_name "domain name = other.example.") + check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1 + got=$(parse_nslookup_name "domain name = two.example.") + check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1 + got=$(parse_nslookup_name "domain name = tab.example.") + check_eq parse_nslookup_name "$got" '' "domain tab name = must not parse as name got '$got'" || return 1 + got=$(parse_nslookup_name "name = dns.google. hostname = evil.example.") + check_eq parse_nslookup_name "$got" "dns.google" "standalone name = must beat hostname = got '$got'" || return 1 run_logging_selftest || return 1 @@ -834,6 +953,18 @@ Address: 127.0.0.1:53") echo "skip: jshn not available (poll cap via jshn not tested)" >&2 return 0 fi + # Dash rejects libubox `export -n` in json_init. Skip numeric poll-cap + # checks unless this interpreter can actually use the helper; poll still + # defaults to 50 and logs a named cause in that case. + if ! ( + set +u + # shellcheck disable=SC1090,SC1091 + . "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 1 + json_init >/dev/null 2>&1 + ); then + echo "skip: jshn library not usable (poll cap via jshn not tested)" >&2 + return 0 + fi got=$(poll_lines_from_input '{"addresses":["999999"]}') check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "expected $POLL_LINES_MAX got $got" || return 1 @@ -851,6 +982,64 @@ Address: 127.0.0.1:53") got=$(poll_lines_from_input '{"addresses":["100"]}') check_eq poll_lines_from_input "$got" "100" "expected 100 got $got" || return 1 + # jshn failures must log a named cause; stdout stays the numeric default. + # logger() override is inherited by the $() subshell; command -v still finds + # the binary, so the unqualified logger call is what this intercepts. + _poll_log="/tmp/fwlive-poll-cause.$$" + : > "$_poll_log" + logger() { printf '%s\n' "$*" >> "$_poll_log"; } + + got=$(poll_lines_from_input '{"addresses":["500"]}') + check_eq poll_lines_from_input "$got" "500" "override path expected 500 got $got" || return 1 + case "$(cat "$_poll_log")" in + *jshn_missing*|*jshn_lib_missing*|*invalid_input*) + echo "poll_lines_from_input: successful cap must stay silent, got $(cat "$_poll_log")" >&2 + rm -f "$_poll_log" + unset -f logger + return 1 + ;; + esac + + : > "$_poll_log" + got=$(poll_lines_from_input '{}') + check_eq poll_lines_from_input "$got" "50" "missing addresses expected 50 got $got" || return 1 + case "$(cat "$_poll_log")" in + *jshn_missing*|*jshn_lib_missing*|*invalid_input*) + echo "poll_lines_from_input: missing addresses[0] must stay silent, got $(cat "$_poll_log")" >&2 + rm -f "$_poll_log" + unset -f logger + return 1 + ;; + esac + + : > "$_poll_log" + got=$(poll_lines_from_input 'not-json{{{') + check_eq poll_lines_from_input "$got" "50" "invalid JSON expected 50 got $got" || return 1 + case "$(cat "$_poll_log")" in + *invalid_input*) ;; + *) echo "poll_lines_from_input: expected invalid_input log, got $(cat "$_poll_log")" >&2 + rm -f "$_poll_log" + unset -f logger + return 1 ;; + esac + + : > "$_poll_log" + _saved_jshn_sh="$FWLIVE_JSHN_SH" + FWLIVE_JSHN_SH="/no/such/fwlive-jshn.sh" + got=$(poll_lines_from_input '{"addresses":["500"]}') + FWLIVE_JSHN_SH="$_saved_jshn_sh" + check_eq poll_lines_from_input "$got" "50" "missing library expected 50 got $got" || return 1 + case "$(cat "$_poll_log")" in + *jshn_lib_missing*) ;; + *) echo "poll_lines_from_input: expected jshn_lib_missing log, got $(cat "$_poll_log")" >&2 + rm -f "$_poll_log" + unset -f logger + return 1 ;; + esac + + rm -f "$_poll_log" + unset -f logger + return 0 } @@ -863,6 +1052,10 @@ case "${1:-}" in poll_clamp_lines "${2:-}" exit $? ;; + __poll_lines) + poll_lines_from_input "${2:-}" + exit $? + ;; __tmp_dir_ok) _fwlive_tmp_dir_ok "${2:-}" exit $? diff --git a/luci-app-istoreenhance/Makefile b/luci-app-istoreenhance/Makefile index 145a19e2c..663431f68 100644 --- a/luci-app-istoreenhance/Makefile +++ b/luci-app-istoreenhance/Makefile @@ -6,11 +6,10 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for KSpeeder -LUCI_DEPENDS:=+istoreenhance +LUCI_DEPENDS:=+istoreenhance +luci-lib-linkeaseauth +linkease-app-entry LUCI_PKGARCH:=all -PKG_VERSION:=0.6.6-r2 -# PKG_RELEASE MUST be empty for luci.mk -PKG_RELEASE:=2 +PKG_VERSION:=0.8.0 +PKG_RELEASE:=3 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-istoreenhance/luasrc/controller/istoreenhance.lua b/luci-app-istoreenhance/luasrc/controller/istoreenhance.lua index bf1d65baf..b3cb37af9 100644 --- a/luci-app-istoreenhance/luasrc/controller/istoreenhance.lua +++ b/luci-app-istoreenhance/luasrc/controller/istoreenhance.lua @@ -1,26 +1,34 @@ module("luci.controller.istoreenhance", package.seeall) function index() - if not nixio.fs.access("/etc/config/istoreenhance") then - return - end + if not nixio.fs.access("/etc/config/istoreenhance") then return end entry({"admin", "services", "istoreenhance"}, cbi("istoreenhance"), _("KSpeeder"), 20).dependent = true - entry({"admin", "services", "istoreenhance_status"}, call("istoreenhance_status")) + local open = entry({"admin", "services", "istoreenhance", "open"}, call("istoreenhance_open")) + open.leaf = true + open.dependent = false + open.sysauth = false +end + +local function compat() + local dispatcher = require "luci.dispatcher" + return require("luci.model.linkease.apps_compat").new({ + http = require "luci.http", + resolver = require("luci.model.linkease.apps_openwrt").new(), + auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth") + }) end function istoreenhance_status() - local sys = require "luci.sys" - local uci = require "luci.model.uci".cursor() - local port = tonumber(uci:get_first("istoreenhance", "istoreenhance", "adminport")) - - local status = { - running = (sys.call("pidof iStoreEnhance >/dev/null") == 0), - port = (port or 5003) - } - - luci.http.prepare_content("application/json") - luci.http.write_json(status) + local sys = require "luci.sys" + local uci = require "luci.model.uci".cursor() + compat():legacy_status("kspeeder", { + running = sys.call("pidof iStoreEnhance >/dev/null") == 0, + port = uci:get_first("istoreenhance", "istoreenhance", "adminport") or "5003" + }) end +function istoreenhance_open() + compat():open("kspeeder") +end diff --git a/luci-app-istoreenhance/luasrc/view/istoreenhance_status.htm b/luci-app-istoreenhance/luasrc/view/istoreenhance_status.htm index d4450ba6a..f9e7cbe32 100644 --- a/luci-app-istoreenhance/luasrc/view/istoreenhance_status.htm +++ b/luci-app-istoreenhance/luasrc/view/istoreenhance_status.htm @@ -8,7 +8,7 @@ if (st.running) { tb.innerHTML = '
<%:The KSpeeder service is running.%>' - + "

\" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>"; + + "

\" onclick=\"window.open('<%=url("admin/services/istoreenhance/open")%>')\"/>"; } else { diff --git a/luci-app-kai/Makefile b/luci-app-kai/Makefile index f475e2032..6ba2b8e03 100755 --- a/luci-app-kai/Makefile +++ b/luci-app-kai/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=kai PKG_VERSION:=1.0.2 -PKG_RELEASE:=10 -LUCI_DEPENDS:=+kai +kai_session +luci-compat +PKG_RELEASE:=11 +LUCI_DEPENDS:=+kai +luci-compat +luci-lib-linkeaseauth +linkease-app-entry LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-kai/luasrc/controller/kai.lua b/luci-app-kai/luasrc/controller/kai.lua index 3e3b3f3b9..128d247ae 100644 --- a/luci-app-kai/luasrc/controller/kai.lua +++ b/luci-app-kai/luasrc/controller/kai.lua @@ -16,10 +16,14 @@ end function kai_status() local sys = require "luci.sys" local uci = require "luci.model.uci".cursor() + local port = tonumber(uci:get_first("kai", "kai", "port")) or 8197 + if port < 1 or port > 65535 or port % 1 ~= 0 then + port = 8197 + end local status = { running = (sys.call("pidof kai_bin >/dev/null") == 0), - port = 8197 + port = port } - luci.http.prepare_content("application/json") - luci.http.write_json(status) + http.prepare_content("application/json") + http.write_json(status) end diff --git a/luci-app-kai/luasrc/view/kai/kai_status.htm b/luci-app-kai/luasrc/view/kai/kai_status.htm index be616836d..43c2c0e37 100644 --- a/luci-app-kai/luasrc/view/kai/kai_status.htm +++ b/luci-app-kai/luasrc/view/kai/kai_status.htm @@ -1,4 +1,6 @@ - + diff --git a/luci-theme-argon/ucode/template/themes/argon/footer_login.ut b/luci-theme-argon/ucode/template/themes/argon/footer_login.ut index e82cd32d6..b20b68f86 100644 --- a/luci-theme-argon/ucode/template/themes/argon/footer_login.ut +++ b/luci-theme-argon/ucode/template/themes/argon/footer_login.ut @@ -25,27 +25,15 @@ - + diff --git a/luci-theme-argon/ucode/template/themes/argon/header.ut b/luci-theme-argon/ucode/template/themes/argon/header.ut index f4cdbe5c6..8694d23f5 100644 --- a/luci-theme-argon/ucode/template/themes/argon/header.ut +++ b/luci-theme-argon/ucode/template/themes/argon/header.ut @@ -17,7 +17,7 @@ -#} {% - import { readfile, access } from 'fs'; + import { access } from 'fs'; import { cursor } from 'uci'; import { getuid, getspnam } from 'luci.core'; @@ -30,37 +30,50 @@ http.prepare_content('text/html; charset=UTF-8'); //Custom settings - let mode = 'normal'; - let bar_color = '#5e72e4'; - let primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark; - const dark_css = readfile('/www/luci-static/argon/css/dark.css'); - if (access('/etc/config/argon')) { - primary = cfg.get_first('argon', 'global', 'primary'); - dark_primary = cfg.get_first('argon', 'global', 'dark_primary'); - blur_radius = cfg.get_first('argon', 'global', 'blur'); - blur_radius_dark = cfg.get_first('argon', 'global', 'blur_dark'); - blur_opacity = cfg.get_first('argon', 'global', 'transparency'); - blur_opacity_dark = cfg.get_first('argon', 'global', 'transparency_dark'); - mode = cfg.get_first('argon', 'global', 'mode'); - bar_color = (mode == 'dark') ? dark_primary : primary; + function validColor(value, fallback) { + return type(value) == 'string' && match(value, /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/) ? value : fallback; } + function validNumber(value, fallback, maximum) { + return type(value) == 'string' && match(value, /^(0|[1-9][0-9]*)(\.[0-9]+)?$/) && +value <= maximum ? value : fallback; + } + + let mode = 'normal'; + let primary = '#5e72e4'; + let dark_primary = '#483d8b'; + let blur_radius = '10'; + let blur_radius_dark = '10'; + let blur_opacity = '0.5'; + let blur_opacity_dark = '0.5'; + const has_argon_config = access('/etc/config/argon'); + if (has_argon_config) { + primary = validColor(cfg.get_first('argon', 'global', 'primary'), primary); + dark_primary = validColor(cfg.get_first('argon', 'global', 'dark_primary'), dark_primary); + blur_radius = validNumber(cfg.get_first('argon', 'global', 'blur'), blur_radius, 100); + blur_radius_dark = validNumber(cfg.get_first('argon', 'global', 'blur_dark'), blur_radius_dark, 100); + blur_opacity = validNumber(cfg.get_first('argon', 'global', 'transparency'), blur_opacity, 1); + blur_opacity_dark = validNumber(cfg.get_first('argon', 'global', 'transparency_dark'), blur_opacity_dark, 1); + let configured_mode = cfg.get_first('argon', 'global', 'mode'); + if (configured_mode == 'normal' || configured_mode == 'dark' || configured_mode == 'light') + mode = configured_mode; + } + const bar_color = mode == 'dark' ? dark_primary : primary; + -%} - + {% include("themes/" + theme + "/head_meta", { hostname: hostname, node: node, bar_color: bar_color, media: media }) %} + {% if (mode == 'normal'): %} + + {% elif (mode == 'dark'): %} + + {% endif %}