🎈 Sync 2026-09-30 10:37:56

This commit is contained in:
github-actions[bot] committed 2026-09-30 10:37:57 +08:00
1 parent 7bb984c650
commit 214a01499a
17 files changed
+386 -69

No files matched your search

+10 -7
View File
@@ -9,18 +9,20 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=firewall
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL=$(PROJECT_GIT)/project/firewall3.git
PKG_SOURCE_DATE:=2022-02-17
PKG_SOURCE_VERSION:=4cd7d4f36bea731bf901cb067456f1d460294926
PKG_SOURCE_DATE:=2025-10-03
PKG_SOURCE_VERSION:=3a65fde5a3e675fdf2a24cb3f5d12d0a426be36e
PKG_MIRROR_HASH:=skip
PKG_MAINTAINER:=Jo-Philipp Wich <jo@mein.io>
PKG_LICENSE:=ISC
PKG_CONFIG_DEPENDS := CONFIG_IPV6
PKG_BUILD_FLAGS:=gc-sections lto
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/cmake.mk
@@ -28,8 +30,11 @@ define Package/firewall
SECTION:=net
CATEGORY:=Base system
TITLE:=OpenWrt C Firewall
DEPENDS:=+libubox +libubus +libuci +libip4tc +IPV6:libip6tc +libiptext +IPV6:libiptext6 +libxtables +kmod-ipt-core +kmod-ipt-conntrack +iptables-mod-conntrack-extra +IPV6:kmod-nf-conntrack6 +kmod-ipt-nat +iptables-mod-fullconenat \
+ipset +iptables-mod-conntrack-extra +iptables-mod-iprange +iptables-mod-socket +iptables-mod-tproxy
DEPENDS:=+libubox +libubus +libuci +libip4tc +IPV6:libip6tc +libiptext +IPV6:libiptext6 \
+libxtables +kmod-ipt-core +kmod-ipt-conntrack +IPV6:kmod-nf-conntrack6 +kmod-ipt-nat \
+iptables-mod-fullconenat \
+iptables-mod-fullconenat +ip6tables-mod-fullconenat +iptables-legacy +ip6tables-legacy +kmod-ipt-nat6 +kmod-ipt-offload \
+ipset +iptables-mod-conntrack-extra +iptables-mod-iprange +iptables-mod-socket +iptables-mod-tproxy
PROVIDES:=uci-firewall
endef
@@ -42,8 +47,6 @@ define Package/firewall/conffiles
/etc/firewall.user
endef
TARGET_CFLAGS += -ffunction-sections -fdata-sections -flto
TARGET_LDFLAGS += -Wl,--gc-sections -flto
CMAKE_OPTIONS += $(if $(CONFIG_IPV6),,-DDISABLE_IPV6=1)
define Package/firewall/install
+7 -7
View File
@@ -1,9 +1,10 @@
config defaults
option synflood_protect 1
option input ACCEPT
option input REJECT
option output ACCEPT
option forward REJECT
option fullcone 0
option flow_offloading 1
option fullcone 1
# Uncomment this line to disable ipv6 rules
# option disable_ipv6 1
@@ -55,13 +56,12 @@ config rule
option target ACCEPT
# Allow DHCPv6 replies
# see https://dev.openwrt.org/ticket/10381
# see https://github.com/openwrt/openwrt/issues/5066
config rule
option name Allow-DHCPv6
option src wan
option proto udp
option src_ip fc00::/6
option dest_ip fc00::/6
option dest_ip fe80::/10
option dest_port 546
option family ipv6
option target ACCEPT
@@ -82,7 +82,7 @@ config rule
config rule
option name Allow-ICMPv6-Input
option src wan
option proto icmp
option proto icmp
list icmp_type echo-request
list icmp_type echo-reply
list icmp_type destination-unreachable
@@ -141,7 +141,7 @@ config rule
option proto udp
option family ipv4
option target REJECT
option enabled false
option enabled 0
# include a file with users custom iptables rules
config include
-23
View File
@@ -4,17 +4,6 @@ START=19
USE_PROCD=1
QUIET=""
LOCK_FILE="/var/lock/procd_firewall.lock"
set_lock() {
exec 1000>"$LOCK_FILE"
flock -xn 1000
}
unset_lock() {
flock -u 1000
rm -rf "$LOCK_FILE"
}
validate_firewall_redirect()
{
uci_validate_section firewall redirect "${1}" \
@@ -41,39 +30,27 @@ validate_firewall_rule()
service_triggers() {
procd_add_reload_trigger firewall
set_lock
procd_open_validate
validate_firewall_redirect
validate_firewall_rule
procd_close_validate
unset_lock
}
restart() {
unset_lock
set_lock
fw3 restart
unset_lock
}
start_service() {
set_lock
fw3 ${QUIET} start
unset_lock
}
stop_service() {
fw3 flush
unset_lock
}
reload_service() {
unset_lock
set_lock
reload_config firewall
fw3 reload
unset_lock
}
boot() {
File renamed without changes.
@@ -1,6 +1,6 @@
--- a/defaults.c
+++ b/defaults.c
@@ -49,7 +49,7 @@ const struct fw3_option fw3_flag_opts[]
@@ -49,7 +49,7 @@
FW3_OPT("synflood_rate", limit, defaults, syn_flood_rate),
FW3_OPT("synflood_burst", int, defaults, syn_flood_rate.burst),
@@ -9,9 +9,10 @@
FW3_OPT("tcp_syncookies", bool, defaults, tcp_syncookies),
FW3_OPT("tcp_ecn", int, defaults, tcp_ecn),
--- a/options.h
+++ b/options.h
@@ -98,6 +98,13 @@ enum fw3_reject_code
@@ -98,6 +98,13 @@
__FW3_REJECT_CODE_MAX
};
@@ -25,7 +26,7 @@
extern const char *fw3_flag_names[__FW3_FLAG_MAX];
@@ -297,7 +304,7 @@ struct fw3_defaults
@@ -297,7 +304,7 @@
enum fw3_reject_code any_reject_code;
bool syn_flood;
@@ -34,9 +35,10 @@
struct fw3_limit syn_flood_rate;
bool tcp_syncookies;
--- a/zones.c
+++ b/zones.c
@@ -757,7 +757,7 @@ print_zone_rule(struct fw3_ipt_handle *h
@@ -756,7 +756,7 @@
r = fw3_ipt_rule_new(handle);
fw3_ipt_rule_src_dest(r, msrc, mdest);
/*FIXME: Workaround for FULLCONE-NAT*/
@@ -45,7 +47,7 @@
{
warn("%s will enable FULLCONE-NAT", zone->name);
fw3_ipt_rule_target(r, "FULLCONENAT");
@@ -767,6 +767,12 @@ print_zone_rule(struct fw3_ipt_handle *h
@@ -766,6 +766,12 @@
fw3_ipt_rule_target(r, "FULLCONENAT");
fw3_ipt_rule_append(r, "zone_%s_prerouting", zone->name);
}
+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy-pro
PKG_VERSION:=28.9.1.14
PKG_RELEASE:=8
PKG_RELEASE:=9
# Only files the package actually ships belong here. The previous list named
# four paths that exist nowhere in the tree - certs/ and ruleset/ are created at
@@ -26,6 +26,15 @@ config homeproxy-pro 'config'
option main_udp_node 'same'
option dns_server '8.8.8.8'
option china_dns_server '223.5.5.5'
# Bootstrap resolver for the DoH/DoT endpoint's own hostname. Without
# it `main-dns` borrows the WAN resolver, so the one lookup that decides
# where every proxied name goes is answered by the ISP: poisoning that
# single answer is enough to break the whole DNS split, and it is the
# only query that was still leaving through the ISP after the split
# itself was configured correctly. This is a conffile, so upgrading
# never rewrites an existing /etc/config/homeproxy-pro - the value below
# only reaches fresh installs.
list bootstrap_dns '223.5.5.5'
option routing_mode 'bypass_mainland_china'
option routing_port 'common'
option proxy_mode 'redirect_tproxy'
@@ -2064,6 +2064,34 @@ fi
rm -rf "$SNAP_TMP"
trap - EXIT INT TERM
echo
echo "== guard 45: a fresh install bootstraps main-dns, an untouched config still does not =="
# E3. With no bootstrap_dns, `main-dns` (the DoH/DoT endpoint) falls back to
# the WAN resolver, so the ISP answers the one lookup that decides where every
# proxied name goes. The package default below closes that for fresh
# installs; this guard exists so neither half drifts silently:
# (a) an explicitly empty list must still emit NO bootstrap-dns server, so
# a user who clears the field gets the old behaviour back rather than a
# resolver the generator invents for them;
# (b) the package-shipped default is what makes the fix reach a new install
# at all - the field is read from the 'config' section, and the file is
# a conffile, so this line only lands on fresh installs.
if grep -q "if (isEmpty(ctx.bootstrap_dns))" "$SCRIPTS/generator/dns.uc"; then
pass "an empty bootstrap_dns still emits no bootstrap-dns server"
else
fail "append_bootstrap_dns no longer short-circuits on an empty bootstrap_dns - a"
fail "user who cleared the field would get a resolver they did not ask for"
fi
if grep -q "^ list bootstrap_dns '223.5.5.5'" "$ROOT/root/etc/config/homeproxy-pro"; then
pass "a fresh install ships a bootstrap_dns, so main-dns never asks the ISP"
else
fail "the package-shipped /etc/config/homeproxy-pro no longer ships a bootstrap_dns -"
fail "a new install would resolve its DoH endpoint's hostname through the WAN"
fail "resolver, which is the last query that still leaves via the ISP"
fi
echo
printf '%s checks, %s failures\n' "$checks" "$([ "$FAILED" = 0 ] && echo 0 || echo 'nonzero')"
if [ "$FAILED" != 0 ]; then
+1 -1
View File
@@ -10,7 +10,7 @@ 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.6.0
PKG_RELEASE:=11
PKG_RELEASE:=12
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
@@ -18,6 +18,10 @@
-- 归一为白名单取值后用 json_encode 输出,保证是合法的 JS 字符串字面量
if local_mode ~= "form" then local_mode = "text" end
local js_mode = util.json_encode(local_mode) or '"text"'
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
local node_mod = require("substore.node")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
%>
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
@@ -118,6 +122,7 @@ var FIELD_LABELS = {
"obfs": "Obfs",
"obfs-param": "Obfs Param",
"obfs-password": "Obfs Password",
"protocol": "Protocol",
"protocol-param": "Protocol Param",
"udp": "UDP",
"skip-cert-verify": "Skip Cert Verify",
@@ -140,8 +145,13 @@ var NODEFORM_I18N = {
"node": "<%:Node%>",
"defaultOpt": "<%:Default%>"
};
// 协议与字段清单由服务端注入(唯一来源:substore/node.lua 的 M.PROTOS /
// M.PROTO_FIELDS)。core.merge_form_node 用同一份清单决定哪些字段可以被表单
// 覆盖,两边必须严格一致,因此不在前端另抄一份。
var SUBSTORE_PROTOS = <%=js_protos%>;
var SUBSTORE_PROTO_FIELDS = <%=js_proto_fields%>;
</script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.2.0"></script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.6.1"></script>
<script>
// 切换导入模式:仅切换显示/禁用,不提交表单
function substore_local_switch(mode) {
@@ -10,6 +10,10 @@
local n = nodes[idx]
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
local js_node = n and ((util.json_encode(n) or "null"):gsub("</", "<\\/")) or "null"
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
local node_mod = require("substore.node")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
%>
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
@@ -61,6 +65,7 @@ var FIELD_LABELS = {
"obfs": "Obfs",
"obfs-param": "Obfs Param",
"obfs-password": "Obfs Password",
"protocol": "Protocol",
"protocol-param": "Protocol Param",
"udp": "UDP",
"skip-cert-verify": "Skip Cert Verify",
@@ -83,9 +88,14 @@ var NODEFORM_I18N = {
"node": "<%:Node%>",
"defaultOpt": "<%:Default%>"
};
// 协议与字段清单由服务端注入(唯一来源:substore/node.lua 的 M.PROTOS /
// M.PROTO_FIELDS)。core.merge_form_node 用同一份清单决定哪些字段可以被表单
// 覆盖,两边必须严格一致,因此不在前端另抄一份。
var SUBSTORE_PROTOS = <%=js_protos%>;
var SUBSTORE_PROTO_FIELDS = <%=js_proto_fields%>;
var NODE_EDIT_DATA = <%=js_node%>;
</script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.2.0"></script>
<script src="/luci-static/resources/substore/nodeform.js?v=2.6.1"></script>
<script>
function node_edit_prepare() {
var arr = collectNodes();
@@ -14,8 +14,12 @@
local group = http.formvalue("group") or q.group or ""
local sort_by = http.formvalue("sort") or q.sort or "name"
local sort_desc = http.formvalue("desc") == "1" or q.desc == "1"
-- id 会进入 <script> 里的 JS 字符串字面量,用 JSON 编码而不是 HTML 转义
local js_id = util.json_encode(id) or '""'
-- id 会进入 <script> 里的 JS 字符串字面量,用 JSON 编码而不是 HTML 转义。
-- 但只有 JSON 编码还不够:json_encode 不转义 "<",id 直接来自查询串,
-- 形如 "</script><script>alert(1)</script>" 的值会在 HTML 词法阶段就闭合
-- script 元素(与 JS 字符串上下文无关),剩下的部分被当作 HTML 执行。
-- 转义 "</" 即可:在 JS 字符串里 "\/" 就是 "/",而 HTML 不再看到 "</script"。
local js_id = (util.json_encode(id) or '""'):gsub("</", "<\\/")
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
for i, nn in ipairs(nodes) do nn.__idx = i end
@@ -386,11 +386,68 @@ local FORM_KEYS = {
username = true,
}
-- 合并表单节点到原节点:表单字段整体替换(可清空),非表单字段保留
-- FORM_KEYS 里同一个语义的多种写法。表单只渲染其中的规范写法,别名必须跟随规范
-- 写法一起清空:解析器产出的就是别名(parser_clash_yaml 写 skip_cert_verify、
-- parser_json_config 与 parser.lua 写 obfs_param / protocol_param),残留的别名
-- 仍会被输出模块读到,表现为「关不掉」——例如 build_tls 在 security 为 "none"
-- 时还会退回 n.tls,output_formats.surge_line 同理,于是用户把 vmess 的 TLS
-- 关掉之后 Surge 输出里依然是 tls=true。
local FORM_ALIASES = {
["obfs_param"] = "obfs-param",
["protocol_param"] = "protocol-param",
["skip_cert_verify"] = "skip-cert-verify",
["obfs_password"] = "obfs-password",
["private_key"] = "private-key",
["public_key"] = "public-key",
["peer-public-key"] = "public-key",
["peer_public_key"] = "public-key",
["preshared_key"] = "pre-shared-key",
["allowed_ips"] = "allowed-ips",
["persistent_keepalive"] = "persistent-keepalive",
["listen_port"] = "listen-port",
["network"] = "net",
-- normalize 把 tls 归一到 security,两者是同一语义的两种写法
["tls"] = "security",
-- shadowsocks 用 method、vmess/ssr 用 cipher,parse_local 双向互为别名
["method"] = "cipher",
["cipher"] = "method",
}
-- 合并表单节点到原节点。
--
-- 只有「该协议的表单确实渲染过」的字段才允许被覆盖(含被清空):表单按
-- node.PROTO_FIELDS[proto] 渲染,未渲染的字段根本提交不上来,把它们一并清空
-- 就等于用空值覆盖原值 —— vmess 的 security、hysteria2/tuic 的 security、
-- WireGuard 的 dns 都是这样被静默抹掉的(详见 node.PROTO_FIELDS 的注释)。
-- 别名(FORM_ALIASES)跟随其规范写法一起清空,避免残留值「关不掉」。
--
-- 协议未知(不在 PROTO_FIELDS 里)时退回「清空全部表单字段」的旧行为:
-- 那时无从判断表单渲染了什么,清空虽然可能丢字段,但至少不会留下用户改不动的旧值。
function M.merge_form_node(orig, formnode)
local node_mod = require("substore.node")
local out = {}
for k, v in pairs(orig or {}) do out[k] = v end
for k in pairs(FORM_KEYS) do out[k] = nil end
local fields = type(formnode) == "table" and node_mod.PROTO_FIELDS[formnode.proto] or nil
local old_fields = type(orig) == "table" and node_mod.PROTO_FIELDS[orig.proto] or nil
-- 原协议未知(或原节点没有 proto)时不做「只清渲染字段」的裁剪:那种情况下
-- 无从判断原节点里哪些字段是本协议的,退回到清空全部表单字段的旧行为。
if fields and (orig == nil or orig.proto == nil or old_fields) then
local rendered = {}
for _, k in ipairs(fields) do rendered[k] = true end
-- 协议被改过时,旧协议的字段也要清掉(vmess 改成 trojan 不该留着 uuid)。
-- 协议没变时 old_fields == fields,是同一个集合,无副作用。
for _, k in ipairs(old_fields or {}) do rendered[k] = true end
for _, k in ipairs(node_mod.FORM_ALWAYS_FIELDS) do rendered[k] = true end
for k in pairs(FORM_KEYS) do
-- 先看字段本身是否被渲染;只有「别名」才回退到它的规范写法。
-- 顺序不能反:method 与 cipher 互为别名,若一律先查别名表,
-- shadowsocks 渲染的 method 会被判成「cipher 没渲染」而不清空。
local canon = rendered[k] and k or (FORM_ALIASES[k] or k)
if rendered[canon] then out[k] = nil end
end
else
for k in pairs(FORM_KEYS) do out[k] = nil end
end
for k, v in pairs(formnode or {}) do
if k ~= "type" then out[k] = v end
end
@@ -7,11 +7,55 @@ M.PROTOS = {
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
}
-- 每种协议在「表单导入 / 节点编辑」界面里渲染的字段集合,数组顺序即界面顺序。
--
-- 这里是**唯一**的字段清单:LuCI 页面把它渲染成 window.SUBSTORE_PROTO_FIELDS
-- 交给 nodeform.js 渲染表单,core.merge_form_node 用它决定「哪些字段可以被表单
-- 覆盖(含清空)」。两边共用一份数据不是洁癖,而是必须的 —— 表单没渲染的字段
-- 提交不上来,合并时若一并清空就等于用空值覆盖原值:
-- * vmess 的 TLS 层字段是 security,而表单原先渲染的是 tls,编辑一次就把
-- security 抹成 nil,normalize 再补成 "none",一个启用 TLS 的 vmess 节点
-- 静默变成明文;
-- * hysteria2 / tuic / hysteria 是 TLS-only 协议(parser.lua 补 security="tls"),
-- 表单不渲染 security,编辑一次就丢掉 TLS,sing-box 会因 C.ErrTLSRequired
-- 直接拒绝启动;
-- * WireGuard 的 dns、vmess 的 flow 同理。
-- 所以字段清单必须与表单渲染的字段严格一致,不能各维护一份。
M.PROTO_FIELDS = {
ssr = { "server", "port", "password", "cipher", "protocol", "obfs", "obfs-param", "protocol-param", "udp" },
-- vmess 的 TLS 开关是 security(与 vless 一致):节点模型里 TLS 层统一存于
-- 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" },
trojan = { "server", "port", "password", "sni", "net", "headerType", "path", "host", "udp", "skip-cert-verify" },
shadowsocks = { "server", "port", "password", "method", "headerType", "udp" },
hysteria2 = { "server", "port", "password", "sni", "obfs", "obfs-password", "skip-cert-verify" },
-- hysteria(v1):obfs 是普通字符串(不是 hysteria2 的 salamander),因此没有 obfs-password。
-- 字段集合以各输出模块实际消费的键为准(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" },
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" },
}
-- 表单里与协议无关、始终渲染的字段(nodeform.js 的 nodeTemplate 固定输出这三个)
M.FORM_ALWAYS_FIELDS = { "name", "group" }
-- 协议默认值,用于补全缺省字段
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" },
}
-- vmess 加密方式(cipher)白名单。sing-box 的 vmess.security、Xray 的
@@ -2,28 +2,19 @@
* 依赖页面在引入本文件前定义:
* var FIELD_LABELS = { "name": "...", "group": "...", ... }; // 字段标签(模板用 <%:...%> 渲染)
* var NODEFORM_I18N = { node: "...", defaultOpt: "..." }; // 界面文本(同上)
* var SUBSTORE_PROTOS = [...]; // 协议下拉框
* var SUBSTORE_PROTO_FIELDS = { proto: [...] }; // 各协议渲染的字段
* 提供:SUBSTORE_PROTOS / PROTO_FIELDS / renderFields / collectNodes / addNodeRow
*
* 协议与字段清单**不在本文件里**,由页面从 substore/node.lua 的 M.PROTOS /
* M.PROTO_FIELDS 渲染后注入。这不是洁癖:服务端的 core.merge_form_node 用同一份
* 清单决定「哪些字段可以被表单覆盖(含清空)」,两边各维护一份必然会漂移 ——
* 表单没渲染的字段提交不上来,而合并时一并清空就等于用空值覆盖原值(vmess 与
* hysteria2/tuic 的 TLS 层字段就是这样被静默抹掉的)。详见 node.PROTO_FIELDS 的注释。
*/
var SUBSTORE_PROTOS = window.SUBSTORE_PROTOS || [];
// 必须与 substore/node.lua 的 M.PROTOS 一致(规格 §25「本地表单协议能力」的协议集合)。
// 之前这里少了 hysteria / socks:node.lua、节点页筛选、规则 proto_filter 都认这两个协议,
// 唯独表单下拉框选不到,用户无法用「表单导入」录入它们。
var SUBSTORE_PROTOS = ["vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"];
var PROTO_FIELDS = {
ssr: ["server","port","password","cipher","protocol","obfs","obfs-param","protocol-param","udp"],
vmess: ["server","port","uuid","alterId","cipher","net","headerType","path","host","sni","tls","udp","skip-cert-verify"],
vless: ["server","port","uuid","security","flow","net","headerType","path","host","sni","udp","skip-cert-verify"],
trojan: ["server","port","password","sni","net","headerType","path","host","udp","skip-cert-verify"],
shadowsocks: ["server","port","password","method","headerType","udp"],
hysteria2: ["server","port","password","sni","obfs","obfs-password","skip-cert-verify"],
// hysteria(v1):obfs 是普通字符串(不是 hysteria2 的 salamander),因此没有 obfs-password。
// 字段集合以各输出模块实际消费的键为准(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"],
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"]
};
var PROTO_FIELDS = window.SUBSTORE_PROTO_FIELDS || {};
// 枚举字段用下拉框;键为 "协议.字段" 优先,退化为通用 "字段"
// 选项集合以模型/输出模块实际支持的值为准(见 output_singbox.build_transport / output_uri)
@@ -53,5 +53,63 @@ local m5 = core.merge_form_node(orig, nil)
check("nil form keeps non-form fields", m5.raw == "vmess://xxx")
check("nil form clears form fields", m5.name == nil)
-- ---------- 表单没渲染的字段不得被清空 ----------
--
-- 表单按 node.PROTO_FIELDS[proto] 渲染,没渲染的字段提交不上来。原先
-- merge_form_node 一律清空全部表单字段,等于用空值覆盖原值:vmess 与
-- hysteria2/tuic 的 TLS 层字段(security)就是这样被静默抹掉的 —— 前者
-- 变成明文,后者让 sing-box 因 C.ErrTLSRequired 拒绝启动。
local parser = require("substore.parser")
local util = require("substore.util")
local node = require("substore.node")
-- 模拟一次真实的表单提交:只提交本协议渲染出来的字段,再过 parse_local
local function submit(proto, vals)
local t = {}
for _, k in ipairs(node.PROTO_FIELDS[proto] or {}) do
if vals[k] ~= nil and vals[k] ~= "" then t[k] = tostring(vals[k]) end
end
t.type = proto
return parser.parse_local(util.json_encode({ t }), "form").nodes[1]
end
local vless_node = { proto = "vmess", name = "n", server = "1.1.1.1", port = 443, uuid = "u", security = "tls" }
local kept = core.merge_form_node(vless_node, submit("vmess", { name = "n", server = "1.1.1.1", port = 443, uuid = "u", security = "tls" }))
check("vmess security kept through form", kept.security == "tls")
-- 关掉 TLS 时,别名 tls 必须一起清掉,否则 build_tls / surge_line 会退回旧值
local off = core.merge_form_node(
{ proto = "vmess", name = "n", server = "1.1.1.1", port = 443, uuid = "u", security = "tls", tls = true },
submit("vmess", { name = "n", server = "1.1.1.1", port = 443, uuid = "u", security = "none" }))
check("vmess security off honoured", off.security == "none")
check("vmess tls alias cleared", off.tls == nil)
-- hysteria2 / tuic / hysteria 是 TLS-only,表单不渲染 security,不得被清掉
for _, p in ipairs({ "hysteria2", "hysteria", "tuic" }) do
local o = { proto = p, name = "n", server = "1.1.1.1", port = 443, password = "pw", security = "tls" }
local r = core.merge_form_node(o, submit(p, { name = "n", server = "1.1.1.1", port = 443, password = "pw" }))
check(p .. " security kept through form", r.security == "tls")
end
-- 未渲染的普通字段同样保留(WireGuard 的 dns、vmess 的 flow)
local wg = { proto = "wireguard", name = "w", server = "1.1.1.1", port = 51820, ["private-key"] = "pk", dns = "1.1.1.1" }
local wg2 = core.merge_form_node(wg, submit("wireguard", { name = "w", server = "1.1.1.1", port = 51820, ["private-key"] = "pk" }))
check("wireguard dns preserved", wg2.dns == "1.1.1.1")
local vm = { proto = "vmess", name = "n", server = "1.1.1.1", port = 443, uuid = "u", flow = "xtls-rprx-vision" }
local vm2 = core.merge_form_node(vm, submit("vmess", { name = "n", server = "1.1.1.1", port = 443, uuid = "u" }))
check("vmess flow preserved", vm2.flow == "xtls-rprx-vision")
-- 别名跟随规范名一起清空(解析器产出的是下划线写法)
local alias = core.merge_form_node(
{ proto = "vmess", name = "n", server = "1.1.1.1", port = 443, uuid = "u", ["skip-cert-verify"] = true, skip_cert_verify = true },
submit("vmess", { name = "n", server = "1.1.1.1", port = 443, uuid = "u" }))
check("skip-cert-verify cleared", alias["skip-cert-verify"] == nil)
check("skip_cert_verify alias cleared", alias.skip_cert_verify == nil)
-- 协议未知时退回旧行为(清空全部表单字段)
local unk = core.merge_form_node({ proto = "snell", name = "s", server = "1.1.1.1", port = 443, password = "p" },
{ proto = "snell", name = "s", server = "1.1.1.1", port = 443 })
check("unknown proto falls back to full wipe", unk.password == nil)
print(string.format("%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,124 @@
-- view_injection_test.lua — 视图层的注入与字段清单一致性检查
-- 用法:lua5.1 tests/view_injection_test.lua
--
-- 这些是静态源码检查(.htm 需要 LuCI 运行时才能渲染,lua5.1 跑不了模板),
-- 但检查的正是那类「只在浏览器里才暴露」的缺陷:
-- H2 注入到 <script> 里的值必须转义 "</":json_encode 不转义 "<",
-- 而 "</script" 会在 HTML 词法阶段就闭合脚本元素(与 JS 字符串上下文无关),
-- 查询串里的 id 可以直接构造出 "</script><script>alert(1)</script>"。
-- 字段清单必须由服务端注入:core.merge_form_node 用 node.PROTO_FIELDS 决定
-- 哪些字段可以被表单覆盖,前端若另抄一份就会漂移(vmess 的 TLS 字段
-- 丢失正是这么来的)。
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local node = require("substore.node")
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 VIEWS = {
"root/usr/lib/lua/luci/view/substore/nodes.htm",
"root/usr/lib/lua/luci/view/substore/node_edit.htm",
"root/usr/lib/lua/luci/view/substore/local_form.htm",
"root/usr/lib/lua/luci/view/substore/subscriptions.htm",
"root/usr/lib/lua/luci/view/substore/form.htm",
"root/usr/lib/lua/luci/view/substore/combo.htm",
"root/usr/lib/lua/luci/view/substore/output.htm",
}
-- ---------- H2:JSON 编码注入到 JS 的位置必须同时转义 "</" ----------
--
-- 逐个 json_encode 调用点检查:要么同一行/下一行出现 gsub("</", …),
-- 要么该值是白名单归一化后的固定取值(注释里标注了「白名单」)。
for _, path in ipairs(VIEWS) do
local src = util.read_file(path)
if src then
local lines = {}
for l in (src .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = l end
for i, l in ipairs(lines) do
-- 只关心真正注入到模板里的调用,跳过注释
if l:find("json_encode") and not l:match("^%s*%-%-") and not l:match("^%s*//") then
-- 转义可能写在下一行,白名单说明通常写在紧邻的注释里(上一两行)
local window = (lines[i - 2] or "") .. (lines[i - 1] or "") .. l .. (lines[i + 1] or "")
local escaped = window:find('gsub("</"', 1, true) ~= nil
-- 白名单归一化的取值("text"/"form")不含 "<",无需转义
local whitelisted = window:find("白名单", 1, true) ~= nil
check(path:match("[^/]+$") .. ":" .. i .. " json_encode escapes </",
escaped or whitelisted)
end
end
end
end
-- 反向验证:不转义时确实能闭合 script(说明上面的检查不是空转)
local payload = "</script><script>alert(1)</script>"
check("json_encode does not escape <", util.json_encode(payload):find("</script", 1, true) ~= nil)
check("gsub(</) removes the closing tag",
(util.json_encode(payload):gsub("</", "<\\/")):find("</script", 1, true) == nil)
-- ---------- 字段清单:唯一来源在 Lua,页面负责注入 ----------
local js = util.read_file("root/www/luci-static/resources/substore/nodeform.js")
check("nodeform.js readable", js ~= nil)
-- 注释里会举例说明注入方式("var PROTO_FIELDS = { proto: [...] }"),先剥掉注释再查
local js_code = js:gsub("/%*.-%*/", ""):gsub("//[^\n]*", "")
check("nodeform.js does not hardcode PROTO_FIELDS", js_code:find("PROTO_FIELDS = {") == nil)
check("nodeform.js does not hardcode SUBSTORE_PROTOS",
js_code:find('SUBSTORE_PROTOS = %[') == nil)
check("nodeform.js reads injected PROTO_FIELDS", js:find("window.SUBSTORE_PROTO_FIELDS") ~= nil)
check("nodeform.js reads injected SUBSTORE_PROTOS", js:find("window.SUBSTORE_PROTOS") ~= nil)
for _, path in ipairs({ "root/usr/lib/lua/luci/view/substore/node_edit.htm",
"root/usr/lib/lua/luci/view/substore/local_form.htm" }) do
local src = util.read_file(path) or ""
local name = path:match("[^/]+$")
check(name .. " injects SUBSTORE_PROTOS", src:find("var SUBSTORE_PROTOS = <%=js_protos%>", 1, true) ~= nil)
check(name .. " injects SUBSTORE_PROTO_FIELDS", src:find("var SUBSTORE_PROTO_FIELDS = <%=js_proto_fields%>", 1, true) ~= nil)
check(name .. " loads nodeform.js", src:find("nodeform.js", 1, true) ~= nil)
end
-- 每个协议都要有字段清单,否则表单渲染不出任何输入框
for _, p in ipairs(node.PROTOS) do
local f = node.PROTO_FIELDS[p]
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
end
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
local has = false
for _, k in ipairs(node.PROTO_FIELDS[p]) do if k == "security" then has = true end end
check(p .. " form renders security", has)
end
-- TLS-only 协议必须由 normalize 保证 security,而不是靠 URI 解析器各自补
for _, p in ipairs({ "hysteria2", "hysteria", "tuic", "trojan" }) do
check(p .. " normalize forces security=tls",
node.normalize({ proto = p, server = "h", port = 1 }).security == "tls")
end
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)