🌴 Sync 2026-10-06 02:20:56

This commit is contained in:
github-actions[bot] committed 2026-10-06 02:20:56 +08:00
1 parent 5c201dd3af
commit 487c4fad50
111 files changed
+4641 -746

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
80de46ce77221bee421a3c19f7d9d0190ee1b7cd
9c9ad92d8e1621d0708c83afc215bb141e7bafb8
e192c5983e50169ed984d48b094798869a0a780b
+175
View File
@@ -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"
+8 -5
View File
@@ -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 ""
+24 -1
View File
@@ -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
+47 -11
View File
@@ -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 =="
+10
View File
@@ -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);
+6
View File
@@ -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.
+50 -4
View File
@@ -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);
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 = ""
+13 -11
View File
@@ -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 = {
+9
View File
@@ -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 "清除定时执行命令。"
}
+2 -2
View File
@@ -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"
+59 -59
View File
@@ -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"
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -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 ""
+1 -1
View File
@@ -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
+116
View File
@@ -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 ""
+1 -1
View File
@@ -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 \
+17 -1
View File
@@ -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"
+155
View File
@@ -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)。
+19 -5
View File
@@ -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))
+20 -4
View File
@@ -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/.../`.
+17 -4
View File
@@ -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/.../` 下。
+27
View File
@@ -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).
+18 -3
View File
@@ -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
+141
View File
@@ -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 为审计表中**其余中危 / 低危**项中性质明确、无需另行决策的缺陷,例如
+1
View File
@@ -0,0 +1 @@
zh_Hans
-1
View File
@@ -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)
+1 -1
View File
@@ -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 浏览器不会提交)。
+22 -2
View File
@@ -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 ----------
-- <% %> 代码块抹成空白(保留换行,行号不漂)。剩下的是模板里写死的界面文本,
-- 它不会进翻译表 —— 英文界面下原样输出中文。
+37 -13
View File
@@ -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