diff --git a/brlaser/Makefile b/brlaser/Makefile index 36c083bbe..4cac7ba21 100644 --- a/brlaser/Makefile +++ b/brlaser/Makefile @@ -9,19 +9,22 @@ include $(TOPDIR)/rules.mk PKG_NAME:=brlaser -PKG_VERSION:=v3 -PKG_RELEASE:=1 +PKG_VERSION:=3 +PKG_RELEASE:=2 PKG_SOURCE_URL:=https://github.com/pdewacht/brlaser.git PKG_SOURCE_PROTO:=git PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_VERSION:=2a49e3287c70c254e7e3ac9dabe9d6a07218c3fa PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-$(PKG_SOURCE_VERSION).tar.gz +PKG_MIRROR_HASH:=skip PKG_FIXUP:=autoreconf - PKG_INSTALL:=1 +# Add CMAKE_POLICY_VERSION_MINIMUM to handle CMake 3.5+ compatibility +CMAKE_OPTIONS += -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/cmake.mk diff --git a/honk/Makefile b/honk/Makefile index 85b5fd2e9..b2d30c1a5 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -3,17 +3,17 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=2026.10.3_pre2 -PKG_RELEASE:=6 +PKG_VERSION:=2026.10.4_pre1 +PKG_RELEASE:=7 # Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) # because that is what the GitHub release asset name uses. -HONK_RELEASE_TAG:=debug.2026.10.3.native-api.2 +HONK_RELEASE_TAG:=debug.2026.10.4.native-api.1 HONK_ASSET_TAG:=debug # SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh. -HONK_HASH_X86_64:=7ce4585150c46e95829f916f843675ce3471fee48e13a012c0f3652028f137dd -HONK_HASH_AARCH64:=ee5ff000688237d1b1bde701f933736dfc7ed479cb84fdf2bddc6d64c95c33ab +HONK_HASH_X86_64:=dfeab5ca18744563c0cb672069fc548744360e3f4b2f35d7be3cfbfef96dbf6c +HONK_HASH_AARCH64:=a17fd9ee544b7671f57defc4c030a7a3620c1ebfdf329e6e2a22050223ddad9e PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE_FILE:=LICENSE diff --git a/luci-app-banmac-ipt/Makefile b/luci-app-banmac-ipt/Makefile deleted file mode 100644 index d1b324fb3..000000000 --- a/luci-app-banmac-ipt/Makefile +++ /dev/null @@ -1,32 +0,0 @@ -include $(TOPDIR)/rules.mk - -PKG_NAME:=luci-app-banmac-ipt -PKG_VERSION:=20211118 -PKG_RELEASE:=1 - - -include $(INCLUDE_DIR)/package.mk - -define Package/luci-app-banmac-ipt - SECTION:=luci - CATEGORY:=LuCI - SUBMENU:=3. Applications - TITLE:=LuCI Support for Ban Client - PKGARCH:=all -endef - -define Build/Compile -endef - -define Package/luci-app-banmac-ipt/install - $(INSTALL_DIR) $(1)/usr/lib/lua/luci - cp -pR ./luasrc/* $(1)/usr/lib/lua/luci - $(INSTALL_DIR) $(1)/usr/banmac - $(INSTALL_BIN) ./files/banmac/* $(1)/usr/banmac - $(INSTALL_DIR) $(1)/etc - $(INSTALL_CONF) ./files/banmaclog $(1)/etc/banmaclog - $(INSTALL_DIR) $(1)/etc/config - $(INSTALL_CONF) ./files/banmac.conf $(1)/etc/config/banmac -endef - -$(eval $(call BuildPackage,luci-app-banmac-ipt)) diff --git a/luci-app-banmac-ipt/files/banmac.conf b/luci-app-banmac-ipt/files/banmac.conf deleted file mode 100644 index 2b0df1a05..000000000 --- a/luci-app-banmac-ipt/files/banmac.conf +++ /dev/null @@ -1,5 +0,0 @@ - -config banlist - -config banmac 'smartphone' - diff --git a/luci-app-banmac-ipt/files/banmac/ban.sh b/luci-app-banmac-ipt/files/banmac/ban.sh deleted file mode 100755 index 9a5130bcf..000000000 --- a/luci-app-banmac-ipt/files/banmac/ban.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -sectionname=$(echo $0 | cut -d '_' -f 2 | sed 's/^OO!%!OO//') -getcmac=$(uci get banmac.$sectionname.banlist_mac | tr 'A-Z' 'a-z') -iptables -I FORWARD -m mac --mac-source $getcmac -j DROP -hostname=$(grep -n $getcmac /tmp/dhcp.leases | cut -d ' ' -f 4) -hostip=$(grep -n $getcmac /tmp/dhcp.leases | cut -d ' ' -f 3) -echo "★禁网设备:$hostname($hostip) MAC地址:$getcmac 操作日期:$(date +%Y年%m月%d日\ %H点%M分%S秒)" >> /etc/banmaclog - -for i in $(seq 0 1) -do - for x in $(iw wlan${i} station dump | grep -i station | cut -d ' ' -f 2) - do - if [ $x = $getcmac ]; then - iw dev wlan${i} station del $x - fi - done -done diff --git a/luci-app-banmac-ipt/files/banmac/unban.sh b/luci-app-banmac-ipt/files/banmac/unban.sh deleted file mode 100755 index 42d7e704c..000000000 --- a/luci-app-banmac-ipt/files/banmac/unban.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -sectionname=$(echo $0 | cut -d '_' -f 2 | sed 's/^OO!%!OO//') -getcmac=$(uci get banmac.$sectionname.banlist_mac | tr 'A-Z' 'a-z') -iptables -D FORWARD -m mac --mac-source $getcmac -j DROP - -sed -i "s/$(cat /etc/banmaclog | grep $getcmac | head -n 1)//;/^\s*$/d" /etc/banmaclog diff --git a/luci-app-banmac-ipt/files/banmaclog b/luci-app-banmac-ipt/files/banmaclog deleted file mode 100644 index 26906d62b..000000000 --- a/luci-app-banmac-ipt/files/banmaclog +++ /dev/null @@ -1,3 +0,0 @@ -#点击 One-Click Ban 快速禁止指定客户端。点击 One-Click UnBan 快速解禁。 -#这里记录了客户端列表 ----------------------------------------------------------------------- diff --git a/luci-app-banmac-ipt/luasrc/controller/banmac.lua b/luci-app-banmac-ipt/luasrc/controller/banmac.lua deleted file mode 100644 index d78af5cba..000000000 --- a/luci-app-banmac-ipt/luasrc/controller/banmac.lua +++ /dev/null @@ -1,7 +0,0 @@ -module("luci.controller.banmac",package.seeall) - -function index() - local page = entry({"admin", "services", "banmac"}, cbi("banmac"), _("BanMac"),5) - page.dependent = true -end - diff --git a/luci-app-banmac-ipt/luasrc/model/cbi/banmac.lua b/luci-app-banmac-ipt/luasrc/model/cbi/banmac.lua deleted file mode 100644 index 73e056bf4..000000000 --- a/luci-app-banmac-ipt/luasrc/model/cbi/banmac.lua +++ /dev/null @@ -1,47 +0,0 @@ -m = Map("banmac", translate("BanMac")) - -local banlist = m:section(TypedSection, "banlist", translate("log")) -banlist.anonymous = true - -local bmdetails = "/etc/banmaclog" -local BMNXFS = require "nixio.fs" -bmd = banlist:option(TextValue, "details") -bmd.rows = 6 -bmd.wrap = "off" -bmd.cfgvalue = function(self, section) - return BMNXFS.readfile(bmdetails) or "" -end -bmd.write = function(self, section, value) - BMNXFS.writefile(bmdetails, value:gsub("\r\n", "\n")) -end - -s = m:section(TypedSection, "banmac", "") -s.anonymous = false -s.addremove = true - -s:tab("banmactab", translate("BanMac Menu")) - -banlist_mac = s:taboption("banmactab", Value, "banlist_mac", translate("MAC address")) -banlist_mac.rmempty = true -banlist_mac.datatype = "macaddr" -luci.sys.net.mac_hints(function(mac, name) - banlist_mac:value(mac, "%s (%s)" %{ mac, name }) -end) - -ban_mac = s:taboption("banmactab", Button, "ban_mac", translate("One-Click BAN")) -ban_mac.rmempty = false -ban_mac.inputstyle = "apply" -function ban_mac.write(self, section) - luci.util.exec("cp /usr/banmac/ban.sh /tmp/ban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") - luci.util.exec("/tmp/ban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") -end - -unban_mac = s:taboption("banmactab", Button, "unban_mac", translate("One-Click UnBAN")) -unban_mac.rmempty = false -unban_mac.inputstyle = "apply" -function unban_mac.write(self, section) - luci.util.exec("cp /usr/banmac/unban.sh /tmp/unban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") - luci.util.exec("/tmp/unban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") -end - -return m diff --git a/luci-app-banmac-nft/Makefile b/luci-app-banmac-nft/Makefile deleted file mode 100644 index 42bf6de51..000000000 --- a/luci-app-banmac-nft/Makefile +++ /dev/null @@ -1,32 +0,0 @@ -include $(TOPDIR)/rules.mk - -PKG_NAME:=luci-app-banmac-nft -PKG_VERSION:=20231012 -PKG_RELEASE:=1 - - -include $(INCLUDE_DIR)/package.mk - -define Package/luci-app-banmac-nft - SECTION:=luci - CATEGORY:=LuCI - SUBMENU:=3. Applications - TITLE:=LuCI Support for Ban Client - PKGARCH:=all -endef - -define Build/Compile -endef - -define Package/luci-app-banmac-nft/install - $(INSTALL_DIR) $(1)/usr/lib/lua/luci - cp -pR ./luasrc/* $(1)/usr/lib/lua/luci - $(INSTALL_DIR) $(1)/usr/banmac - $(INSTALL_BIN) ./files/banmac/* $(1)/usr/banmac - $(INSTALL_DIR) $(1)/etc - $(INSTALL_CONF) ./files/banmaclog $(1)/etc/banmaclog - $(INSTALL_DIR) $(1)/etc/config - $(INSTALL_CONF) ./files/banmac.conf $(1)/etc/config/banmac -endef - -$(eval $(call BuildPackage,luci-app-banmac-nft)) diff --git a/luci-app-banmac-nft/files/banmac.conf b/luci-app-banmac-nft/files/banmac.conf deleted file mode 100644 index 2b0df1a05..000000000 --- a/luci-app-banmac-nft/files/banmac.conf +++ /dev/null @@ -1,5 +0,0 @@ - -config banlist - -config banmac 'smartphone' - diff --git a/luci-app-banmac-nft/files/banmac/ban.sh b/luci-app-banmac-nft/files/banmac/ban.sh deleted file mode 100755 index 075d00a8f..000000000 --- a/luci-app-banmac-nft/files/banmac/ban.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -sectionname=$(echo $0 | cut -d '_' -f 2 | sed 's/^OO!%!OO//') -getcmac=$(uci get banmac.$sectionname.banlist_mac | tr 'A-Z' 'a-z') -iptables -I FORWARD -m mac --mac-source $getcmac -j DROP -hostname=$(grep -n $getcmac /tmp/dhcp.leases | cut -d ' ' -f 4) -hostip=$(grep -n $getcmac /tmp/dhcp.leases | cut -d ' ' -f 3) -echo "★禁网设备:$hostname($hostip) MAC地址:$getcmac 操作日期:$(date +%Y年%m月%d日\ %H点%M分%S秒)" >> /etc/banmaclog - -for i in $(seq 0 1);do - for j in $(seq 0 1);do - for x in $(iw dev phy${i}-ap${j} station dump | grep -i station | awk '{print$2}');do - if [ $x = $getcmac ]; then - iw dev phy${i}-ap${j} station del $x - fi - done - done -done diff --git a/luci-app-banmac-nft/files/banmac/unban.sh b/luci-app-banmac-nft/files/banmac/unban.sh deleted file mode 100755 index 42d7e704c..000000000 --- a/luci-app-banmac-nft/files/banmac/unban.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -sectionname=$(echo $0 | cut -d '_' -f 2 | sed 's/^OO!%!OO//') -getcmac=$(uci get banmac.$sectionname.banlist_mac | tr 'A-Z' 'a-z') -iptables -D FORWARD -m mac --mac-source $getcmac -j DROP - -sed -i "s/$(cat /etc/banmaclog | grep $getcmac | head -n 1)//;/^\s*$/d" /etc/banmaclog diff --git a/luci-app-banmac-nft/files/banmaclog b/luci-app-banmac-nft/files/banmaclog deleted file mode 100644 index 26906d62b..000000000 --- a/luci-app-banmac-nft/files/banmaclog +++ /dev/null @@ -1,3 +0,0 @@ -#点击 One-Click Ban 快速禁止指定客户端。点击 One-Click UnBan 快速解禁。 -#这里记录了客户端列表 ----------------------------------------------------------------------- diff --git a/luci-app-banmac-nft/luasrc/controller/banmac.lua b/luci-app-banmac-nft/luasrc/controller/banmac.lua deleted file mode 100644 index d78af5cba..000000000 --- a/luci-app-banmac-nft/luasrc/controller/banmac.lua +++ /dev/null @@ -1,7 +0,0 @@ -module("luci.controller.banmac",package.seeall) - -function index() - local page = entry({"admin", "services", "banmac"}, cbi("banmac"), _("BanMac"),5) - page.dependent = true -end - diff --git a/luci-app-banmac-nft/luasrc/model/cbi/banmac.lua b/luci-app-banmac-nft/luasrc/model/cbi/banmac.lua deleted file mode 100644 index 73e056bf4..000000000 --- a/luci-app-banmac-nft/luasrc/model/cbi/banmac.lua +++ /dev/null @@ -1,47 +0,0 @@ -m = Map("banmac", translate("BanMac")) - -local banlist = m:section(TypedSection, "banlist", translate("log")) -banlist.anonymous = true - -local bmdetails = "/etc/banmaclog" -local BMNXFS = require "nixio.fs" -bmd = banlist:option(TextValue, "details") -bmd.rows = 6 -bmd.wrap = "off" -bmd.cfgvalue = function(self, section) - return BMNXFS.readfile(bmdetails) or "" -end -bmd.write = function(self, section, value) - BMNXFS.writefile(bmdetails, value:gsub("\r\n", "\n")) -end - -s = m:section(TypedSection, "banmac", "") -s.anonymous = false -s.addremove = true - -s:tab("banmactab", translate("BanMac Menu")) - -banlist_mac = s:taboption("banmactab", Value, "banlist_mac", translate("MAC address")) -banlist_mac.rmempty = true -banlist_mac.datatype = "macaddr" -luci.sys.net.mac_hints(function(mac, name) - banlist_mac:value(mac, "%s (%s)" %{ mac, name }) -end) - -ban_mac = s:taboption("banmactab", Button, "ban_mac", translate("One-Click BAN")) -ban_mac.rmempty = false -ban_mac.inputstyle = "apply" -function ban_mac.write(self, section) - luci.util.exec("cp /usr/banmac/ban.sh /tmp/ban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") - luci.util.exec("/tmp/ban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") -end - -unban_mac = s:taboption("banmactab", Button, "unban_mac", translate("One-Click UnBAN")) -unban_mac.rmempty = false -unban_mac.inputstyle = "apply" -function unban_mac.write(self, section) - luci.util.exec("cp /usr/banmac/unban.sh /tmp/unban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") - luci.util.exec("/tmp/unban_OO!%!OO" ..section.. "_.sh >/dev/null 2>&1 &") -end - -return m diff --git a/luci-app-fwlive/Makefile b/luci-app-fwlive/Makefile index ef7afcf4a..a978740af 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.51 -PKG_RELEASE:=58 +PKG_RELEASE:=59 # 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/root/usr/libexec/fwlive-logging.sh b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh index 3429c0609..5f6e8453c 100755 --- a/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh +++ b/luci-app-fwlive/root/usr/libexec/fwlive-logging.sh @@ -1143,11 +1143,13 @@ enable_wan_logging() { current=$(wan_zone_log_value "$zone") if wan_filter_log_enabled "$current"; then - # Already-on with a lost baseline: reconstruct the usual first-enable - # snapshot (unset), not the current on-value. Recording "1" would make - # uninstall restore logging after disable. Skip-if-exists in - # the helper. Failure must not become baseline_snapshot_failed. - maybe_snapshot_wan_log_baseline "$zone" "" || true + # Already-on with a lost baseline: reconstruct the value before this + # package's owned filter-log bit was enabled. Preserve other log-mask + # bits so uninstall cannot erase unrelated logging settings. The helper + # skips an existing baseline. Failure stays best-effort and must not + # become baseline_snapshot_failed. + baseline=$(wan_filter_log_clear_value "$current") + maybe_snapshot_wan_log_baseline "$zone" "$baseline" || true if ! wan_log_generation_bump >/dev/null; then release_wan_log_lock wan_log_tracking_failed_json "$zone_json" diff --git a/luci-app-homeproxy-pro/Makefile b/luci-app-homeproxy-pro/Makefile index a9d3789d4..4b1333761 100644 --- a/luci-app-homeproxy-pro/Makefile +++ b/luci-app-homeproxy-pro/Makefile @@ -18,7 +18,7 @@ LUCI_DEPENDS:= \ PKG_NAME:=luci-app-homeproxy-pro PKG_VERSION:=28.10.1.14 -PKG_RELEASE:=18 +PKG_RELEASE:=19 LUCI_BASENAME:=homeproxy-pro diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/homeproxy-pro.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/homeproxy-pro.js index b33a481d0..01cf04886 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/homeproxy-pro.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/homeproxy-pro.js @@ -92,17 +92,16 @@ return baseclass.extend({ /* Parse sing-box's rule-set download failures out of a log. * - * With `ruleset_safe_start` on, a remote rule-set that cannot be - * downloaded is no longer a failed start: sing-box reads the `initial_path` - * fallback, comes up, and retries in the background, logging + * A remote rule-set is fetched during initialization, before the inbounds + * bind, so a failure there is a failed start: * - * 2026-10-03 01:15:42 ERROR router: fetch rule-set geoip-cn: Get "https://…": dial tcp … + * 2026-10-03 01:15:42 ERROR router: fetch rule-set example: Get "https://…": dial tcp … * - * That is a good trade, and it is also invisible: the instance is healthy, - * the health gate passes, the LAN is proxied - and the rule-set that is - * supposed to keep mainland traffic direct matches NOTHING, so all of it - * goes to `final`. The status page is the only place a user looks without - * ssh, so it has to show this. + * That is loud - but only in a log, and only for the rule-sets a user + * defined themselves (the built-in China split is local files, nothing to + * download). A start that failed leaves no running instance and no status + * page, so the reason is readable only over ssh. Surfacing it here is the + * whole job. * * Two deliberate choices about the format: * diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js index 0d76ba9a6..ae3e26ebb 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/status.js @@ -77,25 +77,19 @@ const ensureLogPoll = hp.statusPoller({ /* --- rule-set download failures ------------------------------------------ * - * With ruleset_safe_start on, a remote rule-set that cannot be downloaded no - * longer stops the service: sing-box reads the empty initial_path fallback, - * binds, and retries in the background. The instance is healthy and the LAN is - * proxied - and the rule-set that keeps mainland traffic direct matches - * nothing, so all of it goes to `final`. - * - * That is the trade, and it is the reason this block exists: the degradation - * has no other symptom. The health gate does not look at rule-sets at all - * (there is nothing in health.sh that could), so the service reports itself - * healthy while a rule-set is silently empty. + * A remote rule-set that cannot be downloaded stops the service: sing-box + * fetches it during initialization, before the inbounds bind, so a failure + * there is a failed start. That makes it a loud problem, and this block is + * here to make it legible rather than to solve it - a start that failed leaves + * no instance, so sing-box-c.log is the only place the reason is written down. * * sing-box-c.log is read rather than a new RPC: the ACL already grants the * browser read on that file for the log viewer below, so this costs no * permission and no new method to keep in step with the method table. * - * Whether a fallback is actually in play is read from UCI rather than guessed: - * with the opt-in OFF, a failing rule-set means the instance would not have - * started at all, which is a different and much more visible problem - so the - * wording has to differ, not just the colour. */ + * The built-in China split is not among the rule-sets this can be about: those + * are local files the resource updater maintains, generated from the same lists + * the nft sets are rendered from, and there is nothing to download. */ const ruleSetNode = E('div', { 'id': 'ruleset_status' }, E('img', { 'src': L.resource('icons/loading.svg'), @@ -112,7 +106,6 @@ function renderRuleSetStatus() { fs.read_direct(`${hp_dir}/sing-box-c.log`, 'text') .then((text) => { const failures = hp.parseRuleSetFetchFailures(text); - const safeStart = (uci.get('homeproxy-pro', 'config', 'ruleset_safe_start') || '0') === '1'; if (!failures.length) { dom.content(ruleSetNode, E('span', { 'style': 'color:green' }, @@ -125,9 +118,7 @@ function renderRuleSetStatus() { _('%d rule-set(s) cannot be downloaded.').format(failures.length) ]), E('p', { 'style': 'color:gray' }, [ - safeStart - ? _('Startup fallback in use: these rule-sets are EMPTY, so the traffic they would have split falls through to the default route. The service is up, but the split is not in effect until the download succeeds.') - : _('Startup fallback is OFF, so a rule-set that cannot be downloaded prevents the service from starting at all. The service is running, so the log below is from an earlier attempt or the download has since recovered.') + _('A remote rule-set is fetched before the service starts, so a rule-set that cannot be downloaded prevents the start. The service is running, so the log below is from an earlier attempt or the download has since recovered.') ]), E('ul', {}, failures.map((f) => E('li', [ E('code', [ f.tag ]), @@ -386,6 +377,50 @@ return view.extend({ return ruleSetNode; }; + /* A dedicated section rather than a fourth log view: the question this + * answers is "collect everything someone would ask me to look at, in + * one file", and the answer is a download rather than something to read + * on the page. The report is assembled by the backend because the + * commands that fill it (nft, ip rule, the package queries) are not + * browser-reachable; the ACL grants read on the output path only. + * + * The title is "Diagnostic report" and not the more natural + * "Diagnostics" on purpose: a shipped zh-cn catalog somewhere in the + * LuCI stack already renders a bare "Diagnostics" as 网络诊断, which + * reads as a network diagnostic tool rather than a generated file. + * A msgid this specific cannot collide with another package's. */ + s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Diagnostic report')); + s.anonymous = true; + + o = s.option(form.DummyValue, '_debug_report'); + o.rawhtml = true; + o.render = function() { + return E('div', { 'class': 'cbi-value' }, [ + E('p', {}, _('Collects system, dependency, routing, firewall and configuration state into one file. Credentials are masked by option name; public addresses and LAN topology are not. Read it before posting it anywhere.')), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, () => { + return hp.rpcCall('debug_report', [], { expect: { '': {} } }) + .then((res) => { + if (!res.result) + throw new Error(res.error || _('Could not build the report.')); + return fs.read_direct(res.path, 'blob'); + }) + .then((blob) => { + const url = window.URL.createObjectURL(blob, { type: 'text/markdown' }); + const link = document.createElement('a'); + link.href = url; + link.download = 'homeproxy-pro-debug.log'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }); + }) + }, [ _('Generate and download') ]) + ]); + }; + s = m.section(form.NamedSection, 'config', 'homeproxy-pro'); s.anonymous = true; diff --git a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot index 198f70456..239b47860 100644 --- a/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot +++ b/luci-app-homeproxy-pro/po/templates/homeproxy-pro.pot @@ -441,6 +441,15 @@ msgstr "" msgid "Collecting data..." msgstr "" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:408 +msgid "" +"Collects system, dependency, routing, firewall and configuration " +"state into one file. Credentials are masked by option name; public " +"addresses and LAN topology are not. Read it before posting it " +"anywhere." +msgstr "" + + #: htdocs/luci-static/resources/view/homeproxy-pro/client/routing.js:105 msgid "Common ports only (bypass P2P traffic)" msgstr "" @@ -461,6 +470,11 @@ msgstr "" msgid "Conservative" msgstr "" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:415 +msgid "Could not build the report." +msgstr "" + + #: htdocs/luci-static/resources/view/homeproxy-pro/node.js:823 msgid "Could not start subscription update: %s" msgstr "" @@ -569,6 +583,12 @@ msgstr "" msgid "Default timeout per DNS query in seconds (sing-box default: 10)." msgstr "" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:401 +msgid "Diagnostic report" +msgstr "诊断报告" + + + #: htdocs/luci-static/resources/homeproxy-pro.js:217 #: htdocs/luci-static/resources/view/homeproxy-pro/client/common.js:335 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:97 @@ -1048,6 +1068,11 @@ msgstr "" msgid "Generate" msgstr "" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:429 +msgid "Generate and download" +msgstr "" + + #: htdocs/luci-static/resources/view/homeproxy-pro/status.js:357 msgid "GitHub token" msgstr "" diff --git a/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po b/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po index 8a392ad8a..3ca75a6c0 100644 --- a/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po +++ b/luci-app-homeproxy-pro/po/zh_Hans/homeproxy-pro.po @@ -454,6 +454,15 @@ msgstr "Cloudflare" msgid "Collecting data..." msgstr "正在收集数据中..." +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:408 +msgid "" +"Collects system, dependency, routing, firewall and configuration " +"state into one file. Credentials are masked by option name; public " +"addresses and LAN topology are not. Read it before posting it " +"anywhere." +msgstr "将系统、依赖、路由、防火墙和配置状态汇总到一个文件。凭据按选项名脱敏,但公网地址和内网拓扑不会脱敏。发到任何地方之前请先阅读。" + + #: htdocs/luci-static/resources/view/homeproxy-pro/client/routing.js:105 msgid "Common ports only (bypass P2P traffic)" msgstr "仅常用端口(绕过 P2P 流量)" @@ -474,6 +483,11 @@ msgstr "连接复用" msgid "Conservative" msgstr "保守" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:415 +msgid "Could not build the report." +msgstr "无法生成诊断报告。" + + #: htdocs/luci-static/resources/view/homeproxy-pro/node.js:823 msgid "Could not start subscription update: %s" msgstr "无法启动订阅更新:%s" @@ -582,6 +596,12 @@ msgstr "默认服务器名称" msgid "Default timeout per DNS query in seconds (sing-box default: 10)." msgstr "每次 DNS 查询的默认超时时间(秒,sing-box 默认为 10)。" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:401 +msgid "Diagnostic report" +msgstr "诊断报告" + + + #: htdocs/luci-static/resources/homeproxy-pro.js:217 #: htdocs/luci-static/resources/view/homeproxy-pro/client/common.js:335 #: htdocs/luci-static/resources/view/homeproxy-pro/client/nodes.js:97 @@ -1077,6 +1097,11 @@ msgstr "Gecko(1.14)" msgid "Generate" msgstr "生成" +#: htdocs/luci-static/resources/view/homeproxy-pro/status.js:429 +msgid "Generate and download" +msgstr "生成并下载" + + #: htdocs/luci-static/resources/view/homeproxy-pro/status.js:357 msgid "GitHub token" msgstr "GitHub 令牌" diff --git a/luci-app-homeproxy-pro/root/etc/config/homeproxy-pro b/luci-app-homeproxy-pro/root/etc/config/homeproxy-pro index 7bafdab9e..9daa1c7aa 100644 --- a/luci-app-homeproxy-pro/root/etc/config/homeproxy-pro +++ b/luci-app-homeproxy-pro/root/etc/config/homeproxy-pro @@ -55,7 +55,6 @@ config homeproxy-pro 'config' # With it off, a cold cache on a fresh install waits for # raw.githubusercontent.com (through the selected node) before the instance # will listen at all, and a failure there fails the whole start. - option ruleset_safe_start '0' option tun_dns_mode '' option udp_mapping '' option udp_filtering '' diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.source.json b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.source.json deleted file mode 100644 index 78913c829..000000000 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.source.json +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"rules":[]} \ No newline at end of file diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.srs b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.srs deleted file mode 100644 index 075a79548..000000000 Binary files a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/ruleset/initial/empty.srs and /dev/null differ diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/loader.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/loader.uc index a67ec4867..5d6d72520 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/loader.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/config/loader.uc @@ -404,7 +404,6 @@ export const Loader = { * configuration generated cleanly, `sing-box check` passed, and the * feature simply never turned on. Guard 53 now compares the two * lists so that class cannot come back quietly. */ - ruleset_safe_start: opt(uci, 'main', 'ruleset_safe_start'), main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [], main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'), main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'), diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut index 648610034..dec368b8a 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/firewall_post.ut @@ -59,6 +59,49 @@ function resolve_ipv6(str) { return ipv6.addr; } +/* The node server addresses the generator exported (generate_client.uc's + * export_node_addresses()), as an nftables anonymous set - or null when there + * is nothing to protect. + * + * Only the literal-address form is rendered here. A node configured with a + * host name reaches the same set at runtime: the dnsmasq snippet runtime/dns.sh + * writes resolves the name and back-fills the set through `nftset=`, so one + * mechanism covers both without this template needing a resolver of its own. + * A literal address has no name to resolve, so it can only come from here. + * + * readfile() rather than a UCI read on purpose: the generator is the only + * place that knows which nodes are actually in use (main_node, main_udp_node, + * urltest membership, custom-mode routing_nodes, wireguard endpoints), and a + * copy of that selection in this template is a copy that can drift. A missing + * file renders null, which is the behaviour before this existed. */ +function node_addr_to_nftarr(run_dir, family) { + let raw = readfile(run_dir + '/node-addr-ips.txt'); + if (isEmpty(raw)) + return null; + + let out = []; + for (let entry in split(trim(raw), /[\r\n]/)) { + let parsed = fw4.parse_subnet(entry)?.[0]; + if (!parsed || parsed.family !== family) + continue; + push(out, (parsed.bits > -1) ? `${parsed.addr}/${parsed.bits}` : parsed.addr); + } + + return isEmpty(out) ? null : array_to_nftarr(out); +} + +/* Whether the node bypass has anything to protect at all. + + Either export file counts. The domains file is the one that matters most: + a host-name node - the common case, and the one the device this was written + for uses - appears only there, and its set has to exist before dnsmasq can + put anything in it. Gating on the literal-address file instead is what left + the whole mechanism doing nothing. */ +function node_addr_protected(run_dir) { + return !isEmpty(readfile(run_dir + '/node-addr-ips.txt')) + || !isEmpty(readfile(run_dir + '/node-addr-domains.txt')); +} + /* Normalize a list of IPv6 entries into an nftables anonymous set (or null when empty), so IPv6 ACLs match in a single set lookup instead of one rule per address. */ @@ -140,6 +183,26 @@ function dnsmasq_nftset() { /* Misc config */ const resources_dir = '/etc/homeproxy-pro/resources'; +const run_dir = '/var/run/homeproxy-pro'; + +/* Resolved here, in the ucode block, rather than with `{% set %}` in the + * template body below: utpl's `set` tag takes a plain expression and rejects a + * call outright - `{% set x = f(); %}` is a parse error ("Unexpected token, + * Expecting ';'"). A parse error kills the whole render, fw4_post.nft comes + * out empty, and the health gate silently keeps the previous ruleset, so a + * template that fails this way does not look broken at all. r41 shipped + * `{% set node_addr_v4 = node_addr_to_nftarr(4); %}` and never rendered once. + * + * Both helpers take run_dir as a parameter rather than closing over it. In + * this ucode a closure captures the block variables that already exist where + * the function is DEFINED, not where it is called: a function defined above + * `const run_dir` cannot see run_dir even when it is called far below, and + * the error is "Reference error: access to undeclared variable run_dir" - + * which reads as though run_dir were missing, when it is in fact declared 80 + * lines above the call. Passing it in is the only spelling that works. */ +const node_protect = node_addr_protected(run_dir); +const node_addr_v4 = node_addr_to_nftarr(run_dir, 4); +const node_addr_v6 = node_addr_to_nftarr(run_dir, 6); /* UCI config start */ const cfgname = 'homeproxy-pro'; @@ -399,6 +462,42 @@ set homeproxy_routing_port { } {% endif %} +{# Node server addresses. + Declared when the generator exported any node address at all - and NOT + only when there is a literal address to put in it. + + That distinction is the whole point of the set existing. A node configured + with a host name contributes no element here; dnsmasq resolves the name and + back-fills this set through the nftset= snippet runtime/dns.sh writes. A + set that is declared only when it already has elements is therefore never + declared for a host-name node, the back-fill has nothing to land in, and the + return rules below - which are what actually exclude the address - are not + emitted either. r41 gated both on the literal form and was inert for the + configuration it was written for. + + An empty set is harmless: nft accepts one with no elements, and the cost is + one lookup per packet until the first answer back-fills it. #} +{% if (node_protect): %} +set homeproxy_node_addr_v4 { + type ipv4_addr + flags interval + auto-merge +{% if (node_addr_v4): %} + elements = { {{ node_addr_v4 }} } +{% endif %} +} +{% endif %} +{% if (node_protect && v6_handled): %} +set homeproxy_node_addr_v6 { + type ipv6_addr + flags interval + auto-merge +{% if (node_addr_v6): %} + elements = { {{ node_addr_v6 }} } +{% endif %} +} +{% endif /* node_addr_v6 */ %} + {# DNS hijack & TCP redirect #} chain dstnat { {% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %} @@ -461,6 +560,13 @@ chain homeproxy_redirect_lanac { chain homeproxy_redirect { meta mark {{ self_mark }} counter return + {% if (node_protect): %} + ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" + {% endif %} + {% if (node_protect && v6_handled): %} + ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" + {% endif %} + ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port @@ -582,6 +688,13 @@ chain homeproxy_mangle_lanac { } chain homeproxy_mangle_prerouting { + {% if (node_protect): %} + ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" + {% endif %} + {% if (node_protect && v6_handled): %} + ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" + {% endif %} + ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port @@ -646,6 +759,13 @@ chain homeproxy_mangle_prerouting { chain homeproxy_mangle_output { meta mark {{ self_mark }} counter return + {% if (node_protect): %} + ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address" + {% endif %} + {% if (node_protect && v6_handled): %} + ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address" + {% endif %} + ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark {% if (v6_handled): %} ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/fw4_names.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/fw4_names.sh index 66fe94f73..bff3b4e23 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/fw4_names.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/fw4_names.sh @@ -44,5 +44,7 @@ HP_FW4_SETS=" homeproxy_wan_proxy_addr_v6 homeproxy_wan_direct_addr_v4 homeproxy_wan_direct_addr_v6 + homeproxy_node_addr_v4 + homeproxy_node_addr_v6 homeproxy_routing_port " diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc index 0c1b9f3c8..918b4b829 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generate_client.uc @@ -29,8 +29,8 @@ import { lstat, mkdtemp, readfile, writefile } from 'fs'; import { Loader } from './config/loader.uc'; import { generate } from './generator/client.uc'; -import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, rule_set_tags } from './generator/common.uc'; -import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ruleSetFormatFromPath, ruleSetInitialFallback, RULESET_EMPTY_BINARY, RULESET_EMPTY_SOURCE, RULESET_INITIAL_DIR, shellQuote, validateRuleSetPath, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc'; +import { rule_set_tags } from './generator/common.uc'; +import { removeBlankAttrs, isEmpty, isValidCIDR, probeRuleSetFile, ruleSetFormatFromPath, shellQuote, validateRuleSetPath, validation, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc'; /* Resolve the GenerationContext inputs. This is the only impure step on the * client generation path, and it is deliberately here rather than under @@ -46,69 +46,87 @@ import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ru * * The extra parentheses around the ubus call keep the ?. chain guarded when * connect() returns null. */ -/* Write one EMPTY rule-set to , in . + +/* Normalise one server address into `host`, or return null when there is + * nothing usable. WireGuard peers and the plain outbounds disagree on the + * field name and the IPv6 form, so this is the only place that knows both. */ +function node_host_of(v) { + if (isEmpty(v)) + return null; + + let host = trim(v); + + /* `[fdfe::1]` is how an IPv6 literal arrives from a UCI value; the set + * syntax wants the bare address. */ + if (length(host) > 2 && substr(host, 0, 1) === '[' && substr(host, -1, 1) === ']') + host = substr(host, 1, -2); + + return isEmpty(host) ? null : host; +} + +/* Export the node server addresses, split by form, into two files the + * intercept layer reads: * - * The fallback exists so that a remote rule-set with no initial file of the - * user's own does not have to be downloaded before the inbounds bind - and on - * a cold cache that is the difference between a router that comes up and one - * that waits on raw.githubusercontent.com (through the node) before it will - * listen at all. + * RUN_DIR/node-addr-ips.txt literal addresses, rendered straight into + * the nft set by firewall_post.ut + * RUN_DIR/node-addr-domains.txt host names, handed to dnsmasq as + * `server=` + `nftset=` so the addresses they + * resolve to land in the same set * - * binary is produced by compiling, not by writing bytes: a file produced by - * the sing-box that is running cannot drift from it, and the compiler is - * always there because the very next thing this script does is run it. The - * shipped 14-byte constant is the fallback for the fallback - a router where - * the compile cannot run - and it is a file in the package rather than a byte - * string in the source, so `hexdump -C` on a device can confirm it. + * Why the set exists at all: sing-box's own outbound already carries + * `routing_mark: self_mark` and every chain returns on that mark, so the + * tunnel's connection to the node is never re-intercepted. The LAN's is not. + * A client opening a connection to the node's own address - the panel, an SSH + * session to the landing box, anything on a port the proxy covers - was + * redirected into sing-box, which then dialled the very address it was asked + * for through the tunnel to that same node: one extra hairpin hop, and the + * node sees the connection arriving from itself. * - * Returns true when holds a usable file afterwards. A false return is - * not fatal: the caller skips the initial_path, and the rule-set then behaves - * exactly as it does today (fetched during initialization, blocking on - * failure). That is the point of the whole opt-in - it must never make a - * situation worse than not having it. */ -function writeEmptyRuleSet(path, format) { - const ext = ruleSetFormatExtension(format); + * The generator is the only place that knows which nodes are actually in use + * (main_node, main_udp_node, urltest membership, custom-mode routing_nodes, + * wireguard endpoints), so it is the only place that can produce this list + * without the firewall template re-deriving that selection and drifting. + * + * Failure is not fatal. A missing file means the set renders empty, which is + * exactly the behaviour before this existed. */ +function export_node_addresses(config) { + const addrs = []; + const domains = []; - if (!ext) - return false; + const add = (v) => { + const host = node_host_of(v); + if (host === null) + return; + if (isValidCIDR(host, 4) || isValidCIDR(host, 6)) + push(addrs, host); + else if (validation('hostname', host)) + push(domains, host); + }; - system('mkdir -p ' + shellQuote(RULESET_INITIAL_DIR)); + for (let ob in (config.outbounds || [])) + add(ob.server); - if (format === 'source') { - /* No compile needed, and no temp file: the source form is the JSON - * itself. The shipped empty.source.json is the source of record, so - * a missing one means the install is incomplete and the copy is the - * thing that says so. */ - if (system('cp -f ' + shellQuote(RULESET_EMPTY_SOURCE) + ' ' + shellQuote(path)) !== 0) - return false; - } else { - /* Compile into the destination directly. A partial output is - * possible if the compile dies, so the file is only accepted when the - * compile succeeded AND something is there - sing-box opening a - * truncated .srs would block startup, which is the outcome this is - * all meant to prevent. - * - * `-o` rather than `--output`, because that is the form - * tests/ucode/test_generators.sh already exercises against this - * binary, and a flag spelling that only ever runs in production is a - * flag spelling nobody has ever seen work. */ - if (system('sing-box rule-set compile ' + shellQuote(RULESET_EMPTY_SOURCE) - + ' -o ' + shellQuote(path) + ' >/dev/null 2>&1') !== 0) { - if (system('cp -f ' + shellQuote(RULESET_EMPTY_BINARY) + ' ' + shellQuote(path)) !== 0) - return false; - } + /* WireGuard moved from `outbounds` to `endpoints` in 1.13, and the peer + * address is the node there. */ + for (let ep in (config.endpoints || [])) + for (let peer in (ep.peers || [])) + add(peer.address); - const st = lstat(path); - if (!st || st.type !== 'file' || st.size <= 0) - return false; - } + const dump = (path, list) => { + if (isEmpty(list)) + return; + /* ucode's join() takes the separator FIRST: join(sep, list). With + * the arguments the other way round it does not raise, it returns + * null - and null + '\n' is the four-character string "null\n", so + * the file comes out non-empty and every "is it there?" check on + * it passes. That is exactly how a released r41 ended up with + * dnsmasq resolving a host literally named "null". */ + if (writefile(path, join('\n', uniq(list)) + '\n') == null) + warn(sprintf('homeproxy-pro: could not write %s\n', path)); + }; - /* The jailed client reads this as the sing-box user. writefile() has no - * mode argument and cp preserves the source's, so the mode is set - * explicitly rather than inherited from whatever umask was in force. */ - system('chmod 644 ' + shellQuote(path)); - - return true; + dump(RUN_DIR + '/node-addr-ips.txt', addrs); + dump(RUN_DIR + '/node-addr-domains.txt', domains); } function resolve_env(dm) { @@ -147,6 +165,11 @@ function resolve_env(dm) { * argument to get wrong - and because the neighbouring stderr-size * check in homeproxy-pro.uc already reads sizes through it. */ china_ip6_ready: lstat(HP_DIR + '/resources/china_ip6.json') !== null, + /* The DNS half of the same split: whether china-domain.json is + * there. Same reason, same lstat(), same one-sided default - a + * missing file has to leave the DNS rule out rather than name a path + * that does not exist and fail the whole start. */ + china_domain_ready: lstat(HP_DIR + '/resources/china-domain.json') !== null, /* Which enabled `type: local` rule-sets have a usable file on disk, * keyed by UCI section name. * @@ -205,7 +228,6 @@ function resolve_env(dm) { /* Which empty startup fallbacks are actually on disk, keyed by rule_set * tag: { '': true }. Presence, not a path - see the block at the * end of this function. */ - ruleset_initial: {} }; if (routing_mode === 'custom') { @@ -302,92 +324,6 @@ function resolve_env(dm) { } } - /* ruleset_safe_start: write the empty fallbacks. - * - * Recorded as PRESENCE, not as a path: env.ruleset_initial[tag] is true - * only once a file for that tag is on disk and readable. The generator - * then emits `initial_path` for exactly the tags it can point at, which - * is what keeps the two sides from disagreeing - a file that could not be - * written is simply not offered, and that rule-set keeps today's - * behaviour (fetched during initialization) instead of pointing at - * something that is not there. sing-box ignores a missing initial_path - * and blocks startup exactly as if none had been configured, so this - * cannot make a broken install worse - but the point of the opt-in is - * that it helps, and a pointer to a nonexistent file helps nobody. - * - * Built-ins first, then the user's own. Both come from the same helper - * the generator uses, so the format the file was written in and the - * format declared in the config are decided by one question asked once. */ - if (dm.general.ruleset_safe_start === '1') { - if (declaresBuiltinRemoteRuleSets(routing_mode)) { - for (let rs in BUILTIN_REMOTE_RULE_SETS) { - const path = ruleSetInitialFallback([ rs.tag ], rs.format, rs.url); - if (path && writeEmptyRuleSet(path, rs.format)) - env.ruleset_initial[rs.tag] = true; - else - warn(sprintf("homeproxy-pro: could not write the empty fallback for '%s'; this rule-set will be fetched during startup, as before.", rs.tag)); - } - } - - /* User rule-sets are custom-mode only - build_user_rulesets() is not - * called in any other mode - so writing fallbacks for them anywhere - * else would create files nothing ever points at. A rule-set with an - * initial file of its own is left completely alone. */ - if (routing_mode === 'custom') { - for (let cfg in (dm.routing.rulesets || [])) { - if (!cfg.enabled || cfg.type !== 'remote' || !isEmpty(cfg.initial_path)) - continue; - - const format = cfg.format || ruleSetFormatFromPath(cfg.url); - const tags = rule_set_tags(cfg); - const template = ruleSetInitialFallback(tags, format, cfg.url); - - if (!template) { - warn(sprintf("homeproxy-pro: rule-set '%s' has no usable format for the startup fallback (format '%s', url '%s'); it will be fetched during startup, as before.", cfg.name, cfg.format || '', cfg.url || '')); - continue; - } - - for (let tag in tags) { - if (env.ruleset_initial[tag]) - continue; - - /* A single-tag rule-set's template IS the path; a - * multi-tag one carries {tag} and needs one file each - - * sing-box opens every tag's file, so a missing second - * one blocks startup as surely as a missing first. */ - const path = (length(tags) > 1) ? replace(template, '{tag}', tag) : template; - - if (writeEmptyRuleSet(path, format)) - env.ruleset_initial[tag] = true; - else - warn(sprintf("homeproxy-pro: could not write the empty fallback for rule-set '%s' (tag %s); it will be fetched during startup, as before.", cfg.name, tag)); - } - } - } - } - - /* One line naming what is running on an empty fallback. - * - * The CLI already warns per rule-set when a fallback could not be written, - * but "which rule-sets are currently on an EMPTY one" is the fact a user - * needs and nothing else records it: the running configuration is not - * readable by the browser, the health gate does not look at rule-sets, and - * sing-box's own log only says the download failed. This line is the - * durable answer, and it goes to homeproxy-pro.log, which the status page can - * read with the permission it already has. - * - * Emitted only when the opt-in is on AND something actually got a - * fallback, so the log does not gain a line on every generation of a - * configuration that is not using the feature. */ - if (dm.general.ruleset_safe_start === '1' && !isEmpty(env.ruleset_initial)) { - const tags = []; - - for (let tag in env.ruleset_initial) - push(tags, tag); - - warn(sprintf("homeproxy-pro: ruleset_safe_start is on; these rule-sets have an EMPTY initial file and match nothing until their download succeeds: %s.", join(', ', tags))); - } - if (routing_mode !== 'custom') { const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt'); env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : []; @@ -432,6 +368,11 @@ if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) { exit(1); } +/* Only now, with a configuration sing-box has accepted: the firewall and the + * dnsmasq snippets read the exported list, and they must never be pointed at + * addresses from a configuration that does not start. */ +export_node_addresses(config); + if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) { system('rm -rf ' + shellQuote(work_dir)); exit(1); diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/common.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/common.uc index 4524b4a52..9cd567df2 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/common.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/common.uc @@ -18,7 +18,7 @@ 'use strict'; -import { isEmpty } from '../homeproxy-pro.uc'; +import { isEmpty, HP_DIR } from '../homeproxy-pro.uc'; import { ConfigQuery } from '../config/model.uc'; @@ -142,43 +142,42 @@ export function rule_set_tags(cfg) { return tags; }; -/* BUILTIN_REMOTE_RULE_SETS - the remote rule-sets the generator declares - * itself, in the modes that split on mainland China. +/* The routing modes that split on mainland China, and the rule-sets they + * declare. * - * geoip-cn referenced by the route rule to split destinations by IP - * geosite-cn referenced only by the DNS rule (server: china-dns); the - * route layer never matches it, but sing-box still loads and - * keeps it, so removing it would break the DNS split + * The three of them - china-ip, china-ip6, china-domain - are local files the + * resource updater generates, one per list, and each is declared only when + * something references it and its file is on disk. route.uc owns that + * decision because the conditions are per-tag (the address list in both + * mainland modes, the v6 half only with IPv6 on, the domain list only in + * bypass mode), so there is nothing here for a shared list to keep in step + * with. * - * This list is shared with generate_client.uc, which has to create the - * "do not block startup" fallback file for each of them before the generator - * runs. The two used to be independent: route.uc hard-coded the entries and - * the CLI knew nothing about them, which is why a cold-cache first start - * blocked on downloading them and why a change to either side could not be - * checked against the other. One list, two readers. + * They were `type: remote` entries pointing at SagerNet's sing-geoip and + * sing-geosite repositories, and two things followed from that which the local + * arrangement does not have: * - * Not a conffile-order dependency - this is the generator's own declaration, - * in the order the generated rule_set array must keep so the emitted bytes - * stay stable. */ -export const BUILTIN_REMOTE_RULE_SETS = [ - { - tag: 'geoip-cn', - format: 'binary', - url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs', - update_interval: '24h' - }, - { - tag: 'geosite-cn', - format: 'binary', - url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs', - update_interval: '24h' - } -]; - -/* The routing modes that declare them. Kept beside the list so the CLI can - * decide whether it needs to create their fallbacks without repeating the - * condition route.uc branches on - and so the two cannot drift. */ -export function declaresBuiltinRemoteRuleSets(routing_mode) { + * - a cold start had to download them before the inbounds bind, over the + * node (download_detour), so a first start - or a start after the cache + * was cleared - depended on GitHub being reachable through the proxy it + * was trying to bring up; + * - the two halves of the same split read two different files. The kernel's + * came from china_ip4.txt and the resolver's from a .srs, and the route.uc + * comment about them disagreeing is the scar of patching that instead of + * fixing it. + * + * Now the nft set and the sing-box rule-set are both generated from the same + * list, on the same schedule, with nothing to download. + * + * Source JSON, not a compiled .srs: the updater writes the canonical form and + * sing-box watches the file, so a new list is picked up in place. Compiling + * first was measured rather than assumed - 111k domain suffixes cost 3.7 MB of + * RSS in source form (44.9 MB baseline vs 48.7 MB loaded), which is not worth + * a second artifact and a second failure mode. */ +/* The routing modes that split on mainland China. The China domain list is + * only meaningful where the split is by domain: proxy_mainland_china decides + * by address on the route side and leaves the resolver to the mode default. */ +export function declaresBuiltinRuleSets(routing_mode) { return routing_mode === 'bypass_mainland_china' || routing_mode === 'proxy_mainland_china'; }; diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc index 8d407bb84..afeb752d7 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/context.uc @@ -138,6 +138,11 @@ export function build_context(dm, env) { * whole config. Default false - an absent env must not turn into an * optimistic "assume the file is there". */ china_ip6_ready: (env?.china_ip6_ready === true), + /* Whether china-domain.json is on disk. Same one-sided default as + * the field above, for the same reason: it is a filesystem fact the + * CLI resolves, and an absent env must not be read as "assume the + * file is there". */ + china_domain_ready: (env?.china_domain_ready === true), /* Which enabled `type: local` rule-sets have a usable file on disk, * keyed by UCI section name: { '
': true }. * @@ -256,29 +261,6 @@ export function build_context(dm, env) { * Default '0' preserves the * 300ms / default-list behaviour so an upgrade is invisible. */ sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0', - /* ruleset_safe_start: when '1' (opt-in), every enabled remote - * rule-set that has no initial_path of its own gets one, pointing at - * an EMPTY rule-set the generator writes before the config is - * checked. A remote rule-set with no initial file is fetched during - * initialization, before the inbounds bind, so on a cold cache the - * first start depends on the CDN being reachable - and on the node, - * because the fetch goes through http_clients. - * - * The fallback is empty on purpose and that is the trade: a rule-set - * with no rules matches nothing, so its traffic falls through to - * `final`. That is a routing change, not a robustness tweak, which - * is why the default is '0' and why the whole repository stays - * fail-loud by default. Guard 52 pins the default, the way guard 39 - * pins sniffer_advanced_mode's. */ - ruleset_safe_start: dm.general.ruleset_safe_start || '0', - /* Which empty startup fallbacks the CLI actually wrote, keyed by - * rule_set tag. Presence, not a path: the generator emits - * `initial_path` only for tags it can point at, so a fallback that - * could not be written leaves that rule-set on today's behaviour - * rather than pointing at a file that is not there. Pessimistic by - * construction - an env that forgot the field (the server path) has - * no fallbacks, and the server declares no remote rule-sets anyway. */ - ruleset_initial: (env?.ruleset_initial || {}), main_urltest_nodes: dm.general.main_urltest_nodes || [], main_urltest_interval: dm.general.main_urltest_interval, main_urltest_tolerance: dm.general.main_urltest_tolerance, diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc index bfe6d4be6..bbfc58bbd 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/dns.uc @@ -278,11 +278,22 @@ function append_proxy_dns(config, dm, ctx) { server: 'default-dns' }); - push(config.dns.rules, { - rule_set: 'geosite-cn', - action: 'route', - server: 'china-dns' - }); + /* The China domain list, generated from china_list.txt - the same + * schedule and the same updater as the address list the route and + * firewall halves read. It replaced geosite-geolocation-cn.srs, + * which had to be downloaded before the inbounds could bind. + * + * Only with the file there: a rule naming a tag nothing declares + * fails the config, and a rule-set whose path is missing fails it + * too, so both the declaration (route.uc) and this reference are + * gated on the same lstat. */ + if (ctx.china_domain_ready) { + push(config.dns.rules, { + rule_set: 'china-domain', + action: 'route', + server: 'china-dns' + }); + } /* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */ if (ctx.cn_ip_fallback === '1') { @@ -292,18 +303,17 @@ function append_proxy_dns(config, dm, ctx) { tag: 'cn-fallback' }); /* The response is matched by the addresses the upstream returned, - * and geoip-cn has no IPv6 in it, so with IPv6 support on a - * mainland domain that resolved to an AAAA address was never - * recognised as mainland and its answer kept coming from the - * proxy resolver. Adding the v6 rule-set here is what makes the - * DNS half of the split agree with the route half, which - * classifies the same destinations through the same list - * (route.uc: china-ip6). Both tags have to be declared before - * this rule runs, which is why the list is built here rather than - * inlined: a rule_set naming an undeclared tag fails the config. - * china-ip6 is only declared when its file exists, so it is only - * named here under the same condition. */ - const cn_fallback_sets = ['geoip-cn']; + * and the address list has to be the one the route half uses for + * the same decision to agree with it - so this names china-ip, not + * a list of its own. It is the same reason the declaration lives + * in common.uc. + * + * china-ip6 is added only when it is declared: the rule_set list + * has to name declared tags only, or the config is rejected. + * Without it a mainland domain that resolved to an AAAA address + * is never recognised as mainland and its answer keeps coming + * from the proxy resolver. */ + const cn_fallback_sets = ['china-ip']; if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) push(cn_fallback_sets, 'china-ip6'); push(config.dns.rules, { diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc index 943797bbf..3a040cf11 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/route.uc @@ -26,9 +26,9 @@ 'use strict'; -import { isEmpty, strToInt, strToTime, strToBool, parse_port, ruleSetInitialFallback, HP_DIR } from '../homeproxy-pro.uc'; +import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc'; -import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc'; +import { declaresBuiltinRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc'; /* --- shared initial block (every routing mode) ------------------------- */ @@ -93,10 +93,10 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { outbound: 'direct-out' }); - /* Split by destination IP, in both directions. + /* Split by destination address, in both directions. * - * sing-box does not match an IP-based rule set (geoip-cn) against a - * domain destination unless it is resolved first, so an explicit resolve + * sing-box does not match an address-based rule set against a domain + * destination unless it is resolved first, so an explicit resolve * action comes first. Which way the split points is the mode's default * policy (ctx.proxy_fallback): * @@ -105,10 +105,17 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { * proxy_mainland_china mainland -> proxy, the rest falls through to * direct-out * - * Using geoip-cn rather than the geosite-* domain lists is deliberate: - * those mis-classify foreign domains (Google's gvt2.com beacons) as "cn" - * and would send them direct to time out. Keep the direct-domain - * fast-path above for known direct domains. */ + * Using china-ip rather than a domain list is deliberate: those + * mis-classify foreign domains (Google's gvt2.com beacons) as "cn" and + * would send them direct to time out. Keep the direct-domain fast-path + * above for known direct domains. + * + * One list for both halves of the split: china-ip is generated from the + * same china_ip4.txt the firewall renders homeproxy_mainland_addr_v4 + * from, so the kernel and the resolver cannot disagree about what counts + * as mainland. It used to be geoip-cn, with china-ip added later as a + * correction for the two disagreeing - two rule-sets, one of them + * redundant in this mode, neither able to see the other's data. */ if (ctx.routing_mode === 'bypass_mainland_china' || ctx.routing_mode === 'proxy_mainland_china') { push(config.route.rules, { @@ -116,37 +123,20 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null }); push(config.route.rules, { - rule_set: 'geoip-cn', + rule_set: 'china-ip', action: 'route', outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out' }); - } - if (ctx.routing_mode === 'bypass_mainland_china') { - /* geoip-cn.srs is upstream's list; the firewall's mainland set is - * built from our own china_ip4.txt (firewall_post.ut reads it - * directly). The two disagree - the bundled list has 8.152.0.0/13 - * (Alibaba Cloud) and geoip-cn.srs does not - so a connection into - * that range passes the firewall as "not mainland" and would then be - * sent direct by the rule above instead of to the proxy. This local - * rule-set is generated from the same china_ip4.txt, which is what - * makes both sides decide from one list. In - * proxy_mainland_china it is not needed twice: the geoip-cn rule - * already selects the mainland side for the proxy. */ - push(config.route.rules, { - rule_set: 'china-ip', - action: 'route', - outbound: 'direct-out' - }); - - /* IPv6 half of the same split, and the reason a mainland site opened - * over IPv6 used to go through the proxy even with IPv6 support on. - * geoip-cn.srs carries no IPv6 and china_ip4.json is generated from an - * IPv4 list, so a destination that resolved to an AAAA address matched - * neither and fell through to `final` - which in this mode is main-out. - * The rule is emitted in both mainland modes (unlike china-ip, which - * proxy_mainland_china does not need twice because geoip-cn already - * picks the mainland side there): with no IPv6 in geoip-cn there is - * nothing left to pick it, so the side has to be named explicitly. + /* The IPv6 half of the same split, and the reason a mainland site + * opened over IPv6 used to go through the proxy even with IPv6 + * support on. china_ip4.json is generated from an IPv4 list, so a + * destination that resolved to an AAAA address matched nothing and + * fell through to `final` - which in bypass mode is main-out. The + * rule is emitted in both mainland modes for that reason. Declared + * only when china_ip6.json is actually there: a rule_set pointing at + * a missing file takes the whole config down with it, and a router + * whose v6 list is unusable must still start (the firewall has + * already degraded to passing IPv6 through, with a warning). * `prefer_ipv4` on the resolve rule above is what lets both families * through - it biases, it does not filter. */ if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) { @@ -211,90 +201,36 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { ] }); - /* Both mainland modes split on geoip-cn, so both need it declared - it is - * what the route rule added above matches. `proxy_mainland_china` used - * to reach this point without it and sing-box refused the whole config - * with "initialize rule[3]: rule-set not found: geoip-cn", which the - * health gate turned into a rollback and an unproxied network. */ - if (declaresBuiltinRemoteRuleSets(ctx.routing_mode)) { - /* - * Fetched straight from the upstream SagerNet repositories and - * downloaded through the selected node. A direct fetch depends on - * the CDN staying reachable from mainland China, where DNS pollution - * makes it fail intermittently; the files total ~250 KB per - * day, so proxying the download costs almost nothing. - * - * Both rule_sets are emitted, but they are consumed by different - * blocks: - * - * geoip-cn - referenced by the route rule above to split - * destinations by IP: mainland -> direct-out in - * bypass_mainland_china, mainland -> main-out in - * proxy_mainland_china. - * geosite-cn - referenced only by the DNS rule in - * generator/dns.uc (rule_set: geosite-cn -> - * server: china-dns); the route layer never matches - * it. Sing-box still loads and keeps the rule-set in - * memory even when only one block references it, so - * removing it would also break the DNS split. - * - * History: a third geosite-noncn used to be declared here as well, - * but no rule referenced it and sing-box still loaded and updated - * it daily, so it was removed (the comment above was rewritten - * during the 2026-09-29 review to clarify that geosite-cn is still - * in use - the DNS side - just not from this block). - * - * The list itself lives in common.uc, because the CLI has to create - * an "initial" fallback file for each of these before this generator - * runs - see the note on BUILTIN_REMOTE_RULE_SETS. That is the whole - * reason this block is a loop: these two are the rule-sets a cold - * install blocks on, so a change to them that only reached one of the - * two readers would put the fallback out of step with the config - * that points at it. - */ - for (let rs in BUILTIN_REMOTE_RULE_SETS) { - push(config.route.rule_set, { - type: 'remote', - tag: rs.tag, - format: rs.format, - url: rs.url, - update_interval: rs.update_interval, - /* Only with the opt-in on, and only for a fallback the CLI - * actually wrote. A remote rule-set with no initial_path is - * fetched during initialization, before the inbounds bind, so - * a cold cache makes the first start depend on the CDN - and - * on the node, since these go through http_clients. The - * fallback is an EMPTY rule-set, which changes routing for - * everything it would have matched, so it stays off unless - * the user asks for it. - * - * These two are the rule-sets a fresh install blocks on, so - * they are also the whole point of the feature: an earlier - * version of this block only knew how to build the entries, - * and the CLI had no idea they existed. */ - initial_path: (ctx.ruleset_safe_start === '1' && ctx.ruleset_initial[rs.tag]) - ? ruleSetInitialFallback([ rs.tag ], rs.format, rs.url) : null, - download_detour: 'main-out' - }); - } - /* Local, and not downloaded: it is generated from the same - * china_ip4.txt the firewall renders homeproxy_mainland_addr_v4 - * from, so the two cannot drift apart. `type: local` is watched by - * sing-box with fswatch, so when the resource updater replaces the - * file the running instance reloads it in place - no restart and, - * more importantly, no second copy of the list to keep in sync. */ + /* Both mainland modes split on the China address list, so both need it + * declared - it is what the route rule added above matches. + * `proxy_mainland_china` used to reach this point without a declaration and + * sing-box refused the whole config with "initialize rule[3]: rule-set not + * found: geoip-cn", which the health gate turned into a rollback and an + * unproxied network. + * + * All three are `type: local` and none of them is downloaded. Each is + * generated by runtime/{china_ip,domain}_ruleset.uc from a list the resource + * updater maintains, and sing-box watches the file with fswatch, so a new + * list is picked up in place - no restart, and no second copy of the data to + * keep in step with the firewall's nft set. + * + * Which ones are declared depends on what references them: + * china-ip the route rule above, in both mainland modes + * china-ip6 the IPv6 route rule, when there is one + * china-domain the DNS rule in generator/dns.uc, bypass mode only - + * proxy_mainland_china decides by address and leaves the + * resolver to the mode default, so declaring it there would + * load 111k suffixes for a rule that does not exist. */ + if (declaresBuiltinRuleSets(ctx.routing_mode)) { push(config.route.rule_set, { type: 'local', tag: 'china-ip', path: HP_DIR + '/resources/china_ip4.json' }); - /* The IPv6 counterpart, from china_ip6.txt - the same list the - * firewall renders homeproxy_mainland_addr_v6 from, and the only - * IPv6 mainland data either side of the split has. Declared only - * when the file is actually there: a rule_set pointing at a missing - * file takes the whole config down with it, and a router whose v6 - * list is unusable must still start (the firewall has already + /* Declared only when the file is actually there: a rule_set pointing + * at a missing file takes the whole config down with it, and a router + * whose v6 list is unusable must still start (the firewall has already * degraded to passing IPv6 through, with a warning). */ if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) { push(config.route.rule_set, { @@ -303,6 +239,18 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { path: HP_DIR + '/resources/china_ip6.json' }); } + + /* The DNS half. Same "only when it is there" rule as the v6 one + * above, for the same reason: generate_client.uc's dns rule names this + * tag, and a declaration whose file is missing fails the start rather + * than degrading. */ + if (ctx.routing_mode === 'bypass_mainland_china' && ctx.china_domain_ready) { + push(config.route.rule_set, { + type: 'local', + tag: 'china-domain', + path: HP_DIR + '/resources/china-domain.json' + }); + } } if (isEmpty(config.route.rule_set)) diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/ruleset.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/ruleset.uc index fcbe849c9..bb064522e 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/ruleset.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/generator/ruleset.uc @@ -26,7 +26,7 @@ 'use strict'; -import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, ruleSetInitialFallback, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc'; +import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc'; import { get_outbound, isDirectOutboundTag, rule_set_tags } from './common.uc'; @@ -214,37 +214,6 @@ export function build_user_rulesets(rule_set_array, dm, ctx) { if (!validateRuleSetPath(cfg.initial_path)) die(sprintf("homeproxy-pro: rule-set '%s' initial_path '%s' is outside the allowed rule-set roots (%s); move the file into one of them, or clear the field to let sing-box fetch the rule-set on its own.", cfg.name, cfg.initial_path, rulePathRootsText())); ruleset.initial_path = cfg.initial_path; - } else if (cfg.type === 'remote' && ctx.ruleset_safe_start === '1') { - /* No initial file of the user's own, and the opt-in is on: point at - * the empty fallback the CLI wrote before this generator ran. - * - * ctx.ruleset_initial records PRESENCE, not a path, and every tag - * has to be present. sing-box substitutes {tag} and opens every - * resulting file, so a multi-tag rule-set whose second file is - * missing blocks startup exactly as surely as one whose first is - - * measured on 1.14.2, where a literal "{tag}.srs" on disk makes - * it fall back to downloading and fail. All-or-nothing is the - * only correct answer here. - * - * Nothing is emitted when the answer is no: the rule-set then - * behaves exactly as it does today, which is also what happens if - * the CLI could not write the file. */ - const fallback = ruleSetInitialFallback(all_tags, cfg.format, cfg.url); - - if (fallback) { - let ready = true; - - for (let t in all_tags) - if (!ctx.ruleset_initial[t]) { - ready = false; - break; - } - - if (ready) - ruleset.initial_path = fallback; - else - warn(sprintf("homeproxy-pro: rule-set '%s' has no complete empty fallback, so it will be fetched during startup as before.", cfg.name)); - } } push(rule_set_array, ruleset); } diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc index 7d6af7d47..d3fb820c1 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc @@ -300,116 +300,6 @@ export function ruleSetFormatFromPath(path) { return null; }; -/* --- the "do not block startup on the first download" fallback ---------- * - * - * A remote rule-set with no `initial_path` is fetched during - * initialization, BEFORE the inbounds bind. On a cold cache that means a - * fresh install cannot start until raw.githubusercontent.com answers - and if - * it does not, the whole instance dies with a FATAL and the health gate turns - * that into a rollback or a released intercept layer. The node has to be up - * too, because these downloads go through http_clients, so a working - * configuration can be kept off the air by an unreachable CDN. - * - * `initial_path` is the way out: sing-box reads the file first, starts - * immediately, and refreshes from the URL in the background. A download that - * keeps failing then becomes an ERROR line in the log instead of a failed - * start - and the health gate does not look at rule-sets at all, so a - * degraded rule-set is promoted like any other healthy start. - * - * What the file holds is an EMPTY rule-set, and that is the whole trade: a - * rule-set with no rules matches nothing, so its traffic falls through to - * `final`. That changes routing, so this is opt-in (config.ruleset_safe_start, - * default off) and never the default. - * - * The format has to match the declared one. A binary rule-set fed a source - * JSON is not read at all - sing-box ignores the mismatch and blocks exactly - * as it does with no initial_path - so the decision of whether a fallback is - * even possible is made once, here, from the same inputs on both sides. */ - -/* Where the generated fallbacks live. Inside the rule-set archive, so - * validateRuleSetPath() admits it and the jail's read-only mount of HP_DIR - * already exposes it to the sing-box user. */ -export const RULESET_INITIAL_DIR = HP_DIR + '/ruleset/initial'; - -/* The two files the package ships for this. empty.source.json is what - * `sing-box rule-set compile` consumes (24 bytes, readable, and the form - * every version agrees on); empty.srs is the compiled result of THAT source - * on 1.14.2, kept as the last resort for a router where the compile cannot - * run. - * - * The .srs is committed rather than built at package time on purpose: it is a - * 14-byte binary, and having it in the tree means `hexdump -C` on a router - * can confirm byte for byte that the shipped constant is the documented one. - * It is deliberately NOT a conffile - it is ours, not the user's, and an - * upgrade may replace it freely. - * - * Compatibility note, since a hard-coded binary artifact invites the question: - * sing-box only rejects an SRS whose version is NEWER than its own, so a - * version-2 file stays readable. The compile path exists anyway, because a - * file produced by the kernel that is running it cannot drift from it. */ -export const RULESET_EMPTY_SOURCE = RULESET_INITIAL_DIR + '/empty.source.json'; -export const RULESET_EMPTY_BINARY = RULESET_INITIAL_DIR + '/empty.srs'; - -/* A tag becomes a file name, so it has to be one. UCI section names are - * already restricted, but a rule_set `tag` is a sing-box identifier that UCI - * does not fully own, and this value is about to be concatenated into a path - * the generator writes as root. Anything outside this set is refused, and - * the caller skips the fallback rather than sanitising it into a name that - * could collide with another rule-set's. */ -export function isSafeRuleSetTag(tag) { - if (!tag || type(tag) !== 'string') - return false; - - return match(tag, /^[A-Za-z0-9][A-Za-z0-9._-]*$/) != null; -}; - -/* The extension a format is stored under. Null for anything that is not one - * of the two formats, which is the caller's signal that no fallback is - * possible rather than something to guess at. */ -export function ruleSetFormatExtension(format) { - if (format === 'binary') - return 'srs'; - - if (format === 'source') - return 'json'; - - return null; -}; - -/* ruleSetInitialFallback(tags, declared, url) -> the initial_path to emit, or - * null when no fallback is possible. - * - * One function, called from both sides on purpose. The CLI asks it to - * decide which files to create and the generator asks it where to point - * `initial_path`; if they each worked it out separately they could disagree - * about the format, and a mismatch is the failure this whole mechanism exists - * to avoid - sing-box ignores a wrong-format initial file and blocks startup - * exactly as if there were none. - * - * `declared` is the rule-set's own `format`, which for a rule-set with no - * initial file is also the format the generator emits (nothing to probe, so - * nothing is corrected). With no declared format the URL's extension is the - * only remaining evidence; with neither, the answer is null and the rule-set - * keeps today's behaviour rather than being handed a file of the wrong shape. - * - * More than one tag gets a `{tag}` placeholder, because that is what sing-box - * substitutes and it requires every tag's file to exist - P3 on 1.14.2: a - * literal "{tag}.srs" on disk makes it try to download and block. */ -export function ruleSetInitialFallback(tags, declared, url) { - const format = declared || ruleSetFormatFromPath(url); - const ext = ruleSetFormatExtension(format); - - if (!ext) - return null; - - for (let tag in (tags || [])) - if (!isSafeRuleSetTag(tag)) - return null; - - const stem = (length(tags || []) > 1) ? '{tag}' : tags[0]; - - return RULESET_INITIAL_DIR + '/' + stem + '.' + ext; -}; /* probeRuleSetFile(path) -> 'binary' | 'source' | null. * diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/debug_report.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/debug_report.uc new file mode 100644 index 000000000..c43b15eb2 --- /dev/null +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/debug_report.uc @@ -0,0 +1,444 @@ +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * Copyright (C) 2025 ImmortalWrt.org + * + * A single self-contained diagnostic report, built in-process for the + * `debug_report` rpc method (see luci.homeproxy-pro). It exists because the + * failure modes this plugin has are not visible from the log page: a route + * that stopped splitting, an nft rule that is not in the fw4 table, a + * rule-set that never downloaded, a mark that no longer matches anything. + * Each of those needs a different command to confirm, and collecting them + * by hand over ssh is slow and easy to get wrong - the commands are spread + * across `ip rule`, `nft`, the procd state and the resource version files. + * + * Two properties are deliberate and are the reason this is not a shell + * script dropped in beside the other runtime helpers: + * + * 1. REDACTION IS DEFAULT-DENY. A node section is a credential store: it + * holds passwords, uuids, private keys, SNI and the server address. A + * report that is going to be pasted into an issue tracker has to be safe + * by construction, so the rule is "if the key looks like a secret, mask + * it" rather than an enumerated list of keys to mask - the enumerated + * kind is exactly the one that goes stale when a field is added. The + * pattern is applied to the key name, so a value is masked no matter + * which section it came from. + * + * 2. EVERY PROBE DEGRADES. A report that aborts because `nft` is missing, + * or that silently omits a section when a command fails, is worse than + * no report: the reader cannot tell "not applicable" from "not + * collected". Each probe therefore reports one of three states - + * the output, "(unavailable)" for a missing command, or + * "(no output, exit N)" for one that ran and failed. This is the same + * distinction health.sh draws between "probe failed" and "cannot probe + * here", and it is why a testbed without netfilter still produces a + * usable report. + * + * The commands below are fixed literals: nothing in this file is built from + * configuration, so there is no shell interpolation to get wrong and no + * argument that would need shellQuote(). + */ + +'use strict'; + +import { popen } from 'fs'; + +import { HP_DIR, RUN_DIR, shellQuote } from '../homeproxy-pro.uc'; + +export const DEBUG_REPORT_PATH = RUN_DIR + '/debug.log'; + +/* Per-section output cap. The nft dump is the one section that is expected + * to be large - a populated fw4 table runs to a few thousand lines, and + * truncating it is worse than useless because a truncated chain looks like + * a chain that ends there. The cap is high enough that a real router never + * reaches it, and reaching it is stated in the output rather than hidden. */ +const SECTION_MAX = 128 * 1024; + +/* A key whose name matches any of these is masked. Deliberately broad: a + * false positive costs a line of a report, a false negative ships someone's + * private key. `method` and `path` are in the list because obfs parameters + * and websocket paths are credentials in practice; `user` catches both + * `username` and the access-control user lists; `short` is there for + * `tls_reality_short_id`, which matched nothing until a canary run put a + * value in it and found it in the report verbatim. + * + * `url` is the one pattern that is NOT a bare substring, and it has to stay + * that way: a plain `url` alternative matches inside `main_urltest_nodes` and + * `main_urltest_tolerance` (`main_u-r-l-test`), which silently blanked the + * urltest pool out of the report. Those lists are how you tell "this node + * is still in the test pool" from "this node was dropped", which is a + * question the report exists to answer. Anchoring on a word boundary keeps + * `url` / `urls` / `subscription_urls` covered and leaves the pool visible. */ +const SECRET_KEY_RE = /pass|secret|token|key|uuid|(^|_)urls?$|address|server|host|sni|user|psk|path|method|param|seed|salt|auth|hosts|short/i; + +/* Exported for the rpc behaviour test: the redaction rule is the one part of + * this file with no I/O to observe, so it is the one part that can be + * asserted directly - a key that stops being recognised is a silent leak. */ +export function isSecretKey(name) { + /* ucode has no RegExp.prototype.test - the module-level match() is the + * only way to apply a pattern, and every other file in this tree does it + * the same way. */ + return type(name) === 'string' && match(name, SECRET_KEY_RE) != null; +}; + +function maskValue(value) { + /* A list keeps its length visible: "3 masked entries" tells the reader + * the subscription imported something, which is a fact worth having, + * without disclosing any of it. */ + if (type(value) === 'array') + return value === null || length(value) === 0 + ? value + : sprintf('[%d masked]', length(value)); + + return '***'; +}; + +/* Run one collector. Returns { state, text } where state is 'ok', + * 'unavailable' or 'failed', so the caller can label it honestly instead of + * printing an empty block that reads like "nothing to see". */ +function probe(command) { + if (command == null) + return { state: 'unavailable', text: '' }; + + const fd = popen(command); + if (fd == null) + return { state: 'unavailable', text: '' }; + + let out = fd.read('all'); + fd.close(); + + if (out == null) + return { state: 'failed', text: '' }; + + out = trim(out); + + /* popen() gives no exit status, so an empty result is the only signal + * that the command produced nothing. Whether that is a problem + * depends on the command, and the section header says which. */ + if (out === '') + return { state: 'failed', text: '' }; + + if (length(out) > SECTION_MAX) + out = substr(out, 0, SECTION_MAX) + + sprintf('\n... [truncated at %d bytes; rerun the command by hand for the full output]\n', SECTION_MAX); + + return { state: 'ok', text: out }; +}; + +/* The value form of the same collector: trimmed stdout, or null when the + * command produced nothing. Used where the caller wants a value rather + * than a rendered block. Deliberately not written with the optional-chaining + * shorthand, which no other file in this tree uses. */ +function runCommand(command) { + const fd = popen(command); + if (fd == null) + return null; + + const out = fd.read('all'); + fd.close(); + + if (out == null) + return null; + + const t = trim(out); + return t === '' ? null : t; +}; + +function runDate() { + return runCommand('date "+%Y-%m-%d %H:%M:%S %Z"') || 'unknown'; +}; + +/* Render one ```bash fenced block for a command, with the state spelled out + * when there is no output to show. */ +function block(command, unavailable_label) { + const r = probe(command); + + if (r.state === 'ok') + return sprintf('```shell\n# %s\n%s\n```\n', command, r.text); + + if (r.state === 'unavailable') + return sprintf('```\n(unavailable: %s)\n```\n', unavailable_label || command); + + return '```\n(no output)\n```\n'; +}; + +/* Render a two-column table, or a single "(unavailable)" row. */ +function table(rows, unavailable_label) { + if (rows == null) + return sprintf('_%s_\n', unavailable_label || 'unavailable'); + + let out = '| 项目 | 值 |\n|------|-----|\n'; + for (let r in rows) + out += sprintf('| %s | %s |\n', r[0], r[1]); + return out; +}; + +/* `apk list -I ` prints one line: + * + * sing-box-1.14.2-r1 x86_64 {feeds/.../sing-box} (GPL-3.0-or-later) [installed] + * + * The package name is the argument, so the version is simply what follows + * that name's trailing dash up to the first space. A pattern anchored on + * a negated class instead - `^[^ -]+-([0-9][^ ]*)` - stops at the FIRST + * dash, so it works for `firewall4` and silently fails for every package + * whose own name contains one (`sing-box`, `luci-app-homeproxy-pro`), which is + * most of them. Splitting on the known prefix has no such case. + * + * This also avoids `\\s` inside a negated class, whose meaning inside `[...]` + * is not what it is outside: `homeproxy-pro.uc`'s `replace(reason, /\s+/g, ' ')` + * collapses whitespace correctly, yet the same `\s` inside `[^\\s-]` did not + * exclude whitespace, and the version came back with the architecture glued + * to it. */ +function packageVersion(name) { + if (system('command -v apk >/dev/null 2>&1') !== 0) + return null; + + const fd = popen(sprintf('apk list -I %s 2>/dev/null', shellQuote(name))); + if (fd == null) + return null; + + const line = trim(fd.read('line')); + fd.close(); + + if (line == null || line === '') + return null; + + const prefix = name + '-'; + if (index(line, prefix) !== 0) + return null; + + const rest = substr(line, length(prefix)); + const sp = index(rest, ' '); + const v = (sp == -1) ? rest : substr(rest, 0, sp); + + return v === '' ? null : v; +}; + +/* --- UCI summary --------------------------------------------------------- */ + +/* A structural, redacted view of the configuration: counts and switch + * positions, never a credential. Sections are summarised by type rather + * than dumped, because a node section has no field that is safe to print + * and a subscription section has exactly one. */ +function configSummary(uci) { + const all = uci.get_all('homeproxy-pro') || {}; + const byType = {}; + + for (let sid in all) { + const section = all[sid]; + const stype = section['.type'] || 'unknown'; + + if (byType[stype] == null) + byType[stype] = { count: 0, sample: section['.name'] || sid }; + + byType[stype].count++; + } + + if (byType == null || length(keys(byType)) === 0) + return '_configuration is empty_\n'; + + let out = ''; + + for (let t in keys(byType)) { + out += sprintf('- `%s`: %d section(s)\n', t, byType[t].count); + } + + /* The switches that decide what the report is FOR: which mode, which + * ports, and the mark - these are the first things anyone reading a + * routing problem looks for, and none of them is sensitive. Each entry + * is [section, option]. */ + const switches = [ + ['config', 'proxy_mode'], + ['config', 'routing_mode'], + ['config', 'main_node'], + ['config', 'main_udp_node'], + ['config', 'ipv6_support'], + ['config', 'log_level'], + ['config', 'bypass_cn_traffic'], + ['infra', 'mixed_port'], + ['infra', 'redirect_port'], + ['infra', 'tproxy_port'], + ['infra', 'dns_port'], + ['infra', 'self_mark'], + ['infra', 'tproxy_mark'], + ['infra', 'tun_mark'], + ['infra', 'table_mark'], + ['infra', 'tun_name'], + ['server', 'enabled'] + ]; + + out += '\n| 开关 | 值 |\n|------|-----|\n'; + for (let sw in switches) { + const v = uci.get('homeproxy-pro', sw[0], sw[1]); + out += sprintf('| `%s.%s` | %s |\n', sw[0], sw[1], + (v == null || v === '') ? '_(unset)_' : (isSecretKey(sw[1]) ? maskValue(v) : v)); + } + + return out; +}; + +/* Every non-secret option, flattened, with anything that looks like a + * credential masked. This is the section that makes the report useful for + * "which option did I set wrong" - the summary above cannot show them. */ +function fullConfig(uci) { + const all = uci.get_all('homeproxy-pro') || {}; + let out = ''; + + for (let sid in keys(all)) { + const section = all[sid]; + const stype = section['.type'] || 'unknown'; + const sname = section['.name'] || sid; + + out += sprintf('\n### %s `%s`\n\n```\n', stype, sname); + + for (let k in keys(section)) { + if (substr(k, 0, 1) === '.') + continue; + + const v = section[k]; + + if (isSecretKey(k)) { + out += sprintf('option %s %s\n', k, maskValue(v)); + } else if (type(v) === 'array') { + out += sprintf('list %s %s\n', k, + length(v) === 0 ? '' : join(' ', v)); + } else { + out += sprintf('option %s %s\n', k, v); + } + } + + out += '```\n'; + } + + return out == '' ? '_configuration is empty_\n' : out; +}; + +/* --- resource versions --------------------------------------------------- */ + +/* The .ver files next to each shipped list. A rule-set that has been + * failing to download shows up here as a version that never moves, which is + * the cheapest way to tell "the list is stale" from "the list is missing". */ +function resourceVersions() { + const names = ['china_ip4', 'china_ip6', 'china_list', 'gfw_list']; + let out = ''; + + for (let n in names) { + const path = sprintf('%s/resources/%s.ver', HP_DIR, n); + const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(path))); + + if (fd != null) { + const v = trim(fd.read('all')); + fd.close(); + out += sprintf('- `%s`: %s\n', n, (v == null || v === '') ? '_(no version file)_' : v); + } else { + out += sprintf('- `%s`: _(unavailable)_\n', n); + } + } + + /* The dnsmasq nftset capability marker: init.d writes this at start + * and firewall_post.ut gates the gfwlist mode on it, so a device whose + * dnsmasq was built without HAVE_NFTSET silently routes everything + * direct while DNS still goes through the proxy. */ + const marker = sprintf('%s/resources/.dnsmasq-nftset', HP_DIR); + const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(marker))); + let cap = 'unknown'; + if (fd != null) { + cap = trim(fd.read('all')) || 'unknown'; + fd.close(); + } + out += sprintf('- `dnsmasq nftset`: %s', cap == '1' ? 'yes' : (cap == '0' ? 'no (gfwlist mode cannot work)' : cap)); + + return out + '\n'; +}; + +/* --- the report ---------------------------------------------------------- */ + +export function buildDebugReport(uci) { + const stamp = trim(runDate()); + + let out = ''; + out += '# HomeProxy 诊断报告\n\n'; + out += sprintf('> 生成时间: %s\n', stamp == '' ? '(unknown)' : stamp); + out += sprintf('> 插件版本: %s\n', packageVersion('luci-app-homeproxy-pro') || 'unknown'); + out += '> 隐私: 凭据字段已按名称脱敏,但本报告仍含公网 IP、内网拓扑和 LAN 主机地址。\n'; + out += '> 外发前请自行确认。\n'; + + /* 1. System */ + out += '\n## 1. 系统\n\n'; + out += block('cat /etc/openwrt_release 2>/dev/null', 'uname -a'); + out += block('uname -a'); + out += block('uptime'); + out += block('df -h / /tmp /etc/homeproxy-pro 2>/dev/null', 'df unavailable'); + out += block('free -m 2>/dev/null', 'free unavailable'); + + /* 2. Dependencies - the table, not a "is it there" yes/no, because the + * version is what a reader needs when reporting "it does not work on + * this build". */ + out += '\n## 2. 依赖检查\n\n'; + const deps = ['sing-box', 'firewall4', 'kmod-nft-tproxy', 'kmod-tun', + 'ip-full', 'uclient-fetch', 'ucode', 'ucode-mod-digest']; + const dep_rows = []; + + for (let d in deps) { + const v = packageVersion(d); + push(dep_rows, [d, v == null ? '_(not reported by apk; may be built in)_' : v]); + } + + out += table(dep_rows, 'apk not available on this system'); + + /* 3. Core capabilities */ + out += '\n## 3. 内核与能力\n\n'; + out += block('/usr/bin/sing-box version 2>/dev/null', 'sing-box not installed'); + out += block('lsmod 2>/dev/null | grep -E "tun|nft_tproxy|inet_diag"', 'lsmod unavailable'); + out += block('command -v nft fw4 utpl ucode uci 2>/dev/null', 'PATH lookup unavailable'); + + /* 4. Configuration */ + out += '\n## 4. 配置摘要(脱敏)\n\n'; + out += configSummary(uci); + + /* 5. Resources */ + out += '\n## 5. 资源版本\n\n'; + out += resourceVersions(); + + /* 6. Service */ + out += '\n## 6. 服务状态\n\n'; + out += block('/etc/init.d/homeproxy-pro info 2>&1', 'init script unavailable'); + out += block('ubus call service list \'"name":"homeproxy-pro"\' 2>/dev/null', + 'ubus unavailable'); + + /* 7. Routing - `ip rule` first, because the mark that routes a + * connection into the proxy is set there, and a rule that is missing + * explains a proxy that binds its ports and then times out. */ + out += '\n## 7. 路由策略\n\n'; + out += '### ip rule\n\n'; + out += block('ip rule list'); + out += '### ip route\n\n'; + out += block('ip -4 route list'); + out += block('ip -6 route list'); + out += sprintf('\n**策略路由表**(table_mark / tproxy_mark / tun_mark):\n\n```shell\n# ip route list table 100\n%s\n```\n', + trim(runCommand('ip route list table 100 2>/dev/null') || '(empty or unavailable)')); + + /* 8. Firewall - the full fw4 table, not just homeproxy-pro's own chains. + * The failure this covers is "homeproxy-pro's rules are present and + * correct but something in fw4 jumps to another chain first", and + * that is only visible with the surrounding rules in view. */ + out += '\n## 8. 防火墙\n\n'; + out += '_The full `inet fw4` table. homeproxy-pro\'s chains are the ones named ' + + '`homeproxy_*`; everything else is fw4\'s own._\n\n'; + out += block('nft -a list table inet fw4 2>/dev/null', 'nft unavailable (is firewall4 installed?)'); + + /* 9. Log excerpts - the tail of each log, not the whole file: the + * interesting part of a failure is the last thing that happened. */ + out += '\n## 9. 日志尾部\n\n'; + for (let l in ['homeproxy-pro', 'sing-box-c', 'sing-box-s']) { + out += sprintf('### %s.log\n\n', l); + out += block(sprintf('tail -n 60 %s/%s.log 2>/dev/null', RUN_DIR, l), + sprintf('%s.log not present', l)); + } + + /* 10. Full redacted configuration */ + out += '\n## 10. 完整配置(脱敏)\n\n'; + out += fullConfig(uci); + + return out; +}; diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/dns.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/dns.sh index e4ed1bd10..2388925b7 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/dns.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/dns.sh @@ -73,7 +73,7 @@ hp_dnsmasq_has_nftset() { [ "$HP_DNSMASQ_NFTSET" = "1" ] } -# hp_dnsmasq_render_snippets +# hp_dnsmasq_render_snippets [run-dir] # Render the desired snippet set into . Writes nothing outside it, # restarts nothing: the caller decides whether the result differs from what is # already installed. @@ -89,6 +89,7 @@ hp_dnsmasq_render_snippets() { local routing_mode="$3" local dns_port="$4" local ipv6="$5" + local run_dir="${6:-/var/run/homeproxy-pro}" local gfw_nftset_v6="" wan_nftset_v6="" local nftset_ok=1 @@ -154,6 +155,33 @@ hp_dnsmasq_render_snippets() { "$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1 fi fi + + # Node server addresses, host-name form. + # + # This snippet carries no `server=`: the routing mode's own snippet above + # already decides which upstream answers, and in every proxied mode that is + # sing-box's dns-in. The generator resolves the node through the WAN + # resolver either way (route.default_domain_resolver), so the address that + # comes back is the same one sing-box will dial - which is the point. Only + # the `nftset=` is added here, and dnsmasq back-fills it from the answer it + # relays, forwarded or not. + # + # Without it, a LAN client opening a connection to the node's own address + # was redirected into sing-box, which dialled that same address through the + # tunnel to that same node. sing-box's own outbound is already exempt + # (routing_mark / self_mark); this covers the LAN, which is not. + # + # The literal-address form cannot come from here - there is no name to + # resolve - so firewall_post.ut renders it into the same set directly. + if [ "$nftset_ok" = "1" ] && [ -s "$run_dir/node-addr-domains.txt" ]; then + local node_nftset_v6="" + [ "$ipv6" -eq "0" ] || node_nftset_v6=",6#inet#fw4#homeproxy_node_addr_v6" + sed -r -e '/^\s*$/d' \ + -e "s/(.*)/nftset=\/.\1\/4#inet#fw4#homeproxy_node_addr_v4$node_nftset_v6/g" \ + "$run_dir/node-addr-domains.txt" > "$stage/node_addr.conf" || return 1 + elif [ -s "$run_dir/node-addr-domains.txt" ]; then + log "Warning: this dnsmasq has no nftset support; LAN connections to the node's address are not excluded from the proxy." + fi } # hp_dnsmasq_dir_differs @@ -179,7 +207,7 @@ hp_dnsmasq_dir_differs() { return 1 } -# hp_dnsmasq_write_snippets +# hp_dnsmasq_write_snippets [run-dir] # Write the include file plus the mode-specific snippet, then restart # dnsmasq. Requires config_load (reads ipv6_support and dns_port). # @@ -192,6 +220,7 @@ hp_dnsmasq_write_snippets() { local dnsmasq_dir="$1" local hp_dir="$2" local routing_mode="$3" + local run_dir="${4:-/var/run/homeproxy-pro}" local ipv6_support dns_port local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf" local stage changed=0 @@ -208,7 +237,7 @@ hp_dnsmasq_write_snippets() { # installed snippet set untouched: the staged directory is discarded, so # the caller keeps the snippets that are already in place instead of # installing a truncated one. - if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then + if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support" "$run_dir"; then rm -rf "$stage" return 1 fi diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc new file mode 100644 index 000000000..8936684f6 --- /dev/null +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc @@ -0,0 +1,157 @@ +#!/usr/bin/ucode +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * runtime/domain_ruleset.uc + * + * The domain-list counterpart of china_ip_ruleset.uc: turn a bare host-name + * list into a sing-box source rule-set of `domain_suffix` rules, so the DNS + * side of the China split reads a local file instead of downloading + * geosite-geolocation-cn.srs from GitHub on a schedule. + * + * Why this exists, in the terms that decided it: the two consumers of the + * China split - the nft mainland set and the sing-box rule-set - were reading + * two different files. The kernel's came from china_ip4.txt, the resolver's + * from a remote .srs, and the remote copy is what a cold start has to wait for + * (through the node, when download_detour says so) before the inbounds bind. + * With the local copy there is nothing to wait for and nothing to keep in step. + * + * The entry is a suffix, not an exact domain: china_list.txt holds registrable + * domains ("163.com", "taobao.com"), and the DNS rule has to match every name + * under them, which is what `domain_suffix` means to sing-box. `domain` would + * match only the name itself and quietly stop matching www. for the + * entire list. + * + * One rule holding the whole list, for the same reason china_ip_ruleset.uc + * gives: sing-box matches rule-sets by walking a prefix-compressed structure, + * and splitting the list would only add rules to walk per query. + * + * Entries are validated here rather than trusted. The strict parser on the + * other side is the authority for what a valid name is, and one bad entry + * makes it reject the *whole* rule-set - which would take the client's start + * with it. A malformed line is skipped and reported, exactly like the blank + * lines and comments a list may carry. + */ +'use strict'; +import { readfile, writefile, rename, unlink } from 'fs'; + +if (length(ARGV) < 2 || ARGV[0] == null || ARGV[1] == null) { + warn(sprintf('usage: domain_ruleset.uc \n')); + exit(1); +} +const src = ARGV[0]; +const dst = ARGV[1]; +const data = readfile(src); +if (type(data) !== 'string') { + warn(sprintf('cannot read %s\n', src)); + exit(1); +} + +const suffixes = []; +let skipped = 0; + +/* A structural hostname check, in process. + * + * validation() from homeproxy-pro.uc is the obvious tool and the wrong one here: + * it shells out to /sbin/validate_data once per call, and this list is 111k + * lines. That is 111k process spawns. china_ip_ruleset.uc makes the same call + * for the same reason - its is_v4/is_v6 are hand-written rather than delegated. + * + * Deliberately structural, not exhaustive: the goal is to keep a line that + * cannot be a domain out of the file, since one bad entry makes sing-box reject + * the whole rule-set and take the client's start with it. The strict parser on + * the other side stays the authority for what is really valid. */ +function is_hostname(entry) { + if (length(entry) < 1 || length(entry) > 253) + return false; + + /* Anything outside this set is a path, a URL, a port, an email, or a + * comment marker that survived the updater's filter. */ + if (!match(entry, /^[A-Za-z0-9.-]+$/)) + return false; + + /* A leading dot, a doubled dot, or a trailing dot all mean the list is + * not in the bare form this emits; sing-box treats them differently from + * what the DNS rule expects to match. */ + if (substr(entry, 0, 1) == '.' || substr(entry, -1, 1) == '.') + return false; + if (match(entry, /\.\./)) + return false; + + for (let label in split(entry, '.')) { + if (length(label) < 1 || length(label) > 63) + return false; + /* A label may not start or end with a hyphen (RFC 1035). */ + if (substr(label, 0, 1) == '-' || substr(label, -1, 1) == '-') + return false; + } + + /* An all-numeric set of labels is a bare IPv4 address, not a domain: + * the structure above accepts "192.168.1.1" quite happily, and a + * domain_suffix rule holding an address never matches a query. No TLD + * is all digits, so this cannot cost a real name. IPv6 never reaches + * here at all - it carries colons and was rejected above. */ + let all_numeric = true; + for (let label in split(entry, '.')) { + if (!match(label, /^[0-9]+$/)) { + all_numeric = false; + break; + } + } + if (all_numeric) + return false; + + return true; +} + +for (let line in split(data, /\n/)) { + const entry = trim(line); + if (entry == '') + continue; + if (match(entry, /^[#;]/)) + continue; + + /* The updater already strips `full:` prefixes and anything with a colon + * (update_resources.sh's china_list case), so what arrives here is a bare + * name. Anything that is not still a hostname is dropped rather than + * passed on. */ + if (!is_hostname(entry)) { + skipped++; + continue; + } + push(suffixes, entry); +} + +if (skipped > 0) + warn(sprintf('skipped %d malformed entries in %s\n', skipped, src)); + +/* An empty rule-set is worse than no rule-set: it matches nothing, so every + * name the list should have covered falls through to whatever the DNS side + * defaults to. Refuse rather than install one - the previous file stays. */ +if (length(suffixes) == 0) { + warn(sprintf('no usable domain entries in %s\n', src)); + exit(1); +} + +/* The temporary name is a sibling so the rename stays on one filesystem: the + * watcher must never read a half-written file. `%.J` is the same serializer + * the config generator uses, so the output is the canonical sing-box form. */ +const rendered = sprintf('%.J\n', { + version: 3, + rules: [ + { domain_suffix: suffixes } + ] +}); + +const tmp = dst + '.tmp'; +if (!writefile(tmp, rendered)) { + warn(sprintf('cannot write %s\n', tmp)); + exit(1); +} +if (!rename(tmp, dst)) { + unlink(tmp); + warn(sprintf('cannot install %s\n', dst)); + exit(1); +} + +printf('%d domain suffixes\n', length(suffixes)); diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh index 86e4c5867..9581e8902 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/runtime/service.sh @@ -87,8 +87,8 @@ hp_require_ucode() { return 1 } -# hp_crontab_drop -# Remove the auto-update entry from . +# hp_crontab_drop [marker] +# Remove the cron entries tagged from . # # Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with # BSD sed it fails ("invalid command code"), which silently left the stale entry @@ -96,11 +96,16 @@ hp_require_ucode() { # behaves identically on busybox, GNU and BSD sed, and it is what lets this # module be exercised off-target at all # (tests/runtime/test_runtime_extraction.sh stages and drives it). +# +# The marker is a parameter because there are two independent entries now: the +# resource lists and the subscriptions. A shared marker would let dropping one +# take the other with it. hp_crontab_drop() { local crontab="$1" + local marker="${2:-${CONF}_autosetup}" local tmp="${crontab}.hp-new" - sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; } + sed "/#${marker}/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; } # mv replaces the *file*, not its contents: the temporary was created by the @@ -115,6 +120,36 @@ hp_crontab_drop() { return 0 } +# hp_sync_resource_cron +# Install the resource-list update cron entry. Unconditional. +# +# The resource lists were previously refreshed by the same cron line as the +# subscription update, behind the *subscription's* auto_update switch. Two +# unrelated things behind one switch, and the consequence was silent: a router +# with subscription auto-update off kept a months-old china_ip4.txt, and with it +# a months-old `homeproxy_mainland_addr_v4` nft set - the firewall was deciding +# "mainland or not" from a list nobody was maintaining. The subscription switch +# says nothing about the user wanting stale routing data, so the two are now +# scheduled independently. +# +# The hour is a constant rather than a setting. This is a maintenance job with +# nothing to configure, and adding an option for it is exactly the kind of +# switch that ends up defaulted and never touched. Two hours earlier than the +# subscription default (2) so the two runs of uclient-fetch do not collide. +# +# The entry runs update_resources_cron.sh, which reloads the service only when a +# list actually changed - so a day where the upstream lists did not move costs +# one round of requests and no interruption. +HP_RESOURCE_CRON_HOUR=3 +hp_sync_resource_cron() { + hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \ + || log "Warning: failed to drop the previous resource-update cron entry." + printf '0 %s * * * %s/scripts/update_resources_cron.sh #%s_resource_cron\n' \ + "$HP_RESOURCE_CRON_HOUR" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \ + || log "Warning: failed to install the resource-update cron entry." + /etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron." +} + # hp_sync_autoupdate_cron # Install (or drop) the subscription auto-update cron entry. is a # config_get_bool result; the entry is only touched when it is "1". @@ -140,7 +175,7 @@ hp_sync_autoupdate_cron() { return 1 fi - hp_crontab_drop "/etc/crontabs/root" \ + hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \ || log "Warning: failed to drop the previous auto-update cron entry." printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \ "$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \ @@ -149,10 +184,16 @@ hp_sync_autoupdate_cron() { } # hp_clear_autoupdate_cron -# Drop the auto-update cron entry and restart cron. Used by stop_service. +# Drop both cron entries and restart cron. Used by stop_service. +# +# Both markers: stop_service has to leave no homeproxy-pro cron entry behind, and +# the resource entry is installed unconditionally, so dropping only the +# subscription one would leak a daily job on every stop. hp_clear_autoupdate_cron() { - hp_crontab_drop "/etc/crontabs/root" \ - || log "Warning: failed to drop the auto-update cron entry." + hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \ + || log "Warning: failed to drop the previous auto-update cron entry." + hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \ + || log "Warning: failed to drop the previous resource-update cron entry." /etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron." } @@ -251,6 +292,29 @@ hp_prepare_runtime_files() { log "WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: ${hp_dir}/scripts/update_resources.sh china_ip6" fi + # The DNS half of the China split, same arrangement as china_ip4.json above: + # the dns rule sends the list's domains to china-dns, and it reads + # china-domain.json rather than a remote geosite-geolocation-cn.srs so a + # cold start has nothing to download. Generated here for the same reason - + # an install that predates the file, or a list replaced while the service + # was stopped, must still get one, or the client config names a file that + # does not exist and sing-box refuses to start. + # + # A missing china_list.txt is not an error: the generator then emits no + # DNS-side rule for it, and the split degrades to the resolver default + # rather than failing the start. + if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then + if ucode -S "$hp_dir/scripts/runtime/domain_ruleset.uc" \ + "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json" >>"$LOG_PATH" 2>&1; then + chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \ + || log "Warning: failed to hand ${hp_dir}/resources/china-domain.json to sing-box." + else + # Same reasoning as the v6 branch above: the helper refuses to + # write an empty rule-set, and the previous one is still valid. + log "Warning: could not generate ${hp_dir}/resources/china-domain.json; the DNS side keeps the previous list." + fi + fi + # cache_file is enabled for bypass_mainland_china and custom alike # (generator/common.uc:attachExperimental). The file has to exist before # the client jail binds it, or sing-box cannot create it in there. diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_crond.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_crond.sh index 0c3cd9059..12b93a2fa 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_crond.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_crond.sh @@ -1,12 +1,14 @@ #!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # -# Copyright (C) 2023 ImmortalWrt.org +# Subscription auto-update, run from the crontab entry that +# runtime/service.sh installs when subscription.auto_update is on. +# +# The resource lists used to be refreshed from this same script. They are not +# any more: they have their own entry (update_resources_cron.sh), because a +# subscription switch had been deciding whether the firewall's mainland address +# set was still current. See hp_sync_resource_cron() in runtime/service.sh. SCRIPTS_DIR="/etc/homeproxy-pro/scripts" -for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do - "$SCRIPTS_DIR"/update_resources.sh "$i" -done - "$SCRIPTS_DIR"/update_subscriptions.uc diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh index 7ccdf0b7e..40902fe06 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources.sh @@ -236,6 +236,23 @@ check_list_update() { log "[$(to_upper "$listtype")] Warning: could not regenerate $listtype.json (list has no usable CIDR entry?); the route side keeps the previous list." fi ;; + "china_list") + # The DNS half of the same split. It reads this file rather than + # a remote geosite-geolocation-cn.srs for the same reasons + # china_ip4.json exists: one list with two readers, and nothing for + # a cold start to download before the inbounds bind. + # + # The sed above rewrote the file in place, so this runs after it + # and sees the cleaned list. `full:` prefixes and anything with a + # colon are gone by now; what is left is what the helper accepts. + if ucode -S "$SCRIPT_DIR/runtime/domain_ruleset.uc" \ + "$RESOURCES_DIR/$listtype.txt" "$RESOURCES_DIR/china-domain.json" >>"$LOG_PATH" 2>&1; then + log "[CHINA_LIST] DNS-side rule-set regenerated." + chown sing-box:sing-box "$RESOURCES_DIR/china-domain.json" 2>"/dev/null" + else + log "[CHINA_LIST] Warning: could not regenerate china-domain.json (list has no usable domain entry?); the DNS side keeps the previous list." + fi + ;; esac else rm -f "$RUN_DIR/$listname" diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources_cron.sh b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources_cron.sh new file mode 100755 index 000000000..ffdb659d5 --- /dev/null +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_resources_cron.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# +# Refresh the four resource lists (china_ip4 / china_ip6 / gfw_list / +# china_list) and reload the service only when one of them actually moved. +# +# Scheduled unconditionally - see hp_sync_resource_cron() in runtime/service.sh +# for why this is not behind the subscription auto_update switch. +# +# Why a reload is needed at all, when the sing-box side reloads itself: the +# lists feed two consumers. update_resources.sh regenerates the sing-box +# rule-sets from them, and sing-box watches those files and picks a new one up +# in place. The firewall reads the same lists directly when firewall_post.ut is +# rendered into the nft sets, and that only happens on an fw4 reload - so +# without this the kernel kept routing from the previous list while sing-box +# had already moved to the new one, and the two halves of the same split +# disagreed. That window is invisible: mainland traffic still works, it just +# stops being mainland for whatever the new entries added. +# +# update_resources.sh exits 3 when a list is already current, so a day with no +# upstream movement costs one round of requests and no interruption. + +SCRIPTS_DIR="/etc/homeproxy-pro/scripts" +NAME="homeproxy-pro" +RUN_DIR="/var/run/$NAME" +LOG_PATH="$RUN_DIR/$NAME.log" + +mkdir -p "$RUN_DIR" 2>/dev/null + +log() { + printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH" +} + +changed=0 +for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do + "$SCRIPTS_DIR"/update_resources.sh "$i" + case "$?" in + 0) changed=1 ;; + esac +done + +[ "$changed" = "1" ] || exit 0 + +log "A resource list changed; reloading $NAME so the nft sets catch up." +if ! /etc/init.d/$NAME reload >>"$LOG_PATH" 2>&1; then + log "Warning: the $NAME reload after a resource update failed; the firewall still uses the previous list." +fi diff --git a/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro b/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro index b2da8ac4d..4e481da01 100755 --- a/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro +++ b/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro @@ -157,7 +157,7 @@ hp_rearm_intercept() { [ -f "$RUN_DIR/intercept-released" ] || return 0 rm -f "$RUN_DIR/intercept-released" log "Client is up again; reinstalling the intercept layer." - hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2" + hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2" "$RUN_DIR" hp_net_setup "$1" "$2" # hp_firewall_apply already writes $RUN_DIR/failed-firewall on its own # failure, but the helper's caller is service_started, which only reads @@ -265,6 +265,11 @@ start_service() { config_get_bool auto_update "subscription" "auto_update" "0" config_get auto_update_time "subscription" "auto_update_time" "2" hp_sync_autoupdate_cron "$auto_update" "$auto_update_time" + # Unconditional, and independent of the switch above: the resource + # lists decide the firewall's mainland set, and that going stale is + # not something "update my subscriptions" should have been able to + # cause. + hp_sync_resource_cron # The DNS snippets are the other half of the intercept layer: without # them the LAN resolves through the ISP while the nft rules still send @@ -273,7 +278,7 @@ start_service() { # leaving an empty snippet behind; record it the same way a firewall # failure is recorded, so the configuration is not promoted to # known-good and the log names the real problem. - if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then + if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode" "$RUN_DIR"; then log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration." printf '%s\n' "dns" > "$RUN_DIR/failed-dns" fi diff --git a/luci-app-homeproxy-pro/root/usr/share/rpcd/acl.d/luci-app-homeproxy-pro.json b/luci-app-homeproxy-pro/root/usr/share/rpcd/acl.d/luci-app-homeproxy-pro.json index 4b9edac6f..000087ac7 100644 --- a/luci-app-homeproxy-pro/root/usr/share/rpcd/acl.d/luci-app-homeproxy-pro.json +++ b/luci-app-homeproxy-pro/root/usr/share/rpcd/acl.d/luci-app-homeproxy-pro.json @@ -5,7 +5,8 @@ "file": { "/var/run/homeproxy-pro/homeproxy-pro.log": [ "read" ], "/var/run/homeproxy-pro/sing-box-c.log": [ "read" ], - "/var/run/homeproxy-pro/sing-box-s.log": [ "read" ] + "/var/run/homeproxy-pro/sing-box-s.log": [ "read" ], + "/var/run/homeproxy-pro/debug.log": [ "read" ] }, "ubus": { "service": [ "list" ], @@ -31,6 +32,7 @@ "luci.homeproxy-pro": [ "acllist_write", "certificate_write", + "debug_report", "log_clean", "resources_update", "singbox_generator", diff --git a/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro b/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro index a399d5a36..8c2ff2e15 100644 --- a/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro +++ b/luci-app-homeproxy-pro/root/usr/share/rpcd/ucode/luci.homeproxy-pro @@ -12,6 +12,7 @@ import { cursor } from 'uci'; import { fetchBinary, isBinary, isValidECHConfig, isValidPEM, shellQuote, executeCommand } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc'; import { parse_uri } from '/etc/homeproxy-pro/scripts/parser/uri.uc'; +import { buildDebugReport, DEBUG_REPORT_PATH } from '/etc/homeproxy-pro/scripts/runtime/debug_report.uc'; /* Kanged from ucode/luci - replaced by the imported shellQuote() so every shell * argument in this file goes through one helper. */ @@ -473,6 +474,46 @@ const methods = { return { running, log_tail: tail }; } + }, + + /* Build a single self-contained diagnostic report and leave it where the + * browser can read it. The report is generated, not fetched: the ACL + * grants the browser `read` on the output path and nothing else, so the + * commands that produce it (nft, ip rule, the package queries) stay + * behind this method rather than becoming browser-reachable ones. + * + * RUN_DIR is tmpfs, so the report does not survive a reboot and never + * occupies flash - the reader downloads it, and the copy they keep is + * the one that matters. A fixed filename means the ACL grants exactly + * one path and the button always reads the freshest report. + * + * The collector degrades per probe instead of aborting, so on a machine + * without netfilter the report still renders the sections that do work. + * The catch is here rather than inside buildDebugReport() so a bug in + * the collector surfaces as a toast with the ucode error type instead + * of an empty file the reader has to guess about. */ + debug_report: { + call: function() { + const uci = cursor(); + + let report = null; + try { + report = buildDebugReport(uci); + } catch (e) { + return { + result: false, + error: sprintf('diagnostic report failed (%s): %s', e.type, e.message) + }; + } + + if (type(report) !== 'string' || report === '') { + return { result: false, error: 'the diagnostic report came back empty' }; + } + + writefile(DEBUG_REPORT_PATH, report); + + return { result: true, path: DEBUG_REPORT_PATH, size: length(report) }; + } } }; diff --git a/luci-app-homeproxy-pro/tests/README.md b/luci-app-homeproxy-pro/tests/README.md index 83a97b236..62493b1b5 100644 --- a/luci-app-homeproxy-pro/tests/README.md +++ b/luci-app-homeproxy-pro/tests/README.md @@ -282,7 +282,7 @@ Two further host differences are bridged so the remaining checks still run: | `tests/ucode/test_parser_flatten.uc` | Round-trip invariant for the canonical-Node pipeline: `flatten(normalize(parse_uri(uri)))` must equal the parser's flat UCI dict field-for-field for every supported scheme. This is what stops the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Repository's flatten from drifting apart. | | `tests/ucode/test_subscription_repository.uc` | Integration test for `subscription/repository.uc`: stages a sandboxed UCI file with a user node, a kept subscription node, and a dropped subscription node, then runs `Repository.apply_nodes` and asserts (a) the user node is untouched, (b) the kept node has the new fields and no stale fields, (c) the dropped node is gone, (d) a brand-new node is added under `md5(group+label)`. PR-03 added the `apply_main_node_refs` paths (urltest prune + missing-target switch + reset-to-nil) and the `scrub_stale_urltest_refs` pass to the same test. | | `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. The `redirect` case is the product's default `proxy_mode` and asserts, from the emitted JSON, that every dialling outbound carries the runtime `routing_mark`. | -| `tests/ucode/test_dns_proxy_list.sh` | The `proxy_list.txt` → `dns.rules` plumbing and the bootstrap resolver, neither of which `run_case()` in the generator suite can see (it truncates both resource lists before every case and the fixture has no `china_dns_server` option). Patches the client fixture to each of the four proxy modes with both lists populated, and asserts the DNS rule order (`direct-domain` < `proxy-domain` via `main-dns` < the SVCB/HTTPS reject, `geosite-cn` last when the mode emits it) plus the routing half's `"tag": "proxy-domain"` and `route.final: main-out`. Then, separately, the bootstrap resolver: it is derived from `china_dns_server`, so a bare IP there must make `main-dns` resolve its own hostname through `bootstrap-dns` with no `domain_resolver` of its own, and a DoH URL there (a hostname, which cannot resolve a hostname) must emit no `bootstrap-dns` server at all and leave `main-dns` on `default-dns`. | +| `tests/ucode/test_dns_proxy_list.sh` | The `proxy_list.txt` → `dns.rules` plumbing and the bootstrap resolver, neither of which `run_case()` in the generator suite can see (it truncates both resource lists before every case and the fixture has no `china_dns_server` option). Patches the client fixture to each of the four proxy modes with both lists populated, and asserts the DNS rule order (`direct-domain` < `proxy-domain` via `main-dns` < the SVCB/HTTPS reject, `china-domain` last when the mode emits it) plus the routing half's `"tag": "proxy-domain"` and `route.final: main-out`. Then, separately, the bootstrap resolver: it is derived from `china_dns_server`, so a bare IP there must make `main-dns` resolve its own hostname through `bootstrap-dns` with no `domain_resolver` of its own, and a DoH URL there (a hostname, which cannot resolve a hostname) must emit no `bootstrap-dns` server at all and leave `main-dns` on `default-dns`. | | `tests/toolchain/build-ucode-linux.sh` | Builds the local ucode toolchain described above; not part of `tests/run.sh`. | | `tests/toolchain/validate-data.sh` | Off-target stand-in for `/sbin/validate_data`, used through `HP_VALIDATE_DATA`; not part of `tests/run.sh`. | | `tests/ucode/test_ucode_grammar.sh` | Pins the ucode dialect: the toolchain must reject `export function ... }` without `;` and object/array destructuring, and must accept the constructs the package uses (`?.`/`??`, object spread, computed keys, template literals). | diff --git a/luci-app-homeproxy-pro/tests/arch-guard.sh b/luci-app-homeproxy-pro/tests/arch-guard.sh index 9aa8208f6..128886806 100755 --- a/luci-app-homeproxy-pro/tests/arch-guard.sh +++ b/luci-app-homeproxy-pro/tests/arch-guard.sh @@ -2223,9 +2223,9 @@ fi # proxy resolver even though the route side would now send it direct. cn_fb_block="$(sed -n '/cn_fallback/,/^ }/p' "$SCRIPTS/generator/dns.uc" || true)" if printf '%s' "$cn_fb_block" | grep -q "china-ip6"; then - pass "the cn-fallback response match covers china-ip6 as well as geoip-cn" + pass "the cn-fallback response match covers china-ip6 as well as china-ip" else - fail "cn-fallback still matches geoip-cn only; the DNS half of the IPv6 split" + fail "cn-fallback still matches china-ip only; the DNS half of the IPv6 split" fail "is not in agreement with the route half" fi @@ -2626,118 +2626,115 @@ else fi echo -echo "== guard 52: the startup fallback stays opt-in, and the two sides stay in step ==" +echo "== guard 52: the China split is local, and both halves read one list ==" -# ruleset_safe_start points every remote rule-set that has no initial file of -# its own at an EMPTY rule-set, so sing-box starts immediately instead of -# fetching during initialization - before the inbounds bind. On a cold cache -# that is the difference between a router that comes up and one that waits on -# raw.githubusercontent.com, through the node, before it will listen at all. +# The split that keeps mainland traffic direct used to be fed by two remote +# rule-sets downloaded from SagerNet's repositories: geoip-cn on the route +# side, geosite-cn on the DNS side. Three things were wrong with that and all +# three are silent when they come back: # -# It is opt-in because the fallback is empty on purpose: a rule-set with no -# rules matches nothing, so everything it would have split falls through to -# `final`. On bypass_mainland_china that means mainland destinations can be -# proxied until the download succeeds. That is a routing change, so the -# default must stay off, exactly as sniffer_advanced_mode's does. +# - a cold start had to fetch them before the inbounds bind, through the +# node, so the proxy's own start depended on the CDN being reachable +# through the proxy; +# - the kernel's half (homeproxy_mainland_addr_v4, from china_ip4.txt) and +# the resolver's half came from different files, and route.uc once carried +# a corrective rule plus a long comment because they disagreed; +# - every day of that, the resource lists behind the kernel's half were only +# refreshed if the user happened to have subscription auto-update on. # -# The rest of the guard is about the mechanism being one mechanism: the CLI -# writes the file and the generator points at it, and a disagreement between -# them is silent and fatal (a wrong-format or missing initial file is ignored -# by sing-box, which then blocks startup exactly as if none existed). -SAFE_START="$(python3 - "$ROOT" "$SCRIPTS" <<'PY' +# They are local files now, each generated from a list the resource updater +# maintains, and the firewall renders its nft set from those same lists. This +# guard pins the arrangement rather than the feature it replaced: the tags the +# two halves name have to be the same tags route.uc declares, they have to be +# declared local, and nothing may reintroduce a remote download for them. +CN_LOCAL="$(python3 - "$ROOT" "$SCRIPTS" <<'PY' import pathlib, re, sys root, scripts = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) -config_uc = (root / 'root/etc/config/homeproxy-pro').read_text(encoding='utf-8') -context_uc = (scripts / 'generator/context.uc').read_text(encoding='utf-8') route_uc = (scripts / 'generator/route.uc').read_text(encoding='utf-8') -ruleset_uc = (scripts / 'generator/ruleset.uc').read_text(encoding='utf-8') +dns_uc = (scripts / 'generator/dns.uc').read_text(encoding='utf-8') common_uc = (scripts / 'generator/common.uc').read_text(encoding='utf-8') -homeproxy_uc = (scripts / 'homeproxy-pro.uc').read_text(encoding='utf-8') client_uc = (scripts / 'generate_client.uc').read_text(encoding='utf-8') +service_sh = (scripts / 'runtime/service.sh').read_text(encoding='utf-8') +update_sh = (scripts / 'update_resources.sh').read_text(encoding='utf-8') +fw_ut = (scripts / 'firewall_post.ut').read_text(encoding='utf-8') problems = [] def strip_comments(src): src = re.sub(r'/\*[\s\S]*?\*/', '', src) return '\n'.join(l for l in src.split('\n') if not l.lstrip().startswith(('#', '//', '*'))) -# (a) The default, in both places it is stated. Same shape as guard 39. -if "ruleset_safe_start: dm.general.ruleset_safe_start || '0'" not in context_uc: - problems.append("context.uc no longer falls back to '0' for ruleset_safe_start; a missing " - "UCI value would have to mean something other than the safe default") -if not re.search(r"^\toption ruleset_safe_start '0'$", config_uc, re.M): - problems.append("the package-shipped /etc/config/homeproxy-pro no longer ships ruleset_safe_start " - "'0'; upgrading users would silently start routing with empty rule-sets") +route, dns = strip_comments(route_uc), strip_comments(dns_uc) -# (b) An initial_path may only be emitted for a fallback that exists. Both -# sites, because one without the other is the silent failure. -if "ctx.ruleset_initial" not in strip_comments(route_uc): - problems.append('route.uc emits initial_path without checking ctx.ruleset_initial; a built-in ' - 'could be pointed at a file the CLI never wrote') -if "ctx.ruleset_initial" not in strip_comments(ruleset_uc): - problems.append('ruleset.uc emits initial_path without checking ctx.ruleset_initial') -if 'ruleset_initial' not in client_uc: - problems.append('generate_client.uc no longer writes the fallbacks, so ctx.ruleset_initial is ' - 'always empty and the opt-in does nothing') +# (a) None of the three may be a remote rule-set again. A `type: 'remote'` +# in the built-in block brings back both the cold-start download and the +# second copy of the data. +decl = re.search(r'if \(declaresBuiltinRuleSets.*?\n\t\}', route, re.S) +if not decl: + problems.append('route.uc no longer declares the built-in rule-sets behind ' + 'declaresBuiltinRuleSets(); the China split would have no data source') +else: + block = decl.group(0) + for tag in ('china-ip', 'china-ip6', 'china-domain'): + if "tag: '%s'" % tag not in block: + problems.append("route.uc no longer declares '%s'; the rule that matches it " + "would fail the whole configuration with rule-set not found" % tag) + if "type: 'remote'" in block: + problems.append("route.uc declares a built-in as type: 'remote' again; a cold start " + "would have to download it before the inbounds bind") -# (c) The built-ins come from the shared list. route.uc hard-coding the tags -# is how the two readers drifted in the first place: the CLI had no idea -# these rule-sets existed. -for tag in ('geoip-cn', 'geosite-cn'): - if re.search(r"tag:\s*'%s'" % re.escape(tag), strip_comments(route_uc)): - problems.append("route.uc hard-codes tag '%s' again; it must come from " - "BUILTIN_REMOTE_RULE_SETS so the CLI can create its fallback" % tag) -if 'BUILTIN_REMOTE_RULE_SETS' not in route_uc: - problems.append('route.uc no longer reads BUILTIN_REMOTE_RULE_SETS') -if 'BUILTIN_REMOTE_RULE_SETS' not in client_uc: - problems.append('generate_client.uc no longer reads BUILTIN_REMOTE_RULE_SETS') +# (b) The two halves must name the same tags. This is the part that actually +# drifted before: the route rule named geoip-cn and the DNS rule named +# geosite-cn, and the fix was a second rule rather than one list. +if re.search(r"rule_set:\s*'geoip-cn'", route + dns): + problems.append("a China rule names 'geoip-cn' again; the address list is china-ip, " + "generated from the same china_ip4.txt the nft set is rendered from") +if re.search(r"rule_set:\s*'geosite-cn'", route + dns): + problems.append("a China rule names 'geosite-cn' again; the domain list is china-domain, " + "generated from china_list.txt") +if "rule_set: 'china-domain'" not in dns: + problems.append('dns.uc no longer routes the China domain list; those domains would fall ' + 'through to the mode default and resolve through the proxy') -# (d) The shipped constant, byte for byte. It is a binary artifact in the -# tree, and a corrupted one fails in the worst possible way: sing-box -# cannot parse the initial file, ignores it, and blocks startup - the -# exact failure the file exists to prevent. Comparing the bytes here is -# the only thing standing between a bad `cp` and that. -expected_srs = bytes([0x53, 0x52, 0x53, 0x02, 0x78, 0xDA, 0x62, 0x00, - 0x0C, 0x00, 0x00, 0x01, 0x00, 0x01]) -srs = root / 'root/etc/homeproxy-pro/ruleset/initial/empty.srs' -src = root / 'root/etc/homeproxy-pro/ruleset/initial/empty.source.json' -if not srs.is_file(): - problems.append('the offline fallback %s is missing from the package payload' % srs) -elif srs.read_bytes() != expected_srs: - problems.append('%s is %s, not the documented %s; it is the last-resort binary ' - 'fallback and a corrupted one makes sing-box block startup' - % (srs.name, srs.read_bytes().hex(' '), expected_srs.hex(' '))) -if not src.is_file(): - problems.append('the empty source %s is missing from the package payload' % src) -elif src.read_bytes() != b'{"version":3,"rules":[]}': - problems.append('%s is %r, not the documented empty source' % (src.name, src.read_bytes())) +# (c) The fallback tags have to be declared under the same condition they are +# referenced, or sing-box rejects the config with an undeclared tag. +for name, src, tag in (('route', route, 'china-ip6'), ('dns', dns, 'china-ip6')): + if re.search(r"rule_set:\s*'china-ip6'", src) and 'china_ip6_ready' not in src: + problems.append('%s names china-ip6 without gating on china_ip6_ready; the declaration ' + 'is conditional, so this is an undeclared tag' % name) -# (e) The fallbacks live inside the rule-set archive, so the existing jail -# mount of HP_DIR and the existing path whitelist both already cover them. -# A directory outside the archive would need a whitelist change and a new -# jail mount, and neither exists. -m = re.search(r"RULESET_INITIAL_DIR\s*=\s*HP_DIR \+ '([^']*)'", homeproxy_uc) -if not m: - problems.append('RULESET_INITIAL_DIR is gone or no longer derived from HP_DIR') -elif not m.group(1).startswith('/ruleset/'): - problems.append('RULESET_INITIAL_DIR is %r, outside the rule-set archive; the jail mount and ' - 'the path whitelist would not cover it' % m.group(1)) -if 'RULESET_INITIAL_DIR' not in common_uc and 'BUILTIN_REMOTE_RULE_SETS' not in common_uc: - problems.append('common.uc no longer carries the shared built-in list') +# (d) Both halves of each list have to be generated by something that runs. +# domain_ruleset.uc is the newest of the three and the only one with no +# long history pointing at it, so it is the one that can quietly go +# missing and leave china-domain.json absent - which degrades the DNS +# split with nothing in the log. +for name, src in (('runtime/service.sh', service_sh), ('update_resources.sh', update_sh)): + if 'domain_ruleset.uc' not in src: + problems.append('%s no longer invokes domain_ruleset.uc; china-domain.json is only ' + 'produced there, and a missing file silently drops the DNS half of ' + 'the split' % name) +if 'china_ip_ruleset.uc' not in service_sh: + problems.append('runtime/service.sh no longer invokes china_ip_ruleset.uc; a cold install ' + 'would have no china_ip4.json and the route half would be missing too') + +# (e) The kernel's half has to keep coming from the same .txt the generator's +# half is made from. This is the pair that disagreed once. +if "resources_dir + '/china_ip4.txt'" not in fw_ut: + problems.append('firewall_post.ut no longer renders the mainland set from china_ip4.txt; ' + 'the kernel and the resolver would decide "mainland" from two lists again') print('\n'.join(problems)) PY -)" || SAFE_START="__SCAN_FAILED__" -if [ "$SAFE_START" = "__SCAN_FAILED__" ]; then - fail "the safe-start scan could not run - fix the guard before trusting a pass" -elif [ -z "$SAFE_START" ]; then - pass "the startup fallback is opt-in, both sides read one shared list, and the" - pass " shipped constant is byte-for-byte the documented one" +)" || CN_LOCAL="__SCAN_FAILED__" +if [ "$CN_LOCAL" = "__SCAN_FAILED__" ]; then + fail "the China-split scan could not run - fix the guard before trusting a pass" +elif [ -z "$CN_LOCAL" ]; then + pass "the China split is three local files, the two halves name the same tags, and" + pass " each half is generated by something that runs on start and on update" else - fail "the startup fallback has been broken or silently enabled:" - printf ' %s\n' "$SAFE_START" + fail "the China split has drifted off one list:" + printf ' %s\n' "$CN_LOCAL" fi - echo echo "== guard 53: every dm.general field the generators read is one the Loader lists ==" @@ -2748,7 +2745,7 @@ echo "== guard 53: every dm.general field the generators read is one the Loader # left the option off. # # That is not a hypothetical. ruleset_safe_start was added to -# /etc/config/homeproxy-pro and read in context.uc, and nothing said the key was +# /etc/config/homeproxy-pro and read in context.uc (removed in r41), and nothing said the key was # missing: the configuration generated cleanly, `sing-box check` passed, every # test that did not look for the feature's effect was green, and the feature # was simply never on. Only the e2e case that asserts an initial_path exists @@ -2888,6 +2885,127 @@ else printf ' %s\n' "$FETCH_LAYER" fi +# guard 55: every join() call passes the separator first. +# +# ucode's join(sep, list) does not validate its arguments. Swapped, it +# returns null rather than raising, and null + '\n' is the string "null\n" - +# a non-empty file naming nothing. That shipped in r41: the node-address +# export wrote "null", dnsmasq rendered nftset=/.null/... and the node +# bypass silently did nothing while every existence check stayed green. +# +# This is cheap to pin because every call is a literal, so an argument that +# is a list literal or a call returning one is the tell. +join_bad="$(python3 - "$SCRIPTS" "$RPC" <<'PY_JOIN' +import re, sys, pathlib + +def strip_comments(src): + src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S) + return re.sub(r"//[^\n]*", " ", src) + +def top_level_first(args): + """First argument, split on commas that are not inside quotes or parens - + the separator itself is routinely ', '.""" + depth, j = 0, 0 + while j < len(args): + c = args[j] + if c in "'\"": + k = j + 1 + while k < len(args) and args[k] != c: + k += 1 + j = k + 1; continue + if c in "([{": + depth += 1 + elif c in ")]}": + depth -= 1 + elif c == "," and depth == 0: + return args[:j].strip() + j += 1 + return args.strip() + +def join_args(src, i): + depth, j, out = 0, i, [] + while j < len(src): + c = src[j] + if c in "'\"": + k = j + 1 + while k < len(src) and src[k] != c: + k += 1 + out.append(src[j:k + 1]); j = k + 1; continue + if c == "(": + depth += 1 + if depth == 1: + j += 1; continue + elif c == ")": + depth -= 1 + if depth == 0: + return "".join(out), j + out.append(c); j += 1 + return None, j + +bad = [] +for root in sys.argv[1:]: + p = pathlib.Path(root) + files = ([p] if p.is_file() + else sorted(p.rglob("*.uc")) + sorted(p.rglob("*.ut"))) + for f in files: + src = strip_comments(f.read_text(errors="replace")) + for m in re.finditer(r"\bjoin\s*\(", src): + args, _ = join_args(src, m.end() - 1) + if args is None: + continue + first = top_level_first(args) + # A separator is a string literal. Anything else in that slot - + # a call, a subscript, a bare identifier - is the swapped form, + # and ucode answers it with null instead of raising. + if not re.fullmatch(r"'[^']*'|\"[^\"]*\"", first): + bad.append("%s: join(%s, ...)" % (f.name, first)) +print("\n".join(sorted(set(bad)))) +PY_JOIN +)" +if [ -n "$join_bad" ]; then + fail "a join() call does not pass a string literal first (ucode takes join(sep, list), and" + fail "swapped it returns null silently instead of raising):" + printf ' %s\n' "$join_bad" +else + pass "every join() call passes a string-literal separator first; swapped arguments would" + pass " have returned null silently and shipped an empty-of-meaning export" +fi + +# guard 56: no `{% set %}` tag assigns the result of a function call. +# +# utpl's set tag takes a plain expression and rejects a call outright: +# `{% set x = f(); %}` is a parse error. The damage is disproportionate to +# the typo, because a parse error kills the whole render - firewall_post.ut +# produces a zero-byte fw4_post.nft, fw4 has nothing new to load, and the +# health gate quietly keeps the previous ruleset. Nothing looks broken: the +# service is up, the proxy works, and the template change is simply absent. +# r41 shipped such a line and never rendered once; r43 fixed it. +# +# So the rule is mechanical and worth pinning: inside a set tag, no `(`. +set_call="$(python3 - "$SCRIPTS" <<'PY_SET' +import re, sys, pathlib +bad = [] +for p in sorted(pathlib.Path(sys.argv[1]).rglob("*.ut")): + src = p.read_text(errors="replace") + # Comments quote the mistake on purpose; only real tags count. + src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S) + for m in re.finditer(r"\{%-?\s*set\b([^%]*?)-?%\}", src, flags=re.S): + rhs = m.group(1) + # A call is any identifier immediately followed by "(". + if re.search(r"[A-Za-z_][A-Za-z0-9_.]*\s*\(", rhs): + bad.append("%s: {%% set%s%%}" % (p.name, rhs.strip())) +print("\n".join(bad)) +PY_SET +)" +if [ -n "$set_call" ]; then + fail "a {% set %} tag assigns a function call; utpl rejects it, and the parse error" + fail "zeroes the whole render instead of failing visibly:" + printf ' %s\n' "$set_call" +else + pass "no {% set %} tag calls a function (utpl would reject it and the whole render" + pass " would silently collapse to an empty ruleset)" +fi + echo printf '%s checks, %s failures\n' "$checks" "$([ "$FAILED" = 0 ] && echo 0 || echo 'nonzero')" if [ "$FAILED" != 0 ]; then diff --git a/luci-app-homeproxy-pro/tests/fixtures/generators/custom_safe_start.uci b/luci-app-homeproxy-pro/tests/fixtures/generators/custom_safe_start.uci deleted file mode 100644 index 3c5e05d9e..000000000 --- a/luci-app-homeproxy-pro/tests/fixtures/generators/custom_safe_start.uci +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only -# -# Regression fixture for config.ruleset_safe_start = '1' (issue #7 batch 2a). -# -# custom.uci with the opt-in on and two REMOTE rule-sets added, because those -# are the only ones the fallback applies to: a local rule-set already has its -# file on disk and is never fetched during initialization. -# -# It is a separate file rather than three sed variations of custom.uci for two -# reasons. The opt-in has to be in the `config homeproxy-pro 'routing'` section -# (dm.general.ruleset_safe_start), which is a different section from anything -# the variations were anchored on; and a multi-expression sed that both inserts -# an option and appends a section is a construct this suite had never run - -# the one-expression form is proven, the two-expression form is not, and a test -# whose own tooling is untested is how a case silently stops testing anything. -# -# The two rule-sets cover the two shapes the fallback has to get right: -# -# rs_remote one tag. initial_path is a concrete path and one file is -# written for it. -# rs_multi two tags. sing-box substitutes {tag} and opens EVERY resulting -# file, so initial_path must carry the placeholder and one file -# must be written per tag - a rule-set with a literal -# "{tag}.srs" on disk falls back to downloading and blocks -# startup, which is the exact failure the feature removes. -# -# __RULESET_DIR__ is substituted by tests/ucode/test_generators.sh with the -# per-run archive. - -config homeproxy-pro 'config' - option routing_mode 'custom' - option proxy_mode 'redirect_tproxy' - option ipv6_support '0' - option log_level 'error' - option ruleset_safe_start '1' - -config homeproxy-pro 'routing' - option default_outbound 'direct-out' - option default_outbound_dns 'default-dns' - -config homeproxy-pro 'infra' - option mixed_port '5330' - option dns_port '5333' - option redirect_port '5331' - option self_mark '100' - -# direct node: exercises the direct_overrides write path -config node 'n_direct' - option label 'direct-node' - option type 'direct' - option override_address '1.2.3.4' - option override_port '8443' - -# routing node + rule: exercises get_direct_override() reading direct_overrides -config routing_node 'rn_direct' - option enabled '1' - option label 'rn-direct' - option node 'n_direct' - option domain_resolver 'default-dns' - option domain_strategy 'prefer_ipv4' - -config routing_rule 'rr_direct' - option enabled '1' - option label 'rr-direct' - option outbound 'rn_direct' - option action 'route' - -# The two remote rule-sets the fallback applies to. Neither has an -# initial_path of its own, which is the whole precondition: a rule-set that has -# one is left completely alone by the fallback. -config ruleset 'rs_remote' - option enabled '1' - option label 'remote-rs' - option type 'remote' - option format 'binary' - option url 'https://example.invalid/remote.srs' - -config ruleset 'rs_multi' - option enabled '1' - option label 'multi-rs' - option type 'remote' - option format 'binary' - option url 'https://example.invalid/{tag}.srs' - list extra_tags 'alt' - -# A disabled remote rule-set: the fallback must not create a file for it, since -# build_user_rulesets() skips it before it can ever reach the generated -# configuration. -config ruleset 'rs_off' - option enabled '0' - option label 'off-rs' - option type 'remote' - option format 'binary' - option url 'https://example.invalid/off.srs' diff --git a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt index e7bee8375..c3daafd78 100644 --- a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt +++ b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt @@ -9,6 +9,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart ip link show singtun0 ip link set singtun0 up @@ -42,6 +44,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -74,6 +78,7 @@ sing-box version -n log sing-box 1.14.0 started. start rc=0 chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro ip rule del fwmark 101 table 100 @@ -130,6 +135,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -162,6 +171,8 @@ ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart ip rule del fwmark 101 table 100 ip rule add fwmark 101 table 100 @@ -219,6 +230,8 @@ procd_set_param respawn procd_close_instance log WARNING: IPv6 support is ON but etc/homeproxy-pro/resources/china_ip6.txt has 0 usable prefixes. log WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: etc/homeproxy-pro/scripts/update_resources.sh china_ip6 +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box etc/homeproxy-pro/certs chmod 700 etc/homeproxy-pro/certs chown sing-box:sing-box etc/homeproxy-pro/certs/server_privatekey.pem @@ -269,90 +282,6 @@ sing-box version -n log sing-box 1.14.0 started. start rc=0 chmod 600 etc/crontabs/root -init.d/cron restart -config_load homeproxy-pro -ip rule del fwmark 101 table 100 -ip route del local 0.0.0.0/0 dev lo table 100 -ip -6 rule del fwmark 101 table 100 -ip -6 route del local ::/0 dev lo table 100 -ip route del default dev singtun0 table 100 -ip rule del fwmark 102 table 100 -ip -6 route del default dev singtun0 table 100 -ip -6 rule del fwmark 102 table 100 -nft flush chain inet fw4 homeproxy_output_redir -nft delete chain inet fw4 homeproxy_output_redir -nft flush chain inet fw4 homeproxy_redirect -nft delete chain inet fw4 homeproxy_redirect -nft flush chain inet fw4 homeproxy_redirect_proxy -nft delete chain inet fw4 homeproxy_redirect_proxy -nft flush chain inet fw4 homeproxy_redirect_proxy_port -nft delete chain inet fw4 homeproxy_redirect_proxy_port -nft flush chain inet fw4 homeproxy_redirect_lanac -nft delete chain inet fw4 homeproxy_redirect_lanac -nft flush chain inet fw4 homeproxy_mangle_prerouting -nft delete chain inet fw4 homeproxy_mangle_prerouting -nft flush chain inet fw4 homeproxy_mangle_output -nft delete chain inet fw4 homeproxy_mangle_output -nft flush chain inet fw4 homeproxy_mangle_tproxy -nft delete chain inet fw4 homeproxy_mangle_tproxy -nft flush chain inet fw4 homeproxy_mangle_tproxy_port -nft delete chain inet fw4 homeproxy_mangle_tproxy_port -nft flush chain inet fw4 homeproxy_mangle_lanac -nft delete chain inet fw4 homeproxy_mangle_lanac -nft flush chain inet fw4 homeproxy_mangle_mark -nft delete chain inet fw4 homeproxy_mangle_mark -nft flush chain inet fw4 homeproxy_mangle_tun -nft delete chain inet fw4 homeproxy_mangle_tun -nft flush chain inet fw4 homeproxy_mangle_tun_mark -nft delete chain inet fw4 homeproxy_mangle_tun_mark -nft flush set inet fw4 homeproxy_local_addr_v4 -nft delete set inet fw4 homeproxy_local_addr_v4 -nft flush set inet fw4 homeproxy_local_addr_v6 -nft delete set inet fw4 homeproxy_local_addr_v6 -nft flush set inet fw4 homeproxy_gfw_list_v4 -nft delete set inet fw4 homeproxy_gfw_list_v4 -nft flush set inet fw4 homeproxy_gfw_list_v6 -nft delete set inet fw4 homeproxy_gfw_list_v6 -nft flush set inet fw4 homeproxy_mainland_addr_v4 -nft delete set inet fw4 homeproxy_mainland_addr_v4 -nft flush set inet fw4 homeproxy_mainland_addr_v6 -nft delete set inet fw4 homeproxy_mainland_addr_v6 -nft flush set inet fw4 homeproxy_wan_proxy_addr_v4 -nft delete set inet fw4 homeproxy_wan_proxy_addr_v4 -nft flush set inet fw4 homeproxy_wan_proxy_addr_v6 -nft delete set inet fw4 homeproxy_wan_proxy_addr_v6 -nft flush set inet fw4 homeproxy_wan_direct_addr_v4 -nft delete set inet fw4 homeproxy_wan_direct_addr_v4 -nft flush set inet fw4 homeproxy_wan_direct_addr_v6 -nft delete set inet fw4 homeproxy_wan_direct_addr_v6 -nft flush set inet fw4 homeproxy_routing_port -nft delete set inet fw4 homeproxy_routing_port -fw4 reload -init.d/miniupnpd enabled -init.d/miniupnpd running -init.d/miniupnpd restart -init.d/dnsmasq restart -log Service stopped. -config_load homeproxy-pro -ip link set singtun0 down -ip tuntap del mode tun name singtun0 -stop rc=0 -file var/run/homeproxy-pro/fw4_forward.nft: -file var/run/homeproxy-pro/fw4_input.nft: -file var/run/homeproxy-pro/fw4_post.nft: -file var/run/homeproxy-pro/homeproxy-pro.log: -file var/run/homeproxy-pro/known-good/sing-box-c.json: {| "log": {},| "inbounds": [| {| "tag": "dns-in",| "listen_port": 5333| },| {| "tag": "mixed-in",| "listen_port": 5330| }| ],| "outbounds": []|}| -file var/run/homeproxy-pro/known-good/sing-box-s.json: {| "log": {},| "inbounds": [],| "outbounds": []|}| -file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.main_node=n1|homeproxy-pro.config.main_udp_node=u1|homeproxy-pro.config.proxy_mode=redirect_tproxy|homeproxy-pro.config.routing_mode=gfwlist|homeproxy-pro.config.routing_port=|homeproxy-pro.config.ipv6_support=1|homeproxy-pro.config.china_dns_server=|homeproxy-pro.infra.self_mark=|homeproxy-pro.infra.redirect_port=|homeproxy-pro.infra.tproxy_port=|homeproxy-pro.infra.tproxy_mark=101|homeproxy-pro.infra.tun_mark=102|homeproxy-pro.infra.tun_name=singtun0|homeproxy-pro.infra.tun_addr4=|homeproxy-pro.infra.tun_addr6=|homeproxy-pro.infra.dns_redirect=|homeproxy-pro.infra.dns_port=5333|homeproxy-pro.infra.common_port=|homeproxy-pro.infra.table_mark=100|homeproxy-pro.routing.bypass_cn_traffic=|homeproxy-pro.routing.default_outbound=direct-out|homeproxy-pro.server.enabled=1|homeproxy-pro.control.lan_proxy_mode=|list homeproxy-pro.control.listen_interfaces=|list homeproxy-pro.control.lan_direct_ipv4_ips=|list homeproxy-pro.control.lan_direct_ipv6_ips=|list homeproxy-pro.control.lan_direct_mac_addrs=|list homeproxy-pro.control.lan_proxy_ipv4_ips=|list homeproxy-pro.control.lan_proxy_ipv6_ips=|list homeproxy-pro.control.lan_proxy_mac_addrs=|list homeproxy-pro.control.lan_gaming_mode_ipv4_ips=|list homeproxy-pro.control.lan_gaming_mode_ipv6_ips=|list homeproxy-pro.control.lan_gaming_mode_mac_addrs=|list homeproxy-pro.control.lan_global_proxy_ipv4_ips=|list homeproxy-pro.control.lan_global_proxy_ipv6_ips=|list homeproxy-pro.control.lan_global_proxy_mac_addrs=|list homeproxy-pro.control.wan_proxy_ipv4_ips=|list homeproxy-pro.control.wan_proxy_ipv6_ips=|list homeproxy-pro.control.wan_direct_ipv4_ips=|list homeproxy-pro.control.wan_direct_ipv6_ips=| - -===== scenario: C-nothing-configured (start) ===== -uci -q show dhcp.@dnsmasq[0] -config_load homeproxy-pro -ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) -sing-box version -n -log Neither client nor server is configured. Service not started. -config_load homeproxy-pro -start rc=0 chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro @@ -410,6 +339,100 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 +nft flush set inet fw4 homeproxy_routing_port +nft delete set inet fw4 homeproxy_routing_port +fw4 reload +init.d/miniupnpd enabled +init.d/miniupnpd running +init.d/miniupnpd restart +init.d/dnsmasq restart +log Service stopped. +config_load homeproxy-pro +ip link set singtun0 down +ip tuntap del mode tun name singtun0 +stop rc=0 +file var/run/homeproxy-pro/fw4_forward.nft: +file var/run/homeproxy-pro/fw4_input.nft: +file var/run/homeproxy-pro/fw4_post.nft: +file var/run/homeproxy-pro/homeproxy-pro.log: +file var/run/homeproxy-pro/known-good/sing-box-c.json: {| "log": {},| "inbounds": [| {| "tag": "dns-in",| "listen_port": 5333| },| {| "tag": "mixed-in",| "listen_port": 5330| }| ],| "outbounds": []|}| +file var/run/homeproxy-pro/known-good/sing-box-s.json: {| "log": {},| "inbounds": [],| "outbounds": []|}| +file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.main_node=n1|homeproxy-pro.config.main_udp_node=u1|homeproxy-pro.config.proxy_mode=redirect_tproxy|homeproxy-pro.config.routing_mode=gfwlist|homeproxy-pro.config.routing_port=|homeproxy-pro.config.ipv6_support=1|homeproxy-pro.config.china_dns_server=|homeproxy-pro.infra.self_mark=|homeproxy-pro.infra.redirect_port=|homeproxy-pro.infra.tproxy_port=|homeproxy-pro.infra.tproxy_mark=101|homeproxy-pro.infra.tun_mark=102|homeproxy-pro.infra.tun_name=singtun0|homeproxy-pro.infra.tun_addr4=|homeproxy-pro.infra.tun_addr6=|homeproxy-pro.infra.dns_redirect=|homeproxy-pro.infra.dns_port=5333|homeproxy-pro.infra.common_port=|homeproxy-pro.infra.table_mark=100|homeproxy-pro.routing.bypass_cn_traffic=|homeproxy-pro.routing.default_outbound=direct-out|homeproxy-pro.server.enabled=1|homeproxy-pro.control.lan_proxy_mode=|list homeproxy-pro.control.listen_interfaces=|list homeproxy-pro.control.lan_direct_ipv4_ips=|list homeproxy-pro.control.lan_direct_ipv6_ips=|list homeproxy-pro.control.lan_direct_mac_addrs=|list homeproxy-pro.control.lan_proxy_ipv4_ips=|list homeproxy-pro.control.lan_proxy_ipv6_ips=|list homeproxy-pro.control.lan_proxy_mac_addrs=|list homeproxy-pro.control.lan_gaming_mode_ipv4_ips=|list homeproxy-pro.control.lan_gaming_mode_ipv6_ips=|list homeproxy-pro.control.lan_gaming_mode_mac_addrs=|list homeproxy-pro.control.lan_global_proxy_ipv4_ips=|list homeproxy-pro.control.lan_global_proxy_ipv6_ips=|list homeproxy-pro.control.lan_global_proxy_mac_addrs=|list homeproxy-pro.control.wan_proxy_ipv4_ips=|list homeproxy-pro.control.wan_proxy_ipv6_ips=|list homeproxy-pro.control.wan_direct_ipv4_ips=|list homeproxy-pro.control.wan_direct_ipv6_ips=| + +===== scenario: C-nothing-configured (start) ===== +uci -q show dhcp.@dnsmasq[0] +config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) +sing-box version -n +log Neither client nor server is configured. Service not started. +config_load homeproxy-pro +start rc=0 +chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root +init.d/cron restart +config_load homeproxy-pro +ip rule del fwmark 101 table 100 +ip route del local 0.0.0.0/0 dev lo table 100 +ip -6 rule del fwmark 101 table 100 +ip -6 route del local ::/0 dev lo table 100 +ip route del default dev singtun0 table 100 +ip rule del fwmark 102 table 100 +ip -6 route del default dev singtun0 table 100 +ip -6 rule del fwmark 102 table 100 +nft flush chain inet fw4 homeproxy_output_redir +nft delete chain inet fw4 homeproxy_output_redir +nft flush chain inet fw4 homeproxy_redirect +nft delete chain inet fw4 homeproxy_redirect +nft flush chain inet fw4 homeproxy_redirect_proxy +nft delete chain inet fw4 homeproxy_redirect_proxy +nft flush chain inet fw4 homeproxy_redirect_proxy_port +nft delete chain inet fw4 homeproxy_redirect_proxy_port +nft flush chain inet fw4 homeproxy_redirect_lanac +nft delete chain inet fw4 homeproxy_redirect_lanac +nft flush chain inet fw4 homeproxy_mangle_prerouting +nft delete chain inet fw4 homeproxy_mangle_prerouting +nft flush chain inet fw4 homeproxy_mangle_output +nft delete chain inet fw4 homeproxy_mangle_output +nft flush chain inet fw4 homeproxy_mangle_tproxy +nft delete chain inet fw4 homeproxy_mangle_tproxy +nft flush chain inet fw4 homeproxy_mangle_tproxy_port +nft delete chain inet fw4 homeproxy_mangle_tproxy_port +nft flush chain inet fw4 homeproxy_mangle_lanac +nft delete chain inet fw4 homeproxy_mangle_lanac +nft flush chain inet fw4 homeproxy_mangle_mark +nft delete chain inet fw4 homeproxy_mangle_mark +nft flush chain inet fw4 homeproxy_mangle_tun +nft delete chain inet fw4 homeproxy_mangle_tun +nft flush chain inet fw4 homeproxy_mangle_tun_mark +nft delete chain inet fw4 homeproxy_mangle_tun_mark +nft flush set inet fw4 homeproxy_local_addr_v4 +nft delete set inet fw4 homeproxy_local_addr_v4 +nft flush set inet fw4 homeproxy_local_addr_v6 +nft delete set inet fw4 homeproxy_local_addr_v6 +nft flush set inet fw4 homeproxy_gfw_list_v4 +nft delete set inet fw4 homeproxy_gfw_list_v4 +nft flush set inet fw4 homeproxy_gfw_list_v6 +nft delete set inet fw4 homeproxy_gfw_list_v6 +nft flush set inet fw4 homeproxy_mainland_addr_v4 +nft delete set inet fw4 homeproxy_mainland_addr_v4 +nft flush set inet fw4 homeproxy_mainland_addr_v6 +nft delete set inet fw4 homeproxy_mainland_addr_v6 +nft flush set inet fw4 homeproxy_wan_proxy_addr_v4 +nft delete set inet fw4 homeproxy_wan_proxy_addr_v4 +nft flush set inet fw4 homeproxy_wan_proxy_addr_v6 +nft delete set inet fw4 homeproxy_wan_proxy_addr_v6 +nft flush set inet fw4 homeproxy_wan_direct_addr_v4 +nft delete set inet fw4 homeproxy_wan_direct_addr_v4 +nft flush set inet fw4 homeproxy_wan_direct_addr_v6 +nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -435,6 +458,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart ip link show singtun0 ip link set singtun0 up @@ -468,6 +493,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -504,6 +531,7 @@ config_load homeproxy-pro ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 var/run/homeproxy-pro/candidate/sing-box-c.json chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro nft flush chain inet fw4 homeproxy_output_redir @@ -552,6 +580,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -568,6 +600,8 @@ ip route show default ip -6 route show default ifstatus wan log Activating the client configuration validated by the reload preflight. +chmod 600 etc/crontabs/root +init.d/cron restart log dnsmasq snippets unchanged, skipping the restart. ip link show singtun0 ip link set singtun0 up @@ -601,6 +635,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -756,6 +792,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -806,6 +846,7 @@ uci delete homeproxy-pro.control.wan_direct_ipv4_ips uci delete homeproxy-pro.control.wan_direct_ipv6_ips uci commit homeproxy-pro chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro nft flush chain inet fw4 homeproxy_output_redir @@ -854,6 +895,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -870,6 +915,8 @@ ip route show default ip -6 route show default ifstatus wan log Starting with the last known-good client configuration. +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart ip link show singtun0 ip link set singtun0 up @@ -903,6 +950,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -949,6 +998,7 @@ log sing-box 1.14.0 started. log Rollback completed; the previous configuration is running. reload_service rc=0 chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro nft flush chain inet fw4 homeproxy_output_redir @@ -997,6 +1047,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1027,6 +1081,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart ip link show singtun0 ip link set singtun0 up @@ -1060,6 +1116,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown -R sing-box:sing-box etc/homeproxy-pro/ruleset chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log @@ -1093,90 +1151,6 @@ sing-box version -n log sing-box 1.14.0 started. start rc=0 chmod 600 etc/crontabs/root -init.d/cron restart -config_load homeproxy-pro -ip rule del fwmark 101 table 100 -ip route del local 0.0.0.0/0 dev lo table 100 -ip -6 rule del fwmark 101 table 100 -ip -6 route del local ::/0 dev lo table 100 -ip route del default dev singtun0 table 100 -ip rule del fwmark 102 table 100 -ip -6 route del default dev singtun0 table 100 -ip -6 rule del fwmark 102 table 100 -nft flush chain inet fw4 homeproxy_output_redir -nft delete chain inet fw4 homeproxy_output_redir -nft flush chain inet fw4 homeproxy_redirect -nft delete chain inet fw4 homeproxy_redirect -nft flush chain inet fw4 homeproxy_redirect_proxy -nft delete chain inet fw4 homeproxy_redirect_proxy -nft flush chain inet fw4 homeproxy_redirect_proxy_port -nft delete chain inet fw4 homeproxy_redirect_proxy_port -nft flush chain inet fw4 homeproxy_redirect_lanac -nft delete chain inet fw4 homeproxy_redirect_lanac -nft flush chain inet fw4 homeproxy_mangle_prerouting -nft delete chain inet fw4 homeproxy_mangle_prerouting -nft flush chain inet fw4 homeproxy_mangle_output -nft delete chain inet fw4 homeproxy_mangle_output -nft flush chain inet fw4 homeproxy_mangle_tproxy -nft delete chain inet fw4 homeproxy_mangle_tproxy -nft flush chain inet fw4 homeproxy_mangle_tproxy_port -nft delete chain inet fw4 homeproxy_mangle_tproxy_port -nft flush chain inet fw4 homeproxy_mangle_lanac -nft delete chain inet fw4 homeproxy_mangle_lanac -nft flush chain inet fw4 homeproxy_mangle_mark -nft delete chain inet fw4 homeproxy_mangle_mark -nft flush chain inet fw4 homeproxy_mangle_tun -nft delete chain inet fw4 homeproxy_mangle_tun -nft flush chain inet fw4 homeproxy_mangle_tun_mark -nft delete chain inet fw4 homeproxy_mangle_tun_mark -nft flush set inet fw4 homeproxy_local_addr_v4 -nft delete set inet fw4 homeproxy_local_addr_v4 -nft flush set inet fw4 homeproxy_local_addr_v6 -nft delete set inet fw4 homeproxy_local_addr_v6 -nft flush set inet fw4 homeproxy_gfw_list_v4 -nft delete set inet fw4 homeproxy_gfw_list_v4 -nft flush set inet fw4 homeproxy_gfw_list_v6 -nft delete set inet fw4 homeproxy_gfw_list_v6 -nft flush set inet fw4 homeproxy_mainland_addr_v4 -nft delete set inet fw4 homeproxy_mainland_addr_v4 -nft flush set inet fw4 homeproxy_mainland_addr_v6 -nft delete set inet fw4 homeproxy_mainland_addr_v6 -nft flush set inet fw4 homeproxy_wan_proxy_addr_v4 -nft delete set inet fw4 homeproxy_wan_proxy_addr_v4 -nft flush set inet fw4 homeproxy_wan_proxy_addr_v6 -nft delete set inet fw4 homeproxy_wan_proxy_addr_v6 -nft flush set inet fw4 homeproxy_wan_direct_addr_v4 -nft delete set inet fw4 homeproxy_wan_direct_addr_v4 -nft flush set inet fw4 homeproxy_wan_direct_addr_v6 -nft delete set inet fw4 homeproxy_wan_direct_addr_v6 -nft flush set inet fw4 homeproxy_routing_port -nft delete set inet fw4 homeproxy_routing_port -fw4 reload -init.d/miniupnpd enabled -init.d/miniupnpd running -init.d/miniupnpd restart -init.d/dnsmasq restart -log Service stopped. -config_load homeproxy-pro -ip link set singtun0 down -ip tuntap del mode tun name singtun0 -stop rc=0 -file var/run/homeproxy-pro/fw4_forward.nft: -file var/run/homeproxy-pro/fw4_input.nft: -file var/run/homeproxy-pro/fw4_post.nft: -file var/run/homeproxy-pro/homeproxy-pro.log: -file var/run/homeproxy-pro/known-good/sing-box-c.json: {| "log": {},| "inbounds": [| {| "tag": "dns-in",| "listen_port": 5333| },| {| "tag": "mixed-in",| "listen_port": 5330| }| ],| "outbounds": []|}| -file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.main_node=n1|homeproxy-pro.config.main_udp_node=nil|homeproxy-pro.config.proxy_mode=tun|homeproxy-pro.config.routing_mode=custom|homeproxy-pro.config.routing_port=|homeproxy-pro.config.ipv6_support=0|homeproxy-pro.config.china_dns_server=|homeproxy-pro.infra.self_mark=|homeproxy-pro.infra.redirect_port=|homeproxy-pro.infra.tproxy_port=|homeproxy-pro.infra.tproxy_mark=101|homeproxy-pro.infra.tun_mark=102|homeproxy-pro.infra.tun_name=singtun0|homeproxy-pro.infra.tun_addr4=|homeproxy-pro.infra.tun_addr6=|homeproxy-pro.infra.dns_redirect=|homeproxy-pro.infra.dns_port=5333|homeproxy-pro.infra.common_port=|homeproxy-pro.infra.table_mark=100|homeproxy-pro.routing.bypass_cn_traffic=|homeproxy-pro.routing.default_outbound=direct-out|homeproxy-pro.server.enabled=0|homeproxy-pro.control.lan_proxy_mode=|list homeproxy-pro.control.listen_interfaces=|list homeproxy-pro.control.lan_direct_ipv4_ips=|list homeproxy-pro.control.lan_direct_ipv6_ips=|list homeproxy-pro.control.lan_direct_mac_addrs=|list homeproxy-pro.control.lan_proxy_ipv4_ips=|list homeproxy-pro.control.lan_proxy_ipv6_ips=|list homeproxy-pro.control.lan_proxy_mac_addrs=|list homeproxy-pro.control.lan_gaming_mode_ipv4_ips=|list homeproxy-pro.control.lan_gaming_mode_ipv6_ips=|list homeproxy-pro.control.lan_gaming_mode_mac_addrs=|list homeproxy-pro.control.lan_global_proxy_ipv4_ips=|list homeproxy-pro.control.lan_global_proxy_ipv6_ips=|list homeproxy-pro.control.lan_global_proxy_mac_addrs=|list homeproxy-pro.control.wan_proxy_ipv4_ips=|list homeproxy-pro.control.wan_proxy_ipv6_ips=|list homeproxy-pro.control.wan_direct_ipv4_ips=|list homeproxy-pro.control.wan_direct_ipv6_ips=| -file etc/homeproxy-pro/cache.db: - -===== scenario: F-custom-routing-nothing-configured (start) ===== -uci -q show dhcp.@dnsmasq[0] -config_load homeproxy-pro -ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) -sing-box version -n -log Neither client nor server is configured. Service not started. -config_load homeproxy-pro -start rc=0 chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro @@ -1234,6 +1208,100 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 +nft flush set inet fw4 homeproxy_routing_port +nft delete set inet fw4 homeproxy_routing_port +fw4 reload +init.d/miniupnpd enabled +init.d/miniupnpd running +init.d/miniupnpd restart +init.d/dnsmasq restart +log Service stopped. +config_load homeproxy-pro +ip link set singtun0 down +ip tuntap del mode tun name singtun0 +stop rc=0 +file var/run/homeproxy-pro/fw4_forward.nft: +file var/run/homeproxy-pro/fw4_input.nft: +file var/run/homeproxy-pro/fw4_post.nft: +file var/run/homeproxy-pro/homeproxy-pro.log: +file var/run/homeproxy-pro/known-good/sing-box-c.json: {| "log": {},| "inbounds": [| {| "tag": "dns-in",| "listen_port": 5333| },| {| "tag": "mixed-in",| "listen_port": 5330| }| ],| "outbounds": []|}| +file var/run/homeproxy-pro/known-good/uci-snapshot.txt: homeproxy-pro.config.main_node=n1|homeproxy-pro.config.main_udp_node=nil|homeproxy-pro.config.proxy_mode=tun|homeproxy-pro.config.routing_mode=custom|homeproxy-pro.config.routing_port=|homeproxy-pro.config.ipv6_support=0|homeproxy-pro.config.china_dns_server=|homeproxy-pro.infra.self_mark=|homeproxy-pro.infra.redirect_port=|homeproxy-pro.infra.tproxy_port=|homeproxy-pro.infra.tproxy_mark=101|homeproxy-pro.infra.tun_mark=102|homeproxy-pro.infra.tun_name=singtun0|homeproxy-pro.infra.tun_addr4=|homeproxy-pro.infra.tun_addr6=|homeproxy-pro.infra.dns_redirect=|homeproxy-pro.infra.dns_port=5333|homeproxy-pro.infra.common_port=|homeproxy-pro.infra.table_mark=100|homeproxy-pro.routing.bypass_cn_traffic=|homeproxy-pro.routing.default_outbound=direct-out|homeproxy-pro.server.enabled=0|homeproxy-pro.control.lan_proxy_mode=|list homeproxy-pro.control.listen_interfaces=|list homeproxy-pro.control.lan_direct_ipv4_ips=|list homeproxy-pro.control.lan_direct_ipv6_ips=|list homeproxy-pro.control.lan_direct_mac_addrs=|list homeproxy-pro.control.lan_proxy_ipv4_ips=|list homeproxy-pro.control.lan_proxy_ipv6_ips=|list homeproxy-pro.control.lan_proxy_mac_addrs=|list homeproxy-pro.control.lan_gaming_mode_ipv4_ips=|list homeproxy-pro.control.lan_gaming_mode_ipv6_ips=|list homeproxy-pro.control.lan_gaming_mode_mac_addrs=|list homeproxy-pro.control.lan_global_proxy_ipv4_ips=|list homeproxy-pro.control.lan_global_proxy_ipv6_ips=|list homeproxy-pro.control.lan_global_proxy_mac_addrs=|list homeproxy-pro.control.wan_proxy_ipv4_ips=|list homeproxy-pro.control.wan_proxy_ipv6_ips=|list homeproxy-pro.control.wan_direct_ipv4_ips=|list homeproxy-pro.control.wan_direct_ipv6_ips=| +file etc/homeproxy-pro/cache.db: + +===== scenario: F-custom-routing-nothing-configured (start) ===== +uci -q show dhcp.@dnsmasq[0] +config_load homeproxy-pro +ucode -e import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1) +sing-box version -n +log Neither client nor server is configured. Service not started. +config_load homeproxy-pro +start rc=0 +chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root +init.d/cron restart +config_load homeproxy-pro +ip rule del fwmark 101 table 100 +ip route del local 0.0.0.0/0 dev lo table 100 +ip -6 rule del fwmark 101 table 100 +ip -6 route del local ::/0 dev lo table 100 +ip route del default dev singtun0 table 100 +ip rule del fwmark 102 table 100 +ip -6 route del default dev singtun0 table 100 +ip -6 rule del fwmark 102 table 100 +nft flush chain inet fw4 homeproxy_output_redir +nft delete chain inet fw4 homeproxy_output_redir +nft flush chain inet fw4 homeproxy_redirect +nft delete chain inet fw4 homeproxy_redirect +nft flush chain inet fw4 homeproxy_redirect_proxy +nft delete chain inet fw4 homeproxy_redirect_proxy +nft flush chain inet fw4 homeproxy_redirect_proxy_port +nft delete chain inet fw4 homeproxy_redirect_proxy_port +nft flush chain inet fw4 homeproxy_redirect_lanac +nft delete chain inet fw4 homeproxy_redirect_lanac +nft flush chain inet fw4 homeproxy_mangle_prerouting +nft delete chain inet fw4 homeproxy_mangle_prerouting +nft flush chain inet fw4 homeproxy_mangle_output +nft delete chain inet fw4 homeproxy_mangle_output +nft flush chain inet fw4 homeproxy_mangle_tproxy +nft delete chain inet fw4 homeproxy_mangle_tproxy +nft flush chain inet fw4 homeproxy_mangle_tproxy_port +nft delete chain inet fw4 homeproxy_mangle_tproxy_port +nft flush chain inet fw4 homeproxy_mangle_lanac +nft delete chain inet fw4 homeproxy_mangle_lanac +nft flush chain inet fw4 homeproxy_mangle_mark +nft delete chain inet fw4 homeproxy_mangle_mark +nft flush chain inet fw4 homeproxy_mangle_tun +nft delete chain inet fw4 homeproxy_mangle_tun +nft flush chain inet fw4 homeproxy_mangle_tun_mark +nft delete chain inet fw4 homeproxy_mangle_tun_mark +nft flush set inet fw4 homeproxy_local_addr_v4 +nft delete set inet fw4 homeproxy_local_addr_v4 +nft flush set inet fw4 homeproxy_local_addr_v6 +nft delete set inet fw4 homeproxy_local_addr_v6 +nft flush set inet fw4 homeproxy_gfw_list_v4 +nft delete set inet fw4 homeproxy_gfw_list_v4 +nft flush set inet fw4 homeproxy_gfw_list_v6 +nft delete set inet fw4 homeproxy_gfw_list_v6 +nft flush set inet fw4 homeproxy_mainland_addr_v4 +nft delete set inet fw4 homeproxy_mainland_addr_v4 +nft flush set inet fw4 homeproxy_mainland_addr_v6 +nft delete set inet fw4 homeproxy_mainland_addr_v6 +nft flush set inet fw4 homeproxy_wan_proxy_addr_v4 +nft delete set inet fw4 homeproxy_wan_proxy_addr_v4 +nft flush set inet fw4 homeproxy_wan_proxy_addr_v6 +nft delete set inet fw4 homeproxy_wan_proxy_addr_v6 +nft flush set inet fw4 homeproxy_wan_direct_addr_v4 +nft delete set inet fw4 homeproxy_wan_direct_addr_v4 +nft flush set inet fw4 homeproxy_wan_direct_addr_v6 +nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1259,6 +1327,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart procd_open_instance sing-box-c procd_set_param command /usr/bin/sing-box @@ -1287,6 +1357,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -1442,6 +1514,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1451,6 +1527,7 @@ init.d/miniupnpd restart init.d/dnsmasq restart start rc=1 chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro ip rule del fwmark 101 table 100 @@ -1507,6 +1584,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1534,6 +1615,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart log Warning: infra.dns_port=70000 is out of range; skipping the dnsmasq snippets. log Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration. ip link show singtun0 @@ -1568,6 +1651,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.json chown sing-box:sing-box var/run/homeproxy-pro/sing-box-c.log chown sing-box:sing-box etc/homeproxy-pro/cache.db @@ -1599,6 +1684,7 @@ sing-box version -n log sing-box 1.14.0 started. start rc=0 chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro ip rule del fwmark 101 table 100 @@ -1655,6 +1741,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1682,6 +1772,8 @@ ip route show default ip -6 route show default ifstatus wan ucode -S etc/homeproxy-pro/scripts/generate_client.uc +chmod 600 etc/crontabs/root +init.d/cron restart init.d/dnsmasq restart procd_open_instance sing-box-c procd_set_param command /usr/bin/sing-box @@ -1731,6 +1823,8 @@ procd_open_instance log-cleaner procd_set_param command etc/homeproxy-pro/scripts/clean_log.sh procd_set_param respawn procd_close_instance +ucode -S etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc etc/homeproxy-pro/resources/china_list.txt etc/homeproxy-pro/resources/china-domain.json +chown sing-box:sing-box etc/homeproxy-pro/resources/china-domain.json chown sing-box:sing-box etc/homeproxy-pro/certs chmod 700 etc/homeproxy-pro/certs chown sing-box:sing-box etc/homeproxy-pro/certs/server_privatekey.pem @@ -1839,6 +1933,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload @@ -1904,6 +2002,7 @@ sing-box version -n log sing-box 1.14.0 started. start rc=0 chmod 600 etc/crontabs/root +chmod 600 etc/crontabs/root init.d/cron restart config_load homeproxy-pro ip rule del fwmark 101 table 100 @@ -1960,6 +2059,10 @@ nft flush set inet fw4 homeproxy_wan_direct_addr_v4 nft delete set inet fw4 homeproxy_wan_direct_addr_v4 nft flush set inet fw4 homeproxy_wan_direct_addr_v6 nft delete set inet fw4 homeproxy_wan_direct_addr_v6 +nft flush set inet fw4 homeproxy_node_addr_v4 +nft delete set inet fw4 homeproxy_node_addr_v4 +nft flush set inet fw4 homeproxy_node_addr_v6 +nft delete set inet fw4 homeproxy_node_addr_v6 nft flush set inet fw4 homeproxy_routing_port nft delete set inet fw4 homeproxy_routing_port fw4 reload diff --git a/luci-app-homeproxy-pro/tests/frontend-rpc-fallbacks.js b/luci-app-homeproxy-pro/tests/frontend-rpc-fallbacks.js index c0829004b..81cbc33ac 100644 --- a/luci-app-homeproxy-pro/tests/frontend-rpc-fallbacks.js +++ b/luci-app-homeproxy-pro/tests/frontend-rpc-fallbacks.js @@ -178,10 +178,10 @@ function makePoll() { /* --- the rule-set degradation report ------------------------------------ * - * parseRuleSetFetchFailures() is the only thing that tells a user a rule-set - * is currently EMPTY, because with ruleset_safe_start on there is no other - * symptom: the instance is healthy, the health gate passes (nothing in - * health.sh looks at rule-sets), and the LAN is proxied. + * A remote rule-set is fetched before the service starts, so a failure there + * is a failed start and the running instance has nothing to show for it. The + * reason is only ever written to a log, and a start that failed has no status + * page - so this parse is the only place it becomes legible without ssh. * * It parses a file sing-box is concurrently writing and that clean_log.sh * truncates at 50 KB, so the cases below are about tolerance and about not diff --git a/luci-app-homeproxy-pro/tests/ucode/run.sh b/luci-app-homeproxy-pro/tests/ucode/run.sh index 312922fb0..2b1f1f579 100755 --- a/luci-app-homeproxy-pro/tests/ucode/run.sh +++ b/luci-app-homeproxy-pro/tests/ucode/run.sh @@ -362,6 +362,12 @@ echo "== china ip rule-set generator ==" # validation and its atomic write are both load-bearing. sh "$ROOT/tests/ucode/test_china_ip_ruleset.sh" "$ROOT" "$WORK/china_ip_ruleset" || FAILED=1 +# The DNS half of the same split. Same failure mode and the same consequence +# class - one bad entry in a list that is replaced unattended makes sing-box +# reject the whole rule-set - plus one that is its own: the entry has to be a +# suffix, or the split quietly stops matching anything under the listed names. +sh "$ROOT/tests/ucode/test_domain_ruleset.sh" "$ROOT" "$WORK/domain_ruleset" || FAILED=1 + echo "== test doubles still match production ==" # The mocks copy isEmpty/decodeBase64Str/parseURL/redactUrl from production and # their headers say to keep them in sync; nothing enforced it, and the fetcher's diff --git a/luci-app-homeproxy-pro/tests/ucode/test_dns_proxy_list.sh b/luci-app-homeproxy-pro/tests/ucode/test_dns_proxy_list.sh index 01b13f05c..3b94c6473 100644 --- a/luci-app-homeproxy-pro/tests/ucode/test_dns_proxy_list.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_dns_proxy_list.sh @@ -83,6 +83,17 @@ stage_case() { printf '{"version":3,"rules":[{"ip_cidr":["192.0.2.0/24"]}]}\n' > "$dir/resources/china_ip4.json" fi + # The DNS half of the China split. generate_client.uc reports it as + # ctx.china_domain_ready, an lstat on this file; absent, the domestic + # lookup rule is not emitted and the ordering this file asserts cannot be + # observed at all. On a router hp_prepare_runtime_files generates it from + # china_list.txt by the same route as china_ip4.json above. + if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc" \ + "$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" "$dir/resources/china-domain.json" \ + >>"$dir/resources/china-domain.log" 2>&1; then + printf '{"version":3,"rules":[{"domain_suffix":["example.invalid"]}]}\n' > "$dir/resources/china-domain.json" + fi + VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}" sed -e "s#^export const HP_DIR = '/etc/homeproxy-pro';#export const HP_DIR = '$dir';#" \ -e "s#^export const RUN_DIR = '/var/run/homeproxy-pro';#export const RUN_DIR = '$dir/run';#" \ @@ -173,15 +184,15 @@ check_fallback_pair() { echo "PASS: $label: route.final=$have_route and dns.final=$have_dns agree" - # Both mainland modes match destinations against geoip-cn, so both have to - # declare it. proxy_mainland_china reached this point without the - # declaration once and sing-box refused the whole config with - # "initialize rule[3]: rule-set not found: geoip-cn"; the health gate then + # Both mainland modes match destinations against the local china-ip + # rule-set, so both have to declare it. proxy_mainland_china reached this + # point without the declaration once and sing-box refused the whole config + # with "initialize rule[3]: rule-set not found"; the health gate then # rolled the intercept layer back and the network went unproxied. case "$label" in bypass_mainland_china|proxy_mainland_china) - if ! grep -qF '"rule_set": "geoip-cn"' "$json" || ! grep -qF '"tag": "geoip-cn"' "$json"; then - echo "FAIL: $label: the route rule matches geoip-cn but the rule-set is not declared" + if ! grep -qF '"rule_set": "china-ip"' "$json" || ! grep -qF '"tag": "china-ip"' "$json"; then + echo "FAIL: $label: the route rule matches china-ip but the rule-set is not declared" return 1 fi ;; @@ -232,9 +243,9 @@ check_proxy_list() { # In the one mode that has both, the domestic lookup still comes last. case "$label" in bypass_mainland_china*) - geosite_rule="$(grep -nF '"rule_set": "geosite-cn"' "$json" | head -1 | cut -d: -f1)" - if [ -z "$geosite_rule" ] || [ "$geosite_rule" -lt "$dns_rule" ]; then - echo "FAIL: $label: geosite-cn must come after proxy-domain (proxy=$dns_rule geosite=${geosite_rule:-none})" + china_domain_rule="$(grep -nF '"rule_set": "china-domain"' "$json" | head -1 | cut -d: -f1)" + if [ -z "$china_domain_rule" ] || [ "$china_domain_rule" -lt "$dns_rule" ]; then + echo "FAIL: $label: china-domain must come after proxy-domain (proxy=$dns_rule china-domain=${china_domain_rule:-none})" return 1 fi ;; diff --git a/luci-app-homeproxy-pro/tests/ucode/test_domain_ruleset.sh b/luci-app-homeproxy-pro/tests/ucode/test_domain_ruleset.sh new file mode 100755 index 000000000..f2e3f7c76 --- /dev/null +++ b/luci-app-homeproxy-pro/tests/ucode/test_domain_ruleset.sh @@ -0,0 +1,184 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# +# runtime/domain_ruleset.uc: the text -> sing-box local rule-set generator for +# the DNS half of the China split. +# +# Why this has its own test: it is the only thing standing between a downloaded +# host-name list and a DNS split. sing-box rejects a rule-set containing an +# entry it cannot parse, and it rejects the WHOLE file when it does - so one bad +# line in a list that is replaced unattended, on a router nobody is watching, +# takes the resolver's half of the split down with it. The cases below are the +# ones that decide whether that can happen. +# +# Three properties matter beyond "it produces a file": +# +# - the entry has to be a SUFFIX. `domain` would match only the name itself +# and quietly stop matching www. for the whole list, which is a +# split that looks configured and behaves as if the list were empty. +# - the host-name check has to be in-process. validation() in homeproxy-pro.uc +# shells out to /sbin/validate_data once per call, and this list is 111k +# lines - that is 111k process spawns, which is not a test that passes, it +# is a test that never finishes. +# - an empty or all-malformed source must fail rather than install a +# rule-set that matches nothing. +# +# Usage: sh tests/ucode/test_domain_ruleset.sh [workdir] + +set -u + +ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)" +WORK="${2:-/tmp/hp-domain-test}" + +if ! command -v ucode > "/dev/null" 2>&1; then + echo "NOT RUN: ucode is not on PATH." + exit 2 +fi + +HELPER="$ROOT/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc" +rm -rf "$WORK" +mkdir -p "$WORK" + +FAILED=0 +CHECKS=0 +FAILURES=0 + +expect() { + # expect + CHECKS=$((CHECKS + 1)) + if [ "$2" = "$3" ]; then + echo "PASS: $1" + else + echo "FAIL: $1 (expected '$3', got '$2')" + FAILED=1 + FAILURES=$((FAILURES + 1)) + fi +} + +generate() { + # generate ; stdout is "N domain suffixes" + ucode -S "$HELPER" "$1" "$2" 2>"/dev/null" +} + +# --- fixtures --------------------------------------------------------------- + +# A list shaped like the real one: comments, blank lines, plain two-label +# names, the single-label and deep forms that appear in a real suffix list, and +# the ways a line can be malformed. +# +# Every malformed entry below is named so that its own assertion's grep pattern +# MATCHES it. That is the point: an assertion whose pattern cannot match the +# entry it is about stays green whether or not the entry is rejected, which is +# a guard pointing at the wrong side. The first draft of this fixture had +# "trailing-hyphen.com" and "trailing.dot.com" - both perfectly valid names - +# while the assertions claimed a trailing hyphen and a trailing dot, and both +# assertions were vacuous. +cat > "$WORK/mixed.txt" <<'EOF' +# comment +; also a comment + +taobao.com +163.com +0.zone +a.b.c.example.cn +xn--fiqs8s.com +under_score.com +-leading-hyphen.com +trailinghyphen-.com +double..dot.com +.dotted.com +trailingdot.com. +with space.com +http://scheme.com +192.168.1.1 +EOF + +# Five survive: taobao.com, 163.com, 0.zone, a.b.c.example.cn, +# xn--fiqs8s.com. Everything else in the fixture is one malformation. +expect "mixed list: only the valid entries survive" \ + "$(generate "$WORK/mixed.txt" "$WORK/mixed.json")" "5 domain suffixes" +expect "mixed list: the underscore name is gone" \ + "$(grep -c 'under_score' "$WORK/mixed.json")" "0" +expect "mixed list: the leading-hyphen label is gone" \ + "$(grep -c 'leading-hyphen' "$WORK/mixed.json")" "0" +expect "mixed list: the trailing-hyphen label is gone" \ + "$(grep -c 'trailinghyphen-' "$WORK/mixed.json")" "0" +expect "mixed list: the doubled dot is gone" \ + "$(grep -c 'double\.\.dot' "$WORK/mixed.json")" "0" +expect "mixed list: the leading dot is gone" \ + "$(grep -c '"\.dotted\.com"' "$WORK/mixed.json")" "0" +expect "mixed list: the trailing dot is gone" \ + "$(grep -c '"trailingdot\.com\."' "$WORK/mixed.json")" "0" +expect "mixed list: the name with a space is gone" \ + "$(grep -c 'with space' "$WORK/mixed.json")" "0" +expect "mixed list: the URL is gone" \ + "$(grep -c 'scheme.com' "$WORK/mixed.json")" "0" +expect "mixed list: a bare address is not treated as a domain" \ + "$(grep -c '192\.168\.1\.1' "$WORK/mixed.json")" "0" +expect "mixed list: the plain names stay" \ + "$(grep -c 'taobao\.com\|163\.com\|0\.zone\|a\.b\.c\.example\.cn\|xn--fiqs8s\.com' "$WORK/mixed.json")" "5" + +# The single most consequential property: suffix, not exact. +expect "output is a single domain_suffix rule" \ + "$(grep -c '"domain_suffix"' "$WORK/mixed.json")" "1" +expect "output does not use exact domain matching" \ + "$(grep -c '"domain":' "$WORK/mixed.json")" "0" +expect "output carries a version" \ + "$(grep -c '"version": 3' "$WORK/mixed.json")" "1" + +# --- the real bundled list -------------------------------------------------- + +# This is the list the DNS split runs on. A generator that mangles it would +# send every mainland lookup to the proxy resolver while looking healthy. +if [ -f "$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" ]; then + expect "the bundled china_list.txt generates" \ + "$(generate "$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" "$WORK/real.json")" \ + "$(wc -l < "$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" | tr -d ' ') domain suffixes" + expect "the bundled list is emitted as suffixes, not exact names" \ + "$(grep -c '"domain_suffix"' "$WORK/real.json")" "1" + expect "the bundled list emits no exact-domain rules" \ + "$(grep -c '"domain":' "$WORK/real.json")" "0" +else + echo "SKIP: the bundled china_list.txt is not in the tree." +fi + +# --- failure paths ---------------------------------------------------------- + +# An empty source must not install an empty rule-set: the DNS split would then +# match nothing and every mainland lookup would go to the proxy resolver, with +# nothing in the log saying so. +: > "$WORK/empty.txt" +expect "empty source produces no file" "$(generate "$WORK/empty.txt" "$WORK/empty.json")" "" +ucode -S "$HELPER" "$WORK/empty.txt" "$WORK/empty.json" >/dev/null 2>&1 +expect "empty source exits non-zero" "$?" "1" + +# A source that is entirely malformed is the same case reached differently. +printf 'http://a\nhttp://b\nunder_score\n' > "$WORK/junk.txt" +expect "all-malformed source produces no file" "$(generate "$WORK/junk.txt" "$WORK/junk.json")" "" +ucode -S "$HELPER" "$WORK/junk.txt" "$WORK/junk.json" >/dev/null 2>&1 +expect "all-malformed source exits non-zero" "$?" "1" + +# A missing source is a caller error, not an empty list. +expect "missing source exits non-zero" \ + "$(ucode -S "$HELPER" "$WORK/no-such-file.txt" "$WORK/none.json" >/dev/null 2>&1; echo $?)" "1" + +# --- atomicity -------------------------------------------------------------- + +# sing-box watches this file and reloads it in place, so a partially written +# one is read by a running instance. The destination has to appear whole or +# not at all. +cat > "$WORK/atomic.txt" <<'EOF' +one.example +two.example +three.example +EOF +generate "$WORK/atomic.txt" "$WORK/atomic.json" >/dev/null +expect "the destination is complete after a run" \ + "$(grep -c 'one\.example\|two\.example\|three\.example' "$WORK/atomic.json")" "3" +expect "no temporary file is left behind" \ + "$(ls "$WORK" | grep -c 'atomic\.json\.tmp')" "0" + +# --- summary ---------------------------------------------------------------- + +echo " ($CHECKS checks, $FAILURES failures)" +exit "$FAILED" diff --git a/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh b/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh index e6568e9c3..e55b90409 100644 --- a/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh @@ -118,6 +118,86 @@ OUT="$STAGE/rendered.nft" ERR="$STAGE/render.err" trap '[ "$OWN_STAGE" = 1 ] && rm -rf "$STAGE"' EXIT INT TERM +# --- the node-address bypass is not gated on the literal-address file ------ +# +# This is a source-level check, not a render one, on purpose. The render layer +# below is skipped wherever firewall4 is absent - which is every off-target +# run - and the failure this pins is one that renders perfectly and does +# nothing: with a host-name node there are no literal addresses, so a set and +# rules gated on "the literal list is non-empty" simply are not emitted, and +# dnsmasq's nftset= back-fill has no set to land in. r41 shipped that. +# +# The invariant, stated so it can be grepped: `node_addr_v4` may guard the +# `elements = { ... }` line and nothing else. Deciding whether the set and the +# return rules exist at all belongs to node_protect, which is true when either +# export file has content. +if ! grep -q 'function node_addr_protected(run_dir)' "$TEMPLATE_SRC"; then + echo "FAIL: firewall_post.ut has no node_addr_protected(); the node bypass cannot" + echo " distinguish 'nothing to protect' from 'a host name dnsmasq will resolve'" + FAILED=1 +else + np_body="$(sed -n '/function node_addr_protected(run_dir)/,/^}/p' "$TEMPLATE_SRC")" + for f in node-addr-ips.txt node-addr-domains.txt; do + if ! printf '%s' "$np_body" | grep -q "$f"; then + echo "FAIL: node_addr_protected() does not look at $f" + FAILED=1 + fi + done + [ "$FAILED" -eq 0 ] && echo "PASS: node_addr_protected() covers both export files" +fi + +# Both helpers read run_dir, and they are defined ~80 lines above its +# declaration. In this ucode a closure captures the block variables that exist +# where the function is DEFINED, so a function defined earlier cannot see a +# const declared later - the call fails with "access to undeclared variable +# run_dir" while run_dir sits right there in the file. Passing it in is the +# only spelling that works, and r43 is what proved it. +for fn in node_addr_protected node_addr_to_nftarr; do + if ! grep -q "function $fn(run_dir" "$TEMPLATE_SRC"; then + echo "FAIL: $fn does not take run_dir as a parameter; in this ucode a closure only" + echo " sees block variables declared before the function definition, and both" + echo " helpers are defined above run_dir" + FAILED=1 + fi +done +[ "$FAILED" -eq 0 ] && echo "PASS: the node-address helpers take run_dir as a parameter" + +# Every consumer of the v4 set outside its own elements= line must be gated on +# node_protect. `node_addr_v4)` as a condition is exactly the mistake. +stray_gate="$(awk ' + /if \(node_addr_v4/ { + # The one legitimate use guards the elements= line that follows it. + saw = 1; ln = NR; next + } + saw { + if ($0 ~ /elements *= *\{/) { saw = 0; next } + print " " ln ": " $0 + saw = 0 + } +' "$TEMPLATE_SRC" || true)" +if [ -n "$stray_gate" ]; then + echo "FAIL: the node-address set is gated on node_addr_v4 having elements; a host-name" + echo " node has none, and the set plus its return rules then never exist:" + printf ' %s\n' "$stray_gate" + FAILED=1 +fi + +# And the return rules themselves must be present in all three chains, each +# behind node_protect rather than behind a literal-address list. +for chain in redirect mangle_prerouting mangle_output; do + if ! grep -q "$chain" "$TEMPLATE_SRC"; then + echo "FAIL: chain $chain disappeared from firewall_post.ut" + FAILED=1 + fi +done +v4_rules="$(grep -c 'ip daddr @homeproxy_node_addr_v4 counter return' "$TEMPLATE_SRC" || true)" +if [ "$v4_rules" -eq 3 ]; then + echo "PASS: all three chains carry the node-address return rule" +else + echo "FAIL: expected 3 'ip daddr @homeproxy_node_addr_v4 counter return' rules, found $v4_rules" + FAILED=1 +fi + # Probe with ucode, not utpl: `utpl -e` is not an eval flag, it renders the # argument as template text and always succeeds. if ! ucode -e 'require("fw4");' > "/dev/null" 2>&1; then diff --git a/luci-app-homeproxy-pro/tests/ucode/test_generators.sh b/luci-app-homeproxy-pro/tests/ucode/test_generators.sh index de0207c56..a666ae303 100755 --- a/luci-app-homeproxy-pro/tests/ucode/test_generators.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_generators.sh @@ -73,6 +73,19 @@ run_case() { fi fi + # And the DNS half of the split, staged for the same reason and by the + # same route: hp_prepare_runtime_files runs domain_ruleset.uc over + # china_list.txt, and generate_client.uc reports the result as + # ctx.china_domain_ready (an lstat on this very file). Without it the + # DNS rule that carries the domestic lookup is not emitted at all - by + # design, because a rule_set naming a tag nothing declared makes + # sing-box reject the whole config. + if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/domain_ruleset.uc" \ + "$ROOT/root/etc/homeproxy-pro/resources/china_list.txt" "$dir/resources/china-domain.json" \ + >>"$dir/resources/china-domain.log" 2>&1; then + printf '{"version":3,"rules":[{"domain_suffix":["example.invalid"]}]}\n' > "$dir/resources/china-domain.json" + fi + if grep -q "__RULESET_DIR__" "$fixture"; then # The fixture needs a real local rule-set on disk. The path has to # satisfy validateRuleSetPath(), whose RULE_PATH_ROOTS was rewritten @@ -127,15 +140,11 @@ run_case() { fi - # Stage the two empty-rule-set files the "do not block startup" fallback - # uses. RULESET_INITIAL_DIR is derived from HP_DIR, which was rewritten - # to $dir above, so the generator looks for them under $dir/ruleset/initial - # and the repository's copy has to be staged there. Copied rather than - # referenced so a case that deletes or corrupts one cannot damage the - # checkout - the byte-exactness of the constant is arch-guard 52's job. - mkdir -p "$dir/ruleset/initial" - cp "$ROOT/root/etc/homeproxy-pro/ruleset/initial/empty.srs" "$dir/ruleset/initial/" - cp "$ROOT/root/etc/homeproxy-pro/ruleset/initial/empty.source.json" "$dir/ruleset/initial/" + # Stage the rule-set archive. A user-defined `type: local` rule-set names + # a path under it, and `sing-box check` opens every one of them, so the + # directory has to exist before the generated configuration is validated - + # the same reason hp_prepare_ruleset_dir() creates it on a real start. + mkdir -p "$dir/ruleset" # HP_VALIDATE_DATA lets a development host replace /sbin/validate_data # (see tests/README.md); on a target the production path is kept. @@ -464,6 +473,75 @@ EOF esac fi +# The same fixture with a NON-default self_mark. 250 is deliberately clear +# of every other mark in the fixtures (self_mark 100, tproxy_mark 101, +# tun_mark 102), so a value that leaked in from the wrong place would show up +# as a mismatch rather than as a coincidence. +# +# Why this case exists at all: the mark is a two-sided contract read from one +# UCI option - firewall_post.ut renders `meta mark counter +# return`, generator/context.uc turns the same value into every outbound's +# routing_mark. Both sides read infra.self_mark, but they normalise it +# differently: the nft side goes through mark_value_or(), which falls back to +# 100 for anything fw4.parse_mark does not reduce to a bare hex value, while +# the generator hands the raw string to strToInt(). Until now every fixture +# declared 100, so the two paths never had to agree on anything else and the +# divergence was untested. +# +# A dedicated fixture file would differ from redirect.uci by that one option, +# which is what run_case's variation argument is for (see the main_udp_node +# case below for the other user of it). The expected value is read back from +# the STAGED fixture rather than hardcoded: fixture_self_mark() takes a path, +# and the staged copy is what the generator actually saw, so the assertion +# cannot drift away from its input the way a literal 250 would. +run_case redirect-self-mark "$ROOT/tests/fixtures/generators/redirect.uci" generate_client.uc sing-box-c.json \ + "s/^\([[:space:]]*\)option self_mark '100'/\1option self_mark '250'/" + +rsm_dir="$WORK/redirect-self-mark" +rsm_json="$rsm_dir/run/sing-box-c.json" +rsm_mark="$(fixture_self_mark "$rsm_dir/config/homeproxy-pro")" + +if [ "$rsm_mark" != "250" ]; then + # Two ways to land here, both a vacuous assertion rather than a product + # failure: the variation did not apply, or fixture_self_mark cannot read + # what is there. run_case's own cmp guard catches a variation that + # matched nothing, so reaching this means the mark is unreadable. + echo "FAIL: redirect-self-mark: the staged fixture declares self_mark='$rsm_mark'," + echo " not the 250 this case varies to, so the assertion is vacuous" + FAILED=1 +elif [ ! -f "$rsm_json" ]; then + echo "FAIL: redirect-self-mark: no config was generated" + FAILED=1 +else + mark_result="$(ucode "$WORK/markcheck.uc" "$rsm_mark" "$rsm_json" 2>"/dev/null")" + mark_checked="${mark_result%% *}" + mark_problems="${mark_result##* }" + + case "$mark_checked" in + ''|*[!0-9]*) + echo "FAIL: redirect-self-mark: could not check the emitted outbounds ($rsm_json)" + FAILED=1 + ;; + 0) + echo "FAIL: redirect-self-mark: no dialling outbound was emitted, the assertion is vacuous" + FAILED=1 + ;; + *) + if [ "$mark_problems" -ne 0 ]; then + echo "FAIL: redirect-self-mark: $mark_problems of $mark_checked dialling outbounds do not" + echo " carry routing_mark=$rsm_mark. With infra.self_mark at a non-default" + echo " value the nft side and the generator must still agree: a generator" + echo " that fell back to 100 (or dropped the field) while firewall_post.ut" + echo " emitted 'meta mark 250 counter return' leaves the loop-prevention" + echo " return matching nothing." + FAILED=1 + else + echo "PASS: redirect-self-mark: all $mark_checked dialling outbounds carry routing_mark=$rsm_mark" + fi + ;; + esac +fi + # The UDP tproxy path: a dedicated UDP node makes context.uc assign # tproxy_port, firewall_post.ut emit the tproxy chain, and inbound.uc emit the # tproxy-in that chain redirects to. No fixture covered this path before - @@ -722,7 +800,7 @@ const config = json(readfile(ARGV[0])); const rules = config.dns?.rules || []; let has_eval_tag = false, has_match_response = false, geoip_ref = false; /* rule_set is emitted as an array because the cn-fallback match names more - * than one rule-set when IPv6 support is on (geoip-cn + china-ip6). sing-box + * than one rule-set when IPv6 support is on (china-ip + china-ip6). sing-box * accepts a bare string for the single-tag case, so accept both shapes here * rather than pinning the one this build happens to produce. */ const names = (v) => (type(v) === 'array' ? v : [v]); @@ -731,7 +809,7 @@ for (let r in rules) { has_eval_tag = true; if (r.match_response === 'cn-fallback') { has_match_response = true; - if (index(names(r.rule_set), 'geoip-cn') >= 0) + if (index(names(r.rule_set), 'china-ip') >= 0) geoip_ref = true; } } @@ -739,7 +817,7 @@ printf('%d %d %d\n', +has_eval_tag, +has_match_response, +geoip_ref); EOF cn_info="$(ucode "$WORK/cn-fallback.uc" "$client_json" 2>/dev/null)" if [ "$cn_info" = "1 1 1" ]; then - echo "PASS: cn-fallback-default-on: evaluate+match_response with geoip-cn is in the proxy config" + echo "PASS: cn-fallback-default-on: evaluate+match_response with china-ip is in the proxy config" else echo "FAIL: cn-fallback-default-on: evaluate/match_response flags = '$cn_info', expected '1 1 1'" FAILED=1 @@ -957,36 +1035,36 @@ run_case_type_error dangling-resolver "does not exist or is disabled" \ # it runs identically on macOS and the testbed. ucode-stdout probes # are out; structural assertions against the emitted bytes are in. -# 1) P2 #2: geosite-cn is the *DNS*-layer rule_set consumer. +# 1) china-domain is the *DNS*-layer rule_set consumer. # The route layer never references it as a rule_set value (route.rule_set # declares it as a tag). Indent cannot disambiguate dns.rules[] from # route.rules[] (both pretty-print at 4 tabs on the testbed; both are # bare-string `"rule_set": "..."`), so use count-based invariants: -# - "tag": "geosite-cn" appears at least once (route.rule_set) -# - "rule_set": "geosite-cn" appears EXACTLY once (dns.rules only; +# - "tag": "china-domain" appears at least once (route.rule_set) +# - "rule_set": "china-domain" appears EXACTLY once (dns.rules only; # route.rules must NOT add a second one). -# A route regression that adds `"rule_set": "geosite-cn"` to route.rules +# A route regression that adds `"rule_set": "china-domain"` to route.rules # bumps the count to 2 and fails loud. -run_case route-no-geosite-cn "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json +run_case route-no-china-domain "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json -rs_json="$WORK/route-no-geosite-cn/run/sing-box-c.json" +rs_json="$WORK/route-no-china-domain/run/sing-box-c.json" if [ ! -f "$rs_json" ]; then - echo "FAIL: route-no-geosite-cn: no config was generated" + echo "FAIL: route-no-china-domain: no config was generated" FAILED=1 else - rs_tag_count="$(grep -c '"tag": "geosite-cn"' "$rs_json")" - rs_ruleset_count="$(grep -c '"rule_set": "geosite-cn"' "$rs_json")" + rs_tag_count="$(grep -c '"tag": "china-domain"' "$rs_json")" + rs_ruleset_count="$(grep -c '"rule_set": "china-domain"' "$rs_json")" if [ "$rs_tag_count" -lt 1 ]; then - echo "FAIL: route-no-geosite-cn: route.rule_set does not declare geosite-cn (tag count=$rs_tag_count, want >=1)" + echo "FAIL: route-no-china-domain: route.rule_set does not declare china-domain (tag count=$rs_tag_count, want >=1)" FAILED=1 fi if [ "$rs_ruleset_count" -ne 1 ]; then - echo "FAIL: route-no-geosite-cn: expected exactly 1 '\"rule_set\": \"geosite-cn\"' line (dns.rules only); got $rs_ruleset_count" - grep -n '"rule_set": "geosite-cn"' "$rs_json" | sed 's/^/ /' + echo "FAIL: route-no-china-domain: expected exactly 1 '\"rule_set\": \"china-domain\"' line (dns.rules only); got $rs_ruleset_count" + grep -n '"rule_set": "china-domain"' "$rs_json" | sed 's/^/ /' FAILED=1 fi if [ "$rs_tag_count" -ge 1 ] && [ "$rs_ruleset_count" -eq 1 ]; then - echo "PASS: route-no-geosite-cn: route.rule_set has geosite-cn (tag=$rs_tag_count); route.rules never references it (rule_set=$rs_ruleset_count, dns only)" + echo "PASS: route-no-china-domain: route.rule_set has china-domain (tag=$rs_tag_count); route.rules never references it (rule_set=$rs_ruleset_count, dns only)" fi fi @@ -1355,177 +1433,74 @@ for pair in "ruleset-update-interval:3600s:3600" "ruleset-update-interval-unit:2 fi done -# 8) Batch 2a: the "do not block startup on the first download" fallback. +# 8) Node server addresses: the generator exports them for the intercept layer. # -# A remote rule-set with no initial_path is fetched during initialization, -# BEFORE the inbounds bind. On a cold cache - which is what a fresh install -# has, since cache.db ships empty - that means the first start waits on -# raw.githubusercontent.com, through the selected node, before the instance -# will listen at all; and a failure there is a FATAL that the health gate -# turns into a rollback or a released intercept layer. +# LAN traffic to the node's own address used to be redirected into sing-box, +# which then dialled that same address through the tunnel to that same node. +# sing-box's own outbound is exempt (routing_mark / self_mark); the LAN was +# not, and nothing in the rule-set kept it out. # -# The opt-in points such a rule-set at an EMPTY rule-set, so sing-box reads -# a file, starts, and refreshes from the URL in the background. It is -# opt-in because an empty rule-set matches nothing and everything it would -# have split falls through to `final` - a routing change, not a robustness -# tweak. -# -# The first case is the one that matters most and is the easiest to get -# wrong: with the switch off, nothing may change. Not "the feature is off", -# but no initial_path, no file written, and a configuration that differs -# from the pre-feature one. +# The two files are the whole mechanism, and both are checked here because +# either one being wrong fails silently: a missing domains file means dnsmasq +# writes no nftset= snippet, and a missing ips file means the set renders with +# no elements. Neither logs anything at the point of failure. +run_case node-addr "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json -# 8a) The default. opt-in off -> no initial_path anywhere, and no fallback -# file created. The generated config must be identical to the same -# fixture with the option absent, which is the upgrade-invisible property. -run_case safe-start-off "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json -run_case safe-start-off-explicit "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \ - "s/option log_level 'error'/option log_level 'error'\n\toption ruleset_safe_start '0'/" - -for name in safe-start-off safe-start-off-explicit; do - f="$WORK/$name/run/sing-box-c.json" - if [ ! -f "$f" ]; then - echo "FAIL: $name: no config was generated" - FAILED=1 - elif grep -q 'initial_path' "$f"; then - echo "FAIL: $name: the opt-in is OFF but the config carries an initial_path:" - grep -n 'initial_path' "$f" | sed 's/^/ /' - FAILED=1 - elif [ -d "$WORK/$name/ruleset/initial" ] && [ -n "$(ls -A "$WORK/$name/ruleset/initial" 2>/dev/null | grep -v '^empty\.')" ]; then - echo "FAIL: $name: the opt-in is OFF but a fallback file was written:" - ls -1 "$WORK/$name/ruleset/initial" | sed 's/^/ /' - FAILED=1 - else - echo "PASS: $name: opt-in off -> no initial_path, no fallback file" - fi -done - -# 8b) opt-in on, the built-ins. These two ARE the feature: an earlier version -# of this file only knew how to build their entries, and the CLI had no -# idea they existed, so a cold install blocked on downloading them. -run_case safe-start-on "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \ - "s/option log_level 'error'/option log_level 'error'\n\toption ruleset_safe_start '1'/" - -ss_json="$WORK/safe-start-on/run/sing-box-c.json" -ss_initial="$WORK/safe-start-on/ruleset/initial" -if [ ! -f "$ss_json" ]; then - echo "FAIL: safe-start-on: no config was generated" +na_dir="$WORK/node-addr/run" +if [ ! -f "$na_dir/sing-box-c.json" ]; then + echo "FAIL: node-addr: no config was generated" FAILED=1 else - # grep -F on the fully-constructed path, not an awk range around the entry. - # The range form over-captured: it ran past the entry's closing brace into - # the next one and then reported success on whatever it found, so a - # rule-set with NO initial_path would have passed whenever the next entry - # had one. Asserting the exact string also pins that the path is inside - # this run's archive, which is the property that makes it readable by the - # jailed client and admitted by the path whitelist. - for tag in geoip-cn geosite-cn; do - want="\"initial_path\": \"$ss_initial/$tag.srs\"" - if ! grep -qF "$want" "$ss_json"; then - echo "FAIL: safe-start-on: the built-in '$tag' does not carry" - echo " $want" - grep -n 'initial_path' "$ss_json" | sed 's/^/ /' - FAILED=1 - elif [ ! -s "$ss_initial/$tag.srs" ]; then - echo "FAIL: safe-start-on: '$tag' points at an initial file that was not written" - FAILED=1 - else - echo "PASS: safe-start-on: built-in '$tag' -> initial_path inside the archive, file present" - fi - done - - # The file has to be a real compiled rule-set, not a copy of the source - # JSON and not an empty file: sing-box IGNORES an initial file whose format - # does not match the declared one and blocks startup exactly as if there - # were none, which would make the whole feature a no-op that looks enabled. - # The magic is the check - it is what distinguishes a .srs from source JSON. - for tag in geoip-cn geosite-cn; do - f="$ss_initial/$tag.srs" - [ -s "$f" ] || continue - if [ "$(dd if="$f" bs=1 count=3 2>/dev/null)" = "SRS" ]; then - echo "PASS: safe-start-on: $tag.srs is a compiled rule-set (SRS magic)" - else - echo "FAIL: safe-start-on: $tag.srs is not a compiled rule-set - the first three" - echo " bytes are not the SRS magic, so sing-box would ignore the initial file" - FAILED=1 - fi - done + # The fixture's nodes are all host names (a.example.com ...), so the + # domains file must name them. "The file exists" is NOT the assertion: + # join() with its arguments the wrong way round returns null, null + "\n" + # is the string "null\n", and the file is then non-empty while naming + # nothing. A released r41 shipped exactly that and dnsmasq went on to + # resolve a host called "null". So this greps for a name that is + # actually in the fixture. + if grep -qx 'a\.example\.com' "$na_dir/node-addr-domains.txt" 2>/dev/null; then + echo "PASS: node-addr: node-addr-domains.txt names the node ($(wc -l < "$na_dir/node-addr-domains.txt") host names)" + elif [ -s "$na_dir/node-addr-domains.txt" ]; then + echo "FAIL: node-addr: node-addr-domains.txt is non-empty but does not name a.example.com:" + sed 's/^/ /' "$na_dir/node-addr-domains.txt" | head -5 + FAILED=1 + else + echo "FAIL: node-addr: no node-addr-domains.txt; dnsmasq would get no nftset= snippet and" + echo " LAN connections to the node would be redirected into the proxy" + FAILED=1 + fi + # The literal "null" is the specific failure above, and it is worth + # naming: it is the one value that satisfies a bare existence check. + if grep -qx 'null' "$na_dir/node-addr-domains.txt" 2>/dev/null \ + || grep -qx 'null' "$na_dir/node-addr-ips.txt" 2>/dev/null; then + echo "FAIL: node-addr: an exported file contains the literal 'null' - join() has its" + echo " arguments reversed (ucode takes the separator first)" + FAILED=1 + fi + # A literal address cannot be back-filled by dnsmasq - there is no name for + # it to resolve - so firewall_post.ut renders it from this file instead. + if grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$|^[0-9a-fA-F:]+:[0-9a-fA-F:]*$' "$na_dir/node-addr-ips.txt" 2>/dev/null; then + echo "PASS: node-addr: node-addr-ips.txt holds literal addresses" + elif [ -s "$na_dir/node-addr-domains.txt" ]; then + # The fixture's node is a host name, so an empty ips file is the + # correct answer here rather than a silent failure. + echo "PASS: node-addr: no literal-address node in this fixture, ips file correctly empty" + else + echo "FAIL: node-addr: node-addr-ips.txt is missing and the domains file is empty too -" + echo " the generator did not export anything" + FAILED=1 + fi + # An entry that is neither a host name nor an address is what the split is + # for; a bracketed IPv6 literal has to arrive unbracketed or the nft set + # element is a syntax error and fw4 rejects the whole ruleset. + if grep -q '\[' "$na_dir/node-addr-domains.txt" 2>/dev/null || grep -q '\[' "$na_dir/node-addr-ips.txt" 2>/dev/null; then + echo "FAIL: node-addr: an address kept its brackets; the nft set element would be invalid" + FAILED=1 + else + echo "PASS: node-addr: no bracketed address leaked into either file" + fi fi -# 8c/8d) opt-in on, user-defined remote rule-sets: one tag and two tags. -# One fixture covers both, plus a disabled one: -# tests/fixtures/generators/custom_safe_start.uci. A separate file rather -# than sed variations of custom.uci, because the opt-in belongs to the -# `config homeproxy-pro 'routing'` section - a different section than anything -# the existing variations anchor on - and because a two-expression sed that -# both inserts an option and appends a whole section is a construct this -# suite has never run. The one-expression form is proven here; the -# two-expression form is not, and a test whose own tooling is untested is -# how a case quietly stops testing anything. -run_case safe-start-user-remote "$ROOT/tests/fixtures/generators/custom_safe_start.uci" \ - generate_client.uc sing-box-c.json - -sr_json="$WORK/safe-start-user-remote/run/sing-box-c.json" -sr_initial="$WORK/safe-start-user-remote/ruleset/initial" -if [ ! -f "$sr_json" ]; then - echo "FAIL: safe-start-user-remote: no config was generated" - FAILED=1 -else - # Single tag: a concrete path, and the file it names has to be there. A - # pointer at a file that does not exist is not a degraded feature, it is no - # feature - sing-box ignores it and blocks startup exactly as before. - # Same reason as 8b: assert the exact string, not a range that can run on - # into the next entry. - sr_want="\"initial_path\": \"$sr_initial/cfg-rs_remote-rule.srs\"" - if ! grep -qF "$sr_want" "$sr_json"; then - echo "FAIL: safe-start-user-remote: the single-tag rule-set does not carry" - echo " $sr_want" - grep -n 'initial_path' "$sr_json" | sed 's/^/ /' - FAILED=1 - fi - if [ -s "$sr_initial/cfg-rs_remote-rule.srs" ]; then - echo "PASS: safe-start-user-remote: single-tag -> concrete initial_path, file present" - else - echo "FAIL: safe-start-user-remote: no fallback file for cfg-rs_remote-rule.srs" - FAILED=1 - fi - - # Multi tag: {tag} in the path, and one file per tag. The entry is matched - # on the multi-tag shape (a tag ARRAY) rather than on the section name, so - # the assertion cannot be satisfied by the single-tag entry above. - mt_want="\"initial_path\": \"$sr_initial/{tag}.srs\"" - if ! grep -qF "$mt_want" "$sr_json"; then - echo "FAIL: safe-start-user-remote: the multi-tag rule-set does not carry" - echo " $mt_want" - echo " sing-box substitutes {tag} and opens every resulting file, so a" - echo " literal path would make it fetch and block - the failure this removes" - grep -n 'initial_path' "$sr_json" | sed 's/^/ /' - FAILED=1 - fi - mt_missing="" - for tag in cfg-rs_multi-rule cfg-alt-rule; do - [ -s "$sr_initial/$tag.srs" ] || mt_missing="$mt_missing $tag" - done - if [ -n "$mt_missing" ]; then - echo "FAIL: safe-start-user-remote: no fallback file for:$mt_missing" - echo " sing-box opens every tag's file, so a missing one blocks startup" - FAILED=1 - else - echo "PASS: safe-start-user-remote: multi-tag -> {tag} placeholder, one file per tag" - fi - - # Disabled: build_user_rulesets() skips it, so it must be absent from the - # config AND must not have caused a file to be written for it. - if grep -q 'cfg-rs_off-rule' "$sr_json"; then - echo "FAIL: safe-start-user-remote: a disabled rule-set reached the generated config" - FAILED=1 - elif [ -e "$sr_initial/cfg-rs_off-rule.srs" ]; then - echo "FAIL: safe-start-user-remote: a fallback file was written for a disabled rule-set" - FAILED=1 - else - echo "PASS: safe-start-user-remote: a disabled rule-set gets neither path nor file" - fi -fi # 6) P3 #8 (extra_tags die() on missing {tag}): deferred to # a dedicated 'testbed-dialect' sprint. The multi-line sed to # attach an extra_tags list onto the existing rs_local ruleset diff --git a/luci-app-homeproxy-pro/tests/ucode/test_rpc_methods.sh b/luci-app-homeproxy-pro/tests/ucode/test_rpc_methods.sh index 92dddc085..1ebcfa572 100755 --- a/luci-app-homeproxy-pro/tests/ucode/test_rpc_methods.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_rpc_methods.sh @@ -224,9 +224,9 @@ check('certificate_write tolerates an empty request', threw == null, threw); * executed. Four of these were referenced by no test at all. */ const all_methods = [ 'acllist_read', 'acllist_write', 'certificate_write', 'connection_check', - 'log_clean', 'node_parse', 'resources_get_version', 'resources_update', - 'singbox_generator', 'singbox_get_features', 'update_subscriptions', - 'update_subscriptions_status' + 'debug_report', 'log_clean', 'node_parse', 'resources_get_version', + 'resources_update', 'singbox_generator', 'singbox_get_features', + 'update_subscriptions', 'update_subscriptions_status' ]; for (let m in all_methods) { @@ -249,6 +249,78 @@ check('connection_check rejects an unknown site', check('log_clean rejects an unknown log type', rpc.log_clean.call({ args: { type: '../etc/passwd' } }).status !== 0); +/* --- debug_report ------------------------------------------------------- + * + * This one is worth more than a shape check, because the property that + * makes it usable is the opposite of "works on my router": it has to + * produce a complete, readable report on a machine that has none of the + * runtime - no nft, no sing-box, no apk, no /etc/config/homeproxy-pro. That is + * exactly this test host, so a regression that turns a failed probe into an + * exception, or drops a section when its command is missing, fails here + * rather than on a user's router where the report is the thing they are + * relying on. */ +import { isSecretKey } from '@@SCRIPTS@@/runtime/debug_report.uc'; + +const dr = rpc.debug_report.call({ args: {} }); +check('debug_report succeeds where no runtime is installed', dr.result === true, + dr.error); +check('debug_report names the file it wrote', type(dr.path) === 'string' && length(dr.path) > 0); +check('debug_report reports a non-zero size', dr.size > 0); + +const dr_text = readfile(sprintf('%s/run/debug.log', WORK)); +check('the report was written where the ACL grants read', dr_text != null && length(dr_text) > 0); + +if (dr_text != null && length(dr_text) > 0) { + check('the report carries a title', index(dr_text, '# HomeProxy ') != -1); + check('the report carries a privacy notice', index(dr_text, '脱敏') != -1); + + /* Every section header, checked as a literal substring rather than a + * regex: the headers carry CJK and full-width brackets, and a probe that + * throws would take the whole report down rather than leave one section + * missing - so "the section is still there" is the property worth pinning. */ + for (let h in ['## 1. ', '## 2. ', '## 3. ', '## 4. ', '## 5. ', + '## 6. ', '## 7. ', '## 8. ', '## 9. ', '## 10. ']) + check(sprintf('the report still has section %s', h), index(dr_text, h) != -1); + + /* A missing command must be said out loud. A report that silently omits + * the firewall dump reads exactly like "the firewall is empty", which is + * the opposite of what happened. */ + check('an unavailable probe is labelled, not omitted', + index(dr_text, 'unavailable') != -1 || index(dr_text, '(no output)') != -1); +} + +/* The redaction rule has no I/O to observe, so it is asserted directly: a + * key that quietly stops being recognised is a credential in an issue + * thread, and nothing else in this suite would notice. */ +for (let k in ['password', 'uuid', 'secret', 'token', 'private_key', 'url', + 'urls', 'subscription_urls', 'address', 'tls_sni', 'username', + 'hysteria_obfs_password', 'tls_reality_public_key', + 'tls_reality_short_id', 'ws_path', 'hysteria_obfs_method', +'auth']) + check(sprintf('the key %s is redacted', k), isSecretKey(k)); + +/* ...and the other direction matters just as much: over-broad matching + * would blank out the very switches the report exists to show. */ +for (let k in ['proxy_mode', 'routing_mode', 'mixed_port', 'redirect_port', + 'self_mark', 'tproxy_mark', 'log_level', 'ipv6_support', + 'enabled', 'grouphash']) + check(sprintf('the key %s is left readable', k), !isSecretKey(k)); + +/* The urltest lists are the reason `url` is anchored rather than a bare + * substring: `main_urltest_nodes` contains the letters u-r-l, so an + * unanchored pattern blanked the pool out of the report - and "is this node + * still in the test pool" is exactly what the report is read for. These + * three were found by a canary run, not by inspection. */ +for (let k in ['main_urltest_nodes', 'main_urltest_tolerance', + 'main_udp_urltest_nodes']) + check(sprintf('the key %s is left readable (urltest, not url)', k), !isSecretKey(k)); + +/* grpc_servicename names the gRPC service, it is not a credential: masking + * it would hide which transport a node uses, which is worth reporting. + * Asserted on the readable side because the obvious guess is that anything + * shaped like a service identifier should be hidden. */ + check('the key grpc_servicename is left readable', !isSecretKey('grpc_servicename')); + printf('rpc methods: %d checks, %d failures\n', checks, failures); exit(failures ? 1 : 0); DRIVER @@ -259,7 +331,8 @@ DRIVER # `methods` and the driver drives it in the same scope. Nothing else about the # file changes. sed '$d' "$WORK/rpc.uc" > "$WORK/run.uc" -sed -e "s#@@WORK@@#$WORK#" -e "s#@@STAGE@@#$WORK/tmp#" "$WORK/driver_body.uc" >> "$WORK/run.uc" +sed -e "s#@@WORK@@#$WORK#" -e "s#@@STAGE@@#$WORK/tmp#" -e "s#@@SCRIPTS@@#$WORK/scripts#g" \ + "$WORK/driver_body.uc" >> "$WORK/run.uc" if ucode -L "$WORK/scripts" -L "$WORK" "$WORK/run.uc"; then echo "PASS: rpc method behaviour" diff --git a/luci-app-ksmbd/Makefile b/luci-app-ksmbd/Makefile index 1ebe5cca1..efc649ddc 100644 --- a/luci-app-ksmbd/Makefile +++ b/luci-app-ksmbd/Makefile @@ -3,7 +3,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=Network Shares - Ksmbd the SMB kernel fileserver -LUCI_DEPENDS:=+luci-base +ksmbd-server +ksmbd-hotplug +LUCI_DEPENDS:=+luci-base +ksmbd-server +ksmbd-hotplug +umdns +nbnsd PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Richard Yu diff --git a/luci-app-openvpn-server-client/Makefile b/luci-app-openvpn-server-client/Makefile index 4ef4ec3b9..23ba5a49e 100644 --- a/luci-app-openvpn-server-client/Makefile +++ b/luci-app-openvpn-server-client/Makefile @@ -6,11 +6,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for OpenVPN Server -LUCI_DEPENDS:=+openvpn-openssl +openvpn-easy-rsa +LUCI_DEPENDS:=+openvpn-openssl +openvpn-easy-rsa +ip-full +resolveip LUCI_PKGARCH:=all PKG_NAME:=luci-app-openvpn-server-client PKG_VERSION:=6.0 -PKG_RELEASE:=1 +PKG_RELEASE:=2 MAINTAINER:=lunatickochiya <125438787@qq.com> include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-openvpn-server-client/luasrc/controller/openvpn-server.lua b/luci-app-openvpn-server-client/luasrc/controller/openvpn-server.lua index 373fc775e..7f85aba9a 100755 --- a/luci-app-openvpn-server-client/luasrc/controller/openvpn-server.lua +++ b/luci-app-openvpn-server-client/luasrc/controller/openvpn-server.lua @@ -27,9 +27,57 @@ function index() entry({"admin", "vpn", "openvpn-server","status"},call("act_status")).leaf=true end +-- 内核是否提供 DCO (需要 ovpn.ko) +function dco_kernel_supported() + local fs = require "nixio.fs" + local krel = luci.sys.exec("uname -r"):gsub("%s+$", "") + return (fs.access("/sys/module/ovpn") or fs.access("/lib/modules/" .. krel .. "/ovpn.ko")) and true or false +end + +-- openvpn 是否编译了 DCO 支持 +function dco_build_supported() + local v = luci.sys.exec("openvpn --version 2>/dev/null | head -n1") + return v:find("%[DCO%]") ~= nil +end + +-- DCO 是否真的在工作: 优先看内核模块引用计数, 退路是查运行日志 +function dco_in_use() + local fs = require "nixio.fs" + local rc = fs.readfile("/sys/module/ovpn/refcnt") + if rc then + -- 注意: gsub 返回两个值, 必须加括号只取第一个 + local n = tonumber((rc:gsub("%s+$", ""))) + if n and n > 0 then + return true + end + end + + local uci = require("luci.model.uci").cursor() + local logf = uci:get("openvpn", "myvpn", "log") or "/tmp/openvpn.log" + local hit = luci.sys.exec("grep -m1 -E 'DCO device .* opened|ovpn-dco device' '" .. logf .. "' 2>/dev/null") + return hit ~= nil and hit ~= "" +end + function act_status() - local e={} - e.running=luci.sys.call("pgrep openvpn >/dev/null")==0 - luci.http.prepare_content("application/json") - luci.http.write_json(e) + local e = {} + e.running = luci.sys.call("pgrep openvpn >/dev/null") == 0 + + e.dco_kernel = dco_kernel_supported() + e.dco_build = dco_build_supported() + e.dco_supported = (e.dco_kernel and e.dco_build) and true or false + e.dco_active = dco_in_use() + + local uci = require("luci.model.uci").cursor() + local ac = uci:get("openvpn", "myvpn", "allow_compression") + local lzo = uci:get("openvpn", "myvpn", "comp_lzo") + local dis = uci:get("openvpn", "myvpn", "disable_dco") + + e.dco_disabled = (dis == "1") + e.allow_compression = ac or "" + e.comp_lzo = lzo or "" + -- 非法的压缩组合: allow-compression=no 与 comp_lzo 同时存在, openvpn 会拒绝启动 + e.conflict = (ac == "no" and lzo ~= nil and lzo ~= "") + + luci.http.prepare_content("application/json") + luci.http.write_json(e) end diff --git a/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server.lua b/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server.lua index 77df625b1..e59ba7fbc 100755 --- a/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server.lua +++ b/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server.lua @@ -33,6 +33,42 @@ end local major, minor, patch, lzo = get_openvpn_info() +-- DCO 是否真正可用: 需要内核模块 ovpn.ko (只有 openvpn 编译支持还不够) +local function dco_supported() + local fs = require "nixio.fs" + local krel = luci.sys.exec("uname -r"):gsub("%s+$", "") + return (fs.access("/sys/module/ovpn") or fs.access("/lib/modules/" .. krel .. "/ovpn.ko")) and true or false +end +local dco_ok = dco_supported() + +-- 打开页面时顺手修复历史遗留的不一致配置: +-- 1) allow-compression=no 与 comp_lzo 不能共存 (openvpn 会拒绝启动) +-- 2) 启用 DCO 时不能有压缩 (否则 DCO 静默失效, 不加速) +if (major > 2) or (major == 2 and minor >= 5) or dco_ok then + local cur = require("luci.model.uci").cursor() + cur:foreach("openvpn", "openvpn", function(sec) + local name = sec[".name"] + local ac, lzo, dis = sec.allow_compression, sec.comp_lzo, sec.disable_dco + local need_fix = false + + if dco_ok and dis ~= "1" then + -- DCO 已启用: 压缩必须关掉 + if ac ~= "no" or (lzo ~= nil and lzo ~= "") then + need_fix = true + end + elseif ac == "no" and lzo then + -- 致命冲突: allow-compression=no 与 comp_lzo 共存 + need_fix = true + end + + if need_fix then + cur:set("openvpn", name, "allow_compression", "no") + cur:delete("openvpn", name, "comp_lzo") + cur:commit("openvpn") + end + end) +end + mp = Map("openvpn", "OpenVPN Server",translate("An easy config OpenVPN Server Web-UI")) mp:section(SimpleSection).template = "openvpn/openvpn_status" @@ -72,7 +108,16 @@ proto.default ="tcp4" if (major > 2) or (major == 2 and minor >= 6) then disable_dco = s:taboption("basic",Flag,"disable_dco", translate("disable dco")) -disable_dco.description = translate("Disabling DCO provides better compatibility but disables acceleration.") +if dco_ok then +disable_dco.description = translate("Better compatibility but no acceleration. This device supports DCO: uncheck to enable it (compression will be turned off automatically).") +else +disable_dco.description = translate("Better compatibility but no acceleration. This device does NOT support DCO (kernel module ovpn.ko is missing); unchecking has no effect.") +end + +-- DCO 与压缩互斥: 启用 DCO 时必须关闭压缩, 否则 openvpn 会静默退回用户态, +-- DCO 等于没开。 +-- 注意: Flag 取消勾选时 LuCI 不会调用 write, 所以实际修正放在 on_after_commit +-- (那个钩子每次保存都会执行, 已验证)。 end if (major > 2) or (major == 2 and minor >= 5) then @@ -84,6 +129,26 @@ allow_compression:value("no") allow_compression.default="asym" allow_compression.description = translate("Allow compression, DCO can only be used when set to NO. asym is compatible mode.") +-- 修复 allow_compression 与 comp_lzo 的冲突: +-- openvpn 2.5+ 在两者同时存在时会直接拒绝启动, 报: +-- "Options error: Compression is not allowed since allow-compression is set to 'stub-only'" +-- CBI 的 depends 只会把 comp_lzo 隐藏, 并不会删除 UCI 里已有的值, +-- 于是把 Allow Compression 改成 no 再保存, openvpn 就起不来了。 +-- 这里在写入 allow_compression=no 时同步删除 comp_lzo。 +local _allow_compression_write = allow_compression.write +allow_compression.write = function(self, section, value) + local c = require("luci.model.uci").cursor() + -- DCO 已启用时不允许再开压缩 (DCO 与压缩互斥), 直接强制为 no + if dco_ok and c:get("openvpn", section, "disable_dco") ~= "1" then + value = "no" + end + _allow_compression_write(self, section, value) + if value == "no" then + c:delete("openvpn", section, "comp_lzo") + c:commit("openvpn") + end +end + push_peer_info = s:taboption("basic",Flag,"push_peer_info", translate("push peer info")) push_peer_info.description = translate("This will allow the server to know more info about the client like HWADDR, very useful for managing IoT devices.") @@ -107,6 +172,17 @@ comp_lzo:depends("comp_lzo", "adaptive") comp_lzo.default="adaptive" end comp_lzo.description = translate("Using LZO compression, it does not support versions above 2.5.X, does not support DCO; if your version number is greater than this version, select NO to disable it.") +-- 反向保护: 只要 allow_compression 已经是 no, 就绝不写入 comp_lzo +if (major > 2) or (major == 2 and minor >= 5) then + local _comp_lzo_write = comp_lzo.write + comp_lzo.write = function(self, section, value) + local ac = require("luci.model.uci").cursor():get("openvpn", section, "allow_compression") + if ac == "no" then + return + end + _comp_lzo_write(self, section, value) + end +end end auth_user_pass_verify = s:taboption("basic",Value,"auth_user_pass_verify", translate("user password verify")) @@ -252,6 +328,20 @@ end local comp_lzo_val = uci:get("openvpn", "myvpn", "comp_lzo") function mp.on_after_commit(self) + -- 保存后统一修正 DCO / 压缩 的互斥关系。 + -- Flag 取消勾选(启用 DCO)时 LuCI 不会走 write, 所以在这里处理 + -- (on_after_commit 每次保存都会执行)。 + local c = require("luci.model.uci").cursor() + if dco_ok and c:get("openvpn", "myvpn", "disable_dco") ~= "1" then + local ac = c:get("openvpn", "myvpn", "allow_compression") + local lzo = c:get("openvpn", "myvpn", "comp_lzo") + if ac ~= "no" or (lzo ~= nil and lzo ~= "") then + c:set("openvpn", "myvpn", "allow_compression", "no") + c:delete("openvpn", "myvpn", "comp_lzo") + c:commit("openvpn") + end + end + os.execute("uci set firewall.openvpn.dest_port=$(uci get openvpn.myvpn.port) && uci commit firewall && /etc/init.d/firewall restart") os.execute("/etc/init.d/openvpn restart") if comp_lzo_val == "no" then diff --git a/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server_ovpn.lua b/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server_ovpn.lua index 2c77bcbf9..426d568a7 100755 --- a/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server_ovpn.lua +++ b/luci-app-openvpn-server-client/luasrc/model/cbi/openvpn-server/openvpn-server_ovpn.lua @@ -48,6 +48,11 @@ o = sl:taboption("base", Flag, "enabled", translate("Enable Instance")) o.default = o.disabled o.rmempty = false ++o = sl:taboption("base", Flag, "tcp_cubic", translate("Use CUBIC for outer TCP")) +o.default = o.disabled +o.rmempty = false +o.description = translate("For TCP client profiles only. Uses the current route to each remote endpoint and keeps the system congestion control unchanged.") + o = sl:taboption("base", Value, "config", translate("Config File Path")) o.readonly = true o.placeholder = translate("Auto-generated based on instance identifier") @@ -60,7 +65,7 @@ end o = sl:taboption("config_file", TextValue, "_config_content") o.title = translate("OpenVPN Configuration (.ovpn/.conf)") -o.description = translate("Paste the content of the .ovpn or .conf file here. This will be saved to the path shown in 'Base Settings'.") +o.description = translate("Paste the content of the .ovpn or .conf file here. This will be saved to the path shown in 'Base Settings'.") .. " " .. translate("If DCO hangs on this device, add disable-dco on its own line before reconnecting.") o.rows = 15 o.wrap = "off" o.rmempty = true @@ -171,6 +176,12 @@ sp.on_remove = function(self, section) end m2.on_commit = function(self) + sys.call("/etc/init.d/openvpn-client-cubic enable >/dev/null 2>&1") + local route_result = sys.call("/etc/init.d/openvpn-client-cubic restart >/dev/null 2>&1") + if route_result ~= 0 then + self.message = translate("Could not prepare the CUBIC route for the OpenVPN client.") + return + end local result = sys.call("/etc/init.d/openvpn reload") if result ~= 0 then self.message = translate("Failed to reload OpenVPN service.") diff --git a/luci-app-openvpn-server-client/luasrc/view/openvpn/openvpn_status.htm b/luci-app-openvpn-server-client/luasrc/view/openvpn/openvpn_status.htm index 9ce663fe7..e68c38868 100755 --- a/luci-app-openvpn-server-client/luasrc/view/openvpn/openvpn_status.htm +++ b/luci-app-openvpn-server-client/luasrc/view/openvpn/openvpn_status.htm @@ -2,14 +2,44 @@ XHR.poll(3, '<%=url([[admin]], [[vpn]], [[openvpn-server]], [[status]])%>', null, function(x, data) { var tb = document.getElementById('openvpn_status'); - if (data && tb) { - if (data.running) { - var links = 'OpenVPN <%:RUNNING%>'; - tb.innerHTML = links; - } else { - tb.innerHTML = 'OpenVPN <%:NOT RUNNING%>'; - } + if (!(data && tb)) return; + + var s = ''; + + /* OpenVPN 运行状态 */ + s += data.running + ? 'OpenVPN <%:RUNNING%>' + : 'OpenVPN <%:NOT RUNNING%>'; + + s += '  ·  '; + + /* DCO 状态 */ + if (data.dco_active) { + s += '<%:DCO ACTIVE%>'; + } else if (!data.dco_supported) { + s += '<%:DCO NOT SUPPORTED%>'; + } else if (data.dco_disabled) { + s += '<%:DCO DISABLED%>'; + } else { + s += '<%:DCO INACTIVE (COMPRESSION CONFLICT)%>'; } + + /* 压缩配置冲突 */ + if (data.conflict) { + s += '  ·  <%:COMPRESSION CONFIG CONFLICT%>'; + } + + /* 明细 */ + var d = []; + d.push('<%:DCO kernel module%>: ' + (data.dco_kernel ? '✔' : '✘')); + d.push('<%:OpenVPN built with DCO%>: ' + (data.dco_build ? '✔' : '✘')); + d.push('allow-compression: ' + (data.allow_compression || '-')); + d.push('comp_lzo: ' + (data.comp_lzo || '-')); + if (data.conflict) { + d.push('<%:allow-compression=no and comp_lzo cannot coexist, openvpn will refuse to start.%>'); + } + + tb.innerHTML = s + '
' + d.join('  |  ') + ''; } ); //]]> @@ -19,4 +49,4 @@ XHR.poll(3, '<%=url([[admin]], [[vpn]], [[openvpn-server]], [[status]])%>', null

<%:Collecting data...%>

- \ No newline at end of file + diff --git a/luci-app-openvpn-server-client/po/zh-cn/openvpn-server.po b/luci-app-openvpn-server-client/po/zh-cn/openvpn-server.po index afa7bb692..f57f3d979 100644 --- a/luci-app-openvpn-server-client/po/zh-cn/openvpn-server.po +++ b/luci-app-openvpn-server-client/po/zh-cn/openvpn-server.po @@ -216,3 +216,104 @@ msgstr "这将允许服务器获取更多关于客户端的信息,比如硬件 msgid "Disabling DCO provides better compatibility but disables acceleration." msgstr "禁用DCO可以提供更好的兼容性,但会关闭加速功能。" + +# ---- DCO / compression related (added) ---- + +msgid "Allow Compression" +msgstr "允许压缩" + +msgid "Allow compression, DCO can only be used when set to NO. asym is compatible mode." +msgstr "允许压缩。只有设为 NO 时才能使用 DCO 加速;asym 为兼容模式。" + +msgid "Base Settings" +msgstr "基本设置" + +msgid "Better compatibility but no acceleration. This device does NOT support DCO (kernel module ovpn.ko is missing); unchecking has no effect." +msgstr "兼容性更好但不走加速。本机不支持 DCO(缺少内核模块 ovpn.ko),取消勾选也不会生效。" + +msgid "Better compatibility but no acceleration. This device supports DCO: uncheck to enable it (compression will be turned off automatically)." +msgstr "兼容性更好但不走加速。本机支持 DCO:取消勾选即可启用(会同时自动关闭压缩)。" + +msgid "COMPRESSION CONFIG CONFLICT" +msgstr "压缩配置冲突" + +msgid "Client" +msgstr "客户端" + +msgid "Client Config File Content" +msgstr "客户端配置内容" + +msgid "Collecting data..." +msgstr "正在收集数据…" + +msgid "Config File Path" +msgstr "配置文件路径" + +msgid "Configure authentication files for OpenVPN client instances." +msgstr "为 OpenVPN 客户端实例配置认证文件。" + +msgid "DCO ACTIVE" +msgstr "DCO 加速中" + +msgid "DCO DISABLED" +msgstr "DCO 已关闭" + +msgid "DCO INACTIVE (COMPRESSION CONFLICT)" +msgstr "DCO 未生效(压缩冲突)" + +msgid "DCO NOT SUPPORTED" +msgstr "DCO 不支持" + +msgid "DCO kernel module" +msgstr "DCO 内核模块" + +msgid "Failed to reload OpenVPN service." +msgstr "OpenVPN 服务重载失败。" + +msgid "NOT RUNNING" +msgstr "未运行" + +msgid "OpenVPN Cert" +msgstr "OpenVPN 证书" + +msgid "OpenVPN Client Authentication" +msgstr "OpenVPN 客户端认证" + +msgid "OpenVPN built with DCO" +msgstr "openvpn 编译支持 DCO" + +msgid "RUNNING" +msgstr "运行中" + +msgid "Start" +msgstr "启动" + +msgid "Stop" +msgstr "停止" + +msgid "allow-compression=no and comp_lzo cannot coexist, openvpn will refuse to start." +msgstr "allow-compression=no 与 comp_lzo 不能共存,openvpn 会拒绝启动。" + +msgid "client-to-client" +msgstr "客户端互通(client-to-client)" + +msgid "client_cert_not_required" +msgstr "客户端免证书(client_cert_not_required)" + +msgid "comp_lzo" +msgstr "LZO 压缩(comp_lzo)" + +msgid "disable dco" +msgstr "关闭 DCO" + +msgid "duplicate_cn" +msgstr "允许重复 CN(duplicate_cn)" + +msgid "proto" +msgstr "传输协议" + +msgid "push peer info" +msgstr "推送客户端信息(push_peer_info)" + +msgid "username_as_common_name" +msgstr "用户名作为 CN(username_as_common_name)" diff --git a/luci-app-openvpn-server-client/root/etc/config/openvpn b/luci-app-openvpn-server-client/root/etc/config/openvpn deleted file mode 100644 index 4c1b66480..000000000 --- a/luci-app-openvpn-server-client/root/etc/config/openvpn +++ /dev/null @@ -1,41 +0,0 @@ - -config openvpn 'myvpn' - option enabled '0' - option proto 'tcp4' - option dev 'tun' - option topology 'subnet' - option server '10.8.0.0 255.255.255.0' - option comp_lzo 'adaptive' - option ca '/etc/openvpn/pki/ca.crt' - option dh '/etc/openvpn/pki/dh.pem' - option cert '/etc/openvpn/pki/server.crt' - option key '/etc/openvpn/pki/server.key' - option persist_key '1' - option persist_tun '1' - option max_clients '88' - option keepalive '10 120' - option verb '3' - option status '/var/log/openvpn_status.log' - option log '/tmp/openvpn.log' - option port '1194' - option ddns 'myserver.f3322.net' - option auth_user_pass_verify '/etc/openvpn/server/checkpsw.sh via-env' - option script_security '3' - list push 'route 192.168.9.0 255.255.255.0' - list push 'comp-lzo adaptive' - list push 'redirect-gateway def1 bypass-dhcp' - list push 'dhcp-option DNS 192.168.9.1' - -config openvpn 'client1' - option enabled '0' - option config '/etc/openvpn/client1.conf' - -config openvpn 'client2' - option enabled '0' - option config '/etc/openvpn/client2.conf' - -config openvpnpassword 'pwd1' - option pwdfile '/etc/openvpn/pass.txt' - -config openvpnpassword 'pwd2' - option pwdfile '/etc/openvpn/pass2.txt' diff --git a/luci-app-openvpn-server-client/root/etc/easy-rsa/vars b/luci-app-openvpn-server-client/root/etc/easy-rsa/vars deleted file mode 100644 index c7f26a1ce..000000000 --- a/luci-app-openvpn-server-client/root/etc/easy-rsa/vars +++ /dev/null @@ -1,10 +0,0 @@ -set_var EASYRSA_OPENSSL "openssl" -set_var EASYRSA_KEY_SIZE 2048 -set_var EASYRSA_REQ_COUNTRY "lunatickochiya" -set_var EASYRSA_REQ_PROVINCE "github" -set_var EASYRSA_REQ_CITY "github" -set_var EASYRSA_REQ_ORG "github" -set_var EASYRSA_REQ_EMAIL "lunatickochiya@github.com" -set_var EASYRSA_REQ_OU "github" -set_var EASYRSA_CA_EXPIRE 3650 -set_var EASYRSA_CERT_EXPIRE 3650 diff --git a/luci-app-openvpn-server-client/root/etc/hotplug.d/iface/99-openvpn-client-cubic b/luci-app-openvpn-server-client/root/etc/hotplug.d/iface/99-openvpn-client-cubic new file mode 100755 index 000000000..a3073bdcc --- /dev/null +++ b/luci-app-openvpn-server-client/root/etc/hotplug.d/iface/99-openvpn-client-cubic @@ -0,0 +1,5 @@ +#!/bin/sh + +case "$ACTION" in + ifup|ifupdate) /usr/libexec/openvpn-client-cubic-route update >/dev/null 2>&1 ;; +esac diff --git a/luci-app-openvpn-server-client/root/etc/init.d/openvpn-client-cubic b/luci-app-openvpn-server-client/root/etc/init.d/openvpn-client-cubic new file mode 100755 index 000000000..7ee423a2f --- /dev/null +++ b/luci-app-openvpn-server-client/root/etc/init.d/openvpn-client-cubic @@ -0,0 +1,29 @@ +#!/bin/sh /etc/rc.common + +START=89 +STOP=10 +USE_PROCD=1 + +start_service() { + local attempt=0 + /usr/libexec/openvpn-client-cubic-route enabled || return 0 + + # OpenVPN starts at 90. Wait for DNS and the uplink before it connects. + while ! /usr/libexec/openvpn-client-cubic-route update; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 12 ] || { + logger -t openvpn-client-cubic 'Could not install CUBIC route before OpenVPN startup' + return 1 + } + sleep 2 + done + + procd_open_instance + procd_set_param command /usr/libexec/openvpn-client-cubic-route watch + procd_set_param respawn 3600 5 -1 + procd_close_instance +} + +stop_service() { + /usr/libexec/openvpn-client-cubic-route clear +} diff --git a/luci-app-openvpn-server-client/root/etc/openvpncert.sh b/luci-app-openvpn-server-client/root/etc/openvpncert.sh index a593719ee..dd1ee7816 100755 --- a/luci-app-openvpn-server-client/root/etc/openvpncert.sh +++ b/luci-app-openvpn-server-client/root/etc/openvpncert.sh @@ -1,34 +1,52 @@ #!/bin/sh -function rand_str() { +rand_str() { (base64 /dev/urandom | tr -dc 'A-Za-z' | head -c $1) 2>/dev/null } -function rand_str_upper() { +rand_str_upper() { (rand_str $1 | tr 'a-z' 'A-Z') 2>/dev/null } -function rand_str_lower() { +rand_str_lower() { (rand_str $1 | tr 'A-Z' 'a-z') 2>/dev/null } -function rand_easy_rsa_vars() { +set_easy_rsa_var() { + local name="$1" + local value="$2" + + if grep -q "^[[:space:]]*set_var[[:space:]]\+$name[[:space:]]" /etc/easy-rsa/vars; then + sed -i "s|^[[:space:]]*set_var[[:space:]]\+$name[[:space:]].*$|set_var $name\t$value|" /etc/easy-rsa/vars + else + printf 'set_var %s\t%s\n' "$name" "$value" >> /etc/easy-rsa/vars + fi +} + +init_easy_rsa_vars() { + touch /etc/easy-rsa/vars + set_easy_rsa_var EASYRSA_OPENSSL '"openssl"' + set_easy_rsa_var EASYRSA_KEY_SIZE '2048' + set_easy_rsa_var EASYRSA_CA_EXPIRE '3650' + set_easy_rsa_var EASYRSA_CERT_EXPIRE '3650' +} + +rand_easy_rsa_vars() { local KEY_PROVINCE="$(rand_str_upper 6)" local KEY_CITY="$(rand_str 8)" local KEY_ORG="$(rand_str 8)" local KEY_EMAIL="$(rand_str_lower 8)@$(rand_str_lower 4).$(rand_str_lower 3)" local KEY_OU="$(rand_str 8)" - sed -i \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_COUNTRY[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_COUNTRY\t\"$KEY_PROVINCE\"/" \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_PROVINCE[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_PROVINCE\t\"$KEY_CITY\"/" \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_CITY[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_CITY\t\"$KEY_ORG\"/" \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_ORG[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_ORG\t\"$KEY_ORG\"/" \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_EMAIL[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_EMAIL\t\"$KEY_EMAIL\"/" \ - -e "s/^[[:space:]]*set_var[[:space:]]\+EASYRSA_REQ_OU[[:space:]]\+\".*\"$/set_var EASYRSA_REQ_OU\t\"$KEY_OU\"/" \ - /etc/easy-rsa/vars + set_easy_rsa_var EASYRSA_REQ_COUNTRY "\"$KEY_PROVINCE\"" + set_easy_rsa_var EASYRSA_REQ_PROVINCE "\"$KEY_CITY\"" + set_easy_rsa_var EASYRSA_REQ_CITY "\"$KEY_ORG\"" + set_easy_rsa_var EASYRSA_REQ_ORG "\"$KEY_ORG\"" + set_easy_rsa_var EASYRSA_REQ_EMAIL "\"$KEY_EMAIL\"" + set_easy_rsa_var EASYRSA_REQ_OU "\"$KEY_OU\"" } +init_easy_rsa_vars rand_easy_rsa_vars diff --git a/luci-app-openvpn-server-client/root/etc/uci-defaults/99-openvpn-client-cubic b/luci-app-openvpn-server-client/root/etc/uci-defaults/99-openvpn-client-cubic new file mode 100755 index 000000000..379db479a --- /dev/null +++ b/luci-app-openvpn-server-client/root/etc/uci-defaults/99-openvpn-client-cubic @@ -0,0 +1,4 @@ +#!/bin/sh + +/etc/init.d/openvpn-client-cubic enable +exit 0 diff --git a/luci-app-openvpn-server-client/root/etc/uci-defaults/openvpn b/luci-app-openvpn-server-client/root/etc/uci-defaults/openvpn index 1b6ac47e7..e021a0dc5 100755 --- a/luci-app-openvpn-server-client/root/etc/uci-defaults/openvpn +++ b/luci-app-openvpn-server-client/root/etc/uci-defaults/openvpn @@ -1,6 +1,70 @@ #!/bin/sh -grep -qv "myvpn" /etc/config/openvpn && grep -q "myvpn" /etc/config/openvpn-opkg && cp -f /etc/config/openvpn-opkg /etc/config/openvpn +if ! uci -q get openvpn.myvpn >/dev/null; then + uci -q batch <<-EOF >/dev/null + set openvpn.myvpn=openvpn + set openvpn.myvpn.enabled='0' + set openvpn.myvpn.proto='tcp4' + set openvpn.myvpn.dev='tun' + set openvpn.myvpn.topology='subnet' + set openvpn.myvpn.server='10.8.0.0 255.255.255.0' + set openvpn.myvpn.comp_lzo='adaptive' + set openvpn.myvpn.ca='/etc/openvpn/pki/ca.crt' + set openvpn.myvpn.dh='/etc/openvpn/pki/dh.pem' + set openvpn.myvpn.cert='/etc/openvpn/pki/server.crt' + set openvpn.myvpn.key='/etc/openvpn/pki/server.key' + set openvpn.myvpn.persist_key='1' + set openvpn.myvpn.persist_tun='1' + set openvpn.myvpn.max_clients='88' + set openvpn.myvpn.keepalive='10 120' + set openvpn.myvpn.verb='3' + set openvpn.myvpn.status='/var/log/openvpn_status.log' + set openvpn.myvpn.log='/tmp/openvpn.log' + set openvpn.myvpn.port='1194' + set openvpn.myvpn.ddns='myserver.f3322.net' + set openvpn.myvpn.auth_user_pass_verify='/etc/openvpn/server/checkpsw.sh via-env' + set openvpn.myvpn.script_security='3' + add_list openvpn.myvpn.push='route 192.168.9.0 255.255.255.0' + add_list openvpn.myvpn.push='comp-lzo adaptive' + add_list openvpn.myvpn.push='redirect-gateway def1 bypass-dhcp' + add_list openvpn.myvpn.push='dhcp-option DNS 192.168.9.1' + commit openvpn + EOF +fi + +if ! uci -q get openvpn.client1 >/dev/null; then + uci -q batch <<-EOF >/dev/null + set openvpn.client1=openvpn + set openvpn.client1.enabled='0' + set openvpn.client1.config='/etc/openvpn/client1.conf' + commit openvpn + EOF +fi + +if ! uci -q get openvpn.client2 >/dev/null; then + uci -q batch <<-EOF >/dev/null + set openvpn.client2=openvpn + set openvpn.client2.enabled='0' + set openvpn.client2.config='/etc/openvpn/client2.conf' + commit openvpn + EOF +fi + +if ! uci -q get openvpn.pwd1 >/dev/null; then + uci -q batch <<-EOF >/dev/null + set openvpn.pwd1=openvpnpassword + set openvpn.pwd1.pwdfile='/etc/openvpn/pass.txt' + commit openvpn + EOF +fi + +if ! uci -q get openvpn.pwd2 >/dev/null; then + uci -q batch <<-EOF >/dev/null + set openvpn.pwd2=openvpnpassword + set openvpn.pwd2.pwdfile='/etc/openvpn/pass2.txt' + commit openvpn + EOF +fi openvpn_port="$(uci -q get openvpn.myvpn.port)" [ -z "$openvpn_port" ] && openvpn_port=1194 diff --git a/luci-app-openvpn-server-client/root/usr/libexec/openvpn-client-cubic-route b/luci-app-openvpn-server-client/root/usr/libexec/openvpn-client-cubic-route new file mode 100755 index 000000000..1e2662509 --- /dev/null +++ b/luci-app-openvpn-server-client/root/usr/libexec/openvpn-client-cubic-route @@ -0,0 +1,124 @@ +#!/bin/sh +# Set CUBIC only for opted-in OpenVPN clients' outer TCP destinations. + +umask 077 +state=${OPENVPN_CLIENT_CUBIC_STATE:-/tmp/openvpn-client-cubic-routes} + +sections() { + uci -q show openvpn 2>/dev/null | + sed -n 's/^openvpn\.\(client[A-Za-z0-9_-]*\)=openvpn$/\1/p' +} + +client_enabled() { + [ "$(uci -q get "openvpn.$1.enabled")" = 1 ] && + [ "$(uci -q get "openvpn.$1.tcp_cubic")" = 1 ] +} + +has_clients() { + for section in $(sections); do + client_enabled "$section" && return 0 + done + return 1 +} + +delete_old_routes() { + [ -f "$state" ] || return 0 + while IFS='|' read -r address via device; do + [ -n "$address" ] && [ -n "$device" ] || continue + if [ -n "$via" ]; then + ip -4 route del "$address/32" via "$via" dev "$device" 2>/dev/null + else + ip -4 route del "$address/32" dev "$device" 2>/dev/null + fi + done < "$state" + return 0 +} + +add_route() { + local address=$1 route via device + route=$(ip -4 route get "$address" 2>/dev/null) || return 1 + set -- $route + while [ "$#" -gt 1 ]; do + case "$1" in + via) via=$2; shift 2 ;; + dev) device=$2; shift 2 ;; + *) shift ;; + esac + done + [ -n "$device" ] && [ "$device" != lo ] || return 1 + if [ -n "$via" ]; then + ip -4 route replace "$address/32" via "$via" dev "$device" congctl cubic || return 1 + else + ip -4 route replace "$address/32" dev "$device" congctl cubic || return 1 + fi + printf '%s|%s|%s\n' "$address" "$via" "$device" >> "$new_state" +} + +update_routes() { + local section conf host addresses address failed=0 desired new_state + desired=$(mktemp /tmp/openvpn-client-cubic-desired.XXXXXX) || return 1 + new_state=$(mktemp /tmp/openvpn-client-cubic-state.XXXXXX) || { + rm -f "$desired" + return 1 + } + + for section in $(sections); do + client_enabled "$section" || continue + conf=$(uci -q get "openvpn.$section.config") + if [ ! -r "$conf" ]; then + logger -t openvpn-client-cubic "Cannot read config for $section" + failed=1 + continue + fi + # An outer TCP congestion controller has no effect on UDP clients. + grep -Eiq '^[[:space:]]*proto[[:space:]]+tcp' "$conf" || continue + for host in $(awk '$1 == "remote" { gsub(/["\047]/, "", $2); print $2 }' "$conf"); do + case "$host" in + ''|*[!0-9.]*) addresses=$(resolveip -4 -t 5 "$host" 2>/dev/null) ;; + *) addresses=$host ;; + esac + if [ -z "$addresses" ]; then + logger -t openvpn-client-cubic "Cannot resolve $host for $section" + failed=1 + continue + fi + for address in $addresses; do + case "$address" in + *[!0-9.]*|'') continue ;; + esac + printf '%s\n' "$address" >> "$desired" + done + done + done + + if [ "$failed" -ne 0 ]; then + rm -f "$desired" "$new_state" + return 1 + fi + + delete_old_routes + sort -u "$desired" | while IFS= read -r address; do + [ -n "$address" ] || continue + add_route "$address" || exit 1 + done + failed=$? + rm -f "$desired" + if [ "$failed" -eq 0 ]; then + mv -f "$new_state" "$state" + else + rm -f "$new_state" + fi + return "$failed" +} + +case "$1" in + enabled) has_clients ;; + update) update_routes ;; + clear) delete_old_routes; rm -f "$state" ;; + watch) + while sleep 300; do + update_routes || logger -t openvpn-client-cubic 'Could not refresh CUBIC routes' + done + ;; + *) echo "Usage: $0 {enabled|update|clear|watch}" >&2; exit 2 ;; +esac diff --git a/luci-app-openvpn-server-client/root/usr/share/rpcd/acl.d/luci-app-openvpn-server-client.json b/luci-app-openvpn-server-client/root/usr/share/rpcd/acl.d/luci-app-openvpn-server-client.json deleted file mode 100644 index 242c68965..000000000 --- a/luci-app-openvpn-server-client/root/usr/share/rpcd/acl.d/luci-app-openvpn-server-client.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "luci-app-openvpn-server-client": { - "description": "Grant UCI access for luci-app-openvpn-server-client", - "read": { - "uci": [ "openvpn" ] - }, - "write": { - "uci": [ "openvpn" ] - } - } -} diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index d9236a3f4..7a9d35bea 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.10.3 -PKG_RELEASE:=309 +PKG_RELEASE:=310 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/controller/passwall.lua b/luci-app-passwall/luasrc/controller/passwall.lua index cd6e7655c..4cbd3f74b 100644 --- a/luci-app-passwall/luasrc/controller/passwall.lua +++ b/luci-app-passwall/luasrc/controller/passwall.lua @@ -894,12 +894,14 @@ end function com_update(comname) local json = nil local task = http.formvalue("task") - if task == "extract" then + if task == "progress" then + json = api.to_download_progress(comname, http.formvalue("id"), http.formvalue("total_size")) + elseif task == "extract" then json = api.to_extract(comname, http.formvalue("file"), http.formvalue("subfix")) elseif task == "move" then json = api.to_move(comname, http.formvalue("file")) else - json = api.to_download(comname, http.formvalue("url"), http.formvalue("size")) + json = api.to_download(comname, http.formvalue("url"), http.formvalue("size"), http.formvalue("id")) end http_write_json(json) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/app_update.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/app_update.lua index 89e38db31..51a219fa5 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/app_update.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/app_update.lua @@ -11,8 +11,13 @@ s = m:section(NamedSection, "@global_app[0]", "global_app", translate("App Updat s:appendTemplate("/app_update/app_version", {com = com}) -o = s:option(Flag, "github_proxy", translate("GitHub Proxy"), translate("Use gh-proxy instead of proxy nodes for component updates.")) -o.default = 0 +o = s:option(ListValue, "gh_proxy_url", translate("GitHub Proxy"), translate("Use GitHub instead of proxy nodes for component updates.")) +o.default = "" +o:value("", translate("Close(Not use)")) +o:value("https://gh-proxy.org/", "gh-proxy.org") +o:value("https://ghproxy.cc/", "ghproxy.cc") +o:value("https://mirror.ghproxy.com/", "ghproxy.com") +o:value("https://ghfast.top/", "ghfast.top") local k, v for _, k in ipairs(com.order) do diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index 04c5bc768..174658999 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -1132,10 +1132,10 @@ local default_file_tree = { } local function get_api_json(url) - local gh_proxy = uci_get_c("@global_app[0]", "github_proxy") or "0" + local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or "" local return_code, content - if gh_proxy == "1" then - url = "https://gh-proxy.org/" .. url + if gh_proxy ~= "" then + url = gh_proxy .. url return_code, content = curl_base(url, nil, curl_args) else return_code, content = curl_auto(url, nil, curl_args) @@ -1232,7 +1232,7 @@ function to_check(arch, app_name) } end -function to_download(app_name, url, size) +function to_download(app_name, url, size, task_id) local result = check_path(app_name) if result.code ~= 0 then return result @@ -1244,7 +1244,13 @@ function to_download(app_name, url, size) remove("/tmp/" .. app_name .. "_download.*") - local tmp_file = trim(util.exec("mktemp -u -t " .. app_name .. "_download.XXXXXX")) + local tmp_file + if task_id and task_id:match("^[%w_-]+$") then + tmp_file = "/tmp/" .. app_name .. "_download." .. task_id + remove(tmp_file) + else + tmp_file = trim(util.exec("mktemp -u -t " .. app_name .. "_download.XXXXXX")) + end if size then local kb1 = get_free_space("/tmp") @@ -1256,10 +1262,10 @@ function to_download(app_name, url, size) local _curl_args = clone(curl_args) table.insert(_curl_args, "--speed-limit 51200 --speed-time 15 --max-time 300") - local gh_proxy = uci_get_c("@global_app[0]", "github_proxy") or "0" + local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or "" local return_code, result - if gh_proxy == "1" then - url = "https://gh-proxy.org/" .. url + if gh_proxy ~= "" then + url = gh_proxy .. url return_code, result = curl_base(url, tmp_file, _curl_args) else return_code, result = curl_auto(url, tmp_file, _curl_args) @@ -1277,6 +1283,23 @@ function to_download(app_name, url, size) return {code = 0, file = tmp_file, zip = com[app_name].zipped } end +function to_download_progress(app_name, task_id, total_size) + if not com[app_name] or type(task_id) ~= "string" or not task_id:match("^[%w_-]+$") then + return {code = 1, error = i18n.translate("Invalid download task.")} + end + + total_size = tonumber(total_size) or 0 + local tmp_file = "/tmp/" .. app_name .. "_download." .. task_id + local downloaded = tonumber(fs.stat(tmp_file, "size")) or 0 + local percent + if total_size > 0 then + -- The download request has not completed yet, so leave 100% for its success callback. + percent = math.min(99, math.floor(downloaded * 100 / total_size)) + end + + return {code = 0, downloaded = downloaded, total = total_size, percent = percent} +end + function to_extract(app_name, file, subfix) local result = check_path(app_name) if result.code ~= 0 then @@ -1425,10 +1448,10 @@ end function to_check_self() local url = "https://raw.githubusercontent.com/Openwrt-Passwall/openwrt-passwall/main/luci-app-passwall/Makefile" local tmp_file = "/tmp/passwall_makefile" - local gh_proxy = uci_get_c("@global_app[0]", "github_proxy") or "0" + local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or "" local return_code, result - if gh_proxy == "1" then - url = "https://gh-proxy.org/" .. url + if gh_proxy ~= "" then + url = gh_proxy .. url return_code, result = curl_base(url, tmp_file, curl_args) else return_code, result = curl_auto(url, tmp_file, curl_args) diff --git a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm index d6ccb0bfe..a0d2fec20 100644 --- a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm +++ b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm @@ -129,12 +129,54 @@ local com = self.com var appUpdateUrl = '<%=api.url("update_")%>' + app; var appInfo = appInfoList[app]; + var downloadId = Date.now().toString(36) + Math.random().toString(36).substring(2, 8); + var totalSize = appInfo && appInfo.data ? Number(appInfo.data.size) : 0; + if (!isFinite(totalSize) || totalSize <= 0) { + totalSize = 0; + } + var downloadProgressActive = true; + var downloadProgressPending = false; + var progressTimer = null; + + function stopDownloadProgress() { + downloadProgressActive = false; + if (progressTimer) { + clearInterval(progressTimer); + progressTimer = null; + } + } + + function updateDownloadProgress() { + if (!downloadProgressActive || downloadProgressPending || totalSize <= 0) { + return; + } + downloadProgressPending = true; + XHR.get(appUpdateUrl, { + token: tokenStr, + task: 'progress', + id: downloadId, + total_size: totalSize + }, function (x, json) { + downloadProgressPending = false; + if (downloadProgressActive && json && !json.code && typeof json.percent === 'number') { + btn.value = downloadingText + ' ' + json.percent + '%'; + } + }, 300); + } + + if (totalSize > 0) { + updateDownloadProgress(); + progressTimer = setInterval(updateDownloadProgress, 1000); + } + // Download file XHR.get(appUpdateUrl, { token: tokenStr, url: appInfo ? appInfo.data.browser_download_url : '', - size: appInfo ? appInfo.data.size / 1024 : null + size: appInfo ? appInfo.data.size / 1024 : null, + id: downloadId }, function (x, json) { + stopDownloadProgress(); if (json.code) { removePageNotice(); onRequestError(btn, json.error); diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index a4bd6593b..3f6cdaae5 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -1199,8 +1199,8 @@ msgstr "用于版本检查的 API URL" msgid "GitHub Proxy" msgstr "GitHub 反代" -msgid "Use gh-proxy instead of proxy nodes for component updates." -msgstr "开启后将使用 gh-proxy 反向代理,不通过节点更新组件。" +msgid "Use GitHub instead of proxy nodes for component updates." +msgstr "开启后将使用 GitHub 反向代理,不通过节点更新组件。" msgid "Node Subscribe" msgstr "节点订阅" diff --git a/luci-app-passwall/root/usr/share/passwall/rules/direct_host b/luci-app-passwall/root/usr/share/passwall/rules/direct_host index 87959032a..c19e80f36 100644 --- a/luci-app-passwall/root/usr/share/passwall/rules/direct_host +++ b/luci-app-passwall/root/usr/share/passwall/rules/direct_host @@ -28,3 +28,6 @@ dot.360.cn #GitHub Proxy gh-proxy.com gh-proxy.org +ghproxy.cc +ghfast.top +ghproxy.com diff --git a/luci-app-quickactions/Makefile b/luci-app-quickactions/Makefile index 3ee147e83..cfce82aff 100644 --- a/luci-app-quickactions/Makefile +++ b/luci-app-quickactions/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-quickactions PKG_VERSION:=3.0.0 -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=arafatrahmanzami diff --git a/luci-app-quickactions/po/zh-cn b/luci-app-quickactions/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-quickactions/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-quickactions/po/zh_Hans/quickactions.po b/luci-app-quickactions/po/zh_Hans/quickactions.po new file mode 100644 index 000000000..bff256dfa --- /dev/null +++ b/luci-app-quickactions/po/zh_Hans/quickactions.po @@ -0,0 +1,1600 @@ +msgid "" +msgstr "" +"Project-Id-Version: luci-app-quickactions 3.0.0\n" +"POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" +"PO-Revision-Date: 2026-10-02 12:00+0000\n" +"Last-Translator: Devin \n" +"Language-Team: Chinese (Simplified) \n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +msgid "(no bridge-vlan entries yet)" +msgstr "(暂无 bridge-vlan 条目)" + +msgid "(no recent log)" +msgstr "(暂无近期日志)" + +msgid "(none)" +msgstr "(无)" + +msgid "(not loaded)" +msgstr "(未加载)" + +msgid "+ Create new script" +msgstr "+ 新建脚本" + +msgid "." +msgstr "." + +msgid ". Metadata in" +msgstr "。元数据位于" + +msgid "..." +msgstr "…" + +msgid "/etc/init.d/firewall restart" +msgstr "例如:/etc/init.d/firewall restart" + +msgid "A VLAN (Virtual Local Area Network) divides one physical LAN into multiple logical networks. Devices on the same switch can be isolated into separate groups — as if they were on entirely different networks." +msgstr "VLAN(虚拟局域网)把一个物理 LAN 划分为多个逻辑网络。同一交换机上的设备可以被隔离到不同的分组——如同处于完全不同的网络。" + +msgid "A user interface for easily creating and management isolated Wi-Fi networks for guests, each with its own access point, DHCP, and firewall rules." +msgstr "一个方便创建和管理隔离的访客 Wi-Fi 网络的图形界面,每个网络都有独立的接入点、DHCP 和防火墙规则。" + +msgid "Accept one client then exit" +msgstr "仅接受一个客户端后退出" + +msgid "Access port:" +msgstr "接入端口(Access):" + +msgid "Actions" +msgstr "操作" + +msgid "Add new guest network..." +msgstr "添加新的访客网络..." + +msgid "Add to Cron" +msgstr "添加到 Cron" + +msgid "All backend options. Save applies and restarts ttyd." +msgstr "所有后端选项。保存后会应用并重启 ttyd。" + +msgid "Allow LAN → Guest (one-way): Network → Firewall → Zones → lan → Allow forward to guest_zone." +msgstr "允许 LAN → Guest(单向):网络 → 防火墙 → 区域 → lan → 允许转发到 guest_zone。" + +msgid "Allow URL args" +msgstr "允许 URL 参数" + +msgid "Applied with rollback in 90s. Click Confirm in Safety subtab to keep." +msgstr "已应用,90 秒后自动回滚。在\"安全\"子标签中点击\"确认\"以保留。" + +msgid "Apply this VLAN plan to the router? A snapshot will be taken first. Network will reload." +msgstr "将此 VLAN 方案应用到路由器?会先自动快照,然后网络重载。" + +msgid "Apply to this router" +msgstr "应用到本路由器" + +msgid "Apply with 90-second rollback? If you lose access, the network will auto-restore after 90 seconds." +msgstr "应用并启用 90 秒回滚?如果失去连接,网络将在 90 秒后自动恢复。" + +msgid "Apply with 90s rollback" +msgstr "应用(90 秒自动回滚)" + +msgid "April" +msgstr "四月" + +msgid "Arguments (space-separated)" +msgstr "参数(空格分隔)" + +msgid "Arguments — passed as $1 $2 on Run." +msgstr "参数 — 运行时以 $1 $2 传入。" + +msgid "Arguments:" +msgstr "参数:" + +msgid "Attach an existing SSID to this VLAN interface" +msgstr "将现有 SSID 绑定到此 VLAN 接口" + +msgid "Attach to network (VLAN interface)" +msgstr "绑定到网络(VLAN 接口)" + +msgid "Attached network" +msgstr "已绑定的网络" + +msgid "August" +msgstr "八月" + +msgid "Avoid VLAN 0, 1 and 4095." +msgstr "避免使用 VLAN 0、1 和 4095。" + +msgid "Back to settings" +msgstr "返回设置" + +msgid "Backup interfaces (multi-select)" +msgstr "备用接口(可多选)" + +msgid "Basic auth user:password" +msgstr "基本认证 用户名:密码" + +msgid "Bind to UNIX domain socket instead of IP port" +msgstr "绑定到 UNIX 域套接字而不是 IP 端口" + +msgid "Block cross-origin WebSocket" +msgstr "阻止跨源 WebSocket" + +msgid "Blocks communication between clients on this guest network." +msgstr "阻止此访客网络中客户端之间的互相通信。" + +msgid "Blocks guests from reaching each other on the same SSID. Enable \"Isolate clients\" in the wireless advanced settings, or use the SSID subtab in this tab." +msgstr "阻止同一 SSID 下的客户端互访。可在无线高级设置中启用\"隔离客户端\",或使用本页的 SSID 子标签。" + +msgid "Bridge VLAN table" +msgstr "桥接 VLAN 表" + +msgid "Bridge-VLANs" +msgstr "桥接 VLAN" + +msgid "Bridges" +msgstr "网桥" + +msgid "Build a plan in the Wizard or Import first." +msgstr "请先在\"向导\"或\"导入\"中构建方案。" + +msgid "CLI" +msgstr "CLI" + +msgid "Cancel" +msgstr "取消" + +msgid "Category" +msgstr "分类" + +msgid "Category — failover / network / system / iot / custom (icon only)." +msgstr "分类 — failover / network / system / iot / custom(仅用于图标)。" + +msgid "Category:" +msgstr "分类:" + +msgid "Change SSID, security, password, or attach a network interface — for every wireless interface on both radios (master / client / mesh / guest / iot)." +msgstr "为每个无线接口修改 SSID、加密方式、密码或绑定网络接口——支持两个射频上的所有接口(master / client / mesh / guest / iot)。" + +msgid "Changes applied." +msgstr "变更已应用。" + +msgid "Character types to use when generating a password." +msgstr "生成密码时使用的字符类型。" + +msgid "Check origin" +msgstr "检查来源" + +msgid "Check period (seconds)" +msgstr "检查周期(秒)" + +msgid "Check the cron entry - it must contain time + command" +msgstr "请检查 cron 条目 - 必须包含时间和命令" + +msgid "Choose \"this router\" to apply directly. Other targets generate a CLI script to paste into the external device." +msgstr "选择\"本路由器\"直接应用。其他目标会生成 CLI 脚本,粘贴到外部设备执行。" + +msgid "Choose per-port mode. Leave ports on \"off\" if unused." +msgstr "选择每个端口的模式。未使用的端口保持 \"off\"。" + +msgid "Cisco: switchport trunk allowed vlan 10,20 ; switchport trunk native vlan 1" +msgstr "Cisco: switchport trunk allowed vlan 10,20 ; switchport trunk native vlan 1" + +msgid "Clear Log" +msgstr "清除记录" + +msgid "Click Confirm to keep the changes, or do nothing to revert." +msgstr "点击\"确认\"保留变更,不操作则自动回滚。" + +msgid "Client isolation" +msgstr "客户端隔离" + +msgid "Client limit" +msgstr "客户端数量限制" + +msgid "Client option" +msgstr "客户端选项" + +msgid "Close" +msgstr "关闭" + +msgid "Command" +msgstr "命令" + +msgid "Command \"%s\" will run at" +msgstr "命令“%s”将运行于" + +msgid "Command \"%s\" will run every minute." +msgstr "命令“%s”将每分钟运行。" + +msgid "Command to execute:" +msgstr "要执行的命令:" + +msgid "Command to run in the shell" +msgstr "要在 shell 中运行的命令" + +msgid "Commands to run" +msgstr "要执行的命令" + +msgid "Confirm" +msgstr "确认" + +msgid "Connectivity Monitors" +msgstr "连通性监控" + +msgid "Consecutive failures before failover." +msgstr "连续失败多少次后触发故障切换。" + +msgid "Consecutive successes before switching back." +msgstr "连续成功多少次后切回。" + +msgid "Copied to clipboard." +msgstr "已复制到剪贴板。" + +msgid "Copy" +msgstr "复制" + +msgid "Cost efficiency: no extra switches or routers needed." +msgstr "成本效益:无需额外的交换机或路由器。" + +msgid "Create / update firewall zone" +msgstr "创建/更新防火墙区域" + +msgid "Create DHCP pool" +msgstr "创建 DHCP 池" + +msgid "Create logical interface" +msgstr "创建逻辑接口" + +msgid "Credential" +msgstr "凭据" + +msgid "Cron-driven ping tests run once per minute through the primary interface." +msgstr "cron 驱动的 ping 检测每分钟通过主接口执行一次。" + +msgid "Cross-VLAN ping" +msgstr "跨 VLAN ping" + +msgid "Custom index.html path" +msgstr "自定义 index.html 路径" + +msgid "DHCP client limit" +msgstr "DHCP 客户端限制" + +msgid "DHCP lease time" +msgstr "DHCP 租用时间" + +msgid "DHCP pools" +msgstr "DHCP 池" + +msgid "DHCP range start" +msgstr "DHCP 分配起始地址" + +msgid "DHCP rule ports" +msgstr "DHCP 规则端口" + +msgid "DNS rule port" +msgstr "DNS 规则端口" + +msgid "DSA bridge-vlan → MikroTik /interface bridge vlan add bridge=... vlan-ids=N tagged=... untagged=..." +msgstr "DSA bridge-vlan → MikroTik /interface bridge vlan add bridge=... vlan-ids=N tagged=... untagged=..." + +msgid "Day" +msgstr "天" + +msgid "Debug" +msgstr "调试" + +msgid "December" +msgstr "十二月" + +msgid "Delayed Start(seconds)" +msgstr "延迟启动(秒)" + +msgid "Delete" +msgstr "删除" + +msgid "Delete generated script \"" +msgstr "删除生成的脚本 \"" + +msgid "Delete script \"" +msgstr "删除脚本 \"" + +msgid "Delete template \"" +msgstr "删除模板 \"" + +msgid "Deleted" +msgstr "已删除" + +msgid "Deny client write access to TTY" +msgstr "拒绝客户端对 TTY 的写入权限" + +msgid "Description" +msgstr "描述" + +msgid "Description:" +msgstr "描述:" + +msgid "Detection has not run yet." +msgstr "尚未运行检测。" + +msgid "Device" +msgstr "设备" + +msgid "Device:" +msgstr "设备:" + +msgid "Diagnostics" +msgstr "诊断" + +msgid "Diagnostics tools. Load on demand — nothing runs in the background." +msgstr "诊断工具。按需加载——后台不运行任何内容。" + +msgid "Digits" +msgstr "数字" + +msgid "Disable" +msgstr "禁用" + +msgid "Discard All" +msgstr "全部丢弃" + +msgid "Download Log" +msgstr "下载日志" + +msgid "Download failed:" +msgstr "下载失败:" + +msgid "Downloading:" +msgstr "正在下载:" + +msgid "E.g. \"1h\", \"30m\", \"12h\". Minimum is \"2m\"." +msgstr "例如 “1h”、“30m”、“12h”。最短为 “2m”。" + +msgid "Easy management: move a device without reconfiguring its network." +msgstr "便于管理:移动设备无需重新配置网络。" + +msgid "EasyMesh bat0 devices" +msgstr "EasyMesh bat0 设备" + +msgid "EdgeOS: set interfaces ethernet ethN vif address " +msgstr "EdgeOS: set interfaces ethernet ethN vif address " + +msgid "Edit" +msgstr "编辑" + +msgid "Edit Custom Script" +msgstr "编辑自定义脚本" + +msgid "Edit Custom Script2" +msgstr "编辑自定义脚本2" + +msgid "Edit SSID:" +msgstr "编辑 SSID:" + +msgid "Edit VLAN" +msgstr "编辑 VLAN" + +msgid "Edit existing configuration. Every change is saved to memory; use the top-bar Save & Apply to commit." +msgstr "编辑现有配置。每次修改先保存到内存,使用顶栏\"保存并应用\"提交。" + +msgid "Edit interface" +msgstr "编辑接口" + +msgid "Edit wireless:" +msgstr "编辑无线:" + +msgid "Enable" +msgstr "启用" + +msgid "Enable guest network." +msgstr "启用访客网络。" + +msgid "Enabled" +msgstr "启用" + +msgid "Enabled:" +msgstr "已启用:" + +msgid "Encryption" +msgstr "加密" + +msgid "Enhanced Open" +msgstr "增强型开放" + +msgid "Error" +msgstr "错误" + +msgid "Error saving configuration:" +msgstr "保存配置时出错:" + +msgid "Every VLAN apply takes an automatic snapshot first. Restore if something goes wrong." +msgstr "每次应用 VLAN 都会先自动快照。出错时可恢复。" + +msgid "Every wireless interface on every radio — attach any SSID to a VLAN network with one click." +msgstr "每个射频上的每个无线接口——一键将任意 SSID 绑定到 VLAN 网络。" + +msgid "Execute commands when a logical interface goes up, down, or changes state." +msgstr "当逻辑接口上线、下线或状态变化时执行命令。" + +msgid "Existing SSID" +msgstr "现有 SSID" + +msgid "Failover — lightweight mwan3 alternative." +msgstr "Failover — 轻量级 mwan3 替代品。" + +msgid "Failures before interface reset" +msgstr "接口重置前的失败次数" + +msgid "Failures before reboot" +msgstr "重启前的失败次数" + +msgid "February" +msgstr "二月" + +msgid "Firewall zone guest traffic is forwarded to (usually \"wan\"). Pick an existing zone from the list or fill out the -- custom -- field to enter one manually." +msgstr "访客流量转发的防火墙区域(通常为 “wan”)。从列表中选择现有区域,或填写 -- 自定义 -- 字段手动输入。" + +msgid "Firewall zones" +msgstr "防火墙区域" + +msgid "Fixes stuck tailscale0 routes when the WAN IP changes." +msgstr "修复 WAN IP 变化后 tailscale0 路由残留的问题。" + +msgid "Focus Terminal" +msgstr "聚焦终端" + +msgid "Format" +msgstr "格式" + +msgid "Forward" +msgstr "转发" + +msgid "Forward to zone" +msgstr "转发到区域" + +msgid "Forwarding destination zone" +msgstr "转发目标区域" + +msgid "Friday" +msgstr "星期五" + +msgid "Generate" +msgstr "生成" + +msgid "Generate CLI" +msgstr "生成 CLI" + +msgid "Generate CLI for" +msgstr "生成 CLI 用于" + +msgid "Generate CLI for selected target" +msgstr "为选定目标生成 CLI" + +msgid "Generate QR…" +msgstr "生成二维码…" + +msgid "Generate guest WiFi QR…" +msgstr "正在生成访客 Wi-Fi 二维码…" + +msgid "Generate password" +msgstr "生成密码" + +msgid "Generated" +msgstr "已生成" + +msgid "Generated CLI examples" +msgstr "生成的 CLI 示例" + +msgid "Generated CLI for" +msgstr "已生成的 CLI:" + +msgid "Generated cron entry:" +msgstr "生成的 cron 条目:" + +msgid "Generated scripts" +msgstr "已生成的脚本" + +msgid "Generates a QR code with the guest network data (SSID, encryption, password) so a client device can scan and connect." +msgstr "生成包含访客网络数据(SSID、加密方式、密码)的二维码,以便客户端设备扫描并连接。" + +msgid "Generates ~1440 log lines per day. Leave off unless debugging." +msgstr "每天约产生 1440 行日志。除调试外保持关闭。" + +msgid "Graphical Crontab Configurator" +msgstr "图形化 Crontab 配置器" + +msgid "Group ID" +msgstr "组 ID" + +msgid "Guest Wi-Fi" +msgstr "访客 Wi-Fi" + +msgid "Guest WiFi configuration saved and applied." +msgstr "访客 Wi-Fi 配置已保存并应用。" + +msgid "Guest networks" +msgstr "访客网络" + +msgid "Guide" +msgstr "指南" + +msgid "Health check" +msgstr "健康检查" + +msgid "High" +msgstr "高" + +msgid "Hostname:" +msgstr "主机名:" + +msgid "Hotplug Rules" +msgstr "Hotplug 规则" + +msgid "Hour" +msgstr "小时" + +msgid "Hour(0~23)" +msgstr "小时(0~23)" + +msgid "IP" +msgstr "IP" + +msgid "IP address" +msgstr "IP 地址" + +msgid "IPv6" +msgstr "IPv6" + +msgid "Import" +msgstr "导入" + +msgid "Index" +msgstr "首页" + +msgid "Info" +msgstr "信息" + +msgid "Input" +msgstr "输入" + +msgid "Install Failover-lite preset" +msgstr "安装 Failover-lite 预设" + +msgid "Install Failover-lite preset? Adds an editable MWAN3-substitute script." +msgstr "安装 Failover-lite 预设?将添加一个可编辑的 MWAN3 替代脚本。" + +msgid "Interface" +msgstr "接口" + +msgid "Interface Events" +msgstr "接口事件" + +msgid "Interface IP address" +msgstr "接口IP地址" + +msgid "Interface changed" +msgstr "接口已变更" + +msgid "Interface created" +msgstr "接口已创建" + +msgid "Interface down" +msgstr "接口离线" + +msgid "Interface name" +msgstr "接口名" + +msgid "Interface received link" +msgstr "接口已收到链路" + +msgid "Interface reload" +msgstr "接口重载" + +msgid "Interface removed" +msgstr "接口已移除" + +msgid "Interface stats" +msgstr "接口统计" + +msgid "Interface up" +msgstr "接口在线" + +msgid "Interface up failed" +msgstr "接口上线失败" + +msgid "Interfaces" +msgstr "接口" + +msgid "January" +msgstr "一月" + +msgid "July" +msgstr "七月" + +msgid "June" +msgstr "六月" + +msgid "Kernel:" +msgstr "内核:" + +msgid "LAN IP Address Changed" +msgstr "LAN IP 地址已更改" + +msgid "Last log line" +msgstr "最后一行日志" + +msgid "Lease time" +msgstr "租约时间" + +msgid "Library" +msgstr "脚本库" + +msgid "Limit" +msgstr "限制" + +msgid "Load" +msgstr "加载" + +msgid "Load bridge vlan show" +msgstr "载入 bridge vlan show 输出" + +msgid "Loading" +msgstr "加载中" + +msgid "Loading scripts..." +msgstr "正在加载脚本…" + +msgid "Loading..." +msgstr "加载中…" + +msgid "Log Viewer" +msgstr "日志查看器" + +msgid "Log every healthy check" +msgstr "记录每次正常检查" + +msgid "Log level" +msgstr "日志级别" + +msgid "Low" +msgstr "低" + +msgid "Lowercase letters" +msgstr "小写字母" + +msgid "Lowest leased address, as offset from the network address." +msgstr "最低的租用地址,作为网络地址的偏移量。" + +msgid "MAC address" +msgstr "MAC 地址" + +msgid "MAC-based: VLAN membership decided by MAC address." +msgstr "基于 MAC:按 MAC 地址决定 VLAN 归属。" + +msgid "Make executable" +msgstr "设为可执行" + +msgid "Manual restore:" +msgstr "手动恢复:" + +msgid "March" +msgstr "三月" + +msgid "Max clients" +msgstr "最大客户端数量" + +msgid "Maximum number of leased addresses." +msgstr "最大地址分配数量。" + +msgid "May" +msgstr "五月" + +msgid "Medium" +msgstr "一般" + +msgid "Memory:" +msgstr "内存:" + +msgid "Minimum length is 8 characters, 13 characters recommended." +msgstr "最小长度为8个字符,建议使用13个字符。" + +msgid "Minute" +msgstr "分钟" + +msgid "Minute(0~59)" +msgstr "分钟(0~59)" + +msgid "Mixed Mode" +msgstr "Mixed 模式" + +msgid "Mode" +msgstr "模式" + +msgid "Mode:" +msgstr "模式:" + +msgid "Monday" +msgstr "星期一" + +msgid "Month" +msgstr "月份" + +msgid "Month(0~11)" +msgstr "月份(0~11)" + +msgid "More Characters" +msgstr "过短" + +msgid "Name" +msgstr "名称" + +msgid "Name *" +msgstr "名称 *" + +msgid "Name — display name." +msgstr "名称 — 显示名。" + +msgid "Name:" +msgstr "名称:" + +msgid "Netmask" +msgstr "网络掩码" + +msgid "Network" +msgstr "网络" + +msgid "Network interface" +msgstr "网络接口" + +msgid "Network interface to bind (e.g. br-lan, lo)" +msgstr "要绑定的网络接口(例如 br-lan、lo)" + +msgid "Network name (SSID)" +msgstr "网络名称 (SSID)" + +msgid "Network → Interfaces → Devices with \"Bridge VLAN filtering\" = DSA (modern)." +msgstr "\"网络 → 接口 → 设备\"中有\"桥接 VLAN 过滤\" = DSA(新式)。" + +msgid "Network → Switch menu = swconfig (legacy)." +msgstr "\"网络 → 交换机\"菜单 = swconfig(旧式)。" + +msgid "Networks" +msgstr "网络" + +msgid "Never apply VLAN changes over Wi-Fi or over the port you are changing." +msgstr "切勿通过 Wi-Fi 或正在修改的端口应用 VLAN 变更。" + +msgid "New bridge name" +msgstr "新桥接名称" + +msgid "No" +msgstr "否" + +msgid "No encryption (open network)" +msgstr "不加密(开放网络)" + +msgid "No generated scripts yet. Use Generate CLI in the Wizard." +msgstr "还没有生成的脚本。请在向导中使用\"生成 CLI\"。" + +msgid "No scripts yet." +msgstr "暂无脚本。" + +msgid "No snapshots yet." +msgstr "暂无快照。" + +msgid "No templates yet. Save one from the Wizard after you build a plan." +msgstr "暂无模板。在向导中构建方案后可保存为模板。" + +msgid "No wireless interfaces found." +msgstr "未找到无线接口。" + +msgid "Note" +msgstr "备注" + +msgid "Notes" +msgstr "备注" + +msgid "Notice" +msgstr "通知" + +msgid "November" +msgstr "十一月" + +msgid "October" +msgstr "十月" + +msgid "Once" +msgstr "一次" + +msgid "Open backup page →" +msgstr "打开备份页面 →" + +msgid "OpenWrt 21.02+ typically uses DSA. Some single-port travel routers do not support VLAN filtering at all." +msgstr "OpenWrt 21.02+ 通常使用 DSA。部分单口旅行路由器完全不支持 VLAN 过滤。" + +msgid "Options" +msgstr "选项" + +msgid "Output" +msgstr "输出" + +msgid "Override URL in Terminal tab (for reverse proxy)" +msgstr "在终端标签页中覆盖 URL(用于反向代理)" + +msgid "Override default MAC address - the range of usable addresses might be limited by the driver" +msgstr "重设默认 MAC 地址 - 可用地址的范围可能受限于驱动" + +msgid "Overview" +msgstr "概览" + +msgid "Parent bridge" +msgstr "父网桥" + +msgid "Parse" +msgstr "解析" + +msgid "Password" +msgstr "密码" + +msgid "Password composition" +msgstr "密码组成" + +msgid "Password length" +msgstr "密码长度" + +msgid "Password must be at least 8 characters long (WPA-PSK/WPA2-PSK/WPA3-SAE)." +msgstr "密码长度必须至少为 8 个字符 (WPA-PSK/WPA2-PSK/WPA3-SAE)。" + +msgid "Password strength" +msgstr "密码强度" + +msgid "Paste a VLAN configuration from another platform. Parsers run in your browser — nothing is sent to the router until you click Apply." +msgstr "粘贴其他平台的 VLAN 配置。解析在浏览器中完成——点击\"应用\"前不会发送到路由器。" + +msgid "Path:" +msgstr "路径:" + +msgid "Per-ping timeout in seconds." +msgstr "每次 ping 的超时秒数。" + +msgid "Permissions:" +msgstr "权限:" + +msgid "Pick time units, type a command, preview the generated cron line, then add it." +msgstr "选择时间单位,输入命令,预览生成的 cron 行,然后添加。" + +msgid "Ping interface" +msgstr "Ping 接口" + +msgid "Ping test hosts through an interface. After consecutive failures, reset the interface or reboot." +msgstr "通过接口对测试主机执行 Ping。连续失败达到阈值后,重置接口或重启。" + +msgid "Platform" +msgstr "平台" + +msgid "Please enter the command" +msgstr "请输入命令" + +msgid "Please use System → Backup / Flash Firmware to download a config backup." +msgstr "请使用\"系统 → 备份/升级固件\"下载配置备份。" + +msgid "Port" +msgstr "端口 " + +msgid "Port to listen (default: 7681, use 0 for random)" +msgstr "监听端口(默认 7681,填 0 表示随机)" + +msgid "Port-based: one port = one VLAN." +msgstr "基于端口:一个端口 = 一个 VLAN。" + +msgid "Ports" +msgstr "端口" + +msgid "Ports (space-separated, use :t for tagged)" +msgstr "端口(空格分隔,加 :t 表示 tagged)" + +msgid "Preset installed:" +msgstr "预设已安装:" + +msgid "Prevents the \"LAN access lost\" bug on some OpenWrt forks." +msgstr "防止某些 OpenWrt 衍生版的\"LAN 失联\" bug。" + +msgid "Preview plan" +msgstr "预览方案" + +msgid "Preview:" +msgstr "预览:" + +msgid "Primary (fastest) interface" +msgstr "主接口(最快)" + +msgid "Proto" +msgstr "协议" + +msgid "Protocol" +msgstr "协议" + +msgid "Protocol-based: VLAN membership decided by packet protocol." +msgstr "基于协议:按报文协议决定 VLAN 归属。" + +msgid "QR Code" +msgstr "二维码" + +msgid "QR Error Correction Code Level" +msgstr "二维码错误纠正级别" + +msgid "Quartile" +msgstr "四分之一" + +msgid "Quick Actions" +msgstr "快捷操作" + +msgid "Radio" +msgstr "射频" + +msgid "Read-only" +msgstr "只读" + +msgid "Refresh" +msgstr "刷新" + +msgid "Remarks" +msgstr "备注" + +msgid "Reset" +msgstr "复位" + +msgid "Reset firewall on failover" +msgstr "故障切换时重置防火墙" + +msgid "Resolved:" +msgstr "已解析:" + +msgid "Resource mode:" +msgstr "资源模式:" + +msgid "Restart Tailscale on failover and recovery" +msgstr "故障切换及恢复时重启 Tailscale" + +msgid "Restore" +msgstr "还原" + +msgid "Restore from" +msgstr "从以下位置恢复" + +msgid "Restored." +msgstr "已恢复。" + +msgid "Reusable VLAN schemes. Templates can be loaded back into the Wizard and applied on this or another router." +msgstr "可复用的 VLAN 方案。模板可载入向导,应用于本机或其他路由器。" + +msgid "Reverse Order" +msgstr "反向显示" + +msgid "Rollback cancelled." +msgstr "回滚已取消。" + +msgid "Run" +msgstr "运行" + +msgid "Run commands on network interface events or when connectivity monitors fail. Replaces luci-app-hotplug." +msgstr "在网口事件或连通性监控失败时执行命令。替代 luci-app-hotplug。" + +msgid "Run ping" +msgstr "运行 ping" + +msgid "Run watchdog" +msgstr "运行看门狗" + +msgid "Running" +msgstr "运行中" + +msgid "Runs at" +msgstr "运行于" + +msgid "SSID" +msgstr "SSID" + +msgid "SSID saved and applied." +msgstr "SSID 已保存并应用。" + +msgid "SSL" +msgstr "SSL" + +msgid "SSL CA" +msgstr "SSL CA" + +msgid "SSL cert" +msgstr "SSL 证书" + +msgid "SSL key" +msgstr "SSL 密钥" + +msgid "Safety" +msgstr "安全" + +msgid "Saturday" +msgstr "星期六" + +msgid "Save" +msgstr "保存" + +msgid "Save & Apply" +msgstr "保存并应用" + +msgid "Save & Apply All" +msgstr "保存并应用全部" + +msgid "Save & Apply all VLAN changes" +msgstr "保存并应用所有 VLAN 变更" + +msgid "Save & Restart ttyd" +msgstr "保存并重启 ttyd" + +msgid "Save Firmware Settings" +msgstr "保存固件设置" + +msgid "Save WAN Settings (smart diff)" +msgstr "保存 WAN 设置(智能差异)" + +msgid "Save Wireless" +msgstr "保存无线设置" + +msgid "Save current plan as template" +msgstr "将当前方案保存为模板" + +msgid "Save failed:" +msgstr "保存失败:" + +msgid "Saved" +msgstr "已保存" + +msgid "Saved to memory." +msgstr "已保存到内存。" + +msgid "Saved to memory. Use Save & Apply at the top to commit." +msgstr "已保存到内存。使用顶部\"保存并应用\"提交。" + +msgid "Saved. Scroll to the top of the page and click \"Save & Apply\" to commit." +msgstr "已保存。滚动到页面顶部点击\"保存并应用\"提交。" + +msgid "Scan this QR code with the client device." +msgstr "用客户端设备扫描此二维码。" + +msgid "Scheduled Clearmem" +msgstr "定时清理内存" + +msgid "Scheduled Closewan" +msgstr "定时关闭WAN" + +msgid "Scheduled Customscript" +msgstr "定时 自定义脚本" + +msgid "Scheduled Customscript2" +msgstr "定时 自定义脚本2" + +msgid "Scheduled DisReconn" +msgstr "定时断网重连" + +msgid "Scheduled DisRereboot" +msgstr "定时断网重启" + +msgid "Scheduled Poweroff" +msgstr "定时关机" + +msgid "Scheduled ReNetwork" +msgstr "定时重启网络" + +msgid "Scheduled Reboot" +msgstr "定时重启" + +msgid "Scheduled RestartSamba" +msgstr "定时重启Samba" + +msgid "Scheduled Restartmwan3" +msgstr "定时重启MWAN3" + +msgid "Scheduled Restartwan" +msgstr "定时重启WAN" + +msgid "Scheduled Sysfree" +msgstr "定时系统清理" + +msgid "Scheduled Tasks" +msgstr "计划任务" + +msgid "Scheduled Type" +msgstr "定时类型" + +msgid "Scheduled Wifidown" +msgstr "定时关闭WiFi" + +msgid "Scheduled Wifiup" +msgstr "定时开启WiFi" + +msgid "Scheduled and startup tasks. Presets include reboot, shutdown, network restart, memory cleanup, custom scripts." +msgstr "定时任务与开机任务。预设包括重启、关机、网络重启、内存清理、自定义脚本等。" + +msgid "Script body *" +msgstr "脚本内容 *" + +msgid "Script body — the actual shell script." +msgstr "脚本内容 — 实际的 shell 脚本。" + +msgid "Script detail:" +msgstr "脚本详情:" + +msgid "Scripts" +msgstr "脚本" + +msgid "Scripts execute as root. Only run scripts you trust." +msgstr "脚本以 root 身份执行。只运行你信任的脚本。" + +msgid "Scripts live in /etc/quickactions/scripts/. Each script is a normal shell file." +msgstr "脚本位于 /etc/quickactions/scripts/。每个脚本是普通 shell 文件。" + +msgid "Scripts — help" +msgstr "脚本 — 帮助" + +msgid "Seconds between cron runs." +msgstr "cron 运行间隔(秒)。" + +msgid "Section" +msgstr "分区" + +msgid "Security" +msgstr "安全" + +msgid "Security: keep sensitive devices (printers, NAS) separate from guest traffic." +msgstr "安全:将敏感设备(打印机、NAS)与访客流量隔离。" + +msgid "Select password components..." +msgstr "选择密码组成部分..." + +msgid "Send option to client" +msgstr "发送选项到客户端" + +msgid "September" +msgstr "九月" + +msgid "Shell commands for [Customscript2] task type" +msgstr "【自定义脚本2】任务类型的Shell命令" + +msgid "Shell commands for [Customscript] task type" +msgstr "【自定义脚本】任务类型的Shell命令" + +msgid "Show stats" +msgstr "显示统计" + +msgid "Signal" +msgstr "信号强度" + +msgid "Signal to send to command on exit (default: 1)" +msgstr "命令退出时要发送的信号(默认 1)" + +msgid "Size" +msgstr "大小" + +msgid "Size:" +msgstr "大小:" + +msgid "Snapshot / Restore" +msgstr "快照 / 恢复" + +msgid "Snapshot note (optional):" +msgstr "快照备注(可选):" + +msgid "Snapshot saved:" +msgstr "快照已保存:" + +msgid "Space-separated. Pings go out through the primary interface." +msgstr "空格分隔。ping 经主接口发出。" + +msgid "Special characters" +msgstr "特殊字符" + +msgid "Start" +msgstr "启动" + +msgid "Start offset" +msgstr "起始偏移" + +msgid "Startup Tasks" +msgstr "开机启动任务" + +msgid "Startup Type" +msgstr "启动类型" + +msgid "Status" +msgstr "状态" + +msgid "Step 0 — Target platform" +msgstr "步骤 0 — 目标平台" + +msgid "Step 1 — Enable VLAN filtering on the bridge:" +msgstr "步骤 1 — 在网桥上启用 VLAN 过滤:" + +msgid "Step 1 — VLAN ID and parent bridge" +msgstr "步骤 1 — VLAN ID 与父网桥" + +msgid "Step 2 — Add VLAN 20: pick ports, mark them tagged or untagged." +msgstr "步骤 2 — 添加 VLAN 20:选择端口,标记为 tagged 或 untagged。" + +msgid "Step 2 — Port assignment" +msgstr "步骤 2 — 端口分配" + +msgid "Step 3 — Create the interface (protocol static, IP 192.168.20.1/24, device br-lan.20)." +msgstr "步骤 3 — 创建接口(协议 static,IP 192.168.20.1/24,设备 br-lan.20)。" + +msgid "Step 3 — Logical interface" +msgstr "步骤 3 — 逻辑接口" + +msgid "Step 4 — Create the firewall zone, then click Save & Apply." +msgstr "步骤 4 — 创建防火墙区域,然后点击\"保存并应用\"。" + +msgid "Step 4 — DHCP server" +msgstr "步骤 4 — DHCP 服务器" + +msgid "Step 5 — Firewall zone" +msgstr "步骤 5 — 防火墙区域" + +msgid "Step 6 — Attach SSID (optional)" +msgstr "步骤 6 — 绑定 SSID(可选)" + +msgid "Strong" +msgstr "强" + +msgid "Sunday" +msgstr "星期日" + +msgid "Tagged (802.1Q): one port carries multiple VLANs, each packet tagged." +msgstr "Tagged(802.1Q):一个端口承载多个 VLAN,每个报文带标签。" + +msgid "Tagged packet:" +msgstr "带标签报文:" + +msgid "Target" +msgstr "目标" + +msgid "Target platform" +msgstr "目标平台" + +msgid "Tasks to run after boot, with a delay in seconds." +msgstr "开机后延迟指定秒数运行的任务。" + +msgid "Template loaded. Switch to Wizard to edit and apply." +msgstr "模板已载入。切换到\"向导\"进行编辑和应用。" + +msgid "Template saved" +msgstr "模板已保存" + +msgid "Templates" +msgstr "模板" + +msgid "Terminal type" +msgstr "终端类型" + +msgid "Test hosts" +msgstr "测试主机" + +msgid "The Safety subtab above takes snapshots and supports a 90-second auto-rollback after every apply." +msgstr "上方\"安全\"子标签支持快照,并在每次应用后提供 90 秒自动回滚。" + +msgid "The address %s is already in use and no free address is available in the 172.16.0.1 - 172.31.0.1 range." +msgstr "地址 %s 已在使用中,且在 172.16.0.1 - 172.31.0.1 范围内没有可用的空闲地址。" + +msgid "The legacy download endpoint no longer works directly." +msgstr "旧的下载接口已不再直接可用。" + +msgid "The logical interface to watch (e.g. wan, lan)" +msgstr "要监视的逻辑接口(例如 wan、lan)" + +msgid "The radio on which the guest network will work." +msgstr "访客网络将工作的无线射频。" + +msgid "The router is the gateway between VLANs. Traffic between them goes through the firewall zone forwarding rules." +msgstr "路由器是 VLAN 之间的网关。VLAN 间流量经防火墙区域转发规则处理。" + +msgid "This entry already exists" +msgstr "此条目已存在" + +msgid "This section is coming in a follow-up patch." +msgstr "此部分将在后续补丁中提供。" + +msgid "This tab lets you view, create, and edit VLANs on this router. It can also generate matching configs for external switches (MikroTik, Cisco, EdgeOS)." +msgstr "本页可查看、创建和编辑本路由器的 VLAN,并可为外部交换机(MikroTik、Cisco、EdgeOS)生成匹配配置。" + +msgid "Thursday" +msgstr "星期四" + +msgid "Tier saved. Reload the tab to apply." +msgstr "层级已保存。重新加载此标签页生效。" + +msgid "Timeout (s)" +msgstr "超时(秒)" + +msgid "Timeout — seconds before the script is force-killed." +msgstr "超时 — 脚本被强制终止前的秒数。" + +msgid "Timeout:" +msgstr "超时:" + +msgid "Timestamp" +msgstr "时间戳" + +msgid "Traffic from guests to the router itself (e.g. LuCI, SSH)." +msgstr "从访客到路由器本身的流量(例如:LuCI、SSH)。" + +msgid "Traffic from the router itself to guests. ACCEPT is recommended so router services (DHCP, DNS) keep working." +msgstr "从路由器本身到访客的流量。建议设为 接受(ACCEPT),以确保路由器服务(DHCP、DNS)正常工作。" + +msgid "Traffic management: reduce broadcast traffic and improve speed." +msgstr "流量管理:减少广播流量、提升速度。" + +msgid "Traffic passing between guests and other networks/zones (e.g. LAN). Keep this REJECT or DROP to isolate guests from your LAN. ACCEPT would allow guests to reach other networks." +msgstr "访客与其他网络/区域(如 LAN)之间通过的流量。请保持为 拒绝(REJECT) 或 丢弃(DROP) 以将访客与您的 LAN 隔离。设为 接受(ACCEPT) 将允许访客访问其他网络。" + +msgid "Traffic switches to whichever backup is up with the lowest route metric." +msgstr "流量切换到可用且路由度量值最低的备用接口。" + +msgid "Traffic uses this interface whenever it is healthy." +msgstr "健康时流量始终走此接口。" + +msgid "Trigger event" +msgstr "触发事件" + +msgid "Trunk port:" +msgstr "Trunk 端口:" + +msgid "Tuesday" +msgstr "星期二" + +msgid "UDP port range for DHCP traffic, e.g. \"67-68\"." +msgstr "DHCP 流量的 UDP 端口范围,例如 “67-68”。" + +msgid "UNIX socket" +msgstr "UNIX 套接字" + +msgid "URL override" +msgstr "URL 重写" + +msgid "Uppercase letters" +msgstr "大写字母" + +msgid "Use \"Install Failover-lite preset\" for an MWAN3 substitute on low-RAM routers." +msgstr "低内存路由器可使用\"安装 Failover-lite 预设\"替代 MWAN3。" + +msgid "Use the SSID subtab above: pick the SSID, pick the VLAN interface, save." +msgstr "使用上方 SSID 子标签:选择 SSID、选择 VLAN 接口、保存。" + +msgid "Use the Wizard subtab above to do all four steps in one flow." +msgstr "使用上方\"向导\"子标签一次完成全部步骤。" + +msgid "User ID" +msgstr "用户 ID" + +msgid "VLAN" +msgstr "VLAN" + +msgid "VLAN Guide" +msgstr "VLAN 指南" + +msgid "VLAN ID" +msgstr "VLAN ID" + +msgid "VLAN ID (2–4094)" +msgstr "VLAN ID(2–4094)" + +msgid "VLAN ID:" +msgstr "VLAN ID:" + +msgid "VLAN Management" +msgstr "VLAN 管理" + +msgid "VLAN applied. Network reloaded." +msgstr "VLAN 已应用,网络已重载。" + +msgid "VLAN filtering" +msgstr "VLAN 过滤" + +msgid "VLANs" +msgstr "VLAN" + +msgid "View, create, and edit VLANs on this router and generate matching configs for MikroTik, Cisco, and EdgeOS devices." +msgstr "查看、创建和编辑本路由器的 VLAN,并生成 MikroTik、Cisco、EdgeOS 设备的匹配配置。" + +msgid "WAN Failover" +msgstr "WAN 故障切换" + +msgid "WEP Open System" +msgstr "WEP 开放式系统" + +msgid "WEP Shared Key" +msgstr "WEP 共享密钥" + +msgid "WEP key must be 5 or 13 ASCII characters, or 10 or 26 hexadecimal digits." +msgstr "WEP 密钥必须是 5 或 13 个 ASCII 字符,或者是 10 或 26 个十六进制数字。" + +msgid "WPA2-PSK is recommended for most devices. WPA3-SAE offers the strongest security but requires WPA3-capable clients." +msgstr "推荐大多数设备使用 WPA2-PSK。WPA3-SAE 提供了最强的安全性,但需要客户端支持 WPA3。" + +msgid "Warning" +msgstr "警告" + +msgid "Weak" +msgstr "弱" + +msgid "Wednesday" +msgstr "星期三" + +msgid "Week Day(0~6)" +msgstr "星期(0~6)" + +msgid "Weekday" +msgstr "星期" + +msgid "Wireless" +msgstr "无线" + +msgid "Wireless saved and applied. Reloading..." +msgstr "无线配置已保存并应用。正在重载…" + +msgid "Wizard" +msgstr "向导" + +msgid "Yes" +msgstr "是" + +msgid "You do not tag the SSID directly — you attach the wireless interface to the VLAN's logical network." +msgstr "不是直接给 SSID 打标签——而是把无线接口绑定到 VLAN 的逻辑网络。" + +msgid "Your APs currently have different passwords. Saving will overwrite every AP with the password entered above." +msgstr "当前 AP 密码不一致。保存后将用上方输入的密码覆盖所有 AP。" + +msgid "Your router:" +msgstr "你的路由器:" + +msgid "Zone name" +msgstr "区域名称" + +msgid "a port carrying multiple VLANs (tagged)." +msgstr "承载多个 VLAN 的端口(tagged)。" + +msgid "a port dedicated to one VLAN." +msgstr "专属于一个 VLAN 的端口。" + +msgid "accept" +msgstr "接受" + +msgid "anonymous" +msgstr "匿名" + +msgid "are available in the Wizard subtab once you build a plan and pick a target platform." +msgstr "构建方案并选择目标平台后,可在\"向导\"子标签中使用。" + +msgid "carries a VLAN tag identifying its network." +msgstr "携带标识其所属网络的 VLAN 标签。" + +msgid "day: %s" +msgstr "日:%s" + +msgid "disabled" +msgstr "已禁用" + +msgid "driver default" +msgstr "驱动默认" + +msgid "drop" +msgstr "丢弃" + +msgid "e.g. Restart firewall on WAN up" +msgstr "例如:WAN 上线时重启防火墙" + +msgid "enabled" +msgstr "已启用" + +msgid "hour: %s" +msgstr "时:%s" + +msgid "minute: %s" +msgstr "分:%s" + +msgid "month: %s" +msgstr "月:%s" + +msgid "number 1–4094. Avoid 0, 1, 4095." +msgstr "数字 1–4094。避免使用 0、1、4095。" + +msgid "randomly generated" +msgstr "随机生成" + +msgid "reject" +msgstr "拒绝" + +msgid "ttyd Configuration" +msgstr "ttyd 配置" + +msgid "ttyd Configuration — full settings" +msgstr "ttyd 配置 — 完整设置" + +msgid "ttyd Instance" +msgstr "ttyd 实例" + +msgid "weekday: %s" +msgstr "周:%s" + +msgid "— create, edit, run, and manage shell scripts. Files live in" +msgstr "— 创建、编辑、运行和管理 shell 脚本。文件位于" + +msgid "— none —" +msgstr "— 无 —" + +msgid "↗ Open in New Tab" +msgstr "↗ 在新标签页打开" + +msgid "↻ Force Reconnect" +msgstr "↻ 强制重连" + +msgid "↻ Refresh" +msgstr "↻ 刷新" + +msgid "⚠️ Connect via a LAN port you will NOT modify." +msgstr "⚠️ 请通过你不会修改的 LAN 口连接。" + +msgid "⚠️ Connect via a LAN port you will NOT modify. Never apply VLAN changes over Wi-Fi or over the port you are changing. You will lock yourself out." +msgstr "⚠️ 请通过你不会修改的 LAN 口连接。切勿通过 Wi-Fi 或正在修改的端口应用 VLAN 变更,否则会把你自己锁在外面。" + +msgid "⚠️ Never enable Guest → LAN. That defeats the isolation." +msgstr "⚠️ 切勿启用 Guest → LAN。那会破坏隔离。" + +msgid "⚠️ Pending auto-rollback in" +msgstr "⚠️ 自动回滚倒计时" + +msgid "❔ Help" +msgstr "❔ 帮助" + +msgid "🌐 Cross-platform notes" +msgstr "🌐 跨平台说明" + +msgid "💻 OpenWrt DSA quick example — VLAN 20 for a Guest network" +msgstr "💻 OpenWrt DSA 快速示例 — 为访客网络创建 VLAN 20" + +msgid "📂 Types of VLAN" +msgstr "📂 VLAN 的类型" + +msgid "📖 Term definitions" +msgstr "📖 术语定义" + +msgid "📘 What is a VLAN?" +msgstr "📘 什么是 VLAN?" + +msgid "📶 Tagging a Wi-Fi SSID to a VLAN" +msgstr "📶 将 Wi-Fi SSID 绑定到 VLAN" + +msgid "📸 Snapshot now" +msgstr "📸 立即快照" + +msgid "🔍 First check: DSA or swconfig?" +msgstr "🔍 先检查:DSA 还是 swconfig?" + +msgid "🔥 Zone Forwarding" +msgstr "🔥 区域转发" + +msgid "🛟 Anti-lockout & rollback" +msgstr "🛟 防锁死与回滚" + +msgid "🛠️ Why use VLAN?" +msgstr "🛠️ 为什么使用 VLAN?" + +msgid "🛡️ Client Isolation" +msgstr "🛡️ 客户端隔离" + +msgid "🪵 Inter-VLAN Routing (Router-on-a-Stick)" +msgstr "🪵 VLAN 间路由(单臂路由)" + diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile index ae9c47e30..32776e853 100644 --- a/luci-app-wwand/Makefile +++ b/luci-app-wwand/Makefile @@ -17,12 +17,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wwand -PKG_VERSION:=1.6.9 -PKG_RELEASE:=37 +PKG_VERSION:=1.6.9_p1 +PKG_RELEASE:=38 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git -PKG_SOURCE_VERSION:=214093f765f34f0d99dcf0e3a94581929d81d9f3 +PKG_SOURCE_VERSION:=91e492e8c33b192b6bfcfcb8690dfcfd380af7d8 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only diff --git a/natflow/Makefile b/natflow/Makefile index cbcbc07f0..ac2c7f08d 100644 --- a/natflow/Makefile +++ b/natflow/Makefile @@ -10,7 +10,7 @@ include $(INCLUDE_DIR)/kernel.mk PKG_NAME:=natflow PKG_VERSION:=20260930 -PKG_RELEASE:=89 +PKG_RELEASE:=90 PKG_SOURCE:=$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git diff --git a/natflow/files/natflow-boot.config b/natflow/files/natflow-boot.config index 0ed6a4bbc..7808e26d8 100644 --- a/natflow/files/natflow-boot.config +++ b/natflow/files/natflow-boot.config @@ -17,7 +17,7 @@ config zone config zone option type 'wan' option id '2' - option ifname 'pppoe-wan' # можно префикс: 'eth+' + option ifname 'wan' # or mask prefix: 'eth+' # config vline # option type 'vline' # vline | relay diff --git a/natflow/files/natflow-boot.init b/natflow/files/natflow-boot.init index 695154cf1..40bb52a1c 100644 --- a/natflow/files/natflow-boot.init +++ b/natflow/files/natflow-boot.init @@ -12,6 +12,28 @@ opt() { [ -n "$3" ] && echo "$2=$3" >"$1" } +# rate_to_bytes -> байт/с +# N | NK|NM|NG (KiB/MiB/GiB, x1024) | Nkbit|Nmbit|Ngbit (биты, x1000) +rate_to_bytes() { + local v="$1" n u + + n="${v%%[!0-9]*}" + u="${v#$n}" + [ -n "$n" ] || return 1 + u="$(echo "$u" | tr 'A-Z' 'a-z')" + + case "$u" in + "") echo "$n" ;; + k|kb) echo $((n * 1024)) ;; + m|mb) echo $((n * 1024 * 1024)) ;; + g|gb) echo $((n * 1024 * 1024 * 1024)) ;; + kbit) echo $((n * 125)) ;; + mbit) echo $((n * 125000)) ;; + gbit) echo $((n * 125000000)) ;; + *) return 1 ;; + esac +} + apply_zone() { local cfg="$1" type id ifname @@ -62,6 +84,15 @@ apply_qos() { config_get rxbytes "$cfg" rxbytes 0 config_get txbytes "$cfg" txbytes 0 + rxbytes="$(rate_to_bytes "$rxbytes")" || { + logger -t natflow "qos $cfg: bad rxbytes, rule skipped" + return 0 + } + txbytes="$(rate_to_bytes "$txbytes")" || { + logger -t natflow "qos $cfg: bad txbytes, rule skipped" + return 0 + } + echo "add user=$user,user_port=$user_port,remote=$remote,remote_port=$remote_port,proto=$proto,rxbytes=$rxbytes,txbytes=$txbytes" >"$QOS" } @@ -123,4 +154,4 @@ stop() { reload() { start -} \ No newline at end of file +} diff --git a/wwand/Makefile b/wwand/Makefile index aa5aacd4e..3f4f29a75 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -50,12 +50,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=wwand -PKG_VERSION:=1.6.9_p2 -PKG_RELEASE:=67 +PKG_VERSION:=1.6.9_p5 +PKG_RELEASE:=68 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git -PKG_SOURCE_VERSION:=e82b67689936839a249ae7a69b9a67e070db253d +PKG_SOURCE_VERSION:=c52913c970fa31f845dcb464339cc8c8ab1dac5b PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only