mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 21:05:13 +08:00
🌴 Sync 2026-10-06 02:20:56
This commit is contained in:
1 parent
5c201dd3af
commit
487c4fad50
111 files changed
+4641
-746
No files matched your search
+2
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+2
-2
@@ -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)?
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
80de46ce77221bee421a3c19f7d9d0190ee1b7cd
|
||||
9c9ad92d8e1621d0708c83afc215bb141e7bafb8
|
||||
e192c5983e50169ed984d48b094798869a0a780b
|
||||
@@ -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 <muuvckxh81zd@ve0ai.com>\n"
|
||||
"Language-Team: Danish <https://hosted.weblate.org/projects/openwrt/"
|
||||
"luciapplicationsadguardhome/da/>\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"
|
||||
@@ -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 <m_iv@seznam.cz>\n"
|
||||
"Last-Translator: Karakurt <karakurtspider.s0net@gmail.com>\n"
|
||||
"Language-Team: Ukrainian <https://hosted.weblate.org/projects/openwrt/"
|
||||
"luciapplicationsadguardhome/uk/>\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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 <string>)` 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 <string>)` 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') || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 <tun>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) + '...';
|
||||
|
||||
@@ -262,24 +262,26 @@ hp_rule_set_current() {
|
||||
[ -f "$out" ] && [ -s "$out" ] && [ "$out" -nt "$src" ]
|
||||
}
|
||||
|
||||
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
||||
hp_prepare_runtime_files() {
|
||||
# hp_prepare_generated_inputs <hp-dir> <client-enabled>
|
||||
# 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 <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> 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." ;;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 <china_ip4 / china_ip6 / gfw_list / china_list>"
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<family> 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.<field> 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 =="
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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)".
|
||||
|
||||
@@ -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 <repo-root>
|
||||
*/
|
||||
|
||||
'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);
|
||||
@@ -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(' ')}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 '<URL>'" 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:= \
|
||||
|
||||
@@ -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("<a href='javascript:void(0)' onclick='javascript:fetchCertSha256(this)'>%s</a>", "→ " .. 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"), '<a href="https://go.dev/src/crypto/tls/cipher_suites.go#L44" target="_blank">***</a>' .. " " .. 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"))
|
||||
|
||||
@@ -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("<a href='javascript:void(0)' onclick='javascript:fetchCertSha256(this)'>%s</a>", "→ " .. 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"), '<a href="https://go.dev/src/crypto/tls/cipher_suites.go#L44" target="_blank">***</a>' .. " " .. 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"))
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 隧道地址"
|
||||
@@ -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 "清除定时执行命令。"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgstr ""
|
||||
"Language-Team: Spanish <https://hosted.weblate.org/projects/openwrt/"
|
||||
"luciapplicationsqosify/es/>\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"
|
||||
|
||||
@@ -7,27 +7,27 @@ msgstr ""
|
||||
"Language-Team: Korean <https://hosted.weblate.org/projects/openwrt/"
|
||||
"luciapplicationsqosify/ko/>\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 <port> to <endport>"
|
||||
@@ -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"
|
||||
|
||||
+254
-174
File diff suppressed because it is too large.
Load diff
@@ -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 <m_iv@seznam.cz>\n"
|
||||
"Last-Translator: Karakurt <karakurtspider.s0net@gmail.com>\n"
|
||||
"Language-Team: Ukrainian <https://hosted.weblate.org/projects/openwrt/"
|
||||
"luciapplicationsqosify/uk/>\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 ""
|
||||
|
||||
@@ -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 <mengzhuo@iscas.ac.cn>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=<tcp|ws|http>` + `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)。
|
||||
|
||||
@@ -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/<lang>/ 目录自动生成独立的
|
||||
# 翻译包 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.<lang> 才生成,语言码取
|
||||
# $(firstword $(LUCI_LC_ALIAS.<lang>) <lang>),包名 luci-i18n-<LUCI_BASENAME>-<语言码>
|
||||
# (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))
|
||||
@@ -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 <openwrt-tree>/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/.../`.
|
||||
|
||||
@@ -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 <openwrt-tree>/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/.../` 下。
|
||||
|
||||
@@ -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/<lang>/` 目录自动生成,
|
||||
主包的 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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 为审计表中**其余中危 / 低危**项中性质明确、无需另行决策的缺陷,例如
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
@@ -1 +0,0 @@
|
||||
zh-cn
|
||||
@@ -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 "定时:"
|
||||
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 "更新失败"
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
module("luci.controller.admin.substore", package.seeall)
|
||||
|
||||
-- 用户可见文案统一走 LuCI 的翻译层:msgid 一律写**英文**,译文放
|
||||
-- po/<lang>/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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -64,7 +64,12 @@
|
||||
<label><%:Type%>:
|
||||
<select name="proto">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do %>
|
||||
<%
|
||||
-- 协议清单来自 node.PROTOS,不在这里另抄一份:抄一份就会漂移,
|
||||
-- 症状是节点页的「类型」下拉永远选不到新协议(勾了没用、不报错)。
|
||||
-- 另外三个表单模板早已统一用 core.RULE_PROTOS(= node.PROTOS)。
|
||||
%>
|
||||
<% for _, p in ipairs(node.PROTOS) do %>
|
||||
<option value="<%=p%>" <%=proto==p and "selected" or ""%>><%=p%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.7.1"
|
||||
M.version = "2.7.2"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
@@ -26,8 +26,10 @@ M.TIMEOUT = 20
|
||||
-- 一旦漂移,勾选框就会生成一个永远匹配不到任何节点的 proto_filter,
|
||||
-- 而症状是「勾了没用」—— 不会报错,最难查。
|
||||
-- 不含 socks5:解析阶段已把它归一成 socks(见 parser.lua 的说明)。
|
||||
M.RULE_PROTOS = { "vmess", "vless", "trojan", "shadowsocks", "ssr",
|
||||
"hysteria2", "tuic", "hysteria", "wireguard", "socks" }
|
||||
-- 直接引用 node.PROTOS,而不是再写一份字面量:两份逐字相同的清单必然会漂移,
|
||||
-- 而漂移的症状是「勾了没用」且不报错(最难查的一类)。node.lua 只 require
|
||||
-- substore.util,不反向依赖 core,因此这里引用它不会形成循环依赖。
|
||||
M.RULE_PROTOS = require("substore.node").PROTOS
|
||||
|
||||
-- 「订阅客户端类型」预设。部分机场(如 Allblue 加速器)按 User-Agent 区分客户端:
|
||||
-- 同一个订单链接,只有用**该订单绑定的客户端**的 UA 去请求才返回真实节点,
|
||||
|
||||
@@ -8,6 +8,9 @@ local M = {}
|
||||
|
||||
M.PROTOS = {
|
||||
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
|
||||
-- anytls 追加在末尾:清单顺序即界面下拉/复选框顺序,插在中间会改动既有
|
||||
-- 界面的排列(无功能影响,但会让每次 diff 都动到无关行)。
|
||||
"anytls",
|
||||
}
|
||||
|
||||
-- 每种协议在「表单导入 / 节点编辑」界面里渲染的字段集合,数组顺序即界面顺序。
|
||||
@@ -30,7 +33,11 @@ M.PROTO_FIELDS = {
|
||||
-- security,vmess 的加密方式存于 cipher。表单渲染 tls 时读不到 security,
|
||||
-- 保存即丢 TLS。
|
||||
vmess = { "server", "port", "uuid", "alterId", "cipher", "net", "headerType", "path", "host", "sni", "security", "udp", "skip-cert-verify" },
|
||||
vless = { "server", "port", "uuid", "security", "flow", "net", "headerType", "path", "host", "sni", "udp", "skip-cert-verify" },
|
||||
vless = { "server", "port", "uuid", "security", "flow", "net", "headerType", "path", "host", "sni", "udp", "skip-cert-verify",
|
||||
-- Reality(security=reality)参数。public-key 缺失时客户端根本连不上;
|
||||
-- 表单不渲染这几项的话,用户在界面上编辑一次就把 Reality 参数清空
|
||||
-- (core.merge_form_node 把「在清单里但没提交」的字段当作清空处理)。
|
||||
"public-key", "short-id", "spider-x", "fp" },
|
||||
trojan = { "server", "port", "password", "sni", "net", "headerType", "path", "host", "udp", "skip-cert-verify" },
|
||||
-- plugin:SIP003 插件串(`obfs-local;obfs=http;obfs-host=x`)。不进这个清单
|
||||
-- 会有两个后果:表单不渲染它,且 core.merge_form_node 会在保存时把原值清掉
|
||||
@@ -41,6 +48,23 @@ M.PROTO_FIELDS = {
|
||||
-- 字段集合以各输出模块实际消费的键为准(output_clash_meta / output_singbox / output_uri)。
|
||||
hysteria = { "server", "port", "password", "sni", "obfs", "skip-cert-verify" },
|
||||
tuic = { "server", "port", "uuid", "password", "sni", "udp", "skip-cert-verify" },
|
||||
-- anytls:字段集合以各输出实际消费的键为准。mihomo 的 AnyTLSOption 里是
|
||||
-- password / sni / alpn / skip-cert-verify(**注意是 sni,没有 servername**);
|
||||
-- sing-box 是 password + tls{server_name, alpn, insecure};Surge 是 password
|
||||
-- 加共享 TLS 参数。三者交集即下面这几项。
|
||||
-- client-fingerprint 故意不列入:它只在 mihomo 侧有意义,而「不在清单里的
|
||||
-- 字段」在表单保存时会被原样保留(只有清单内的字段才会被清空),
|
||||
-- 所以不列入既不影响导入的节点,也少一个只对单一客户端生效的输入框。
|
||||
--
|
||||
-- Reality:AnyTLS 同样能跑在 Reality 上(Loon 的节点行有 AnyTLS 的 Reality
|
||||
-- 示例、QX 1.6+ 的 anytls 行认 reality-base64-pubkey、sing-box 1.12+ 的 anytls
|
||||
-- 出站 tls 里也能带 reality),public-key 缺失时客户端根本连不上。而
|
||||
-- core.merge_form_node 会把「在清单里但没提交」的字段当作清空处理 ——
|
||||
-- 不列入的话,用户在界面上编辑一次就把这两个凭据抹掉。
|
||||
-- spider-x 不列入:没有任何 anytls 输出端消费它(Loon 只写 public-key /
|
||||
-- short-id,QX 与 sing-box 的 Reality 也没有 spiderX 这个参数)。
|
||||
anytls = { "server", "port", "password", "sni", "alpn", "skip-cert-verify",
|
||||
"public-key", "short-id" },
|
||||
wireguard = { "server", "port", "private-key", "public-key", "pre-shared-key", "ip", "ipv6", "allowed-ips", "reserved", "persistent-keepalive", "listen-port", "mtu", "amnezia-wg-option" },
|
||||
socks = { "server", "port", "username", "password", "udp" },
|
||||
}
|
||||
@@ -48,21 +72,84 @@ M.PROTO_FIELDS = {
|
||||
-- 表单里与协议无关、始终渲染的字段(nodeform.js 的 nodeTemplate 固定输出这三个)
|
||||
M.FORM_ALWAYS_FIELDS = { "name", "group" }
|
||||
|
||||
-- TLS-only 协议:协议本身要求 TLS 层,缺了客户端直接起不来
|
||||
-- (sing-box 的 hysteria2/tuic 出站在 TLS 缺失或未启用时返回 C.ErrTLSRequired)。
|
||||
-- 这是协议自身的约束,属于归一化该保证的不变量,不能依赖各解析器自己补:
|
||||
-- URI 解析器会补 security="tls",但 Clash YAML / sing-box JSON 导入的同名节点
|
||||
-- 没有这个字段,导出后是一份客户端起不来的配置。
|
||||
--
|
||||
-- 单独抽成一张表,是因为 DEFAULTS(下面)与测试(tests/view_injection_test.lua)
|
||||
-- 都要用它。两边各写一份必然漂移:新增一个 TLS-only 协议时只改一处,
|
||||
-- 另一处会静默失效 —— 测试从此不再覆盖新协议。
|
||||
M.TLS_ONLY = {
|
||||
trojan = true,
|
||||
hysteria2 = true,
|
||||
hysteria = true,
|
||||
tuic = true,
|
||||
-- anytls 同样建立在 TLS 之上,协议本身没有明文模式:mihomo 的 AnyTLSOption
|
||||
-- 根本没有 tls 开关,sing-box 的 anytls 出站把 tls 标为 Required,Surge
|
||||
-- 文档写明 "AnyTLS always encrypts traffic with TLS"。
|
||||
-- 缺 security 时 output_singbox.build_tls 返回 nil,sni / insecure 全丢,
|
||||
-- 客户端直接起不来 —— 与 hysteria2/tuic 是同一个不变量。
|
||||
anytls = true,
|
||||
}
|
||||
|
||||
-- 支持 Reality 的协议。Reality 的凭据是 public-key(对端公钥,Base64)+
|
||||
-- short-id(十六进制),两者都从服务端配置里抄来。
|
||||
--
|
||||
-- 判据必须是「协议 + 有没有 public-key」两者,只看 public-key 会误伤 wireguard:
|
||||
-- wireguard 的 public-key 是「对端公钥」,与 Reality 毫无关系,一旦被当成 Reality,
|
||||
-- security 会被写成 "reality",output_singbox 就会去读 tls.reality,wireguard
|
||||
-- 节点的 TLS 无关字段整批丢失。
|
||||
--
|
||||
-- 协议清单取自各客户端文档:Loon 的 Reality 示例覆盖 VLESS / VMess / Trojan /
|
||||
-- AnyTLS(nsloon.app/docs/Node/),mihomo 的 reality-opts 也出现在这几类出站上。
|
||||
M.REALITY_PROTOS = {
|
||||
vless = true,
|
||||
vmess = true,
|
||||
trojan = true,
|
||||
anytls = true,
|
||||
}
|
||||
|
||||
-- 支持 uTLS 客户端指纹(mihomo 的 client-fingerprint)的协议。
|
||||
--
|
||||
-- 键名与语义都已对照上游源码确认(MetaCubeX/mihomo,adapter/outbound/*.go):
|
||||
-- * vmess / vless / trojan / shadowsocks / anytls 的选项结构体里是
|
||||
-- `ClientFingerprint string \`proxy:"client-fingerprint,omitempty"\``,即 uTLS 指纹;
|
||||
-- * hysteria / hysteria2 / tuic 上叫 `fingerprint`,但那是**证书固定**
|
||||
-- (SHA256 pin),与 uTLS 是两回事,把 fp 写过去是语义错误;
|
||||
-- * 全仓库没有任何结构体声明 `proxy:"fp,..."`。旧输出写的 `fp:` 键在 mihomo
|
||||
-- 里根本不存在,而它的 proxy 解码器对未知键是**静默忽略**的
|
||||
-- (common/structure/structure.go:多余的键留在 dataValKeysUnused 里,没有
|
||||
-- `,remain` 字段就再也不检查、不报错),于是这个错误不报错、不生效 ——
|
||||
-- 用户拿到的配置看起来「有指纹」,实际握手用的是默认指纹。
|
||||
--
|
||||
-- 抽成一张表而不是在各输出模块里各写一遍 if 链:新增协议(如 anytls)时只改
|
||||
-- 一处,遗漏由 tests/protocol_registry_test.lua 直接暴露。
|
||||
M.CLIENT_FP_PROTOS = {
|
||||
vmess = true,
|
||||
vless = true,
|
||||
trojan = true,
|
||||
shadowsocks = true,
|
||||
anytls = true,
|
||||
}
|
||||
|
||||
-- 协议是否支持 uTLS 客户端指纹。兼容 ss 等别名写法(未归一化的节点直接进来
|
||||
-- 时 proto 可能是 "ss"),归一化规则与 M.normalize 保持一致。
|
||||
function M.supports_client_fp(proto)
|
||||
if proto == "ss" then proto = "shadowsocks" end
|
||||
return M.CLIENT_FP_PROTOS[proto] == true
|
||||
end
|
||||
|
||||
-- 协议默认值,用于补全缺省字段
|
||||
local DEFAULTS = {
|
||||
vmess = { net = "tcp", security = "none" },
|
||||
vless = { net = "tcp", security = "none" },
|
||||
trojan = { security = "tls" },
|
||||
-- hysteria2 / hysteria / tuic 在 sing-box 与 mihomo 里都是 TLS-only:
|
||||
-- sing-box 的 hysteria2/tuic 出站在 TLS 缺失或未启用时直接返回 C.ErrTLSRequired
|
||||
-- 拒绝启动。URI 解析器(parser.lua)本来就会补 security="tls",但 Clash YAML
|
||||
-- 与 sing-box JSON 导入的 hysteria2/tuic 节点没有这个字段,导出后是一份客户端
|
||||
-- 起不来的配置。TLS-only 是协议本身的约束,属于归一化该保证的不变量,
|
||||
-- 与 trojan 同理。
|
||||
hysteria2 = { security = "tls" },
|
||||
hysteria = { security = "tls" },
|
||||
tuic = { security = "tls" },
|
||||
}
|
||||
-- TLS-only 协议的默认 security 由 M.TLS_ONLY 推导,不在这里另列一遍
|
||||
for proto in pairs(M.TLS_ONLY) do
|
||||
DEFAULTS[proto] = { security = "tls" }
|
||||
end
|
||||
|
||||
-- vmess 加密方式(cipher)白名单。sing-box 的 vmess.security、Xray 的
|
||||
-- users[].security、Clash 的 vmess.cipher 取同一组值;写入非法值会让客户端
|
||||
@@ -117,6 +204,17 @@ function M.normalize(node)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reality:带 public-key 就是 Reality,与 security 是怎么写的无关 ——
|
||||
-- 分享链接写 security=reality,Clash / Loon 的行则写 public-key 而 security
|
||||
-- 仍是 tls 或 none(Loon 的 Reality 行就是 over-tls=true + public-key)。
|
||||
-- 统一在这里推导,各解析器与各输出模块就不必各判一次,也不会出现
|
||||
-- 「同一个节点在 clash 里出了 reality-opts、在 v2ray 里却被当成普通 TLS」
|
||||
-- 这种按格式分叉的漏判。
|
||||
-- 必须排在 TLS_ONLY 补的 security="tls" 之后,否则 anytls 会被改回 tls。
|
||||
if node["public-key"] and M.REALITY_PROTOS[node.proto] then
|
||||
node.security = "reality"
|
||||
end
|
||||
|
||||
-- vmess 的加密方式存于 cipher,与 TLS 层(security)无关;
|
||||
-- 非白名单取值(例如被误写成 "tls")直接丢弃,避免输出非法配置
|
||||
if node.proto == "vmess" and node.cipher ~= nil and not M.VMESS_CIPHERS[node.cipher] then
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
-- node.lua 只 require util,不反向依赖任何输出模块,因此这里不会形成循环依赖。
|
||||
-- 需要它是因为「哪些协议支持 uTLS 客户端指纹」必须只有一个来源(见
|
||||
-- node.CLIENT_FP_PROTOS 的说明),在输出模块里另抄一份必然漂移。
|
||||
local node_model = require("substore.node")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -88,12 +92,23 @@ local PROTOCOL_TYPE_MAP = {
|
||||
socks = "socks5",
|
||||
socks5 = "socks5",
|
||||
ssr = "ssr",
|
||||
-- anytls:mihomo 的 type 字面量就是 "anytls"(adapter/outbound/anytls.go 里
|
||||
-- AnyTLSOption 的注册名)。Stash 与 mihomo 同源,共用这份映射。
|
||||
anytls = "anytls",
|
||||
}
|
||||
|
||||
local function get_clash_type(proto)
|
||||
return PROTOCOL_TYPE_MAP[proto] or proto
|
||||
end
|
||||
|
||||
-- 能写 `reality-opts` 的出站类型。mihomo 的 RealityOptions 只挂在
|
||||
-- vless / vmess / trojan 三个出站上(adapter/outbound/{vless,vmess,trojan}.go
|
||||
-- 都声明了 `RealityOpts RealityOptions \`proxy:"reality-opts,omitempty"\``);
|
||||
-- anytls 的 AnyTLSOption 里**没有**任何 reality 字段,写了会被静默忽略
|
||||
-- (mihomo 的 proxy 解码器对未知键不报错),节点看起来配了 Reality 实际没配。
|
||||
-- 这里的键是 mihomo 的 type 字面量(get_clash_type 的输出),不是统一模型的 proto。
|
||||
local REALITY_CTYPES = { vless = true, vmess = true, trojan = true }
|
||||
|
||||
-- SIP003 插件 → mihomo 的 plugin / plugin-opts。
|
||||
--
|
||||
-- 名称与参数名都和 SIP003 原文不同,需要逐项翻译:
|
||||
@@ -234,9 +249,13 @@ local function format_node(node, name)
|
||||
-- 会同时填上两者(先 sni = p.sni or p.servername,随后的字段保留循环又原样复制了
|
||||
-- servername),各写一行会让 YAML 出现重复键 —— 严格解析器直接报错,宽松解析器
|
||||
-- 则取最后一个,行为不确定。
|
||||
local is_hysteria = (ctype == "hysteria" or ctype == "hysteria2")
|
||||
-- mihomo 里 vmess / vless / trojan 用 servername;hysteria / hysteria2 与 anytls
|
||||
-- 用 sni。anytls 的 AnyTLSOption 结构体里**根本没有 servername 字段**(只有
|
||||
-- sni),写了会被它的解码器静默忽略 —— SNI 丢失,客户端拿 IP 校验证书直接
|
||||
-- 握手失败,与 hysteria 系列是同一个坑。anytls 的 sni 由下面的协议专属分支输出。
|
||||
local uses_sni = (ctype == "hysteria" or ctype == "hysteria2" or ctype == "anytls")
|
||||
local tls_name = node.sni or node.servername
|
||||
if tls_name and not is_hysteria then
|
||||
if tls_name and not uses_sni then
|
||||
lines[#lines + 1] = " servername: " .. esc_yaml(tls_name)
|
||||
end
|
||||
|
||||
@@ -261,8 +280,14 @@ local function format_node(node, name)
|
||||
lines[#lines + 1] = " - " .. esc_yaml(v)
|
||||
end
|
||||
end
|
||||
if node.fp then
|
||||
lines[#lines + 1] = " fp: " .. esc_yaml(node.fp)
|
||||
-- uTLS 客户端指纹。键名是 client-fingerprint,**不是** fp —— 详见
|
||||
-- node.CLIENT_FP_PROTOS 的说明:mihomo 全仓库没有任何结构体声明
|
||||
-- `proxy:"fp,..."`,而它的解码器对未知键静默忽略,所以旧的 `fp:` 既不报错
|
||||
-- 也不生效(配置看起来有指纹,实际用默认指纹)。
|
||||
-- 同时只对真正支持 uTLS 的协议输出:hysteria / hysteria2 / tuic 上的
|
||||
-- `fingerprint` 是证书固定(SHA256 pin),语义不同,不能拿 fp 顶上。
|
||||
if node.fp and node_model.supports_client_fp(node.proto) then
|
||||
lines[#lines + 1] = " client-fingerprint: " .. esc_yaml(node.fp)
|
||||
end
|
||||
|
||||
-- 协议特定字段
|
||||
@@ -285,6 +310,24 @@ local function format_node(node, name)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reality:mihomo 的 RealityOptions 结构体是
|
||||
-- `{public-key, short-id, support-x25519mlkem768}`,作为出站的 `reality-opts`
|
||||
-- 子映射。缺 public-key 时 reality 握手无法建立,所以判据取「有没有 public-key」
|
||||
-- 而不是 security == "reality" —— 与 output_formats / output_singbox 一致,
|
||||
-- 也免得 security 写法不同(分享链接写 reality,Loon 的行只写 over-tls)
|
||||
-- 导致同一节点在某个格式下漏掉 Reality 配置。
|
||||
-- 能写这个子映射的类型见 REALITY_CTYPES;wireguard 顶层的 public-key 是
|
||||
-- **对端公钥**,与 Reality 无关,绝不能落进这里。
|
||||
-- uTLS 指纹**不在**这个子映射里 —— 它是代理顶层的 client-fingerprint,
|
||||
-- 由上面的通用分支输出(两者是独立的键,别合并)。
|
||||
if REALITY_CTYPES[ctype] and node["public-key"] then
|
||||
lines[#lines + 1] = " reality-opts:"
|
||||
lines[#lines + 1] = " public-key: " .. esc_yaml(node["public-key"])
|
||||
if node["short-id"] then
|
||||
lines[#lines + 1] = " short-id: " .. esc_yaml(node["short-id"])
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "hysteria2" then
|
||||
if node.sni then
|
||||
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
|
||||
@@ -310,6 +353,14 @@ local function format_node(node, name)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "anytls" then
|
||||
-- 键名是 sni,不是 servername —— 见上面 uses_sni 的说明。
|
||||
local sn = node.sni or node.servername
|
||||
if sn then
|
||||
lines[#lines + 1] = " sni: " .. esc_yaml(sn)
|
||||
end
|
||||
end
|
||||
|
||||
if ctype == "tuic" then
|
||||
if node["udp-relay-mode"] then
|
||||
lines[#lines + 1] = " udp-relay-mode: " .. esc_yaml(node["udp-relay-mode"])
|
||||
|
||||
@@ -13,10 +13,35 @@ local function props(n)
|
||||
return proto
|
||||
end
|
||||
|
||||
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
|
||||
function M.surge_line(n)
|
||||
-- 支持 Reality 的目标格式。Reality 只在 Loon 的节点行里有官方文档:
|
||||
-- nsloon.app/docs/Node/ 给出 VLESS / VMess / Trojan / AnyTLS 的 Reality 示例,
|
||||
-- 统一用 public-key / short-id 两个具名参数。
|
||||
--
|
||||
-- Surge 家族没有 Reality:Surge 手册的协议清单里没有 VLESS,共享 TLS 参数页
|
||||
-- (manual.nssurge.com/policies/tls.html)也只列 skip-cert-verify / sni / alpn /
|
||||
-- server-cert-verify-name / server-cert-fingerprint-sha256 / client-cert,
|
||||
-- 没有任何公钥参数;Surfboard 的 AnyTLS 文档同样只有 password / skip-cert-verify /
|
||||
-- sni / server-cert-fingerprint-sha256 / reuse。给它们写 public-key 是「写了客户端
|
||||
-- 不认识的参数」,节点必然连不上,那个参数对客户端也只是噪声。
|
||||
-- 注意:Surge 对「不认识的代理行 / 参数」的处置**没有官方依据** —— 官方只说明过
|
||||
-- 无法识别的 *section* 会原样保留且不报错,代理行的情况未提及(本仓库早期的注释
|
||||
-- 与 LEGACY_ISSUES 里写的「会拒绝加载整份配置」未经证实,已按此更正)。
|
||||
-- 所以这里的判据是「不写客户端读不懂的东西」,与本文件丢弃 wireguard / ssr 同一约定,
|
||||
-- 不依赖任何未经证实的「整份配置会被拒绝」前提。
|
||||
-- Egern 确实有 Reality,但它的写法是 YAML 里的 reality 对象
|
||||
-- (egernapp.com/docs/configuration/proxies/),与本文件输出的逗号行对不上。
|
||||
local REALITY_FLAVORS = { loon = true }
|
||||
|
||||
-- 生成 Surge 风格代理行(Surge / Surfboard / SurgeMac / Loon / Egern 通用)。
|
||||
-- flavor 取目标格式名,只用于「各客户端写法确实不同」的参数 —— 目前是 AnyTLS
|
||||
-- 密码的位置与 Reality 是否支持;其余参数各家共用同一套 Surge 语法。
|
||||
function M.surge_line(n, flavor)
|
||||
flavor = flavor or "surge"
|
||||
local proto = props(n)
|
||||
local head = proto .. ", " .. (n.server or "") .. ", " .. tostring(n.port or 0)
|
||||
local head_parts = { proto, n.server or "", tostring(n.port or 0) }
|
||||
-- 位置参数:紧跟端口之后的字段(Surfboard / Loon 的 AnyTLS 密码写在这里)。
|
||||
-- 与 e(具名 key=value 列表)分开攒,最后按「端口, 位置参数…, 具名参数…」拼接。
|
||||
local positional = {}
|
||||
local e = {}
|
||||
local tls = (n.security and n.security ~= "none") or (n.tls and n.tls ~= false and n.tls ~= "none")
|
||||
|
||||
@@ -31,6 +56,22 @@ function M.surge_line(n)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reality 参数。Loon 的 VLESS / VMess / Trojan / AnyTLS Reality 示例共用
|
||||
-- public-key(Base64 公钥)与 short-id(十六进制)这两个参数名
|
||||
-- (nsloon.app/docs/Node/ 原文:「public-key 和 short-id 用于 Reality」)。
|
||||
--
|
||||
-- 判据取「有没有 public-key」而不是 security == "reality":public-key 就是
|
||||
-- Reality 的凭据,缺了它客户端根本连不上;而 security 在两种来源里含义不同 ——
|
||||
-- 分享链接写 security=reality,Loon 的行则写 over-tls=true + public-key
|
||||
-- (security 只是 tls)。只看 security 会漏掉后者,导出后 Reality 参数全丢。
|
||||
local function add_reality()
|
||||
-- 只有 Loon 的节点行文档化了 Reality 参数(见 REALITY_FLAVORS 的说明)
|
||||
if not REALITY_FLAVORS[flavor] then return end
|
||||
if not n["public-key"] then return end
|
||||
e[#e + 1] = "public-key=" .. n["public-key"]
|
||||
if n["short-id"] then e[#e + 1] = "short-id=" .. n["short-id"] end
|
||||
end
|
||||
|
||||
if proto == "shadowsocks" then
|
||||
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-256-gcm")
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
@@ -38,17 +79,53 @@ function M.surge_line(n)
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if tls then e[#e + 1] = "tls=true" end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
-- VMess 的 Reality 与 VLESS 同形。Loon 文档的 VMess-Reality 示例:
|
||||
-- `VMess = VMess,host,443,aes-128-gcm,"uuid",transport=tcp,alterId=0,`
|
||||
-- `public-key="…",short-id=…,over-tls=true`(nsloon.app/docs/Node/)。
|
||||
-- 此前只在 vless / trojan / anytls 三个分支调了 add_reality(),vmess 的
|
||||
-- Reality 节点导出到 Loon 后 public-key / short-id 全丢 —— 客户端按普通
|
||||
-- TLS 去连,REALITY 握手建立不起来,而 Loon 不会因此报错。
|
||||
add_reality()
|
||||
add_ws()
|
||||
elseif proto == "vless" then
|
||||
e[#e + 1] = "username=" .. (n.uuid or "")
|
||||
if tls then e[#e + 1] = "tls=true" end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
if n.flow then e[#e + 1] = "flow=" .. n.flow end
|
||||
add_reality()
|
||||
add_ws()
|
||||
elseif proto == "trojan" then
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
e[#e + 1] = "tls=true"
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
add_reality()
|
||||
elseif proto == "anytls" then
|
||||
-- AnyTLS 建立在 TLS 之上,协议本身没有明文模式,行里也就没有 tls 开关
|
||||
-- (Surge 手册:TLS 总是启用,共用 TLS 参数;sing-box 把 tls 标为 Required)。
|
||||
--
|
||||
-- 密码的写法各家不同,且三家都有官方文档,只能按 flavor 分别输出:
|
||||
-- Surge(iOS 5.17.0+ / Mac 6.4.3+)与 SurgeMac:具名参数
|
||||
-- `Name = anytls, host, port, password=pwd`
|
||||
-- (manual.nssurge.com/policies/anytls.html)
|
||||
-- Surfboard:端口之后的位置参数,裸值不带引号
|
||||
-- `Name = anytls, host, port, password, skip-cert-verify=true, ...`
|
||||
-- (getsurfboard.com/docs/profile-format/proxy/external-proxy/anytls/)
|
||||
-- Loon:端口之后的位置参数,值用双引号包起来
|
||||
-- `Name = AnyTLS,host,port,"password",sni=...`(nsloon.app/docs/Node/)
|
||||
-- Egern 没有逗号行语法(配置是 YAML),这里沿用具名写法。
|
||||
--
|
||||
-- 这是本文件里唯一按 flavor 分叉的协议:trojan / vmess / vless 一律用具名
|
||||
-- 参数,而 Loon / Surfboard 的文档同样把它们的凭据画在位置参数上 —— 那是
|
||||
-- 既有实现,改动面大且不在本次范围内,所以只让 anytls 按文档写对。
|
||||
if flavor == "surfboard" then
|
||||
positional[#positional + 1] = n.password or ""
|
||||
elseif flavor == "loon" then
|
||||
positional[#positional + 1] = '"' .. (n.password or "") .. '"'
|
||||
else
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
end
|
||||
if n.sni then e[#e + 1] = "sni=" .. n.sni end
|
||||
add_reality()
|
||||
elseif proto == "ssr" then
|
||||
e[#e + 1] = "encrypt-method=" .. (n.method or n.cipher or "aes-128-cfb")
|
||||
e[#e + 1] = "password=" .. (n.password or "")
|
||||
@@ -87,6 +164,12 @@ function M.surge_line(n)
|
||||
e[#e + 1] = "username=" .. n.username
|
||||
if n.password then e[#e + 1] = "password=" .. n.password end
|
||||
end
|
||||
else
|
||||
-- 未知协议:以前会一路掉到这里,生成 `Name = snell, host, port` 这样的残行。
|
||||
-- 客户端遇到不认识的类型,这一个节点必然不可用(是跳过该节点还是拒绝加载
|
||||
-- 整份配置,各家均未获官方证实),没有理由把它输出出去。
|
||||
-- 返回 nil 由调用方整条剔除,与下面「值里含逗号」同一约定。
|
||||
return nil
|
||||
end
|
||||
|
||||
if n["skip-cert-verify"] then e[#e + 1] = "skip-cert-verify=1" end
|
||||
@@ -102,8 +185,14 @@ function M.surge_line(n)
|
||||
local v = kv:match("^[^=]*=(.*)$")
|
||||
if v and v:find(",", 1, true) then return nil end
|
||||
end
|
||||
-- 位置参数同样没有引号 / 转义机制(Loon 的那对引号只是标记,值里的逗号照样
|
||||
-- 会被当成分隔符),判定条件与具名参数一致
|
||||
for _, v in ipairs(positional) do
|
||||
if v:find(",", 1, true) then return nil end
|
||||
end
|
||||
|
||||
local line = (n.name or "") .. " = " .. head
|
||||
for _, v in ipairs(positional) do head_parts[#head_parts + 1] = v end
|
||||
local line = (n.name or "") .. " = " .. table.concat(head_parts, ", ")
|
||||
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
|
||||
-- 节点名与各参数值都来自订阅(不可信):含换行会截断本行并伪造出一条新的代理行
|
||||
return util.one_line(line)
|
||||
@@ -114,8 +203,9 @@ end
|
||||
-- 两类名字必须排除,否则生成的配置整体非法:
|
||||
-- * 含逗号:这些格式的成员列表就是 `NAME = select, X, Y, DIRECT`,语法里没有
|
||||
-- 引号 / 转义机制,名字里的逗号会被当成成员分隔符 —— `A,B` 被读成两个成员
|
||||
-- `A` 与 `B`,两个都不存在,Surge / QX 会因引用不存在的代理而拒绝加载整份
|
||||
-- 配置。节点定义本身仍留在 [Proxy] / [server_local] 中,只是不进成员列表。
|
||||
-- `A` 与 `B`,两个都不存在(客户端的处置未获官方证实,但引用不存在的代理
|
||||
-- 无论如何都不是用户想要的结果)。节点定义本身仍留在 [Proxy] /
|
||||
-- [server_local] 中,只是不进成员列表。
|
||||
-- * 换行:定义行经过 util.one_line(换行→空格),成员列表若用原始名就对不上
|
||||
-- 定义行,同样成为悬空引用。这里统一先 one_line 再比对,保证两边一致。
|
||||
local function names_of(nodes)
|
||||
@@ -131,7 +221,8 @@ end
|
||||
|
||||
-- Surge 家族配置(Surge / Surfboard / SurgeMac / Loon / Egern 通用)
|
||||
-- supports_ssr:Loon / Egern 支持 SSR;Surge / Surfboard / SurgeMac 不支持,跳过 ssr 节点
|
||||
local function surge_config(nodes, group_name, supports_ssr)
|
||||
-- flavor:目标格式名,原样交给 surge_line,用于按客户端分叉的少数参数
|
||||
local function surge_config(nodes, group_name, supports_ssr, flavor)
|
||||
local list = nodes or {}
|
||||
-- 过滤 Surge 家族无法用单行 [Proxy] 表达的协议:
|
||||
-- ssr:仅 Loon / Egern 支持(supports_ssr),Surge/Surfboard/SurgeMac 丢
|
||||
@@ -151,11 +242,10 @@ local function surge_config(nodes, group_name, supports_ssr)
|
||||
local out = {}
|
||||
out[#out + 1] = "[Proxy]"
|
||||
-- surge_line 对「值里含逗号」这类无法表达的节点返回 nil,这些节点必须
|
||||
-- 同时从成员列表里剔除 —— 否则 [Proxy Group] 会引用一个不存在的代理,
|
||||
-- Surge 直接拒绝加载整份配置。
|
||||
-- 同时从成员列表里剔除 —— 否则 [Proxy Group] 会引用一个不存在的代理。
|
||||
local rendered = {}
|
||||
for _, n in ipairs(list) do
|
||||
local line = M.surge_line(n)
|
||||
local line = M.surge_line(n, flavor)
|
||||
if line then
|
||||
out[#out + 1] = line
|
||||
rendered[#rendered + 1] = n
|
||||
@@ -173,28 +263,28 @@ end
|
||||
|
||||
function M.to_surge(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surge")
|
||||
end
|
||||
|
||||
function M.to_surfboard(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surfboard")
|
||||
end
|
||||
|
||||
function M.to_surgemac(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", false)
|
||||
return surge_config(nodes, options.name or "PROXY", false, "surgemac")
|
||||
end
|
||||
|
||||
-- Loon / Egern:Surge 兼容语法(支持 SSR)
|
||||
function M.to_loon(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true)
|
||||
return surge_config(nodes, options.name or "PROXY", true, "loon")
|
||||
end
|
||||
|
||||
function M.to_egern(nodes, options)
|
||||
options = options or {}
|
||||
return surge_config(nodes, options.name or "PROXY", true)
|
||||
return surge_config(nodes, options.name or "PROXY", true, "egern")
|
||||
end
|
||||
|
||||
-- Stash:Clash 兼容 YAML
|
||||
@@ -206,6 +296,11 @@ end
|
||||
-- 只排除"确定不支持"的,不做白名单,避免误丢原版其实支持的类型。
|
||||
local CLASH_LEGACY_UNSUPPORTED = {
|
||||
vless = true, hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
|
||||
-- anytls 同样是原版 Clash 之后才出现的类型(Dreamacro/clash 的配置参考里
|
||||
-- 只有 ss / ssr / vmess / snell / trojan / http / socks5)。不排除的话
|
||||
-- to_clash 会把它交给 clash_meta 生成 `type: anytls`,原版 Clash 解析到
|
||||
-- 未知类型会拒绝加载整份配置。
|
||||
anytls = true,
|
||||
}
|
||||
|
||||
-- Clash 原版:过滤掉原版不认识的协议后,复用 Clash.Meta 的 YAML 生成
|
||||
@@ -234,6 +329,22 @@ local function qx_tls(n, f)
|
||||
if n.security and n.security ~= "none" then f[#f + 1] = "tls-verification=true" end
|
||||
end
|
||||
|
||||
-- QX 的 Reality 参数。官方 sample.conf(crossutility/Quantumult-X 仓库)的
|
||||
-- [server_local] 里,vmess / vless / trojan / anytls 四类条目都用这两个键:
|
||||
-- Base64 公钥 reality-base64-pubkey 与十六进制 reality-hex-shortid。
|
||||
-- sample.conf 的注释写明「只要该行设置了 reality-base64-pubkey,标准 TLS 就会被
|
||||
-- 换成 Reality TLS」。判据取「有没有 public-key」,与 surge_line 的 add_reality 一致。
|
||||
--
|
||||
-- 注意(已知差异,见 CHANGELOG):sample.conf 的注释把「该行带 TLS 标志」列为
|
||||
-- 生效前提,而 vmess / vless 的 TLS 标志在 QX 里写作 obfs=over-tls / obfs=wss
|
||||
-- (trojan / anytls 才写 over-tls=true + tls-host=)。本函数只负责输出 Reality
|
||||
-- 两个键,TLS 标志由上面的 qx_tls 输出;两者写法不一致时 QX 会忽略公钥。
|
||||
local function qx_reality(n, f)
|
||||
if not n["public-key"] then return end
|
||||
f[#f + 1] = "reality-base64-pubkey=" .. n["public-key"]
|
||||
if n["short-id"] then f[#f + 1] = "reality-hex-shortid=" .. n["short-id"] end
|
||||
end
|
||||
|
||||
-- 单条 [server_local] 定义行。返回 nil 表示该节点无法用 QX 的行语法表达,
|
||||
-- 由调用方整条剔除(定义行与 [policy] 成员一并去掉)。
|
||||
--
|
||||
@@ -253,15 +364,33 @@ local function qx_server_line(n, tag)
|
||||
f[#f + 1] = "password=" .. (n.uuid or "")
|
||||
qx_transport(n, f)
|
||||
qx_tls(n, f)
|
||||
-- sample.conf 有 vmess 的 Reality 条目(obfs=over-tls + reality-base64-pubkey
|
||||
-- + reality-hex-shortid),此前只给 vless / anytls 输出了公钥。
|
||||
qx_reality(n, f)
|
||||
elseif proto == "vless" then
|
||||
f[#f + 1] = "method=none"
|
||||
f[#f + 1] = "password=" .. (n.uuid or "")
|
||||
qx_transport(n, f)
|
||||
qx_tls(n, f)
|
||||
-- sample.conf 的 vless Reality 条目在公钥之外还带 vless-flow=xtls-rprx-vision
|
||||
if n["public-key"] and n.flow then f[#f + 1] = "vless-flow=" .. n.flow end
|
||||
qx_reality(n, f)
|
||||
elseif proto == "trojan" then
|
||||
f[#f + 1] = "password=" .. (n.password or "")
|
||||
f[#f + 1] = "over-tls=true"
|
||||
if n.sni then f[#f + 1] = "tls-host=" .. n.sni end
|
||||
-- sample.conf 的 trojan Reality 条目就是这一形:
|
||||
-- `trojan=host:443, password=pwd, over-tls=true, tls-host=apple.com,`
|
||||
-- `reality-base64-pubkey=…, reality-hex-shortid=…, tag=…`
|
||||
qx_reality(n, f)
|
||||
elseif proto == "anytls" then
|
||||
-- QX 1.6.0 起支持 anytls(版本号只有第三方转述,语法本身取自官方
|
||||
-- sample.conf)。写法与上面的 trojan 同形:
|
||||
-- `anytls=host:port, password=pwd, over-tls=true, tls-host=<sni>, ...`
|
||||
f[#f + 1] = "password=" .. (n.password or "")
|
||||
f[#f + 1] = "over-tls=true"
|
||||
if n.sni then f[#f + 1] = "tls-host=" .. n.sni end
|
||||
qx_reality(n, f)
|
||||
end
|
||||
if #f == 0 then return nil end
|
||||
for _, kv in ipairs(f) do
|
||||
@@ -278,7 +407,8 @@ end
|
||||
function M.to_qx(nodes, options)
|
||||
options = options or {}
|
||||
local out = {}
|
||||
-- 真正写出了 [server_local] 行的节点。QX 只支持下面这 4 类协议,其余节点
|
||||
-- 真正写出了 [server_local] 行的节点。QX 只支持下面这 5 类协议
|
||||
-- (shadowsocks / vmess / vless / trojan / anytls),其余节点
|
||||
-- (hysteria2 / hysteria / tuic / socks / wireguard / ssr …)没有定义行;
|
||||
-- [policy] 若把它们也列进去,就成了引用不存在服务器的悬空条目。
|
||||
local emitted = {}
|
||||
|
||||
@@ -57,6 +57,8 @@ local TYPE_MAP = {
|
||||
socks = "socks",
|
||||
socks5 = "socks",
|
||||
http = "http",
|
||||
-- anytls:sing-box 自 1.12.0 起支持(option/anytls.go,type 字面量 "anytls")。
|
||||
anytls = "anytls",
|
||||
}
|
||||
|
||||
-- 该协议能否落到 sing-box outbound(SSR 无法表达,跳过)
|
||||
@@ -90,6 +92,24 @@ local function build_tls(n)
|
||||
elseif n.skip_cert_verify ~= nil then
|
||||
tls.insecure = bool(n.skip_cert_verify)
|
||||
end
|
||||
-- uTLS 指纹与 Reality。键名与 mihomo 完全不同:sing-box 用 snake_case 且
|
||||
-- 都嵌在 tls 里(option/tls.go 的 OutboundUTLSOptions / OutboundRealityOptions)。
|
||||
-- Reality 下 uTLS 是**强制**的:上游 common/tls/reality_client.go 里
|
||||
-- `if options.UTLS == nil || !options.UTLS.Enabled { return nil,
|
||||
-- E.New("uTLS is required by reality client") }` —— 只写 reality 不写 utls
|
||||
-- 会让整份配置起不来。
|
||||
if n.fp then
|
||||
tls.utls = { enabled = true, fingerprint = n.fp }
|
||||
end
|
||||
if n.security == "reality" and n["public-key"] then
|
||||
tls.reality = { enabled = true, public_key = n["public-key"] }
|
||||
if n["short-id"] then tls.reality.short_id = n["short-id"] end
|
||||
-- Reality 规范里 fp 不可省略(缺省 chrome)。节点没带 fp 时补默认值,
|
||||
-- 否则上面那条 uTLS 强制检查会让 sing-box 拒绝加载整份配置。
|
||||
if not tls.utls then
|
||||
tls.utls = { enabled = true, fingerprint = "chrome" }
|
||||
end
|
||||
end
|
||||
return tls
|
||||
end
|
||||
|
||||
@@ -179,6 +199,10 @@ function M.to_outbound(n, tag)
|
||||
o.uuid = n.uuid or ""
|
||||
o.password = n.password or ""
|
||||
if n.congestion_control then o.congestion_control = n.congestion_control end
|
||||
elseif stype == "anytls" then
|
||||
-- anytls 出站的认证字段就是 password,TLS 走通用的 tls 块
|
||||
-- (parse_anytls 已补 security="tls",build_tls 因此会写出 tls 对象)。
|
||||
o.password = n.password or ""
|
||||
elseif stype == "wireguard" then
|
||||
local pk = n["private-key"] or n.private_key
|
||||
local ppk = n["peer-public-key"] or n.peer_public_key or n["public-key"] or n.public_key
|
||||
|
||||
@@ -106,6 +106,12 @@ function M.to_share_uri(n)
|
||||
if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end
|
||||
if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end
|
||||
if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end
|
||||
-- Reality 参数:键名 pbk / sid / spx 来自 Xray 分享链接规范
|
||||
-- (XTLS/Xray-core discussion #716),mihomo 与 v2rayN 都按它解析。
|
||||
-- 不写出 pbk 的话,链接导入到任何客户端都得不到 Reality 配置,节点不可用。
|
||||
if n["public-key"] then q[#q + 1] = "pbk=" .. url_encode(n["public-key"]) end
|
||||
if n["short-id"] then q[#q + 1] = "sid=" .. url_encode(n["short-id"]) end
|
||||
if n["spider-x"] then q[#q + 1] = "spx=" .. url_encode(n["spider-x"]) end
|
||||
return "vless://" .. (n.uuid or "") .. "@" .. server .. ":" .. tostring(port)
|
||||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||||
end
|
||||
@@ -120,6 +126,20 @@ function M.to_share_uri(n)
|
||||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "anytls" then
|
||||
-- anytls:// 分享链接(由 anytls-go 定义):anytls://[auth@]host[:port]/?sni=..&insecure=..
|
||||
-- 密码放在 userinfo(auth)位置,端口缺省 443。URI 规范只定义了 sni 与
|
||||
-- insecure 两个查询参数,alpn 之类只存在于各家客户端的配置文件里 ——
|
||||
-- 写进链接是非法参数(严格解析器会拒绝,宽松的会静默忽略)。
|
||||
local q = {}
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
local ins = insecure_flag(n)
|
||||
if ins ~= nil then q[#q + 1] = "insecure=" .. ins end
|
||||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||||
return "anytls://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||||
.. suffix .. "#" .. name
|
||||
end
|
||||
|
||||
if proto == "hysteria2" or proto == "hysteria" then
|
||||
local q = {}
|
||||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||||
|
||||
@@ -43,15 +43,37 @@ local function build_stream_settings(n)
|
||||
|
||||
-- 安全层
|
||||
local security = n.security
|
||||
if security and security ~= "none" then
|
||||
ss.security = security
|
||||
local sni = n.sni or n.servername
|
||||
local sni = n.sni or n.servername
|
||||
if security == "reality" and n["public-key"] then
|
||||
-- Reality 必须写 realitySettings,**不是** tlsSettings。Xray 的 StreamConfig
|
||||
-- 校验是 `case "reality": if c.REALITYSettings == nil { return nil,
|
||||
-- errors.New(`REALITY: Empty "realitySettings".`) }` —— 写成 tlsSettings 的
|
||||
-- reality 节点会被直接拒绝,一个节点废掉整份配置。
|
||||
-- 客户端字段名取自 infra/conf/transport_security.go 的 REALITYConfig:
|
||||
-- fingerprint / serverName / publicKey / shortId / spiderX(外加 show)。
|
||||
-- 注意 publicKey 是**客户端**侧的键名(privateKey 只属于服务端),
|
||||
-- spiderX 在 Xray 侧缺省为 "/"。
|
||||
local rs = { show = false, fingerprint = n.fp or "chrome" }
|
||||
if sni then rs.serverName = sni end
|
||||
rs.publicKey = n["public-key"]
|
||||
if n["short-id"] then rs.shortId = n["short-id"] end
|
||||
if n["spider-x"] then rs.spiderX = n["spider-x"] end
|
||||
ss.security = "reality"
|
||||
ss.realitySettings = rs
|
||||
elseif security and security ~= "none" then
|
||||
-- security 声称 reality 却没有 public-key 时也走这里,但要把 security 降回
|
||||
-- tls:REALITYConfig.Build() 在公钥为空时直接报错(客户端分支是
|
||||
-- `empty "password"`,PublicKey 可由 Password 推导),而 StreamConfig 又要求
|
||||
-- security == "reality" 必须配 realitySettings —— 照抄写出去会让 Xray
|
||||
-- 拒绝加载**整份**配置。没有公钥就没有 Reality 可言,降级成普通 TLS
|
||||
-- 至少配置能加载(与 sing-box / mihomo / QX 三个输出端的行为一致:
|
||||
-- 它们同样只在有 public-key 时才写 Reality 配置)。
|
||||
ss.security = (security == "reality") and "tls" or security
|
||||
if sni then
|
||||
ss.tlsSettings = { serverName = sni, allowInsecure = false }
|
||||
end
|
||||
elseif n.tls and n.tls ~= "none" and n.tls ~= false then
|
||||
ss.security = "tls"
|
||||
local sni = n.sni or n.servername
|
||||
if sni then ss.tlsSettings = { serverName = sni } end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ local SUPPORTED = {
|
||||
vmess = true, vless = true, trojan = true, ss = true, ssr = true,
|
||||
hysteria = true, hysteria2 = true, tuic = true, wireguard = true,
|
||||
socks = true, socks5 = true,
|
||||
-- anytls 同样必须能进:output_uri 会生成 anytls:// 链接,缺了这一项
|
||||
-- 「导出→导入」回环就把节点整行丢掉(H 系列的老问题)。
|
||||
anytls = true,
|
||||
}
|
||||
|
||||
local function split_lines(content)
|
||||
@@ -344,6 +347,15 @@ local function parse_vless(uri, body)
|
||||
if query.path then out.path = query.path end
|
||||
if query.host then out.host = query.host end
|
||||
if query.flow then out.flow = query.flow end
|
||||
-- Reality(security=reality)参数。键名取自 Xray 分享链接规范
|
||||
-- (XTLS/Xray-core discussion #716):pbk = public-key、sid = short-id、
|
||||
-- spx = spiderX。三者都必须回读,否则「导出→导入」回环会把 Reality 参数
|
||||
-- 丢光 —— 缺 public-key 的 Reality 节点客户端根本连不上。
|
||||
-- fp 上面已经回读:Reality 规范里 fp 不可省略(缺省 chrome),它同时就是
|
||||
-- 这条连接的 uTLS 指纹。
|
||||
if query.pbk then out["public-key"] = query.pbk end
|
||||
if query.sid then out["short-id"] = query.sid end
|
||||
if query.spx then out["spider-x"] = query.spx end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -499,6 +511,53 @@ local function parse_hysteria2(uri, body)
|
||||
return out
|
||||
end
|
||||
|
||||
-- anytls://password@host:port/?sni=..&insecure=..#name
|
||||
-- 链接规范由 anytls-go 定义(mihomo 与 Shadowrocket 都按它解析):密码放在
|
||||
-- userinfo(auth)位置,端口缺省 443,查询参数只有 sni 与 insecure 两个。
|
||||
local function parse_anytls(uri, body)
|
||||
local name, rest = "", body
|
||||
local hash = rest:find("#", 1, true)
|
||||
if hash then
|
||||
name = util.url_decode(rest:sub(hash + 1))
|
||||
rest = rest:sub(1, hash - 1)
|
||||
end
|
||||
local query = {}
|
||||
local qpos = rest:find("?", 1, true)
|
||||
local hp = rest
|
||||
if qpos then
|
||||
hp = rest:sub(1, qpos - 1)
|
||||
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
|
||||
query[k] = util.url_decode(v)
|
||||
end
|
||||
end
|
||||
-- 取最后一个 @(同 parse_socks / parse_hysteria2):按第一个 @ 切会把密码里的
|
||||
-- 裸 @ 当成 userinfo 分隔符,静默解析出 server="x@1.2.3.4" 这样的错节点。
|
||||
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
|
||||
if not userinfo then return nil, "bad anytls" end
|
||||
local password = util.url_decode(userinfo)
|
||||
local host, port = util.split_hostport(hostport)
|
||||
-- 端口缺省 443:规范写的是 hostname[:port],省略端口是合法写法。不补默认值
|
||||
-- 会让 valid_hostport 判假,整条链接被丢弃。
|
||||
if not port or port == "" then port = 443 end
|
||||
if not valid_hostport(host, port) then return nil, "bad anytls" end
|
||||
local out = node.normalize({
|
||||
proto = "anytls", name = name, server = host, port = tonumber(port),
|
||||
password = password, raw = uri,
|
||||
})
|
||||
if query.sni then out.sni = query.sni end
|
||||
-- anytls 在 mihomo / sing-box / Surge 里都是 TLS-only(协议本身没有明文模式),
|
||||
-- 而 anytls:// 不带 security 参数。不补上的话 output_singbox.build_tls 直接
|
||||
-- 返回 nil,sni / insecure 全部丢失,生成的 outbound 不可用。
|
||||
out.security = "tls"
|
||||
-- insecure 是 URI 参数名,模型里的权威字段是 skip-cert-verify
|
||||
-- (与 parse_hysteria2 的处理保持一致)。
|
||||
if query.insecure ~= nil then
|
||||
out.insecure = query.insecure
|
||||
out["skip-cert-verify"] = not (query.insecure == "0" or query.insecure == "false")
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- hysteria://password@host:port/?sni=..&insecure=..&obfs=..#name(v1)
|
||||
-- 与 hysteria2 的区别(均已对照上游确认):
|
||||
-- * v1 的 obfs 是普通字符串,没有 obfs-password(obfs-password 是 hysteria2 的 salamander)
|
||||
@@ -862,6 +921,7 @@ function M.parse_uri(uri)
|
||||
if proto == "hysteria2" then return parse_hysteria2(uri, body) end
|
||||
if proto == "tuic" then return parse_tuic(uri, body) end
|
||||
if proto == "wireguard" then return parse_wireguard(uri, body) end
|
||||
if proto == "anytls" then return parse_anytls(uri, body) end
|
||||
if proto == "socks" or proto == "socks5" then return parse_socks(uri, body) end
|
||||
return nil, "unsupported"
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@ local TYPE_MAP = {
|
||||
wireguard = "wireguard",
|
||||
ssr = "ssr",
|
||||
http = "http",
|
||||
anytls = "anytls",
|
||||
}
|
||||
|
||||
-- 导出供 parser.lua 的简易 YAML 兜底解析复用(单一事实来源)
|
||||
@@ -254,7 +255,13 @@ local function map_clash_node(p)
|
||||
net = p.network or p.net,
|
||||
sni = p.sni or p.servername,
|
||||
tls = p.tls,
|
||||
fp = p.fp,
|
||||
-- uTLS 指纹:mihomo 的键是 client-fingerprint(全仓库没有任何结构体声明
|
||||
-- `proxy:"fp,..."`,详见 node.CLIENT_FP_PROTOS 的说明)。只读 p.fp 的话,
|
||||
-- 一份真正的 mihomo 配置导入后指纹全部丢失 —— 与输出端写 `fp:` 是同一个
|
||||
-- 错误的另一半。仍然读 p.fp 是为了兼容本项目旧版本自己导出的配置。
|
||||
-- 注意不要读 `fingerprint`:hysteria/hysteria2/tuic 上那是证书固定
|
||||
-- (SHA256 pin),与 uTLS 语义不同,映射过来是错的。
|
||||
fp = p["client-fingerprint"] or p.fp,
|
||||
}
|
||||
if p.alpn then n.alpn = p.alpn end
|
||||
if p.udp ~= nil then n.udp = p.udp end
|
||||
|
||||
@@ -22,12 +22,13 @@ local proto_map = {
|
||||
tuic = "tuic",
|
||||
wireguard = "wireguard",
|
||||
ssr = "ssr",
|
||||
anytls = "anytls",
|
||||
}
|
||||
|
||||
local SUPPORTED = {
|
||||
vmess = true, vless = true, trojan = true, shadowsocks = true,
|
||||
hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
|
||||
socks = true, http = true, ssr = true,
|
||||
socks = true, http = true, ssr = true, anytls = true,
|
||||
}
|
||||
|
||||
-- ---------- Sing-box JSON 解析 ----------
|
||||
@@ -68,6 +69,8 @@ function M.parse_singbox_json(content)
|
||||
node_data.uuid = outbound.uuid
|
||||
node_data.password = outbound.password
|
||||
if outbound.congestion_control then node_data.congestion_control = outbound.congestion_control end
|
||||
elseif proto == "anytls" then
|
||||
node_data.password = outbound.password
|
||||
elseif proto == "wireguard" then
|
||||
node_data["private-key"] = outbound["private-key"] or outbound.private_key
|
||||
node_data["peer-public-key"] = outbound["peer-public-key"] or outbound.peer_public_key or outbound["public-key"] or outbound.public_key
|
||||
@@ -124,6 +127,18 @@ function M.parse_singbox_json(content)
|
||||
if type(outbound.tls.utls) == "table" and outbound.tls.utls.fingerprint then
|
||||
node_data.fp = outbound.tls.utls.fingerprint
|
||||
end
|
||||
-- Reality:sing-box 的键是 tls.reality.{enabled, public_key, short_id}
|
||||
-- (option/tls.go 的 OutboundRealityOptions)。这里必须把
|
||||
-- security 覆写成 "reality" —— 上面那句 `if outbound.tls.enabled
|
||||
-- ~= false then node_data.security = "tls"` 已经把它写成了 "tls",
|
||||
-- 不覆写的话导出到 mihomo / Xray 时 Reality 参数会连同 security
|
||||
-- 一起丢掉,节点降级成普通 TLS 后连不上。
|
||||
local rz = outbound.tls.reality
|
||||
if type(rz) == "table" and rz.enabled ~= false and rz.public_key then
|
||||
node_data.security = "reality"
|
||||
node_data["public-key"] = rz.public_key
|
||||
if rz.short_id then node_data["short-id"] = rz.short_id end
|
||||
end
|
||||
end
|
||||
|
||||
-- sing-box 只有 vmess 出站有 security 字段,且它是加密方式
|
||||
@@ -260,8 +275,20 @@ function M.parse_v2ray_json(content)
|
||||
if ss2.tlsSettings and type(ss2.tlsSettings) == "table" then
|
||||
if ss2.tlsSettings.serverName then node_data.sni = ss2.tlsSettings.serverName end
|
||||
end
|
||||
-- Reality:Xray 的 realitySettings 是 camelCase
|
||||
-- (fingerprint / serverName / publicKey / shortId / spiderX)。
|
||||
-- 原先只读 serverName,publicKey 等全部丢失 —— 导入一份
|
||||
-- Xray Reality 配置后导出的节点根本连不上。
|
||||
-- publicKey 是客户端键名;旧配置可能写成 password
|
||||
-- (Xray 侧 `if c.Password != "" { c.PublicKey = c.Password }`)。
|
||||
if ss2.realitySettings and type(ss2.realitySettings) == "table" then
|
||||
if ss2.realitySettings.serverName then node_data.sni = ss2.realitySettings.serverName end
|
||||
local rs = ss2.realitySettings
|
||||
if rs.serverName then node_data.sni = rs.serverName end
|
||||
if rs.fingerprint then node_data.fp = rs.fingerprint end
|
||||
local pbk = rs.publicKey or rs.password
|
||||
if pbk then node_data["public-key"] = pbk end
|
||||
if rs.shortId then node_data["short-id"] = rs.shortId end
|
||||
if rs.spiderX then node_data["spider-x"] = rs.spiderX end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -312,6 +339,8 @@ function M.parse_clash_json(content)
|
||||
elseif proto == "tuic" then
|
||||
node_data.uuid = proxy.uuid
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "anytls" then
|
||||
node_data.password = proxy.password
|
||||
elseif proto == "wireguard" then
|
||||
node_data["private-key"] = proxy["private-key"] or proxy.private_key
|
||||
node_data["peer-public-key"] = proxy["peer-public-key"] or proxy.peer_public_key or proxy["public-key"] or proxy.public_key
|
||||
@@ -345,6 +374,18 @@ function M.parse_clash_json(content)
|
||||
elseif proxy.servername then
|
||||
node_data.sni = proxy.servername
|
||||
end
|
||||
-- uTLS 指纹与 Reality。键名与 parser_clash_yaml 完全一致
|
||||
-- (client-fingerprint、reality-opts.{public-key, short-id}),
|
||||
-- 两条 Clash 导入路径必须给出同样的节点,否则同一条订阅换个
|
||||
-- 文件后缀就会导入出不同的结果。
|
||||
-- 不读 `fingerprint`:hysteria/hysteria2/tuic 上那是证书固定
|
||||
-- (SHA256 pin),语义与 uTLS 指纹不同。
|
||||
node_data.fp = proxy["client-fingerprint"] or proxy.fp
|
||||
if type(proxy["reality-opts"]) == "table" then
|
||||
local ro = proxy["reality-opts"]
|
||||
if ro["public-key"] then node_data["public-key"] = ro["public-key"] end
|
||||
if ro["short-id"] then node_data["short-id"] = ro["short-id"] end
|
||||
end
|
||||
if proxy.tls then node_data.tls = proxy.tls end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(node_data)
|
||||
|
||||
@@ -20,12 +20,62 @@ local function map_proto(raw)
|
||||
return TYPE_MAP[raw:lower()]
|
||||
end
|
||||
|
||||
-- 剥掉 Loon 位置参数外面的双引号:`"password"` → `password`。
|
||||
-- Loon 的节点行一律把凭据写成带引号的位置参数,Surge 家族的具名参数则不带引号;
|
||||
-- 只剥一层(`""x""` → `"x"`),没有引号时原样返回。
|
||||
local function unquote(v)
|
||||
v = util.trim(v or "")
|
||||
local inner = v:match('^"(.*)"$')
|
||||
return inner or v
|
||||
end
|
||||
|
||||
-- 按逗号切分字段,但**引号内的逗号不作为分隔符**。
|
||||
--
|
||||
-- Loon 把凭据写成双引号包裹的位置参数(nsloon.app/docs/Node/):
|
||||
-- Trojan = Trojan,trojan.example.com,443,"password",transport=tcp,...
|
||||
-- 密码里含逗号时,`rest:gmatch("[^,]+")` 会把它切成两段 ——
|
||||
-- Trojan = Trojan,h,443,"pa,ss",sni=x
|
||||
-- 的密码会静默变成 `"pa`(连引号一起、少掉后半段),用户拿到的不是他填的凭据,
|
||||
-- 而且没有任何提示。生成端早就有「值里含逗号就整条丢弃」的防护(output_formats
|
||||
-- 的 surge_line),解析端此前没有对应处理。
|
||||
--
|
||||
-- 两条边界:
|
||||
-- * 引号个数为奇数时(订阅内容不可信,可能是落单的引号)**不做引号感知**,
|
||||
-- 退回按逗号切分。否则那个引号会一直「开着」,把后面的 sni= / over-tls=
|
||||
-- 全吞进同一个字段,比按逗号切更糟。
|
||||
-- * 只跟踪引号开合,不做转义处理 —— Surge / Loon / QX 的行语法里都没有转义
|
||||
-- 机制(Loon 文档只把引号列为位置参数的写法,未提及任何转义序列)。
|
||||
--
|
||||
-- 无引号的输入与旧的 `gmatch("[^,]+")` 逐字符等价:都按逗号切、都丢弃空字段、
|
||||
-- 都对每段做 trim(旧的 `[^,]+` 会保留纯空白段,trim 后成空串,这里同样保留)。
|
||||
local function split_fields(s)
|
||||
local nquote = 0
|
||||
for _ in s:gmatch('"') do nquote = nquote + 1 end
|
||||
if nquote % 2 == 1 then
|
||||
local out = {}
|
||||
for part in s:gmatch("[^,]+") do out[#out + 1] = util.trim(part) end
|
||||
return out
|
||||
end
|
||||
local out, buf, quoted = {}, {}, false
|
||||
for i = 1, #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == '"' then
|
||||
quoted = not quoted
|
||||
buf[#buf + 1] = c
|
||||
elseif c == "," and not quoted then
|
||||
if #buf > 0 then out[#out + 1] = util.trim(table.concat(buf)) end
|
||||
buf = {}
|
||||
else
|
||||
buf[#buf + 1] = c
|
||||
end
|
||||
end
|
||||
if #buf > 0 then out[#out + 1] = util.trim(table.concat(buf)) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 解析 Surge 风格行:Name = proto, server, port, k=v, ...
|
||||
local function parse_surge_line(name, rest)
|
||||
local parts = {}
|
||||
for part in rest:gmatch("[^,]+") do
|
||||
parts[#parts + 1] = util.trim(part)
|
||||
end
|
||||
local parts = split_fields(rest)
|
||||
if #parts < 3 then return nil end
|
||||
|
||||
local proto = map_proto(parts[1])
|
||||
@@ -33,10 +83,24 @@ local function parse_surge_line(name, rest)
|
||||
local server = parts[2]
|
||||
local port = tonumber(parts[3])
|
||||
|
||||
local kv = {}
|
||||
-- 具名参数(Surge 家族:`password=pwd`)与位置参数(Loon:`,443,"pwd",`)并存。
|
||||
-- Loon 的凭据一律写成端口之后的位置参数并用双引号包起来:
|
||||
-- Trojan = Trojan,trojan.example.com,443,"password",transport=tcp,...
|
||||
-- VLESS-Reality= VLESS,vless.example.com,443,"ae521383-...",transport=tcp,...
|
||||
-- AnyTLS = AnyTLS,anytls.example.com,443,"password",sni=...
|
||||
-- VMess = VMess,vmess.example.com,443,aes-128-gcm,"52396e06-...",transport=...
|
||||
-- 此前只认具名参数,Loon 配置导入后 uuid / password 全丢,节点因缺凭据被
|
||||
-- 下游静默丢弃。位置参数只收集「不含 = 的字段」,具名行完全不受影响。
|
||||
-- 切分走 split_fields:引号内的逗号不再被当作分隔符,`"pa,ss"` 这种位置
|
||||
-- 参数能原样保留(见该函数的说明)。
|
||||
local kv, pos = {}, {}
|
||||
for i = 4, #parts do
|
||||
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
|
||||
if k then kv[k] = util.trim(v) end
|
||||
if k then
|
||||
kv[k] = util.trim(v)
|
||||
else
|
||||
pos[#pos + 1] = unquote(parts[i])
|
||||
end
|
||||
end
|
||||
|
||||
local data = {
|
||||
@@ -46,14 +110,29 @@ local function parse_surge_line(name, rest)
|
||||
data.method = kv["encrypt-method"] or kv.cipher or kv.method
|
||||
data.password = kv.password
|
||||
elseif proto == "vmess" or proto == "vless" then
|
||||
data.uuid = kv.username or kv.uuid
|
||||
-- Loon 的 VMess 位置参数是「加密方式, UUID」两个字段;VLESS 只有一个 UUID
|
||||
if proto == "vmess" and pos[2] then
|
||||
data.cipher = pos[1]
|
||||
data.uuid = kv.username or kv.uuid or pos[2]
|
||||
else
|
||||
data.uuid = kv.username or kv.uuid or pos[1]
|
||||
end
|
||||
if kv.flow then data.flow = kv.flow end
|
||||
elseif proto == "trojan" then
|
||||
data.password = kv.password
|
||||
data.password = kv.password or pos[1]
|
||||
elseif proto == "anytls" then
|
||||
data.password = kv.password or pos[1]
|
||||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||||
data.password = kv.password
|
||||
end
|
||||
|
||||
-- Reality 参数。Loon 的 VLESS / VMess / Trojan / AnyTLS Reality 行共用
|
||||
-- public-key(Base64 公钥)与 short-id(十六进制)两个参数名,公钥取值带引号:
|
||||
-- public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",short-id=164168844958a16d
|
||||
-- 原样带过去即可 —— node.normalize 会据 public-key 把 security 定为 reality。
|
||||
if kv["public-key"] then data["public-key"] = unquote(kv["public-key"]) end
|
||||
if kv["short-id"] then data["short-id"] = unquote(kv["short-id"]) end
|
||||
|
||||
-- 传输/TLS 通用字段
|
||||
if kv.net or kv.network then data.net = kv.net or kv.network end
|
||||
if kv["ws"] == "true" then data.net = "ws" end
|
||||
@@ -61,8 +140,27 @@ local function parse_surge_line(name, rest)
|
||||
if kv["ws-headers"] and kv["ws-headers"]:match("^Host:") then
|
||||
data.host = kv["ws-headers"]:match("^Host:%s*(.*)$")
|
||||
end
|
||||
-- Loon 的传输写法是 transport=<tcp|ws|http> + path= + host=
|
||||
-- (nsloon.app/docs/Node/ 的示例行),与 Surge 家族的 net= / ws=true /
|
||||
-- ws-path / ws-headers=Host: 是同一组语义的两代写法 —— Loon 文档明说
|
||||
-- 「旧参数 ws=true、ws-path、ws-headers=Host:域名 分别对应 transport=ws、
|
||||
-- path、host」,即旧写法只是兼容别名。只认旧写法时,别人给的 Loon 配置
|
||||
-- 导入后 net 保持默认 tcp、path / host 全丢,导出到任何格式都按 tcp 去连一个
|
||||
-- 只开了 ws 的端口 —— 握手失败且不报错。
|
||||
-- transport=http 按同一段文档「兼容配置中的 transport=http 会按 WebSocket
|
||||
-- 处理」同样落成 ws。
|
||||
-- 放在 ws=true / ws-path / ws-headers 之后:同一行两种写法都出现时以新写法为准。
|
||||
if kv.transport then
|
||||
data.net = (kv.transport == "http") and "ws" or kv.transport
|
||||
end
|
||||
if kv.path then data.path = kv.path end
|
||||
if kv.host then data.host = kv.host end
|
||||
if kv.sni then data.sni = kv.sni end
|
||||
-- Surge 家族写 tls=true,Loon 写 over-tls=true,两种都是「启用 TLS」
|
||||
if kv.tls and kv.tls ~= "false" and kv.tls ~= "none" then data.security = "tls" end
|
||||
if kv["over-tls"] and kv["over-tls"] ~= "false" and kv["over-tls"] ~= "none" then
|
||||
data.security = "tls"
|
||||
end
|
||||
if kv["skip-cert-verify"] and kv["skip-cert-verify"] ~= "0" and kv["skip-cert-verify"] ~= "false" then
|
||||
data["skip-cert-verify"] = true
|
||||
end
|
||||
@@ -73,10 +171,7 @@ end
|
||||
local function parse_qx_line(content)
|
||||
local proto, rest = content:match("^([%w_]+)%s*=%s*(.+)$")
|
||||
if not proto then return nil end
|
||||
local parts = {}
|
||||
for part in rest:gmatch("[^,]+") do
|
||||
parts[#parts + 1] = util.trim(part)
|
||||
end
|
||||
local parts = split_fields(rest)
|
||||
if #parts < 1 then return nil end
|
||||
|
||||
local hostport = parts[1]
|
||||
@@ -99,12 +194,31 @@ local function parse_qx_line(content)
|
||||
data.uuid = kv.password or kv.uuid
|
||||
elseif proto == "trojan" then
|
||||
data.password = kv.password
|
||||
elseif proto == "anytls" then
|
||||
data.password = kv.password
|
||||
end
|
||||
-- QX 用 obfs= 表示传输 / TLS 模式(官方 sample.conf):over-tls 是「纯 TLS」、
|
||||
-- wss 是「ws + TLS」、ws 是明文 ws。obfs-host 在 ws 下是 Host 头、在 over-tls
|
||||
-- 下是 SNI —— 语义不同,必须分别落到 host / sni,否则 vless + over-tls 的节点
|
||||
-- 导入后 SNI 被写进 host,Reality 那条更是连不上。
|
||||
if kv["obfs"] == "over-tls" then
|
||||
data.security = "tls"
|
||||
if kv["obfs-host"] then data.sni = kv["obfs-host"] end
|
||||
else
|
||||
if kv["obfs"] == "ws" or kv["obfs"] == "wss" then data.net = "ws" end
|
||||
if kv["obfs"] == "wss" then data.security = "tls" end
|
||||
if kv["obfs-uri"] then data.path = kv["obfs-uri"] end
|
||||
if kv["obfs-host"] then data.host = kv["obfs-host"] end
|
||||
end
|
||||
if kv["obfs"] == "ws" then data.net = "ws" end
|
||||
if kv["obfs-uri"] then data.path = kv["obfs-uri"] end
|
||||
if kv["obfs-host"] then data.host = kv["obfs-host"] end
|
||||
if kv["tls-host"] then data.sni = kv["tls-host"] end
|
||||
if kv["tls-verification"] == "true" or kv["over-tls"] == "true" then data.security = "tls" end
|
||||
-- flow:QX 的 vless 写 vless-flow=xtls-rprx-vision(sample.conf 的 Reality 条目)
|
||||
if kv["vless-flow"] then data.flow = kv["vless-flow"] end
|
||||
-- Reality:QX 的键名与分享链接 / Clash 都不同 —— Base64 公钥是
|
||||
-- reality-base64-pubkey,十六进制 short id 是 reality-hex-shortid。
|
||||
-- node.normalize 会据 public-key 把 security 定为 reality。
|
||||
if kv["reality-base64-pubkey"] then data["public-key"] = kv["reality-base64-pubkey"] end
|
||||
if kv["reality-hex-shortid"] then data["short-id"] = kv["reality-hex-shortid"] end
|
||||
return node.normalize(data)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
-- anytls_reality_test.lua — AnyTLS 协议与 Reality(VLESS/VMess/Trojan/AnyTLS)的回归测试
|
||||
-- 用法:lua5.1 tests/anytls_reality_test.lua
|
||||
--
|
||||
-- 这个文件锁住三件容易「改一处漏一处」的事:
|
||||
-- 1. 各解析器读进来的 Reality 参数(分享链接的 pbk/sid/spx、Loon 的
|
||||
-- public-key/short-id、QX 的 reality-base64-pubkey/reality-hex-shortid)
|
||||
-- 必须落到统一模型的同一组键上;
|
||||
-- 2. node.normalize 据 public-key 推导 security=reality,且**不能**误伤
|
||||
-- wireguard(它的 public-key 是对端公钥,与 Reality 无关);
|
||||
-- 3. 每个输出格式按各自客户端的文档写出 Reality 参数 —— 支持的要写全,
|
||||
-- 不支持的(Surge 家族、Xray、原版 Clash)一个都不能写:这些客户端的
|
||||
-- 协议清单里根本没有对应参数,写过去只会是客户端读不懂的噪声。
|
||||
-- (早期注释写的「Surge 会拒绝加载整份配置」**未经官方证实** ——
|
||||
-- 官方只说明过无法识别的 *section* 会原样保留且不报错;判据是
|
||||
-- 「不写客户端读不懂的东西」,见 output_formats.lua 的 REALITY_FLAVORS。)
|
||||
--
|
||||
-- 逐条依据见各处注释引用的官方文档 / 客户端源码。
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local node = require("substore.node")
|
||||
local parser = require("substore.parser")
|
||||
local output = require("substore.output")
|
||||
local fmts = require("substore.output_formats")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function has(s, sub)
|
||||
return type(s) == "string" and s:find(sub, 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- ============ 1. 分享链接解析 ============
|
||||
|
||||
local r1 = parser.parse("anytls://pwd123@a.example.com:8443?sni=s.example.com&insecure=1#MyAny")
|
||||
local a1 = r1.nodes[1]
|
||||
check("anytls URI parsed", a1 and a1.proto == "anytls")
|
||||
check("anytls URI password from userinfo", a1.password == "pwd123")
|
||||
check("anytls URI server/port", a1.server == "a.example.com" and a1.port == 8443)
|
||||
check("anytls URI sni", a1.sni == "s.example.com")
|
||||
check("anytls URI insecure -> skip-cert-verify", a1["skip-cert-verify"] == true)
|
||||
-- anytls 建立在 TLS 之上,没有明文模式
|
||||
check("anytls URI security is tls", a1.security == "tls")
|
||||
|
||||
local r2 = parser.parse("vless://uuid-1@b.example.com:443?security=reality&pbk=PUBKEY&sid=abc123"
|
||||
.. "&spx=%2F&fp=chrome&sni=www.example.com&flow=xtls-rprx-vision#MyReality")
|
||||
local v2 = r2.nodes[1]
|
||||
check("vless reality URI parsed", v2 and v2.proto == "vless")
|
||||
check("vless reality URI pbk -> public-key", v2["public-key"] == "PUBKEY")
|
||||
check("vless reality URI sid -> short-id", v2["short-id"] == "abc123")
|
||||
check("vless reality URI spx -> spider-x (url-decoded)", v2["spider-x"] == "/")
|
||||
check("vless reality URI fp", v2.fp == "chrome")
|
||||
check("vless reality URI flow", v2.flow == "xtls-rprx-vision")
|
||||
check("vless reality URI sni", v2.sni == "www.example.com")
|
||||
check("vless reality URI security", v2.security == "reality")
|
||||
|
||||
-- ============ 2. 行式配置解析(Loon 位置参数 / QX / Surge 具名参数) ============
|
||||
|
||||
-- Loon 的凭据一律是端口之后的**位置参数**且带双引号:
|
||||
-- AnyTLS = AnyTLS,host,443,"password",sni=... (nsloon.app/docs/Node/)
|
||||
-- VMess = VMess,host,443,aes-128-gcm,"uuid",...
|
||||
-- 只认具名参数的话,Loon 配置导入后 uuid / password 全丢,节点被下游静默丢弃。
|
||||
local loon = "[Proxy]\n"
|
||||
.. 'AnyTLS = AnyTLS,anytls.example.com,443,"password",sni=anytls.example.com,'
|
||||
.. 'public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",short-id=164168844958a16d,over-tls=true\n'
|
||||
.. 'VLESS-R = VLESS,vless.example.com,443,"ae521383-9375-2e0d-c347-48cf3d98eb6e",'
|
||||
.. 'transport=tcp,flow=xtls-rprx-vision,public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",'
|
||||
.. 'short-id=164168844958a16d,over-tls=true,sni=www.example.com\n'
|
||||
.. 'VMess-R = VMess,vmess.example.com,443,aes-128-gcm,"52396e06-3a1a-4e3a-9b3f-000000000001",'
|
||||
.. 'public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",short-id=164168844958a16d,'
|
||||
.. 'over-tls=true,sni=www.example.com\n'
|
||||
.. 'Trojan-R = Trojan,trojan.example.com,443,"pwd",public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",'
|
||||
.. 'short-id=164168844958a16d,over-tls=true,sni=t.example.com\n'
|
||||
|
||||
local lr = parser.parse(loon)
|
||||
check("loon config parsed as surge", lr.format == "surge" and #lr.nodes == 4)
|
||||
local byname = {}
|
||||
for _, n in ipairs(lr.nodes) do byname[n.name] = n end
|
||||
|
||||
local la = byname["AnyTLS"]
|
||||
check("loon anytls parsed", la and la.proto == "anytls")
|
||||
check("loon anytls positional password unquoted", la.password == "password")
|
||||
check("loon anytls sni", la.sni == "anytls.example.com")
|
||||
check("loon anytls public-key unquoted", la["public-key"] == "LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk")
|
||||
check("loon anytls short-id", la["short-id"] == "164168844958a16d")
|
||||
check("loon anytls over-tls -> security reality", la.security == "reality")
|
||||
|
||||
local lv = byname["VLESS-R"]
|
||||
check("loon vless positional uuid unquoted", lv.uuid == "ae521383-9375-2e0d-c347-48cf3d98eb6e")
|
||||
check("loon vless flow", lv.flow == "xtls-rprx-vision")
|
||||
check("loon vless reality", lv.security == "reality" and lv["public-key"] ~= nil)
|
||||
|
||||
local lm = byname["VMess-R"]
|
||||
check("loon vmess positional cipher", lm.cipher == "aes-128-gcm")
|
||||
check("loon vmess positional uuid", lm.uuid == "52396e06-3a1a-4e3a-9b3f-000000000001")
|
||||
check("loon vmess reality", lm.security == "reality")
|
||||
|
||||
local lt = byname["Trojan-R"]
|
||||
check("loon trojan positional password", lt.password == "pwd")
|
||||
check("loon trojan reality", lt.security == "reality")
|
||||
|
||||
-- QX:Reality 的键名与别家都不同(reality-base64-pubkey / reality-hex-shortid),
|
||||
-- vless 的 flow 写 vless-flow。语法取自官方 sample.conf。
|
||||
local qx = "[server_local]\n"
|
||||
.. "anytls=example.com:443, password=pwd, over-tls=true, tls-host=apple.com, "
|
||||
.. "reality-base64-pubkey=k4Uxez0sjl8bKaZH2Vgi8-WDFshML51QkxKFLWFIONk, "
|
||||
.. "reality-hex-shortid=0123456789abcdef, tag=anytls-reality\n"
|
||||
.. "vless=192.168.1.1:443, method=none, password=23ad6b10-8d1a-40f7-8ad0-e3e35cd32291, "
|
||||
.. "obfs=over-tls, obfs-host=apple.com, "
|
||||
.. "reality-base64-pubkey=k4Uxez0sjl8bKaZH2Vgi8-WDFshML51QkxKFLWFIONk, "
|
||||
.. "reality-hex-shortid=0123456789abcdef, vless-flow=xtls-rprx-vision, tag=vless-reality\n"
|
||||
|
||||
local qr = parser.parse(qx)
|
||||
check("qx config parsed", qr.format == "surge" and #qr.nodes == 2)
|
||||
local qby = {}
|
||||
for _, n in ipairs(qr.nodes) do qby[n.name] = n end
|
||||
|
||||
local qa = qby["anytls-reality"]
|
||||
check("qx anytls parsed", qa and qa.proto == "anytls")
|
||||
check("qx anytls password", qa.password == "pwd")
|
||||
check("qx anytls tls-host -> sni", qa.sni == "apple.com")
|
||||
check("qx anytls reality-base64-pubkey -> public-key",
|
||||
qa["public-key"] == "k4Uxez0sjl8bKaZH2Vgi8-WDFshML51QkxKFLWFIONk")
|
||||
check("qx anytls reality-hex-shortid -> short-id", qa["short-id"] == "0123456789abcdef")
|
||||
check("qx anytls security reality", qa.security == "reality")
|
||||
|
||||
local qv = qby["vless-reality"]
|
||||
check("qx vless password -> uuid", qv.uuid == "23ad6b10-8d1a-40f7-8ad0-e3e35cd32291")
|
||||
check("qx vless obfs=over-tls -> security tls", qv.security == "reality")
|
||||
check("qx vless obfs-host -> sni", qv.sni == "apple.com")
|
||||
check("qx vless vless-flow -> flow", qv.flow == "xtls-rprx-vision")
|
||||
check("qx vless public-key", qv["public-key"] == "k4Uxez0sjl8bKaZH2Vgi8-WDFshML51QkxKFLWFIONk")
|
||||
check("qx vless short-id", qv["short-id"] == "0123456789abcdef")
|
||||
|
||||
-- Surge 具名参数(Surge iOS 5.17.0+ / Mac 6.4.3+ 起支持 anytls)
|
||||
local sr = parser.parse("[Proxy]\nSAny = anytls, 1.2.3.4, 443, password=pwd, sni=s.example.com, skip-cert-verify=1\n")
|
||||
local sa = sr.nodes[1]
|
||||
check("surge anytls named password", sa.password == "pwd")
|
||||
check("surge anytls security tls (no reality)", sa.security == "tls")
|
||||
check("surge anytls skip-cert-verify", sa["skip-cert-verify"] == true)
|
||||
|
||||
-- ============ 3. node.normalize 的 Reality 推导 ============
|
||||
|
||||
local function sec(t)
|
||||
return node.normalize(t).security
|
||||
end
|
||||
|
||||
-- Loon 的 Reality 行写的是 over-tls=true + public-key(security 只是 tls),
|
||||
-- 分享链接写 security=reality —— 两种来源都要归到同一个 security。
|
||||
check("normalize anytls + public-key -> reality",
|
||||
sec({ proto = "anytls", server = "s", port = 1, password = "p", ["public-key"] = "PBK" }) == "reality")
|
||||
check("normalize vless + public-key (security=tls) -> reality",
|
||||
sec({ proto = "vless", server = "s", port = 1, uuid = "u", security = "tls", ["public-key"] = "PBK" }) == "reality")
|
||||
check("normalize vmess + public-key -> reality",
|
||||
sec({ proto = "vmess", server = "s", port = 1, uuid = "u", ["public-key"] = "PBK" }) == "reality")
|
||||
check("normalize trojan + public-key -> reality",
|
||||
sec({ proto = "trojan", server = "s", port = 1, password = "p", ["public-key"] = "PBK" }) == "reality")
|
||||
|
||||
-- wireguard 的 public-key 是**对端公钥**,与 Reality 毫无关系。一旦被当成 Reality,
|
||||
-- security 会变成 reality,output_singbox 就会去读 tls.reality,wireguard 节点的
|
||||
-- TLS 无关字段整批丢失。
|
||||
local wg = node.normalize({ proto = "wireguard", server = "s", port = 51820,
|
||||
["private-key"] = "k", ["public-key"] = "k2", ip = "10.0.0.2/32" })
|
||||
check("normalize wireguard public-key does NOT mean reality", wg.security ~= "reality")
|
||||
|
||||
-- 不在 REALITY_PROTOS 里的协议即使带了 public-key 也不推导
|
||||
check("normalize socks + public-key stays non-reality",
|
||||
sec({ proto = "socks", server = "s", port = 1, username = "u", password = "p", ["public-key"] = "PBK" }) ~= "reality")
|
||||
check("normalize shadowsocks + public-key stays non-reality",
|
||||
sec({ proto = "shadowsocks", server = "s", port = 1, method = "aes-256-gcm", password = "p",
|
||||
["public-key"] = "PBK" }) ~= "reality")
|
||||
|
||||
-- ============ 4. 各输出格式的 Reality 参数 ============
|
||||
|
||||
local ANY = node.normalize({ proto = "anytls", name = "AnyX", server = "1.2.3.4", port = 443,
|
||||
password = "p", sni = "s.example.com", ["skip-cert-verify"] = true,
|
||||
["public-key"] = "PBK", ["short-id"] = "SID" })
|
||||
local VLESS = node.normalize({ proto = "vless", name = "VlX", server = "1.2.3.4", port = 443,
|
||||
uuid = "u", ["public-key"] = "PBK", ["short-id"] = "SID", ["spider-x"] = "/sp",
|
||||
fp = "chrome", sni = "www.example.com", flow = "xtls-rprx-vision" })
|
||||
local VMESS = node.normalize({ proto = "vmess", name = "VmX", server = "1.2.3.4", port = 443,
|
||||
uuid = "u", ["public-key"] = "PBK", ["short-id"] = "SID", fp = "chrome", sni = "www.example.com" })
|
||||
local TROJAN = node.normalize({ proto = "trojan", name = "TrX", server = "1.2.3.4", port = 443,
|
||||
password = "p", ["public-key"] = "PBK", ["short-id"] = "SID", fp = "chrome", sni = "www.example.com" })
|
||||
|
||||
local function gen(n, f)
|
||||
return output.generate({ n }, f, { name = "PROXY" })
|
||||
end
|
||||
|
||||
-- --- clashmeta:mihomo 的 reality-opts 只在 vless / vmess / trojan 的出站结构体里
|
||||
-- (adapter/outbound/{vless,vmess,trojan}.go 的 RealityOpts),anytls.go 的
|
||||
-- AnyTLSOption 里**没有**任何 reality 字段 —— 写过去会被静默忽略。
|
||||
local cm_any = gen(ANY, "clashmeta")
|
||||
check("clashmeta anytls type", has(cm_any, "type: anytls"))
|
||||
check("clashmeta anytls password", has(cm_any, "password: p"))
|
||||
check("clashmeta anytls tls true", has(cm_any, "tls: true"))
|
||||
check("clashmeta anytls sni (not servername)", has(cm_any, "sni: s.example.com")
|
||||
and not has(cm_any, "servername:"))
|
||||
check("clashmeta anytls has NO reality-opts", not has(cm_any, "reality-opts"))
|
||||
|
||||
local cm_v = gen(VLESS, "clashmeta")
|
||||
check("clashmeta vless reality-opts", has(cm_v, "reality-opts:"))
|
||||
check("clashmeta vless reality public-key", has(cm_v, "public-key: PBK"))
|
||||
check("clashmeta vless reality short-id", has(cm_v, "short-id: SID"))
|
||||
-- uTLS 指纹是代理**顶层**的 client-fingerprint,不是 reality-opts 的子键
|
||||
check("clashmeta vless client-fingerprint (not fp)", has(cm_v, "client-fingerprint: chrome")
|
||||
and not has(cm_v, "\n fp:"))
|
||||
|
||||
local cm_m = gen(VMESS, "clashmeta")
|
||||
check("clashmeta vmess reality-opts", has(cm_m, "reality-opts:") and has(cm_m, "public-key: PBK"))
|
||||
local cm_t = gen(TROJAN, "clashmeta")
|
||||
check("clashmeta trojan reality-opts", has(cm_t, "reality-opts:") and has(cm_t, "public-key: PBK"))
|
||||
-- wireguard 的顶层 public-key 不能被当成 reality-opts
|
||||
local WG = node.normalize({ proto = "wireguard", name = "WG", server = "1.2.3.4", port = 51820,
|
||||
["private-key"] = "k", ["public-key"] = "k2", ip = "10.0.0.2/32" })
|
||||
check("clashmeta wireguard has NO reality-opts", not has(gen(WG, "clashmeta"), "reality-opts"))
|
||||
|
||||
-- --- sing-box:tls.reality(snake_case),且 Reality 下 uTLS 是强制的
|
||||
local sb_any = gen(ANY, "singbox")
|
||||
check("singbox anytls type", has(sb_any, '"type":"anytls"'))
|
||||
check("singbox anytls password", has(sb_any, '"password":"p"'))
|
||||
check("singbox anytls reality enabled", has(sb_any, '"reality":{"enabled":true'))
|
||||
check("singbox anytls reality public_key/short_id", has(sb_any, '"public_key":"PBK"')
|
||||
and has(sb_any, '"short_id":"SID"'))
|
||||
local sb_v = gen(VLESS, "singbox")
|
||||
check("singbox vless reality + utls", has(sb_v, '"reality":{"enabled":true')
|
||||
and has(sb_v, '"utls":{"enabled":true,"fingerprint":"chrome"}'))
|
||||
check("singbox vless flow", has(sb_v, '"flow":"xtls-rprx-vision"'))
|
||||
|
||||
-- --- Xray / V2Ray:realitySettings(camelCase),字段名取自 REALITYConfig
|
||||
local v2_v = gen(VLESS, "v2ray")
|
||||
check("v2ray vless security reality", has(v2_v, '"security":"reality"'))
|
||||
check("v2ray vless realitySettings publicKey", has(v2_v, '"publicKey":"PBK"'))
|
||||
check("v2ray vless realitySettings shortId", has(v2_v, '"shortId":"SID"'))
|
||||
check("v2ray vless realitySettings spiderX", has(v2_v, '"spiderX":"/sp"'))
|
||||
check("v2ray vless realitySettings serverName/fingerprint",
|
||||
has(v2_v, '"serverName":"www.example.com"') and has(v2_v, '"fingerprint":"chrome"'))
|
||||
-- Xray 不认识 anytls:节点整体丢弃,而不是生成一个非法 outbound
|
||||
check("v2ray drops anytls", not has(gen(ANY, "v2ray"), "anytls"))
|
||||
|
||||
-- security 声称 reality 却没有 public-key:REALITYConfig.Build() 会因公钥为空报错
|
||||
-- (客户端分支报 `empty "password"`),而 security == "reality" 又必须配
|
||||
-- realitySettings —— 原样输出会让 Xray 拒绝加载整份配置。降级成普通 TLS。
|
||||
local NOPBK = node.normalize({ proto = "vless", name = "NoPbk", server = "1.2.3.4", port = 443,
|
||||
uuid = "u", security = "reality", sni = "x.example.com" })
|
||||
local v2_nopbk = gen(NOPBK, "v2ray")
|
||||
check("v2ray reality without public-key -> no realitySettings",
|
||||
not has(v2_nopbk, "realitySettings"))
|
||||
check("v2ray reality without public-key -> downgraded to tls",
|
||||
has(v2_nopbk, '"security":"tls"') and has(v2_nopbk, '"tlsSettings"'))
|
||||
|
||||
-- --- 分享链接:vless 用 pbk/sid/spx(Xray 分享链接规范)
|
||||
local uri_v = gen(VLESS, "v2rayuri")
|
||||
check("vless uri scheme", has(uri_v, "vless://u@1.2.3.4:443"))
|
||||
check("vless uri pbk/sid/spx", has(uri_v, "pbk=PBK") and has(uri_v, "sid=SID") and has(uri_v, "spx=%2Fsp"))
|
||||
check("vless uri security + flow", has(uri_v, "security=reality") and has(uri_v, "flow=xtls-rprx-vision"))
|
||||
-- anytls:// 链接规范只定义了 sni / insecure,Reality 参数写进去是非法参数
|
||||
local uri_a = gen(ANY, "v2rayuri")
|
||||
check("anytls uri scheme", has(uri_a, "anytls://p@1.2.3.4:443"))
|
||||
check("anytls uri has NO reality params", not has(uri_a, "pbk=") and not has(uri_a, "sid="))
|
||||
|
||||
-- --- QX:reality-base64-pubkey / reality-hex-shortid
|
||||
local qx_any = gen(ANY, "qx")
|
||||
check("qx anytls line", has(qx_any, "anytls=1.2.3.4:443"))
|
||||
check("qx anytls password + over-tls", has(qx_any, "password=p") and has(qx_any, "over-tls=true"))
|
||||
check("qx anytls tls-host", has(qx_any, "tls-host=s.example.com"))
|
||||
check("qx anytls reality keys", has(qx_any, "reality-base64-pubkey=PBK")
|
||||
and has(qx_any, "reality-hex-shortid=SID"))
|
||||
local qx_v = gen(VLESS, "qx")
|
||||
check("qx vless flow + reality keys", has(qx_v, "vless-flow=xtls-rprx-vision")
|
||||
and has(qx_v, "reality-base64-pubkey=PBK") and has(qx_v, "reality-hex-shortid=SID"))
|
||||
-- sample.conf 的 Reality 条目不止 vless / anytls:vmess 与 trojan 各有一条
|
||||
-- (`;vmess=…, obfs=over-tls, …, reality-base64-pubkey=…` /
|
||||
-- `;trojan=…, over-tls=true, tls-host=…, reality-base64-pubkey=…`)。
|
||||
local qx_m = gen(VMESS, "qx")
|
||||
check("qx vmess reality keys", has(qx_m, "reality-base64-pubkey=PBK")
|
||||
and has(qx_m, "reality-hex-shortid=SID"))
|
||||
local qx_t = gen(TROJAN, "qx")
|
||||
check("qx trojan over-tls + reality keys", has(qx_t, "over-tls=true")
|
||||
and has(qx_t, "reality-base64-pubkey=PBK") and has(qx_t, "reality-hex-shortid=SID"))
|
||||
|
||||
-- --- Surge 家族:只有 Loon 的节点行文档化了 Reality(public-key / short-id)。
|
||||
-- Surge / SurgeMac / Surfboard / Egern 写 public-key 是「客户端不认识的参数」,
|
||||
-- Surge 遇到无法解析的代理行会拒绝加载整份配置。
|
||||
check("surge anytls named password, no reality",
|
||||
has(gen(ANY, "surge"), "anytls, 1.2.3.4, 443, password=p") and not has(gen(ANY, "surge"), "public-key"))
|
||||
check("surgemac anytls named password, no reality",
|
||||
has(gen(ANY, "surgemac"), "password=p") and not has(gen(ANY, "surgemac"), "public-key"))
|
||||
-- Surfboard 的 anytls 密码是端口之后的位置参数,裸值不带引号
|
||||
check("surfboard anytls bare positional password",
|
||||
has(gen(ANY, "surfboard"), "anytls, 1.2.3.4, 443, p,"))
|
||||
check("surfboard anytls no reality", not has(gen(ANY, "surfboard"), "public-key"))
|
||||
-- Loon 的 anytls 密码是带引号的位置参数,且 Reality 参数写全
|
||||
local lo_any = gen(ANY, "loon")
|
||||
check("loon anytls quoted positional password", has(lo_any, 'anytls, 1.2.3.4, 443, "p",'))
|
||||
check("loon anytls reality params", has(lo_any, "public-key=PBK") and has(lo_any, "short-id=SID"))
|
||||
local lo_v = gen(VLESS, "loon")
|
||||
check("loon vless reality params", has(lo_v, "public-key=PBK") and has(lo_v, "short-id=SID"))
|
||||
-- Loon 文档的 Reality 示例覆盖 VLESS / VMess / Trojan / AnyTLS 四种节点
|
||||
-- (nsloon.app/docs/Node/,原文:「public-key 和 short-id 用于 Reality」)。
|
||||
local lo_m = gen(VMESS, "loon")
|
||||
check("loon vmess reality params", has(lo_m, "public-key=PBK") and has(lo_m, "short-id=SID"))
|
||||
local lo_t = gen(TROJAN, "loon")
|
||||
check("loon trojan reality params", has(lo_t, "public-key=PBK") and has(lo_t, "short-id=SID"))
|
||||
check("surge vless reality params dropped",
|
||||
not has(gen(VLESS, "surge"), "public-key") and not has(gen(VLESS, "surge"), "short-id"))
|
||||
check("surge vmess reality params dropped",
|
||||
not has(gen(VMESS, "surge"), "public-key") and not has(gen(VMESS, "surge"), "short-id"))
|
||||
check("surfboard trojan reality params dropped",
|
||||
not has(gen(TROJAN, "surfboard"), "public-key"))
|
||||
check("egern anytls named password, no reality",
|
||||
has(gen(ANY, "egern"), "password=p") and not has(gen(ANY, "egern"), "public-key"))
|
||||
|
||||
-- ============ 5. 表单字段清单 ============
|
||||
-- core.merge_form_node 会把「在 PROTO_FIELDS 里但表单没提交」的字段当作清空处理。
|
||||
-- anytls 的 Reality 凭据不列入清单的话,用户在界面上编辑一次就把它们抹掉 ——
|
||||
-- 而表单渲染的字段正是同一份清单(window.SUBSTORE_PROTO_FIELDS),
|
||||
-- 所以列入即会被渲染出来,两边不会脱节。
|
||||
local core = require("substore.core")
|
||||
local af = node.PROTO_FIELDS.anytls
|
||||
local af_set = {}
|
||||
for _, k in ipairs(af) do af_set[k] = true end
|
||||
check("anytls PROTO_FIELDS carries public-key", af_set["public-key"] == true)
|
||||
check("anytls PROTO_FIELDS carries short-id", af_set["short-id"] == true)
|
||||
|
||||
local kept = core.merge_form_node(
|
||||
{ proto = "anytls", name = "A", server = "s", port = 443, password = "p",
|
||||
["public-key"] = "PBK", ["short-id"] = "SID" },
|
||||
{ proto = "anytls", name = "A2", server = "s", port = 443, password = "p",
|
||||
["public-key"] = "PBK", ["short-id"] = "SID" })
|
||||
check("anytls form merge keeps reality creds",
|
||||
kept["public-key"] == "PBK" and kept["short-id"] == "SID")
|
||||
|
||||
-- ============ 6. surge_line 的边界 ============
|
||||
|
||||
-- 未知协议以前会一路掉到末尾,生成 `Name = snell, host, port` 这样的残行 ——
|
||||
-- 客户端解析到不认识的类型会拒绝加载整份配置,必须整条剔除(返回 nil)。
|
||||
check("surge_line unknown proto -> nil",
|
||||
fmts.surge_line({ proto = "snell", name = "S", server = "1.2.3.4", port = 443 }) == nil)
|
||||
|
||||
-- 参数值里的逗号没有引号 / 转义机制,会把凭据静默截断;位置参数同理
|
||||
-- (Loon 的那对引号只是标记,值里的逗号照样是分隔符)。
|
||||
local bad_pw = node.normalize({ proto = "anytls", name = "Bad", server = "1.2.3.4", port = 443, password = "a,b" })
|
||||
check("surge_line comma in named password -> nil", fmts.surge_line(bad_pw, "surge") == nil)
|
||||
check("surge_line comma in quoted positional password -> nil", fmts.surge_line(bad_pw, "loon") == nil)
|
||||
check("surge_line comma in bare positional password -> nil", fmts.surge_line(bad_pw, "surfboard") == nil)
|
||||
|
||||
-- ============ 结果 ============
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -237,7 +237,10 @@ CORE_STUB.write_nodes = function() WRITE_CALLS = WRITE_CALLS + 1; return false e
|
||||
FORM = { token = "t", id = "s00000001", idx = "1", content = VALID_NODE }
|
||||
ctl.action_node_save()
|
||||
check("node_save write failure reports error", has_err())
|
||||
check("node_save write failure surfaced reason", URLENC[#URLENC] == "写入节点数据失败")
|
||||
-- 控制器文案已接入 i18n(msgid 是英文),本测试的 luci.i18n stub 是恒等翻译,
|
||||
-- 因此这里断言的是英文 msgid 原文;中文译文由 po/zh_Hans/substore.po 提供,
|
||||
-- 由 tests/view_i18n_test.lua 校验其完整性。
|
||||
check("node_save write failure surfaced reason", URLENC[#URLENC] == "Failed to write node data")
|
||||
|
||||
-- 无 token:拒绝执行,但**必须回显**(见上面 M28 的说明)
|
||||
reset()
|
||||
|
||||
@@ -133,7 +133,13 @@ check("ss type present", yaml_output and yaml_output:find("type: ss") ~= nil)
|
||||
check("skip-cert-verify present", yaml_output and yaml_output:find("skip%-cert%-verify") ~= nil)
|
||||
check("udp present", yaml_output and yaml_output:find("udp:") ~= nil)
|
||||
check("alpn present", yaml_output and yaml_output:find("alpn:") ~= nil)
|
||||
check("fp present", yaml_output and yaml_output:find("fp:") ~= nil)
|
||||
-- H14:uTLS 指纹的键名是 client-fingerprint,**不是** fp。
|
||||
-- mihomo 全仓库没有任何结构体声明 `proxy:"fp,..."`,而它的 proxy 解码器对未知键
|
||||
-- 是静默忽略的 —— 写 `fp:` 既不报错也不生效,配置看起来有指纹、实际用默认指纹。
|
||||
-- 这条断言此前锁的正是那个 bug(find("fp:"))。
|
||||
check("client-fingerprint present",
|
||||
yaml_output and yaml_output:find("client%-fingerprint: chrome") ~= nil)
|
||||
check("no bogus fp key", yaml_output and yaml_output:find(" fp:") == nil)
|
||||
|
||||
-- 检查 proxy-groups
|
||||
check("SELECT group present", yaml_output and yaml_output:find("type: select") ~= nil)
|
||||
@@ -172,5 +178,20 @@ local nl_out = output_clash_meta.generate({ { proto = "vmess", name = "a\nb", se
|
||||
check("newline name quoted and escaped", nl_out:find('name: "a\\nb"') ~= nil)
|
||||
check("newline name does not add a line", nl_out:find('name: a\nb') == nil)
|
||||
|
||||
-- H14b:client-fingerprint 只对真正支持 uTLS 的协议输出。
|
||||
-- hysteria / hysteria2 / tuic 在 mihomo 里的同名字段叫 fingerprint,但那是
|
||||
-- 证书固定(SHA256 pin),与 uTLS 不是一回事;把模型的 fp 顶上去是语义错误,
|
||||
-- 而 mihomo 又会静默忽略未知键 —— 用户以为指纹生效了,其实没有。
|
||||
local hy2_fp = output_clash_meta.generate({ { proto = "hysteria2", name = "H2", server = "1.1.1.1",
|
||||
port = 443, password = "p", fp = "chrome" } })
|
||||
check("hysteria2 drops fp (cert-pin semantics differ)", hy2_fp:find("fingerprint") == nil)
|
||||
local tuic_fp = output_clash_meta.generate({ { proto = "tuic", name = "T", server = "1.1.1.1",
|
||||
port = 443, uuid = "u", password = "p", fp = "chrome" } })
|
||||
check("tuic drops fp", tuic_fp:find("fingerprint") == nil)
|
||||
-- ss 别名写法(未归一化的节点直接进输出模块)同样要认出来
|
||||
local ss_fp = output_clash_meta.generate({ { proto = "ss", name = "S", server = "1.1.1.1",
|
||||
port = 8388, method = "aes-256-gcm", password = "p", fp = "chrome" } })
|
||||
check("ss alias emits client-fingerprint", ss_fp:find("client%-fingerprint: chrome") ~= nil)
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -72,7 +72,7 @@ if #bad_msgids > 0 then
|
||||
end
|
||||
|
||||
-- 新英文 msgid 必须在 zh-cn 译文表里有条目,否则中文界面会显示英文
|
||||
local po = read("po/zh-cn/substore.po")
|
||||
local po = read("po/zh_Hans/substore.po")
|
||||
check("po has Operation failed", po:find('msgid "Operation failed"', 1, true) ~= nil)
|
||||
check("po has Pick a format msgid",
|
||||
po:find("Pick a format to generate the subscription link", 1, true) ~= nil)
|
||||
|
||||
@@ -45,7 +45,7 @@ proxies:
|
||||
alpn:
|
||||
- h2
|
||||
- http/1.1
|
||||
fp: chrome
|
||||
client-fingerprint: chrome
|
||||
- name: TestTrojan
|
||||
type: trojan
|
||||
server: 3.3.3.3
|
||||
@@ -115,7 +115,10 @@ if nodes and #nodes >= 2 then
|
||||
check("vless uuid", n2.uuid == "vless-uuid-123")
|
||||
check("vless sni", n2.sni == "example.org")
|
||||
check("vless alpn", n2.alpn ~= nil)
|
||||
check("vless fp", n2.fp == "chrome")
|
||||
-- 键名必须是 mihomo 的 client-fingerprint(fixture 里就是它)。
|
||||
-- 旧 fixture 用的是 `fp: chrome` —— 那是本项目自己输出端的错误键名,
|
||||
-- 拿它当「标准」会让解析器读不到真正的 mihomo 配置(见下文的兼容性用例)。
|
||||
check("vless client-fingerprint", n2.fp == "chrome")
|
||||
end
|
||||
|
||||
if nodes and #nodes >= 3 then
|
||||
@@ -298,5 +301,47 @@ check("hash without space kept", hash_plain[1] ~= nil and hash_plain[1].password
|
||||
local hash_quoted = parser.parse('proxies:\n - name: A\n type: ss\n server: 1.1.1.1\n port: 443\n cipher: aes-128-gcm\n password: "a # b"\n')
|
||||
check("hash inside quotes kept", hash_quoted[1] ~= nil and hash_quoted[1].password == "a # b")
|
||||
|
||||
-- ---------- uTLS 指纹键名:client-fingerprint 为主,fp 为兼容兜底 ----------
|
||||
-- mihomo 的键是 client-fingerprint;`fp` 从来不是 mihomo 的键,它是本项目旧版本
|
||||
-- 自己输出时写错的键名(output_clash_meta 已改)。解析端两者都读:
|
||||
-- * 只读 fp → 真正的 mihomo 配置导入后指纹全丢(本次修复的 bug);
|
||||
-- * 只读 client-fingerprint → 用旧版本导出的配置导入后指纹全丢。
|
||||
local cfp = parser.parse([[
|
||||
proxies:
|
||||
- name: A
|
||||
type: vless
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: u
|
||||
tls: true
|
||||
client-fingerprint: chrome
|
||||
]])
|
||||
check("client-fingerprint parsed", cfp[1] ~= nil and cfp[1].fp == "chrome")
|
||||
|
||||
local legacy_fp = parser.parse([[
|
||||
proxies:
|
||||
- name: A
|
||||
type: vless
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
uuid: u
|
||||
tls: true
|
||||
fp: firefox
|
||||
]])
|
||||
check("legacy fp still parsed", legacy_fp[1] ~= nil and legacy_fp[1].fp == "firefox")
|
||||
|
||||
-- hysteria2 的 `fingerprint` 是证书固定(SHA256 pin),不是 uTLS 指纹,
|
||||
-- 不得映射到模型的 fp(语义不同,映射过去会让输出端写出一个错误的指纹)。
|
||||
local pin = parser.parse([[
|
||||
proxies:
|
||||
- name: A
|
||||
type: hysteria2
|
||||
server: 1.1.1.1
|
||||
port: 443
|
||||
password: p
|
||||
fingerprint: aa:bb:cc
|
||||
]])
|
||||
check("cert-pin fingerprint not mapped to fp", pin[1] ~= nil and pin[1].fp == nil)
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,184 @@
|
||||
-- parser_surge_fields_test.lua — Surge 系 / Loon / QX 行解析的两个字段级缺陷
|
||||
-- 用法:lua5.1 tests/parser_surge_fields_test.lua
|
||||
--
|
||||
-- 锁住两件事(对应 docs/LEGACY_ISSUES.md 第七节的 7.5 与 7.6):
|
||||
-- 7.5 行拆分必须**引号感知**:Loon 把凭据写成双引号包裹的位置参数
|
||||
-- (nsloon.app/docs/Node/),密码里含逗号时旧实现(`rest:gmatch("[^,]+")`)
|
||||
-- 会把它切成两段 —— 密码静默变成 `"pa`,用户拿到的不是他填的凭据。
|
||||
-- 7.6 Loon 的 `transport=<tcp|ws|http>` + `path=` + `host=` 必须映射到统一模型。
|
||||
-- 只认 Surge 旧写法(ws=true / ws-path / ws-headers=Host:)时,别人给的
|
||||
-- Loon 配置导入后 net 保持默认 tcp、path / host 全丢,导出到任何格式都按
|
||||
-- tcp 去连一个只开了 ws 的端口 —— 握手失败且不报错。
|
||||
--
|
||||
-- 反向验证:本文件在修复前应当失败(见每条注释里的「修复前」)。
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local surge = require("substore.parser_surge")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 带值输出的断言:反向验证时能直接从输出看出「改前是什么」
|
||||
local function eq(name, got, want)
|
||||
check(name .. " (got " .. string.format("%q", tostring(got))
|
||||
.. ", want " .. string.format("%q", tostring(want)) .. ")", got == want)
|
||||
end
|
||||
|
||||
-- 解析一份 [Proxy] 配置,返回按名字索引的节点表
|
||||
local function parse_proxy(lines)
|
||||
local r = surge.parse("[Proxy]\n" .. table.concat(lines, "\n") .. "\n")
|
||||
local by = {}
|
||||
for _, n in ipairs(r) do by[n.name] = n end
|
||||
return by, r
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- 7.5 引号感知切分
|
||||
-- ============================================================
|
||||
|
||||
-- Loon / Surge 位置参数:`"pa,ss"` 是一个字段,不是两个。
|
||||
-- 修复前:密码被切成 `"pa`(多一个引号、少掉后半段),sni 之后仍能解析。
|
||||
local p1 = parse_proxy({
|
||||
'T = Trojan,t.example.com,443,"pa,ss",sni=t.example.com',
|
||||
})
|
||||
eq("7.5 surge positional quoted comma password", p1["T"] and p1["T"].password, "pa,ss")
|
||||
eq("7.5 surge positional quoted comma keeps following fields", p1["T"] and p1["T"].sni, "t.example.com")
|
||||
|
||||
-- Loon 的 AnyTLS 同样是引号位置参数
|
||||
local p2 = parse_proxy({
|
||||
'A = AnyTLS,a.example.com,443,"p,w",sni=a.example.com',
|
||||
})
|
||||
eq("7.5 loon anytls quoted comma password", p2["A"] and p2["A"].password, "p,w")
|
||||
|
||||
-- VMess 的位置参数是「加密方式, UUID」两个字段;UUID 含逗号时不能被切开
|
||||
local p3 = parse_proxy({
|
||||
'V = VMess,v.example.com,443,aes-128-gcm,"uu,id"',
|
||||
})
|
||||
eq("7.5 loon vmess cipher still positional[1]", p3["V"] and p3["V"].cipher, "aes-128-gcm")
|
||||
eq("7.5 loon vmess quoted comma uuid", p3["V"] and p3["V"].uuid, "uu,id")
|
||||
|
||||
-- QX:值里的逗号同理。注意 QX 的 kv 值**不做 unquote**(unquote 的注释写明只用于
|
||||
-- Loon 的位置参数,QX 的 sample.conf 也没有引号写法),所以引号原样保留 ——
|
||||
-- 这里要锁的是「值没有被逗号截断」:修复前拿到的是 `"pa`(逗号之后的半段丢失),
|
||||
-- 修复后 `pa,ss` 完整保留。
|
||||
local q1 = surge.parse("[server_local]\n"
|
||||
.. 'trojan=h.example.com:443, password="pa,ss", over-tls=true, tag=T\n')
|
||||
check("7.5 qx comma in value is not truncated (got "
|
||||
.. string.format("%q", tostring(q1[1] and q1[1].password)) .. ")",
|
||||
q1[1] ~= nil and q1[1].password ~= nil
|
||||
and q1[1].password:find("pa,ss", 1, true) ~= nil)
|
||||
eq("7.5 qx quoted comma keeps following fields", q1[1] and q1[1].security, "tls")
|
||||
|
||||
-- 回归:无引号的输入必须与旧实现逐字符等价
|
||||
local p4 = parse_proxy({
|
||||
'S = ss,h.example.com,8388,encrypt-method=aes-256-gcm,password=plainpwd',
|
||||
'T2 = Trojan,h.example.com,443,plainpwd,sni=s.example.com',
|
||||
})
|
||||
eq("regression unquoted ss method", p4["S"] and p4["S"].method, "aes-256-gcm")
|
||||
eq("regression unquoted ss password", p4["S"] and p4["S"].password, "plainpwd")
|
||||
eq("regression unquoted trojan positional password", p4["T2"] and p4["T2"].password, "plainpwd")
|
||||
|
||||
-- 回归:引号包裹但**不含逗号**的值仍被剥掉引号(既有行为)
|
||||
local p5 = parse_proxy({
|
||||
'T3 = Trojan,h.example.com,443,"pwd"',
|
||||
})
|
||||
eq("regression quoted password unquoted", p5["T3"] and p5["T3"].password, "pwd")
|
||||
|
||||
-- 边界:空字段照旧丢弃(与 gmatch("[^,]+") 一致,位置参数不会多出空项)
|
||||
local p6 = parse_proxy({
|
||||
'T4 = Trojan,h.example.com,443,,sni=s.example.com',
|
||||
})
|
||||
eq("boundary empty field dropped", p6["T4"] and p6["T4"].password, nil)
|
||||
eq("boundary empty field keeps later kv", p6["T4"] and p6["T4"].sni, "s.example.com")
|
||||
|
||||
-- 边界:引号个数为奇数(订阅内容不可信)时**不做**引号感知,退回按逗号切分。
|
||||
-- 否则那个落单的引号会一直「开着」,把后面的 sni= 一起吞进同一个字段。
|
||||
local p7 = parse_proxy({
|
||||
'T5 = Trojan,h.example.com,443,pa"ss,sni=s.example.com',
|
||||
})
|
||||
eq("boundary odd quote falls back to comma split (password)", p7["T5"] and p7["T5"].password, 'pa"ss')
|
||||
eq("boundary odd quote does not swallow later kv", p7["T5"] and p7["T5"].sni, "s.example.com")
|
||||
|
||||
-- ============================================================
|
||||
-- 7.6 Loon transport= / path= / host=
|
||||
-- ============================================================
|
||||
|
||||
-- Loon 文档的 VLESS-over-WS 行(nsloon.app/docs/Node/)
|
||||
-- 修复前:net 保持 vless 的默认 "tcp",path / host 为 nil
|
||||
local t1 = parse_proxy({
|
||||
'VLESS = VLESS,v.example.com,443,"uu-id",transport=ws,path=/websocket,'
|
||||
.. 'host=cdn.example.com,over-tls=true,sni=s.example.com',
|
||||
})
|
||||
local n1 = t1["VLESS"]
|
||||
eq("7.6 loon transport=ws -> net", n1 and n1.net, "ws")
|
||||
eq("7.6 loon path=", n1 and n1.path, "/websocket")
|
||||
eq("7.6 loon host=", n1 and n1.host, "cdn.example.com")
|
||||
eq("7.6 loon over-tls still tls", n1 and n1.security, "tls")
|
||||
eq("7.6 loon sni kept", n1 and n1.sni, "s.example.com")
|
||||
eq("7.6 loon positional uuid kept", n1 and n1.uuid, "uu-id")
|
||||
|
||||
-- Loon 文档:兼容配置里的 transport=http 按 WebSocket 处理
|
||||
local t2 = parse_proxy({
|
||||
'VLESS2 = VLESS,v.example.com,443,"uu-id",transport=http,path=/h,host=h.example.com',
|
||||
})
|
||||
eq("7.6 loon transport=http -> ws (per Loon docs)", t2["VLESS2"] and t2["VLESS2"].net, "ws")
|
||||
|
||||
-- transport=tcp 显式写出来时保持 tcp(不引入新取值)
|
||||
local t3 = parse_proxy({
|
||||
'TRO = Trojan,t.example.com,443,"pwd",transport=tcp,sni=t.example.com',
|
||||
})
|
||||
eq("7.6 loon transport=tcp stays tcp", t3["TRO"] and t3["TRO"].net, "tcp")
|
||||
|
||||
-- 回归:Surge 旧写法(ws=true / ws-path / ws-headers=Host:)仍然生效
|
||||
local t4 = parse_proxy({
|
||||
'M = vmess,v.example.com,443,username=uu,tls=true,sni=s.example.com,'
|
||||
.. 'ws=true,ws-path=/oldpath,ws-headers=Host:old.example.com',
|
||||
})
|
||||
local n4 = t4["M"]
|
||||
eq("regression legacy ws=true -> net", n4 and n4.net, "ws")
|
||||
eq("regression legacy ws-path", n4 and n4.path, "/oldpath")
|
||||
eq("regression legacy ws-headers Host", n4 and n4.host, "old.example.com")
|
||||
|
||||
-- 新写法优先:同一行两种都出现时以 Loon 的 transport= / path= / host= 为准
|
||||
local t5 = parse_proxy({
|
||||
'M2 = vmess,v.example.com,443,username=uu,ws=true,ws-path=/old,'
|
||||
.. 'ws-headers=Host:old.example.com,transport=ws,path=/new,host=new.example.com',
|
||||
})
|
||||
local n5 = t5["M2"]
|
||||
eq("new form wins: path", n5 and n5.path, "/new")
|
||||
eq("new form wins: host", n5 and n5.host, "new.example.com")
|
||||
|
||||
-- Loon 的 VMess-over-WS 行:位置参数(加密方式, UUID)与传输参数并存
|
||||
local t6 = parse_proxy({
|
||||
'VM = VMess,v.example.com,443,aes-128-gcm,"uu-id",transport=ws,path=/websocket,'
|
||||
.. 'host=cdn.example.com,over-tls=true,sni=s.example.com',
|
||||
})
|
||||
local n6 = t6["VM"]
|
||||
eq("7.6 loon vmess cipher", n6 and n6.cipher, "aes-128-gcm")
|
||||
eq("7.6 loon vmess uuid", n6 and n6.uuid, "uu-id")
|
||||
eq("7.6 loon vmess net", n6 and n6.net, "ws")
|
||||
eq("7.6 loon vmess path/host", n6 and (n6.path .. "|" .. tostring(n6.host)), "/websocket|cdn.example.com")
|
||||
|
||||
-- 回归:Reality(7.4 相关)不受 7.6 改动影响
|
||||
local t7 = parse_proxy({
|
||||
'R = VLESS,v.example.com,443,"uu-id",transport=tcp,public-key="PBK",short-id=sid123,over-tls=true',
|
||||
})
|
||||
local n7 = t7["R"]
|
||||
eq("regression reality public-key", n7 and n7["public-key"], "PBK")
|
||||
eq("regression reality short-id", n7 and n7["short-id"], "sid123")
|
||||
eq("regression reality security", n7 and n7.security, "reality")
|
||||
eq("regression reality transport=tcp", n7 and n7.net, "tcp")
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -0,0 +1,209 @@
|
||||
-- protocol_registry_test.lua — 协议 × 目标格式的注册表一致性(防漂移)
|
||||
-- 用法:lua5.1 tests/protocol_registry_test.lua
|
||||
--
|
||||
-- 这个仓库最反复出现的失效模式是「协议清单散落多处,漏改一处 → 节点被静默丢弃」:
|
||||
-- 新增一个协议要同时改模型、解析器、若干输出模块和模板,而漏掉任何一处都**不报错**,
|
||||
-- 只是导出的订阅里少了那个节点(用户看到的仍是「导出成功」)。
|
||||
--
|
||||
-- 所以这里不逐个断言注册表常量(它们多是各模块内的 local,取值域也各不相同),
|
||||
-- 而是走**行为**:把每个协议的最小可用节点喂给每一种目标格式,断言「节点是否出现
|
||||
-- 在输出里」与下方 DROPPED 表完全一致。DROPPED 是唯一的显式声明,每条都注明
|
||||
-- 由哪个模块决定,便于审查它是「协议确实不支持」还是「漏接了」。
|
||||
--
|
||||
-- 两个方向都会红:
|
||||
-- * 新协议漏接某个输出 → 该格式下节点消失,但 DROPPED 里没写 → FAIL;
|
||||
-- * 某格式补上了对某协议的支持(或退化了)→ 与 DROPPED 不符 → FAIL;
|
||||
-- * 新增目标格式 → 格式清单直接取自 output.FORMAT_OPTIONS,新格式会被遍历到,
|
||||
-- 若它对某协议有过滤而表里没写 → FAIL。
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local output = require("substore.output")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(name, cond)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
print("PASS " .. name)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
-- 每个协议的最小可用节点:只给身份字段,其余交给 node.normalize 补默认值。
|
||||
-- 样本缺身份字段时输出模块可能因校验而跳过该节点,那会把「协议不支持」与
|
||||
-- 「样本不全」混为一谈,测试就失去意义了。
|
||||
local SAMPLES = {
|
||||
vmess = { uuid = "u" },
|
||||
vless = { uuid = "u" },
|
||||
trojan = { password = "p" },
|
||||
shadowsocks = { method = "aes-256-gcm", password = "p" },
|
||||
ssr = { password = "p", method = "aes-128-cfb", protocol = "origin", obfs = "plain" },
|
||||
hysteria2 = { password = "p" },
|
||||
hysteria = { password = "p" },
|
||||
tuic = { uuid = "u", password = "p" },
|
||||
wireguard = { ["private-key"] = "k", ["public-key"] = "k2", ip = "10.0.0.2/32" },
|
||||
socks = { username = "u", password = "p" },
|
||||
anytls = { password = "p" },
|
||||
}
|
||||
|
||||
-- 分享链接的 scheme。URI 类格式(shadowrocket / v2rayuri)会把节点名编码进
|
||||
-- base64(vmess 甚至是「base64 里套 base64」),按名字查找必然漏判 ——
|
||||
-- 只能按 scheme 判断节点有没有被输出。
|
||||
local SCHEME = {
|
||||
vmess = "vmess://", vless = "vless://", trojan = "trojan://",
|
||||
shadowsocks = "ss://", ssr = "ssr://", hysteria2 = "hysteria2://",
|
||||
hysteria = "hysteria://", tuic = "tuic://", wireguard = "wireguard://",
|
||||
socks = "socks5://",
|
||||
anytls = "anytls://",
|
||||
}
|
||||
local URI_FORMATS = { shadowrocket = true, v2rayuri = true }
|
||||
|
||||
-- 目标格式清单取自唯一注册点 output.FORMAT_OPTIONS(不在这里另抄一份)
|
||||
local FORMATS = {}
|
||||
for _, opt in ipairs(output.FORMAT_OPTIONS) do FORMATS[#FORMATS + 1] = opt[1] end
|
||||
|
||||
-- 每个协议在哪些格式下**会被丢弃**,以及原因(决定它的模块)。
|
||||
-- 未列出的 (协议, 格式) 一律断言「节点必须出现在输出里」。
|
||||
local DROPPED = {
|
||||
-- output_wireguard_conf 只输出 wireguard 节点,其余一律过滤
|
||||
vmess = { wgconf = "wgconf 只收 wireguard" },
|
||||
vless = { clash = "原版 Clash 无 VLESS", wgconf = "wgconf 只收 wireguard" },
|
||||
trojan = { wgconf = "wgconf 只收 wireguard" },
|
||||
shadowsocks = { wgconf = "wgconf 只收 wireguard" },
|
||||
-- ssr:Surge/Surfboard/SurgeMac 不支持(surge_config 的 supports_ssr=false),
|
||||
-- Loon/Egern 支持;to_qx 只认 ss/vmess/vless/trojan;singbox 的 supported()
|
||||
-- 明确排除 ssr;v2ray 的 V2RAY_PROTOS 里没有 ssr。
|
||||
ssr = {
|
||||
surge = "Surge 无 SSR", surfboard = "Surfboard 无 SSR", surgemac = "SurgeMac 无 SSR",
|
||||
qx = "to_qx 只认 ss/vmess/vless/trojan", singbox = "singbox 不支持 ssr",
|
||||
v2ray = "V2RAY_PROTOS 无 ssr", wgconf = "wgconf 只收 wireguard",
|
||||
},
|
||||
-- hysteria2 / tuic / hysteria:原版 Clash、qx、v2ray 都不支持
|
||||
hysteria2 = { clash = "原版 Clash 无 hysteria2", qx = "to_qx 无 hysteria2",
|
||||
v2ray = "V2RAY_PROTOS 无 hysteria2", wgconf = "wgconf 只收 wireguard" },
|
||||
tuic = { clash = "原版 Clash 无 tuic", qx = "to_qx 无 tuic",
|
||||
v2ray = "V2RAY_PROTOS 无 tuic", wgconf = "wgconf 只收 wireguard" },
|
||||
hysteria = { clash = "原版 Clash 无 hysteria", qx = "to_qx 无 hysteria",
|
||||
v2ray = "V2RAY_PROTOS 无 hysteria", wgconf = "wgconf 只收 wireguard" },
|
||||
-- wireguard:Surge 家族需要专用的多段 [WireGuard] 配置,单行 [Proxy] 表达不了,
|
||||
-- surge_config 统一丢弃;原版 Clash / qx / v2ray 同样不支持。
|
||||
-- (shadowrocket / v2rayuri 会输出本项目自定义的 wireguard:// 链接 —— 这是既有
|
||||
-- 行为,此处如实记录,并不代表已验证该 scheme 被客户端接受。)
|
||||
wireguard = { clash = "原版 Clash 无 wireguard", surge = "Surge 单行表达不了",
|
||||
surfboard = "同 Surge", surgemac = "同 Surge", loon = "同 Surge", egern = "同 Surge",
|
||||
qx = "to_qx 无 wireguard", v2ray = "V2RAY_PROTOS 无 wireguard" },
|
||||
socks = { qx = "to_qx 无 socks", wgconf = "wgconf 只收 wireguard" },
|
||||
-- anytls:mihomo / Stash / sing-box 1.12+ / Shadowrocket / Surge 家族
|
||||
-- (Surge iOS 5.17.0+ 与 Mac 6.4.3+、Surfboard、Loon、Egern)以及
|
||||
-- QX 1.6.0+ 都支持;原版 Clash(Dreamacro)与 Xray/V2Ray 都不认识该协议
|
||||
-- (Xray issue #4428 以 not planned 关闭),wgconf 只收 wireguard。
|
||||
anytls = {
|
||||
clash = "原版 Clash 无 anytls", v2ray = "V2RAY_PROTOS 无 anytls",
|
||||
wgconf = "wgconf 只收 wireguard",
|
||||
},
|
||||
}
|
||||
|
||||
-- ---------- 表本身的自洽性(防止错别字 / 过期条目把测试变成空转) ----------
|
||||
local proto_set, fmt_set = {}, {}
|
||||
for _, p in ipairs(node.PROTOS) do proto_set[p] = true end
|
||||
for _, f in ipairs(FORMATS) do fmt_set[f] = true end
|
||||
|
||||
local unknown_proto = {}
|
||||
for p in pairs(DROPPED) do if not proto_set[p] then unknown_proto[#unknown_proto + 1] = p end end
|
||||
table.sort(unknown_proto)
|
||||
check("DROPPED keys are all in node.PROTOS"
|
||||
.. (#unknown_proto > 0 and (" (" .. table.concat(unknown_proto, ",") .. ")") or ""),
|
||||
#unknown_proto == 0)
|
||||
|
||||
local unknown_fmt = {}
|
||||
for _, p in pairs(DROPPED) do
|
||||
for f in pairs(p) do if not fmt_set[f] then unknown_fmt[f] = true end end
|
||||
end
|
||||
local uf = {}
|
||||
for f in pairs(unknown_fmt) do uf[#uf + 1] = f end
|
||||
table.sort(uf)
|
||||
check("DROPPED formats are all in FORMAT_OPTIONS"
|
||||
.. (#uf > 0 and (" (" .. table.concat(uf, ",") .. ")") or ""),
|
||||
#uf == 0)
|
||||
|
||||
-- 每个协议都要有样本与 scheme,否则下面的循环会静默跳过它(测试看着全绿其实漏测)
|
||||
local missing_sample, missing_scheme = {}, {}
|
||||
for _, p in ipairs(node.PROTOS) do
|
||||
if not SAMPLES[p] then missing_sample[#missing_sample + 1] = p end
|
||||
if not SCHEME[p] then missing_scheme[#missing_scheme + 1] = p end
|
||||
end
|
||||
check("every proto has a sample"
|
||||
.. (#missing_sample > 0 and (" (" .. table.concat(missing_sample, ",") .. ")") or ""),
|
||||
#missing_sample == 0)
|
||||
check("every proto has a scheme"
|
||||
.. (#missing_scheme > 0 and (" (" .. table.concat(missing_scheme, ",") .. ")") or ""),
|
||||
#missing_scheme == 0)
|
||||
check("FORMATS is non-empty", #FORMATS > 0)
|
||||
|
||||
-- ---------- 行为:逐个 (协议, 格式) 验证 ----------
|
||||
-- 名字带首尾标记,避免 MKhysteria 成为 MKhysteria2 的子串(子串匹配会误判)
|
||||
local function sample(proto)
|
||||
local n = { proto = proto, name = "MK" .. proto .. "X", server = "10.9.0.1", port = 443 }
|
||||
for k, v in pairs(SAMPLES[proto] or {}) do n[k] = v end
|
||||
return node.normalize(n)
|
||||
end
|
||||
|
||||
local function emitted(out, proto, fmt)
|
||||
if type(out) ~= "string" then return false end
|
||||
if URI_FORMATS[fmt] then
|
||||
local s = out
|
||||
if fmt == "shadowrocket" then
|
||||
local ok, dec = pcall(util.base64_decode, out)
|
||||
if not ok or type(dec) ~= "string" then return false end
|
||||
s = dec
|
||||
end
|
||||
return s:find(SCHEME[proto], 1, true) ~= nil
|
||||
end
|
||||
return out:find("MK" .. proto .. "X", 1, true) ~= nil
|
||||
end
|
||||
|
||||
local total_pairs = 0
|
||||
for _, proto in ipairs(node.PROTOS) do
|
||||
local drops = DROPPED[proto] or {}
|
||||
local bad = {}
|
||||
for _, fmt in ipairs(FORMATS) do
|
||||
total_pairs = total_pairs + 1
|
||||
local ok, out = pcall(output.generate, { sample(proto) }, fmt, {})
|
||||
local got, want
|
||||
if not ok then
|
||||
got, want = "error: " .. tostring(out), "kept"
|
||||
else
|
||||
got, want = emitted(out, proto, fmt), not drops[fmt]
|
||||
end
|
||||
if got ~= want then
|
||||
bad[#bad + 1] = fmt .. " want " .. (want and "kept" or "dropped")
|
||||
.. " got " .. tostring(got)
|
||||
end
|
||||
end
|
||||
check(proto .. " matches the format registry"
|
||||
.. (#bad > 0 and (" (" .. table.concat(bad, "; ") .. ")") or ""),
|
||||
#bad == 0)
|
||||
end
|
||||
check("registry covers every proto x format pair",
|
||||
total_pairs == #node.PROTOS * #FORMATS)
|
||||
|
||||
-- plain 是「无损导出」:它不做任何协议过滤,任何协议都必须出现在 Plain JSON 里。
|
||||
-- 单独命名这条不变量,是因为一旦有人给 to_plain 加上过滤,症状会非常隐蔽
|
||||
-- (备份/迁移用的导出静默少了节点)。
|
||||
local plain_lossy = {}
|
||||
for _, proto in ipairs(node.PROTOS) do
|
||||
local ok, out = pcall(output.generate, { sample(proto) }, "plain", {})
|
||||
if not ok or not emitted(out, proto, "plain") then plain_lossy[#plain_lossy + 1] = proto end
|
||||
end
|
||||
check("plain JSON is lossless for every proto"
|
||||
.. (#plain_lossy > 0 and (" (missing: " .. table.concat(plain_lossy, ",") .. ")") or ""),
|
||||
#plain_lossy == 0)
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -101,6 +101,23 @@ check("controller uses core.RULE_PROTOS", ctl:find("RULE_PROTOS", 1, true) ~= ni
|
||||
check("controller has no hardcoded proto list",
|
||||
ctl:find('ipairs({"vmess"', 1, true) == nil)
|
||||
|
||||
-- ---------- 静态:nodes.htm 的协议下拉不得另抄一份清单 ----------
|
||||
-- nodes.htm 是**节点筛选页**(GET 表单,单个 proto 下拉),不是规则表单:它没有
|
||||
-- rename_map / proto_filter_* 复选框,proto 块也不是 ipairs(core.RULE_PROTOS)。
|
||||
-- 因此它**不能**放进上面的 TEMPLATES —— 那组断言会要求它提交那两个字段并渲染
|
||||
-- proto_filter 复选框,加进去必然误报(这是本轮刻意不做的事)。
|
||||
--
|
||||
-- 但它曾经正是「协议清单的第 3 份拷贝」,而且长期没被发现,后果是节点页的
|
||||
-- 「类型」下拉永远选不到新协议(勾了没用、不报错)。所以单独断言它遍历共享清单、
|
||||
-- 且不再内联硬编码字面量(旧写法就是 `ipairs({"vmess","vless",...})`)。
|
||||
local NODES_VIEW = "root/usr/lib/lua/luci/view/substore/nodes.htm"
|
||||
local nv = util.read_file(NODES_VIEW) or ""
|
||||
check("nodes.htm iterates shared proto list", nv:find("ipairs(node.PROTOS)", 1, true) ~= nil)
|
||||
check("nodes.htm has no inline proto literal", nv:find('ipairs({"', 1, true) == nil)
|
||||
-- 「共享」必须是同一张表,而不是内容恰好相等的两份拷贝:
|
||||
-- 逐字相等仍然会漂移,同一张表不会。
|
||||
check("core.RULE_PROTOS is node.PROTOS", core.RULE_PROTOS == node.PROTOS)
|
||||
|
||||
-- ---------- 行为:复刻控制器的收集逻辑,验证它真的能过滤 ----------
|
||||
-- 与 controller 的 read_rules_fields 保持一致:按 RULE_PROTOS 顺序遍历,
|
||||
-- 表单里出现即为勾选(未勾选的 checkbox 浏览器不会提交)。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- A 真渲染:把模板按 LuCI 的方式重建成 Lua chunk,用恒等 translate 渲染一遍,
|
||||
-- 输出里不得出现 CJK。这直接复现「英文界面显示中文」这个缺陷 ——
|
||||
-- 中文字面量在输出里,而 <%:…%> 输出的是英文 msgid。
|
||||
-- B po 覆盖:模板里每个 msgid 都必须在 po/zh-cn/substore.po 里有条目,
|
||||
-- B po 覆盖:模板里每个 msgid 都必须在 po/zh_Hans/substore.po 里有条目,
|
||||
-- 否则中文界面会显示英文。
|
||||
-- C 字面文本:去掉 <% %> 代码块与 HTML 注释后,剩下的字面 HTML 里不得有 CJK。
|
||||
-- A 只覆盖能离线渲染的模板(需要数据 fixture 的 nodes.htm / output.htm 跳过),
|
||||
@@ -182,7 +182,7 @@ for _, file in ipairs({ "form.htm", "combo.htm", "local_form.htm", "node_edit.ht
|
||||
end
|
||||
|
||||
-- ---------- B:模板里的 msgid 必须都有 zh-cn 译文 ----------
|
||||
local po = util.read_file("po/zh-cn/substore.po") or ""
|
||||
local po = util.read_file("po/zh_Hans/substore.po") or ""
|
||||
local missing = {}
|
||||
for _, file in ipairs(VIEWS) do
|
||||
local src = util.read_file(VIEW_DIR .. file) or ""
|
||||
@@ -205,6 +205,26 @@ if #missing > 0 then
|
||||
for _, m in ipairs(missing) do print(" no po entry: " .. m) end
|
||||
end
|
||||
|
||||
-- ---------- B2:控制器里 _("…") 的 msgid 同样必须有 zh-cn 译文 ----------
|
||||
-- 控制器的失败原因经 ?err= 带回页面显示,是用户可见文本。漏了 po 条目时
|
||||
-- translate 回退成 msgid,中文界面下就冒出英文(与模板漏条目是同一个缺陷,
|
||||
-- 只是入口不同)。控制器里的 msgid 一律写英文原文,见文件头的说明。
|
||||
local ctl_src = util.read_file("root/usr/lib/lua/luci/controller/admin/substore.lua") or ""
|
||||
local ctl_missing = {}
|
||||
for i, line in ipairs(lines_of(ctl_src)) do
|
||||
if not line:match("^%s*%-%-") then
|
||||
for id in line:gmatch('_%("(.-)"%)') do
|
||||
if po:find('msgid "' .. id .. '"', 1, true) == nil then
|
||||
ctl_missing[#ctl_missing + 1] = i .. " " .. id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("every controller msgid has a zh-cn translation", #ctl_missing == 0)
|
||||
if #ctl_missing > 0 then
|
||||
for _, m in ipairs(ctl_missing) do print(" no po entry: controller:" .. m) end
|
||||
end
|
||||
|
||||
-- ---------- C:字面 HTML 文本里不得有 CJK ----------
|
||||
-- <% %> 代码块抹成空白(保留换行,行号不漂)。剩下的是模板里写死的界面文本,
|
||||
-- 它不会进翻译表 —— 英文界面下原样输出中文。
|
||||
|
||||
@@ -109,19 +109,39 @@ for _, p in ipairs(node.PROTOS) do
|
||||
check("PROTO_FIELDS has " .. p, type(f) == "table" and #f > 0)
|
||||
end
|
||||
|
||||
-- 每个渲染出来的字段都要有标签,否则界面上直接显示英文键名
|
||||
local label_src = (util.read_file("root/usr/lib/lua/luci/view/substore/node_edit.htm") or "")
|
||||
local labels = {}
|
||||
for k in label_src:gmatch('"([%w%-]+)"%s*:') do labels[k] = true end
|
||||
local missing = {}
|
||||
for p, fields in pairs(node.PROTO_FIELDS) do
|
||||
for _, k in ipairs(fields) do
|
||||
if not labels[k] then missing[#missing + 1] = p .. "." .. k end
|
||||
-- 每个渲染出来的字段都要有标签,否则界面上直接显示英文键名。
|
||||
--
|
||||
-- 两份模板各有一份 FIELD_LABELS(node_edit.htm 编辑单个节点,local_form.htm
|
||||
-- 本地订阅的表单导入),此前只检查了 node_edit.htm —— 于是 local_form.htm
|
||||
-- 可以静默缺标签,症状是那个表单里直接显示英文键名。两份都要查。
|
||||
--
|
||||
-- 这里刻意**不做**「两份键集完全相等」的断言:标签是用 '"([%w%-]+)"%s*:' 从
|
||||
-- 模板文本里抠出来的,会连带抠到无关的 JS 字符串键(实测 local_form.htm 会多出
|
||||
-- `? "block" : "none"` 里的 "block"),做相等断言必然误报。真正要保证的不变量
|
||||
-- 是「每份模板都覆盖了全部字段」,逐个字段查即可。
|
||||
local LABEL_TEMPLATES = {
|
||||
"root/usr/lib/lua/luci/view/substore/node_edit.htm",
|
||||
"root/usr/lib/lua/luci/view/substore/local_form.htm",
|
||||
}
|
||||
local REQUIRED_FIELDS = {}
|
||||
for _, fields in pairs(node.PROTO_FIELDS) do
|
||||
for _, k in ipairs(fields) do REQUIRED_FIELDS[k] = true end
|
||||
end
|
||||
for _, k in ipairs(node.FORM_ALWAYS_FIELDS) do REQUIRED_FIELDS[k] = true end
|
||||
|
||||
for _, path in ipairs(LABEL_TEMPLATES) do
|
||||
local name = path:match("[^/]+$")
|
||||
local src = util.read_file(path) or ""
|
||||
local labels = {}
|
||||
for k in src:gmatch('"([%w%-]+)"%s*:') do labels[k] = true end
|
||||
local missing = {}
|
||||
for k in pairs(REQUIRED_FIELDS) do
|
||||
if not labels[k] then missing[#missing + 1] = k end
|
||||
end
|
||||
table.sort(missing)
|
||||
check(name .. " labels every form field" .. (#missing > 0 and (" (missing: " .. table.concat(missing, ",") .. ")") or ""),
|
||||
#missing == 0)
|
||||
end
|
||||
table.sort(missing)
|
||||
check("every form field has a label" .. (#missing > 0 and (" (missing: " .. table.concat(missing, ",") .. ")") or ""),
|
||||
#missing == 0)
|
||||
|
||||
-- TLS 层字段必须出现在表单里,否则编辑一次就丢 TLS(vmess 的历史缺陷)
|
||||
for _, p in ipairs({ "vmess", "vless" }) do
|
||||
@@ -130,11 +150,15 @@ for _, p in ipairs({ "vmess", "vless" }) do
|
||||
check(p .. " form renders security", has)
|
||||
end
|
||||
|
||||
-- TLS-only 协议必须由 normalize 保证 security,而不是靠 URI 解析器各自补
|
||||
for _, p in ipairs({ "hysteria2", "hysteria", "tuic", "trojan" }) do
|
||||
-- TLS-only 协议必须由 normalize 保证 security,而不是靠 URI 解析器各自补。
|
||||
-- 清单直接读 node.TLS_ONLY(唯一来源):在这里另写一份字面量的话,新增一个
|
||||
-- TLS-only 协议时本用例不会覆盖它,测试看着全绿其实漏测。
|
||||
for p in pairs(node.TLS_ONLY) do
|
||||
check(p .. " normalize forces security=tls",
|
||||
node.normalize({ proto = p, server = "h", port = 1 }).security == "tls")
|
||||
end
|
||||
-- TLS_ONLY 必须非空,否则上面的循环一条都不跑,静默通过
|
||||
check("TLS_ONLY non-empty", next(node.TLS_ONLY) ~= nil)
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user