diff --git a/ariang/Makefile b/ariang/Makefile index 65dcbec5a..7c71b89b0 100644 --- a/ariang/Makefile +++ b/ariang/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ariang -PKG_VERSION:=1.3.14 -PKG_RELEASE:=4 +PKG_VERSION:=1.3.15 +PKG_RELEASE:=5 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).zip PKG_SOURCE_URL:=https://github.com/mayswind/AriaNg/releases/download/$(PKG_VERSION) diff --git a/clashoo/Makefile b/clashoo/Makefile index 8072eb650..09f09df3a 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -5,13 +5,13 @@ PKG_SHORT_SHA:=9f053c4 PKG_COMMIT_DATE:=2026.10.02 PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA) -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src -PKG_SOURCE_VERSION:=c10fd37cf083bc4922237b7ec915d62614be0623 +PKG_SOURCE_VERSION:=216c1655025e1ede60cba790761d61315ced3e5d PKG_HASH:=skip PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA) diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft index ad0f24d8d..a355878d1 100644 --- a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft +++ b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft @@ -955,6 +955,7 @@ set clashoo_china6 { 2402:7540::/32, 2402:75c0::/32, 2402:7740::/32, + 2402:7820::/32, 2402:7d00::/32, 2402:7d80::/32, 2402:8180::/32, @@ -1254,11 +1255,10 @@ set clashoo_china6 { 2403:62c0::/32, 2403:6380::/42, 2403:6380:41::/48, - 2403:6380:43::/48, 2403:6380:44::/46, 2403:6380:48::/45, 2403:6380:50::/44, - 2403:6380:60::/43, + 2403:6380:70::/44, 2403:6380:80::/41, 2403:6380:100::/40, 2403:6380:200::/39, @@ -1761,7 +1761,8 @@ set clashoo_china6 { 2405:84c0:8000::/48, 2405:84c0:8002::/48, 2405:84c0:8006::/48, - 2405:84c0:8008::/45, + 2405:84c0:800a::/47, + 2405:84c0:800c::/46, 2405:84c0:8010::/48, 2405:84c0:8019::/48, 2405:84c0:801c::/46, @@ -1934,7 +1935,13 @@ set clashoo_china6 { 2406:840:118::/45, 2406:840:120::/43, 2406:840:140::/42, - 2406:840:180::/41, + 2406:840:181::/48, + 2406:840:182::/47, + 2406:840:184::/46, + 2406:840:188::/45, + 2406:840:190::/44, + 2406:840:1a0::/43, + 2406:840:1c0::/42, 2406:840:201::/48, 2406:840:202::/47, 2406:840:204::/46, @@ -2235,7 +2242,9 @@ set clashoo_china6 { 2406:840:9667::/48, 2406:840:9668::/45, 2406:840:9670::/44, - 2406:840:9680::/41, + 2406:840:9690::/44, + 2406:840:96a0::/43, + 2406:840:96c0::/42, 2406:840:9800::/46, 2406:840:9808::/45, 2406:840:9810::/45, @@ -2252,6 +2261,7 @@ set clashoo_china6 { 2406:840:9900::/42, 2406:840:9940::/43, 2406:840:9960::/47, + 2406:840:9963::/48, 2406:840:9965::/48, 2406:840:9968::/46, 2406:840:996d::/48, @@ -2285,7 +2295,7 @@ set clashoo_china6 { 2406:840:e0cc::/47, 2406:840:e0ce::/48, 2406:840:e0d0::/44, - 2406:840:e0e4::/46, + 2406:840:e0e6::/47, 2406:840:e0e9::/48, 2406:840:e0ea::/47, 2406:840:e0ec::/46, @@ -2459,10 +2469,6 @@ set clashoo_china6 { 2406:840:f600::/42, 2406:840:f640::/43, 2406:840:f670::/44, - 2406:840:f680::/47, - 2406:840:f682::/48, - 2406:840:f684::/46, - 2406:840:f688::/45, 2406:840:f690::/44, 2406:840:f6a0::/43, 2406:840:f6c0::/42, @@ -2623,7 +2629,6 @@ set clashoo_china6 { 2406:840:fee8::/46, 2406:840:feec::/48, 2406:840:feee::/47, - 2406:840:fef1::/48, 2406:840:ff00::/40, 2406:880::/32, 2406:8c0::/32, @@ -2874,7 +2879,7 @@ set clashoo_china6 { 2406:e780::/32, 2406:e8c0::/32, 2406:ea40::/28, - 2406:f280::/32, + 2406:f280::/33, 2406:f300::/32, 2406:f4c0::/32, 2406:f7c0::/32, @@ -3054,7 +3059,6 @@ set clashoo_china6 { 2408:4008::/29, 2408:4010::/30, 2408:4014::/31, - 2408:4016:1::/48, 2408:4016:2::/47, 2408:4016:4::/46, 2408:4016:8::/45, @@ -3223,7 +3227,36 @@ set clashoo_china6 { 2620:57:4004::/47, 2804:1e48:9002::/48, 2a03:f900::/29, + 2a04:3e00::/48, + 2a04:3e00:2::/47, + 2a04:3e00:4::/46, + 2a04:3e00:8::/45, + 2a04:3e00:10::/44, + 2a04:3e00:20::/43, + 2a04:3e00:40::/42, + 2a04:3e00:80::/41, + 2a04:3e00:100::/40, + 2a04:3e00:200::/39, + 2a04:3e00:400::/38, + 2a04:3e00:800::/37, + 2a04:3e00:1001::/48, 2a04:3e00:1002::/48, + 2a04:3e00:1004::/46, + 2a04:3e00:1008::/45, + 2a04:3e00:1010::/44, + 2a04:3e00:1020::/43, + 2a04:3e00:1040::/42, + 2a04:3e00:1080::/41, + 2a04:3e00:1100::/40, + 2a04:3e00:1200::/39, + 2a04:3e00:1400::/38, + 2a04:3e00:1800::/37, + 2a04:3e00:2000::/35, + 2a04:3e00:4000::/34, + 2a04:3e00:8000::/33, + 2a04:3e01::/32, + 2a04:3e02::/31, + 2a04:3e04::/30, 2a04:f580:8010::/47, 2a04:f580:8090::/48, 2a04:f580:8210::/47, @@ -3285,7 +3318,9 @@ set clashoo_china6 { 2a0a:d681:e200::/39, 2a0a:d681:e400::/38, 2a0a:d681:e800::/37, - 2a0a:d681:f000::/36, + 2a0a:d681:f000::/37, + 2a0a:d681:f800::/38, + 2a0a:d681:fe00::/40, 2a0a:d682::/33, 2a0a:d682:8000::/34, 2a0a:d682:c000::/36, diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft index 0a0ed205f..0731a7574 100644 --- a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft +++ b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft @@ -435,6 +435,7 @@ set clashoo_china { 43.241.48.0/22, 43.241.76.0/22, 43.241.80.0/20, + 43.241.100.0/23, 43.241.112.0/22, 43.241.168.0/21, 43.241.176.0/21, @@ -2325,7 +2326,6 @@ set clashoo_china { 103.169.108.0/23, 103.169.162.0/23, 103.169.202.0/23, - 103.169.216.0/24, 103.170.4.0/23, 103.170.134.0/23, 103.170.212.0/23, @@ -4191,6 +4191,7 @@ set clashoo_china { 163.52.28.0/23, 163.52.76.0/23, 163.52.108.0/23, + 163.52.246.0/23, 163.53.0.0/20, 163.53.36.0/22, 163.53.40.0/21, diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs index bc53b98ff..09674d699 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs and b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs index 24308eda4..b0dd06675 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs and b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs index 17ba01545..bbbb3a115 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs index 52341a40d..ea32030d3 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs differ diff --git a/cloudflared-next/Makefile b/cloudflared-next/Makefile index 489642df1..7a990ad6b 100644 --- a/cloudflared-next/Makefile +++ b/cloudflared-next/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cloudflared-next -PKG_VERSION:=2026.9.3 -PKG_RELEASE:=2 +PKG_VERSION:=2026.10.0 +PKG_RELEASE:=3 # Architecture mapping for pre-built binaries ifeq ($(ARCH),x86_64) diff --git a/cups/Makefile b/cups/Makefile index 020c69290..81597b86f 100644 --- a/cups/Makefile +++ b/cups/Makefile @@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cups -PKG_VERSION:=2.4.19 -PKG_RELEASE:=4 +PKG_VERSION:=2.4.20 +PKG_RELEASE:=5 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION)-source.tar.gz PKG_SOURCE_URL:=https://github.com/OpenPrinting/cups/releases/download/v$(PKG_VERSION) PKG_MD5SUM:=skip diff --git a/honk/Makefile b/honk/Makefile index a63cefd58..7e64bdb30 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -3,17 +3,17 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=2026.10.4_pre3 -PKG_RELEASE:=8 +PKG_VERSION:=2026.10.6_pre1 +PKG_RELEASE:=9 # 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.4.native-api.3 +HONK_RELEASE_TAG:=debug.2026.10.6.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:=55aa17f673e2908cb8bd537cae2542a0f4c9dc370bf70ad0d9263b48b3bab692 -HONK_HASH_AARCH64:=ac9f1ad0ce7666291915020c4e547ddd4c4cf8c358d12d0ff10c4acde8f69712 +HONK_HASH_X86_64:=b5c90ca96fdfd2800476f0b45abef40736ccc6a5517ff9ea46d953f3245c43e1 +HONK_HASH_AARCH64:=db124488f4eca22165dcfbcc45d650f95367b1a1ef3f86d6e93e65c336f72cc2 PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE_FILE:=LICENSE diff --git a/hysteria/Makefile b/hysteria/Makefile index e709a7a58..7891f3284 100644 --- a/hysteria/Makefile +++ b/hysteria/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=hysteria -PKG_VERSION:=2.12.3 -PKG_RELEASE:=17 +PKG_VERSION:=2.13.0 +PKG_RELEASE:=18 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/apernet/hysteria/tar.gz/app/v$(PKG_VERSION)? diff --git a/luci-app-adguardhome/.upstream_meta b/luci-app-adguardhome/.upstream_meta index e6afc2515..ac0302076 100644 --- a/luci-app-adguardhome/.upstream_meta +++ b/luci-app-adguardhome/.upstream_meta @@ -1,2 +1,2 @@ -80de46ce77221bee421a3c19f7d9d0190ee1b7cd +9c9ad92d8e1621d0708c83afc215bb141e7bafb8 e192c5983e50169ed984d48b094798869a0a780b diff --git a/luci-app-adguardhome/po/da/adguardhome.po b/luci-app-adguardhome/po/da/adguardhome.po new file mode 100644 index 000000000..9f65f1a76 --- /dev/null +++ b/luci-app-adguardhome/po/da/adguardhome.po @@ -0,0 +1,175 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Content-Transfer-Encoding: 8bit\n" +"Last-Translator: Hosted Weblate user 168028 \n" +"Language-Team: Danish \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"PO-Revision-Date: 2026-10-05 13:33+0000\n" +"X-Generator: Weblate 2026.10.1.dev0\n" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:244 +msgid "" +"A soft memory cap for the Go runtime, allowing the garbage collector to run " +"more frequently as usage approaches the limit to prevent Out-of-Memory (OOM) " +"kills." +msgstr "" +"En blød hukommelseshætte til Go runtime, så skraldsamleren kan køre oftere " +"som brug nærmer sig grænsen for at forhindre Out-of-Memory (OOM) dræber." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:113 +#: applications/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json:3 +msgid "AdGuard Home" +msgstr "AdGuard Home" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:137 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:199 +msgid "Advanced Settings" +msgstr "Avancerede indstillinger" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:146 +msgid "Configuration file" +msgstr "Konfigurations fil" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:89 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:147 +msgid "" +"Configuration file must be stored in its own directory, and not in '/etc'." +msgstr "Konfigurationsfilen skal gemmes i sin egen mappe, og ikke i '/etc'." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:159 +msgid "Directory where filters, logs, and statistics are stored." +msgstr "Mappe, hvor filtre, logfiler og statistik gemmes." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:132 +msgid "File System Access" +msgstr "Filsystem Adgang" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:133 +msgid "" +"Files and directories that AdGuard Home should have read-only or read-write " +"access to." +msgstr "" +"Filer og mapper, som AdGuard Home skal have læst eller skrive adgang til." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:129 +msgid "General Settings" +msgstr "Generelle indstillinger" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:138 +msgid "" +"Go environment variables that tune garbage collector and memory management." +msgstr "Go miljøvariabler, der tuner affaldssamler og hukommelsesstyring." + +#: applications/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json:3 +msgid "Grant permissions for the AdGuard Home LuCI app" +msgstr "giv tilladelser til AdGuard Home LuCI app" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:181 +msgid "Group the service runs under." +msgstr "Gruppen kører under." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:149 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:161 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:171 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:181 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:217 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 +msgid "If empty, defaults to" +msgstr "Hvis tomme, standarder til" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:139 +msgid "Modify at your own risk." +msgstr "Rediger på egen risiko." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:24 +msgid "Not running" +msgstr "Not running" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:148 +msgid "Parent directory will be owned by the service user." +msgstr "Forældremappen ejes af tjenestebrugeren." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:83 +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:99 +msgid "Path must be absolute." +msgstr "sti skal være absolut." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:86 +msgid "Path must not end with a slash." +msgstr "sti må ikke ende med en slash." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:207 +msgid "Read-only access" +msgstr "Læseadgang" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:208 +msgid "Read-write access" +msgstr "Læs-skrive adgang" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:23 +msgid "Running" +msgstr "Running" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:122 +msgid "Service Status" +msgstr "Servicestatus" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:180 +msgid "Service group" +msgstr "Servicegruppe" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:170 +msgid "Service user" +msgstr "Service bruger" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:230 +msgid "" +"The maximum number of operating system threads that can execute user-level " +"Go code simultaneously." +msgstr "" +"Det maksimale antal operativsystemtråde, der kan udføre brugerniveau Go kode " +"samtidigt." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:215 +msgid "" +"Tunes the garbage collector's aggressiveness by setting the percentage of " +"heap growth allowed before the next collection cycle triggers." +msgstr "" +"Tunes skraldfangens aggressivitet ved at indstille procentdelen af heap " +"vækst tilladt før den næste samling cyklus udløsere." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:171 +msgid "User the service runs under." +msgstr "Bruger tjenesten kører under." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:191 +msgid "Verbose logging" +msgstr "Verbose logging" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:119 +msgid "Version" +msgstr "Version" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:160 +msgid "Will be owned by the service user." +msgstr "Vil være ejet af servicebrugeren." + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:158 +msgid "Working directory" +msgstr "Arbejdsmappe" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 +msgid "unset" +msgstr "usæt" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:217 +msgid "unset and 100" +msgstr "usæt og 100" + +#: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 +msgid "unset and matching the number of CPUs" +msgstr "usæt og matchende antallet af CPU'er" diff --git a/luci-app-adguardhome/po/uk/adguardhome.po b/luci-app-adguardhome/po/uk/adguardhome.po index 7e308efd5..137ab36c1 100644 --- a/luci-app-adguardhome/po/uk/adguardhome.po +++ b/luci-app-adguardhome/po/uk/adguardhome.po @@ -3,13 +3,13 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: uk\n" "Content-Transfer-Encoding: 8bit\n" -"Last-Translator: Martin Olmer \n" +"Last-Translator: Karakurt \n" "Language-Team: Ukrainian \n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"PO-Revision-Date: 2026-08-31 06:59+0000\n" -"X-Generator: Weblate 2026.9.dev0\n" +"PO-Revision-Date: 2026-10-04 08:24+0000\n" +"X-Generator: Weblate 2026.10.1.dev0\n" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:244 msgid "" @@ -17,6 +17,9 @@ msgid "" "more frequently as usage approaches the limit to prevent Out-of-Memory (OOM) " "kills." msgstr "" +"М'яке обмеження пам'яті для середовища Go що збільшує частоту збору сміття " +"поки її використання наближується ліміту з метою уникнути зупинки через " +"відсутність пам'яті (OOM)." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:113 #: applications/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json:3 @@ -30,7 +33,7 @@ msgstr "Розширені налаштування" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:146 msgid "Configuration file" -msgstr "" +msgstr "Файл налаштувань" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:89 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:147 @@ -44,7 +47,7 @@ msgstr "" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:132 msgid "File System Access" -msgstr "" +msgstr "Доступ до файлової системи" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:133 msgid "" diff --git a/luci-app-homeproxy-pro/Makefile b/luci-app-homeproxy-pro/Makefile index 7094af7e2..5659e943e 100644 --- a/luci-app-homeproxy-pro/Makefile +++ b/luci-app-homeproxy-pro/Makefile @@ -7,6 +7,29 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64 (sing-box 1.14) LUCI_PKGARCH:=all + +# `cron` is a real dependency, not a convenience: runtime/service.sh writes two +# entries into /etc/crontabs/root and calls `/etc/init.d/cron restart` +# (hp_sync_resource_cron, hp_sync_autoupdate_cron, hp_clear_autoupdate_cron), +# and the resource-list refresh and the subscription update only ever run from +# those entries. Without the package the writes still succeed - a crontab file +# is just a file - so the router looks fine while neither the China lists nor +# the subscriptions are ever refreshed again, and the only trace is a Warning +# in the log from the failed `cron restart`. +# +# DO NOT add `+cron` back. There is no package by that name: ImmortalWrt +# 25.12.2's feeds carry 11 496 packages and none of them is called `cron`, and +# /etc/init.d/cron is a file of *busybox* (whose apk metadata provides only +# `busybox-any`). An unsatisfiable dependency is not a missing-feature warning, +# it makes the package impossible to install at all - apk refuses to select it, +# and forcing the install (--force-broken-world) PURGES the installed package +# instead. Measured on 192.168.1.1: r44 with `+cron` could not be installed, +# and the forced attempt removed the running package and left the router +# unproxied until r44 (built before that line existed) was put back. +# +# The dependency the original change was reaching for is real - the cron entries +# are load-bearing - but it is satisfied by busybox's crond, which every image +# in this family ships and which cannot be removed without removing busybox. LUCI_DEPENDS:= \ +sing-box \ +firewall4 \ @@ -18,7 +41,7 @@ LUCI_DEPENDS:= \ PKG_NAME:=luci-app-homeproxy-pro PKG_VERSION:=28.10.1.14 -PKG_RELEASE:=21 +PKG_RELEASE:=22 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 bf27dc6ad..a390d72e2 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 @@ -984,6 +984,52 @@ return baseclass.extend({ .replace(/[<>]/g, ''); }, + /* Repair node references that point at a section which no longer exists. + * + * Deleting a node from the grid stages a plain `uci.remove`; nothing + * rewrites `config.main_node` / `main_udp_node` / the urltest member lists, + * and the generator then dies on every later run with + * + * cannot build main-out: the node it refers to does not exist (it may + * have been deleted, or the reference is stale). + * + * so the reload aborts (the running configuration is kept) while the UI + * reports the save as applied - measured on the device: generation exit 254 + * with exactly that message. `nil` and `urltest` are not section names, and + * a reference that still resolves is left alone. + * + * Takes the cursor as an argument so this is testable without a form; + * returns the list of repairs it made. + */ + repairNodeRefs(uci, cfg) { + const repaired = []; + + for (let opt of [ 'main_node', 'main_udp_node' ]) { + const ref = uci.get(cfg, 'config', opt); + + if (ref && ref !== 'nil' && ref !== 'urltest' && uci.get(cfg, ref) == null) { + uci.set(cfg, 'config', opt, 'nil'); + repaired.push(opt); + } + } + + for (let opt of [ 'main_urltest_nodes', 'main_udp_urltest_nodes' ]) { + const list = uci.get(cfg, 'config', opt); + + if (!Array.isArray(list) || !list.length) + continue; + + const kept = list.filter((id) => uci.get(cfg, id) != null); + + if (kept.length !== list.length) { + uci.set(cfg, 'config', opt, kept); + repaired.push(opt); + } + } + + return repaired; + }, + loadModalTitle(title, addtitle, uciconfig, ucisection) { let label = uci.get(uciconfig, ucisection, 'label'); return label ? title + ' » ' + this.escapeTitleText(label) : addtitle; diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/node.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/node.js index fce346d54..57034495a 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/node.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/node.js @@ -84,7 +84,12 @@ function renderNodeSettings(section, data, features, main_node, routing_mode) { o.depends({'type': 'shadowtls', 'shadowtls_version': '2'}); o.depends({'type': 'shadowtls', 'shadowtls_version': '3'}); o.depends({'type': 'socks', 'socks_version': '5'}); - o.validate = hp.validatePassword([ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ]); + /* hysteria2 and tuic carry a password in REQUIRED_CREDENTIALS too, and + * leaving them out here let an empty one be saved - after which the whole + * side's generation dies (OutboundFactory.create() calls die() on it) and + * the user only sees "my setting did not take". Pinned against the + * backend table by tests/frontend-protocol-inventory.js. */ + o.validate = hp.validatePassword([ 'anytls', 'hysteria2', 'shadowsocks', 'shadowtls', 'snell', 'trojan', 'tuic' ]); o.modalonly = true; /* Direct config */ @@ -581,6 +586,22 @@ return view.extend({ m = new form.Map('homeproxy-pro', _('Edit nodes')); + /* Every save of this map can commit a node reference the grid's delete + * button just made dangling - including the page footer's Save & Apply, + * which calls map.save() itself and never passes through the buttons + * below. Repair first; see hp.repairNodeRefs(). */ + const map_save = (typeof m.save === 'function') ? m.save.bind(m) : null; + + if (map_save) + m.save = function() { + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + + if (repaired.length) + console.warn('homeproxy-pro: repaired dangling node reference(s): ' + repaired.join(', ')); + + return map_save.apply(null, arguments); + }; + s = m.section(form.NamedSection, 'subscription', 'homeproxy-pro'); /* Node settings start */ diff --git a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/server.js b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/server.js index c7feccf7a..e4f91c935 100644 --- a/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/server.js +++ b/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/server.js @@ -224,7 +224,10 @@ return view.extend({ o.depends('type', 'snell'); o.depends('type', 'trojan'); o.depends('type', 'tuic'); - o.validate = hp.validatePassword([ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ]); + /* Same list, same reason as the node form's: an empty password on + * hysteria2/tuic makes InboundFactory.create() die() and the server side + * never generates. */ + o.validate = hp.validatePassword([ 'anytls', 'http', 'hysteria2', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan', 'tuic' ]); o.modalonly = true; /* AnyTLS config */ 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 5d6d72520..acf504556 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 @@ -277,6 +277,17 @@ function load_routing(uci) { /* access_control is two single sections: `control` (lan_proxy_mode, * wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */ function load_access_control(uci) { + /* Normalised to an array. uci.get() hands a single-value option over as a + * *string* and a list as an array, and `for (let url in )` iterates + * zero times in ucode - so a URL written with `uci set` instead of + * `add_list` (or carried over from an older config) made the whole + * subscription update a silent no-op: exit 0, no log, nothing fetched. + * Measured on the device: list -> array, one iteration; option -> string, + * zero. */ + const sub_urls_raw = uci.get(UCICONFIG, SECTION.subscription, 'subscription_url'); + const sub_urls = sub_urls_raw == null ? [] + : (type(sub_urls_raw) === 'array' ? sub_urls_raw : [ sub_urls_raw ]); + return { control: load_settings(uci, SECTION.control, [ 'bind_interface', 'lan_proxy_mode' @@ -290,7 +301,13 @@ function load_access_control(uci) { 'packet_encoding', 'update_via_proxy', 'filter_nodes', 'user_agent' ]), - subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [], + /* Normalised to an array. uci.get() hands a single-value option over as a + * *string* and a list as an array, and `for (let url in )` iterates + * zero times in ucode - so a URL written with `uci set` instead of + * `add_list` (or migrated from an older config) made the whole subscription + * update a silent no-op: exit 0, no log, nothing fetched. Measured on the + * device: list -> array, one iteration; option -> string, zero. */ + subscription_urls: sub_urls, filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || [] }; } 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 dec368b8a..bf27f9de5 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 @@ -282,12 +282,22 @@ const v6_warn_comment = ((ipv6_support === '1') && !cn_ipv6_ready) let self_mark, redirect_port, tproxy_port, tproxy_mark, - tun_name, tun_mark; + tun_name, tun_mark, tun_self_mark; if (match(proxy_mode, /redirect/)) { self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100')); redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331'); } +/* Whether UDP can actually be proxied in this configuration: there is a + * UDP-capable node, or the user took the decision over with custom rules. + * The UDP tproxy chain already uses exactly this condition, and the QUIC reject + * has to agree with it - it keyed off `routing_mode !== 'custom'` only, so a + * router with `main_udp_node` set still rejected QUIC on UDP 443 (measured on + * the device: 1722 QUIC packets rejected in one sample) and browsers that had + * learned HTTP/3 fell back to TCP needlessly. Now the reject is emitted only + * when UDP really is not proxyable. */ +const udp_proxyable = (outbound_udp_node !== 'nil' || routing_mode === 'custom'); + if (match(proxy_mode, /tproxy/)) if (outbound_udp_node !== 'nil' || routing_mode === 'custom') { tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332'); @@ -296,6 +306,12 @@ if (match(proxy_mode, /tproxy/)) if (match(proxy_mode, /tun/)) { tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0'); tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_mark('102'); + /* The mark sing-box stamps on its own outbound sockets in tun mode + * (generator: route.default_mark, same UCI option as self_mark elsewhere). + * The TUN output chain has to return on it, otherwise the tunnel captures + * sing-box's own dial - including the one to the node - and the only guard + * it had was `iifname `, which cannot match on the output hook. */ + tun_self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100')); } const control_options = [ @@ -312,6 +328,98 @@ const control_info = {}; for (let i in control_options) control_info[i] = uci.get(cfgname, 'control', i); +/* Devices of the wan zone, resolved by fw4 itself. + * + * The intercept layer must not cover the wan side. When the user names + * interfaces the guards below use exactly those; when the option is empty the + * UI says "all", and "all" used to include wan - so wan-inbound traffic to the + * router's own address on a routing_port was captured by the redirect / tproxy + * jump and by the DNS hijack as well. It happened to be masked on the device + * (dnsmasq's own dns_redirect suppressed the DNS half, fw4's input policy + * dropped the rest), but the hole was structural: any wan-side service on one + * of the routing ports would have been hijacked. Empty now means "everything + * except the wan zone". + * + * fw4's own ruleset template renders zone devices from `zone.match_devices`, + * so this asks the same source. A render outside fw4 (the off-target test + * harness has no zone state) leaves the list empty and the guards below fall + * back to the previous behaviour. */ +let wan_devices = []; + +try { + /* fw4.load() is what resolves a zone's *logical* networks to the devices + * that actually appear in `iifname` - the wan zone is `network wan` but + * the device is `pppoe-wan`, and this template runs OUTSIDE fw4's own + * process, where the module has no state at all. Two wrong answers were + * measured on the device before this: + * + * - no load at all: `zones()` is null, the loop threw, the catch left + * the list empty and the guard silently disappeared from the rendered + * ruleset (that is the state this package shipped in r46); + * - fw4.read_state(): parses /var/run/fw4.state, which carries the + * LOGICAL names ("wan", "wan_6"), so the guard rendered as + * `meta iifname { wan }` and could never match - a guard that looks + * present and does nothing. + * + * load() builds the model through ubus the same way fw4 itself does, and + * yields match_devices = [ "pppoe-wan" ]. It is read-only, ~tens of ms, + * and its warnings go to stderr, which the caller discards. */ + fw4.load(); + + for (let zone in (fw4.zones() || [])) + if (zone.name === 'wan') + for (let device in zone.match_devices) + push(wan_devices, device); +} +catch (e) { } + +wan_devices = sort(uniq(wan_devices)); + +/* Validated, nft-ready form of every UCI list, computed once. + * + * The template used to gate on the *raw* UCI value and interpolate the + * validator-backed helper. A list that is non-empty but has no usable entry - + * hand-edited UCI, an address in the wrong family - therefore rendered + * `ip daddr counter return`, an nft syntax error that fails the whole fw4 + * transaction, or `elements = { }`. Gating on the helper's result instead + * means an unusable list simply emits no rule. The rendered output for a + * usable configuration is unchanged (the helpers already dropped invalid + * entries one by one). */ +const NFTARR_FIELDS = { + listen_interfaces: iface_to_nftarr, + lan_proxy_ipv4_ips: ipv4_to_nftarr, + lan_proxy_ipv6_ips: ipv6_to_nftarr, + lan_proxy_mac_addrs: mac_to_nftarr, + lan_direct_ipv4_ips: ipv4_to_nftarr, + lan_direct_ipv6_ips: ipv6_to_nftarr, + lan_direct_mac_addrs: mac_to_nftarr, + lan_gaming_mode_ipv4_ips: ipv4_to_nftarr, + lan_gaming_mode_ipv6_ips: ipv6_to_nftarr, + lan_gaming_mode_mac_addrs: mac_to_nftarr, + lan_global_proxy_ipv4_ips: ipv4_to_nftarr, + lan_global_proxy_ipv6_ips: ipv6_to_nftarr, + lan_global_proxy_mac_addrs: mac_to_nftarr, + wan_proxy_ipv4_ips: ipv4_to_nftarr, + wan_proxy_ipv6_ips: ipv6_to_nftarr, + wan_direct_ipv4_ips: ipv4_to_nftarr, + wan_direct_ipv6_ips: ipv6_to_nftarr +}; +const nftarr = {}; + +for (let field in NFTARR_FIELDS) + nftarr[field] = NFTARR_FIELDS[field](control_info[field] || []); + +/* The local-output chain also exempts `lo`, which the tproxy guard adds by + * hand. Same precomputation, so the guard cannot disagree with the list. */ +nftarr.listen_interfaces_lo = iface_to_nftarr([ ...(control_info.listen_interfaces || []), 'lo' ]); + +/* The routing-port filter. Gating on the raw option while interpolating + * ports_to_nftarr() had the same defect, and there the fallback direction + * matters: an empty rendering would make `tcp dport != @homeproxy_routing_port` + * match everything and send all TCP traffic direct. An unusable list + * therefore disables the filter (ports are proxied) instead of widening it. */ +const routing_port_set = routing_port ? ports_to_nftarr(routing_port) : null; + const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1'; const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0', /* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port, @@ -417,8 +525,8 @@ set homeproxy_wan_proxy_addr_v4 { type ipv4_addr flags interval auto-merge -{% if (control_info.wan_proxy_ipv4_ips): %} - elements = { {{ ipv4_to_nftarr(control_info.wan_proxy_ipv4_ips) }} } +{% if (nftarr.wan_proxy_ipv4_ips): %} + elements = { {{ nftarr.wan_proxy_ipv4_ips }} } {% endif %} } @@ -427,8 +535,8 @@ set homeproxy_wan_proxy_addr_v6 { type ipv6_addr flags interval auto-merge -{% if (control_info.wan_proxy_ipv6_ips): %} - elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} } +{% if (nftarr.wan_proxy_ipv6_ips): %} + elements = { {{ nftarr.wan_proxy_ipv6_ips }} } {% endif /* wan_proxy_ipv6_ips*/ %} } {% endif /* v6_handled */ %} @@ -437,8 +545,8 @@ set homeproxy_wan_direct_addr_v4 { type ipv4_addr flags interval auto-merge -{% if (control_info.wan_direct_ipv4_ips): %} - elements = { {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} } +{% if (nftarr.wan_direct_ipv4_ips): %} + elements = { {{ nftarr.wan_direct_ipv4_ips }} } {% endif %} } @@ -447,18 +555,18 @@ set homeproxy_wan_direct_addr_v6 { type ipv6_addr flags interval auto-merge -{% if (control_info.wan_direct_ipv6_ips): %} - elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} } +{% if (nftarr.wan_direct_ipv6_ips): %} + elements = { {{ nftarr.wan_direct_ipv6_ips }} } {% endif /* wan_direct_ipv6_ips */ %} } {% endif /* v6_handled */ %} -{% if (routing_port): %} +{% if (routing_port_set): %} set homeproxy_routing_port { type inet_service flags interval auto-merge - elements = { {{ ports_to_nftarr(routing_port) }} } + elements = { {{ routing_port_set }} } } {% endif %} @@ -501,8 +609,10 @@ set homeproxy_node_addr_v6 { {# DNS hijack & TCP redirect #} chain dstnat { {% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %} - {% if (control_info.listen_interfaces): %} - meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }} + {% if (nftarr.listen_interfaces): %} + meta iifname {{ nftarr.listen_interfaces }} + {%- elif (!isEmpty(wan_devices)): %} + meta iifname != {{ array_to_nftarr(wan_devices) }} {%- endif /* listen_interfaces */ %} meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack" {% endif /* dns_hijacked */ %} @@ -518,37 +628,39 @@ chain homeproxy_redirect_proxy { } chain homeproxy_redirect_proxy_port { - {% if (routing_port): %} + {% if (routing_port_set): %} tcp dport != @homeproxy_routing_port counter return {% endif %} goto homeproxy_redirect_proxy } chain homeproxy_redirect_lanac { - {% if (control_info.listen_interfaces): %} - meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return + {% if (nftarr.listen_interfaces): %} + meta iifname != {{ nftarr.listen_interfaces }} counter return + {% elif (!isEmpty(wan_devices)): %} + meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} meta mark {{ self_mark }} counter return {% if (control_info.lan_proxy_mode === 'listed_only'): %} - {% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect + {% if (nftarr.lan_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_redirect {% endif /* lan_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect + {% if (nftarr.lan_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_redirect {% endif /* lan_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect + {% if (nftarr.lan_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_redirect {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} - {% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return + {% if (nftarr.lan_direct_ipv4_ips): %} + ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return + {% if (nftarr.lan_direct_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_direct_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return + {% if (nftarr.lan_direct_mac_addrs): %} + ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} @@ -578,14 +690,14 @@ chain homeproxy_redirect { {% endif %} {% if (routing_mode !== 'custom'): %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port + {% if (nftarr.lan_global_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port + {% if (nftarr.lan_global_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port + {% if (nftarr.lan_global_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_redirect_proxy_port {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} @@ -594,6 +706,11 @@ chain homeproxy_redirect { ip6 daddr @homeproxy_wan_direct_addr_v6 counter return {% endif /* v6_handled */ %} + {# No QUIC reject here: this whole block only renders when UDP is + proxyable (see the gate above), so rejecting QUIC on UDP 443 would + break HTTP/3 for traffic that can be proxied. The TUN block below, + which renders either way, carries the reject when UDP cannot be + proxied. #} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} ip daddr != @homeproxy_gfw_list_v4 counter return {% if (v6_handled): %} @@ -611,14 +728,14 @@ chain homeproxy_redirect { {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy + {% if (nftarr.lan_gaming_mode_ipv4_ips): %} + ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy + {% if (nftarr.lan_gaming_mode_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy + {% if (nftarr.lan_gaming_mode_mac_addrs): %} + ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_redirect_proxy {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_redirect_proxy_port @@ -640,45 +757,47 @@ chain homeproxy_mangle_tproxy { } chain homeproxy_mangle_tproxy_port { - {% if (routing_port): %} + {% if (routing_port_set): %} udp dport != @homeproxy_routing_port counter return {% endif %} goto homeproxy_mangle_tproxy } chain homeproxy_mangle_mark { - {% if (routing_port): %} + {% if (routing_port_set): %} udp dport != @homeproxy_routing_port counter return {% endif %} meta l4proto udp meta mark set {{ tproxy_mark }} counter accept } chain homeproxy_mangle_lanac { - {% if (control_info.listen_interfaces): %} - meta iifname != {{ iface_to_nftarr([...control_info.listen_interfaces, 'lo']) }} counter return + {% if (nftarr.listen_interfaces): %} + meta iifname != {{ nftarr.listen_interfaces_lo }} counter return + {% elif (!isEmpty(wan_devices)): %} + meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} meta iifname != lo udp dport 53 counter return meta mark {{ self_mark }} counter return {% if (control_info.lan_proxy_mode === 'listed_only'): %} - {% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting + {% if (nftarr.lan_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting + {% if (nftarr.lan_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting + {% if (nftarr.lan_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_prerouting {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} - {% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return + {% if (nftarr.lan_direct_ipv4_ips): %} + ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return + {% if (nftarr.lan_direct_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_direct_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return + {% if (nftarr.lan_direct_mac_addrs): %} + ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} @@ -706,14 +825,14 @@ chain homeproxy_mangle_prerouting { {% endif %} {% if (routing_mode !== 'custom'): %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port + {% if (nftarr.lan_global_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port + {% if (nftarr.lan_global_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port + {% if (nftarr.lan_global_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tproxy_port {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} @@ -727,15 +846,11 @@ chain homeproxy_mangle_prerouting { {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} - udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} - {% if (routing_mode !== 'custom'): %} - udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" - {% endif /* routing_mode */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} @@ -743,14 +858,14 @@ chain homeproxy_mangle_prerouting { {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy + {% if (nftarr.lan_gaming_mode_ipv4_ips): %} + ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy + {% if (nftarr.lan_gaming_mode_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy + {% if (nftarr.lan_gaming_mode_mac_addrs): %} + ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_mangle_tproxy {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_mangle_tproxy_port @@ -816,29 +931,31 @@ chain homeproxy_mangle_lanac { iifname {{ tun_name }} counter return udp dport 53 counter return - {% if (control_info.listen_interfaces): %} - meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return + {% if (nftarr.listen_interfaces): %} + meta iifname != {{ nftarr.listen_interfaces }} counter return + {% elif (!isEmpty(wan_devices)): %} + meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side" {% endif %} {% if (control_info.lan_proxy_mode === 'listed_only'): %} - {% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun + {% if (nftarr.lan_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun + {% if (nftarr.lan_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun + {% if (nftarr.lan_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_tun {% endif /* lan_proxy_mac_addrs */ %} {% elif (control_info.lan_proxy_mode === 'except_listed'): %} - {% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return + {% if (nftarr.lan_direct_ipv4_ips): %} + ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return {% endif /* lan_direct_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return + {% if (nftarr.lan_direct_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return {% endif /* lan_direct_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_direct_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return + {% if (nftarr.lan_direct_mac_addrs): %} + ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return {% endif /* lan_direct_mac_addrs */ %} {% endif /* lan_proxy_mode */ %} @@ -848,7 +965,7 @@ chain homeproxy_mangle_lanac { } chain homeproxy_mangle_tun_mark { - {% if (routing_port): %} + {% if (routing_port_set): %} {% if (proxy_mode === 'tun'): %} tcp dport != @homeproxy_routing_port counter return {% endif /* proxy_mode */ %} @@ -860,6 +977,16 @@ chain homeproxy_mangle_tun_mark { chain homeproxy_mangle_tun { iifname {{ tun_name }} counter return + {% if (tun_self_mark): %} + meta mark {{ tun_self_mark }} counter return comment "!{{ cfgname }}: sing-box's own traffic" + {% endif %} + + {% 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_tun_mark {% if (v6_handled): %} @@ -872,22 +999,22 @@ chain homeproxy_mangle_tun { {% endif %} {% if (routing_mode !== 'custom'): %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark + {% if (nftarr.lan_global_proxy_ipv4_ips): %} + ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark + {% if (nftarr.lan_global_proxy_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark + {% if (nftarr.lan_global_proxy_mac_addrs): %} + ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tun_mark {% endif /* lan_global_proxy_mac_addrs */ %} {% endif /* routing_mode */ %} - {% if (control_info.wan_direct_ipv4_ips): %} - ip daddr {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return + {% if (nftarr.wan_direct_ipv4_ips): %} + ip daddr {{ nftarr.wan_direct_ipv4_ips }} counter return {% endif /* wan_direct_ipv4_ips */ %} - {% if (control_info.wan_direct_ipv6_ips): %} - ip6 daddr {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return + {% if (nftarr.wan_direct_ipv6_ips): %} + ip6 daddr {{ nftarr.wan_direct_ipv6_ips }} counter return {% endif /* wan_direct_ipv6_ips */ %} {% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %} @@ -895,15 +1022,17 @@ chain homeproxy_mangle_tun { {% if (v6_handled): %} ip6 daddr != @homeproxy_gfw_list_v6 counter return {% endif /* v6_handled */ %} + {% if (!udp_proxyable): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" + {% endif /* udp_proxyable */ %} {% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %} ip daddr @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} ip6 daddr @homeproxy_mainland_addr_v6 counter return {% endif /* v6_handled */ %} - {% if (routing_mode !== 'custom'): %} + {% if (!udp_proxyable): %} udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)" - {% endif /* routing_mode */ %} + {% endif /* udp_proxyable */ %} {% elif (routing_mode === 'proxy_mainland_china'): %} ip daddr != @homeproxy_mainland_addr_v4 counter return {% if (v6_handled): %} @@ -911,14 +1040,14 @@ chain homeproxy_mangle_tun { {% endif /* v6_handled */ %} {% endif /* routing_mode */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %} - ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }} + {% if (nftarr.lan_gaming_mode_ipv4_ips): %} + ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_ipv4_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %} - ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }} + {% if (nftarr.lan_gaming_mode_ipv6_ips): %} + ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_ipv6_ips */ %} - {% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %} - ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }} + {% if (nftarr.lan_gaming_mode_mac_addrs): %} + ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter meta mark set {{ tun_mark }} {% endif /* lan_gaming_mode_mac_addrs */ %} counter goto homeproxy_mangle_tun_mark 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 918b4b829..48c6df8b0 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 @@ -164,6 +164,14 @@ function resolve_env(dm) { * lstat() is used because it is unambiguous - there is no second * argument to get wrong - and because the neighbouring stderr-size * check in homeproxy-pro.uc already reads sizes through it. */ + /* The IPv4 half of the same split. Declared (and referenced) only when the + * file is there: the generator used to push china-ip unconditionally, which + * made a fresh install - where hp_prepare_runtime_files only produced the + * file *after* this ran - fail `sing-box check` with a missing rule-set and + * never start. The rule-set is now produced before generation + * (hp_prepare_generated_inputs), and this flag keeps a failed generation + * degrading to "no mainland address split" instead of a dead service. */ + china_ip4_ready: lstat(HP_DIR + '/resources/china_ip4.json') !== null, 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 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 afeb752d7..fc8b7bd7d 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 @@ -67,6 +67,18 @@ export function build_context(dm, env) { const self_mark = match(proxy_mode, /redirect/) ? (dm.infra.self_mark || '100') : null; + /* The same mark, but resolved for every mode. + * + * In TUN mode self_mark is null (see above), so no outbound carries + * `routing_mark` and sing-box's own sockets carried no mark at all - which + * meant the firewall's TUN output chain could not exempt them, and the + * node-address bypass that exists for tproxy was missing for tun. A LAN + * client (or the router itself) dialling the node's own address went back + * into the tunnel; measured earlier for tproxy ("wget hangs on the node's + * address"), fixed there, never for tun. route.default_mark stamps every + * outbound socket with this value, and the TUN chain returns on it. */ + const tun_self_mark = (dm.infra.self_mark || '100'); + /* proxy-mode-derived ports / TUN params */ let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6, tun_mtu, tcpip_stack, endpoint_independent_nat; @@ -137,6 +149,7 @@ export function build_context(dm, env) { * and one of them naming a tag the other never declared fails the * whole config. Default false - an absent env must not turn into an * optimistic "assume the file is there". */ + china_ip4_ready: (env?.china_ip4_ready === true), 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 @@ -170,7 +183,7 @@ export function build_context(dm, env) { * with no initial_path silently declared as source JSON, and a binary * .srs fetched from a URL would then fail to parse at startup. */ ruleset_formats: (env?.ruleset_formats || {}), - self_mark, ntp_server, dns_port, mixed_port, + self_mark, tun_self_mark, ntp_server, dns_port, mixed_port, redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6, tun_mtu, tcpip_stack, endpoint_independent_nat, 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 bbfc58bbd..42e8f2a3b 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 @@ -295,12 +295,27 @@ function append_proxy_dns(config, dm, ctx) { }); } - /* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */ - if (ctx.cn_ip_fallback === '1') { + /* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in). + * Gated on china_ip4_ready as well: the response-match rule below names + * the china-ip tag, and a rule_set list may only name declared tags - + * with the file absent the declaration is skipped too, so emitting the + * reference would make sing-box reject the whole configuration. */ + if (ctx.cn_ip_fallback === '1' && ctx.china_ip4_ready) { + /* The evaluate query goes through main-dns, i.e. through the + * tunnel, and it sits on the cold path of every domain that is + * not in the direct/proxy/china lists - so it is the one DNS step + * that can add a whole round trip to a page load. Emitted bare, + * as it was, its timeout fell back to the 10 s DNS default and it + * carried no ECS; the package's own reference notes call that out + * ("拖慢首包"). A short bound plus the China ECS prefix keeps the + * fallback cheap and lets the upstream answer with a domestic + * address when it has one. */ push(config.dns.rules, { action: 'evaluate', server: 'main-dns', - tag: 'cn-fallback' + tag: 'cn-fallback', + timeout: '2s', + client_subnet: '223.5.5.0/24' }); /* The response is matched by the addresses the upstream returned, * and the address list has to be the one the route half uses for 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 3a040cf11..6d91f145d 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 @@ -116,8 +116,9 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { * 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') { + if (ctx.china_ip4_ready + && (ctx.routing_mode === 'bypass_mainland_china' + || ctx.routing_mode === 'proxy_mainland_china')) { push(config.route.rules, { action: 'resolve', strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null @@ -173,6 +174,14 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { /* The route half of the mode's default policy - the other consumer of * ctx.proxy_fallback, and the reason dns.final and this line are * asserted together per mode. */ + /* TUN mode carries no per-outbound `routing_mark` (self_mark is null there, + * see context.uc), so sing-box's own dials - including the one to the node - + * were unmarked and the firewall's TUN output chain captured them; the + * node-address bypass existed for tproxy only. `default_mark` stamps every + * outbound socket with the same value the firewall's TUN chain exempts. */ + if (match(ctx.proxy_mode, /tun/)) + config.route.default_mark = strToInt(ctx.tun_self_mark); + config.route.final = ctx.proxy_fallback ? 'main-out' : 'direct-out'; /* --- proxy-mode rule_set block ----------------------------------- */ @@ -222,11 +231,19 @@ function build_route_proxy(config, dm, ctx, direct_overrides) { * 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' - }); + /* Declared only when the file is actually there - the same rule the v6 + * and domain halves already follow, and the reason a fresh install no + * longer dies: a `path` that does not exist fails `sing-box check` and + * takes the whole configuration (and the service) with it. The + * rule-set is produced before generation now, so this is the + * belt-and-braces case (a failed generation, a read-only fs). */ + if (ctx.china_ip4_ready) { + push(config.route.rule_set, { + type: 'local', + tag: 'china-ip', + path: HP_DIR + '/resources/china_ip4.json' + }); + } /* 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 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 d3fb820c1..6358551b2 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 @@ -594,9 +594,13 @@ export function wGETVerbose(url, ua) { * base64 inflation. Anything larger is almost certainly an attack or a * misconfiguration. * - * The pipeline does cost the exit status: `system()` returns head's, which - * is always 0. A fetch failure therefore arrives as an empty body plus the - * fetcher's own message on stderr, and that is reported below. */ + * The pipeline costs the exit status - `system()` returns head's, which is + * always 0 - so the fetcher's own status is carried out of it through a + * file (see the command below). Without that, a *truncated* transfer was + * indistinguishable from a complete one: uclient-fetch exits 4 with + * "Connection reset prematurely", and measured on the device a response + * that declared 244 bytes and delivered 80 was handed to the parser as a + * valid subscription body. */ /* The braces matter: executeCommand() appends `>out 2>err` to the command, * and in `a | b >out 2>err` those redirections bind to b only - the * fetcher's stderr would go to the caller's terminal and the failure @@ -607,7 +611,16 @@ export function wGETVerbose(url, ua) { * being waved through on the grounds that it only ever returns a literal: * guard 25 exists to make "is this shell-safe" a mechanical check instead * of a judgement call, and a quoted constant costs nothing. */ - const output = executeCommand(`{ ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {}; + const dir = mkdtemp(); + const rcpath = dir + '/rc'; + const output = executeCommand(`{ { ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)}; echo $? >${shellQuote(rcpath)}; } | head -c ${HP_FETCH_CAP + 1}; }`) || {}; + + /* The fetcher's status, recovered from the file the pipeline wrote it to. + * `head -c` closing the pipe makes the fetcher exit on SIGPIPE (141), which + * is why the size check below runs first: a too-large body is reported as + * too large, not as a signal. */ + const fetch_rc = int(trim(read_capped(rcpath, 32))) || 0; + cleanup_exec_dir(dir, rcpath, dir + '/unused'); let reason = trim(output.stderr || ''); reason = reason ? replace(reason, /\s+/g, ' ') : ''; /* An HTTP-level failure prints the requested URL back into the message @@ -623,11 +636,13 @@ export function wGETVerbose(url, ua) { if (length(output.stdout || '') > HP_FETCH_CAP) return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` }; - if (output.exitcode !== 0) { + const exitcode = (output.exitcode !== 0) ? output.exitcode : fetch_rc; + + if (exitcode !== 0) { if (length(reason) > 200) reason = substr(reason, 0, 200) + '...'; - return { content: null, error: `fetch exited with status ${output.exitcode}: ${reason || 'no error output'}` }; + return { content: null, error: `fetch exited with status ${exitcode}: ${reason || 'no error output'}` }; } /* A binary body is not a failed fetch. @@ -645,7 +660,9 @@ export function wGETVerbose(url, ua) { if (output.binary) return { content: null, error: 'the fetch succeeded but the response is binary, not a subscription payload' }; - /* head() masks the fetcher's status, so a failed fetch shows up here. */ + /* Reached only when the fetcher exited 0 (the status is recovered above), + * so an empty body here means a 200 with nothing in it - reported with the + * fetcher's own message rather than as a parse failure. */ if (!length(trim(output.stdout)) && reason) { if (length(reason) > 200) reason = substr(reason, 0, 200) + '...'; 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 45b280fbf..e7c44eec3 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 @@ -262,24 +262,26 @@ hp_rule_set_current() { [ -f "$out" ] && [ -s "$out" ] && [ "$out" -nt "$src" ] } -# hp_prepare_runtime_files -# Create the mode-specific working files, truncate the instance logs and hand -# every runtime file to the sing-box user. / are "1"/"0". -hp_prepare_runtime_files() { +# hp_prepare_generated_inputs +# Produce every rule-set the generated configuration references, BEFORE the +# generator runs. +# +# Why before: `sing-box check` opens every local rule_set path, and the client +# generator declares china-ip from china_ip4.json (route.uc, gated on +# china_ip4_ready). When these files were produced *after* the generation step +# (they used to live at the end of hp_prepare_runtime_files), a fresh install had +# no china_ip4.json at generation time, `sing-box check` failed, and the client +# could not start at all - the reload preflight aborts before the rule-set step, +# so it could not heal itself either. Measured on the device: generation exit=1 +# with "parse rule-set[0]: open .../china_ip4.json: no such file or directory", +# and exit=0 once the three files had been produced. +# +# Cheap when nothing changed: hp_rule_set_current skips an unchanged source. +hp_prepare_generated_inputs() { local hp_dir="$1" - local run_dir="$2" - local routing_mode="$3" - local client_enabled="$4" - local server_enabled="$5" + local client_enabled="$2" - # The route rules that keep mainland destinations direct match a local - # rule-set generated from china_ip4.txt, the same list the firewall - # renders its nft set from, so both sides decide from one source. It is - # regenerated here as well as by the resource updater: an install that - # predates this file (or a list replaced while the service was stopped) - # must still get one, or the client config would reference a missing - # file and sing-box would refuse to start. A missing source list is not - # an error - the generator then emits no route-side rule at all. + # china-ip4: the route rule keeping mainland IPv4 destinations direct. if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then if hp_rule_set_current "$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json"; then # Unchanged source: keep the file, but re-assert ownership anyway - @@ -295,16 +297,14 @@ hp_prepare_runtime_files() { fi fi - # IPv6 half of the same arrangement, and the only place the "IPv6 is on - # but cannot be classified" condition is reported in a place a user reads - # without tcpdump. geoip-cn.srs and china_ip4.json are both IPv4-only, so - # without a v6 rule-set a mainland destination reached over IPv6 matches - # no route rule at all and falls through to `final` - i.e. to the proxy. - # The firewall would normally have returned it first, but that set is - # filled from the same china_ip6.txt, so a list that is missing or has no - # usable prefix leaves neither side able to classify it. Say so plainly - # instead: the generated ruleset also carries the same text as a rule - # comment, so `nft list ruleset` shows the degraded state too. + # china-ip6, plus the only user-facing report of "IPv6 is on but cannot be + # classified". Neither geoip-cn.srs nor china_ip4.json carries IPv6, so + # without a v6 rule-set a mainland destination reached over IPv6 matches no + # route rule and falls through to `final`; the firewall's v6 set is filled + # from this same china_ip6.txt, so a missing or prefixless list leaves + # neither side able to classify it. cn_ipv6 counts usable prefixes first - + # the generator needs at least one, so a zero-count list must skip straight + # to the warning rather than call ucode and log a second failure. local ipv6_support cn_ipv6 config_get_bool ipv6_support "config" "ipv6_support" "0" cn_ipv6=0 @@ -320,10 +320,6 @@ hp_prepare_runtime_files() { chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \ || log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box." else - # Only reached when the source is unusable, which china_ip_ruleset.uc - # reports as "no usable CIDR entries". Keep the previous file: an - # empty one would make the route-side set match nothing, which is the - # same silent inversion this whole branch exists to prevent. log "Warning: could not generate ${hp_dir}/resources/china_ip6.json; the route side keeps the previous list." fi fi @@ -332,17 +328,7 @@ 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. + # china-domain: the DNS half, sending the list's domains to china-dns. if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then if hp_rule_set_current "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json"; then chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \ @@ -352,11 +338,55 @@ hp_prepare_runtime_files() { 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 +} + +# hp_prepare_runtime_files +# Create the mode-specific working files, truncate the instance logs and hand +# every runtime file to the sing-box user. / are "1"/"0". +# +# The three China rule-sets below (china-ip4, china-ip6, china-domain) share one +# arrangement, and the reasons for it are stated once here rather than three +# times in the branches: +# +# * Each is generated locally from a .txt the resource updater maintains, not +# downloaded as an upstream .srs (geoip-cn.srs / geosite-geolocation-cn.srs), +# so a cold start has nothing to download before the inbounds bind. The +# firewall reads the same .txt for its nft set, so both halves of the split +# decide from one source and cannot drift. +# * Generated here as well as by the updater: an install that predates the +# .json, or a list replaced while the service was stopped, must still get +# one - otherwise the generated config names a file that does not exist and +# `sing-box check` refuses the start. +# * A missing source .txt is NOT an error. The generator then emits no +# route-side / DNS-side rule for it and the split degrades to the default, +# rather than failing the start. +# * A FAILED generation keeps the previous .json rather than writing an empty +# one. This is the trap all three branches exist to avoid: an empty +# rule-set matches nothing, so a mainland destination silently falls through +# to `final` (i.e. the proxy) and the mode does the exact opposite of what it +# promises - an inversion that produces no error anywhere. Both helper +# scripts (china_ip_ruleset.uc / domain_ruleset.uc) refuse to write an empty +# rule-set and exit non-zero, which is what the else-branches log. +# * The generated rule-set also carries this same text as a rule comment, so +# `nft list ruleset` shows the degraded state too. +# +# NOTE: the three China rule-sets are produced by hp_prepare_generated_inputs(), +# which runs BEFORE the generator (see init.d) - this function no longer creates +# them; it only re-asserts them for a caller that arrives here directly. +hp_prepare_runtime_files() { + local hp_dir="$1" + local run_dir="$2" + local routing_mode="$3" + local client_enabled="$4" + local server_enabled="$5" + + # The rule-sets the generated configuration references. Also called before + # generation (init.d), which is what makes a fresh install work; idempotent + # and cheap when the sources did not change. + hp_prepare_generated_inputs "$hp_dir" "$client_enabled" # cache_file is enabled for bypass_mainland_china and custom alike # (generator/common.uc:attachExperimental). The file has to exist before @@ -657,6 +687,7 @@ hp_start_generated_config() { fi ucode -S "$generator" 2>>"$LOG_PATH" + gen_rc=$? # The generator is transactional on its own: it writes a temporary file # and only renames it over the live one once `sing-box check` accepted @@ -666,6 +697,12 @@ hp_start_generated_config() { hp_ensure_live "$live" "$good" case "$?" in 0) + # A live file exists, but it may be the *previous* configuration: a + # failed generation leaves it in place. That used to be reported as a + # plain success with nothing in the log - `start` on a service whose + # UCI had gone invalid returned 0 while the old file kept running. + [ "$gen_rc" = "0" ] \ + || log "Warning: could not regenerate the ${label} configuration (generator exit ${gen_rc}); the previous configuration stays in place." ;; 1) log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;; diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/subscription/repository.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/subscription/repository.uc index 281243adc..7dfc2f151 100644 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/subscription/repository.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/subscription/repository.uc @@ -78,6 +78,74 @@ import { flatten } from '../parser/flatten.uc'; * * Returns the { added, removed } counts. No commit here: the caller's * single commit is the boundary of "this run wrote something". */ +/* --- content identity ---------------------------------------------------- */ + +/* A fingerprint of a node's *content*: the same server with the same + * credentials and options, whatever it is called. + * + * Section names are md5(grouphash + label), so a subscription that renames a + * node used to make the repository delete the old section and create a new one + * - and every reference to it (`main_node`, `main_udp_node`, the urltest lists) + * was switched away or dropped along with it, silently. The label is not part + * of a node's identity, so it is excluded here along with the section metadata + * and the group hash (the group is already the lookup key). + * + * Keys are sorted so the fingerprint cannot depend on insertion order, and the + * values are serialized with %J because UCI options can be lists. */ +function content_key(flat) { + const parts = []; + + for (let k in sort(keys(flat))) { + if (k === 'label' || k === 'grouphash' || substr(k, 0, 1) === '.') + continue; + push(parts, k + '=' + sprintf('%J', flat[k])); + } + + return md5(join('\n', parts)); +} + +/* The section name the orchestrator keyed this node under. + * + * Normally md5(grouphash + label). When two servers of one subscription share + * a label the orchestrator disambiguates the key with the content fingerprint, + * and the section has to follow it - otherwise both nodes are written into the + * same section and one of them is lost. Returns null for a node the caller did + * not put in the cache (the unit tests hand nodes in directly), which keeps the + * previous behaviour as the fallback. */ +function section_name_for(node, cache) { + if (!cache) + return null; + + for (let key in keys(cache)) + if (cache[key] === node) + return key; + + return null; +} + +/* group hash -> { content_key: canonical node }, built once and only when a + * section was not found by name: a run without renames pays nothing. */ +function build_content_index(node_cache) { + const index = {}; + + for (let group in keys(node_cache)) { + const group_index = index[group] = {}; + const done = {}; + const cache = node_cache[group]; + + for (let key in keys(cache)) { + const node = cache[key]; + + if (done[node]) + continue; + done[node] = true; + group_index[content_key(flatten(node))] = node; + } + } + + return index; +} + function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) { let added = 0, removed = 0; /* Mark the canonical Node objects the foreach loop above has @@ -89,6 +157,9 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) { * object means a stale string key cannot match a renamed node. */ const seen = {}; + /* Built on the first section that is not found by name (see below). */ + let content_index = null; + uci.foreach(uciconfig, ucinode, (cfg) => { /* User-created nodes do not have a grouphash. The * repository must never touch them, even if the @@ -103,7 +174,31 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) { if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0) return null; - const incoming = node_cache[cfg.grouphash][cfg['.name']]; + let incoming = node_cache[cfg.grouphash][cfg['.name']]; + + if (!incoming) { + /* Not found by section name: the node may simply have been + * renamed upstream. Section names hash the label, so without + * this the repository would delete the section and add an + * identical one under a new name, taking every reference to it + * (`main_node`, `main_udp_node`, urltest members) with it - the + * user's node selection silently moves to another server. */ + if (content_index === null) + content_index = build_content_index(node_cache); + + const key = content_key(cfg); + + incoming = content_index[cfg.grouphash][key]; + + if (incoming) { + /* Claim it: two stored sections with the same content must + * not both update themselves into the same node, or the + * duplicate would never be pruned. */ + delete content_index[cfg.grouphash][key]; + log(sprintf('Node was renamed upstream: %s -> %s; keeping its section and every reference to it.', + cfg.label || cfg['.name'], incoming.label || incoming.name)); + } + } if (!incoming) { uci.delete(uciconfig, cfg['.name']); @@ -154,7 +249,12 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) { * identically - four nodes collapsed into one section mixing * several protocols' options. Accept either. */ const label = node.label || node.name; - const nameHash = md5(node.grouphash + label); + + /* The orchestrator's key is the section name: identical to + * md5(grouphash + label) for every node but the ones whose + * label is shared by a different server. */ + const nameHash = section_name_for(node, node_cache[node.grouphash]) + || md5(node.grouphash + label); /* The one boundary where a canonical Node becomes UCI keys. * @@ -324,8 +424,48 @@ function scrub_stale_urltest_refs(uci, uciconfig, log) { return { changed }; } +/* Remove subscription nodes whose group is no longer configured at all. + * + * apply_nodes() deliberately leaves a group alone when the fetch produced + * nothing for it - that is the transient-blip protection (a failed fetch must + * not delete the user's nodes). The same "no cache entry" state also arises + * when the user deletes the subscription URL from the configuration, and then + * the nodes stayed in /etc/config/homeproxy-pro forever, still compiled into the + * sing-box outbounds and urltest pools while the UI showed them as ordinary + * nodes. Only the orchestrator knows which URLs are still configured, so the + * set is passed in explicitly: a group that is configured but produced no cache + * is kept (blip), a group that is not configured is pruned (subscription + * removed). + * + * A section without a grouphash is a user-created node and is never touched. + * Returns the number of pruned sections. + */ +function prune_orphan_nodes(uci, uciconfig, ucinode, configured_groups, log) { + let pruned = 0; + const configured = {}; + + for (let group in configured_groups) + configured[group] = true; + + uci.foreach(uciconfig, ucinode, (cfg) => { + if (!cfg.grouphash) + return null; + + if (configured[cfg.grouphash]) + return null; + + uci.delete(uciconfig, cfg['.name']); + pruned++; + log(sprintf('Removing node of a removed subscription: %s.', cfg.label || cfg['.name'])); + return null; + }); + + return pruned; +} + export const Repository = { apply_nodes, apply_main_node_refs, - scrub_stale_urltest_refs + scrub_stale_urltest_refs, + prune_orphan_nodes }; \ No newline at end of file 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 40902fe06..4850bbaa1 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 @@ -115,7 +115,13 @@ check_list_update() { # --header takes '=': the space-separated form is rejected, and the value # has to stay ONE argv element, which the quoting around it preserves. - local list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")" + # `local x="$(...)"` returns the status of `local`, not of the command + # substitution - measured on the device: busybox ash gives 0 even when the + # fetch failed. That made the branch below dead and reported a network + # failure as "Failed to get the latest version, please retry later", which + # loses the difference between "could not fetch" and "fetched, got nothing". + local list_info + list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")" local fetch_exit=$? if [ $fetch_exit -ne 0 ]; then @@ -278,10 +284,27 @@ case "$1" in # script is exercised off-device where a non-busybox sed fails it with # "invalid command code". Edit through a temp file, the way the crontab # helper in runtime/service.sh does. - check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \ - sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" && \ - mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt" || \ - rm -f "$RESOURCES_DIR/china_list.txt.hp-new" + # The status has to survive the post-processing. As one &&...|| chain it + # did not: when check_list_update returned 3 ("already current") or 1 + # (fetch failed) the chain short-circuited into `rm -f`, whose status 0 + # became the script's - so update_resources_cron.sh counted "no change" as + # a change and reloaded the service every single day, and the LuCI button + # reported "Successfully updated." for a fetch that never happened. + # Measured on the device: china_list exited 0 for both the failure and the + # up-to-date case, while china_ip4 exited 1 / 3 correctly. + check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" + rc=$? + [ "$rc" = "0" ] || exit "$rc" + + # 0 means a new list was just installed; strip the `full:` prefixes and the + # colon-carrying entries before anything else reads it. + if sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" \ + && mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt"; then + exit 0 + fi + rm -f "$RESOURCES_DIR/china_list.txt.hp-new" + log "[CHINA_LIST] Failed to normalise the downloaded list; leaving it as downloaded." + exit 1 ;; *) printf '%s\n' "Usage: $0 " diff --git a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_subscriptions.uc b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_subscriptions.uc index d4a5c9fb8..6efe67cfc 100755 --- a/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_subscriptions.uc +++ b/luci-app-homeproxy-pro/root/etc/homeproxy-pro/scripts/update_subscriptions.uc @@ -196,6 +196,13 @@ function release_lock() { function main() { const node_cache = {}; + + /* Duplicate detection, kept apart from node_cache on purpose: node_cache + * is keyed by *section name* now (the Repository uses that key as the + * section it writes), so folding a content fingerprint into it would make + * the fingerprint look like a section. */ + const conf_seen = {}; + const name_seen = {}; const node_result = []; const ubus = connect(); @@ -217,6 +224,8 @@ function main() { url = replace(url, /#.*$/, ''); const groupHash = md5(url); node_cache[groupHash] = {}; + conf_seen[groupHash] = {}; + name_seen[groupHash] = {}; /* Keep the lock's mtime current for as long as this run is alive. * The stale window (LOCK_STALE) is what lets a later run reclaim a @@ -250,7 +259,7 @@ function main() { if (filter_check(flat.label, filter_mode, filter_keywords, log)) log(sprintf('Skipping blacklist node: %s.', flat.label)); - else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash]) + else if (conf_seen[groupHash][confHash]) log(sprintf('Skipping duplicate node: %s.', flat.label)); else { /* normalize() first: the canonical Node is the only @@ -283,9 +292,28 @@ function main() { apply_policy(node_canonical, { allow_insecure, packet_encoding }); + /* Two different servers can carry the same label - it is + * whatever the provider put after '#' - and section names + * hash the label, so the second one used to be dropped + * here ("Skipping duplicate node") and, even if it were + * let through, would have been written into the first + * one's section. Disambiguate with the content + * fingerprint: the first keeps md5(group + label) (so + * existing sections and every reference to them survive + * an upgrade), the collision gets a key of its own. */ + let section_key = nameHash; + + if (name_seen[groupHash][nameHash]) { + section_key = md5(groupHash + label + '\x00' + confHash); + log(sprintf('Two servers are labelled %s; keeping both (section %s).', + flat.label, section_key)); + } + + conf_seen[groupHash][confHash] = true; + name_seen[groupHash][nameHash] = true; + push(node_result, [ node_canonical ]); - node_cache[groupHash][confHash] = node_canonical; - node_cache[groupHash][nameHash] = node_canonical; + node_cache[groupHash][section_key] = node_canonical; count++; } @@ -316,6 +344,17 @@ function main() { const added = repository_result.added, removed = repository_result.removed; + /* A subscription URL the user deleted leaves its nodes without a cache + * entry, which apply_nodes() treats as "the fetch failed, keep them" - so + * they stayed in the configuration forever (still in the sing-box outbounds + * and urltest pools, and shown in the Nodes tab like a user node). The + * configured URL set is the one thing that tells the two cases apart, and + * this function has it; `node_cache` is keyed by every URL this run + * processed, including the ones whose fetch failed. */ + const orphaned = Repository.prune_orphan_nodes( + uci, uciconfig, ucinode, keys(node_cache), log + ); + /* The 6 inline uci.set/commit sites * (main_urltest_nodes cleanup, main_node switch on missing * target, main_udp_urltest_nodes cleanup, main_udp_node @@ -348,11 +387,43 @@ function main() { * * commit() returns true on success, so a failure is detected here rather * than by generating a configuration from a half-written file. */ + const config_before_commit = readfile(CONFIG_FILE); + if (uci.commit(uciconfig) !== true) { log('FAILED to commit the new configuration; the previous configuration is kept.'); return false; } + /* Nothing changed: the commit rewrote the same bytes. Every mutation was + * a no-op - the subscription returned exactly the nodes that are already + * stored, none was added, removed, renamed or updated, no reference + * needed repairing. + * + * Reloading here means stop+start of both instances for a configuration + * that is already running, and during that window stop_service keeps the + * DNS layer pointing at sing-box's dns-in while the intercept layer is + * gone - "a few seconds of failed lookups" in its own words - for + * nothing. Measured on the device: a client configuration that differs + * only in `control.listen_interfaces`, `control.lan_direct_ipv4_ips` or + * `control.wan_direct_ipv4_ips` renders byte-identical, so the generated + * bytes are the strongest signal available here (they also catch an + * option updated in place, which the added/removed counts cannot see). + * + * Not reloading is safe *because* nothing changed: this script's only + * side effect is this file. The rule-sets, the nft sets, the DNS + * snippets and the crontab all derive from it, and the Repository staged + * every mutation on the one cursor above - so there is nothing that + * could have been left unapplied. (The init script must NOT grow the + * same shortcut: a reload also re-applies layers that do not reach the + * generated file, so "bytes equal" is not a licence to skip it there.) */ + if ((readfile(CONFIG_FILE) || '') === (config_before_commit || '')) { + log(sprintf('%s nodes added, %s removed, %s orphaned; nothing changed, the service was not reloaded.', + added, removed, orphaned)); + log('Successfully updated subscriptions.'); + + return true; + } + /* Reload once, after the whole candidate set is committed * and stale references are scrubbed. The old code stopped * the service before fetching and then did stop+start; the @@ -383,7 +454,7 @@ function main() { * sing-box check warning that was truncated out of view. */ log('Warning: reload stderr was truncated (>512 KiB); the captured output is incomplete.'); - log(sprintf('%s nodes added, %s removed.', added, removed)); + log(sprintf('%s nodes added, %s removed, %s orphaned.', added, removed, orphaned)); log('Successfully updated subscriptions.'); } 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 4e481da01..372f59ca5 100755 --- a/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro +++ b/luci-app-homeproxy-pro/root/etc/init.d/homeproxy-pro @@ -86,6 +86,14 @@ GOOD_DIR="$RUN_DIR/known-good" [ -f "$HP_DIR/scripts/runtime/net.sh" ] && . "$HP_DIR/scripts/runtime/net.sh" log() { + # The run directory lives in tmpfs and does not exist on a cold boot, but + # the first thing this script logs can be a refusal *before* start_service + # reaches its mkdir - the ucode/sing-box version gates and "neither client + # nor server is configured" all write here first. Without this the operator + # got rc=1 and no reason at all: measured on the device, /var/run -> + # /tmp/run (tmpfs), and the gate lines were appended to a path that did not + # exist yet. + [ -d "$RUN_DIR" ] || mkdir -p "$RUN_DIR" 2>"/dev/null" echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH" } @@ -256,6 +264,12 @@ start_service() { # message that names the rule-set and the directory to copy into. hp_prepare_ruleset_dir "$HP_DIR" + # Every rule-set the generated configuration references has to exist + # BEFORE the generator runs: `sing-box check` opens each local path, and + # a missing china_ip4.json used to fail the whole start on a fresh + # install (see hp_prepare_generated_inputs). + hp_prepare_generated_inputs "$HP_DIR" "1" + if ! hp_start_generated_config c "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then hp_release_stale_intercept return 1 @@ -545,6 +559,12 @@ reload_service() { HP_CANDIDATE_SERVER=0 if [ "$outbound_node" != "nil" ]; then + # Same reason as in start_service: the preflight runs `sing-box check`, + # which opens every local rule-set path. Doing this here also means a + # reload after a resource-list update regenerate the files before they + # are validated, instead of failing on a missing china_ip4.json. + hp_prepare_generated_inputs "$HP_DIR" "1" + if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then log "Error: new client configuration is invalid, reload aborted; the running configuration is kept." return 1 diff --git a/luci-app-homeproxy-pro/tests/arch-guard.sh b/luci-app-homeproxy-pro/tests/arch-guard.sh index 128886806..c39d9196d 100755 --- a/luci-app-homeproxy-pro/tests/arch-guard.sh +++ b/luci-app-homeproxy-pro/tests/arch-guard.sh @@ -563,10 +563,15 @@ assert_empty "no bare join(', ', split(routing_port,...)) call site remains" \ # The four field families whose UCI surface area is the report's whole H1: # IPv4 addresses, MAC addresses, interface names, and ports. Any one of # these landing verbatim in an nft expression closes a set and reloads the -# whole fw4 stack. Asserted positively: each helper must be used on every -# field of its family, so a field the helper was meant to cover but the -# template forgot is a guard failure rather than a silent omission. -# (ipv6 is intentionally absent: it already had ipv6_to_nftarr before H1.) +# whole fw4 stack. Asserted positively: each helper must be applied to every +# field of its family, at the one place the template now validates them. +# +# The validation moved from each call site to a single table (template: +# NFTARR_FIELDS) because gating on the *raw* UCI value while interpolating the +# helper rendered `ip daddr counter return` - an nft syntax error that fails +# the whole transaction - for a list that is non-empty but has no usable entry. +# The guard follows the values: the field must be in the table with the right +# helper, and no call site may interpolate the raw field any more. # # Field list is the exact set of control_info. names used by the # template - if a new field of an existing family is added, this list grows @@ -574,8 +579,8 @@ assert_empty "no bare join(', ', split(routing_port,...)) call site remains" \ for f in wan_proxy_ipv4_ips wan_direct_ipv4_ips \ lan_proxy_ipv4_ips lan_direct_ipv4_ips \ lan_global_proxy_ipv4_ips lan_gaming_mode_ipv4_ips; do - if grep -qE "ipv4_to_nftarr\(control_info\.$f\b" "$TEMPLATE"; then - pass "ipv4_to_nftarr covers $f" + if grep -qE "(^|[[:space:],])$f: ipv4_to_nftarr," "$TEMPLATE"; then + pass "ipv4_to_nftarr validates $f (in NFTARR_FIELDS)" else fail "ipv4_to_nftarr is not applied to $f (closes a nft set on a bad value)" fi @@ -583,25 +588,56 @@ done for f in lan_proxy_mac_addrs lan_direct_mac_addrs \ lan_global_proxy_mac_addrs lan_gaming_mode_mac_addrs; do - if grep -qE "mac_to_nftarr\(control_info\.$f\b" "$TEMPLATE"; then - pass "mac_to_nftarr covers $f" + if grep -qE "(^|[[:space:],])$f: mac_to_nftarr," "$TEMPLATE"; then + pass "mac_to_nftarr validates $f (in NFTARR_FIELDS)" else fail "mac_to_nftarr is not applied to $f (closes a nft set on a bad value)" fi done -if grep -qE "iface_to_nftarr\(control_info\.listen_interfaces\b" "$TEMPLATE"; then - pass "iface_to_nftarr covers listen_interfaces" +if grep -qE "(^|[[:space:],])listen_interfaces: iface_to_nftarr," "$TEMPLATE"; then + pass "iface_to_nftarr validates listen_interfaces (in NFTARR_FIELDS)" else fail "iface_to_nftarr is not applied to listen_interfaces" fi -if grep -qE "ports_to_nftarr\(routing_port\b\)" "$TEMPLATE"; then +if grep -qE "ports_to_nftarr\(routing_port\)" "$TEMPLATE"; then pass "ports_to_nftarr covers routing_port" else fail "ports_to_nftarr is not applied to routing_port" fi +# The two derived values the call sites use outside the table. +for derived in "nftarr.listen_interfaces_lo = iface_to_nftarr" "routing_port_set = routing_port ? ports_to_nftarr(routing_port)"; do + if grep -qF "$derived" "$TEMPLATE"; then + pass "derived value is validated: $derived" + else + fail "derived value lost its validator: $derived" + fi +done + +# Nothing may interpolate or gate on a raw list field any more: that is exactly +# the shape that rendered an empty expression. +assert_empty "no helper is called on a raw control_info list at a call site" \ + grep -nE '(ipv4|ipv6|mac|iface)_to_nftarr\(control_info\.' "$TEMPLATE" +assert_empty "no rule/set gate tests a raw control_info list" \ + grep -nE '\{% if \((!?isEmpty\()?control_info\.((lan_|wan_)[a-z0-9_]*_(ips|addrs)|listen_interfaces)\)' "$TEMPLATE" + +# Every nftarr. the template reads must exist in the table (the closure +# runs the other way too: a field the call sites use but the table forgot would +# render `{{ nftarr.x }}` as an empty string, which is the same defect). +for f in $(grep -oE 'nftarr\.[a-z0-9_]+' "$TEMPLATE" | sed 's/^nftarr\.//' | sort -u); do + case "$f" in + listen_interfaces_lo) + continue ;; + esac + if grep -qE "(^|[[:space:],{])$f: " "$TEMPLATE"; then + pass "nftarr.$f is declared in NFTARR_FIELDS" + else + fail "nftarr.$f is read by the template but not declared in NFTARR_FIELDS" + fi +done + echo echo "== guard 12: capabilities stay minimal ==" diff --git a/luci-app-homeproxy-pro/tests/fixtures/firewall/dhcp b/luci-app-homeproxy-pro/tests/fixtures/firewall/dhcp new file mode 100644 index 000000000..b5b46f066 --- /dev/null +++ b/luci-app-homeproxy-pro/tests/fixtures/firewall/dhcp @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Companion to render.uci: the template reads two dnsmasq options through the +# same cursor (the DNS-hijack port and whether dnsmasq already redirects DNS). +# Without this file those reads return null and the render takes a different +# branch than a real device would. + +config dnsmasq + option port '53' + option dns_redirect '1' diff --git a/luci-app-homeproxy-pro/tests/fixtures/firewall/render.uci b/luci-app-homeproxy-pro/tests/fixtures/firewall/render.uci new file mode 100644 index 000000000..5ec6b12a5 --- /dev/null +++ b/luci-app-homeproxy-pro/tests/fixtures/firewall/render.uci @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# The UCI the firewall template render reads when there is no /etc/config on the +# machine - i.e. in CI, where the render used to report NOT RUN because the only +# thing it could read was a device's live configuration. +# +# Hand-maintained on purpose: it is the *minimum* a render needs (one section of +# every kind the template reads, one node so the node-address sets have +# something to export), not a copy of any device. A field the template starts +# reading but this file does not set renders as the template's own default, and +# the assertions in tests/ucode/test_firewall_template.sh are written against the +# statements the template declares unconditionally - so a missing field weakens +# the render, it does not silently pass it. + +config homeproxy-pro 'config' + option routing_mode 'bypass_mainland_china' + option proxy_mode 'redirect_tproxy' + option ipv6_support '0' + option log_level 'error' + option main_node 'n_test' + option main_udp_node 'same' + option dns_server '223.5.5.5' + +config homeproxy-pro 'infra' + option self_mark '100' + option redirect_port '5331' + option tproxy_port '5332' + option tproxy_mark '101' + option tun_name 'singtun0' + option tun_mark '102' + option common_port '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418' + +config homeproxy-pro 'control' + option lan_proxy_mode 'all' + +config node 'n_test' + option label 'render-fixture-node' + option type 'vless' + option address 'example.com' + option port '443' + option uuid '11111111-1111-1111-1111-111111111111' + option tls_enabled '1' + option tls_server_name 'example.com' 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 c3daafd78..8286ef142 100644 --- a/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt +++ b/luci-app-homeproxy-pro/tests/fixtures/runtime/trace.golden.txt @@ -8,6 +8,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -168,6 +170,10 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -457,6 +463,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -528,6 +536,8 @@ log sing-box 1.14.0 started. prime start rc=0 log Reloading service... config_load homeproxy-pro +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 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 @@ -599,6 +609,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 log Activating the client configuration validated by the reload preflight. chmod 600 etc/crontabs/root init.d/cron restart @@ -914,6 +926,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 log Starting with the last known-good client configuration. chmod 600 etc/crontabs/root init.d/cron restart @@ -1080,6 +1094,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -1326,6 +1342,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -1614,6 +1632,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart @@ -1771,6 +1791,8 @@ uname -r ip route show default ip -6 route show default ifstatus wan +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 ucode -S etc/homeproxy-pro/scripts/generate_client.uc chmod 600 etc/crontabs/root init.d/cron restart diff --git a/luci-app-homeproxy-pro/tests/fixtures/uclient-fetch-stub b/luci-app-homeproxy-pro/tests/fixtures/uclient-fetch-stub index be16d2d4c..ca0fd0fc9 100755 --- a/luci-app-homeproxy-pro/tests/fixtures/uclient-fetch-stub +++ b/luci-app-homeproxy-pro/tests/fixtures/uclient-fetch-stub @@ -80,6 +80,16 @@ case "$url" in say "HTTP error 404" exit 8 ;; +*HP_T_STUB_TRUNCATED*) + # A response that declared more bytes than it delivered: part of the body + # IS on stdout, and the applet exits 4. This is the shape the old pipeline + # (`uclient-fetch -O - | head -c N`) reported as a complete fetch, because + # system() returns head's status and the body was non-empty - measured on + # the device with a Content-Length of 244 and 80 bytes sent. + printf 'vless://11111111-1111-1111-1111-111111111111@a.example.com:443#PARTIAL' + say "Connection reset prematurely" + exit 4 + ;; *HP_T_STUB_BINARY*) # A 200 with a binary body. This is the shape that used to be reported # as "fetch failed: … Download completed (34185 bytes)". diff --git a/luci-app-homeproxy-pro/tests/frontend-node-refs.js b/luci-app-homeproxy-pro/tests/frontend-node-refs.js new file mode 100644 index 000000000..ed9d4e630 --- /dev/null +++ b/luci-app-homeproxy-pro/tests/frontend-node-refs.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/* + * SPDX-License-Identifier: GPL-2.0-only + * + * `hp.repairNodeRefs()` - the repair that keeps a deleted node from becoming a + * permanent generation failure. + * + * Deleting a node from the grid stages a plain `uci.remove`; nothing rewrites + * `config.main_node` / `main_udp_node` / the urltest member lists. The + * generator then dies on every later run: + * + * cannot build main-out: the node it refers to does not exist (it may have + * been deleted, or the reference is stale). + * + * Measured on the device: generation exit 254 with exactly that message, the + * reload aborted, and the UI still reported the save as applied. The repair + * runs before every save of the node map (including the footer's Save & Apply, + * which calls map.save() itself). + * + * The helper takes the cursor as an argument, so this drives it with a fake + * one - no form, no browser. `nil` and `urltest` are not section names and a + * reference that still resolves must be left alone: both are asserted, because + * a repair that "fixes" a working configuration is worse than none. + * + * Usage: node tests/frontend-node-refs.js + */ + +'use strict'; + +const path = require('path'); +const { loadLuciModule } = require('./lib/luci-module.js'); + +const root = path.resolve(process.argv[2] || '.'); + +const hp = loadLuciModule( + path.join(root, 'htdocs/luci-static/resources/homeproxy-pro.js'), + { + baseclass: { extend: (o) => o }, + form: { DynamicList: { extend: (o) => o } }, + fs: {}, rpc: {}, uci: {}, ui: {} + }); + +let checks = 0, failures = 0; + +function check(what, ok, detail) { + checks++; + if (ok) + return; + failures++; + console.error(`FAIL ${what}${detail ? ': ' + detail : ''}`); +} + +/* A cursor over a fixed set of sections, recording the writes. */ +function fake_uci(sections, options) { + const state = Object.assign({}, options); + const writes = []; + + return { + writes: writes, + state: state, + get: function(cfg, a, b) { + if (b !== undefined) + return state[a + '.' + b]; + return Object.prototype.hasOwnProperty.call(sections, a) ? sections[a] : null; + }, + set: function(cfg, section, option, value) { + writes.push({ section: section, option: option, value: value }); + state[section + '.' + option] = value; + } + }; +} + +if (typeof hp.repairNodeRefs !== 'function') { + console.error('FAIL homeproxy-pro.js does not export repairNodeRefs()'); + process.exit(1); +} + +/* 1. A dangling main_node is cleared. */ +{ + const uci = fake_uci({ cfgOTHER: 'node' }, { 'config.main_node': 'cfgGONE' }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('a dangling main_node is reported', repaired.includes('main_node'), JSON.stringify(repaired)); + check('a dangling main_node becomes nil', uci.state['config.main_node'] === 'nil', + String(uci.state['config.main_node'])); +} + +/* 2. A main_node that resolves is untouched. */ +{ + const uci = fake_uci({ cfgALIVE: 'node' }, { 'config.main_node': 'cfgALIVE' }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('a resolving main_node is not repaired', repaired.length === 0, JSON.stringify(repaired)); + check('a resolving main_node keeps its value', uci.state['config.main_node'] === 'cfgALIVE'); + check('a resolving main_node is not written', uci.writes.length === 0, JSON.stringify(uci.writes)); +} + +/* 3. The two sentinels are not section names. */ +for (const sentinel of [ 'nil', 'urltest' ]) { + const uci = fake_uci({}, { 'config.main_node': sentinel }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check(`main_node='${sentinel}' is left alone`, repaired.length === 0 && uci.state['config.main_node'] === sentinel, + JSON.stringify([ repaired, uci.state['config.main_node'] ])); +} + +/* 4. main_udp_node is repaired the same way (the UDP node is a separate + * reference and used to be forgotten by the subscription cleanup). */ +{ + const uci = fake_uci({}, { 'config.main_udp_node': 'cfgGONE' }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('a dangling main_udp_node is reported', repaired.includes('main_udp_node'), JSON.stringify(repaired)); + check('a dangling main_udp_node becomes nil', uci.state['config.main_udp_node'] === 'nil'); +} + +/* 5. urltest member lists drop only the missing members. */ +{ + const uci = fake_uci({ cfgA: 'node', cfgB: 'node' }, { + 'config.main_urltest_nodes': [ 'cfgA', 'cfgGONE', 'cfgB' ] + }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('a stale urltest member is reported', repaired.includes('main_urltest_nodes'), JSON.stringify(repaired)); + check('a stale urltest member is dropped and the rest kept', + JSON.stringify(uci.state['config.main_urltest_nodes']) === JSON.stringify([ 'cfgA', 'cfgB' ]), + JSON.stringify(uci.state['config.main_urltest_nodes'])); +} + +/* 6. A fully valid urltest list is untouched. */ +{ + const uci = fake_uci({ cfgA: 'node', cfgB: 'node' }, { + 'config.main_urltest_nodes': [ 'cfgA', 'cfgB' ] + }); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('a valid urltest list is not repaired', repaired.length === 0 && uci.writes.length === 0, + JSON.stringify([ repaired, uci.writes ])); +} + +/* 7. An empty or absent list is not a repair either. */ +{ + const uci = fake_uci({}, {}); + const repaired = hp.repairNodeRefs(uci, 'homeproxy-pro'); + check('absent references are not repaired', repaired.length === 0 && uci.writes.length === 0); +} + +console.log(`frontend node references: ${checks} checks, ${failures} failures`); +process.exit(failures ? 1 : 0); diff --git a/luci-app-homeproxy-pro/tests/frontend-protocol-inventory.js b/luci-app-homeproxy-pro/tests/frontend-protocol-inventory.js index f0b6b3890..90d46a3c9 100644 --- a/luci-app-homeproxy-pro/tests/frontend-protocol-inventory.js +++ b/luci-app-homeproxy-pro/tests/frontend-protocol-inventory.js @@ -85,6 +85,41 @@ function tableKeys(file, name) { return keys; } +/* Protocols whose table entry names `password`, for tables whose depth-1 + * entries are single lines (arrays in adapter.uc, objects in model.uc). */ +function tableKeysWith(file, name, field) { + const keys = tableKeys(file, name); + const lines = fs.readFileSync(file, 'utf8').split('\n'); + const head = new RegExp('^export const ' + name + '\\s*=\\s*\\{'); + const found = []; + let start = -1, depth = 0; + + for (let i = 0; i < lines.length; i++) + if (head.test(lines[i])) { start = i; depth = 1; break; } + if (start === -1) + throw new Error('table ' + name + ' not found'); + + for (let i = start + 1; i < lines.length; i++) { + if (depth === 1) { + const m = /^\t([A-Za-z0-9_]+)\s*:/.exec(lines[i]); + if (m && keys.includes(m[1]) && new RegExp('(^|[^A-Za-z_])' + field + '([^A-Za-z_]|$)').test(lines[i])) + found.push(m[1]); + } + for (const ch of lines[i]) { + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + if (depth <= 0) break; + } + return found; +} + +function validatorTypes(file) { + const src = fs.readFileSync(file, 'utf8'); + const m = src.match(/validatePassword\(\s*\[([^\]]+)\]/); + return m ? m[1].split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) : []; +} + let checks = 0, failures = 0; function check(what, ok, detail) { checks++; @@ -183,6 +218,36 @@ for (const type of unmapped) { 'models it in the backend but no form offers it'); } +/* --- the required-credential surface must be enforced before save --------- + * + * A protocol whose backend entry requires a password but whose form validator + * does not check it can be saved empty; generation then dies and the user sees + * "my setting did not take". That is how hysteria2/tuic slipped through: the + * empty password was accepted at save time and OutboundFactory.create() die()d + * at generation time. The validator lists live in the two view files, so they + * are compared against the backend tables here. */ +const clientNeedsPassword = new Set(tableKeysWith(path.join(scripts, 'config/adapter.uc'), 'REQUIRED_CREDENTIALS', 'password')); +const serverNeedsPassword = new Set(tableKeysWith(path.join(scripts, 'config/model.uc'), 'INBOUND_CREDENTIALS', 'password')); +const clientValidated = new Set(validatorTypes(path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/node.js'))); +const serverValidated = new Set(validatorTypes(path.join(root, 'htdocs/luci-static/resources/view/homeproxy-pro/server.js'))); + +check('adapter.uc REQUIRED_CREDENTIALS parsed for the password field', clientNeedsPassword.size > 0, [ ...clientNeedsPassword ].join(' ')); +check('model.uc INBOUND_CREDENTIALS parsed for the password field', serverNeedsPassword.size > 0, [ ...serverNeedsPassword ].join(' ')); + +for (const type of clientList) { + if (!clientNeedsPassword.has(type)) + continue; + check(`client form validates the password that ${type} requires`, clientValidated.has(type), + `${type} needs a password in adapter.uc but is missing from node.js's validatePassword list`); +} + +for (const type of serverList) { + if (!serverNeedsPassword.has(type)) + continue; + check(`server form validates the password that ${type} requires`, serverValidated.has(type), + `${type} needs a password in model.uc but is missing from server.js's validatePassword list`); +} + console.log(`frontend protocol inventory: ${checks} checks, ${failures} failures`); console.log(` client: ${clientList.join(' ')}`); console.log(` server: ${serverList.join(' ')}`); diff --git a/luci-app-homeproxy-pro/tests/frontend-validators.js b/luci-app-homeproxy-pro/tests/frontend-validators.js index 748f9fdd6..dbc710517 100644 --- a/luci-app-homeproxy-pro/tests/frontend-validators.js +++ b/luci-app-homeproxy-pro/tests/frontend-validators.js @@ -24,6 +24,7 @@ 'use strict'; const path = require('path'); +const fs = require('fs'); const { loadLuciModule } = require('./lib/luci-module.js'); @@ -65,10 +66,24 @@ function ss(method, password) { return { type: 'shadowsocks', shadowsocks_encrypt_method: method, password: password }; } -/* The two forms pass different protocol sets; use the values from the call - * sites rather than inventing a third list. */ -const CLIENT_TYPES = [ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ]; -const SERVER_TYPES = [ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ]; +/* The two forms pass different protocol sets. These used to be hand-written + * copies of the call sites, which is exactly why the lists could drift: when + * hysteria2/tuic were added to the backend's REQUIRED_CREDENTIALS the + * validator's copies stayed behind, an empty password stayed saveable, and the + * whole side then died at generation time. Read them from the production call + * sites instead, and let frontend-protocol-inventory.js cross-check them + * against the backend tables. */ +function callSiteTypes(rel) { + const src = fs.readFileSync(path.join(root, rel), 'utf8'); + const m = src.match(/validatePassword\(\s*\[([^\]]+)\]/); + return m ? m[1].split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) : []; +} + +const CLIENT_TYPES = callSiteTypes('htdocs/luci-static/resources/view/homeproxy-pro/node.js'); +const SERVER_TYPES = callSiteTypes('htdocs/luci-static/resources/view/homeproxy-pro/server.js'); + +check('client: the validator list is read from its call site', CLIENT_TYPES.length > 0, JSON.stringify(CLIENT_TYPES)); +check('server: the validator list is read from its call site', SERVER_TYPES.length > 0, JSON.stringify(SERVER_TYPES)); for (const [label, types] of [['client', CLIENT_TYPES], ['server', SERVER_TYPES]]) { const validate = hp.validatePassword(types); diff --git a/luci-app-homeproxy-pro/tests/run.sh b/luci-app-homeproxy-pro/tests/run.sh index 1436ed35a..9bd34e9e9 100755 --- a/luci-app-homeproxy-pro/tests/run.sh +++ b/luci-app-homeproxy-pro/tests/run.sh @@ -91,6 +91,12 @@ echo "== frontend validators ==" # with a fake form context instead. node "$ROOT/tests/frontend-validators.js" "$ROOT" || FAILED=1 +echo "== frontend node references ==" +# Deleting a node leaves config.main_node dangling and the generator then dies on +# every run; repairNodeRefs() runs before each save of the node map. Pure helper, +# fake cursor, no browser. +node "$ROOT/tests/frontend-node-refs.js" "$ROOT" || FAILED=1 + echo "== package JSON and UCI assets ==" # A malformed acl.d makes rpcd refuse the whole ACL, so every RPC is denied; # a menu.d action pointing at a renamed view drops the page from the menu. diff --git a/luci-app-homeproxy-pro/tests/toolchain/build-ucode-linux.sh b/luci-app-homeproxy-pro/tests/toolchain/build-ucode-linux.sh index 93892e1b1..60c062245 100755 --- a/luci-app-homeproxy-pro/tests/toolchain/build-ucode-linux.sh +++ b/luci-app-homeproxy-pro/tests/toolchain/build-ucode-linux.sh @@ -170,6 +170,27 @@ cp "$LUCI_DIR"/modules/luci-base/ucode/*.uc "$PREFIX/share/ucode/luci/" cp -R "$LUCI_DIR"/modules/luci-base/ucode/controller "$PREFIX/share/ucode/luci/" 2>/dev/null || true cp -R "$LUCI_DIR"/modules/luci-base/ucode/template "$PREFIX/share/ucode/luci/" 2>/dev/null || true +# firewall4's ucode module, for the one layer this toolchain could not reach: +# rendering firewall_post.ut into nft text (tests/ucode/test_firewall_template.sh +# reports NOT RUN without it). It has to be the REAL module - a stub would mean +# asserting on a ruleset the real fw4 never produced - and firewall4 is not part +# of this toolchain's package set, so the single file the template requires is +# taken from the firewall4 source at a pinned revision (FIREWALL4_REV overrides +# it). A failed fetch is deliberately not fatal: the render then reports NOT RUN +# (or fails under HP_REQUIRE_FW4=1) exactly as it did before, so this can never +# turn a missing module into a red build on its own. fw4.uc's own requires - +# fs, uci, ubus - are all built above. +FIREWALL4_REV="${FIREWALL4_REV:-c2ae8c8940a89407da32fbd662d4010ee2c9bbe6}" +if curl -fsSL \ + "https://raw.githubusercontent.com/openwrt/firewall4/$FIREWALL4_REV/root/usr/share/ucode/fw4.uc" \ + -o "$PREFIX/share/ucode/fw4.uc" >/dev/null 2>&1 \ + && [ -s "$PREFIX/share/ucode/fw4.uc" ]; then + echo "==> staged firewall4's fw4.uc ($FIREWALL4_REV), the template render can run" +else + rm -f "$PREFIX/share/ucode/fw4.uc" + echo "==> firewall4's fw4.uc NOT staged; the template render will report NOT RUN" +fi + # sing-box is not built here, but the generator cases run `sing-box check` and # the package targets sing-box >= 1.14, so an older binary reports unknown # fields ("handshake_timeout", "certificate_provider") and fails the fixtures. diff --git a/luci-app-homeproxy-pro/tests/ucode/run.sh b/luci-app-homeproxy-pro/tests/ucode/run.sh index 2b1f1f579..3a61c0619 100755 --- a/luci-app-homeproxy-pro/tests/ucode/run.sh +++ b/luci-app-homeproxy-pro/tests/ucode/run.sh @@ -395,19 +395,65 @@ cp "$ROOT/tests/ucode/test_homeproxy_utils.uc" "$WORK/homeproxy-pro/" # same thing everywhere and needs no network. # # On a target /bin/uclient-fetch really exists, so fetchBinary() returns it and -# the same assertions run against the real applet. Both directions are useful: -# the stub catches "an option we invented", the target catches "an option the -# real applet lacks". +# the assertions run against the real applet. Both directions are useful: the +# stub catches "an option we invented", the target catches "an option the real +# applet lacks". mkdir -p "$WORK/fetchbin" cp "$ROOT/tests/fixtures/uclient-fetch-stub" "$WORK/fetchbin/uclient-fetch" chmod +x "$WORK/fetchbin/uclient-fetch" -if ( cd "$WORK/homeproxy-pro" && PATH="$WORK/fetchbin:$PATH" ucode test_homeproxy_utils.uc ); then +# The binary and HTTP-error shapes are something the fetcher has to PRODUCE, so +# on a target the real applet can only produce them by talking to a real server +# - the stub's magic URL paths mean nothing to it (they read as an ordinary URL +# it fails to connect to, which is why those three assertions used to fail on +# every real device). So stand up a throwaway uhttpd over a temp docroot and +# hand the test its base URL via HP_T_HTTP_BASE. +# +# It is a second uhttpd on a high port serving a temp directory, killed right +# after the test and by the trap below: the running LuCI instance, /www and +# port 80 are never touched. If it cannot be started the test reports those +# three as skipped, which is honest; they must never be made to read as a pass. +HP_T_HTTP_BASE="" +HP_T_HTTP_PID="" +hp_http_stop() { + if [ -n "$HP_T_HTTP_PID" ]; then + kill "$HP_T_HTTP_PID" 2> /dev/null + wait "$HP_T_HTTP_PID" 2> /dev/null + HP_T_HTTP_PID="" + fi +} +trap hp_http_stop EXIT INT TERM + +if { [ -x /bin/uclient-fetch ] || [ -x /usr/bin/uclient-fetch ]; } && + command -v uhttpd > /dev/null 2>&1; then + hp_docroot="$WORK/httproot" + rm -rf "$hp_docroot" + mkdir -p "$hp_docroot" + # A body executeCommand() must classify as binary: an .srs magic number + # with NUL bytes in it, the same shape the stub emits. + printf 'SRS\002x\332b\000\014\000\000\001\000\001' > "$hp_docroot/hp-binary.srs" + + for hp_port in 18099 18100 18101 18102 18103 18104; do + uhttpd -f -p "127.0.0.1:$hp_port" -h "$hp_docroot" -x /cgi-bin -t 5 -T 5 & + HP_T_HTTP_PID=$! + # Integer sleep on purpose: busybox ash has no fractional wait, and + # uhttpd needs a moment to bind before the first fetch can reach it. + sleep 1 + if kill -0 "$HP_T_HTTP_PID" 2> /dev/null; then + HP_T_HTTP_BASE="http://127.0.0.1:$hp_port" + break + fi + HP_T_HTTP_PID="" + done +fi + +if ( cd "$WORK/homeproxy-pro" && PATH="$WORK/fetchbin:$PATH" HP_T_HTTP_BASE="$HP_T_HTTP_BASE" ucode test_homeproxy_utils.uc ); then echo "PASS: executeCommand() regression tests" else echo "FAIL: executeCommand() regression tests" FAILED=1 fi +hp_http_stop echo "== TLS / transport builder tests ==" rm -rf "$WORK/tls_transport" 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 e55b90409..641e65f8d 100644 --- a/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh +++ b/luci-app-homeproxy-pro/tests/ucode/test_firewall_template.sh @@ -190,13 +190,23 @@ for chain in redirect mangle_prerouting mangle_output; do 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 +# Asserted per chain, not as a global count. The global count is what this +# check used to be ("expected 3"), and it is exactly how the missing rule went +# unnoticed: the TUN chain needed one and the assertion counted the three that +# already existed. The four chains below are the ones that steer traffic into +# the tunnel or the redirect/tproxy port - each must exempt the node's own +# address once, so a new chain that starts steering traffic has to be added +# here (and the operator reading the diff sees why). +for chain in homeproxy_redirect homeproxy_mangle_prerouting homeproxy_mangle_output homeproxy_mangle_tun; do + body="$(awk -v c="$chain" 'index($0, "chain " c " {") == 1 { f = 1 } f { print } f && /^}/ { exit }' "$TEMPLATE_SRC")" + n="$(printf '%s\n' "$body" | grep -c 'ip daddr @homeproxy_node_addr_v4 counter return' || true)" + if [ "$n" -eq 1 ]; then + echo "PASS: $chain carries exactly one node-address return rule" + else + echo "FAIL: $chain carries $n node-address return rules (expected exactly 1)" + FAILED=1 + fi +done # Probe with ucode, not utpl: `utpl -e` is not an eval flag, it renders the # argument as template text and always succeeds. @@ -221,12 +231,48 @@ if ! ucode -e 'require("fw4");' > "/dev/null" 2>&1; then exit $FAILED fi +# Where the render reads its UCI from. +# +# * on a target: the real /etc/config - the render has to see the +# configuration that is actually deployed; +# * anywhere else (CI): tests/fixtures/firewall/render.uci, so this layer - +# the only one that turns the template into nft text - runs in CI instead +# of reporting NOT RUN. That is T1's other half: the module it needs is +# staged by the toolchain (tests/toolchain/build-ucode-linux.sh) and the +# configuration it reads is staged here. +# +# HP_T_FW4_CONFIG_DIR overrides both, which is how the fixture path itself is +# exercised on a device (real fw4, fixture config). +FW4_CONFIG_DIR="${HP_T_FW4_CONFIG_DIR:-}" +if [ -z "$FW4_CONFIG_DIR" ] && [ ! -f "/etc/config/homeproxy-pro" ]; then + FW4_CONFIG_DIR="$STAGE/cfg" + mkdir -p "$FW4_CONFIG_DIR" + cp "$ROOT/tests/fixtures/firewall/render.uci" "$FW4_CONFIG_DIR/homeproxy-pro" + cp "$ROOT/tests/fixtures/firewall/dhcp" "$FW4_CONFIG_DIR/dhcp" +fi + # The template is written for a device: it imports homeproxy-pro.uc through # /etc/homeproxy-pro/scripts/ and reads /etc/homeproxy-pro/resources. Neither exists # off-target, so render a staged copy with those prefixes rewritten to the # checkout - the same trick run.sh uses for the absolute import in -# root/usr/share/rpcd/ucode/luci.homeproxy-pro. -sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" "$TEMPLATE_SRC" > "$STAGED" +# root/usr/share/rpcd/ucode/luci.homeproxy-pro. The cursor is rewritten too when +# the UCI comes from the fixture rather than from /etc/config. +if [ -n "$FW4_CONFIG_DIR" ]; then + sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" \ + -e "s#^const uci = cursor();#const uci = cursor('$FW4_CONFIG_DIR');#" \ + "$TEMPLATE_SRC" > "$STAGED" +else + sed -e "s#'/etc/homeproxy-pro/#'$ROOT/root/etc/homeproxy-pro/#g" "$TEMPLATE_SRC" > "$STAGED" +fi + +# Hard guard: the cursor rewrite has to have matched, or the render silently +# reads a configuration that is not there (and the assertions would be about a +# ruleset nobody asked for). +if [ -n "$FW4_CONFIG_DIR" ] && ! grep -qF "cursor('$FW4_CONFIG_DIR')" "$STAGED"; then + echo "FAIL: firewall_post.ut: could not point the render at $FW4_CONFIG_DIR" + echo " (the 'const uci = cursor();' anchor no longer matches)" + exit 1 +fi # Hard guard: if the prefix ever changes, the sed silently no-ops and the # render fails for an unrelated reason, which would look like a template @@ -266,4 +312,36 @@ if [ "$FAILED" -eq 0 ]; then echo "PASS: the gfwlist gate follows the dnsmasq nftset capability" fi +# The wan guard (S4): with `listen_interfaces` empty the intercept layer has to +# exclude the wan zone's devices. Two ways this went wrong on the device and +# both render *something*, which is why the assertion is not just "a guard is +# there": +# +# - r46 shipped a template whose guard silently disappeared: fw4 had no state +# in this process (`zones()` is null outside fw4's own render), the loop +# threw, the catch swallowed it, and the rendered chain had no iifname at +# all - the exact hole the guard was added to close; +# - resolving it from /var/run/fw4.state instead yields the LOGICAL names +# ("wan", "wan_6"), so `meta iifname { wan }` renders and can never match. +# +# Resolving zone devices needs ubus, so off-target runs report SKIP rather than +# pretending: the render needs fw4's real zone data for this one. +GUARD_IFACES="$(grep -oE 'meta iifname( !=)? \{[^}]*\}' "$OUT" | sort -u | tr '\n' ' ')" +case "$GUARD_IFACES" in +*'{ wan }'*|*'{ wan,'*|*'wan_6 }'*|*'wan_6,'*) + echo "FAIL: the wan guard rendered a logical network name, which never matches:" + echo " $GUARD_IFACES" + FAILED=1 + ;; +esac + +if grep -q 'not the WAN side' "$OUT"; then + echo "PASS: the wan guard is rendered (resolved devices: ${GUARD_IFACES:-none})" +elif ubus -v list network.interface > "/dev/null" 2>&1; then + echo "FAIL: the wan guard is missing although fw4 could resolve zone devices" + FAILED=1 +else + echo "SKIP: the wan guard needs ubus to resolve zone devices (not available here)" +fi + exit $FAILED diff --git a/luci-app-homeproxy-pro/tests/ucode/test_homeproxy-pro_utils.uc b/luci-app-homeproxy-pro/tests/ucode/test_homeproxy-pro_utils.uc index 8a15d0560..8ba28f40c 100644 --- a/luci-app-homeproxy-pro/tests/ucode/test_homeproxy-pro_utils.uc +++ b/luci-app-homeproxy-pro/tests/ucode/test_homeproxy-pro_utils.uc @@ -12,7 +12,7 @@ 'use strict'; import { lsdir } from 'fs'; -import { executeCommand, isValidCIDR, isValidPEM, redactReason, redactUrl, ruleSetFormatFromBytes, +import { executeCommand, fetchBinary, isValidCIDR, isValidPEM, redactReason, redactUrl, ruleSetFormatFromBytes, ruleSetFormatFromPath, RULESET_PROBE_BYTES, shellQuote, wGETVerbose } from 'homeproxy-pro'; let failures = 0, @@ -104,17 +104,66 @@ expect('fetch.not-a-usage-error', expect('fetch.reports-a-reason', length(fetch.error || '') > 0, true); expect('fetch.no-content-on-failure', fetch.content, null); +/* The binary and HTTP-error shapes below have to be PRODUCED by the fetcher, so + * which fetcher is in play decides which URL can produce them. + * + * Off-target fetchBinary() resolves to the stub, which manufactures both shapes + * from a magic path in the URL. On a target it returns the real applet, and a + * real applet only produces them by talking to a real server - a magic path + * just looks like an ordinary URL it fails to connect to, which is why these + * three used to fail there. run.sh starts a throwaway uhttpd for exactly this + * and passes its base URL in HP_T_HTTP_BASE. + * + * A real applet with no server (no uhttpd, every candidate port taken) cannot + * be driven at all: report the three as skipped rather than failing, and never + * let them read as a pass. */ +const fetchBin = fetchBinary(); +const realApplet = substr(fetchBin, 0, 1) === '/'; +const httpBase = getenv('HP_T_HTTP_BASE') || ''; +const canDriveFetchShapes = httpBase || !realApplet; +if (realApplet && !httpBase) + printf('SKIP: binary / HTTP-error fetch shapes (real applet, no local server)\n'); + /* A 200 whose body is binary. executeCommand() nulls stdout for binary * content, and the "no content but stderr said something" branch then used to * report it as `fetch failed: … Download completed (34185 bytes)` - a message * that contradicts itself and sends the user after a network problem they do * not have. Measured on a device against a real .srs. */ -const binfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_BINARY'); -expect('fetch.binary-is-not-a-failure', - match(binfetch.error || '', /fetch failed/) == null, true, binfetch.error); -expect('fetch.binary-says-so', - match(binfetch.error || '', /binary/) != null, true, binfetch.error); -expect('fetch.binary-has-no-content', binfetch.content, null); +if (canDriveFetchShapes) { + const binURL = httpBase ? `${httpBase}/hp-binary.srs` : 'http://127.0.0.1:1/HP_T_STUB_BINARY'; + const binfetch = wGETVerbose(binURL); + expect('fetch.binary-is-not-a-failure', + match(binfetch.error || '', /fetch failed/) == null, true, binfetch.error); + expect('fetch.binary-says-so', + match(binfetch.error || '', /binary/) != null, true, binfetch.error); + expect('fetch.binary-has-no-content', binfetch.content, null); +} + +/* A truncated transfer must not look like a subscription. + * + * The fetch used to be `uclient-fetch -O - | head -c N`, and system() returns + * the last command's status - head's, always 0. A response that declared 244 + * bytes and delivered 80 therefore arrived as `{ content: <80 bytes>, error: + * null }`: a half-downloaded list would rewrite the node set and nothing + * anywhere said so. wGETVerbose() now carries the fetcher's own status out of + * the pipeline through a file, and this is the shape that proves it. + * + * The stub is the only thing that can produce "non-empty body AND non-zero + * exit" (a real uhttpd sets Content-Length from the file it serves), so on a + * real-applet run this is skipped on purpose - the same path was measured by + * hand on the device with a truncated response: real uclient-fetch exits 4 + * with "Connection reset prematurely" and wGETVerbose() reports + * `fetch exited with status 4: …`. */ +if (canDriveFetchShapes) { + if (realApplet) { + printf('SKIP: truncated-transfer shape (the real applet cannot be asked for a short body; device-verified by hand)\n'); + } else { + const truncfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_TRUNCATED'); + expect('fetch.truncated-is-not-a-success', truncfetch.content, null); + expect('fetch.truncated-reports-the-status', + match(truncfetch.error || '', /status 4/) != null, true, truncfetch.error); + } +} /* Review H3: the fetcher announces the requested URL on stderr before it reports * anything else, query string and all, so this is what would reach the log. @@ -136,11 +185,14 @@ expect('fetch.redacted-still-names-the-target', /* The HTTP-error shape is the one that leaks - "Downloading ''" followed * by "HTTP error 404" - and it is the common case for a subscription URL that * has expired. */ -const httpfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_HTTP_ERROR?token=secret'); -expect('fetch.http-error-token-not-in-error', - match(httpfetch.error || '', /token=secret/) == null, true); -expect('fetch.http-error-keeps-the-status', - match(httpfetch.error || '', /HTTP error/) != null, true, httpfetch.error); +if (canDriveFetchShapes) { + const errURL = httpBase ? `${httpBase}/hp-missing?token=secret` : 'http://127.0.0.1:1/HP_T_STUB_HTTP_ERROR?token=secret'; + const httpfetch = wGETVerbose(errURL); + expect('fetch.http-error-token-not-in-error', + match(httpfetch.error || '', /token=secret/) == null, true); + expect('fetch.http-error-keeps-the-status', + match(httpfetch.error || '', /HTTP error/) != null, true, httpfetch.error); +} /* redactReason(): central redaction that protects every wGETVerbose caller. * Tested in isolation so the assertion does not depend on any fetcher being diff --git a/luci-app-homeproxy-pro/tests/ucode/test_subscription_repository.uc b/luci-app-homeproxy-pro/tests/ucode/test_subscription_repository.uc index 0984dd176..f0a40f933 100644 --- a/luci-app-homeproxy-pro/tests/ucode/test_subscription_repository.uc +++ b/luci-app-homeproxy-pro/tests/ucode/test_subscription_repository.uc @@ -15,6 +15,9 @@ * - a subscription node dropped from the cache is deleted * - a new node in the cache but not in UCI is added under the * md5(groupHash + label) section name + * - a node whose label changed but whose content did not keeps its + * section (and with it every reference to it), and is not re-added + * - two stored sections with the same content: only one may claim it * * PR-03 changes the input contract: Repository now accepts * canonical Node objects (post- parser/normalize), and flattens @@ -413,6 +416,149 @@ expect('scrub: clean state changes nothing', scrub2.changed, 0); * * U+4E2D U+6587 -> e4 b8 ad e6 96 87 * U+1F600 -> f0 9f 98 80 */ +/* --- a renamed node keeps its section --------------------------------- */ + +/* Section names are md5(grouphash + label), so a subscription that renames a + * node used to make the repository delete the old section and add an identical + * one under the new name - taking every reference to it (config.main_node, + * main_udp_node, the urltest member lists) with it. Measured on the device + * before this: "Removing node: OLD-NAME." + "Adding node: NEW-NAME.", the + * section name changed, and main_node was switched to the new section. The + * repository recognises the stored section by its *content* now: same server, + * same credentials, new label. */ +{ + const uci3 = cursor(ARGV[0]); + uci3.load(CFG); + + const groupR = 'groupRename'; + const u_renamed = 'cfgRENAMED001'; + + uci3.set(CFG, u_renamed, TYPE); + uci3.set(CFG, u_renamed, 'label', 'old-name'); + uci3.set(CFG, u_renamed, 'grouphash', groupR); + uci3.set(CFG, u_renamed, 'type', 'vless'); + uci3.set(CFG, u_renamed, 'address', 'rename.example.com'); + uci3.set(CFG, u_renamed, 'port', '443'); + uci3.commit(CFG); + uci3.load(CFG); + + const r_node = canonical_node({ + grouphash: groupR, + label: 'new-name', + type: 'vless', + address: 'rename.example.com', + port: '443' + }); + const r_cache = { [groupR]: { [md5(groupR + 'new-name')]: r_node } }; + + const r_result = Repository.apply_nodes(uci3, CFG, TYPE, r_cache, [[ r_node ]], LOG); + + expect('rename: not counted as removed', r_result.removed, 0); + expect('rename: not counted as added', r_result.added, 0); + + uci3.commit(CFG); + uci3.load(CFG); + + expect('rename: section kept under its old name', + 'label' in uci3.get_all(CFG, u_renamed), true); + expect('rename: label updated in place', + uci3.get_all(CFG, u_renamed).label, 'new-name'); + expect('rename: nothing added under the new name', + 'label' in uci3.get_all(CFG, md5(groupR + 'new-name')), false); +} + +/* Two stored sections with identical content must not both update themselves + * into the same node: the first one claims the content, the duplicate is + * pruned exactly as before. */ +{ + const uci4 = cursor(ARGV[0]); + uci4.load(CFG); + + const groupD = 'groupDuplicate'; + const u_dup_a = 'cfgDUPA00001'; + const u_dup_b = 'cfgDUPB00001'; + + for (let section in [ u_dup_a, u_dup_b ]) { + uci4.set(CFG, section, TYPE); + uci4.set(CFG, section, 'label', section); + uci4.set(CFG, section, 'grouphash', groupD); + uci4.set(CFG, section, 'type', 'vless'); + uci4.set(CFG, section, 'address', 'dup.example.com'); + uci4.set(CFG, section, 'port', '443'); + } + uci4.commit(CFG); + uci4.load(CFG); + + const d_node = canonical_node({ + grouphash: groupD, + label: 'renamed-dup', + type: 'vless', + address: 'dup.example.com', + port: '443' + }); + const d_cache = { [groupD]: { [md5(groupD + 'renamed-dup')]: d_node } }; + + const d_result = Repository.apply_nodes(uci4, CFG, TYPE, d_cache, [[ d_node ]], LOG); + + expect('duplicate content: one section claims it', d_result.added, 0); + expect('duplicate content: the other is pruned', d_result.removed, 1); + + uci4.commit(CFG); + uci4.load(CFG); + + const survived = filter([ u_dup_a, u_dup_b ], (s) => ('label' in uci4.get_all(CFG, s))); + expect('duplicate content: exactly one section survives', length(survived), 1); + expect('duplicate content: it carries the new label', + uci4.get_all(CFG, survived[0]).label, 'renamed-dup'); +} + +/* --- the orchestrator's key is the section name ----------------------- */ + +/* Two servers of one subscription can share a label - it is whatever the + * provider put after '#'. The orchestrator keeps both and separates them by + * the content fingerprint, so the second node sits in the cache under a key + * that is NOT md5(grouphash + label). The repository has to write that key as + * the section name, or both nodes end up in one section and one is lost. */ +{ + const uci5 = cursor(ARGV[0]); + uci5.load(CFG); + + const groupS = 'groupSharedLabel'; + const s_first = canonical_node({ + grouphash: groupS, + label: 'same-label', + type: 'vless', + address: 'first.example.com', + port: '443' + }); + const s_second = canonical_node({ + grouphash: groupS, + label: 'same-label', + type: 'vless', + address: 'second.example.com', + port: '443' + }); + const disambiguated = md5(groupS + 'same-label' + chr(0x00) + 'different-content'); + const s_cache = { + [groupS]: { + [md5(groupS + 'same-label')]: s_first, + [disambiguated]: s_second + } + }; + + const s_result = Repository.apply_nodes(uci5, CFG, TYPE, s_cache, + [[ s_first ], [ s_second ]], LOG); + expect('shared label: both nodes are added', s_result.added, 2); + + uci5.commit(CFG); + uci5.load(CFG); + + expect('shared label: the first is under md5(grouphash + label)', + uci5.get_all(CFG, md5(groupS + 'same-label')).address, 'first.example.com'); + expect('shared label: the second is under the disambiguated key', + uci5.get_all(CFG, disambiguated).address, 'second.example.com'); +} + const MD5_CJK = chr(0xe4) + chr(0xb8) + chr(0xad) + chr(0xe6) + chr(0x96) + chr(0x87); const MD5_EMOJI = chr(0xf0) + chr(0x9f) + chr(0x98) + chr(0x80); diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index 45db6e70f..5a4c51d2a 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.283 -PKG_RELEASE:=9 +PKG_VERSION:=0.1.286 +PKG_RELEASE:=10 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 86076dab3..ce530b8d3 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.4 -PKG_RELEASE:=316 +PKG_RELEASE:=317 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua index d52f6037e..2e214d5e7 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua @@ -37,6 +37,7 @@ local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | aw local singbox_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(singbox_version, ">=", "1.14.0") +local version_ge_1_15_0 = api.compare_versions(singbox_version, ">=", "1.15.0") o = s:option(ListValue, "protocol", translate("Protocol")) o:value("socks", "Socks") @@ -65,6 +66,9 @@ end if version_ge_1_14_0 then o:value("snell", "Snell") end +if version_ge_1_15_0 and singbox_tags:find("with_quic") then + o:value("masque", "MASQUE") +end o:value("_urltest", translate("URLTest")) o:value("_shunt", translate("Shunt")) o:value("_iface", translate("Custom Interface")) @@ -193,6 +197,7 @@ o:depends({ protocol = "http" }) o:depends({ protocol = "socks" }) o:depends({ protocol = "ssh" }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) o = s:option(Value, "password", translate("Password")) o.password = true @@ -204,6 +209,34 @@ o:depends({ protocol = "tuic" }) o:depends({ protocol = "anytls" }) o:depends({ protocol = "ssh" }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) + +-- [[ MASQUE ]] -- +o = s:option(Value, "masque_path", translate("MASQUE Path")) +o:depends({ protocol = "masque" }) + +o = s:option(TextValue, "masque_headers", translate("Custom HTTP Headers")) +o.rows = 5 +o.wrap = "off" +o:depends({ protocol = "masque" }) +o.cfgvalue = function(self, section) + return (m:get(section, "masque_headers") or ""):gsub("\\n", "\n") +end +o.validate = function(self, value) + value = api.trim(value):gsub("\r\n", "\n"):gsub("\r", "\n") + local headers = {} + for line in value:gmatch("[^\n]+") do + if api.trim(line) ~= "" then + local key = line:match("^%s*([^:]+):") + key = key and api.trim(key):lower() + if not key or not key:match("^[!#$%%&'*+%.%^_`|~%w%-]+$") or headers[key] then + return nil, translate("Not true format, please re-enter!") .. " (" .. line .. ")" + end + headers[key] = true + end + end + return value:gsub("\n", "\\n") +end if version_ge_1_14_0 then -- snell @@ -523,6 +556,7 @@ o:depends({ tls = true }) o:depends({ protocol = "hysteria"}) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(Value, "tls_serverName", "SNI " .. translate("Domain")) o:depends({ tls = true }) @@ -530,6 +564,7 @@ o:depends({ protocol = "hysteria"}) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) o = s:option(Flag, "tls_allowInsecure", translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped.")) o.default = "0" @@ -537,12 +572,14 @@ o:depends({ tls = true }) o:depends({ protocol = "hysteria" }) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(Value, "tls_pinSHA256", translate("TLS Chain Fingerprint (SHA256)")) o:depends({ tls = true }) o:depends({ protocol = "hysteria" }) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o.description = translate("Once set, connects only when the server’s chain fingerprint matches.") .. string.format("%s", "→ " .. translate("Fetch Manually")) @@ -553,6 +590,7 @@ o:depends({ protocol = "hysteria"}) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "tls_certificate_pem", " ", translate("Full certificate (chain), PEM format.")) o.default = "" @@ -569,6 +607,7 @@ end o = s:option(Value, "cipherSuites", translate("Cipher Suites"), '***' .. " " .. translate("Configures the list of supported cipher suites, separated by :")) o:depends({ tls = true }) +o:depends({ protocol = "masque" }) o = s:option(Flag, "ech", translate("ECH")) o.default = "0" @@ -577,6 +616,7 @@ o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria" }) o:depends({ protocol = "hysteria2", hysteria2_realms = false }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "ech_config", translate("ECH Config")) o.default = "" @@ -750,6 +790,7 @@ o:depends({ transport = "http" }) o:depends({ transport = "ws" }) o:depends({ transport = "httpupgrade" }) o:depends({ protocol = "naive" }) +o:depends({ protocol = "masque" }) -- [[ Mux ]]-- o = s:option(Flag, "mux", translate("Mux")) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/2_xray.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/2_xray.lua index 2b3163c07..88bb72128 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/2_xray.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/2_xray.lua @@ -44,6 +44,9 @@ o:value("wireguard", translate("WireGuard")) if api.compare_versions(xray_version, ">=", "26.1.13") then o:value("hysteria2", translate("Hysteria2")) end +if api.compare_versions(xray_version, ">=", "26.9.30") then + o:value("masque", "MASQUE") +end if api.compare_versions(xray_version, ">=", "1.8.12") then o:value("_balancing", translate("Balancing")) end @@ -260,6 +263,7 @@ o:depends({ protocol = "vless" }) o = s:option(Value, "username", translate("Username")) o:depends({ protocol = "http" }) o:depends({ protocol = "socks" }) +o:depends({ protocol = "masque" }) o = s:option(Value, "password", translate("Password")) o.password = true @@ -267,6 +271,34 @@ o:depends({ protocol = "http" }) o:depends({ protocol = "socks" }) o:depends({ protocol = "shadowsocks" }) o:depends({ protocol = "trojan" }) +o:depends({ protocol = "masque" }) + +-- [[ MASQUE ]] -- +o = s:option(Value, "masque_path", translate("MASQUE Path")) +o:depends({ protocol = "masque" }) + +o = s:option(TextValue, "masque_headers", translate("Custom HTTP Headers")) +o.rows = 5 +o.wrap = "off" +o:depends({ protocol = "masque" }) +o.cfgvalue = function(self, section) + return (m:get(section, "masque_headers") or ""):gsub("\\n", "\n") +end +o.validate = function(self, value) + value = api.trim(value):gsub("\r\n", "\n"):gsub("\r", "\n") + local headers = {} + for line in value:gmatch("[^\n]+") do + if api.trim(line) ~= "" then + local key = line:match("^%s*([^:]+):") + key = key and api.trim(key):lower() + if not key or not key:match("^[!#$%%&'*+%.%^_`|~%w%-]+$") or headers[key] or key == "host" or key == "capsule-protocol" then + return nil, translate("Not true format, please re-enter!") .. " (" .. line .. ")" + end + headers[key] = true + end + end + return value:gsub("\n", "\\n") +end o = s:option(ListValue, "security", translate("Encrypt Method")) for a, t in ipairs(security_list) do o:value(t) end @@ -419,21 +451,25 @@ o:depends({ tls = true, reality = false }) o = s:option(Value, "tls_serverName", "SNI " .. translate("Domain")) o:depends({ tls = true }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(Value, "tls_pinSHA256", translate("TLS Chain Fingerprint (SHA256)")) o:depends({ tls = true, reality = false }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o.description = translate("Once set, connects only when the server’s chain fingerprint matches.") .. string.format("%s", "→ " .. translate("Fetch Manually")) o = s:option(Value, "tls_CertByName", translate("TLS Certificate Name (CertName)"), translate("TLS is used to verify the leaf certificate name.")) o:depends({ tls = true, reality = false }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(Flag, "tls_certificate", translate("TLS Certificate (PEM)")) o.default = "0" o:depends({ tls = true, reality = false }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "tls_certificate_pem", " ", translate("Full certificate (chain), PEM format.")) o.default = "" @@ -452,6 +488,7 @@ o = s:option(Flag, "ech", translate("ECH")) o.default = "0" o:depends({ tls = true, reality = false }) o:depends({ protocol = "hysteria2", hysteria2_realms = false }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "ech_config", translate("ECH Config")) o.default = "" @@ -495,6 +532,7 @@ o:depends({ tls = true, reality = true }) o = s:option(Value, "cipherSuites", translate("Cipher Suites"), '***' .. " " .. translate("Configures the list of supported cipher suites, separated by :")) o:depends({ tls = true, reality = false }) +o:depends({ protocol = "masque" }) o = s:option(Flag, "use_mldsa65Verify", translate("ML-DSA-65")) o.default = "0" @@ -702,6 +740,7 @@ o:depends({ transport = "ws" }) o:depends({ transport = "httpupgrade" }) o:depends({ transport = "xhttp" }) o:depends({ transport = "grpc" }) +o:depends({ protocol = "masque" }) -- [[ Mux.Cool ]]-- o = s:option(Flag, "mux", "Mux", translate("Enable Mux.Cool")) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua index eba1dbc91..be05e4ad9 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua @@ -31,6 +31,7 @@ local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | aw local local_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(local_version, ">=", "1.14.0") +local version_ge_1_15_0 = api.compare_versions(local_version, ">=", "1.15.0") local ss_method_list = { "none", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", @@ -249,6 +250,25 @@ o:value("", translate("Disable")) o:value("xtls-rprx-vision") o:depends({ protocol = "vless" , tls = true }) +-- [[ MASQUE ]] -- +if version_ge_1_15_0 and singbox_tags:find("with_quic") then + s.fields["protocol"]:value("masque", "MASQUE") + s.fields["users"]:depends({ protocol = "masque" }) + + o = s:option(Value, "masque_path", translate("MASQUE Path")) + o:depends({ protocol = "masque" }) + + o = s:option(DynamicList, "masque_address", translate("MASQUE Address")) + o.rmempty = false + o:depends({ protocol = "masque" }) + + o = s:option(Value, "masque_mtu", "MTU") + o.datatype = "uinteger" + o.default = "1280" + o:depends({ protocol = "masque" }) +end + +---- [[ TLS ]] o = s:option(Flag, "tls", translate("TLS")) o.default = 0 o.validate = function(self, value, t) @@ -325,6 +345,7 @@ o:depends({ protocol = "naive" }) o:depends({ protocol = "hysteria" }) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(FileUpload, "tls_certificateFile", translate("Path to the certificate file"), translate("as:") .. "/etc/ssl/fullchain.crt") o.default = m:get(s.section, "tls_certificateFile") or "/etc/config/ssl/" .. arg[1] .. ".crt" @@ -334,6 +355,7 @@ o:depends({ protocol = "naive", tls_use_pem = false }) o:depends({ protocol = "hysteria", tls_use_pem = false }) o:depends({ protocol = "tuic", tls_use_pem = false }) o:depends({ protocol = "hysteria2", tls_use_pem = false }) +o:depends({ protocol = "masque", tls_use_pem = false }) o.validate = function(self, value, t) if value and value ~= "" then if not api.fs.access(value) then @@ -353,6 +375,7 @@ o:depends({ protocol = "naive", tls_use_pem = false }) o:depends({ protocol = "hysteria", tls_use_pem = false }) o:depends({ protocol = "tuic", tls_use_pem = false }) o:depends({ protocol = "hysteria2", tls_use_pem = false }) +o:depends({ protocol = "masque", tls_use_pem = false }) o.validate = function(self, value, t) if value and value ~= "" then if not api.fs.access(value) then @@ -397,6 +420,7 @@ o:depends({ protocol = "naive" }) o:depends({ protocol = "hysteria" }) o:depends({ protocol = "tuic" }) o:depends({ protocol = "hysteria2", hysteria2_realms = false }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "ech_key", translate("ECH Key")) o.default = "" diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/2_xray.lua b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/2_xray.lua index 9f7e7d929..107ba9af6 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/2_xray.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/2_xray.lua @@ -65,6 +65,7 @@ o:value("shadowsocks", "Shadowsocks") o:value("trojan", "Trojan") o:value("hysteria2", "Hysteria2") o:value("wireguard", "WireGuard") +o:value("masque", "MASQUE") o:value("tunnel", "Tunnel") o:depends({ custom = false }) @@ -85,6 +86,7 @@ o:depends({ protocol = "vless" }) o:depends({ protocol = "trojan" }) o:depends({ protocol = "hysteria2" }) o:depends({ protocol = "wireguard" }) +o:depends({ protocol = "masque" }) o = s:option(ListValue, "d_protocol", translate("Destination protocol")) o:value("tcp", "TCP") @@ -188,6 +190,19 @@ o:depends({ protocol = "hysteria2", hysteria2_ignore_client_bandwidth = false }) o = s:option(Value, "hysteria2_down_mbps", translate("Max download Mbps")) o:depends({ protocol = "hysteria2", hysteria2_ignore_client_bandwidth = false }) +-- [[ MASQUE ]] -- +o = s:option(Value, "masque_path", translate("MASQUE Path")) +o:depends({ protocol = "masque" }) + +o = s:option(DynamicList, "masque_address", translate("MASQUE Address")) +o.rmempty = false +o:depends({ protocol = "masque" }) + +o = s:option(Value, "masque_mtu", "MTU") +o.datatype = "uinteger" +o.default = "1280" +o:depends({ protocol = "masque" }) + ---- [[ TLS ]] o = s:option(Flag, "tls", translate("TLS")) o.default = 0 @@ -274,12 +289,14 @@ end o = s:option(Flag, "tls_use_pem", translate("Use PEM"), translate("Use certificate and private key PEM content.")) o:depends({ tls = true, reality = false }) o:depends({ protocol = "hysteria2" }) +o:depends({ protocol = "masque" }) o = s:option(FileUpload, "tls_certificateFile", translate("Path to the certificate file"), translate("as:") .. "/etc/ssl/fullchain.crt") o.default = m:get(s.section, "tls_certificateFile") or "/etc/config/ssl/" .. s.section .. ".crt" if o and o:formvalue(s.section) then o.default = o:formvalue(s.section) end o:depends({ tls = true, reality = false, tls_use_pem = false }) o:depends({ protocol = "hysteria2", tls_use_pem = false }) +o:depends({ protocol = "masque", tls_use_pem = false }) o.validate = function(self, value, t) if value and value ~= "" then if not api.fs.access(value) then @@ -296,6 +313,7 @@ o.default = m:get(s.section, "tls_keyFile") or "/etc/config/ssl/" .. s.section . if o and o:formvalue(s.section) then o.default = o:formvalue(s.section) end o:depends({ tls = true, reality = false, tls_use_pem = false }) o:depends({ protocol = "hysteria2", tls_use_pem = false }) +o:depends({ protocol = "masque", tls_use_pem = false }) o.validate = function(self, value, t) if value and value ~= "" then if not api.fs.access(value) then @@ -336,6 +354,7 @@ end o = s:option(Flag, "ech", translate("ECH")) o.default = "0" o:depends({ tls = true, reality = false }) +o:depends({ protocol = "masque" }) o = s:option(TextValue, "ech_key", translate("ECH Key")) o.default = "" diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index ac51a549d..a9e81adf7 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -1462,10 +1462,7 @@ function to_check_self() release = release[1] end if type(release) ~= "table" or not release.tag_name then - return { - code = 1, - error = i18n.translate("Get remote version info failed.") - } + return {code = 1, error = i18n.translate("Get remote version info failed.")} end local local_version = get_version() -- Keep the release suffix (-1, -2, ...) so package revisions are compared too. @@ -1482,15 +1479,20 @@ function to_check_self() extension = ".ipk" end local main, i18n_package - for _, asset in ipairs(release.assets or {}) do - local name = asset.name or "" - if name:find(prefix, 1, true) == 1 and name:sub(-#extension) == extension then - name = name:sub(#prefix + 1) - if name:match("^luci%-app%-" .. appname .. "[_%-]%d") then main = asset end - if name:match("^luci%-i18n%-" .. appname .. "%-zh%-cn[_%-]%d") then i18n_package = asset end + for _, search_prefix in ipairs({prefix, ""}) do + for _, asset in ipairs(release.assets or {}) do + local name = asset.name or "" + if name:find(search_prefix, 1, true) == 1 and name:sub(-#extension) == extension then + name = name:sub(#search_prefix + 1) + if not main and name:match("^luci%-app%-" .. appname .. "[_%-]%d") then main = asset end + if not i18n_package and name:match("^luci%-i18n%-" .. appname .. "%-zh%-cn[_%-]%d") then i18n_package = asset end + end end + if main and i18n_package then break end + end + if not main or not i18n_package then + return {code = 1, error = i18n.translate("Get remote version info failed.")} end - if not main or not i18n_package then return {code = 1} end return { code = 0, has_update = has_update, local_version = local_version, remote_version = remote_version, html_url = release.html_url, diff --git a/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/luci-app-passwall/luasrc/passwall/util_sing-box.lua index 9a09e4758..d1d2338d2 100644 --- a/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -96,8 +96,8 @@ function gen_outbound(flag, node, tag, proxy_table) local proxy_tag, fragment, record_fragment if proxy_table ~= nil and type(proxy_table) == "table" then proxy_tag = proxy_table.tag or nil - fragment = (proxy_table.fragment and node.protocol ~= "naive" and not node.hysteria2_realms) and true or nil - record_fragment = (proxy_table.record_fragment and node.protocol ~= "naive" and not node.hysteria2_realms) and true or nil + fragment = (proxy_table.fragment and node.protocol ~= "naive" and node.protocol ~= "masque" and not node.hysteria2_realms) and true or nil + record_fragment = (proxy_table.record_fragment and node.protocol ~= "naive" and node.protocol ~= "masque" and not node.hysteria2_realms) and true or nil end if node.type ~= "sing-box" then @@ -227,7 +227,14 @@ function gen_outbound(flag, node, tag, proxy_table) end local tls = nil - if node.protocol == "hysteria" or node.protocol == "hysteria2" or node.protocol == "tuic" or node.protocol == "naive" then + if node.protocol == "masque" then + node.alpn = nil + node.utls = nil + node.reality = nil + node.transport = nil + node.mux = nil + end + if node.protocol == "hysteria" or node.protocol == "hysteria2" or node.protocol == "tuic" or node.protocol == "naive" or node.protocol == "masque" then node.tls = "1" end if node.tls == "1" then @@ -687,6 +694,25 @@ function gen_outbound(flag, node, tag, proxy_table) } end + if node.protocol == "masque" then + local headers = {} + if node.user_agent and node.user_agent ~= "" then + headers["user-agent"] = { node.user_agent } + end + for line in (node.masque_headers or ""):gsub("\\n", "\n"):gmatch("[^\r\n]+") do + local key, value = line:match("^%s*([^:]+):%s*(.-)%s*$") + if key then headers[api.trim(key):lower()] = { value } end + end + protocol_table = { + type = "masque-client", + username = not headers.authorization and node.username or nil, + password = not headers.authorization and node.password or nil, + path = (node.masque_path and node.masque_path ~= "") and node.masque_path or nil, + headers = next(headers) and headers or nil, + tls = tls + } + end + if node.protocol == "snell" then protocol_table = { version = tonumber(node.snell_version), @@ -716,6 +742,11 @@ function gen_config_server(node) { type = "direct", tag = "direct" } } + if node.protocol == "masque" then + node.tls = "1" + node.reality = nil + end + local tls = { enabled = true, certificate_path = (node.tls_use_pem ~= "1") and node.tls_certificateFile or nil, @@ -827,7 +858,7 @@ function gen_config_server(node) local user = api.uci_get_s(v) or {} if user[".type"] == "user" then local u = {} - if node.protocol == "mixed" or node.protocol == "socks" or node.protocol == "http" or node.protocol == "naive" then + if node.protocol == "mixed" or node.protocol == "socks" or node.protocol == "http" or node.protocol == "naive" or node.protocol == "masque" then u.username = user.username u.password = user.password end @@ -874,6 +905,18 @@ function gen_config_server(node) local protocol_table = nil + if node.protocol == "masque" then + tls.alpn = nil + protocol_table = { + type = "masque-server", + users = users, + path = (node.masque_path and node.masque_path ~= "") and node.masque_path or nil, + address = node.masque_address, + mtu = tonumber(node.masque_mtu or 1280), + tls = tls + } + end + if node.protocol == "mixed" then protocol_table = { users = users, @@ -1069,8 +1112,7 @@ function gen_config_server(node) end end - if node.protocol == "wireguard" then - inbound.listen = nil + if node.protocol == "wireguard" or node.protocol == "masque" then table.insert(endpoints, inbound) else table.insert(inbounds, inbound) @@ -2389,6 +2431,9 @@ function gen_config(var) } endpoints[#endpoints + 1] = endpoint table.remove(config.outbounds, i) + elseif value.type == "masque-client" then + endpoints[#endpoints + 1] = value + table.remove(config.outbounds, i) end end if #endpoints > 0 then diff --git a/luci-app-passwall/luasrc/passwall/util_xray.lua b/luci-app-passwall/luasrc/passwall/util_xray.lua index 4e7b7de4d..e48c7e1d9 100644 --- a/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -135,6 +135,13 @@ function gen_outbound(flag, node, tag, proxy_table) node.transport = "hysteria" node.stream_security = "tls" end + if node.protocol == "masque" then + node.transport = "masque" + node.stream_security = "tls" + node.alpn = nil + node.utls = nil + node.finalmask = nil + end if node.protocol == "http" and node.stream_security == "tls" then node.transport = "raw" @@ -152,13 +159,13 @@ function gen_outbound(flag, node, tag, proxy_table) _flag_proxy_tag = proxy_tag, tag = tag, protocol = node.protocol, - mux = { + mux = (node.protocol ~= "masque") and { enabled = (node.mux == "1") and true or false, concurrency = (node.mux == "1" and ((node.mux_concurrency) and tonumber(node.mux_concurrency) or -1)) or nil, xudpConcurrency = (node.mux == "1" and ((node.xudp_concurrency) and tonumber(node.xudp_concurrency) or 8)) or nil } or nil, -- 底层传输配置 - streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria" or node.protocol == "http") and { + streamSettings = (node.streamSettings or dialer_proxy_tag or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria" or node.protocol == "http" or node.protocol == "masque") and { sockopt = { mark = 255, domainStrategy = node.domain_strategy or "UseIP", @@ -268,6 +275,23 @@ function gen_outbound(flag, node, tag, proxy_table) return api.cleanEmptyTables(extra) end)() } or nil, + masqueSettings = (node.protocol == "masque") and { + path = (node.masque_path and node.masque_path ~= "") and node.masque_path or nil, + headers = (function() + local headers = {} + if (node.username and node.username ~= "") or (node.password and node.password ~= "") then + headers["authorization"] = "Basic " .. api.base64Encode((node.username or "") .. ":" .. (node.password or "")) + end + if node.user_agent and node.user_agent ~= "" then + headers["user-agent"] = node.user_agent + end + for line in (node.masque_headers or ""):gsub("\\n", "\n"):gmatch("[^\r\n]+") do + local key, value = line:match("^%s*([^:]+):%s*(.-)%s*$") + if key then headers[api.trim(key):lower()] = value end + end + return next(headers) and headers or nil + end)() + } or nil, hysteriaSettings = (node.transport == "hysteria") and { version = 2, auth = node.hysteria2_auth_password @@ -516,6 +540,10 @@ function gen_config_server(node) u.user = user.username u.pass = user.password end + if node.protocol == "masque" then + u.email = user.username + u.pass = user.password + end if node.protocol == "shadowsocks" or node.protocol == "trojan" then u.email = user.username u.password = user.password @@ -586,6 +614,18 @@ function gen_config_server(node) version = 2, users = users } + elseif node.protocol == "masque" then + node.transport = "masque" + node.tls = "1" + node.reality = nil + node.alpn = nil + node.fallback = nil + node.finalmask = nil + settings = { + users = users, + address = node.masque_address, + mtu = tonumber(node.masque_mtu or 1280) + } elseif node.protocol == "tunnel" then settings = { allowedNetwork = node.d_protocol, @@ -695,6 +735,9 @@ function gen_config_server(node) streamSettings = { method = node.transport, security = "none", + masqueSettings = (node.protocol == "masque") and { + path = (node.masque_path and node.masque_path ~= "") and node.masque_path or nil + } or nil, tlsSettings = ("1" == node.tls) and { disableSystemRoot = false, certificates = { diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index b911ca8bb..332ddd8d7 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -2219,3 +2219,12 @@ msgstr "更新失败" msgid "Unknown error" msgstr "未知错误" + +msgid "MASQUE Path" +msgstr "MASQUE 路径" + +msgid "Custom HTTP Headers" +msgstr "自定义 HTTP 请求头" + +msgid "MASQUE Address" +msgstr "MASQUE 隧道地址" diff --git a/luci-app-passwall/root/usr/share/passwall/app.sh b/luci-app-passwall/root/usr/share/passwall/app.sh index f963d3362..847982c48 100755 --- a/luci-app-passwall/root/usr/share/passwall/app.sh +++ b/luci-app-passwall/root/usr/share/passwall/app.sh @@ -946,7 +946,7 @@ start_crontab() { clean_crontab if [ "$ENABLED" != "1" ]; then - /etc/init.d/cron restart + [ -x /etc/init.d/cron ] && /etc/init.d/cron restart >/dev/null 2>&1 return fi @@ -1042,13 +1042,13 @@ start_crontab() { echolog "运行于非代理模式,仅允许服务启停的定时任务。" fi - /etc/init.d/cron restart + [ -x /etc/init.d/cron ] && /etc/init.d/cron restart >/dev/null 2>&1 } stop_crontab() { [ "$1" = "cron" ] && return clean_crontab - /etc/init.d/cron restart + [ -x /etc/init.d/cron ] && /etc/init.d/cron restart >/dev/null 2>&1 #echolog "清除定时执行命令。" } diff --git a/luci-app-qosify/po/es/qosify.po b/luci-app-qosify/po/es/qosify.po index a563d3548..ab9726334 100644 --- a/luci-app-qosify/po/es/qosify.po +++ b/luci-app-qosify/po/es/qosify.po @@ -7,7 +7,7 @@ msgstr "" "Language-Team: Spanish \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"PO-Revision-Date: 2026-09-29 12:10+0000\n" +"PO-Revision-Date: 2026-10-01 12:16+0000\n" "X-Generator: Weblate 2026.10.dev0\n" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:882 @@ -1205,7 +1205,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1205 msgid "Upload" -msgstr "Carga" +msgstr "Cargar" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1210 msgid "Upload & Apply" diff --git a/luci-app-qosify/po/ko/qosify.po b/luci-app-qosify/po/ko/qosify.po index bfc30e617..d6028194d 100644 --- a/luci-app-qosify/po/ko/qosify.po +++ b/luci-app-qosify/po/ko/qosify.po @@ -7,27 +7,27 @@ msgstr "" "Language-Team: Korean \n" "Plural-Forms: nplurals=1; plural=0;\n" -"PO-Revision-Date: 2026-09-29 17:59+0000\n" -"X-Generator: Weblate 2026.10.dev0\n" +"PO-Revision-Date: 2026-10-01 18:42+0000\n" +"X-Generator: Weblate 2026.10\n" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:882 msgid "%d interface" msgid_plural "%d interfaces" -msgstr[0] "" +msgstr[0] "인터페이스 %d개" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:943 msgid "%d rule" msgid_plural "%d rules" -msgstr[0] "" +msgstr[0] "규칙 %d개" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:942 msgid "%d section" msgid_plural "%d sections" -msgstr[0] "" +msgstr[0] "섹션 %d개" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:682 msgid "%s (current)" -msgstr "" +msgstr "%s (현재)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:700 msgid "%s (from bandwidth)" @@ -84,7 +84,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:750 msgid "%s quick settings" -msgstr "" +msgstr "%s 빠른 설정" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2069 msgid "%s uploaded, qosify reloaded." @@ -192,7 +192,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:726 msgid "Automatic download rate" -msgstr "" +msgstr "자동 다운로드 속도" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:893 msgid "Autostart" @@ -218,7 +218,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:716 msgid "Basic" -msgstr "" +msgstr "기본" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:224 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2029 @@ -227,7 +227,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:79 msgid "CAKE diffserv mode" -msgstr "" +msgstr "CAKE 차등 서비스(Diffserv) 모드" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:89 msgid "CAKE egress options" @@ -265,7 +265,7 @@ msgstr "취소" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1214 msgid "Check Devices" -msgstr "" +msgstr "장치 확인" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1193 msgid "Choose file…" @@ -273,7 +273,7 @@ msgstr "파일 선택…" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1114 msgid "Classes" -msgstr "" +msgstr "클래스" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1737 msgid "Cleanup exited with code %d" @@ -342,7 +342,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1404 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1627 msgid "DNS Entries" -msgstr "" +msgstr "DNS 항목" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1364 msgid "DNS Entries (%d of %d)" @@ -362,7 +362,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:63 msgid "DSCP value for ICMP packets" -msgstr "" +msgstr "ICMP 패킷의 DSCP 값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:65 msgid "DSCP value for bulk-marked packets" @@ -386,15 +386,15 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1021 msgid "DSCP values" -msgstr "" +msgstr "DSCP 값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:61 msgid "Default DSCP value for TCP packets" -msgstr "" +msgstr "TCP 패킷의 기본 DSCP 값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:62 msgid "Default DSCP value for UDP packets" -msgstr "" +msgstr "UDP 패킷의 기본 DSCP 값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:60 msgid "Default timeout for dynamically added entries" @@ -403,7 +403,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1022 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1217 msgid "Defaults" -msgstr "" +msgstr "기본값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1022 msgid "" @@ -424,7 +424,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1714 msgid "Device check failed: %s" -msgstr "" +msgstr "장치 확인 실패: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:859 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:881 @@ -451,7 +451,7 @@ msgstr "다운로드 대역폭" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:724 msgid "Download shaping" -msgstr "" +msgstr "다운로드 셰이핑" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1197 msgid "Download the current files from the router." @@ -496,11 +496,11 @@ msgstr "CAKE 호스트 격리 활성화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:78 msgid "Enable egress shaping" -msgstr "" +msgstr "이그레스 셰이핑 활성화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:77 msgid "Enable ingress shaping" -msgstr "" +msgstr "인그레스 셰이핑 활성화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:881 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:930 @@ -509,7 +509,7 @@ msgstr "활성화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:858 msgid "Enabled — Not Running" -msgstr "" +msgstr "활성화 — 실행 중 아님" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:857 msgid "Enabled — Not Shaping (check config)" @@ -517,7 +517,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:711 msgid "Encapsulation overhead" -msgstr "" +msgstr "캡슐화 오버헤드" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2182 msgid "Enter a section name." @@ -538,7 +538,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:941 msgid "Entries" -msgstr "" +msgstr "항목" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1848 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1961 @@ -587,7 +587,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:667 msgid "Files" -msgstr "" +msgstr "파일" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 msgid "Found (empty or invalid)" @@ -604,12 +604,12 @@ msgstr "호스트 격리" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1156 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1169 msgid "IPv4 address, e.g. 1.1.1.1" -msgstr "" +msgstr "IPv4 주소 (예: 1.1.1.1)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1156 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1170 msgid "IPv6 address, e.g. ff01::1" -msgstr "" +msgstr "IPv6 주소 (예: ff01::1)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:738 msgid "Ingress CAKE options" @@ -640,15 +640,15 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1402 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1404 msgid "Loading..." -msgstr "" +msgstr "불러오는 중..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1212 msgid "Maintenance" -msgstr "" +msgstr "유지 관리" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:710 msgid "Manual overhead" -msgstr "" +msgstr "수동 오버헤드" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1166 msgid "Mapping file syntax" @@ -670,7 +670,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:734 msgid "Minimum packet unit (MPU)" -msgstr "" +msgstr "최소 패킷 단위 (MPU)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:883 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 @@ -690,7 +690,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:727 msgid "NAT awareness" -msgstr "" +msgstr "NAT 인식" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 msgid "Netdev to enable QoS on. Required." @@ -769,7 +769,7 @@ msgstr "옵션" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:730 msgid "Overhead" -msgstr "" +msgstr "오버헤드" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:731 msgid "Overhead preset" @@ -804,7 +804,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:717 msgid "QoS Enabled" -msgstr "" +msgstr "QoS 활성화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:721 msgid "Queueing mode" @@ -812,11 +812,11 @@ msgstr "대기열 관리 모드" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1163 msgid "Quick Add" -msgstr "" +msgstr "빠른 추가" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1016 msgid "Quick Add: %s" -msgstr "" +msgstr "빠른 추가: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2153 msgid "Range start must not exceed end." @@ -824,11 +824,11 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1213 msgid "Re-check devices" -msgstr "" +msgstr "장치 재확인" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1707 msgid "Re-checking devices..." -msgstr "" +msgstr "장치를 다시 확인 중..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:768 msgid "Re-read the mapping files only" @@ -856,11 +856,11 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1681 msgid "Reading tc output..." -msgstr "" +msgstr "tc 출력을 읽는 중..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1019 msgid "Reference" -msgstr "" +msgstr "참조" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:765 msgid "Reload" @@ -868,7 +868,7 @@ msgstr "다시 불러오기" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:765 msgid "Reload Rules" -msgstr "" +msgstr "규칙 다시 불러오기" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1695 msgid "Reload failed: %s" @@ -881,7 +881,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1219 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2086 msgid "Reset" -msgstr "" +msgstr "초기화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2120 msgid "Reset failed: %s" @@ -889,7 +889,7 @@ msgstr "초기화 실패: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2084 msgid "Reset to defaults" -msgstr "" +msgstr "기본값으로 초기화" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2116 msgid "Reset to defaults, applied." @@ -905,7 +905,7 @@ msgstr "재시작" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1203 msgid "Restore" -msgstr "" +msgstr "복원" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1218 msgid "Restore qosify defaults" @@ -930,15 +930,15 @@ msgstr "규칙: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:868 msgid "Running & Shaping" -msgstr "" +msgstr "실행 중 및 셰이핑 중" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:869 msgid "Running — Not Shaping" -msgstr "" +msgstr "실행 중 — 셰이핑 중 아님" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:867 msgid "Running — Shaping Unknown" -msgstr "" +msgstr "실행 중 — 셰이핑 상태 알 수 없음" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:753 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1147 @@ -998,7 +998,7 @@ msgstr "서비스" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1745 msgid "Service action failed: %s" -msgstr "" +msgstr "서비스 작업 실패: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1817 msgid "Settings saved" @@ -1007,7 +1007,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:723 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:894 msgid "Shaping" -msgstr "" +msgstr "셰이핑" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:941 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1198 @@ -1030,7 +1030,7 @@ msgstr "상태" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:855 msgid "Status Unknown" -msgstr "" +msgstr "상태 알 수 없음" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:766 msgid "Stop" @@ -1075,7 +1075,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:596 msgid "Traffic shaping and DSCP classification via qosify" -msgstr "" +msgstr "QoSify를 통한 트래픽 셰이핑 및 DSCP 분류" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1168 msgid "UDP single port, or range from to " @@ -1129,7 +1129,7 @@ msgstr "업로드 실패: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:725 msgid "Upload shaping" -msgstr "" +msgstr "업로드 셰이핑" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2035 msgid "Uploading" @@ -1141,7 +1141,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:735 msgid "VLAN tags" -msgstr "" +msgstr "VLAN 태그" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 msgid "Valid" @@ -1155,7 +1155,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1707 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1724 msgid "Working" -msgstr "" +msgstr "작업 중" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1870 msgid "Write empty file" @@ -1219,7 +1219,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1160 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2129 msgid "e.g. %s" -msgstr "" +msgstr "예: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1171 msgid "fnmatch() pattern supporting * and ? as wildcard characters" @@ -1235,7 +1235,7 @@ msgstr "비활성" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1109 msgid "ingress %s, egress %s" -msgstr "" +msgstr "인그레스 %s, 이그레스 %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:282 msgid "ingress and egress are both 0 — nothing is shaped" @@ -1324,7 +1324,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1253 msgid "qosify-status" -msgstr "" +msgstr "qosify-status" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1682 msgid "qosify-status returned no output." @@ -1364,7 +1364,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1160 msgid "value" -msgstr "" +msgstr "값" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:902 msgid "yes" diff --git a/luci-app-qosify/po/ru/qosify.po b/luci-app-qosify/po/ru/qosify.po index 5f0bb4a33..21d669838 100644 --- a/luci-app-qosify/po/ru/qosify.po +++ b/luci-app-qosify/po/ru/qosify.po @@ -9,7 +9,7 @@ msgstr "" "luciapplicationsqosify/ru/>\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"PO-Revision-Date: 2026-09-28 18:23+0000\n" +"PO-Revision-Date: 2026-10-01 12:16+0000\n" "X-Generator: Weblate 2026.10.dev0\n" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:882 @@ -29,70 +29,82 @@ msgstr[2] "%d правил" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:942 msgid "%d section" msgid_plural "%d sections" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "секция" +msgstr[1] "секции" +msgstr[2] "секций" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:682 msgid "%s (current)" -msgstr "" +msgstr "%s (текущий)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:700 msgid "%s (from bandwidth)" -msgstr "" +msgstr "%s (из полосы пропускания)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2085 msgid "" "%s and %s are replaced with the templates shipped by the qosify package, and " "shaping is left disabled." msgstr "" +"%s и %s заменяются шаблонами, поставляемыми пакетом qosify, а шейпинг " +"остаётся отключённым." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1809 msgid "" "%s came back empty although it is %d bytes on disk — refusing to overwrite it" -msgstr "" +msgstr "%s вернулся пустым, хотя на диске %d байт — отказываюсь перезаписывать" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2346 msgid "%s changed on disk — your unsaved edits are still in the editor." -msgstr "" +msgstr "%s изменён на диске — ваши несохранённые правки остаются в редакторе." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2249 msgid "" "%s could not be read — the editor is left empty and will not be saved over " "it." msgstr "" +"%s не удалось прочитать — редактор оставлен пустым, и сохранение поверх него " +"выполнено не будет." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1833 msgid "" "%s has changed since this editor was loaded. Saving now discards those " "changes." msgstr "" +"%s изменён с момента загрузки этого редактора. Сохранение сейчас отбросит " +"эти изменения." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1864 msgid "" "%s is %d bytes on disk but was never loaded into the editor — refusing to " "truncate it. Reload the page first." msgstr "" +"%s занимает %d байт на диске, но не был загружен в редактор — отказываюсь " +"его усекать. Сначала перезагрузите страницу." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:364 msgid "%s is a list in this section — edit %s directly" -msgstr "" +msgstr "%s является списком в этой секции — редактируйте %s напрямую" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1656 msgid "" "%s is not in the defaults list — qosify does not load it, so the Rules tab " "has no effect" msgstr "" +"%s отсутствует в списке defaults — qosify его не загружает, поэтому вкладка «" +"Правила» не действует" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:286 msgid "" "%s is set to \"%s\" — qosify converts it with atoi(), so anything but a non-" "zero number means off" msgstr "" +"%s установлено в \\\"%s\\\" — qosify преобразует его через atoi(), поэтому " +"всё, кроме ненулевого числа, означает «выключено»" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:750 msgid "%s quick settings" -msgstr "" +msgstr "%s быстрые настройки" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2069 msgid "%s uploaded, qosify reloaded." @@ -100,11 +112,11 @@ msgstr "%s загружен, qosify перезапущен." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2103 msgid "%s was not written: %s" -msgstr "" +msgstr "%s не был записан: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2106 msgid "%s was reset but %s was not written: %s" -msgstr "" +msgstr "%s был сброшен, но %s не был записан: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1945 msgid "%s, applied (QoS disabled)." @@ -114,6 +126,8 @@ msgstr "%s применено (QoS отключён)." msgid "" "%s, applied — qosify did not answer on ubus, so shaping could not be checked." msgstr "" +"%s, применено — qosify не ответил через ubus, поэтому шейпинг не удалось " +"проверить." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1947 msgid "%s, applied." @@ -123,30 +137,40 @@ msgstr "%s применено." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:468 msgid "%s: %s \"%s\" is not a class, codepoint or 0-63 — qosify ignores it" msgstr "" +"%s: %s \"%s\" не является классом, кодовой точкой или значением 0–63 — " +"qosify игнорирует это" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:456 msgid "" "%s: %s \"%s\" is not a class, codepoint or 0-63 — qosify rejects the whole " "config and interface changes are not applied" msgstr "" +"%s: %s \"%s\" не является классом, кодовой точкой или значением 0–63 — " +"qosify отклоняет всю конфигурацию, изменения интерфейса не применяются" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:465 msgid "" "%s: %s \"%s\" is not a codepoint or 0-63 — qosify drops this class and the " "rules using it" msgstr "" +"%s: %s \"%s\" не является кодовой точкой или значением 0–63 — qosify " +"отбрасывает этот класс и правила, которые его используют" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:448 msgid "" "%s: %s cannot be checked — write it as one plain or fully quoted value, with " "no backslash" msgstr "" +"%s: %s невозможно проверить — запишите его как одно простое или полностью " +"заключённое в кавычки значение, без обратной косой черты" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:451 msgid "" "%s: %s contains shell metacharacters — qosify runs the tc command with sh -c " "as root, so they would break it or be executed" msgstr "" +"%s: %s содержит метасимволы оболочки — qosify запускает команду tc через sh -" +"c от имени root, поэтому они либо сломают её, либо будут выполнены" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1109 msgid "(alias)" @@ -154,7 +178,7 @@ msgstr "(псевдоним)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:435 msgid "(this qosify build has no NQB)" -msgstr "" +msgstr "(в этой сборке qosify нет NQB)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2185 msgid "A config defaults section already exists." @@ -162,7 +186,7 @@ msgstr "Секция defaults конфигурации уже существуе #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2183 msgid "A section name may only contain letters, digits and underscores." -msgstr "" +msgstr "Имя секции может содержать только буквы, цифры и знаки подчёркивания." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:856 msgid "Active" @@ -175,19 +199,19 @@ msgstr "Добавить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:85 msgid "Adds atm, noatm or ptm when overhead_type is manual" -msgstr "" +msgstr "Добавляет atm, noatm или ptm, когда overhead_type = manual" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:87 msgid "Adds ether-vlan once per level (1 or 2)" -msgstr "" +msgstr "Добавляет ether-vlan по одному разу на уровень (1 или 2)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:86 msgid "Adds mpu " -msgstr "" +msgstr "Добавляет mpu " #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:84 msgid "Adds overhead when overhead_type is manual" -msgstr "" +msgstr "Добавляет overhead , когда overhead_type = manual" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:608 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:737 @@ -196,11 +220,11 @@ msgstr "Дополнительно" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1870 msgid "An empty %s stops all shaping. Continue?" -msgstr "" +msgstr "Пустой %s останавливает весь шейпинг. Продолжить?" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:726 msgid "Automatic download rate" -msgstr "" +msgstr "Автоматическая скорость загрузки" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:893 msgid "Autostart" @@ -208,7 +232,7 @@ msgstr "Автозапуск" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:924 msgid "Autostart state unknown — rpcd did not answer" -msgstr "" +msgstr "Состояние автозапуска неизвестно — rpcd не ответил" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:883 msgid "Available" @@ -216,17 +240,19 @@ msgstr "Доступно" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1196 msgid "Backup" -msgstr "" +msgstr "Резервная копия" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:76 msgid "" "Bandwidth for both directions, where bandwidth_up or bandwidth_down is not " "set" msgstr "" +"Полоса пропускания для обоих направлений, если bandwidth_up или " +"bandwidth_down не заданы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:716 msgid "Basic" -msgstr "" +msgstr "Основные" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:224 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2029 @@ -235,57 +261,59 @@ msgstr "Двоичное содержимое отклонено" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:79 msgid "CAKE diffserv mode" -msgstr "" +msgstr "Режим diffserv CAKE" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:89 msgid "CAKE egress options" -msgstr "" +msgstr "Параметры CAKE для исходящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:739 msgid "CAKE egress options, space separated." -msgstr "" +msgstr "Параметры CAKE для исходящего трафика, разделённые пробелами." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:88 msgid "CAKE ingress options" -msgstr "" +msgstr "Параметры CAKE для входящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:738 msgid "CAKE ingress options, space separated." -msgstr "" +msgstr "Параметры CAKE для входящего трафика, разделённые пробелами." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:90 msgid "CAKE options for ingress + egress" -msgstr "" +msgstr "Параметры CAKE для входящего + исходящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:83 msgid "" "CAKE overhead keyword added to options; manual uses overhead and " "overhead_encap" msgstr "" +"Ключевое слово overhead CAKE, добавляемое к параметрам; manual использует " +"overhead и overhead_encap" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:731 msgid "CAKE overhead keyword. Use none if unsure." -msgstr "" +msgstr "Ключевое слово overhead CAKE. Если не уверены, используйте none." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:564 msgid "Cancel" -msgstr "" +msgstr "Отмена" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1214 msgid "Check Devices" -msgstr "" +msgstr "Проверить устройства" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1193 msgid "Choose file…" -msgstr "" +msgstr "Выбрать файл…" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1114 msgid "Classes" -msgstr "" +msgstr "Классы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1737 msgid "Cleanup exited with code %d" -msgstr "" +msgstr "Очистка завершилась с кодом %d" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1146 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1990 @@ -295,12 +323,12 @@ msgstr "Очистить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1869 msgid "Clear configuration" -msgstr "" +msgstr "Очистить конфигурацию" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1990 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1996 msgid "Clear editor" -msgstr "" +msgstr "Очистить редактор" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:929 msgid "Click to disable autostart" @@ -312,7 +340,7 @@ msgstr "Нажмите, чтобы включить автозапуск" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:740 msgid "Common CAKE options" -msgstr "" +msgstr "Общие параметры CAKE" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:604 msgid "Config" @@ -324,7 +352,7 @@ msgstr "Конфигурация очищена, qosify остановлен." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1894 msgid "Config cleared." -msgstr "" +msgstr "Конфигурация очищена." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1915 msgid "Config saved" @@ -337,28 +365,28 @@ msgstr "Конфигурация: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:566 msgid "Continue" -msgstr "" +msgstr "Продолжить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1245 msgid "Could not read %s: %s" -msgstr "" +msgstr "Не удалось прочитать %s: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:607 msgid "Counters" -msgstr "" +msgstr "Счётчики" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1404 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1627 msgid "DNS Entries" -msgstr "" +msgstr "Записи DNS" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1364 msgid "DNS Entries (%d of %d)" -msgstr "" +msgstr "Записи DNS (%d из %d)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1364 msgid "DNS Entries (%d)" -msgstr "" +msgstr "Записи DNS (%d)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1021 msgid "" @@ -367,51 +395,55 @@ msgid "" "accepted too, and any dscp_* value may also name a class. Prefix with + to " "override only when the DSCP field is zero." msgstr "" +"Кодовые точки DSCP: CS0–CS7, AF11–AF43, EF, VA, NQB, LE, DF (NQB требует " +"qosify от июня 2026; в 24.10 и 25.12 его нет). Необработанное значение от 0 " +"до 63 тоже принимается, и любое значение dscp_* может также указывать имя " +"класса. Префикс + означает переопределять только когда поле DSCP равно нулю." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:63 msgid "DSCP value for ICMP packets" -msgstr "" +msgstr "Значение DSCP для пакетов ICMP" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:65 msgid "DSCP value for bulk-marked packets" -msgstr "" +msgstr "Значение DSCP для пакетов, помеченных как bulk" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:71 msgid "DSCP value for egress" -msgstr "" +msgstr "Значение DSCP для исходящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:70 msgid "DSCP value for ingress" -msgstr "" +msgstr "Значение DSCP для входящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:69 msgid "DSCP value for ingress and egress, where they are not set" -msgstr "" +msgstr "Значение DSCP для входящего и исходящего трафика, если они не заданы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:64 msgid "DSCP value for priority-marked packets" -msgstr "" +msgstr "Значение DSCP для пакетов, помеченных как приоритетные" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1021 msgid "DSCP values" -msgstr "" +msgstr "Значения DSCP" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:61 msgid "Default DSCP value for TCP packets" -msgstr "" +msgstr "Значение DSCP по умолчанию для пакетов TCP" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:62 msgid "Default DSCP value for UDP packets" -msgstr "" +msgstr "Значение DSCP по умолчанию для пакетов UDP" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:60 msgid "Default timeout for dynamically added entries" -msgstr "" +msgstr "Тайм-аут по умолчанию для динамически добавляемых записей" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1022 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1217 msgid "Defaults" -msgstr "" +msgstr "Значения по умолчанию" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1022 msgid "" @@ -429,15 +461,15 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 msgid "Device" -msgstr "" +msgstr "Устройство" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1711 msgid "Device check done." -msgstr "" +msgstr "Проверка устройств завершена." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1714 msgid "Device check failed: %s" -msgstr "" +msgstr "Проверка устройств не удалась: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:859 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:881 @@ -447,11 +479,13 @@ msgstr "Отключено" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:75 msgid "Downlink bandwidth (same format as tc)" -msgstr "" +msgstr "Полоса пропускания нисходящего канала (тот же формат, что и в tc)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:720 msgid "Downlink bandwidth, same format as tc. Set just below line speed." msgstr "" +"Полоса пропускания нисходящего канала, тот же формат, что и в tc. Установите " +"чуть ниже скорости линии." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1198 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1247 @@ -460,15 +494,15 @@ msgstr "Скачать" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:720 msgid "Download bandwidth" -msgstr "" +msgstr "Скорость загрузки" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:724 msgid "Download shaping" -msgstr "" +msgstr "Шейпинг загрузки" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1197 msgid "Download the current files from the router." -msgstr "" +msgstr "Скачать текущие файлы с маршрутизатора." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1166 msgid "" @@ -476,10 +510,14 @@ msgid "" "raw value, a codepoint like CS0, or a class name. DNS entries are compared " "in the order in which they are specified, using the first matching entry." msgstr "" +"Каждая строка содержит два разделённых пробелами поля: match и dscp. dscp " +"может быть необработанным значением, кодовой точкой вроде CS0 или именем " +"класса. Записи DNS сравниваются в порядке указания, используется первая " +"подходящая запись." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:739 msgid "Egress CAKE options" -msgstr "" +msgstr "Параметры CAKE для исходящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2020 msgid "Empty file" @@ -489,31 +527,35 @@ msgstr "Пустой файл" msgid "" "Empty the config editor? Nothing is written until you click Save & Apply." msgstr "" +"Очистить редактор конфигурации? Ничего не записывается, пока вы не нажмёте «" +"Сохранить и применить»." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1996 msgid "" "Empty the rules editor? Nothing is written until you click Save & Apply." msgstr "" +"Очистить редактор правил? Ничего не записывается, пока вы не нажмёте «" +"Сохранить и применить»." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:80 msgid "Enable CAKE NAT host detection via conntrack" -msgstr "" +msgstr "Включить определение хостов NAT в CAKE через conntrack" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:82 msgid "Enable CAKE automatic rate estimation for ingress" -msgstr "" +msgstr "Включить автоматическую оценку скорости CAKE для входящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:81 msgid "Enable CAKE host isolation" -msgstr "" +msgstr "Включить изоляцию хостов CAKE" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:78 msgid "Enable egress shaping" -msgstr "" +msgstr "Включить шейпинг исходящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:77 msgid "Enable ingress shaping" -msgstr "" +msgstr "Включить шейпинг входящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:881 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:930 @@ -530,11 +572,11 @@ msgstr "Включено — шейпинг не выполняется (про #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:711 msgid "Encapsulation overhead" -msgstr "" +msgstr "Накладные расходы инкапсуляции" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2182 msgid "Enter a section name." -msgstr "" +msgstr "Введите имя секции." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2156 msgid "Enter a single IPv4 address (qosify does not accept CIDR)." @@ -543,7 +585,7 @@ msgstr "Введите один IPv4-адрес (qosify не принимает #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2159 msgid "" "Enter a single IPv6 address (qosify does not accept CIDR or a %zone suffix)." -msgstr "" +msgstr "Введите один IPv6-адрес (qosify не принимает CIDR или суффикс %zone)." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2141 msgid "Enter a value." @@ -551,7 +593,7 @@ msgstr "Введите значение." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:941 msgid "Entries" -msgstr "" +msgstr "Записи" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1848 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1961 @@ -567,6 +609,8 @@ msgid "" "Error: invalid characters in bandwidth or options fields. Use alphanumeric, " "spaces, hyphens, dots, colons only." msgstr "" +"Ошибка: недопустимые символы в полях bandwidth или options. Используйте " +"только буквы, цифры, пробелы, дефисы, точки и двоеточия." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1798 msgid "Error: name must be a device or interface name" @@ -578,7 +622,7 @@ msgstr "Ошибка: overhead должен быть целым числом б #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1768 msgid "Error: overhead_mpu must be a whole number of bytes" -msgstr "" +msgstr "Ошибка: overhead_mpu должно быть целым числом байтов" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2070 msgid "Errors:" @@ -588,11 +632,11 @@ msgstr "Ошибки:" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1198 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1205 msgid "File" -msgstr "" +msgstr "Файл" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1832 msgid "File changed on disk" -msgstr "" +msgstr "Файл изменён на диске" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2021 msgid "File too large (max 64KB)" @@ -600,7 +644,7 @@ msgstr "Файл слишком большой (макс. 64 КБ)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:667 msgid "Files" -msgstr "" +msgstr "Файлы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 msgid "Found (empty or invalid)" @@ -612,25 +656,25 @@ msgstr "Предоставить доступ к LuCI-приложению Qosif #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:728 msgid "Host isolation" -msgstr "" +msgstr "Изоляция хостов" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1156 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1169 msgid "IPv4 address, e.g. 1.1.1.1" -msgstr "" +msgstr "IPv4-адрес, например 1.1.1.1" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1156 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1170 msgid "IPv6 address, e.g. ff01::1" -msgstr "" +msgstr "IPv6-адрес, например ff01::1" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:738 msgid "Ingress CAKE options" -msgstr "" +msgstr "Параметры CAKE для входящего трафика" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 msgid "Interface" -msgstr "" +msgstr "Интерфейс" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:741 msgid "Invalid CAKE options can stop qosify starting." @@ -638,52 +682,56 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1173 msgid "Like dns:... but only matches cname entries" -msgstr "" +msgstr "Как dns:..., но совпадает только с записями cname" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:228 msgid "" "Line %d is longer than 1023 characters — the rule loader reads fixed-size " "lines and would split it, comment included" msgstr "" +"Строка %d длиннее 1023 символов — загрузчик правил читает строки " +"фиксированного размера и разделит её, включая комментарий" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:59 msgid "List of files with port/IP/host mappings" -msgstr "" +msgstr "Список файлов с сопоставлениями порт/IP/хост" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1402 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1404 msgid "Loading..." -msgstr "" +msgstr "Загрузка..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1212 msgid "Maintenance" -msgstr "" +msgstr "Обслуживание" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:710 msgid "Manual overhead" -msgstr "" +msgstr "Overhead вручную" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1166 msgid "Mapping file syntax" -msgstr "" +msgstr "Синтаксис файла сопоставлений" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1692 msgid "Mapping files reloaded." -msgstr "" +msgstr "Файлы сопоставлений перезагружены." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:434 msgid "" "Match not loaded by qosify on line %s — ports 1-65534, one IPv4/IPv6 address " "(no CIDR or %%zone), or dns:, dns_c:" msgstr "" +"Match не загружен qosify в строке %s — порты 1–65534, один IPv4/IPv6-адрес " +"(без CIDR или %%zone), либо dns:, dns_c:" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:66 msgid "Maximum average packet length for marking a flow as priority" -msgstr "" +msgstr "Максимальная средняя длина пакета для пометки потока как приоритетного" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:734 msgid "Minimum packet unit (MPU)" -msgstr "" +msgstr "Минимальная единица пакета (MPU)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:883 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 @@ -693,37 +741,39 @@ msgstr "Отсутствует" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:941 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1198 msgid "Modified" -msgstr "" +msgstr "Изменён" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:433 msgid "" "More than two fields on line %s — qosify reads the rest of the line as the " "DSCP value and skips it" msgstr "" +"Больше двух полей в строке %s — qosify читает остаток строки как значение " +"DSCP и пропускает её" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:727 msgid "NAT awareness" -msgstr "" +msgstr "Учёт NAT" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 msgid "Netdev to enable QoS on. Required." -msgstr "" +msgstr "Netdev, на котором включить QoS. Обязательно." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1585 msgid "No CAKE tin statistics." -msgstr "" +msgstr "Нет статистики tin CAKE." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1628 msgid "No DNS entries." -msgstr "" +msgstr "Нет записей DNS." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:432 msgid "No DSCP target on line %s — qosify skips single-field lines" -msgstr "" +msgstr "Нет цели DSCP в строке %s — qosify пропускает строки с одним полем" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1107 msgid "No classes defined in %s" -msgstr "" +msgstr "В %s не определены классы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2142 msgid "No classes defined. Add classes in the Config tab first." @@ -733,7 +783,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1191 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1192 msgid "No file selected" -msgstr "" +msgstr "Файл не выбран" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2006 msgid "No files selected." @@ -741,11 +791,11 @@ msgstr "Файлы не выбраны." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1585 msgid "No output." -msgstr "" +msgstr "Нет вывода." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1568 msgid "No per-class counters." -msgstr "" +msgstr "Нет счётчиков по классам." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2154 msgid "No spaces or # allowed in patterns or addresses." @@ -767,17 +817,19 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:67 msgid "Number of packets per second to trigger bulk flow detection" -msgstr "" +msgstr "Число пакетов в секунду для запуска обнаружения bulk-потока" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1161 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1174 msgid "Only override the DSCP value if it is zero" -msgstr "" +msgstr "Переопределять значение DSCP только если оно равно нулю" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:704 msgid "" "Only sent with host isolation on — add nat to common CAKE options to force it" msgstr "" +"Отправляется только при включённой изоляции хостов — добавьте nat в общие " +"параметры CAKE, чтобы принудительно включить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1017 msgid "Options" @@ -785,11 +837,11 @@ msgstr "Параметры" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:730 msgid "Overhead" -msgstr "" +msgstr "Overhead" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:731 msgid "Overhead preset" -msgstr "" +msgstr "Предустановка overhead" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:603 msgid "Overview" @@ -797,17 +849,20 @@ msgstr "Обзор" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1834 msgid "Overwrite" -msgstr "" +msgstr "Перезаписать" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2007 msgid "Overwrite config files" -msgstr "" +msgstr "Перезаписать файлы конфигурации" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1172 msgid "" "POSIX.2 extended regular expression for matching hostnames. Only works if " "dns lookups are passed to qosify via the add_dns_host ubus call." msgstr "" +"Расширенное регулярное выражение POSIX.2 для сопоставления имён хостов. " +"Работает только если DNS-запросы передаются в qosify через ubus-вызов " +"add_dns_host." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2150 msgid "Port must be 1-65534 (qosify rejects 65535)." @@ -816,7 +871,7 @@ msgstr "Порт должен быть в диапазоне 1–65534 (qosify #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2146 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2149 msgid "Port must be a number or a range (4500, 5060-5061)." -msgstr "" +msgstr "Порт должен быть числом или диапазоном (4500, 5060-5061)." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:717 msgid "QoS Enabled" @@ -824,15 +879,15 @@ msgstr "QoS включён" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:721 msgid "Queueing mode" -msgstr "" +msgstr "Режим очередей" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1163 msgid "Quick Add" -msgstr "" +msgstr "Быстрое добавление" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1016 msgid "Quick Add: %s" -msgstr "" +msgstr "Быстрое добавление: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2153 msgid "Range start must not exceed end." @@ -840,19 +895,19 @@ msgstr "Начало диапазона не должно превышать к #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1213 msgid "Re-check devices" -msgstr "" +msgstr "Перепроверить устройства" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1707 msgid "Re-checking devices..." -msgstr "" +msgstr "Перепроверка устройств..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:768 msgid "Re-read the mapping files only" -msgstr "" +msgstr "Перечитать только файлы сопоставлений" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1690 msgid "Re-reading the mapping files..." -msgstr "" +msgstr "Перечитывание файлов сопоставлений..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1215 msgid "" @@ -872,11 +927,11 @@ msgstr "Чтение и проверка файлов..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1681 msgid "Reading tc output..." -msgstr "" +msgstr "Чтение вывода tc..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1019 msgid "Reference" -msgstr "" +msgstr "Справка" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:765 msgid "Reload" @@ -884,20 +939,20 @@ msgstr "Перезагрузить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:765 msgid "Reload Rules" -msgstr "" +msgstr "Перезагрузить правила" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1695 msgid "Reload failed: %s" -msgstr "" +msgstr "Перезагрузка не удалась: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1585 msgid "Requires write access." -msgstr "" +msgstr "Требуется доступ на запись." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1219 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2086 msgid "Reset" -msgstr "" +msgstr "Сброс" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2120 msgid "Reset failed: %s" @@ -905,7 +960,7 @@ msgstr "Ошибка сброса: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2084 msgid "Reset to defaults" -msgstr "" +msgstr "Сбросить к значениям по умолчанию" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2116 msgid "Reset to defaults, applied." @@ -921,7 +976,7 @@ msgstr "Перезапустить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1203 msgid "Restore" -msgstr "" +msgstr "Восстановить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1218 msgid "Restore qosify defaults" @@ -933,7 +988,7 @@ msgstr "Восстановление значений по умолчанию... #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:605 msgid "Rules" -msgstr "" +msgstr "Правила" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1976 msgid "Rules saved" @@ -954,12 +1009,12 @@ msgstr "Работает — шейпинг не выполняется" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:867 msgid "Running — Shaping Unknown" -msgstr "" +msgstr "Работает — шейпинг неизвестен" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:753 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1147 msgid "Save & Apply" -msgstr "Применить" +msgstr "Сохранить и применить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1825 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1897 @@ -987,6 +1042,8 @@ msgid "" "Section name is the class name that rules and dscp_* values refer to. " "qosify.init reads config alias the same way as config class." msgstr "" +"Имя секции — это имя класса, на которое ссылаются правила и значения dscp_*. " +"qosify.init читает config alias так же, как config class." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1724 msgid "Sending %s to qosify..." @@ -994,23 +1051,23 @@ msgstr "Отправка %s в qosify..." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:711 msgid "Sent to CAKE as atm, noatm or ptm." -msgstr "" +msgstr "Передаётся в CAKE как atm, noatm или ptm." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:735 msgid "Sent to CAKE as ether-vlan, once per tag." -msgstr "" +msgstr "Передаётся в CAKE как ether-vlan, по одному разу на тег." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:734 msgid "Sent to CAKE as mpu, in bytes." -msgstr "" +msgstr "Передаётся в CAKE как mpu, в байтах." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:710 msgid "Sent to CAKE as overhead, in bytes." -msgstr "" +msgstr "Передаётся в CAKE как overhead, в байтах." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:663 msgid "Service" -msgstr "" +msgstr "Служба" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1745 msgid "Service action failed: %s" @@ -1023,16 +1080,16 @@ msgstr "Настройки сохранены" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:723 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:894 msgid "Shaping" -msgstr "" +msgstr "Шейпинг" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:941 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1198 msgid "Size" -msgstr "" +msgstr "Размер" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:73 msgid "Skip this section" -msgstr "" +msgstr "Пропустить эту секцию" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:764 msgid "Start" @@ -1046,7 +1103,7 @@ msgstr "Статус" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:855 msgid "Status Unknown" -msgstr "" +msgstr "Состояние неизвестно" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:766 msgid "Stop" @@ -1054,40 +1111,45 @@ msgstr "Остановить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1167 msgid "TCP single port, or range from to " -msgstr "" +msgstr "Один порт TCP или диапазон от до " #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1680 msgid "The detailed tc output needs write access to this page." -msgstr "" +msgstr "Подробный вывод tc требует доступа на запись к этой странице." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:590 msgid "" "The qosify UCI configuration could not be loaded — class and interface lists " "may be incomplete." msgstr "" +"Не удалось загрузить конфигурацию UCI qosify — списки классов и интерфейсов " +"могут быть неполными." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1204 msgid "" "The selected files replace the ones on the router and qosify is reloaded." msgstr "" +"Выбранные файлы заменяют файлы на маршрутизаторе, и qosify перезагружается." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2008 msgid "" "The selected files replace the ones on the router and qosify is reloaded. " "Download a backup from this tab first if you need one." msgstr "" +"Выбранные файлы заменяют файлы на маршрутизаторе, и qosify перезагружается. " +"Если нужна резервная копия, сначала скачайте её на этой вкладке." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:68 msgid "Time below bulk_trigger_pps threshold until a bulk flow mark is removed" -msgstr "" +msgstr "Время ниже порога bulk_trigger_pps до снятия метки bulk-потока" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1402 msgid "Traffic by CAKE Tin" -msgstr "" +msgstr "Трафик по tin CAKE" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1401 msgid "Traffic by Class" -msgstr "" +msgstr "Трафик по классам" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:596 msgid "Traffic shaping and DSCP classification via qosify" @@ -1095,13 +1157,13 @@ msgstr "Шейпинг трафика и классификация DSCP с по #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1168 msgid "UDP single port, or range from to " -msgstr "" +msgstr "Один порт UDP или диапазон от до " #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:864 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:866 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:925 msgid "Unknown" -msgstr "" +msgstr "Неизвестно" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:435 msgid "Unknown class/DSCP target: %s" @@ -1109,31 +1171,33 @@ msgstr "Неизвестный класс/цель DSCP: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:717 msgid "Unticked sets disabled 1 and qosify skips this section." -msgstr "" +msgstr "Снятый флажок устанавливает disabled 1, и qosify пропускает эту секцию." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:74 msgid "Uplink bandwidth (same format as tc)" -msgstr "" +msgstr "Полоса пропускания восходящего канала (тот же формат, что и в tc)" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:719 msgid "Uplink bandwidth, same format as tc. Set just below line speed." msgstr "" +"Полоса пропускания восходящего канала, тот же формат, что и в tc. Установите " +"чуть ниже скорости линии." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1205 msgid "Upload" -msgstr "" +msgstr "Загрузить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1210 msgid "Upload & Apply" -msgstr "" +msgstr "Загрузить и применить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2009 msgid "Upload and apply" -msgstr "" +msgstr "Загрузить и применить" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:719 msgid "Upload bandwidth" -msgstr "" +msgstr "Скорость отдачи" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2061 msgid "Upload error: %s" @@ -1145,7 +1209,7 @@ msgstr "Загрузка не удалась: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:725 msgid "Upload shaping" -msgstr "" +msgstr "Шейпинг отдачи" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2035 msgid "Uploading" @@ -1153,11 +1217,11 @@ msgstr "Загрузка" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:892 msgid "Uptime" -msgstr "" +msgstr "Время работы" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:735 msgid "VLAN tags" -msgstr "" +msgstr "Теги VLAN" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:938 msgid "Valid" @@ -1177,7 +1241,7 @@ msgstr "Работает" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1870 msgid "Write empty file" -msgstr "" +msgstr "Записать пустой файл" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1906 msgid "Writing config and reloading qosify..." @@ -1192,42 +1256,52 @@ msgid "" "You have read-only access to this page, so editing and service control are " "disabled." msgstr "" +"У вас доступ к этой странице только для чтения, поэтому редактирование и " +"управление службой отключены." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:901 msgid "active" -msgstr "" +msgstr "активен" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1767 msgid "" "bandwidth_down does not look like a tc rate (100mbit, 12MBps, unlimited) — " "passing it through anyway" msgstr "" +"bandwidth_down не похоже на скорость tc (100mbit, 12MBps, unlimited) — всё " +"равно передаю как есть" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1766 msgid "" "bandwidth_up does not look like a tc rate (100mbit, 12MBps, unlimited) — " "passing it through anyway" msgstr "" +"bandwidth_up не похоже на скорость tc (100mbit, 12MBps, unlimited) — всё " +"равно передаю как есть" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1012 msgid "" "config device takes the same options, with name set to a netdev. nat " "defaults to 1 for interfaces and 0 for devices." msgstr "" +"config device принимает те же параметры, но name задаётся как netdev. nat по " +"умолчанию равен 1 для интерфейсов и 0 для устройств." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:900 msgid "device %s" -msgstr "" +msgstr "устройство %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:902 msgid "device: %s, ingress: %s, egress: %s" -msgstr "" +msgstr "устройство: %s, входящий: %s, исходящий: %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:289 msgid "" "disabled is set to \"%s\" — config_get_bool does not recognise that, so the " "section stays enabled" msgstr "" +"disabled установлено в \"%s\" — config_get_bool не распознаёт это, поэтому " +"секция остаётся включённой" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:700 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 @@ -1237,23 +1311,23 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1160 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:2129 msgid "e.g. %s" -msgstr "" +msgstr "например, %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1171 msgid "fnmatch() pattern supporting * and ? as wildcard characters" -msgstr "" +msgstr "Шаблон fnmatch() с поддержкой * и ? как подстановочных символов" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1613 msgid "get_stats did not answer." -msgstr "" +msgstr "get_stats не ответил." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:901 msgid "inactive" -msgstr "" +msgstr "неактивен" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1109 msgid "ingress %s, egress %s" -msgstr "" +msgstr "входящий %s, исходящий %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:282 msgid "ingress and egress are both 0 — nothing is shaped" @@ -1261,11 +1335,11 @@ msgstr "ingress и egress равны 0 — шейпинг не выполняе #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:900 msgid "interface %s" -msgstr "" +msgstr "интерфейс %s" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1155 msgid "match" -msgstr "" +msgstr "match" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:274 msgid "" @@ -1297,14 +1371,16 @@ msgid "" "netifd interface (config interface) or netdev (config device) to enable QoS " "on" msgstr "" +"Интерфейс netifd (config interface) или netdev (config device), на котором " +"включить QoS" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:718 msgid "netifd interface to enable QoS on. Required." -msgstr "" +msgstr "Интерфейс netifd, на котором включить QoS. Обязательно." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:902 msgid "no" -msgstr "" +msgstr "нет" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:284 msgid "no bandwidth_down or bandwidth — ingress CAKE runs unlimited" @@ -1319,7 +1395,7 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:882 msgid "none" -msgstr "" +msgstr "нет" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:281 msgid "overhead and overhead_encap are ignored unless overhead_type is manual" @@ -1335,11 +1411,13 @@ msgstr "qosify" msgid "" "qosify did not appear on ubus, so its config was not applied — press Reload" msgstr "" +"qosify не появился в ubus, поэтому его конфигурация не была применена — " +"нажмите «Перезагрузить»" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:833 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1729 msgid "qosify did not come up — check the system log" -msgstr "" +msgstr "qosify не запустился — проверьте системный журнал" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1610 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1672 @@ -1349,7 +1427,7 @@ msgstr "Qosify не запущен. Запустите его на вкладк #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1735 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1887 msgid "qosify is still running — leaving the qdiscs alone" -msgstr "" +msgstr "qosify всё ещё работает — не трогаю qdisc" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1253 msgid "qosify-status" @@ -1364,6 +1442,8 @@ msgid "" "rpcd is not answering for qosify — check the ACL in /usr/share/rpcd/acl.d " "and restart rpcd" msgstr "" +"rpcd не отвечает за qosify — проверьте ACL в /usr/share/rpcd/acl.d и " +"перезапустите rpcd" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:832 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1609 @@ -1372,32 +1452,32 @@ msgstr "" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1734 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1886 msgid "rpcd is not answering for qosify, so the service state is unknown." -msgstr "" +msgstr "rpcd не отвечает за qosify, поэтому состояние службы неизвестно." #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:955 msgid "section name" -msgstr "" +msgstr "имя секции" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:953 msgid "section type" -msgstr "" +msgstr "тип секции" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1529 #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1533 msgid "share" -msgstr "" +msgstr "доля" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1541 msgid "total" -msgstr "" +msgstr "всего" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1160 msgid "value" -msgstr "" +msgstr "значение" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:902 msgid "yes" -msgstr "" +msgstr "да" #~ msgid "%s contains a quote — qosify rejects the whole value" #~ msgstr "%s содержит кавычку — qosify отклоняет всё значение" diff --git a/luci-app-qosify/po/uk/qosify.po b/luci-app-qosify/po/uk/qosify.po index 469e23d60..dffadd21c 100644 --- a/luci-app-qosify/po/uk/qosify.po +++ b/luci-app-qosify/po/uk/qosify.po @@ -3,13 +3,13 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: uk\n" "Content-Transfer-Encoding: 8bit\n" -"Last-Translator: Martin Olmer \n" +"Last-Translator: Karakurt \n" "Language-Team: Ukrainian \n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"PO-Revision-Date: 2026-08-31 06:59+0000\n" -"X-Generator: Weblate 2026.9.dev0\n" +"PO-Revision-Date: 2026-10-04 08:24+0000\n" +"X-Generator: Weblate 2026.10.1.dev0\n" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:882 msgid "%d interface" @@ -215,7 +215,7 @@ msgstr "Доступно" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:1196 msgid "Backup" -msgstr "" +msgstr "Резервна копія" #: applications/luci-app-qosify/htdocs/luci-static/resources/view/qosify/main.js:76 msgid "" diff --git a/luci-app-sfp-info/Makefile b/luci-app-sfp-info/Makefile index 00fde86aa..ac511dc29 100644 --- a/luci-app-sfp-info/Makefile +++ b/luci-app-sfp-info/Makefile @@ -9,7 +9,7 @@ LUCI_PKGARCH:=all PKG_LICENSE:=Apache-2.0 PKG_VERSION:=1.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=Meng Zhuo include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-sfp-info/po/da/sfp-info.po b/luci-app-sfp-info/po/da/sfp-info.po new file mode 100644 index 000000000..7a2722bf9 --- /dev/null +++ b/luci-app-sfp-info/po/da/sfp-info.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" +"Content-Transfer-Encoding: 8bit\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:68 +msgid "Bias Current" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:65 +msgid "Date Code" +msgstr "" + +#: applications/luci-app-sfp-info/root/usr/share/rpcd/acl.d/luci-app-sfp-info.json:3 +msgid "Grant ubus access to SFP Info" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:61 +msgid "Interface" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:106 +msgid "No SFP/QSFP modules detected." +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:205 +msgid "Normal" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:64 +msgid "Part Number" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:70 +msgid "RX Power" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:182 +msgid "RX power high" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:183 +msgid "RX power low" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:46 +msgid "Range" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:78 +msgid "SFP / QSFP Transceiver Information" +msgstr "" + +#: applications/luci-app-sfp-info/root/usr/share/luci/menu.d/luci-app-sfp-info.json:3 +msgid "SFP Info" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:71 +msgid "Status" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:69 +msgid "TX Power" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:178 +msgid "TX bias high" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:179 +msgid "TX bias low" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:180 +msgid "TX power high" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:181 +msgid "TX power low" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:66 +msgid "Temperature" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:174 +msgid "Temperature high" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:175 +msgid "Temperature low" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:63 +msgid "Type" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:62 +msgid "Vendor" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:67 +msgid "Voltage" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:176 +msgid "Voltage high" +msgstr "" + +#: applications/luci-app-sfp-info/htdocs/luci-static/resources/view/sfp-info/main.js:177 +msgid "Voltage low" +msgstr "" diff --git a/luci-app-ssr-plus/Makefile b/luci-app-ssr-plus/Makefile index 1de785701..b9f564f02 100644 --- a/luci-app-ssr-plus/Makefile +++ b/luci-app-ssr-plus/Makefile @@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus LUCI_PKGARCH:=all PKG_NAME:=luci-app-ssr-plus PKG_VERSION:=196 -PKG_RELEASE:=82 +PKG_RELEASE:=83 PKG_CONFIG_DEPENDS:= \ CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \ diff --git a/luci-app-ssr-plus/root/usr/bin/ssr-rules b/luci-app-ssr-plus/root/usr/bin/ssr-rules index cb7389718..d123a5e6a 100755 --- a/luci-app-ssr-plus/root/usr/bin/ssr-rules +++ b/luci-app-ssr-plus/root/usr/bin/ssr-rules @@ -295,7 +295,7 @@ flush_nftables() { # Optional: force delete all ss_spec related sets (even if table was accidentally deleted) for setname in ss_spec_lan_ac ss_spec_wan_ac ssr_gen_router \ - china fplan bplan gmlan whitelist blacklist gfwlist music; do + china fplan bplan gmlan whitelist whitelist_domain blacklist gfwlist music; do $NFT delete set inet ss_spec $setname 2>/dev/null $NFT delete set ip ss_spec_mangle $setname 2>/dev/null done @@ -362,6 +362,13 @@ ipset_nft() { fi done + # DNS returns individual addresses; keep CIDR bypass entries in whitelist. + if ! $NFT list set inet ss_spec whitelist_domain >/dev/null 2>&1; then + $NFT add set inet ss_spec whitelist_domain '{ type ipv4_addr; }' 2>/dev/null + else + $NFT flush set inet ss_spec whitelist_domain 2>/dev/null + fi + # Bulk import china ip list safely (avoid huge single element limitation) if [ -f "$IGNORE_LIST" ]; then SKIP_INET=1 /usr/share/shadowsocksr/chinaipset.sh "$IGNORE_LIST" @@ -405,6 +412,7 @@ ipset_nft() { # Access control: blacklist -> whitelist -> fplan/bplan — TCP $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @blacklist jump ss_spec_wan_fw $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @whitelist return + $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @whitelist_domain return $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @fplan jump ss_spec_wan_fw $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @bplan return @@ -1448,6 +1456,14 @@ restore_from_persistence() { return 1 fi + # Old snapshots lack the DNS-only whitelist used by dnsmasq. Regenerate + # them through the normal setup path instead of restoring stale schema. + if ! grep -q 'set whitelist_domain {' "$NFTABLES_RULES_FILE" || + ! grep -q 'ip daddr @whitelist_domain return' "$NFTABLES_RULES_FILE"; then + loger 6 "DNS whitelist set missing from persistence; rebuilding rules" + return 1 + fi + loger 6 "Restoring rules from persistence file" # Cleanup existing rules diff --git a/luci-app-ssr-plus/root/usr/share/shadowsocksr/gfw2ipset.sh b/luci-app-ssr-plus/root/usr/share/shadowsocksr/gfw2ipset.sh index 971ca897d..7528c008e 100755 --- a/luci-app-ssr-plus/root/usr/share/shadowsocksr/gfw2ipset.sh +++ b/luci-app-ssr-plus/root/usr/share/shadowsocksr/gfw2ipset.sh @@ -85,7 +85,7 @@ done # 此处直接使用 cat 因为有 sed '/#/d' 删除了 数据 if [ "$nft_support" = "1" ]; then sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' -e "s/.*/server=\/&\/127.0.0.1#$dns_port\nnftset=\/&\/inet#ss_spec#blacklist/" /etc/ssrplus/black.list > "$TMP_DNSMASQ_PATH/blacklist_forward.conf" - sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' -e "s/.*/nftset=\/&\/4#inet#ss_spec#whitelist/" /etc/ssrplus/white.list > "$TMP_DNSMASQ_PATH/whitelist_forward.conf" + sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' -e "s/.*/nftset=\/&\/4#inet#ss_spec#whitelist_domain/" /etc/ssrplus/white.list > "$TMP_DNSMASQ_PATH/whitelist_forward.conf" elif [ "$nft_support" = "0" ]; then sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' -e "s/.*/server=\/&\/127.0.0.1#$dns_port\nipset=\/&\/blacklist/" /etc/ssrplus/black.list > "$TMP_DNSMASQ_PATH/blacklist_forward.conf" sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' -e "s/.*/ipset=\/&\/whitelist/" /etc/ssrplus/white.list > "$TMP_DNSMASQ_PATH/whitelist_forward.conf" diff --git a/luci-app-substore/CHANGELOG.md b/luci-app-substore/CHANGELOG.md index c30389db3..22fedfbc4 100644 --- a/luci-app-substore/CHANGELOG.md +++ b/luci-app-substore/CHANGELOG.md @@ -2,6 +2,161 @@ All notable changes to this project will be documented in this file. +## [2.7.2-r2] - 修复 Surge 系 / Loon / QX 行解析的字段级缺陷(LEGACY_ISSUES 7.5 / 7.6) + +`docs/LEGACY_ISSUES.md` 第七节 2.7.2 审计新发现的**第一轮**修复。两项都在 +`parser_surge.lua`,即「导入别人的 Loon / Surge / QX 配置」这条路径上。 + +### 修复 — 行拆分改为引号感知(7.5) + +Loon 把凭据写成双引号包裹的位置参数(`nsloon.app/docs/Node/`): + +``` +Trojan = Trojan,trojan.example.com,443,"password",transport=tcp,... +``` + +密码里含逗号时,旧的 `rest:gmatch("[^,]+")` 会把它切成两段 —— +`"pa,ss"` 静默变成 `"pa`(多一个引号、少掉后半段),用户拿到的不是他填的凭据, +而且没有任何提示。生成端早就有「值里含逗号就整条丢弃」的防护 +(`output_formats.surge_line`),解析端此前没有对应处理。 + +新增 `split_fields()`,`parse_surge_line` 与 `parse_qx_line` 的切分都改走它: + +- **引号个数为奇数时不做引号感知**,退回旧的按逗号切分。订阅内容不可信,落单的 + 引号若被当成「开引号」,会把后面的 `sni=` / `over-tls=` 全吞进同一个字段, + 比按逗号切更糟。 +- 偶数时按引号开合切分,引号内的逗号不再是分隔符;引号本身保留在字段里, + 由既有的 `unquote()`(Loon 位置参数)或原样(QX 的 kv 值)处理。 +- **无引号的输入与旧实现逐字符等价**:同样按逗号切、同样丢弃空字段、同样 trim。 + +### 修复 — Loon 的 transport= / path= / host= 未映射(7.6) + +Loon 文档的传输写法是 `transport=` + `path=` + `host=`,旧参数 +`ws=true` / `ws-path` / `ws-headers=Host:` 只是兼容别名(`nsloon.app/docs/Node/`)。 +此前只认旧写法,于是别人给的 Loon ws 节点导入后 `net` 保持默认 `tcp`、 +`path` / `host` 全丢 —— 导出到任何格式都按 tcp 去连一个只开了 ws 的端口, +**握手失败且不报错**。 + +`parse_surge_line` 增加这三个映射(`transport=http` 按 Loon 文档「会按 WebSocket +处理」落成 `ws`),放在旧写法之后:同一行两种写法都出现时**以新写法为准**。 + +### 测试 + +新增 `tests/parser_surge_fields_test.lua`(36 条断言):两个缺陷的修复点、旧写法 +回归、无引号输入的逐字符等价、空字段边界、奇数引号边界、「新写法优先」, +以及 Reality 参数不受影响的回归。 + +**反向验证**:把 `parser_surge.lua` stash 掉后跑该文件得到 **12 条 FAIL**,症状与 +预测完全一致(被截断的凭据、`net=tcp`、`path` / `host` 为 nil、旧写法胜出); +恢复后 0 条 FAIL。全套 58 个文件、0 失败。 + +### 文档 / 构建 + +- **更正未经证实的结论**:`output_formats.lua`、`tests/anytls_reality_test.lua` + 与 `LEGACY_ISSUES.md` 里写的「Surge 遇到无法解析的代理行会拒绝加载整份配置」 + **没有官方依据** —— Surge 官方只说明过无法识别的 *section* 会原样保留且不报错。 + 相关注释改为「不输出客户端读不懂的东西」,与本仓库丢弃 wireguard / ssr 同一约定。 +- **CI 修复**:编译前写入 `CONFIG_LUCI_LANG_zh_Hans=y` 再 `make defconfig`。 + `luci-i18n-substore-zh-cn` 是 HIDDEN 包(无 Kconfig prompt),luci.mk 给它的 + 唯一默认值是 `LUCI_LANG_zh_Hans||(ALL&&m)`,而 SDK 默认 `ALL=n` —— 不显式打开 + 这个符号,翻译包就不会被编译出来,`Collect artifact` 的守卫会失败。 + (`CONFIG_PACKAGE_luci-i18n-substore-zh-cn=m` 对无 prompt 的符号是空操作。) +- `docs/BUILD.md`:更正不存在的 `make package/luci-i18n-substore-zh-cn/compile` + 目标(make 目标按**目录**生成),补上语言符号的说明。 + +## [2.7.2-r1] - 新增 AnyTLS 与 Reality 支持;控制器文案接入 i18n;简体中文翻译拆成独立包 + +`core.lua` `M.version` 由 2.7.1 升至 2.7.2。本节包含 2.7.1-r5 之后未发布的两批 +改动:上一批(uTLS 指纹键名与协议清单唯一来源)已在提交 `3651f67` 落地但未记入 +日志,一并补记。 + +### 新功能 — AnyTLS + +- **解析**:`anytls://` 分享链接;Surge / Surfboard / Loon / QX 四种行格式; + Clash YAML 与 sing-box JSON 出站。 +- **输出**:clashmeta、sing-box、v2rayuri、QX、Surge 家族。AnyTLS 密码的位置 + 按各家文档分别输出 —— Surge / SurgeMac 具名 `password=`,Surfboard 端口后的 + 裸位置参数,Loon 端口后的带引号位置参数(`anytls, host, port, "pwd", …`)。 +- **表单**:`node.PROTO_FIELDS.anytls` 与 `nodeform.js` 的字段清单同步更新。 + +### 新功能 — Reality(完整参数集 public-key / short-id / spider-x / fingerprint) + +- **解析**:`vless://…security=reality&pbk=&sid=&spx=&fp=`;Loon 节点行的 + `public-key` / `short-id`;QX 的 `reality-base64-pubkey` / `reality-hex-shortid`; + sing-box 的 `tls.reality`;Clash 的 `reality-opts`。 +- **统一模型**:`node.REALITY_PROTOS = { vless, vmess, trojan, anytls }`; + `normalize` 见到 `public-key` 即把 `security` 置为 `reality`(判据是公钥而非 + `security` 字段 —— Loon 的行只写 `over-tls=true` + `public-key`,没有 + `security=reality`)。 +- **输出**: + - clashmeta:`reality-opts: {public-key, short-id}`,**仅** vless / vmess / + trojan 三个出站(mihomo 的 `RealityOptions` 只挂在这三个结构体上; + `AnyTLSOption` 里没有任何 reality 字段,写了会被静默忽略)。 + - sing-box:`tls.reality`,并强制输出 `tls.utls`(sing-box 的 + `newRealityClient` 在 utls 未启用时直接报 `uTLS is required by reality client`)。 + - v2ray(Xray):`realitySettings`(camelCase)。 + - v2rayuri:`pbk` / `sid` / `spx`(仅 VLESS —— `anytls://` 链接规范没有 + reality 参数)。 + - QX:`reality-base64-pubkey` / `reality-hex-shortid`。 + - Surge 家族:**只有 Loon** 输出 `public-key` / `short-id`(`REALITY_FLAVORS`); + Surge / Surfboard / SurgeMac / Egern 写这两个键是「客户端不认识的参数」, + Surge 遇到无法解析的代理行会拒绝加载整份配置。 + +### 修复(本轮代码级审计发现) + +- `output_clash_meta`:`reality-opts` 此前只写在 `vless` 分支。mihomo 的 + `vmess.go` / `trojan.go` 出站同样声明了 `RealityOpts`,一并补上;`anytls` 不写 + (见上)。 +- `output_v2ray`:`security == "reality"` 但没有 `public-key` 时,原样输出 + `realitySettings` 会让 Xray **拒绝加载整份配置**(`REALITYConfig.Build()` + 客户端分支在公钥为空时报 `empty "password"`,而 `StreamConfig` 又要求 + `security == "reality"` 必须配 `realitySettings`)。改为降级成普通 TLS —— + 与 sing-box / mihomo / QX 三个输出端「只在有 public-key 时才写 Reality」一致。 +- `node.PROTO_FIELDS.anytls`:补 `public-key` / `short-id`。`core.merge_form_node` + 会把「在清单里但表单没提交」的字段当作清空处理,不列入的话用户在界面上编辑 + 一次就把 Reality 凭据抹掉。 +- `output_formats`(Loon / QX):Loon 文档有 **VMess**-Reality 示例,QX 官方 + `sample.conf` 的 Reality 条目覆盖 vmess / vless / trojan / anytls —— 此前 + Loon 只给 vless / trojan / anytls 输出公钥,QX 只给 vless / anytls 输出, + vmess 与 QX-trojan 的 Reality 配置被静默丢弃。 +- 上一批(`3651f67`):`output_clash_meta` 写出的 uTLS 指纹键名是 `fp`,而 + mihomo 的键是 `client-fingerprint`(全仓库没有任何结构体声明 `proxy:"fp,…"`), + mihomo 对未知键静默忽略 —— 配置看起来有指纹、握手实际用默认指纹; + `parser_clash_yaml` 又只读 `p.fp`,而 mihomo 从不写 `fp`,指纹双向全断。 + 两端一并修正,解析端保留 `fp` 兜底以兼容旧版本导出的配置。同时收敛协议清单 + 为唯一来源(`node.PROTOS` / `node.TLS_ONLY`),`nodes.htm` 下拉不再内联第三份副本。 + +### 变更 — i18n 与打包 + +- **控制器文案接入翻译层**:`controller/admin/substore.lua` 里所有用户可见文案 + (经 `?err=` 回显的失败原因等)改为 `_("English")`,msgid 一律英文,中文译文 + 放 `po/zh_Hans/substore.po`。此前这些是硬编码中文字面量,英文界面下原样显示中文。 +- **`po/zh-cn/` → `po/zh_Hans/`**:`luci.mk` 只对 `LUCI_LANG` 里有条目的目录名 + 生成翻译包。`LUCI_LANG.zh-cn` **不存在**(只有 `LUCI_LANG.zh_Hans`,经 + `LUCI_LC_ALIAS.zh_Hans=zh-cn` 映射到语言码 `zh-cn`),所以目录叫 `zh-cn` 时 + luci.mk 会**静默跳过**、不生成任何翻译包 —— 这正是此前译文被硬编译进主包的原因。 +- **简体中文翻译不再硬编译进主包**:删除 `Makefile` install 步骤里的 + `po2lmo`(原先把 `substore.zh-cn.lmo` 直接塞进 `luci-app-substore` 的 ipk/apk), + 改由 luci.mk 按 `po/zh_Hans/` 自动生成的独立包 `luci-i18n-substore-zh-cn` 提供。 + 该包自己调 `po2lmo`,并写 uci-defaults 把 `zh_cn` 加进 `luci.languages`。 + 翻译包的版本号取 LuCI 的 `PKG_PO_VERSION`(由 `po/` 的提交时间推导),与主包的 + `2.7.2-r1` 不同名。 +- **CI**(`.github/workflows/build.yml`):同时编译 `package/luci-i18n-substore-zh-cn/compile`, + 产物收集通配 `luci-i18n-substore*`,Release 说明里注明该包为可选安装。 + +### 测试 + +- 新增 `tests/anytls_reality_test.lua`(110 项断言):URI / 行格式解析、 + `normalize` 的 Reality 推导与误判防护(wireguard 的顶层 `public-key` 是对端 + 公钥、与 Reality 无关)、各输出端逐格式断言、`PROTO_FIELDS` 与 + `merge_form_node` 保留凭据、`surge_line` 的边界(未知协议 → `nil`;密码含逗号 + → `nil`)。 +- `tests/view_i18n_test.lua` 新增 B2 段:控制器里 `_()` 包裹的每个 msgid 都必须在 + `po/zh_Hans/substore.po` 有条目,防止 msgid 与译文表再次漂移。 +- `tests/controller_local_test.lua` 的断言改为英文 msgid(控制器文案已接入 i18n, + 测试用的 `luci.i18n` stub 是恒等翻译)。 +- 全量 Lua 测试 56 个文件与 `tests/cron_result_test.sh` 全部通过。 + ## [2.7.1-r5] - 订阅列表页组合订阅行的「[组合]」徽标移到「订阅地址」列 **纯模板改动,无 Lua 逻辑改动**(`core.lua` `M.version` 不变,仍为 2.7.1)。 diff --git a/luci-app-substore/Makefile b/luci-app-substore/Makefile index 928ce40fd..77c996b24 100644 --- a/luci-app-substore/Makefile +++ b/luci-app-substore/Makefile @@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-substore # 版本约定:每次提交 PKG_RELEASE +1(2.1.2-r1 ~ r9); # 达到 r10 时 PKG_VERSION 末位 +1(2.1.2 -> 2.1.3),PKG_RELEASE 重置为 1。 -PKG_VERSION:=2.7.1 -PKG_RELEASE:=18 +PKG_VERSION:=2.7.2 +PKG_RELEASE:=19 LUCI_DEPENDS:=+luci-lua-runtime +luci-compat @@ -78,9 +78,10 @@ define Package/luci-app-substore/install $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-substore.json $(1)/usr/share/rpcd/acl.d/luci-app-substore.json - # 简体中文翻译(运行时 zh-cn 时由 luci.template.parser 自动加载 *.zh-cn.lmo) - $(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n - po2lmo ./po/zh-cn/substore.po $(1)/usr/lib/lua/luci/i18n/substore.zh-cn.lmo + # 简体中文翻译**不打进本包**:luci.mk 会按 po// 目录自动生成独立的 + # 翻译包 luci-i18n-substore-zh-cn(见文件末尾的说明)。 + # 这里再 po2lmo 一份会把译文硬编译进 ipk/apk,与那个包安装同一个路径 + # /usr/lib/lua/luci/i18n/substore.zh-cn.lmo,装两个包时互相覆盖。 endef define Package/luci-app-substore/postinst @@ -93,4 +94,17 @@ define Package/luci-app-substore/postinst } endef +# 翻译包 luci-i18n-substore-zh-cn 由 luci.mk 自动生成,本文件不手写它的定义。 +# 机制(feeds/luci/luci.mk): +# LUCI_LANGUAGES := po/ 下的目录名(去掉 templates) +# 逐个 lang:定义了 LUCI_LANG. 才生成,语言码取 +# $(firstword $(LUCI_LC_ALIAS.) ),包名 luci-i18n--<语言码> +# (LUCI_BASENAME = luci-app-substore 去掉 luci-app- 前缀 = substore)。 +# 于是有两条硬约束: +# 1) 目录名必须叫 zh_Hans —— 那是 LUCI_LANG 的键。叫 zh-cn 时 +# LUCI_LANG.zh-cn 不存在,luci.mk 直接跳过,**不报错也不生成包**, +# 译文会悄无声息地消失。 +# 2) 包名与 .lmo 文件名取的是别名 zh-cn(LUCI_LC_ALIAS.zh_Hans=zh-cn), +# 即 luci-i18n-substore-zh-cn / substore.zh-cn.lmo,正好等于运行时 +# luci.i18n 查找所用的语言代码,也等于改造前硬编译进主包的那份文件名。 $(eval $(call BuildPackage,luci-app-substore)) \ No newline at end of file diff --git a/luci-app-substore/README.en.md b/luci-app-substore/README.en.md index 226168771..256bfa7f7 100644 --- a/luci-app-substore/README.en.md +++ b/luci-app-substore/README.en.md @@ -84,22 +84,35 @@ group them, then re-emit them in a format your client can consume. ## Installation > The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the -> [Makefile](Makefile) (currently `2.7.1-r5`). +> [Makefile](Makefile) (currently `2.7.2-r1`). **Minimum supported: OpenWrt / ImmortalWrt 23.05** (older releases are out of scope). opkg (OpenWrt / ImmortalWrt 24.10 and earlier): ```bash -opkg install luci-app-substore-2.7.1-r5.ipk +opkg install luci-app-substore-2.7.2-r1.ipk ``` apk (OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.7.1-r5.apk +apk add --allow-untrusted luci-app-substore-2.7.2-r1.apk ``` +The Simplified Chinese UI needs a **separately installed** translation package — +the main package ships no translations, so the UI stays English without it: + +```bash +opkg install luci-i18n-substore-zh-cn_*.ipk # 24.10 and earlier +apk add --allow-untrusted luci-i18n-substore-zh-cn_*.apk # 25.12+ +``` + +> Its version comes from the `po/` commit time (LuCI's `PKG_PO_VERSION`), so it +> does not share the main package's `2.7.2-r1`; install it by filename wildcard. +> Set the LuCI language to Simplified Chinese afterwards — the package's +> uci-defaults adds it to `luci.languages` automatically. + Then open LuCI: **Services → Subscriptions**. ## Usage @@ -174,7 +187,7 @@ oversight; the reasoning for each is in │ │ ├── luci/menu.d/luci-app-substore.json │ │ ├── rpcd/acl.d/luci-app-substore.json # ACL group (referenced by menu depends.acl) │ │ └── substore/*.lua # core logic (no luci.* dependency) -├── po/zh-cn/substore.po # 简体中文 translations +├── po/zh_Hans/substore.po # Simplified Chinese (built as the separate luci-i18n-substore-zh-cn package) ├── docs/ # design & guides └── tests/ # self-contained Lua 5.1 unit tests ``` @@ -199,6 +212,9 @@ target firmware, then: ```bash cp -r luci-app-substore /package/ make package/luci-app-substore/compile V=s +# Simplified Chinese is a separate package (luci.mk auto-generates it from +# po/zh_Hans/), so it must be compiled on its own: +make package/luci-i18n-substore-zh-cn/compile V=s ``` The `.ipk` (or `.apk` on apk builds) is produced under `bin/packages/.../`. diff --git a/luci-app-substore/README.md b/luci-app-substore/README.md index 8453b9e2a..d8b1af60b 100644 --- a/luci-app-substore/README.md +++ b/luci-app-substore/README.md @@ -72,22 +72,33 @@ ## 安装 > 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致 -> (当前 `2.7.1-r5`)。 +> (当前 `2.7.2-r1`)。 **最低支持 OpenWrt / ImmortalWrt 23.05**(更早的版本不在支持范围内)。 opkg(OpenWrt / ImmortalWrt 24.10 及更早): ```bash -opkg install luci-app-substore-2.7.1-r5.ipk +opkg install luci-app-substore-2.7.2-r1.ipk ``` apk(OpenWrt / ImmortalWrt 25.12+): ```bash -apk add --allow-untrusted luci-app-substore-2.7.1-r5.apk +apk add --allow-untrusted luci-app-substore-2.7.2-r1.apk ``` +简体中文界面需要**单独安装**翻译包 —— 主包不含译文,未装时界面为英文: + +```bash +opkg install luci-i18n-substore-zh-cn_*.ipk # 24.10 及更早 +apk add --allow-untrusted luci-i18n-substore-zh-cn_*.apk # 25.12+ +``` + +> 翻译包的版本号由 `po/` 目录的提交时间推导(LuCI 的 `PKG_PO_VERSION`), +> 与主包的 `2.7.2-r1` 不同名,按文件名通配安装即可。装好后 LuCI 语言设为 +> 「简体中文」即生效(该包的 uci-defaults 会自动把它加进 `luci.languages`)。 + 然后在 LuCI 菜单打开:**服务 → 订阅**。 ## 使用方法 @@ -149,7 +160,7 @@ apk add --allow-untrusted luci-app-substore-2.7.1-r5.apk │ │ ├── luci/menu.d/luci-app-substore.json │ │ ├── rpcd/acl.d/luci-app-substore.json # ACL 组(菜单 depends.acl 引用) │ │ └── substore/*.lua # 核心逻辑(不依赖 luci.*) -├── po/zh-cn/substore.po # 简体中文翻译 +├── po/zh_Hans/substore.po # 简体中文翻译(编译为独立包 luci-i18n-substore-zh-cn) ├── docs/ # 设计与指南 └── tests/ # 自包含 Lua 5.1 单元测试 ``` @@ -173,6 +184,8 @@ apk add --allow-untrusted luci-app-substore-2.7.1-r5.apk ```bash cp -r luci-app-substore /package/ make package/luci-app-substore/compile V=s +# 简体中文翻译是独立包(luci.mk 按 po/zh_Hans/ 自动生成),需要单独编译: +make package/luci-i18n-substore-zh-cn/compile V=s ``` `.ipk`(或 apk 构建下的 `.apk`)生成于 `bin/packages/.../` 下。 diff --git a/luci-app-substore/docs/BUILD.md b/luci-app-substore/docs/BUILD.md index 196023319..e526e25df 100644 --- a/luci-app-substore/docs/BUILD.md +++ b/luci-app-substore/docs/BUILD.md @@ -18,5 +18,32 @@ make package/luci-app-substore/compile V=s The `.ipk` (or `.apk` on apk-based builds) is produced under `bin/packages/.../luci-app-substore_*.ipk`. +简体中文翻译是**独立包**,由 `feeds/luci/luci.mk` 按 `po//` 目录自动生成, +主包的 install 步骤**不再**打包 `.lmo`(见 Makefile 末尾的说明): + +**不需要**(也**不能**)单独编译翻译包 —— 它和主包同属 +`package/luci-app-substore` 目录,由上面那条命令一并编出来: + +```bash +make package/luci-app-substore/compile V=s +``` + +> ⚠️ `make package/luci-i18n-substore-zh-cn/compile` 是**不存在的目标**:make 的 +> 目标按**目录**生成(`package/<目录名>/compile`),不是按包名。写成包名会直接 +> `No rule to make target ...`(CI 曾因此整条腿失败)。 +> +> 但翻译包是 HIDDEN 包(`HIDDEN:=1`),luci.mk 给它的唯一默认值是 +> `LUCI_LANG_zh_Hans||(ALL&&m)`,而 SDK 的默认配置里 `ALL=n` —— 不显式打开语言 +> 符号时它**不会被选中**,目录目标也就不会产出它。因此编译前要: + +```bash +echo "CONFIG_LUCI_LANG_zh_Hans=y" >> .config +make defconfig +``` + +产物为 `bin/packages/.../luci-i18n-substore-zh-cn_*.ipk`,内含 +`/usr/lib/lua/luci/i18n/substore.zh-cn.lmo`。目录名必须是 `po/zh_Hans/` +(`LUCI_LANG` 的键)—— 写成 `po/zh-cn/` 时 luci.mk 会静默跳过,不生成任何包。 + > NOTE: stage-0 skeleton has not yet been built/verified on a target device. > This document will be updated once the build chain is validated (see docs/PLAN.md stage 0). \ No newline at end of file diff --git a/luci-app-substore/docs/INSTALL.md b/luci-app-substore/docs/INSTALL.md index 13882c53d..3bcb7063a 100644 --- a/luci-app-substore/docs/INSTALL.md +++ b/luci-app-substore/docs/INSTALL.md @@ -3,7 +3,7 @@ > NOTE: stage-0 skeleton has not yet been installed/verified on a target device. > This document will be updated once installation is validated. > -> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.7.1-r5)。 +> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.7.2-r1)。 **最低支持 OpenWrt / ImmortalWrt 23.05**(更早的版本不在支持范围内 —— 23.05 起 LuCI 使用 ucode dispatcher,ACL 的入口级校验依赖它,见 @@ -12,15 +12,30 @@ LuCI 使用 ucode dispatcher,ACL 的入口级校验依赖它,见 ## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早) ```bash -opkg install luci-app-substore-2.7.1-r5.ipk +opkg install luci-app-substore-2.7.2-r1.ipk ``` ## Install (apk — OpenWrt / ImmortalWrt 25.12+) ```bash -apk add --allow-untrusted luci-app-substore-2.7.1-r5.apk +apk add --allow-untrusted luci-app-substore-2.7.2-r1.apk ``` +## Install the Simplified Chinese translation (optional, separate package) + +主包**不含**译文:界面文本是英文 msgid,简体中文由独立的翻译包提供, +不装时界面为英文。 + +```bash +opkg install luci-i18n-substore-zh-cn_*.ipk # 24.10 及更早 +apk add --allow-untrusted luci-i18n-substore-zh-cn_*.apk # 25.12+ +``` + +该包的版本号由 `po/` 目录的提交时间推导(LuCI 的 `PKG_PO_VERSION`), +与主包的 `2.7.2-r1` 不同名,按文件名通配安装即可。它会安装 +`/usr/lib/lua/luci/i18n/substore.zh-cn.lmo`,并通过 uci-defaults 把 +`zh_cn` 加进 `luci.languages`;把 LuCI 语言切到「简体中文」即生效。 + Refresh LuCI: ```bash diff --git a/luci-app-substore/docs/LEGACY_ISSUES.md b/luci-app-substore/docs/LEGACY_ISSUES.md index 019abb1b5..9237cd042 100644 --- a/luci-app-substore/docs/LEGACY_ISSUES.md +++ b/luci-app-substore/docs/LEGACY_ISSUES.md @@ -641,6 +641,147 @@ crontab 里,`substore-cron.sh` 会拿着已不存在的 id 反复执行,每 --- +# 七、2.7.2 审计新发现 + +本节是给 AnyTLS + Reality 支持做代码级审计时**顺带确认**的问题。 +每条都给出**实测探针或上游文档依据**,无推测项。 + +**状态**(决策已定,按轮次实施): + +| # | 决策 | 状态 | +|---|---|---| +| 7.1 | A:引入 `FAMILY_CAPS` 按客户端能力表过滤 | 待实施(第二轮) | +| 7.2 | A:真正实现 Egern YAML 生成器 | 待实施(第三轮) | +| 7.3 | C:只对带 `public-key` 的 vmess / vless 分叉 `qx_tls` | 待实施(第二轮) | +| 7.4 | A:Loon 位置参数化(并给 `public-key` 加双引号) | 待实施(第二轮) | +| 7.5 | 修复 | **已修复**(第一轮,见下) | +| 7.6 | 修复 | **已修复**(第一轮,见下) | +| 7.7 | A:后端错误串 msgid 化 | 待实施(第四轮) | +| 7.8 | A:删除 `age.lua` / `age_test.lua` | **已删除**(第一轮) | + +7.5 / 7.6 的修复见本节末尾「7.5 / 7.6 修复记录」。 + +| # | 问题 | 位置 | 依据 | 影响 | +|---|---|---|---|---| +| 7.1 | Surge 格式会为 VLESS 节点生成代理行,而 Surge 的协议清单里没有 VLESS | `output_formats.surge_config` | Surge 手册(`manual.nssurge.com`)协议清单无 VLESS;探针见下 | 节点必然不可用(Surge 对「不认识的代理行」的处置**未获官方证实** —— 官方只说明过无法识别的 *section* 会原样保留且不报错;此处按「不输出客户端读不懂的东西」处理,与丢弃 wireguard / ssr 同一约定) | +| 7.2 | Egern 格式输出的是 Surge 逗号行,而 Egern 的配置是 YAML | `output_formats.to_egern` | `egernapp.com/docs/configuration/proxies/`;探针见下 | 选 Egern 格式导出的内容 Egern 读不了 | +| 7.3 | QX 的 vmess / vless 用 `tls-host=` + `tls-verification=true` 表示 TLS,官方 `sample.conf` 用 `obfs=over-tls` / `obfs=wss` + `obfs-host` | `output_formats.qx_tls` | `crossutility/Quantumult-X` 的 `sample.conf`;探针见下 | 见 7.3 的详细说明 —— **会让新加的 QX vmess/vless Reality 公钥不生效** | +| 7.4 | Loon / Surfboard 的 trojan / vmess / vless 凭据在官方文档里是**位置参数**,本生成器一律写具名参数(只有 anytls 按 flavor 分对了) | `output_formats.surge_line` | `nsloon.app/docs/Node/` 的示例行 | Loon 是否同时接受具名写法**无文档依据**,待核实;若不接受则这几类节点导出到 Loon 后连不上 | +| 7.5 | ~~`parser_surge` 的行拆分不是引号感知的(`rest:gmatch("[^,]+")`)~~ **已修复** | `parser_surge.lua` 的 `split_fields` | 代码级:位置参数里含逗号的值会被切断 | 别人给的 Loon / QX 配置里带逗号的密码被**静默截断**(生成端已有「含逗号就整条丢弃」的防护,解析端没有对应防护) | +| 7.6 | ~~Loon 的 `transport=ws` 未映射到 `net`~~ **已修复** | `parser_surge.parse_surge_line` 的 `transport=` 分支 | 只认 `ws=true`(Surge 旧写法)与 `obfs=ws`(QX);`nsloon.app/docs/Node/` 用 `transport=ws` + `path=` + `host=` | Loon 的 ws 节点导入后 `net=tcp`,`path` / `host` 全丢 → 导出到任何格式都按 tcp 连,握手失败**且不报错** | +| 7.7 | 后端模块仍有 **113 处**硬编码中文字符串字面量(注释外) | `core.lua` 47 / `http.lua` 47 / `parser.lua` 9 / `util.lua` 5 / `output_wireguard_conf.lua` 3 / `node.lua` 2 | 扫描脚本(去注释后提取含 CJK / 全角的字符串字面量),见下 | 控制器文案已接入 i18n,但这些来自后端的失败原因经 `?err=` **原样**显示,英文界面下仍是中文 | +| 7.8 | ~~`root/usr/share/substore/age.lua` 与 `tests/age_test.lua` 未被 git 跟踪,且 `age.lua` 未被任何模块 `require`~~ **已删除** | 仓库根 | `grep -rn require` 无引用 | 未随包发布;留在工作区会被后续审计反复重新评估 | + +### 7.7 的统计口径与例外(修复前必读) + +本节原先写的「约 103 处(`grep -c 'return nil, ".*[^ -~]'`)」**口径有误**: +那条 grep 按行匹配,既会把中文注释算进去,又会漏掉「只含全角标点」的字面量 +(如 `","` 这类),逐文件数字也对不上。下面是重新核对的口径。 + +**统计方法**:先去掉注释(`--` 行注释与 `--[[ ]]` 块注释),再提取字符串字面量 +(`"` 与 `'`),保留其中含 CJK / 全角字符的(UTF-8 首字节 `E3`–`E9` 或 `EF`)。 +结果 **113 处**:`core.lua` 47 / `http.lua` 47 / `parser.lua` 9 / `util.lua` 5 / +`output_wireguard_conf.lua` 3 / `node.lua` 2。 + +其中 **76 处**是直接的 `return nil, "…"` / `return false, "…"` 形式;另外 37 处是 +同一类文案经别的路径到达用户,例如: + +* `core.lua` 的 `M.save_meta(id, { error = "…" })` —— 同一个串既写进 meta + (列表页用 `it.error` 显示)又被 `return` 出去,**两处必须用同一个 msgid**; +* `:format()` / `..` 拼接出来的串(如 `"HTTP 错误 "`、`"响应超过大小限制 ("`); +* 表项(`FORMAT_LABELS`)与视图直接渲染的标签(`human_duration`)。 + +**三处不能照搬 msgid 化**: + +| 位置 | 内容 | 为什么特殊 | +|---|---|---| +| `node.lua:377` | `"[^,%s,]+"` | 这是**正则字符类**,全角逗号是模式的一部分,翻译会直接破坏关键词拆分 | +| `util.lua:658` `M.human_duration` | `"已过期"` / `"%d天"` / `"%d小时"` / `"%d分钟"` / `"不足1分钟"` | 由**视图** `view/substore/form.htm:39` 直接渲染,不走 `?err=`;应在视图侧翻译(或 msgid 化后在视图 `_()`) | +| `parser.lua:103` `FORMAT_LABELS` | `"URI 链接"` / `"Surge/Loon 配置"` | 是**格式显示名**,被插进「混用多种格式」的错误文案里 | + +**关键设计点**:`meta.error` 会被 `it.error` 原样显示,所以 msgid 必须**语言中立** +(英文),翻译只发生在显示边界(视图 / 控制器),后端模块不得 `require("luci.i18n")` +—— `substore-cron.sh` 会在独立的 lua 进程里跑 `core.sync`,那里没有 LuCI 环境。 + +## 7.1 / 7.2 / 7.3 的实测探针 + +``` +$ lua5.1 -e '... out.generate({vless节点}, "surge", {name="P"}) ...' +[Proxy] +V = vless, 1.2.3.4, 443, username=u, tls=true ← Surge 协议清单里没有 vless + +$ lua5.1 -e '... out.generate({vmess节点}, "egern", {name="P"}) ...' +[Proxy] +M = vmess, 1.2.3.4, 443, username=u, tls=true ← Egern 的配置是 YAML,不是逗号行 + +$ lua5.1 -e '... out.generate({vmess+reality节点}, "qx", {name="P"}) ...' +[server_local] +vmess=1.2.3.4:443, method=none, password=u, tls-host=s.example.com, +tls-verification=true, reality-base64-pubkey=PBK, reality-hex-shortid=SID, tag=R +``` + +## 7.3 详细说明(唯一一条会影响本次新功能的) + +`sample.conf` 的说明原文(`crossutility/Quantumult-X` 仓库,`[server_local]` 前): + +> …if the corresponding line (socks5: `over-tls=true`, http: `over-tls=true`, +> trojan: `over-tls=true` or `obfs=wss`, anytls: `over-tls=true`, … vmess: +> `obfs=over-tls` or `obfs=wss`, vless: `obfs=over-tls` or `obfs=wss`) contains +> the `reality-base64-pubkey` param, then the standard TLS will be replaced with +> the Reality. + +即:QX 里 vmess / vless 的「TLS 标志」写作 `obfs=over-tls`(或 `obfs=wss`), +trojan / anytls 才写 `over-tls=true`。而 `qx_tls()` 对所有协议统一输出 +`tls-host=` + `tls-verification=true`(探针第三段可见)—— 于是 vmess / vless 的 +`reality-base64-pubkey` **很可能被 QX 忽略**,节点退回普通 TLS。 + +本次**没有**改 `qx_tls()`:它是既有实现,改动会让**所有** QX vmess / vless TLS +节点的输出形态变化(不只是 Reality 节点),需要单独决策与回归;而且 +`tls-host` / `tls-verification` 在 QX 里是否对 vmess / vless 同样有效**没有找到 +官方依据**(`sample.conf` 里只用 `obfs=` 形式,但「未出现」不等于「无效」)。 +本次只保证:**只要 TLS 标志生效,公钥就已经写在那行上了**(`qx_reality()` 覆盖 +vmess / vless / trojan / anytls 四类,与 `sample.conf` 的 Reality 条目一致)。 + +**处置候选**:A. 维持现状 + 本节记录;B. `qx_tls()` 按协议分叉(vmess / vless 走 +`obfs=over-tls` + `obfs-host`,trojan / anytls 维持现状),需要同步更新 +`parser_surge.parse_qx_line` 的读取端与 `tests/protocol_registry_test.lua`。 + +## 第一轮修复记录(7.5 / 7.6 / 7.8) + +**7.5 引号感知切分** —— 新增 `parser_surge.split_fields()`(`parser_surge.lua`), +`parse_surge_line` 与 `parse_qx_line` 的切分都改走它。规则: + +* 先数引号个数,**奇数则退回旧的 `gmatch("[^,]+")`**。订阅内容不可信,落单的 + 引号若被当成「开引号」,会把后面的 `sni=` / `over-tls=` 全吞进同一个字段, + 比按逗号切更糟。 +* 偶数时按引号开合切分,引号内的逗号不再是分隔符;引号本身保留在字段里, + 由既有的 `unquote()`(Loon 位置参数)或原样(QX kv 值)处理。 +* 无引号的输入与旧实现**逐字符等价**:同样按逗号切、同样丢弃空字段、同样 trim。 + +**7.6 Loon 传输参数** —— `parse_surge_line` 增加 `transport=` / `path=` / `host=` +三个映射(`transport=http` 按 Loon 文档「会按 WebSocket 处理」落成 `ws`),放在 +`ws=true` / `ws-path` / `ws-headers=Host:` 之后,同一行两种写法都出现时**以新写法为准**。 + +**回归测试**:新增 `tests/parser_surge_fields_test.lua`(36 条断言),覆盖两个 +缺陷的修复点、旧写法回归、无引号输入的逐字符等价、空字段边界、奇数引号边界、 +以及「新写法优先」。文件头写明「修复前应当失败」。 + +**反向验证**:把 `parser_surge.lua` stash 掉后跑该文件,得到 **12 条 FAIL**,症状 +与预测完全一致(`"pa` / `"p` / `"uu` 被截断的凭据、`net=tcp`、`path=nil`、 +`host=nil`、旧写法的 `/old` 与 `old.example.com` 胜出);`git stash pop` 后 +**0 条 FAIL**。全套测试 58 个文件、0 失败。 + +**7.8 删除** —— `root/usr/share/substore/age.lua` 与 `tests/age_test.lua` 已从工作区 +删除。审计复核发现它们并非「写完没人用」的死代码,而是**未完成的半成品**: +`age.lua` 文件头声称实现了 sha256 / hmac-sha256 / hkdf / chacha20 / poly1305 / +x25519 / bech32 / armor / STREAM,实际只写到 `M._hkdf_sha256` 就停了,末尾留着 +`-- @@NEXT@@` 续写标记(全仓库仅此一处);`tests/age_test.lua` 引用的 +`tests/age_vectors/README.md` 根本不存在。两者都**未被 git 跟踪**,删除即不可恢复, +因此删除前已在仓库外留了一份备份(`~/age-lua-wip-backup-2026-10-05/`,含 +`age.lua` 与 `tests/age_test.lua` 两个原文件)。后续若要继续这条线,从该备份恢复即可。 + +--- + # 附:P2 修复范围(不含本文件所列项) P2 为审计表中**其余中危 / 低危**项中性质明确、无需另行决策的缺陷,例如 diff --git a/luci-app-substore/po/zh-cn b/luci-app-substore/po/zh-cn new file mode 120000 index 000000000..8d69574dd --- /dev/null +++ b/luci-app-substore/po/zh-cn @@ -0,0 +1 @@ +zh_Hans \ No newline at end of file diff --git a/luci-app-substore/po/zh_Hans b/luci-app-substore/po/zh_Hans deleted file mode 120000 index 41451e4a1..000000000 --- a/luci-app-substore/po/zh_Hans +++ /dev/null @@ -1 +0,0 @@ -zh-cn \ No newline at end of file diff --git a/luci-app-substore/po/zh-cn/substore.po b/luci-app-substore/po/zh_Hans/substore.po similarity index 79% rename from luci-app-substore/po/zh-cn/substore.po rename to luci-app-substore/po/zh_Hans/substore.po index 1b3f2beb9..707a4f2c1 100644 --- a/luci-app-substore/po/zh-cn/substore.po +++ b/luci-app-substore/po/zh_Hans/substore.po @@ -1,6 +1,10 @@ # luci-app-substore 简体中文翻译 -# 运行时语言代码 zh-cn(LUCI_LC_ALIAS.zh_Hans=zh-cn), -# 编译产物 substore.zh-cn.lmo 由 Makefile 的 install 步骤用 po2lmo 生成。 +# +# 目录名是 zh_Hans(LUCI_LANG 的键),语言代码取别名 zh-cn +# (LUCI_LC_ALIAS.zh_Hans=zh-cn)。luci.mk 据此自动生成独立的翻译包 +# luci-i18n-substore-zh-cn,由它自己调 po2lmo 编译出 +# /usr/lib/lua/luci/i18n/substore.zh-cn.lmo —— 该 .lmo 不再打进主包 +# luci-app-substore 的 ipk/apk。详见 Makefile 末尾的说明。 msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" @@ -236,6 +240,19 @@ msgstr "私钥" msgid "Peer public key" msgstr "对端公钥" +# Reality / AnyTLS 新增字段标签(模板里以 <%:...%> 包裹,故需要对应 msgid) +msgid "Short ID" +msgstr "Short ID" + +msgid "Spider X" +msgstr "Spider X" + +msgid "ALPN" +msgstr "ALPN" + +msgid "Fingerprint" +msgstr "指纹" + msgid "Default" msgstr "默认" @@ -390,4 +407,78 @@ msgstr "启用后,按关键词包含 / 排除、去重规则筛选合并后的 # 订阅列表:启用了定时更新的订阅,在名称下方显示 cron 表达式 msgid "Schedule:" -msgstr "定时:" \ No newline at end of file +msgstr "定时:" + +# 控制器回显的失败原因(controller/admin/substore.lua 里经 _() 包裹后 +# 由 ?err= 带回页面显示)。msgid 是控制器里的英文原文。 +msgid "Request validation failed (missing token), please refresh the page and try again" +msgstr "请求校验失败(缺少 token),请刷新页面后重试" + +msgid "Request validation failed (token mismatch), please refresh the page and try again" +msgstr "请求校验失败(token 不匹配),请刷新页面后重试" + +msgid "Name and URL cannot be empty" +msgstr "名称和 URL 不能为空" + +msgid "Invalid rules" +msgstr "规则无效" + +msgid "Invalid subscription client type" +msgstr "订阅客户端类型无效" + +msgid "Failed to create subscription" +msgstr "创建订阅失败" + +msgid "Missing subscription ID" +msgstr "缺少订阅 ID" + +msgid "Save failed" +msgstr "保存失败" + +msgid "Name cannot be empty" +msgstr "名称不能为空" + +msgid "Subscription content cannot be empty" +msgstr "订阅内容不能为空" + +msgid "Failed to create local subscription" +msgstr "创建本地订阅失败" + +msgid "Failed to parse subscription content" +msgstr "解析订阅内容失败" + +msgid "No subscription selected for deletion" +msgstr "未指定要删除的订阅" + +msgid "Delete failed: subscription not found" +msgstr "删除失败:订阅不存在" + +msgid "Deleted %d, %d failed" +msgstr "已删除 %d 个,另有 %d 个删除失败" + +msgid "Node not found or invalid index" +msgstr "节点不存在或下标无效" + +msgid "Submitted content is empty" +msgstr "提交内容为空" + +msgid "Failed to parse form data" +msgstr "表单数据解析失败" + +msgid "Failed to write node data" +msgstr "写入节点数据失败" + +msgid "No node selected for deletion" +msgstr "未指定要删除的节点" + +msgid "Node not found or already deleted" +msgstr "节点不存在或已被删除" + +msgid "Failed to save combination" +msgstr "保存组合订阅失败" + +msgid "Subscription not found" +msgstr "订阅不存在" + +msgid "Update failed" +msgstr "更新失败" \ No newline at end of file diff --git a/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua b/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua index afeee0eb0..c8413ffaa 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua +++ b/luci-app-substore/root/usr/lib/lua/luci/controller/admin/substore.lua @@ -2,6 +2,16 @@ module("luci.controller.admin.substore", package.seeall) +-- 用户可见文案统一走 LuCI 的翻译层:msgid 一律写**英文**,译文放 +-- po//substore.po(编译成 *.lmo 后由 luci.template.parser 按运行时语言加载)。 +-- +-- 这里显式取一份 local `_`,不依赖全局:module(..., package.seeall) 的环境里 +-- `_` 是否存在取决于 LuCI 版本(部分版本只在模板环境里注入),显式定义后 +-- 控制器在任何上下文里都能翻译,测试里也能用 stub 的 luci.i18n 跑起来。 +-- 注意必须定义在所有用到它的函数**之前** —— local 只被定义时已可见的闭包捕获。 +local i18n = require("luci.i18n") +local function _(s) return i18n.translate(s) end + -- 返回列表页。err 非空时把错误带到列表页显示(§18:失败必须让用户看见, -- 不能「失败了却看起来像成功」)。沿用 LuCI 既有的 query + 模板渲染,不引入新 framework。 local function back_to_list(err) @@ -85,14 +95,14 @@ local function post_ok() local http = require("luci.http") local tok = fv(http, "token") if tok == nil then - post_fail_msg = "请求校验失败(缺少 token),请刷新页面后重试" + post_fail_msg = _("Request validation failed (missing token), please refresh the page and try again") return false end local ok, authtoken = pcall(function() return require("luci.dispatcher").context.authtoken end) if ok and type(authtoken) == "string" and authtoken ~= "" and tok ~= authtoken then - post_fail_msg = "请求校验失败(token 不匹配),请刷新页面后重试" + post_fail_msg = _("Request validation failed (token mismatch), please refresh the page and try again") return false end post_fail_msg = nil @@ -171,14 +181,14 @@ function action_create() if proxy_enable ~= "1" then proxy_enable = "0" end local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") if name == "" or url == "" then - return back_to_list("名称和 URL 不能为空") + return back_to_list(_("Name and URL cannot be empty")) end local cron_enable, cron_time = read_cron_fields() -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() - if not rules then return back_to_list(rules_err or "规则无效") end + if not rules then return back_to_list(rules_err or _("Invalid rules")) end local user_agent, ua_err = read_ua_fields() - if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end + if user_agent == nil then return back_to_list(ua_err or _("Invalid subscription client type")) end local id, err = core.add(name, url, { proxy_enable = proxy_enable, proxy = proxy, user_agent = user_agent, cron_enable = cron_enable, cron_time = cron_time, @@ -186,7 +196,7 @@ function action_create() keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, dedup = rules.dedup, rename_map = rules.rename_map, }) - if not id then return back_to_list(err or "创建订阅失败") end + if not id then return back_to_list(err or _("Failed to create subscription")) end core.write_cron() end back_to_list(post_fail_msg) @@ -202,16 +212,16 @@ function action_save() local proxy_enable = fv(http, "proxy_enable") or "0" if proxy_enable ~= "1" then proxy_enable = "0" end local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "") - if id == "" then return back_to_list("缺少订阅 ID") end + if id == "" then return back_to_list(_("Missing subscription ID")) end if name == "" or url == "" then - return back_to_list("名称和 URL 不能为空") + return back_to_list(_("Name and URL cannot be empty")) end local cron_enable, cron_time = read_cron_fields() -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() - if not rules then return back_to_list(rules_err or "规则无效") end + if not rules then return back_to_list(rules_err or _("Invalid rules")) end local user_agent, ua_err = read_ua_fields() - if user_agent == nil then return back_to_list(ua_err or "订阅客户端类型无效") end + if user_agent == nil then return back_to_list(ua_err or _("Invalid subscription client type")) end local ok, err = core.save_meta(id, { name = name, url = url, proxy_enable = proxy_enable, proxy = proxy, user_agent = user_agent, @@ -220,7 +230,7 @@ function action_save() keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, dedup = rules.dedup, rename_map = rules.rename_map, }) - if not ok then return back_to_list(err or "保存失败") end + if not ok then return back_to_list(err or _("Save failed")) end core.write_cron() end back_to_list(post_fail_msg) @@ -235,10 +245,10 @@ function action_local_create() local local_mode = fv(http, "local_mode") or "text" -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() - if not rules then return back_to_list(rules_err or "规则无效") end + if not rules then return back_to_list(rules_err or _("Invalid rules")) end -- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅 - if name == "" then return back_to_list("名称不能为空") end - if content == "" then return back_to_list("订阅内容不能为空") end + if name == "" then return back_to_list(_("Name cannot be empty")) end + if content == "" then return back_to_list(_("Subscription content cannot be empty")) end local id, err = core.add_local(name, content, local_mode, { rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, @@ -246,7 +256,7 @@ function action_local_create() keyword_exclude = rules.keyword_exclude, dedup = rules.dedup, }) - if not id then return back_to_list(err or "创建本地订阅失败") end + if not id then return back_to_list(err or _("Failed to create local subscription")) end end back_to_list(post_fail_msg) end @@ -261,10 +271,10 @@ function action_local_save() local local_mode = fv(http, "local_mode") or "text" -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() - if not rules then return back_to_list(rules_err or "规则无效") end - if id == "" then return back_to_list("缺少订阅 ID") end - if name == "" then return back_to_list("名称不能为空") end - if content == "" then return back_to_list("订阅内容不能为空") end + if not rules then return back_to_list(rules_err or _("Invalid rules")) end + if id == "" then return back_to_list(_("Missing subscription ID")) end + if name == "" then return back_to_list(_("Name cannot be empty")) end + if content == "" then return back_to_list(_("Subscription content cannot be empty")) end local ok, err = core.save_meta(id, { name = name, raw_content = content, @@ -275,10 +285,10 @@ function action_local_save() keyword_exclude = rules.keyword_exclude, dedup = rules.dedup, }) - if not ok then return back_to_list(err or "保存失败") end + if not ok then return back_to_list(err or _("Save failed")) end -- 解析失败会写进 meta.error(列表页 Status 列可见),此处再明确提示一次 local sok, serr = core.sync(id) - if not sok then return back_to_list(serr or "解析订阅内容失败") end + if not sok then return back_to_list(serr or _("Failed to parse subscription content")) end end back_to_list(post_fail_msg) end @@ -298,7 +308,7 @@ function action_delete() for s in tostring(fv(http, "id") or ""):gmatch("[^,%s]+") do ids[#ids + 1] = s end - if #ids == 0 then return back_to_list("未指定要删除的订阅") end + if #ids == 0 then return back_to_list(_("No subscription selected for deletion")) end local removed, first_err = 0, nil for _, id in ipairs(ids) do local ok, err = core.remove(id) @@ -318,9 +328,9 @@ function action_delete() if removed > 0 then core.write_cron() end if removed < #ids then if removed == 0 then - return back_to_list(first_err or "删除失败:订阅不存在") + return back_to_list(first_err or _("Delete failed: subscription not found")) end - return back_to_list(string.format("已删除 %d 个,另有 %d 个删除失败", + return back_to_list(string.format(_("Deleted %d, %d failed"), removed, #ids - removed)) end end @@ -357,22 +367,22 @@ function action_node_save() -- 原来只在成功分支里做事,其余情况一律静默重定向, -- 用户提交了坏数据却看到「已保存」的样子。 if not idx or not nodes[idx] then - back_to_nodes(http, "节点不存在或下标无效") + back_to_nodes(http, _("Node not found or invalid index")) return end if content == "" then - back_to_nodes(http, "提交内容为空") + back_to_nodes(http, _("Submitted content is empty")) return end local res, perr = parser.parse_local(content, "form") local newn = res and res.nodes and res.nodes[1] if not newn then - back_to_nodes(http, perr or "表单数据解析失败") + back_to_nodes(http, perr or _("Failed to parse form data")) return end nodes[idx] = core.merge_form_node(nodes[idx], newn) if not core.write_nodes(id, nodes) then - back_to_nodes(http, "写入节点数据失败") + back_to_nodes(http, _("Failed to write node data")) return end core.refresh_combos(id) @@ -394,7 +404,7 @@ function action_node_delete() end -- §18:什么都没删掉时必须说清楚,不能静默跳回列表。 if #idxs == 0 then - back_to_nodes(http, "未指定要删除的节点") + back_to_nodes(http, _("No node selected for deletion")) return end -- 倒序删除,避免 table.remove 后下标偏移 @@ -407,11 +417,11 @@ function action_node_delete() end end if not removed then - back_to_nodes(http, "节点不存在或已被删除") + back_to_nodes(http, _("Node not found or already deleted")) return end if not core.write_nodes(id, nodes) then - back_to_nodes(http, "写入节点数据失败") + back_to_nodes(http, _("Failed to write node data")) return end core.save_meta(id, { node_count = #nodes }) @@ -465,10 +475,10 @@ function action_combo_save() end -- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 —— -- 用户填了表单却什么都没发生,页面上也没有任何提示。 - if name == "" then return back_to_list("名称不能为空") end + if name == "" then return back_to_list(_("Name cannot be empty")) end -- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」 local rules, rules_err = read_rules_fields() - if not rules then return back_to_list(rules_err or "规则无效") end + if not rules then return back_to_list(rules_err or _("Invalid rules")) end local o = { rules_enable = rules.rules_enable, proto_filter = rules.proto_filter, keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude, @@ -482,7 +492,7 @@ function action_combo_save() else nid, err = core.save_combo(id, name, sources, o) end - if not nid then return back_to_list(err or "保存组合订阅失败") end + if not nid then return back_to_list(err or _("Failed to save combination")) end end back_to_list(post_fail_msg) end @@ -499,7 +509,7 @@ function action_update() end -- §18:订阅不存在(ID 拼错 / 已被删除)时此前静默跳回列表, -- 用户点了「更新」却看不到任何反馈。 - if not meta then return back_to_list("订阅不存在") end + if not meta then return back_to_list(_("Subscription not found")) end -- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错, -- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。 local ok, res, err = pcall(core.sync, id) @@ -508,7 +518,7 @@ function action_update() core.save_meta(id, { error = tostring(res), last_update = os.time() }) elseif not res then -- 正常返回但失败:第三个返回值是错误信息 - core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() }) + core.save_meta(id, { error = tostring(err or _("Update failed")), last_update = os.time() }) end end back_to_list(post_fail_msg) diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm index 5e65d36f4..fdd1e8ba8 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/local_form.htm @@ -145,9 +145,11 @@ var FIELD_LABELS = { "net": "Network", "headerType": "Header Type", "flow": "Flow", + "fp": "<%:Fingerprint%>", "path": "Path", "host": "Host", "sni": "SNI", + "alpn": "<%:ALPN%>", "tls": "TLS", "obfs": "Obfs", "obfs-param": "Obfs Param", @@ -161,6 +163,8 @@ var FIELD_LABELS = { "private-key": "Private Key", "peer-public-key": "Peer Public Key", "public-key": "Public Key", + "short-id": "<%:Short ID%>", + "spider-x": "<%:Spider X%>", "pre-shared-key": "Pre Shared Key", "ip": "IP", "ipv6": "IPv6", diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm index 273ec7981..3e201e4c2 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/node_edit.htm @@ -59,9 +59,11 @@ var FIELD_LABELS = { "net": "Network", "headerType": "Header Type", "flow": "Flow", + "fp": "<%:Fingerprint%>", "path": "Path", "host": "Host", "sni": "SNI", + "alpn": "<%:ALPN%>", "tls": "TLS", "obfs": "Obfs", "obfs-param": "Obfs Param", @@ -75,6 +77,8 @@ var FIELD_LABELS = { "private-key": "Private Key", "peer-public-key": "Peer Public Key", "public-key": "Public Key", + "short-id": "<%:Short ID%>", + "spider-x": "<%:Spider X%>", "pre-shared-key": "Pre Shared Key", "ip": "IP", "ipv6": "IPv6", diff --git a/luci-app-substore/root/usr/lib/lua/luci/view/substore/nodes.htm b/luci-app-substore/root/usr/lib/lua/luci/view/substore/nodes.htm index ccbc9e975..7b55d502b 100644 --- a/luci-app-substore/root/usr/lib/lua/luci/view/substore/nodes.htm +++ b/luci-app-substore/root/usr/lib/lua/luci/view/substore/nodes.htm @@ -64,7 +64,12 @@