mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 04:35:13 +08:00
💐 Sync 2026-09-30 01:19:34
This commit is contained in:
1 parent
67427d9030
commit
a64d55f7c5
50 files changed
+2837
-589
No files matched your search
@@ -0,0 +1,67 @@
|
||||
#
|
||||
# Copyright (C) 2013-2016 OpenWrt.org
|
||||
# Copyright (C) 2016 LEDE project
|
||||
#
|
||||
# This is free software, licensed under the GNU General Public License v2.
|
||||
# See /LICENSE for more information.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=firewall
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL=$(PROJECT_GIT)/project/firewall3.git
|
||||
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
|
||||
|
||||
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 +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
|
||||
|
||||
define Package/firewall/description
|
||||
This package provides a config-compatible C implementation of the UCI firewall.
|
||||
endef
|
||||
|
||||
define Package/firewall/conffiles
|
||||
/etc/config/firewall
|
||||
/etc/firewall.user
|
||||
endef
|
||||
|
||||
CMAKE_OPTIONS += $(if $(CONFIG_IPV6),,-DDISABLE_IPV6=1)
|
||||
|
||||
define Package/firewall/install
|
||||
$(INSTALL_DIR) $(1)/sbin
|
||||
$(INSTALL_BIN) $(PKG_BUILD_DIR)/firewall3 $(1)/sbin/fw3
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/firewall.init $(1)/etc/init.d/firewall
|
||||
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
|
||||
$(INSTALL_CONF) ./files/firewall.hotplug $(1)/etc/hotplug.d/iface/20-firewall
|
||||
$(INSTALL_DIR) $(1)/etc/config/
|
||||
$(INSTALL_CONF) ./files/firewall.config $(1)/etc/config/firewall
|
||||
$(INSTALL_DIR) $(1)/etc/
|
||||
$(INSTALL_CONF) ./files/firewall.user $(1)/etc/firewall.user
|
||||
$(INSTALL_DIR) $(1)/usr/share/fw3
|
||||
$(INSTALL_CONF) $(PKG_BUILD_DIR)/helpers.conf $(1)/usr/share/fw3
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,firewall))
|
||||
@@ -0,0 +1,209 @@
|
||||
config defaults
|
||||
option synflood_protect 1
|
||||
option input REJECT
|
||||
option output ACCEPT
|
||||
option forward REJECT
|
||||
option flow_offloading 1
|
||||
option fullcone 1
|
||||
# Uncomment this line to disable ipv6 rules
|
||||
# option disable_ipv6 1
|
||||
|
||||
config zone
|
||||
option name lan
|
||||
list network 'lan'
|
||||
option input ACCEPT
|
||||
option output ACCEPT
|
||||
option forward ACCEPT
|
||||
|
||||
config zone
|
||||
option name wan
|
||||
list network 'wan'
|
||||
list network 'wan6'
|
||||
option input REJECT
|
||||
option output ACCEPT
|
||||
option forward REJECT
|
||||
option masq 1
|
||||
option mtu_fix 1
|
||||
|
||||
config forwarding
|
||||
option src lan
|
||||
option dest wan
|
||||
|
||||
# We need to accept udp packets on port 68,
|
||||
# see https://dev.openwrt.org/ticket/4108
|
||||
config rule
|
||||
option name Allow-DHCP-Renew
|
||||
option src wan
|
||||
option proto udp
|
||||
option dest_port 68
|
||||
option target ACCEPT
|
||||
option family ipv4
|
||||
|
||||
# Allow IPv4 ping
|
||||
config rule
|
||||
option name Allow-Ping
|
||||
option src wan
|
||||
option proto icmp
|
||||
option icmp_type echo-request
|
||||
option family ipv4
|
||||
option target ACCEPT
|
||||
|
||||
config rule
|
||||
option name Allow-IGMP
|
||||
option src wan
|
||||
option proto igmp
|
||||
option family ipv4
|
||||
option target ACCEPT
|
||||
|
||||
# Allow DHCPv6 replies
|
||||
# see https://github.com/openwrt/openwrt/issues/5066
|
||||
config rule
|
||||
option name Allow-DHCPv6
|
||||
option src wan
|
||||
option proto udp
|
||||
option dest_ip fe80::/10
|
||||
option dest_port 546
|
||||
option family ipv6
|
||||
option target ACCEPT
|
||||
|
||||
config rule
|
||||
option name Allow-MLD
|
||||
option src wan
|
||||
option proto icmp
|
||||
option src_ip fe80::/10
|
||||
list icmp_type '130/0'
|
||||
list icmp_type '131/0'
|
||||
list icmp_type '132/0'
|
||||
list icmp_type '143/0'
|
||||
option family ipv6
|
||||
option target ACCEPT
|
||||
|
||||
# Allow essential incoming IPv6 ICMP traffic
|
||||
config rule
|
||||
option name Allow-ICMPv6-Input
|
||||
option src wan
|
||||
option proto icmp
|
||||
list icmp_type echo-request
|
||||
list icmp_type echo-reply
|
||||
list icmp_type destination-unreachable
|
||||
list icmp_type packet-too-big
|
||||
list icmp_type time-exceeded
|
||||
list icmp_type bad-header
|
||||
list icmp_type unknown-header-type
|
||||
list icmp_type router-solicitation
|
||||
list icmp_type neighbour-solicitation
|
||||
list icmp_type router-advertisement
|
||||
list icmp_type neighbour-advertisement
|
||||
option limit 1000/sec
|
||||
option family ipv6
|
||||
option target ACCEPT
|
||||
|
||||
# Allow essential forwarded IPv6 ICMP traffic
|
||||
config rule
|
||||
option name Allow-ICMPv6-Forward
|
||||
option src wan
|
||||
option dest *
|
||||
option proto icmp
|
||||
list icmp_type echo-request
|
||||
list icmp_type echo-reply
|
||||
list icmp_type destination-unreachable
|
||||
list icmp_type packet-too-big
|
||||
list icmp_type time-exceeded
|
||||
list icmp_type bad-header
|
||||
list icmp_type unknown-header-type
|
||||
option limit 1000/sec
|
||||
option family ipv6
|
||||
option target ACCEPT
|
||||
|
||||
config rule
|
||||
option name Allow-IPSec-ESP
|
||||
option src wan
|
||||
option dest lan
|
||||
option proto esp
|
||||
option target ACCEPT
|
||||
|
||||
config rule
|
||||
option name Allow-ISAKMP
|
||||
option src wan
|
||||
option dest lan
|
||||
option dest_port 500
|
||||
option proto udp
|
||||
option target ACCEPT
|
||||
|
||||
# allow interoperability with traceroute classic
|
||||
# note that traceroute uses a fixed port range, and depends on getting
|
||||
# back ICMP Unreachables. if we're operating in DROP mode, it won't
|
||||
# work so we explicitly REJECT packets on these ports.
|
||||
config rule
|
||||
option name Support-UDP-Traceroute
|
||||
option src wan
|
||||
option dest_port 33434:33689
|
||||
option proto udp
|
||||
option family ipv4
|
||||
option target REJECT
|
||||
option enabled 0
|
||||
|
||||
# include a file with users custom iptables rules
|
||||
config include
|
||||
option path /etc/firewall.user
|
||||
|
||||
|
||||
### EXAMPLE CONFIG SECTIONS
|
||||
# do not allow a specific ip to access wan
|
||||
#config rule
|
||||
# option src lan
|
||||
# option src_ip 192.168.45.2
|
||||
# option dest wan
|
||||
# option proto tcp
|
||||
# option target REJECT
|
||||
|
||||
# block a specific mac on wan
|
||||
#config rule
|
||||
# option dest wan
|
||||
# option src_mac 00:11:22:33:44:66
|
||||
# option target REJECT
|
||||
|
||||
# block incoming ICMP traffic on a zone
|
||||
#config rule
|
||||
# option src lan
|
||||
# option proto ICMP
|
||||
# option target DROP
|
||||
|
||||
# port redirect port coming in on wan to lan
|
||||
#config redirect
|
||||
# option src wan
|
||||
# option src_dport 80
|
||||
# option dest lan
|
||||
# option dest_ip 192.168.16.235
|
||||
# option dest_port 80
|
||||
# option proto tcp
|
||||
|
||||
# port redirect of remapped ssh port (22001) on wan
|
||||
#config redirect
|
||||
# option src wan
|
||||
# option src_dport 22001
|
||||
# option dest lan
|
||||
# option dest_port 22
|
||||
# option proto tcp
|
||||
|
||||
### FULL CONFIG SECTIONS
|
||||
#config rule
|
||||
# option src lan
|
||||
# option src_ip 192.168.45.2
|
||||
# option src_mac 00:11:22:33:44:55
|
||||
# option src_port 80
|
||||
# option dest wan
|
||||
# option dest_ip 194.25.2.129
|
||||
# option dest_port 120
|
||||
# option proto tcp
|
||||
# option target REJECT
|
||||
|
||||
#config redirect
|
||||
# option src lan
|
||||
# option src_ip 192.168.45.2
|
||||
# option src_mac 00:11:22:33:44:55
|
||||
# option src_port 1024
|
||||
# option src_dport 80
|
||||
# option dest_ip 194.25.2.129
|
||||
# option dest_port 120
|
||||
# option proto tcp
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ "$ACTION" = ifup -o "$ACTION" = ifupdate ] || exit 0
|
||||
[ "$ACTION" = ifupdate -a -z "$IFUPDATE_ADDRESSES" -a -z "$IFUPDATE_DATA" ] && exit 0
|
||||
|
||||
/etc/init.d/firewall enabled || exit 0
|
||||
|
||||
fw3 -q network "$INTERFACE" >/dev/null || exit 0
|
||||
|
||||
logger -t firewall "Reloading firewall due to $ACTION of $INTERFACE ($DEVICE)"
|
||||
fw3 -q reload
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=19
|
||||
USE_PROCD=1
|
||||
QUIET=""
|
||||
|
||||
validate_firewall_redirect()
|
||||
{
|
||||
uci_validate_section firewall redirect "${1}" \
|
||||
'proto:or(uinteger, string)' \
|
||||
'src:string' \
|
||||
'src_ip:cidr' \
|
||||
'src_dport:or(port, portrange)' \
|
||||
'dest:string' \
|
||||
'dest_ip:cidr' \
|
||||
'dest_port:or(port, portrange)' \
|
||||
'target:or("SNAT", "DNAT")'
|
||||
}
|
||||
|
||||
validate_firewall_rule()
|
||||
{
|
||||
uci_validate_section firewall rule "${1}" \
|
||||
'proto:or(uinteger, string)' \
|
||||
'src:string' \
|
||||
'dest:string' \
|
||||
'src_port:or(port, portrange)' \
|
||||
'dest_port:or(port, portrange)' \
|
||||
'target:string'
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger firewall
|
||||
|
||||
procd_open_validate
|
||||
validate_firewall_redirect
|
||||
validate_firewall_rule
|
||||
procd_close_validate
|
||||
}
|
||||
|
||||
restart() {
|
||||
fw3 restart
|
||||
}
|
||||
|
||||
start_service() {
|
||||
fw3 ${QUIET} start
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
fw3 flush
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
fw3 reload
|
||||
}
|
||||
|
||||
boot() {
|
||||
# Be silent on boot, firewall might be started by hotplug already,
|
||||
# so don't complain in syslog.
|
||||
QUIET=-q
|
||||
start
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# This file is interpreted as shell script.
|
||||
# Put your custom iptables rules here, they will
|
||||
# be executed with each firewall (re-)start.
|
||||
|
||||
# Internal uci firewall chains are flushed and recreated on reload, so
|
||||
# put custom rules into the root chains e.g. INPUT or FORWARD or into the
|
||||
# special user chains, e.g. input_wan_rule or postrouting_lan_rule.
|
||||
@@ -0,0 +1,62 @@
|
||||
--- a/defaults.c
|
||||
+++ b/defaults.c
|
||||
@@ -49,7 +49,7 @@
|
||||
FW3_OPT("synflood_rate", limit, defaults, syn_flood_rate),
|
||||
FW3_OPT("synflood_burst", int, defaults, syn_flood_rate.burst),
|
||||
|
||||
- FW3_OPT("fullcone", bool, defaults, fullcone),
|
||||
+ FW3_OPT("fullcone", int, defaults, fullcone),
|
||||
|
||||
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 @@
|
||||
__FW3_REJECT_CODE_MAX
|
||||
};
|
||||
|
||||
+enum fullcone_code
|
||||
+{
|
||||
+ FULLCONE_DISABLED = 0,
|
||||
+ FULLCONE_CHION = 1,
|
||||
+ FULLCONE_BCM = 2,
|
||||
+};
|
||||
+
|
||||
extern const char *fw3_flag_names[__FW3_FLAG_MAX];
|
||||
|
||||
|
||||
@@ -297,7 +304,7 @@
|
||||
enum fw3_reject_code any_reject_code;
|
||||
|
||||
bool syn_flood;
|
||||
- bool fullcone;
|
||||
+ int fullcone;
|
||||
struct fw3_limit syn_flood_rate;
|
||||
|
||||
bool tcp_syncookies;
|
||||
|
||||
--- a/zones.c
|
||||
+++ b/zones.c
|
||||
@@ -756,7 +756,7 @@
|
||||
r = fw3_ipt_rule_new(handle);
|
||||
fw3_ipt_rule_src_dest(r, msrc, mdest);
|
||||
/*FIXME: Workaround for FULLCONE-NAT*/
|
||||
- if(defs->fullcone)
|
||||
+ if(defs->fullcone == FULLCONE_CHION)
|
||||
{
|
||||
warn("%s will enable FULLCONE-NAT", zone->name);
|
||||
fw3_ipt_rule_target(r, "FULLCONENAT");
|
||||
@@ -766,6 +766,12 @@
|
||||
fw3_ipt_rule_target(r, "FULLCONENAT");
|
||||
fw3_ipt_rule_append(r, "zone_%s_prerouting", zone->name);
|
||||
}
|
||||
+ else if (defs->fullcone == FULLCONE_BCM)
|
||||
+ {
|
||||
+ fw3_ipt_rule_target(r, "MASQUERADE");
|
||||
+ fw3_ipt_rule_extra(r, "--mode fullcone");
|
||||
+ fw3_ipt_rule_append(r, "zone_%s_postrouting", zone->name);
|
||||
+ }
|
||||
else
|
||||
{
|
||||
fw3_ipt_rule_target(r, "MASQUERADE");
|
||||
@@ -0,0 +1,63 @@
|
||||
index 85a3750..9fac9b1 100644
|
||||
--- a/defaults.c
|
||||
+++ b/defaults.c
|
||||
@@ -46,7 +46,9 @@ const struct fw3_option fw3_flag_opts[] = {
|
||||
FW3_OPT("synflood_protect", bool, defaults, syn_flood),
|
||||
FW3_OPT("synflood_rate", limit, defaults, syn_flood_rate),
|
||||
FW3_OPT("synflood_burst", int, defaults, syn_flood_rate.burst),
|
||||
-
|
||||
+
|
||||
+ FW3_OPT("fullcone", bool, defaults, fullcone),
|
||||
+
|
||||
FW3_OPT("tcp_syncookies", bool, defaults, tcp_syncookies),
|
||||
FW3_OPT("tcp_ecn", int, defaults, tcp_ecn),
|
||||
FW3_OPT("tcp_window_scaling", bool, defaults, tcp_window_scaling),
|
||||
diff --git a/options.h b/options.h
|
||||
index 6edd174..c02eb97 100644
|
||||
--- a/options.h
|
||||
+++ b/options.h
|
||||
@@ -267,6 +267,7 @@ struct fw3_defaults
|
||||
bool drop_invalid;
|
||||
|
||||
bool syn_flood;
|
||||
+ bool fullcone;
|
||||
struct fw3_limit syn_flood_rate;
|
||||
|
||||
bool tcp_syncookies;
|
||||
diff --git a/zones.c b/zones.c
|
||||
index 2aa7473..57eead0 100644
|
||||
--- a/zones.c
|
||||
+++ b/zones.c
|
||||
@@ -627,6 +627,7 @@ print_zone_rule(struct fw3_ipt_handle *h
|
||||
struct fw3_address *msrc;
|
||||
struct fw3_address *mdest;
|
||||
struct fw3_ipt_rule *r;
|
||||
+ struct fw3_defaults *defs = &state->defaults;
|
||||
|
||||
if (!fw3_is_family(zone, handle->family))
|
||||
return;
|
||||
@@ -712,8 +713,22 @@ print_zone_rule(struct fw3_ipt_handle *h
|
||||
{
|
||||
r = fw3_ipt_rule_new(handle);
|
||||
fw3_ipt_rule_src_dest(r, msrc, mdest);
|
||||
- fw3_ipt_rule_target(r, "MASQUERADE");
|
||||
- fw3_ipt_rule_append(r, "zone_%s_postrouting", zone->name);
|
||||
+ /*FIXME: Workaround for FULLCONE-NAT*/
|
||||
+ if(defs->fullcone)
|
||||
+ {
|
||||
+ warn("%s will enable FULLCONE-NAT", zone->name);
|
||||
+ fw3_ipt_rule_target(r, "FULLCONENAT");
|
||||
+ fw3_ipt_rule_append(r, "zone_%s_postrouting", zone->name);
|
||||
+ r = fw3_ipt_rule_new(handle);
|
||||
+ fw3_ipt_rule_src_dest(r, msrc, mdest);
|
||||
+ fw3_ipt_rule_target(r, "FULLCONENAT");
|
||||
+ fw3_ipt_rule_append(r, "zone_%s_prerouting", zone->name);
|
||||
+ }
|
||||
+ else
|
||||
+ {
|
||||
+ fw3_ipt_rule_target(r, "MASQUERADE");
|
||||
+ fw3_ipt_rule_append(r, "zone_%s_postrouting", zone->name);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#
|
||||
# Copyright (C) 2021 Jo-Philipp Wich <jo@mein.io>
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=firewall4
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL=$(PROJECT_GIT)/project/firewall4.git
|
||||
PKG_SOURCE_DATE:=2025-03-17
|
||||
PKG_SOURCE_VERSION:=b6e5157527d361f99ad52eaa6da273cb0f2dfd59
|
||||
PKG_MIRROR_HASH:=skip
|
||||
PKG_MAINTAINER:=Jo-Philipp Wich <jo@mein.io>
|
||||
PKG_LICENSE:=ISC
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/firewall4
|
||||
SECTION:=net
|
||||
CATEGORY:=Base system
|
||||
TITLE:=OpenWrt 4th gen firewall
|
||||
DEPENDS:= \
|
||||
+kmod-nft-core +kmod-nft-fib +kmod-nft-offload \
|
||||
+kmod-nft-nat +kmod-nft-fullcone \
|
||||
+nftables-json \
|
||||
+ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci \
|
||||
+iptables +ip6tables +kmod-nft-fullcone +kmod-nft-socket +kmod-nft-tproxy
|
||||
EXTRA_DEPENDS:=ucode (>=2022.03.22)
|
||||
PROVIDES:=uci-firewall
|
||||
DEFAULT_VARIANT:=1
|
||||
endef
|
||||
|
||||
define Package/firewall4/description
|
||||
This package provides an nftables-based implementation of the UCI firewall
|
||||
sharing the same configuration format.
|
||||
endef
|
||||
|
||||
define Package/firewall4/conffiles
|
||||
/etc/config/firewall
|
||||
/etc/nftables.d/
|
||||
/etc/firewall.user
|
||||
endef
|
||||
|
||||
define Package/firewall4/install
|
||||
$(CP) -a $(PKG_BUILD_DIR)/root/* $(1)/
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,firewall4))
|
||||
@@ -0,0 +1,216 @@
|
||||
From aa3b56e289fba7425e649a608c333622ffd9c367 Mon Sep 17 00:00:00 2001
|
||||
From: Syrone Wong <wong.syrone@gmail.com>
|
||||
Date: Sat, 9 Apr 2022 13:24:19 +0800
|
||||
Subject: [PATCH] firewall4: add fullcone support
|
||||
|
||||
fullcone is drop-in replacement of masq for non-udp traffic
|
||||
|
||||
add runtime fullcone rule check, disable it globally if fullcone expr is
|
||||
invalid
|
||||
|
||||
defaults.fullcone and defaults.fullcone6 are switches for IPv4 and IPv6
|
||||
respectively, most IPv6 traffic do NOT need this FullCone NAT functionality.
|
||||
|
||||
Renew: ZiMing Mo <msylgj@immortalwrt.org>
|
||||
---
|
||||
root/etc/config/firewall | 2 ++
|
||||
root/usr/share/firewall4/templates/ruleset.uc | 16 ++++++++++++++--
|
||||
.../firewall4/templates/zone-fullcone.uc | 4 ++++
|
||||
root/usr/share/ucode/fw4.uc | 69 ++++++++++++++++++-
|
||||
4 files changed, 89 insertions(+), 4 deletions(-)
|
||||
create mode 100644 root/usr/share/firewall4/templates/zone-fullcone.uc
|
||||
|
||||
--- a/root/etc/config/firewall
|
||||
+++ b/root/etc/config/firewall
|
||||
@@ -5,6 +5,10 @@ config defaults
|
||||
option forward REJECT
|
||||
# Uncomment this line to disable ipv6 rules
|
||||
# option disable_ipv6 1
|
||||
+ option flow_offloading 1
|
||||
+ option flow_offloading_hw 1
|
||||
+ option fullcone 1
|
||||
+ option fullcone6 0
|
||||
|
||||
config zone
|
||||
option name lan
|
||||
--- a/root/usr/share/firewall4/templates/ruleset.uc
|
||||
+++ b/root/usr/share/firewall4/templates/ruleset.uc
|
||||
@@ -327,6 +327,12 @@ table inet fw4 {
|
||||
{% for (let redirect in fw4.redirects(`dstnat_${zone.name}`)): %}
|
||||
{%+ include("redirect.uc", { fw4, zone, redirect }) %}
|
||||
{% endfor %}
|
||||
+{% if (zone.masq && fw4.default_option("fullcone")): %}
|
||||
+ {%+ include("zone-fullcone.uc", { fw4, zone, family: 4, direction: "dstnat" }) %}
|
||||
+{% endif %}
|
||||
+{% if (zone.masq6 && fw4.default_option("fullcone6")): %}
|
||||
+ {%+ include("zone-fullcone.uc", { fw4, zone, family: 6, direction: "dstnat" }) %}
|
||||
+{% endif %}
|
||||
{% fw4.includes('chain-append', `dstnat_${zone.name}`) %}
|
||||
}
|
||||
|
||||
@@ -337,20 +343,26 @@ table inet fw4 {
|
||||
{% for (let redirect in fw4.redirects(`srcnat_${zone.name}`)): %}
|
||||
{%+ include("redirect.uc", { fw4, zone, redirect }) %}
|
||||
{% endfor %}
|
||||
-{% if (zone.masq): %}
|
||||
+{% if (zone.masq && !fw4.default_option("fullcone")): %}
|
||||
{% for (let saddrs in zone.masq4_src_subnets): %}
|
||||
{% for (let daddrs in zone.masq4_dest_subnets): %}
|
||||
{%+ include("zone-masq.uc", { fw4, zone, family: 4, saddrs, daddrs }) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
-{% if (zone.masq6): %}
|
||||
+{% if (zone.masq6 && !fw4.default_option("fullcone6")): %}
|
||||
{% for (let saddrs in zone.masq6_src_subnets): %}
|
||||
{% for (let daddrs in zone.masq6_dest_subnets): %}
|
||||
{%+ include("zone-masq.uc", { fw4, zone, family: 6, saddrs, daddrs }) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
+{% if (zone.masq && fw4.default_option("fullcone")): %}
|
||||
+ {%+ include("zone-fullcone.uc", { fw4, zone, family: 4, direction: "srcnat" }) %}
|
||||
+{% endif %}
|
||||
+{% if (zone.masq6 && fw4.default_option("fullcone6")): %}
|
||||
+ {%+ include("zone-fullcone.uc", { fw4, zone, family: 6, direction: "srcnat" }) %}
|
||||
+{% endif %}
|
||||
{% fw4.includes('chain-append', `srcnat_${zone.name}`) %}
|
||||
}
|
||||
|
||||
--- /dev/null
|
||||
+++ b/root/usr/share/firewall4/templates/zone-fullcone.uc
|
||||
@@ -0,0 +1,4 @@
|
||||
+{# /usr/share/firewall4/templates/zone-fullcone.uc #}
|
||||
+ meta nfproto {{ fw4.nfproto(family) }} fullcone comment "!fw4: Handle {{
|
||||
+ zone.name
|
||||
+}} {{ fw4.nfproto(family, true) }} fullcone NAT {{ direction }} traffic"
|
||||
--- a/root/usr/share/ucode/fw4.uc
|
||||
+++ b/root/usr/share/ucode/fw4.uc
|
||||
@@ -1,3 +1,5 @@
|
||||
+// /usr/share/ucode/fw4.uc
|
||||
+
|
||||
const fs = require("fs");
|
||||
const uci = require("uci");
|
||||
const ubus = require("ubus");
|
||||
@@ -489,6 +491,25 @@ function nft_try_hw_offload(devices) {
|
||||
return (rc == 0);
|
||||
}
|
||||
|
||||
+function nft_try_fullcone() {
|
||||
+ let nft_test =
|
||||
+ 'add table inet fw4-fullcone-test; ' +
|
||||
+ 'add chain inet fw4-fullcone-test dstnat { ' +
|
||||
+ 'type nat hook prerouting priority -100; policy accept; ' +
|
||||
+ 'fullcone; ' +
|
||||
+ '}; ' +
|
||||
+ 'add chain inet fw4-fullcone-test srcnat { ' +
|
||||
+ 'type nat hook postrouting priority -100; policy accept; ' +
|
||||
+ 'fullcone; ' +
|
||||
+ '}; ';
|
||||
+ let cmd = sprintf("/usr/sbin/nft -c '%s' 2>/dev/null", replace(nft_test, "'", "'\\''"));
|
||||
+ let ok = system(cmd) == 0;
|
||||
+ if (!ok) {
|
||||
+ warn("nft_try_fullcone: cmd "+ cmd + "\n");
|
||||
+ }
|
||||
+ return ok;
|
||||
+}
|
||||
+
|
||||
|
||||
return {
|
||||
read_kernel_version: function() {
|
||||
@@ -855,6 +876,18 @@ return {
|
||||
warn(`[!] ${msg}\n`);
|
||||
},
|
||||
|
||||
+ myinfo: function(fmt, ...args) {
|
||||
+ if (getenv("QUIET"))
|
||||
+ return;
|
||||
+
|
||||
+ let msg = sprintf(fmt, ...args);
|
||||
+
|
||||
+ if (getenv("TTY"))
|
||||
+ warn(`\033[32m${msg}\033[m\n`);
|
||||
+ else
|
||||
+ warn(`[I] ${msg}\n`);
|
||||
+ },
|
||||
+
|
||||
get: function(sid, opt) {
|
||||
return this.cursor.get("firewall", sid, opt);
|
||||
},
|
||||
@@ -1036,6 +1069,21 @@ return {
|
||||
}
|
||||
},
|
||||
|
||||
+ myinfo_section: function(s, msg) {
|
||||
+ if (s[".name"]) {
|
||||
+ if (s.name)
|
||||
+ this.myinfo("Section %s (%s) %s", this.section_id(s[".name"]), s.name, msg);
|
||||
+ else
|
||||
+ this.myinfo("Section %s %s", this.section_id(s[".name"]), msg);
|
||||
+ }
|
||||
+ else {
|
||||
+ if (s.name)
|
||||
+ this.myinfo("ubus %s (%s) %s", s.type || "rule", s.name, msg);
|
||||
+ else
|
||||
+ this.myinfo("ubus %s %s", s.type || "rule", msg);
|
||||
+ }
|
||||
+ },
|
||||
+
|
||||
parse_policy: function(val) {
|
||||
return this.parse_enum(val, [
|
||||
"accept",
|
||||
@@ -1475,6 +1523,7 @@ return {
|
||||
"dnat",
|
||||
"snat",
|
||||
"masquerade",
|
||||
+ "fullcone",
|
||||
"accept",
|
||||
"reject",
|
||||
"drop"
|
||||
@@ -1946,6 +1995,8 @@ return {
|
||||
}
|
||||
|
||||
let defs = this.parse_options(data, {
|
||||
+ fullcone: [ "bool", "0" ],
|
||||
+ fullcone6: [ "bool", "0" ],
|
||||
input: [ "policy", "drop" ],
|
||||
output: [ "policy", "drop" ],
|
||||
forward: [ "policy", "drop" ],
|
||||
@@ -1980,6 +2031,11 @@ return {
|
||||
|
||||
delete defs.syn_flood;
|
||||
|
||||
+ if (!nft_try_fullcone()) {
|
||||
+ delete defs.fullcone;
|
||||
+ warn("nft_try_fullcone failed, disable fullcone globally\n");
|
||||
+ }
|
||||
+
|
||||
this.state.defaults = defs;
|
||||
},
|
||||
|
||||
@@ -2205,10 +2261,23 @@ return {
|
||||
zone.related_subnets = related_subnets;
|
||||
zone.related_physdevs = related_physdevs;
|
||||
|
||||
- if (zone.masq || zone.masq6)
|
||||
+ if (zone.masq) {
|
||||
zone.dflags.snat = true;
|
||||
+ if (this.state.defaults.fullcone) {
|
||||
+ zone.dflags.dnat = true;
|
||||
+ this.myinfo_section(data, "IPv4 fullcone enabled for zone '" + zone.name + "'");
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (zone.masq6) {
|
||||
+ zone.dflags.snat = true;
|
||||
+ if (this.state.defaults.fullcone6) {
|
||||
+ zone.dflags.dnat = true;
|
||||
+ this.myinfo_section(data, "IPv6 fullcone enabled for zone '" + zone.name + "'");
|
||||
+ }
|
||||
+ }
|
||||
|
||||
- if ((zone.auto_helper && !(zone.masq || zone.masq6)) || length(zone.helper)) {
|
||||
+ if ((zone.auto_helper && !(zone.masq || zone.masq6 || this.state.defaults.fullcone || this.state.defaults.fullcone6)) || length(zone.helper)) {
|
||||
zone.dflags.helper = true;
|
||||
|
||||
for (let helper in (length(zone.helper) ? zone.helper : this.state.helpers)) {
|
||||
@@ -23,7 +23,7 @@ return view.extend({
|
||||
render(fwuser) {
|
||||
return E([
|
||||
E('h2', _('Firewall - Custom Rules')),
|
||||
E('p', {}, _('Custom rules allow you to execute arbitrary iptables commands which are not otherwise covered by the firewall framework. The commands are executed after each firewall restart, right after the default ruleset has been loaded.')),
|
||||
E('p', {}, _('Custom rules allow you to define custom firewall rules which are not otherwise covered by the firewall framework. On firewall4 systems, these rules must use nftables syntax.'))
|
||||
E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25 }, [ fwuser != null ? fwuser : '' ]))
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -103,43 +103,6 @@ return view.extend({
|
||||
addTCPCCAOption();
|
||||
}
|
||||
|
||||
o = s.option(form.Flag, 'expose_wan', _('Expose WAN'), _('Danger! Proceed at your own risk.'));
|
||||
|
||||
o = s.option(form.Value, 'export', _('Ports to Expose'), _('Multiple ports can be, separated by spaces, format: 80 81 82'));
|
||||
o.depends('expose_wan', '1');
|
||||
o.validate = function(section_id, value) {
|
||||
if (value.match(/^(\d+\s*)+$/)) {
|
||||
return true;
|
||||
}
|
||||
return _('Please enter valid format.');
|
||||
};
|
||||
|
||||
o = s.option(form.ListValue, 'family', _('Restrict to address family'));
|
||||
o.modalonly = true;
|
||||
o.rmempty = true;
|
||||
o.depends('expose_wan', '1');
|
||||
o.value('', _('IPv4 and IPv6'));
|
||||
o.value('ipv4', _('IPv4 only'));
|
||||
o.value('ipv6', _('IPv6 only'));
|
||||
|
||||
o = s.option(form.ListValue, 'proto', _('Protocol'));
|
||||
o.modalonly = true;
|
||||
o.rmempty = true;
|
||||
o.default = 'tcp';
|
||||
o.depends('expose_wan', '1');
|
||||
o.value('tcp', _('TCP'));
|
||||
o.value('udp', _('UDP'));
|
||||
o.value('tudp', _('TCP+UDP'));
|
||||
|
||||
o = s.option(form.Flag, 'ex_ssh', _('Expose SSH'));
|
||||
o.depends('expose_wan', '1');
|
||||
o = s.option(form.Flag, 'ex_backend', _('Expose Backend'));
|
||||
o.depends('expose_wan', '1');
|
||||
o = s.option(form.Value, 'backend_port', _('Backend Port'), _('国内请使用除80,443外的端口'));
|
||||
o.depends('ex_backend', '1');
|
||||
o.rmempty = false;
|
||||
o.datatype = 'integer';
|
||||
|
||||
let p = [
|
||||
s.option(form.ListValue, 'input', _('Input')),
|
||||
s.option(form.ListValue, 'output', _('Output')),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
/homeproxy-pro
|
||||
/docs/
|
||||
__pycache__/
|
||||
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
|
||||
|
||||
PKG_NAME:=luci-app-homeproxy-pro
|
||||
PKG_VERSION:=28.9.1.14
|
||||
PKG_RELEASE:=6
|
||||
PKG_RELEASE:=7
|
||||
|
||||
# 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
|
||||
|
||||
+22
-2
@@ -122,7 +122,10 @@ function renderDnsCache(ctx) {
|
||||
s.tab('dns_cache', _('DNS Settings'));
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'dns_server', _('DNS server'),
|
||||
_('Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.'));
|
||||
_('Resolves every domain that is not on the China DNS path, through the selected proxy node. ' +
|
||||
'Pick an overseas service: a domestic resolver answers these queries with polluted or ' +
|
||||
'NXDOMAIN results, which is what makes a foreign site fail to resolve while the proxy itself works. ' +
|
||||
'Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('1.1.1.1', _('CloudFlare Public DNS (1.1.1.1)'));
|
||||
o.value('208.67.222.222', _('Cisco Public DNS (208.67.222.222)'));
|
||||
@@ -160,7 +163,10 @@ function renderDnsCache(ctx) {
|
||||
}
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
|
||||
_('The dns server for resolving China domains. Support UDP, TCP, DoH, DoQ, DoT.'));
|
||||
_('Resolves the domains on the China path, so it has to be a domestic service to get the ' +
|
||||
'correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case ' +
|
||||
'a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. ' +
|
||||
'Support UDP, TCP, DoH, DoQ, DoT.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||
o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)'));
|
||||
@@ -198,6 +204,20 @@ function renderDnsCache(ctx) {
|
||||
o.depends('routing_mode', 'bypass_mainland_china');
|
||||
o.rmempty = false;
|
||||
|
||||
/* The DNS server above is the DoH/DoT endpoint, and it is the one server
|
||||
* in the generated configuration whose address is a hostname: it has to
|
||||
* be resolved before it can answer anything. Left empty, that lookup
|
||||
* goes through the WAN resolver (the pre-1.14 behaviour); naming a
|
||||
* resolver here decouples it from the ISP/upstream one. Only one is
|
||||
* used - sing-box resolves a hostname through exactly one server. */
|
||||
o = s.taboption('dns_cache', form.DynamicList, 'bootstrap_dns', _('Bootstrap DNS server'),
|
||||
_('Resolver used to look up the DNS server\'s own hostname - only relevant when that address ' +
|
||||
'is a domain, as with a DoH/DoT endpoint. Leave empty to use the WAN resolver. ' +
|
||||
'Only the first entry is used.'));
|
||||
o.datatype = 'ipaddr';
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy-pro');
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
ss = o.subsection;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -385,6 +385,7 @@ export const Loader = {
|
||||
/* A2.1: scalar + list fields on the `config` section. */
|
||||
dns_server: opt(uci, 'main', 'dns_server'),
|
||||
china_dns_server: opt(uci, 'main', 'china_dns_server'),
|
||||
bootstrap_dns: opt(uci, 'main', 'bootstrap_dns') || [],
|
||||
log_level: opt(uci, 'main', 'log_level') || 'warn',
|
||||
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
|
||||
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
|
||||
|
||||
@@ -94,6 +94,26 @@ export function build_context(dm, env) {
|
||||
? env.wan_dns
|
||||
: ((routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5');
|
||||
|
||||
/* Bootstrap resolvers: the servers the proxy path uses to look up a DNS
|
||||
* server's *own* hostname. `main-dns` is the user's DoH/DoT endpoint
|
||||
* and the only server whose address may be a name, so this lookup has to
|
||||
* work before any configured resolver can answer.
|
||||
*
|
||||
* It used to be borrowed from default-dns (the WAN/ISP resolver), which
|
||||
* is the one resolver a polluted or unreachable WAN takes down; the
|
||||
* option lets the user name a resolver that still answers. Empty means
|
||||
* "keep using the WAN resolver", so an untouched configuration
|
||||
* generates exactly the same bytes as before. */
|
||||
const bootstrap_dns = [];
|
||||
if (type(dm.general.bootstrap_dns) === 'array') {
|
||||
for (let server in dm.general.bootstrap_dns)
|
||||
if (!isEmpty(server))
|
||||
push(bootstrap_dns, server);
|
||||
} else if (!isEmpty(dm.general.bootstrap_dns)) {
|
||||
/* UCI hands back a bare string for a list with a single value. */
|
||||
push(bootstrap_dns, dm.general.bootstrap_dns);
|
||||
}
|
||||
|
||||
/* dns/routing fields used by both sections */
|
||||
const dns_optimistic_cache = (dm.dns.settings || {}).optimistic_cache || '0';
|
||||
const dns_optimistic_timeout = (dm.dns.settings || {}).optimistic_timeout;
|
||||
@@ -119,7 +139,7 @@ export function build_context(dm, env) {
|
||||
tcpip_stack, endpoint_independent_nat,
|
||||
udp_timeout, udp_mapping, udp_filtering, udp_nat_max,
|
||||
tun_dns_mode, tun_dns_address,
|
||||
wan_dns, dns_optimistic_cache, dns_optimistic_timeout,
|
||||
wan_dns, bootstrap_dns, dns_optimistic_cache, dns_optimistic_timeout,
|
||||
dns_query_timeout, dns_store_dns,
|
||||
default_interface: (dm.access_control.control || {}).bind_interface,
|
||||
|
||||
|
||||
@@ -96,11 +96,17 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
if (isEmpty(ctx.main_node))
|
||||
return;
|
||||
|
||||
/* The user's DoH/DoT endpoint is the one server in this block whose
|
||||
* address is a hostname, so it is the one that needs the bootstrap
|
||||
* resolver; without a configured one it keeps resolving through
|
||||
* default-dns (the WAN resolver), exactly as before. */
|
||||
const main_resolver = isEmpty(ctx.bootstrap_dns) ? 'default-dns' : 'bootstrap-dns';
|
||||
|
||||
/* Main DNS */
|
||||
push(config.dns.servers, {
|
||||
tag: 'main-dns',
|
||||
domain_resolver: {
|
||||
server: 'default-dns',
|
||||
server: main_resolver,
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
|
||||
},
|
||||
detour: 'main-out',
|
||||
@@ -115,6 +121,21 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
server: (ctx.routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
|
||||
});
|
||||
|
||||
/* Everything on the proxy list resolves through the proxy path, in
|
||||
* every proxy mode - not just bypass_mainland_china. The list is the
|
||||
* user's "this domain must not be answered by the domestic resolver"
|
||||
* escape hatch (Google Play's connect.googleapis.cn and friends), and
|
||||
* the routing half of it (route.uc pushes the same tag to main-out in
|
||||
* all of these modes) was already mode-agnostic. It has to sit after
|
||||
* direct-domain so that a suffix present in both lists keeps the
|
||||
* bypass-mode precedence and stays predictable. */
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'proxy-domain',
|
||||
action: 'route',
|
||||
server: 'main-dns'
|
||||
});
|
||||
|
||||
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
|
||||
push(config.dns.rules, {
|
||||
query_type: [64, 65],
|
||||
@@ -147,13 +168,6 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
server: 'default-dns'
|
||||
});
|
||||
|
||||
if (length(ctx.proxy_domain_list))
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'proxy-domain',
|
||||
action: 'route',
|
||||
server: 'main-dns'
|
||||
});
|
||||
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'geosite-cn',
|
||||
action: 'route',
|
||||
@@ -466,6 +480,39 @@ function initDns(config, ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
/* Append the bootstrap resolver, if the user configured one.
|
||||
*
|
||||
* Every DNS server whose address is a *hostname* needs a domain_resolver
|
||||
* before sing-box will start it, and that lookup is the one step nothing
|
||||
* else can bootstrap: it has to work before any of the configured servers
|
||||
* can answer. `main-dns` is exactly that case - it is the user's DoH/DoT
|
||||
* endpoint - and it used to borrow default-dns (the WAN/ISP resolver) for
|
||||
* it, which is the single resolver a polluted or unreachable WAN takes down.
|
||||
*
|
||||
* The bootstrap server carries a plain IP, has no domain_resolver of its own
|
||||
* and is never referenced by a DNS rule: it exists only as the target of the
|
||||
* domain_resolver pointer, so there is no cycle for sing-box to reject.
|
||||
* Nothing is emitted when the list is empty ("resolve through the WAN
|
||||
* resolver"), which keeps an untouched configuration byte-identical.
|
||||
*
|
||||
* Only the first entry is used. sing-box resolves a hostname through
|
||||
* exactly one transport (`DomainResolveOptions.Server` is a single tag), and
|
||||
* registering extra servers would not make them a fallback chain - it would
|
||||
* only leave unused servers in the config. The list form is kept because
|
||||
* the option is a DynamicList in the UI and a value with several entries
|
||||
* must not produce an unparseable address. */
|
||||
function append_bootstrap_dns(config, ctx) {
|
||||
if (isEmpty(ctx.bootstrap_dns))
|
||||
return;
|
||||
|
||||
push(config.dns.servers, {
|
||||
tag: 'bootstrap-dns',
|
||||
type: 'udp',
|
||||
server: ctx.bootstrap_dns[0],
|
||||
detour: ctx.self_mark ? 'direct-out' : null
|
||||
});
|
||||
}
|
||||
|
||||
/* Build the sing-box `dns` block. Mutates `config` and returns it for
|
||||
* chaining. Routing mode is determined by ctx.routing_mode: any non-
|
||||
* 'custom' mode follows the proxy path (the proxy-mode split inside the
|
||||
@@ -473,10 +520,14 @@ function initDns(config, ctx) {
|
||||
export function build_dns(config, dm, ctx) {
|
||||
initDns(config, ctx);
|
||||
|
||||
/* Custom mode has no main-dns and never resolves its own servers'
|
||||
* hostnames, so it has no bootstrap problem to solve. */
|
||||
if (ctx.routing_mode === 'custom')
|
||||
append_custom_dns(config, dm, ctx);
|
||||
else
|
||||
else {
|
||||
append_bootstrap_dns(config, ctx);
|
||||
append_proxy_dns(config, dm, ctx);
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -281,6 +281,7 @@ Two further host differences are bridged so the remaining checks still run:
|
||||
| `tests/ucode/test_parser_flatten.uc` | Round-trip invariant for the canonical-Node pipeline: `flatten(normalize(parse_uri(uri)))` must equal the parser's flat UCI dict field-for-field for every supported scheme. This is what stops the parser, the Loader's `PROTOCOL_OPTIONS` (now derived from `parser/mapping.uc`) and the Repository's flatten from drifting apart. |
|
||||
| `tests/ucode/test_subscription_repository.uc` | Integration test for `subscription/repository.uc`: stages a sandboxed UCI file with a user node, a kept subscription node, and a dropped subscription node, then runs `Repository.apply_nodes` and asserts (a) the user node is untouched, (b) the kept node has the new fields and no stale fields, (c) the dropped node is gone, (d) a brand-new node is added under `md5(group+label)`. PR-03 added the `apply_main_node_refs` paths (urltest prune + missing-target switch + reset-to-nil) and the `scrub_stale_urltest_refs` pass to the same test. |
|
||||
| `tests/ucode/test_generators.sh` | Runs `generate_client.uc`/`generate_server.uc` against the UCI fixtures in `tests/fixtures/generators/` inside a scratch directory (the `uci` cursor and `HP_DIR`/`RUN_DIR` are redirected) and validates each result with `sing-box check`. The `redirect` case is the product's default `proxy_mode` and asserts, from the emitted JSON, that every dialling outbound carries the runtime `routing_mark`. |
|
||||
| `tests/ucode/test_dns_proxy_list.sh` | The `proxy_list.txt` → `dns.rules` plumbing and the bootstrap resolver, neither of which `run_case()` in the generator suite can see (it truncates both resource lists before every case and the fixture has no `bootstrap_dns` option). Patches the client fixture to each of the four proxy modes with both lists populated, and asserts the DNS rule order (`direct-domain` < `proxy-domain` via `main-dns` < the SVCB/HTTPS reject, `geosite-cn` last when the mode emits it) plus the routing half's `"tag": "proxy-domain"` and `route.final: main-out`. Then, separately: with `bootstrap_dns` set, `main-dns` must resolve its own hostname through `bootstrap-dns` and that server must carry no `domain_resolver`; with it unset, no `bootstrap-dns` server may be emitted at all. |
|
||||
| `tests/toolchain/build-ucode-linux.sh` | Builds the local ucode toolchain described above; not part of `tests/run.sh`. |
|
||||
| `tests/toolchain/validate-data.sh` | Off-target stand-in for `/sbin/validate_data`, used through `HP_VALIDATE_DATA`; not part of `tests/run.sh`. |
|
||||
| `tests/ucode/test_ucode_grammar.sh` | Pins the ucode dialect: the toolchain must reject `export function ... }` without `;` and object/array destructuring, and must accept the constructs the package uses (`?.`/`??`, object spread, computed keys, template literals). |
|
||||
|
||||
@@ -3282,7 +3282,7 @@
|
||||
"kind": "Value",
|
||||
"name": "dns_server",
|
||||
"title": "DNS server",
|
||||
"description": "Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.",
|
||||
"description": "Resolves every domain that is not on the China DNS path, through the selected proxy node. Pick an overseas service: a domestic resolver answers these queries with polluted or NXDOMAIN results, which is what makes a foreign site fail to resolve while the proxy itself works. Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.",
|
||||
"depends": [
|
||||
{
|
||||
"routing_mode": "custom",
|
||||
@@ -3334,7 +3334,7 @@
|
||||
"kind": "Value",
|
||||
"name": "china_dns_server",
|
||||
"title": "China DNS server",
|
||||
"description": "The dns server for resolving China domains. Support UDP, TCP, DoH, DoQ, DoT.",
|
||||
"description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT.",
|
||||
"depends": [
|
||||
[
|
||||
"routing_mode",
|
||||
@@ -3387,6 +3387,24 @@
|
||||
"rmempty": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "DynamicList",
|
||||
"name": "bootstrap_dns",
|
||||
"title": "Bootstrap DNS server",
|
||||
"description": "Resolver used to look up the DNS server's own hostname - only relevant when that address is a domain, as with a DoH/DoT endpoint. Leave empty to use the WAN resolver. Only the first entry is used.",
|
||||
"depends": [
|
||||
{
|
||||
"routing_mode": "custom",
|
||||
"!reverse": true
|
||||
}
|
||||
],
|
||||
"values": [],
|
||||
"subsection": null,
|
||||
"props": {
|
||||
"datatype": "ipaddr",
|
||||
"modalonly": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "SectionValue",
|
||||
"name": "_dns_cache",
|
||||
|
||||
@@ -432,6 +432,13 @@ fi
|
||||
echo "== generator regression tests =="
|
||||
sh "$ROOT/tests/ucode/test_generators.sh" "$ROOT" "$WORK/generators" || FAILED=1
|
||||
|
||||
echo "== proxy-domain DNS routing and DNS bootstrap =="
|
||||
# The generator suite truncates the resource lists and has no bootstrap option,
|
||||
# so it can see neither proxy_list.txt -> dns.rules (per proxy mode: the
|
||||
# routing half was always mode-agnostic, the DNS half was not) nor the
|
||||
# bootstrap resolver main-dns uses for its own hostname.
|
||||
sh "$ROOT/tests/ucode/test_dns_proxy_list.sh" "$ROOT" "$WORK/dns-proxy-list" || FAILED=1
|
||||
|
||||
echo "== golden protocol snapshot =="
|
||||
sh "$ROOT/tests/ucode/test_golden_outbounds.sh" "$ROOT" "$WORK/golden" || FAILED=1
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Two properties of the proxy-mode DNS block that the generator regression
|
||||
# suite cannot see, because run_case() truncates the resource lists and uses a
|
||||
# fixture without the bootstrap option:
|
||||
#
|
||||
# 1. The proxy-domain resource list has to reach the DNS block in *every*
|
||||
# proxy routing mode, not only in bypass_mainland_china.
|
||||
#
|
||||
# proxy_list.txt is the user's escape hatch for a domain the preset lists
|
||||
# mis-classify - the canonical case is Google Play, whose
|
||||
# connect.googleapis.cn resolves through the domestic resolver and then
|
||||
# stays direct, so the store cannot update. The routing half
|
||||
# (route.uc pushes the proxy-domain rule_set to main-out) has always been
|
||||
# emitted in all the proxy modes, while the DNS half used to be nested
|
||||
# inside the bypass_mainland_china branch. In global / gfwlist /
|
||||
# proxy_mainland_china a listed domain therefore still resolved through
|
||||
# the domestic resolver; only the bypass mode did the right thing.
|
||||
#
|
||||
# 2. The bootstrap resolver: the DoH/DoT endpoint (main-dns) is the only
|
||||
# server whose address is a hostname, and it used to borrow default-dns
|
||||
# (the WAN/ISP resolver) for that lookup. When the user configures a
|
||||
# bootstrap resolver, main-dns must point at it instead, and that server
|
||||
# must stay at the bottom of the chain (no domain_resolver of its own,
|
||||
# no DNS rule routing to it) or the lookup depends on itself.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_dns_proxy_list.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-dns-proxy-list-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FIXTURE="$ROOT/tests/fixtures/generators/client.uci"
|
||||
DIRECT_DOMAIN="direct.example.cn"
|
||||
PROXY_DOMAIN="play.example.cn"
|
||||
BOOTSTRAP_DNS="223.5.5.5"
|
||||
FAILED=0
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# Stage the checkout the way test_generators.sh does: HP_DIR/RUN_DIR/UCICONFIG_DIR
|
||||
# are rewritten into the work dir and the fixture is copied in as the UCI
|
||||
# config. The extra bootstrap line is appended to the `config` section here
|
||||
# (the section's first named section), which is how the Loader reads it.
|
||||
stage_case() {
|
||||
dir="$1"
|
||||
label="$2"
|
||||
bootstrap="$3"
|
||||
fixture="$4"
|
||||
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/config" "$dir/run" "$dir/scripts/config" "$dir/scripts/generator" \
|
||||
"$dir/scripts/parser" "$dir/resources"
|
||||
|
||||
cp "$fixture" "$dir/config/homeproxy-pro"
|
||||
if [ -n "$bootstrap" ]; then
|
||||
sed -i "/^\toption routing_mode /i\\
|
||||
\toption bootstrap_dns '$bootstrap'
|
||||
" "$dir/config/homeproxy-pro"
|
||||
fi
|
||||
|
||||
# Both lists are populated on purpose. direct_list.txt is what makes the
|
||||
# direct-domain DNS rule appear at all, and the generator documents that
|
||||
# proxy-domain sits after it; with only one of the two the relative order
|
||||
# is untestable.
|
||||
printf '%s\n' "$DIRECT_DOMAIN" > "$dir/resources/direct_list.txt"
|
||||
printf '%s\n' "$PROXY_DOMAIN" > "$dir/resources/proxy_list.txt"
|
||||
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#^export const HP_DIR = '/etc/homeproxy-pro';#export const HP_DIR = '$dir';#" \
|
||||
-e "s#^export const RUN_DIR = '/var/run/homeproxy-pro';#export const RUN_DIR = '$dir/run';#" \
|
||||
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$dir/config';#" \
|
||||
-e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy-pro/scripts/homeproxy-pro.uc" > "$dir/scripts/homeproxy-pro.uc"
|
||||
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/loader.uc" "$dir/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/model.uc" "$dir/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/config/adapter.uc" "$dir/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/parser/"*.uc "$dir/scripts/parser/"
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/generator/"*.uc "$dir/scripts/generator/"
|
||||
cp "$ROOT/root/etc/homeproxy-pro/scripts/generate_client.uc" "$dir/scripts/generate_client.uc"
|
||||
|
||||
# macOS: `sing-box check` rejects the SO_MARK-based routing_mark on a
|
||||
# direct outbound (Linux-only), so the generator copy emits null there.
|
||||
# Same patch as test_generators.sh, applied to the same module.
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
sed -i '' "s#routing_mark: strToInt(.*self_mark)#routing_mark: null#" \
|
||||
"$dir/scripts/generator/client.uc"
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! ( cd "$dir/scripts" && ucode -L "$dir/scripts" generate_client.uc > "$dir/generate.out" 2> "$dir/generate.err" ); then
|
||||
echo "FAIL: $label: generate_client.uc exited non-zero"
|
||||
head -5 "$dir/generate.err"
|
||||
return 1
|
||||
fi
|
||||
|
||||
json="$dir/run/sing-box-c.json"
|
||||
if [ ! -s "$json" ]; then
|
||||
echo "FAIL: $label: no client configuration was generated"
|
||||
head -5 "$dir/generate.err"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! sing-box check --config "$json"; then
|
||||
echo "FAIL: $label: sing-box rejected the generated configuration"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# The rule_set assertions, shared by every case: the list reaches the config,
|
||||
# the DNS half resolves through main-dns ahead of the SVCB/HTTPS reject, and
|
||||
# direct-domain keeps its precedence.
|
||||
check_proxy_list() {
|
||||
label="$1"
|
||||
json="$2"
|
||||
|
||||
if ! grep -qF "$PROXY_DOMAIN" "$json"; then
|
||||
echo "FAIL: $label: proxy_list.txt never reached the generated config"
|
||||
return 1
|
||||
fi
|
||||
|
||||
dns_rule="$(grep -nF '"rule_set": "proxy-domain"' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -z "$dns_rule" ]; then
|
||||
echo "FAIL: $label: no proxy-domain rule_set in the generated config"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! sed -n "$((dns_rule + 1)),$((dns_rule + 4))p" "$json" | grep -qF '"main-dns"'; then
|
||||
echo "FAIL: $label: the proxy-domain DNS rule does not resolve through main-dns:"
|
||||
sed -n "$((dns_rule - 3)),$((dns_rule + 4))p" "$json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
reject_line="$(grep -nF '"query_type": [' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -n "$reject_line" ] && [ "$dns_rule" -gt "$reject_line" ]; then
|
||||
echo "FAIL: $label: the proxy-domain DNS rule is emitted after the SVCB/HTTPS reject"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A domain present in both lists keeps direct-domain first: the
|
||||
# relocation into the shared prefix must not have reversed the
|
||||
# precedence the bypass block used to have.
|
||||
direct_rule="$(grep -nF '"rule_set": "direct-domain"' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -z "$direct_rule" ] || [ "$direct_rule" -gt "$dns_rule" ]; then
|
||||
echo "FAIL: $label: direct-domain must be the first DNS rule (direct=${direct_rule:-none} proxy=$dns_rule)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# In the one mode that has both, the domestic lookup still comes last.
|
||||
case "$label" in
|
||||
bypass_mainland_china*)
|
||||
geosite_rule="$(grep -nF '"rule_set": "geosite-cn"' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -z "$geosite_rule" ] || [ "$geosite_rule" -lt "$dns_rule" ]; then
|
||||
echo "FAIL: $label: geosite-cn must come after proxy-domain (proxy=$dns_rule geosite=${geosite_rule:-none})"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 1. bootstrap resolver -------------------------------------------------
|
||||
|
||||
dir="$WORK/bootstrap"
|
||||
if stage_case "$dir" "bootstrap" "$BOOTSTRAP_DNS" "$FIXTURE"; then
|
||||
json="$dir/run/sing-box-c.json"
|
||||
|
||||
bootstrap_line="$(grep -nF '"tag": "bootstrap-dns"' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -z "$bootstrap_line" ]; then
|
||||
echo "FAIL: bootstrap: no bootstrap-dns server was emitted"
|
||||
FAILED=1
|
||||
elif ! sed -n "$bootstrap_line,$((bootstrap_line + 4))p" "$json" | grep -qF "\"$BOOTSTRAP_DNS\""; then
|
||||
echo "FAIL: bootstrap: bootstrap-dns does not carry $BOOTSTRAP_DNS"
|
||||
FAILED=1
|
||||
elif sed -n "$bootstrap_line,$((bootstrap_line + 4))p" "$json" | grep -qF '"domain_resolver"'; then
|
||||
echo "FAIL: bootstrap: the bootstrap resolver has a domain_resolver of its own"
|
||||
FAILED=1
|
||||
else
|
||||
# main-dns is the only hostname-addressed server, so it is the only
|
||||
# one allowed to point at the bootstrap resolver.
|
||||
main_line="$(grep -nF '"tag": "main-dns"' "$json" | head -1 | cut -d: -f1)"
|
||||
if [ -z "$main_line" ]; then
|
||||
echo "FAIL: bootstrap: no main-dns server was emitted"
|
||||
FAILED=1
|
||||
elif ! sed -n "$main_line,$((main_line + 6))p" "$json" | grep -qF '"server": "bootstrap-dns"'; then
|
||||
echo "FAIL: bootstrap: main-dns still resolves through the WAN resolver"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: bootstrap: main-dns resolves its own hostname through bootstrap-dns ($BOOTSTRAP_DNS)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- 2. the default (no bootstrap configured) ------------------------------
|
||||
|
||||
dir="$WORK/default"
|
||||
if stage_case "$dir" "default" "" "$FIXTURE"; then
|
||||
json="$dir/run/sing-box-c.json"
|
||||
if grep -qF '"tag": "bootstrap-dns"' "$json"; then
|
||||
echo "FAIL: default: an unconfigured bootstrap resolver was emitted"
|
||||
FAILED=1
|
||||
elif ! grep -qF '"server": "default-dns"' "$json"; then
|
||||
echo "FAIL: default: main-dns no longer resolves through the WAN resolver"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: default: no bootstrap server, main-dns still resolves through default-dns"
|
||||
fi
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- 3. proxy-domain in every proxy routing mode ---------------------------
|
||||
|
||||
# One case per proxy routing mode. Custom mode is deliberately absent: it
|
||||
# ignores both resource lists (context.uc gates their inputs on
|
||||
# `routing_mode !== 'custom'`) and has its own user-defined dns_rule sections.
|
||||
for mode in bypass_mainland_china global gfwlist proxy_mainland_china; do
|
||||
dir="$WORK/$mode"
|
||||
sed "s#^\(\s*option routing_mode \).*#\1'$mode'#" "$FIXTURE" > "$WORK/fixture-$mode.uci"
|
||||
if stage_case "$dir" "$mode" "" "$WORK/fixture-$mode.uci"; then
|
||||
if check_proxy_list "$mode" "$dir/run/sing-box-c.json"; then
|
||||
echo "PASS: $mode: direct-domain < proxy-domain via main-dns < SVCB/HTTPS reject"
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit $FAILED
|
||||
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall
|
||||
PKG_VERSION:=26.9.27
|
||||
PKG_RELEASE:=301
|
||||
PKG_RELEASE:=302
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
|
||||
@@ -357,7 +357,7 @@ if CHNLIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
|
||||
"add-tagchn-ip" .. ((NFTFLAG == "1") and (" " .. table.concat(sets, ",")) or "")
|
||||
}
|
||||
merge_array(config_lines, tmp_lines)
|
||||
log(string.format(" - 中国域名表(chnroute):%s", DNS_LOCAL or "默认"))
|
||||
log(string.format(" - 中国域名表(chnlist):%s", DNS_LOCAL or "默认"))
|
||||
end
|
||||
|
||||
--回中国模式
|
||||
@@ -374,7 +374,7 @@ if CHNLIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
|
||||
}
|
||||
if NO_IPV6_TRUST == "1" then table.insert(tmp_lines, "no-ipv6 tag:chn_proxy") end
|
||||
insert_array_after(config_lines, tmp_lines, "#--1")
|
||||
log(string.format(" - 中国域名表(chnroute):%s", DNS_TRUST or "默认"))
|
||||
log(string.format(" - 中国域名表(chnlist):%s", DNS_TRUST or "默认"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -552,4 +552,4 @@ if #config_lines > 0 then
|
||||
end
|
||||
end
|
||||
|
||||
log(" - ChinaDNS-NG已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
|
||||
log(" - ChinaDNS-NG已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理)分流失效!!!")
|
||||
@@ -333,19 +333,33 @@ function add_rule(var)
|
||||
pipe:close()
|
||||
end
|
||||
|
||||
local cache_text = ""
|
||||
local nodes_address_md5 = sys.exec("echo -n $(uci show passwall | grep '\\.address') | md5sum")
|
||||
local new_rules = sys.exec("echo -n $(find /usr/share/passwall/rules -type f | xargs md5sum)")
|
||||
new_rules = new_rules .. sys.exec("echo -n $(find /etc/passwall/rules -type f | xargs md5sum)")
|
||||
local new_text = TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. USE_DEFAULT_DNS .. CHINADNS_DNS .. USE_DIRECT_LIST .. USE_PROXY_LIST .. USE_BLOCK_LIST .. USE_GFW_LIST .. CHN_LIST .. DEFAULT_PROXY_MODE .. NO_PROXY_IPV6 .. nodes_address_md5 .. new_rules .. NFTFLAG
|
||||
if fs.access(CACHE_TEXT_FILE) then
|
||||
for line in io.lines(CACHE_TEXT_FILE) do
|
||||
cache_text = line
|
||||
local function get_dns_config_hash()
|
||||
local nodes_address_md5 = sys.exec([[uci show passwall | grep -E '\.(address|download_address|domain_resolver_dns|domain_resolver_dns_https)=' | cut -d "'" -f 2 | sort -u | md5sum | awk '{printf "%s", $1}']])
|
||||
local sub_url_md5 = sys.exec([[uci show passwall | grep -E '^passwall\.sub_[^.]+\.url=' | cut -d "'" -f 2 | sort -u | md5sum | awk '{printf "%s", $1}']])
|
||||
nodes_address_md5 = nodes_address_md5 .. sub_url_md5
|
||||
local new_rules = sys.exec([[
|
||||
for f in \
|
||||
/usr/share/passwall/rules/chnlist \
|
||||
/usr/share/passwall/rules/gfwlist \
|
||||
/etc/passwall/rules/direct_host \
|
||||
/etc/passwall/rules/proxy_host \
|
||||
/etc/passwall/rules/block_host
|
||||
do
|
||||
[ -f "$f" ] && md5sum "$f" | awk '{printf "%s", $1}'
|
||||
done
|
||||
]])
|
||||
local SHUNT_LIST = ""
|
||||
if IS_SHUNT_NODE then
|
||||
local t = api.uci_get_c(NODE)
|
||||
api.uci_foreach_c("shunt_rules", function(s)
|
||||
local _node_id = t[s[".name"]]
|
||||
if _node_id and _node_id ~= "_blackhole" and t["shunt_group"] == s.group then
|
||||
SHUNT_LIST = SHUNT_LIST .. (s.domain_list or "") .. _node_id
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
if cache_text ~= new_text then
|
||||
api.remove(CACHE_DNS_PATH .. "*")
|
||||
new_rules = new_rules .. sys.exec("printf '%s' " .. api.base64Encode(SHUNT_LIST) .. " | md5sum | awk '{printf \"%s\", $1}'")
|
||||
return TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. USE_DEFAULT_DNS .. CHINADNS_DNS .. USE_DIRECT_LIST .. USE_PROXY_LIST .. USE_BLOCK_LIST .. USE_GFW_LIST .. CHN_LIST .. DEFAULT_PROXY_MODE .. NO_PROXY_IPV6 .. nodes_address_md5 .. new_rules .. NFTFLAG
|
||||
end
|
||||
|
||||
local dnsmasq_default_dns
|
||||
@@ -375,6 +389,22 @@ function add_rule(var)
|
||||
local setflag_4= (NFTFLAG == "1") and "4#inet#passwall#" or ""
|
||||
local setflag_6= (NFTFLAG == "1") and "6#inet#passwall#" or ""
|
||||
|
||||
local new_text = ""
|
||||
if USE_CHINADNS_NG == "0" then
|
||||
local cache_text = ""
|
||||
if fs.access(CACHE_TEXT_FILE) then
|
||||
for line in io.lines(CACHE_TEXT_FILE) do
|
||||
cache_text = line
|
||||
end
|
||||
end
|
||||
new_text = get_dns_config_hash()
|
||||
if cache_text ~= new_text then
|
||||
api.remove(CACHE_DNS_PATH .. "*")
|
||||
end
|
||||
else
|
||||
api.remove(CACHE_DNS_PATH .. "*")
|
||||
end
|
||||
|
||||
if not fs.access(CACHE_DNS_PATH) then
|
||||
fs.mkdir(CACHE_DNS_PATH)
|
||||
|
||||
@@ -621,7 +651,7 @@ function add_rule(var)
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
log(string.format(" - 中国域名表(chnroute):%s", fwd_dns or "默认"))
|
||||
log(string.format(" - 中国域名表(chnlist):%s", fwd_dns or "默认"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -763,6 +793,8 @@ function add_rule(var)
|
||||
local f_out = io.open(CACHE_TEXT_FILE, "a")
|
||||
f_out:write(new_text)
|
||||
f_out:close()
|
||||
else
|
||||
log(" - 从缓存加载 Dnsmasq 域名分流规则。")
|
||||
end
|
||||
|
||||
if USE_CHINADNS_NG == "0" then
|
||||
@@ -811,7 +843,7 @@ function add_rule(var)
|
||||
end
|
||||
|
||||
if USE_CHINADNS_NG == "0" then
|
||||
log(" - PassWall必须依赖于Dnsmasq,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
|
||||
log(" - PassWall必须依赖于Dnsmasq,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理)分流失效!!!")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@ if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
|
||||
domain_rules_str = domain_rules_str .. (LOCAL_EXTEND_ARG ~= "" and " " .. LOCAL_EXTEND_ARG or "")
|
||||
table.insert(tmp_lines, domain_rules_str)
|
||||
insert_array_after(config_lines, tmp_lines, "#--2")
|
||||
log(string.format(" - 中国域名表(chnroute)使用分组:%s", LOCAL_GROUP or "默认"))
|
||||
log(string.format(" - 中国域名表(chnlist)使用分组:%s", LOCAL_GROUP or "默认"))
|
||||
end
|
||||
|
||||
--回中国模式
|
||||
@@ -542,7 +542,7 @@ if CHN_LIST ~= "0" and is_file_nonzero(RULES_PATH .. "/chnlist") then
|
||||
end
|
||||
table.insert(tmp_lines, domain_rules_str)
|
||||
insert_array_after(config_lines, tmp_lines, "#--2")
|
||||
log(string.format(" - 中国域名表(chnroute)使用分组:%s", REMOTE_GROUP or "默认"))
|
||||
log(string.format(" - 中国域名表(chnlist)使用分组:%s", REMOTE_GROUP or "默认"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -694,4 +694,4 @@ end
|
||||
|
||||
fs.symlink(TMP_CONF_FILE, SMARTDNS_CONF)
|
||||
sys.call(string.format('echo "conf-file %s" >> /etc/smartdns/custom.conf', string.gsub(SMARTDNS_CONF, "passwall", "passwall*")))
|
||||
log(" - SmartDNS已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理域名)分流失效!!!")
|
||||
log(" - SmartDNS已作为Dnsmasq上游,如果你自行配置了错误的DNS流程,将会导致域名(直连/代理)分流失效!!!")
|
||||
@@ -2,6 +2,120 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.5.1-r1] - 安全加固与数据保真修复
|
||||
|
||||
本轮为缺陷修复版本,重点是**命令注入 / SSRF 绕过 / 静默数据丢失**三类问题。
|
||||
所有修改均经代码或测试确认,未做任何推测性改动。
|
||||
|
||||
### 安全
|
||||
|
||||
- **修复命令注入(可被远程触发)**:此前拼 shell 命令行时使用 `string.format("%q")`
|
||||
做转义。`%q` 生成的是**双引号**字符串,而 `/bin/sh` 在双引号内**仍然执行**
|
||||
`$(...)` 与 `` `...` `` 命令替换——已实测确认 `format("%q", "$(touch /tmp/x)")`
|
||||
会真的创建文件。新增 `util.shq()` 改用单引号转义(`'` → `'\''`),并应用于所有
|
||||
拼接外部数据的位置:
|
||||
- `http.lua`:curl / wget 命令行、临时文件路径、`-x` 代理参数、错误输出文件
|
||||
- `http.lua`:`http_proxy` / `https_proxy` 环境变量赋值
|
||||
- `core.lua`:`logger -t luci-app-substore <msg>`(msg 含下载失败原因等外部内容)
|
||||
- `probe.lua`:探测命令
|
||||
- `util.lua`:`ensure_dir` 的 `mkdir -p`
|
||||
- **修复 SSRF 检查绕过(fail-open)**:`http.parse_url` 只按 `/` 截断主机部分,
|
||||
于是 `http://127.0.0.1?a=1` 的「主机」是 `127.0.0.1?a=1`——既非合法主机名也非
|
||||
数值型 IPv4,DNS 解析必然失败,而**解析失败是放行的**,`curl` 实际连的是回环地址
|
||||
(路由器上正是 LuCI 的 `:80`)。现按 RFC 3986 截断到第一个 `/` `?` `#`,
|
||||
并额外剥离 userinfo(`http://evil@127.0.0.1/` 同理可绕过)。
|
||||
- **修复 curl 后端接受 3xx**:`code:match("^[23]%d%d$")` 会把 3xx 当成成功。
|
||||
重定向应由 `-L` 跟随并由 `validate_redirect_chain` 逐跳校验,而不是靠 3xx 直接放行;
|
||||
现只接受 `2xx`。
|
||||
- **修复 wget 后端不检查退出码**:`os.execute` 的返回值此前被忽略,
|
||||
下载失败但残留部分内容时会当成成功解析。现要求退出码为 0。
|
||||
|
||||
### 数据保真(静默丢字段)
|
||||
|
||||
- **修复从 LuCI 表单编辑 WireGuard 节点会丢掉全部 AmneziaWG 参数**:
|
||||
`amnezia-wg-option` 在表单里是 JSON 文本框,提交上来是**字符串**,
|
||||
而 `output_wireguard_conf` / `output_clash_meta` / `output_uri` 三处都要求它是
|
||||
`table`——字符串被静默忽略。现于表单模式统一解码;JSON 非法时**明确报错**
|
||||
而不是留个字符串让它在导出时无声消失。
|
||||
- **修复表单里的数组字段被写成标量**:`allowed-ips` / `reserved` / `dns` 在统一模型中
|
||||
是数组(见 `.conf` 解析),但表单输入框只能给字符串,导出到 mihomo / sing-box
|
||||
时字段类型非法(这两个客户端的对应字段是列表)。现于表单模式拆分为数组;
|
||||
单值 `dns` 仍保持字符串,与 `.conf` 解析保持一致。
|
||||
- **修复 AmneziaWG 3.0 / 3.1 字段在 `.conf` 导入时被丢弃**:`.conf` 键名白名单只到
|
||||
1.5 版,`HeaderProtectionKey` / `ContentPaddingAddition` / `RekeyAfterTime` /
|
||||
`RekeyTimeout` / `RejectAfterTime` / `KeepaliveTimeout` / `MaxHandshakeAttempts` /
|
||||
`RandomTrailers` / `DisableCookies` 九个字段在导入时被丢掉,而同样的配置走
|
||||
Clash / JSON / URI 路径却能通过——同一份配置换个格式就丢参数。键名核对自
|
||||
amneziawg-tools `src/config.c`;布尔字段按 `parse_bool` 的规则只认 `on` / `off` / 数字。
|
||||
- **修复多个 `[Peer]` 的 `.conf` 只产出部分节点**:现每个 `[Peer]` 各生成一个节点
|
||||
并共享 `[Interface]` 设置;AmneziaWG 参数表**按节点复制**,避免多个节点共享
|
||||
同一个 table 而互相影响。
|
||||
- **修复节点改名规则中的 Lua 模式陷阱**(`node.lua`):
|
||||
- `-` 在 Lua 模式里是**惰性量词**,于是 `Node-(\d+)` 被解释成 `Nod` + `e-` + 数字,
|
||||
**永远匹配不上且不报错**。现于字符类外转义为 `%-`
|
||||
- `|` 在 Lua 模式里没有「或」语义,整个规则静默不匹配。现按**顶层** `|` 拆成多个
|
||||
候选依次替换;括号内与字符类 `[...]` 内的 `|` 不拆(拆开会得到残缺模式,比不拆更糟)
|
||||
- `\b` / `\B` 原被映射成 `%b`——那是 Lua 的「成对匹配」模式,语义完全不同。
|
||||
现按无操作处理(Lua 模式无词边界)
|
||||
- 模板改名的替换值现在转义 `%`,否则 `[50% OFF]` 这类名字会产生 NUL 字节
|
||||
- **修复 Clash YAML 的 `type` 字段泄漏进节点**:`type` 是协议判别字段(已被映射为
|
||||
`proto`),原样拷进来会让 `output_uri` 把它当成 vmess 的 header type 写出
|
||||
(`"type":"vmess"`),生成客户端无法识别的 `vmess://` 链接。
|
||||
- **修复订阅流量信息无法清空**:`save_meta` 用 `pairs` 遍历补丁,而 `pairs` 永远
|
||||
不会给出 `nil` 值,调用方无法表达「把这个字段删掉」,导致过期的流量/到期时间
|
||||
一直显示。新增 `core.CLEAR` 哨兵表达清除。
|
||||
|
||||
### 界面
|
||||
|
||||
- **修复节点保存失败无反馈**:`action_node_save` 此前只在成功分支做事,
|
||||
其余情况一律静默重定向——用户提交了坏数据却看到「已保存」的样子(违反 §18)。
|
||||
现所有失败路径(下标无效 / 内容为空 / 解析失败 / 写入失败)都经 `?err=` 回传,
|
||||
并在节点页渲染为可见的错误提示。
|
||||
- **修复存储型 XSS**:节点页的分组下拉与组合订阅页的名称用 `<%= %>` 原样输出
|
||||
(未转义),恶意订阅里的分组名/名称可注入脚本。现统一走 `luci.util.pcdata()`。
|
||||
|
||||
### Cron
|
||||
|
||||
- **退出码现在反映本轮结果**:`substore-cron.sh` 此前无论成败一律 `exit 0`,
|
||||
cron 的 `MAILTO` / 外部监控无法据此判断。现只要有订阅更新失败即返回非 0;
|
||||
找不到 lua 解释器(整条链路不可用)同样返回非 0,而不是静默成功。
|
||||
|
||||
### 解析器补齐
|
||||
|
||||
- `vless://` 补 `path` / `host` / `flow`;`vmess://`(新格式)补
|
||||
`host` / `path` / `headerType` / `fp` / `alpn`;`trojan://` 补
|
||||
`type`→`net` / `path` / `host` / `fp`;`hysteria2://` 补 `security` 与
|
||||
`skip-cert-verify`;经典 vmess JSON 的 `aid` 正确映射为 `alterId`
|
||||
- `b64u_decode` 增加 round-trip 校验:合法 base64url 解码后重新编码必然一致,
|
||||
明文则不一致——以此区分「这是 base64」和「这就是明文」,避免把明文
|
||||
误当 base64 解出乱码
|
||||
- `M.detect` 调整判定顺序:Surge 配置在 URI 兜底分支之前判定,避免被误判成 URI
|
||||
|
||||
### 测试
|
||||
|
||||
- 新增 `tests/security_fixes_test.lua`(37 项):`shq` 用真实 `sh -c` 验证命令替换
|
||||
不发生;`parse_url` 的 query / fragment / userinfo 剥离;`save_meta` 的 `CLEAR`
|
||||
语义;改名的 `-` / `|` / `%` 行为
|
||||
- `tests/cron_result_test.sh` 增加退出码断言(含缺解释器、无订阅文件两种边界),
|
||||
并验证该测试确实能捕获修复前的行为(对旧脚本跑会失败 3 项)
|
||||
- `tests/controller_local_test.lua` 增加 `action_node_save` 的 6 条失败路径断言,
|
||||
同样验证对旧控制器会失败 6 项
|
||||
- `tests/wireguard_conf_test.lua` 增加表单路径断言(AWG JSON 解码、数组归一、
|
||||
非法 JSON 报错、单值 DNS 保持字符串),并覆盖 AWG 3.0 / 3.1 字段导入与多 `[Peer]`
|
||||
- 全量:33 个 `tests/*_test.lua` 共 **1286** 项断言全部通过;shell 测试 11 项通过
|
||||
|
||||
### 已知限制(未在本轮修复)
|
||||
|
||||
- LuCI 里 `amnezia-wg-option` 仍是**单个 JSON 文本框**,没有逐字段的表单控件。
|
||||
数据已能正确往返(见上),但录入体验不佳。逐字段化属于新功能,按版本约定
|
||||
应进入 minor 版本,故不在 2.5.1 中改动。
|
||||
- wget 后端的下载体积上限仍是**下载后**判断(busybox wget 没有「下载前限流」的选项)。
|
||||
- 括号内的「或」`(a|b)` 在改名规则中不支持(Lua 模式无此语义,按字面处理)。
|
||||
|
||||
### 版本
|
||||
|
||||
- 版本号 2.5.0-r1 → 2.5.1-r1;README.md / README.en.md / docs/INSTALL.md 同步
|
||||
|
||||
## [2.5.0-r1] - 订阅可靠性、错误反馈与 WireGuard 去重修复
|
||||
|
||||
- **修复严重缺陷(订阅更新可能「看起来成功其实失败」)**:`core.sync` 失败时是
|
||||
|
||||
@@ -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.5.0
|
||||
PKG_RELEASE:=9
|
||||
PKG_VERSION:=2.5.1
|
||||
PKG_RELEASE:=10
|
||||
|
||||
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
|
||||
|
||||
|
||||
@@ -93,18 +93,18 @@ 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.5.0-r1`).
|
||||
> [Makefile](Makefile) (currently `2.5.1-r1`).
|
||||
|
||||
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.5.0-r1.ipk
|
||||
opkg install luci-app-substore-2.5.1-r1.ipk
|
||||
```
|
||||
|
||||
apk (OpenWrt / ImmortalWrt 25.12+):
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.5.1-r1.apk
|
||||
```
|
||||
|
||||
Then open LuCI: **Services → Subscriptions**.
|
||||
|
||||
@@ -80,18 +80,18 @@
|
||||
## 安装
|
||||
|
||||
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
|
||||
> (当前 `2.5.0-r1`)。
|
||||
> (当前 `2.5.1-r1`)。
|
||||
|
||||
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.5.0-r1.ipk
|
||||
opkg install luci-app-substore-2.5.1-r1.ipk
|
||||
```
|
||||
|
||||
apk(OpenWrt / ImmortalWrt 25.12+):
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.5.1-r1.apk
|
||||
```
|
||||
|
||||
然后在 LuCI 菜单打开:**服务 → 订阅**。
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
> 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.5.0-r1)。
|
||||
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.5.1-r1)。
|
||||
|
||||
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.5.0-r1.ipk
|
||||
opkg install luci-app-substore-2.5.1-r1.ipk
|
||||
```
|
||||
|
||||
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.5.1-r1.apk
|
||||
```
|
||||
|
||||
Refresh LuCI:
|
||||
|
||||
@@ -17,8 +17,10 @@ for c in lua5.1 lua; do
|
||||
done
|
||||
|
||||
if [ -z "$LUA_BIN" ]; then
|
||||
# 没有解释器 = 整个订阅更新链路不可用,属于必须暴露的故障,
|
||||
# 不能静默 exit 0 让 cron 看起来一切正常。
|
||||
logger -t luci-app-substore "cron: no lua interpreter found"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -f "$LIST_FILE" ] || exit 0
|
||||
@@ -61,4 +63,12 @@ printf '%s\n' "$OUT"
|
||||
if command -v logger >/dev/null 2>&1; then
|
||||
printf '%s\n' "$OUT" | logger -t luci-app-substore
|
||||
fi
|
||||
exit 0
|
||||
|
||||
# 退出码反映本轮结果:有订阅更新失败就返回非 0,让 cron 的 MAILTO / 外部监控
|
||||
# 能据此判断,而不是无论成败都 exit 0。日志行由上面 Lua 以 %d 格式打印,
|
||||
# 不存在前导零,故可直接按字面比较。
|
||||
FAILED=$(printf '%s\n' "$OUT" | sed -n 's/.*: [0-9][0-9]* ok, \([0-9][0-9]*\) failed.*/\1/p' | tail -1)
|
||||
case "$FAILED" in
|
||||
''|0) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@@ -201,13 +201,18 @@ function action_delete()
|
||||
end
|
||||
|
||||
-- 节点页返回链接:保留当前筛选参数
|
||||
local function back_to_nodes(http)
|
||||
-- 返回节点页。err 非空时把失败原因带到页面显示(§18:失败必须让用户看见,
|
||||
-- 不能「失败了却看起来像成功」)。与 back_to_list 同一套约定。
|
||||
local function back_to_nodes(http, err)
|
||||
local id = http.formvalue("id") or ""
|
||||
local qs = "?id=" .. luci.util.urlencode(id)
|
||||
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
|
||||
local v = http.formvalue(k)
|
||||
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
|
||||
end
|
||||
if err ~= nil and tostring(err) ~= "" then
|
||||
qs = qs .. "&err=" .. luci.util.urlencode(tostring(err))
|
||||
end
|
||||
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
|
||||
end
|
||||
|
||||
@@ -221,16 +226,29 @@ function action_node_save()
|
||||
local idx = tonumber(http.formvalue("idx") or "")
|
||||
local content = http.formvalue("content") or ""
|
||||
local nodes = core.read_nodes(id)
|
||||
if idx and nodes[idx] and content ~= "" then
|
||||
local res = parser.parse_local(content, "form")
|
||||
local newn = res and res.nodes and res.nodes[1]
|
||||
if newn then
|
||||
nodes[idx] = core.merge_form_node(nodes[idx], newn)
|
||||
if core.write_nodes(id, nodes) then
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
end
|
||||
-- §18:任何一条失败路径都必须把原因带回页面。
|
||||
-- 原来只在成功分支里做事,其余情况一律静默重定向,
|
||||
-- 用户提交了坏数据却看到「已保存」的样子。
|
||||
if not idx or not nodes[idx] then
|
||||
back_to_nodes(http, "节点不存在或下标无效")
|
||||
return
|
||||
end
|
||||
if content == "" then
|
||||
back_to_nodes(http, "提交内容为空")
|
||||
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 "表单数据解析失败")
|
||||
return
|
||||
end
|
||||
nodes[idx] = core.merge_form_node(nodes[idx], newn)
|
||||
if not core.write_nodes(id, nodes) then
|
||||
back_to_nodes(http, "写入节点数据失败")
|
||||
return
|
||||
end
|
||||
core.refresh_combos(id)
|
||||
end
|
||||
back_to_nodes(http)
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<% for _, x in ipairs(srcs) do %>
|
||||
<div style="margin:0.25em 0">
|
||||
<input type="checkbox" id="src_<%=x.id%>" name="src_<%=x.id%>" value="1" <%=sel[x.id] and "checked" or ""%>/>
|
||||
<label for="src_<%=x.id%>"><%=x.name%>(<%=x.node_count or 0%>)</label>
|
||||
<label for="src_<%=x.id%>"><%=luci.util.pcdata(x.name or "")%>(<%=x.node_count or 0%>)</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if #srcs == 0 then %>
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
nodes = node.sort(nodes, sort_by, sort_desc)
|
||||
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
|
||||
%>
|
||||
<%
|
||||
-- §18:控制器把失败原因放在 ?err= 上带回来,这里渲染成可见的提示。
|
||||
-- pcdata 转义:err 可能包含用户提交的内容,不能直接当 HTML 输出。
|
||||
local flash_err = http.formvalue("err")
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
|
||||
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2><%:Nodes%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
|
||||
<div style="margin:0.5em 0">
|
||||
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
|
||||
@@ -56,7 +66,7 @@
|
||||
<select name="group">
|
||||
<option value=""><%:All%></option>
|
||||
<% for _, g in ipairs(group_list) do %>
|
||||
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=g%></option>
|
||||
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=luci.util.pcdata(g)%></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.5.0"
|
||||
M.version = "2.5.1"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
@@ -171,13 +171,18 @@ function M.generate_link(token, target, opts)
|
||||
return content, ct, filename, nil
|
||||
end
|
||||
|
||||
-- save_meta 补丁里的「清除」哨兵值。
|
||||
-- Lua 的 pairs 永远不会产出值为 nil 的键,所以补丁表里写 `k = nil` 等于什么都没写,
|
||||
-- 调用方无法表达「把这个字段删掉」。需要清除时传 M.CLEAR,save_meta 会还原成 nil。
|
||||
M.CLEAR = setmetatable({}, { __tostring = function() return "substore.CLEAR" end })
|
||||
|
||||
function M.save_meta(id, patch)
|
||||
if not id_is_valid(id) then return false, "非法 ID" end
|
||||
local seq, items = load()
|
||||
local meta = items[id]
|
||||
if not meta then return false, "订阅不存在" end
|
||||
for k, v in pairs(patch or {}) do
|
||||
if v == nil then meta[k] = nil else meta[k] = v end
|
||||
if v == nil or v == M.CLEAR then meta[k] = nil else meta[k] = v end
|
||||
end
|
||||
return save(seq, items)
|
||||
end
|
||||
@@ -227,7 +232,7 @@ end
|
||||
|
||||
-- 下载并解析订阅,写入节点文件并更新状态。成功返回 node_count,失败返回 nil, err
|
||||
function M.sync(id)
|
||||
local log = function(msg) os.execute("logger -t luci-app-substore " .. string.format("%q", msg)) end
|
||||
local log = function(msg) os.execute("logger -t luci-app-substore " .. util.shq(msg)) end
|
||||
log("Sync start id="..tostring(id))
|
||||
local meta = M.get(id)
|
||||
if not meta then log("Sync fail: subscription not found"); return nil, "订阅不存在" end
|
||||
@@ -313,7 +318,12 @@ function M.sync(id)
|
||||
|
||||
local ok = M.save_meta(id, {
|
||||
node_count = #nodes, format = res.format, error = "", last_update = os.time(),
|
||||
upload = ui and ui.upload, download = ui and ui.download, total = ui and ui.total, expire = ui and ui.expire,
|
||||
-- 机场不再下发 subscription-userinfo 时必须把旧数值清掉,
|
||||
-- 否则列表页会一直显示早已过期的流量/到期时间。用 M.CLEAR 表达「清除」。
|
||||
upload = (ui and ui.upload) or M.CLEAR,
|
||||
download = (ui and ui.download) or M.CLEAR,
|
||||
total = (ui and ui.total) or M.CLEAR,
|
||||
expire = (ui and ui.expire) or M.CLEAR,
|
||||
})
|
||||
if not ok then return nil, "更新状态失败" end
|
||||
-- 源订阅更新后,刷新引用它的组合订阅
|
||||
|
||||
@@ -10,6 +10,55 @@ M.DEFAULT_TIMEOUT = 20
|
||||
M.MAX_REDIRECTS = 4
|
||||
|
||||
-- ---------- 私网 / 保留地址判断 ----------
|
||||
|
||||
-- 单段数值:十进制 / 八进制(前导 0)/ 十六进制(0x 前缀)
|
||||
-- 返回数值;不是数值写法返回 nil
|
||||
local function numeral(seg)
|
||||
if seg:match("^0[xX]%x+$") then return tonumber(seg:sub(3), 16) end
|
||||
if seg:match("^0%d+$") then return tonumber(seg:sub(2), 8) end
|
||||
if seg:match("^%d+$") then return tonumber(seg, 10) end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- 把 inet_aton 接受的各种数值型 IPv4 写法归一为点分十进制。
|
||||
-- curl / wget 都按 inet_aton 语义解析主机名,因此 SSRF 检查必须用同一套语义,
|
||||
-- 否则 "0177.0.0.1"(八进制 127.0.0.1)、"2130706433"(十进制)、"0x7f000001"(十六进制)、
|
||||
-- "127.1"(短写)会被 is_private_ipv4 当成公网地址而放行,实测这几种写法都能连到 127.0.0.1。
|
||||
-- 段数与点号数必须一致(拒绝 "1..2" 这类畸形写法)。
|
||||
-- 返回 "a.b.c.d";不是数值型 IPv4 时返回 nil。
|
||||
local function normalize_ipv4(host)
|
||||
local dots = select(2, host:gsub("%.", ""))
|
||||
local parts = {}
|
||||
for seg in host:gmatch("[^%.]+") do parts[#parts + 1] = seg end
|
||||
if #parts ~= dots + 1 or #parts > 4 then return nil end
|
||||
|
||||
local vals = {}
|
||||
for i, seg in ipairs(parts) do
|
||||
local v = numeral(seg)
|
||||
if not v then return nil end
|
||||
vals[i] = v
|
||||
end
|
||||
|
||||
-- inet_aton:前 k-1 段各 8 位,最后一段吃掉剩余位宽
|
||||
local k = #vals
|
||||
for i = 1, k - 1 do
|
||||
if vals[i] > 255 then return nil end
|
||||
end
|
||||
if vals[k] >= 2 ^ (32 - 8 * (k - 1)) then return nil end
|
||||
|
||||
local n = 0
|
||||
for i, v in ipairs(vals) do
|
||||
local shift = (i < k) and (32 - 8 * i) or 0
|
||||
n = n + v * 2 ^ shift
|
||||
end
|
||||
|
||||
local o1 = math.floor(n / 2 ^ 24) % 256
|
||||
local o2 = math.floor(n / 2 ^ 16) % 256
|
||||
local o3 = math.floor(n / 2 ^ 8) % 256
|
||||
local o4 = n % 256
|
||||
return string.format("%d.%d.%d.%d", o1, o2, o3, o4)
|
||||
end
|
||||
|
||||
local function is_private_ipv4(ip)
|
||||
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
if not a then return false end
|
||||
@@ -60,6 +109,18 @@ local function is_private(ip)
|
||||
return is_private_ipv4(ip)
|
||||
end
|
||||
|
||||
-- 粗校验:像不像一个 IPv6 字面量。
|
||||
-- 带 ":" 的主机并不都是 IPv6 —— parse_url 对 "127.0.0.1:" 这类「有冒号但端口为空」
|
||||
-- 的写法取不到端口,会把整个 "127.0.0.1:" 当主机名返回;它既不是合法域名,
|
||||
-- is_private_ipv6 也识别不出,于是被当成公网放行。实测 curl 会把它连到 127.0.0.1:80。
|
||||
-- 因此:含 ":" 但不像 IPv6 的一律拒绝(fail-closed)。
|
||||
local function looks_like_ipv6(h)
|
||||
if not h:match("^[%x:%.]+$") then return false end
|
||||
if select(2, h:gsub(":", "")) < 2 then return false end
|
||||
if h:find("::.*::") then return false end -- "::" 最多出现一次
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---------- URL 解析 ----------
|
||||
function M.parse_url(url)
|
||||
url = util.trim(url or "")
|
||||
@@ -67,7 +128,20 @@ function M.parse_url(url)
|
||||
if not scheme then return nil, "无效 URL" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||||
local hostport = rest:gsub("/.*$", "")
|
||||
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
|
||||
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
|
||||
-- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open),
|
||||
-- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。
|
||||
local hostport = rest:match("^[^/%?#]*") or ""
|
||||
-- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止,
|
||||
-- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1"
|
||||
-- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open),
|
||||
-- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。
|
||||
local at = hostport:find("@", 1, true)
|
||||
while at do
|
||||
hostport = hostport:sub(at + 1)
|
||||
at = hostport:find("@", 1, true)
|
||||
end
|
||||
local host, port
|
||||
if hostport:sub(1, 1) == "[" then
|
||||
local close = hostport:find("]", 1, true)
|
||||
@@ -143,15 +217,50 @@ local function resolve(host)
|
||||
return ips
|
||||
end
|
||||
|
||||
-- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点
|
||||
local LOOPBACK_NAMES = {
|
||||
["localhost"] = true,
|
||||
["localhost.localdomain"] = true,
|
||||
["localhost4"] = true,
|
||||
["localhost4.localdomain4"] = true,
|
||||
["localhost6"] = true,
|
||||
["localhost6.localdomain6"] = true,
|
||||
["ip6-localhost"] = true,
|
||||
["ip6-loopback"] = true,
|
||||
["ip6-localnet"] = true,
|
||||
}
|
||||
|
||||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
|
||||
function M.check_public(host)
|
||||
host = util.trim(host or "")
|
||||
host = util.trim(host or ""):lower()
|
||||
if host == "" then return false, "空主机名" end
|
||||
if host:match("^%d+%.%d+%.%d+%.%d+$") or host:find(":", 1, true) then
|
||||
if is_private(host) then return false, "目标为内网/保留地址" end
|
||||
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
|
||||
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
|
||||
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
|
||||
if host:find("@", 1, true) then return false, "主机名含非法字符 @" end
|
||||
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
|
||||
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
|
||||
if host:find("[%s%%]") then return false, "主机名含非法字符" end
|
||||
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
|
||||
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
|
||||
host = host:gsub("%.$", "")
|
||||
if host == "" then return false, "空主机名" end
|
||||
|
||||
if host:find(":", 1, true) then
|
||||
if not looks_like_ipv6(host) then return false, "无效主机名" end
|
||||
if is_private_ipv6(host) then return false, "目标为内网/保留地址" end
|
||||
return true
|
||||
end
|
||||
if host:lower() == "localhost" then return false, "目标为 localhost" end
|
||||
|
||||
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
|
||||
local norm = normalize_ipv4(host)
|
||||
if norm then
|
||||
if is_private_ipv4(norm) then return false, "目标为内网/保留地址" end
|
||||
return true
|
||||
end
|
||||
|
||||
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
|
||||
|
||||
local ips = resolve(host)
|
||||
if not ips then
|
||||
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
|
||||
@@ -250,17 +359,20 @@ local function fetch_curl(url, parsed, opts)
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local proxy_arg = ""
|
||||
if opts.proxy and opts.proxy ~= "" then
|
||||
proxy_arg = string.format(" -x %q", opts.proxy)
|
||||
proxy_arg = " -x " .. util.shq(opts.proxy)
|
||||
end
|
||||
-- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发,
|
||||
-- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。
|
||||
local tag = util.rnd_hex(8)
|
||||
local cur = url
|
||||
for redirect = 0, M.MAX_REDIRECTS do
|
||||
local tmp = "/tmp/substore_dl_" .. redirect .. ".tmp"
|
||||
local tmp = "/tmp/substore_dl_" .. tag .. "_" .. redirect .. ".tmp"
|
||||
local hdr = tmp .. ".hdr"
|
||||
local errf = tmp .. ".err"
|
||||
os.remove(tmp); os.remove(hdr); os.remove(errf)
|
||||
local cmd = string.format(
|
||||
"curl -sS -o %q --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %q -w \"%%{http_code}\"%s %q 2>%q",
|
||||
tmp, t, math.min(t, 10), max, hdr, proxy_arg, cur, errf)
|
||||
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s",
|
||||
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
|
||||
local p = io.popen(cmd)
|
||||
local code = p and p:read("*a") or ""
|
||||
if p then p:close() end
|
||||
@@ -268,7 +380,10 @@ local function fetch_curl(url, parsed, opts)
|
||||
if code == "" or code == "000" then
|
||||
return nil, util.trim(util.read_file(errf) or "下载失败")
|
||||
end
|
||||
if code:match("^[23]%d%d$") then
|
||||
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
|
||||
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
|
||||
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
|
||||
if code:match("^2%d%d$") then
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
local content = util.read_file(tmp)
|
||||
@@ -304,20 +419,26 @@ function M.wget_proxy_env(proxy)
|
||||
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
|
||||
" 代理,请安装 curl 或改用 http 代理"
|
||||
end
|
||||
return string.format("http_proxy=%q https_proxy=%q ", proxy, proxy)
|
||||
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(proxy) .. " "
|
||||
end
|
||||
|
||||
local function fetch_wget(url, parsed, opts)
|
||||
local max, t = opts.max_size, opts.timeout
|
||||
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
|
||||
if not proxy_env then return nil, perr end
|
||||
local tmp = "/tmp/substore_dl_wget.tmp"
|
||||
local tag = util.rnd_hex(8)
|
||||
local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp"
|
||||
local log = tmp .. ".log"
|
||||
os.remove(tmp); os.remove(log)
|
||||
-- -S 打印响应头(含整条重定向链),据此逐跳复检 SSRF;
|
||||
-- -S 打印响应头(含整条重定向链),据此复检 SSRF;
|
||||
-- 日志与响应体分流:体写 tmp,链写 log
|
||||
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %q %q >%q 2>&1", t, tmp, url, log)
|
||||
os.execute(cmd)
|
||||
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1",
|
||||
t, util.shq(tmp), util.shq(url), util.shq(log))
|
||||
local rc = os.execute(cmd)
|
||||
-- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0
|
||||
-- 会把残缺内容当成下载成功。
|
||||
-- Lua 5.1 的 os.execute 返回数字退出码,5.2+ 返回 true/nil, "exit", code,两者都兼容。
|
||||
local exit_ok = (rc == true) or (type(rc) == "number" and rc == 0)
|
||||
local logtext = util.read_file(log) or ""
|
||||
os.remove(log)
|
||||
local safe, reason = M.validate_redirect_chain(url, logtext)
|
||||
@@ -327,6 +448,10 @@ local function fetch_wget(url, parsed, opts)
|
||||
end
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
if not exit_ok then
|
||||
os.remove(tmp)
|
||||
return nil, "下载失败(wget 退出码非 0)"
|
||||
end
|
||||
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
|
||||
local content = util.read_file(tmp)
|
||||
os.remove(tmp)
|
||||
|
||||
@@ -142,9 +142,16 @@ function M.sort(nodes, by, desc)
|
||||
by = by or "name"
|
||||
desc = desc and true or false
|
||||
table.sort(nodes, function(a, b)
|
||||
local av = a[by] or ""
|
||||
local bv = b[by] or ""
|
||||
if by == "port" then av, bv = tonumber(av) or 0, tonumber(bv) or 0 end
|
||||
local av, bv = a[by], b[by]
|
||||
if by == "port" then
|
||||
av, bv = tonumber(av) or 0, tonumber(bv) or 0
|
||||
else
|
||||
-- 字段值可能是数字(例如 Clash YAML 里 `- name: 123`),
|
||||
-- 数字和字符串直接比较会抛 "attempt to compare number with string",
|
||||
-- 整个节点列表页就崩了;统一转成字符串再比。
|
||||
av = tostring(av == nil and "" or av)
|
||||
bv = tostring(bv == nil and "" or bv)
|
||||
end
|
||||
if av == bv then return false end
|
||||
if desc then return av > bv else return av < bv end
|
||||
end)
|
||||
@@ -159,6 +166,19 @@ function M.rename(node, new_name)
|
||||
return node
|
||||
end
|
||||
|
||||
-- 逗号分隔列表 → 数组,逐项去空白。
|
||||
-- 与 split_keywords 的区别:不做小写化(group / template 名是大小写敏感的)。
|
||||
-- 原来各过滤器直接 gmatch("[^,]+") 不去空白,"vmess, vless" 会把 " vless"
|
||||
-- (带前导空格)当成一个协议名,于是只剩 1 个节点且不报错。
|
||||
local function split_list(s)
|
||||
local out = {}
|
||||
for item in (s or ""):gmatch("[^,]+") do
|
||||
item = item:match("^%s*(.-)%s*$")
|
||||
if item ~= "" then out[#out + 1] = item end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- 将关键词串拆分为数组(英文逗号/中文逗号/空白分隔),忽略空项,并统一小写
|
||||
local function split_keywords(s)
|
||||
local out = {}
|
||||
@@ -187,7 +207,7 @@ function M.apply_rules(nodes, rules)
|
||||
-- 协议过滤
|
||||
if rules.proto_filter and rules.proto_filter ~= "" then
|
||||
local set = {}
|
||||
for p in rules.proto_filter:gmatch("[^,]+") do set[p]=true end
|
||||
for _, p in ipairs(split_list(rules.proto_filter)) do set[p] = true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.proto] then out[#out+1]=n end
|
||||
@@ -197,7 +217,7 @@ function M.apply_rules(nodes, rules)
|
||||
-- 分组过滤
|
||||
if rules.group_filter and rules.group_filter ~= "" then
|
||||
local set = {}
|
||||
for g in rules.group_filter:gmatch("[^,]+") do set[g]=true end
|
||||
for _, g in ipairs(split_list(rules.group_filter)) do set[g]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.group] then out[#out+1]=n end
|
||||
@@ -207,7 +227,7 @@ function M.apply_rules(nodes, rules)
|
||||
-- 标签包含
|
||||
if rules.tags_include and rules.tags_include ~= "" then
|
||||
local set = {}
|
||||
for t in rules.tags_include:gmatch("[^,]+") do set[t]=true end
|
||||
for _, t in ipairs(split_list(rules.tags_include)) do set[t]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
local match = false
|
||||
@@ -221,7 +241,7 @@ function M.apply_rules(nodes, rules)
|
||||
-- 模板过滤
|
||||
if rules.template_filter and rules.template_filter ~= "" then
|
||||
local set = {}
|
||||
for tmpl in rules.template_filter:gmatch("[^,]+") do set[tmpl]=true end
|
||||
for _, tmpl in ipairs(split_list(rules.template_filter)) do set[tmpl]=true end
|
||||
local out = {}
|
||||
for _,n in ipairs(nodes) do
|
||||
if set[n.template] then out[#out+1]=n end
|
||||
@@ -400,18 +420,105 @@ function M.parse_rename_rules(rule_str)
|
||||
end
|
||||
|
||||
-- 展开模板:替换 {var} 占位符
|
||||
-- gsub 的**替换串**里 % 有特殊含义(%1 反向引用、%% 转义),而节点数据是不可信输入:
|
||||
-- 名字里带 "%" 时会被静默吞掉("50% OFF" → "50 OFF"),更糟的是能拼出 %0,
|
||||
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
|
||||
-- 所以替换前必须先把值里的 % 转义成 %%。
|
||||
local function expand_template(template, n)
|
||||
local function esc(v) return (tostring(v == nil and "" or v):gsub("%%", "%%%%")) end
|
||||
local out = template
|
||||
out = out:gsub("{server}", n.server or "")
|
||||
out = out:gsub("{port}", tostring(n.port or ""))
|
||||
out = out:gsub("{proto}", n.proto or "")
|
||||
out = out:gsub("{name}", n.name or "")
|
||||
out = out:gsub("{uuid}", n.uuid or "")
|
||||
out = out:gsub("{password}", n.password or "")
|
||||
out = out:gsub("{group}", n.group or "")
|
||||
out = out:gsub("{server}", esc(n.server))
|
||||
out = out:gsub("{port}", esc(n.port))
|
||||
out = out:gsub("{proto}", esc(n.proto))
|
||||
out = out:gsub("{name}", esc(n.name))
|
||||
out = out:gsub("{uuid}", esc(n.uuid))
|
||||
out = out:gsub("{password}", esc(n.password))
|
||||
out = out:gsub("{group}", esc(n.group))
|
||||
return out
|
||||
end
|
||||
|
||||
-- 正则风格 → Lua pattern 的转义映射。
|
||||
-- Lua pattern 里转义符是 %,所以 \d 之类要改写;\d \D \w \W \s \S 与 Lua 的
|
||||
-- %d %D %w %W %s %S 一一对应。
|
||||
-- \b \B(词边界)在 Lua pattern 里**没有**对应写法:原来一律把 \ 换成 %,
|
||||
-- 于是 \b 变成了 %b —— 那是「成对匹配」(%bxy 匹配 x…y),语义完全不同,
|
||||
-- 而且经常直接抛错、被下面的 pcall 吞掉。这里按「无操作」处理。
|
||||
local REGEX_ESCAPE = {
|
||||
d = "%d", D = "%D", w = "%w", W = "%W", s = "%s", S = "%S",
|
||||
b = "", B = "",
|
||||
}
|
||||
|
||||
-- 把「正则里是普通字符、Lua pattern 里却是元字符」的字符转义。
|
||||
-- 最要命的是 `-`:正则里在字符类外就是普通字符("HK-01"、"Node-42"),
|
||||
-- Lua pattern 里却是「懒惰量词」,于是 `Node-(\d+)` 被解释成 Nod + e- + 数字,
|
||||
-- 永远匹配不上,而且不报错(pcall 也吞不掉,因为根本没抛错)。
|
||||
-- 字符类 [...] 内的 - 是范围([a-z]),两边语义一致,保持原样。
|
||||
-- `\x` 按 REGEX_ESCAPE 映射(\d→%d,\b→空),其余转义字符按 Lua 写法 %x。
|
||||
local function regex_to_lua(pat)
|
||||
local out, i, in_class = {}, 1, false
|
||||
local n = #pat
|
||||
while i <= n do
|
||||
local c = pat:sub(i, i)
|
||||
if c == "\\" and i < n then
|
||||
local nx = pat:sub(i + 1, i + 1)
|
||||
local m = REGEX_ESCAPE[nx]
|
||||
out[#out + 1] = (m ~= nil) and m or ("%" .. nx)
|
||||
i = i + 2
|
||||
elseif c == "[" then
|
||||
in_class = true; out[#out + 1] = c; i = i + 1
|
||||
elseif c == "]" then
|
||||
in_class = false; out[#out + 1] = c; i = i + 1
|
||||
elseif c == "-" and not in_class then
|
||||
out[#out + 1] = "%-"; i = i + 1
|
||||
else
|
||||
out[#out + 1] = c; i = i + 1
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- 把 `a|b` 拆成多个候选(**仅顶层** |)。
|
||||
-- 正则的「或」在 Lua pattern 里不存在,原来整个规则会静默不匹配。
|
||||
-- 只拆括号深度为 0、且不在字符类 [] 里的 |:
|
||||
-- * `[...]` 里的 | 是字面字符(Lua 的 [a|b] 匹配 a、| 或 b),拆开会破坏字符类
|
||||
-- * `(...)` 里的 | 属于分组内部。Lua pattern 的 () 只是捕获,没有「或」语义,
|
||||
-- 拆开只会得到 `^(HK` / `US` / `JP)%-(.*)$` 这种残缺模式——比不拆更糟:
|
||||
-- 每一段都匹配不上,还静默无提示。分组内的「或」明确不支持,按字面处理。
|
||||
local function split_alternatives(pat)
|
||||
local alts, buf = {}, {}
|
||||
local depth, in_class, i = 0, false, 1
|
||||
local n = #pat
|
||||
-- 必须是 while 而不是 for:下面遇到 % 转义序列要一次吃掉两个字符,
|
||||
-- Lua 的数值 for 会在每轮重新赋值控制变量,循环体内改 i 不生效。
|
||||
-- 也不用 goto(Lua 5.1 没有)。
|
||||
while i <= n do
|
||||
local c = pat:sub(i, i)
|
||||
if c == "%" then
|
||||
-- Lua 转义序列整体保留(regex_to_lua 已产出 %d / %- 这类两字符序列)
|
||||
buf[#buf + 1] = c .. pat:sub(i + 1, i + 1)
|
||||
i = i + 2
|
||||
elseif c == "|" and depth == 0 and not in_class then
|
||||
alts[#alts + 1] = table.concat(buf)
|
||||
buf = {}
|
||||
i = i + 1
|
||||
else
|
||||
if in_class then
|
||||
if c == "]" then in_class = false end
|
||||
elseif c == "[" then
|
||||
in_class = true
|
||||
elseif c == "(" then
|
||||
depth = depth + 1
|
||||
elseif c == ")" and depth > 0 then
|
||||
depth = depth - 1
|
||||
end
|
||||
buf[#buf + 1] = c
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
alts[#alts + 1] = table.concat(buf)
|
||||
return alts
|
||||
end
|
||||
|
||||
-- 按规则链式重命名节点(就地修改)
|
||||
function M.rename_with_rules(nodes, rules)
|
||||
rules = rules or {}
|
||||
@@ -423,11 +530,16 @@ function M.rename_with_rules(nodes, rules)
|
||||
n.name = expand_template(r.template, n)
|
||||
elseif r.type == "regex" then
|
||||
local name = n.name or ""
|
||||
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1
|
||||
local pat = (r.pattern or ""):gsub("\\", "%%")
|
||||
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1、HK-01 里的 - 转义成 %-
|
||||
local pat = regex_to_lua(r.pattern or "")
|
||||
local repl = (r.replacement or ""):gsub("%$", "%%")
|
||||
local ok, result = pcall(function()
|
||||
return string.gsub(name, pat, repl)
|
||||
-- 顶层 | 拆成多个候选依次替换(Lua pattern 没有「或」)
|
||||
local s = name
|
||||
for _, alt in ipairs(split_alternatives(pat)) do
|
||||
s = s:gsub(alt, repl)
|
||||
end
|
||||
return s
|
||||
end)
|
||||
if ok then
|
||||
n.name = result
|
||||
|
||||
@@ -22,12 +22,37 @@ local AWG_CONF_KEY_MAP = {
|
||||
h1 = "H1", h2 = "H2", h3 = "H3", h4 = "H4",
|
||||
i1 = "I1", i2 = "I2", i3 = "I3", i4 = "I4", i5 = "I5",
|
||||
j1 = "J1", j2 = "J2", j3 = "J3", itime = "Itime",
|
||||
-- AWG 3.0 / 3.1(键名核对自 amneziawg-tools src/config.c)
|
||||
["header-protection-key"] = "HeaderProtectionKey",
|
||||
["content-padding-addition"] = "ContentPaddingAddition",
|
||||
["rekey-after-time"] = "RekeyAfterTime",
|
||||
["rekey-timeout"] = "RekeyTimeout",
|
||||
["reject-after-time"] = "RejectAfterTime",
|
||||
["keepalive-timeout"] = "KeepaliveTimeout",
|
||||
["max-handshake-attempts"] = "MaxHandshakeAttempts",
|
||||
["random-trailers"] = "RandomTrailers",
|
||||
["disable-cookies"] = "DisableCookies",
|
||||
["advanced-security"] = "AdvancedSecurity",
|
||||
}
|
||||
|
||||
-- 布尔键在 .conf 里必须写成 on/off:amneziawg-tools 的 parse_bool 只接受
|
||||
-- on/off(大小写不敏感)或十进制数,写 true/false 会被判为非法值。
|
||||
local AWG_BOOL_CONF_KEYS = {
|
||||
RandomTrailers = true, DisableCookies = true, AdvancedSecurity = true,
|
||||
}
|
||||
|
||||
local function conf_key(k)
|
||||
return AWG_CONF_KEY_MAP[k]
|
||||
end
|
||||
|
||||
-- 值 → .conf 文本:布尔转 on/off,其余 tostring
|
||||
local function conf_val(conf_key_name, v)
|
||||
if AWG_BOOL_CONF_KEYS[conf_key_name] and type(v) == "boolean" then
|
||||
return v and "on" or "off"
|
||||
end
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- 值可能是标量或数组,统一转为 "a, b, c"
|
||||
local function join_list(v)
|
||||
if v == nil then return nil end
|
||||
@@ -83,7 +108,7 @@ local function build_section(n)
|
||||
end
|
||||
table.sort(mapped, function(x, y) return x[1] < y[1] end)
|
||||
for _, kv in ipairs(mapped) do
|
||||
put(kv[1], tostring(kv[2]))
|
||||
put(kv[1], conf_val(kv[1], kv[2]))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -122,6 +147,15 @@ function M.generate(nodes, options)
|
||||
end
|
||||
|
||||
local n = wg[1]
|
||||
|
||||
-- PrivateKey 是 wg-quick / AmneziaWG 的必填项:缺了它导出的 .conf 连本项目的
|
||||
-- parser.detect 都认不出来(detect 要求 [Interface] + PrivateKey 同时存在),
|
||||
-- 客户端更是无法导入。宁可不导出,也不要产出这种「看起来成功」的残缺文件。
|
||||
local priv = n["private-key"] or n.private_key
|
||||
if priv == nil or priv == "" then
|
||||
return nil, "该 WireGuard 节点没有私钥 (private-key),无法导出 .conf"
|
||||
end
|
||||
|
||||
local lines = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
|
||||
for _, line in ipairs(build_section(n)) do lines[#lines + 1] = line end
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
|
||||
@@ -45,6 +45,12 @@ function M.detect(content)
|
||||
if lower:match("%[interface%]") and lower:match("privatekey%s*=") then
|
||||
return "wireguard-conf"
|
||||
end
|
||||
-- Surge / Loon:必须排在下面的 URI 兜底判断之前。
|
||||
-- Surge 配置头部的 "#!MANAGED-CONFIG https://…" 含 "://",排在后面会被判成 uri,
|
||||
-- 于是整份订阅解析出 0 个节点,且因为 parse 返回的是合法表,同步还会报成功。
|
||||
if parser_surge.is_config(content) then return "surge" end
|
||||
-- JSON 数组(节点数组):必须排在 .conf 判断之后,否则 "[Interface]" 会被当成数组
|
||||
if stripped:sub(1, 1) == "[" then return "json" end
|
||||
if stripped:find("vmess://", 1, true) or stripped:find("vless://", 1, true)
|
||||
or stripped:find("trojan://", 1, true) or stripped:find("ssr://", 1, true)
|
||||
or stripped:find("ss://", 1, true) or content:find("://", 1, true) then
|
||||
@@ -121,11 +127,17 @@ local function parse_ss(body)
|
||||
return out
|
||||
end
|
||||
|
||||
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码
|
||||
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码。
|
||||
-- util.base64_url_decode 永远不会「失败」——它把非 base64 字符直接剔掉再解码,
|
||||
-- 所以纯文本也会解出乱码字节而不是空串,原来的 `if v ~= ""` 回退分支根本不可达,
|
||||
-- 节点名 / obfsparam / group 会被解成乱码(并写盘、下发)。
|
||||
-- 可靠的判据是往返一致性:合法 base64url 重新编码后与原文逐字符相同,纯文本不会。
|
||||
local function b64u_decode(s)
|
||||
s = s or ""
|
||||
local v = util.base64_url_decode(s)
|
||||
if v ~= "" then return v end
|
||||
if v ~= "" and util.base64_url_encode(v) == (s:gsub("=+$", "")) then
|
||||
return v
|
||||
end
|
||||
return util.url_decode(s)
|
||||
end
|
||||
|
||||
@@ -206,6 +218,13 @@ local function parse_vless(uri, body)
|
||||
if query.fp then out.fp = query.fp end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
if query.headerType then out.headerType = query.headerType end
|
||||
-- path / host / flow 以前没有回读:output_uri 会写出这三个参数,读不回来就造成
|
||||
-- 往返丢字段。丢 path/host 的后果尤其严重——Clash 输出会得到 network: ws 却没有
|
||||
-- ws-opts.path,客户端请求 "/" 而非真实路径,节点直接连不上。
|
||||
-- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数,丢了同样握手失败。
|
||||
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
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -236,6 +255,12 @@ local function parse_trojan(uri, body)
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.security then out.security = query.security end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
-- trojan 同样支持 ws/grpc 等传输:output_uri 会写出 type/path/host/fp,
|
||||
-- 以前只回读 sni/security/alpn,造成往返丢字段
|
||||
if query.type then out.net = query.type end
|
||||
if query.path then out.path = query.path end
|
||||
if query.host then out.host = query.host end
|
||||
if query.fp then out.fp = query.fp end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -267,6 +292,12 @@ local function parse_vmess(uri, body)
|
||||
if query.type then out.net = query.type end
|
||||
if query.security then out.security = query.security end
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.fp then out.fp = query.fp end
|
||||
if query.alpn then out.alpn = query.alpn end
|
||||
-- 同上:vmess 新版 URI 也带 host / path / headerType
|
||||
if query.host then out.host = query.host end
|
||||
if query.path then out.path = query.path end
|
||||
if query.headerType then out.headerType = query.headerType end
|
||||
return out
|
||||
end
|
||||
-- 经典格式:vmess://base64(json)
|
||||
@@ -278,7 +309,7 @@ local function parse_vmess(uri, body)
|
||||
proto = "vmess",
|
||||
name = j.ps or (j.add .. ":" .. tostring(j.port)),
|
||||
server = j.add, port = tonumber(j.port),
|
||||
uuid = j.id, aid = tonumber(j.aid),
|
||||
uuid = j.id, alterId = tonumber(j.aid),
|
||||
net = j.net, type = j.type,
|
||||
-- scy 是 vmess 加密方式(cipher),不是 TLS 层;TLS 由 tls 字段表达
|
||||
-- ("tls" 表示启用,"" 表示不启用)
|
||||
@@ -315,7 +346,17 @@ local function parse_hysteria2(uri, body)
|
||||
password = password, raw = uri,
|
||||
})
|
||||
if query.sni then out.sni = query.sni end
|
||||
if query.insecure ~= nil then out.insecure = query.insecure end
|
||||
-- hysteria2 / hysteria 在 sing-box 与 mihomo 里都是 TLS-only,而 hy2 URI 不带
|
||||
-- security 参数。不补上的话 output_singbox.build_tls 直接返回 nil,
|
||||
-- sni / insecure 全部丢失,生成的 outbound 不可用。
|
||||
out.security = "tls"
|
||||
-- insecure 是 URI 参数名,模型里的权威字段是 skip-cert-verify
|
||||
-- (parser_json_config 也把 sing-box 的 tls.insecure 映射到它)。
|
||||
-- 两个都保留:skip-cert-verify 供各输出消费,insecure 供 URI 回写。
|
||||
if query.insecure ~= nil then
|
||||
out.insecure = query.insecure
|
||||
out["skip-cert-verify"] = not (query.insecure == "0" or query.insecure == "false")
|
||||
end
|
||||
-- 混淆参数回读,保证导出→导入回环不丢字段
|
||||
if query.obfs then out.obfs = query.obfs end
|
||||
if query["obfs-password"] then out["obfs-password"] = query["obfs-password"] end
|
||||
@@ -392,19 +433,61 @@ local function parse_wireguard(uri, body)
|
||||
return out
|
||||
end
|
||||
|
||||
-- AmneziaWG 参数随版本演进,未知键直接丢弃:不猜语义,避免把上游新参数错误映射到 mihomo 字段。
|
||||
-- 取值统一走 tonumber-or-raw:数值型(jc/s1/h1…)转数字;非数值载荷(i1..i5、h1 的 "a-b" 区间)保留字符串。
|
||||
local AWG_CONF_KEYS = {
|
||||
"jc", "jmin", "jmax", "s1", "s2", "s3", "s4",
|
||||
"h1", "h2", "h3", "h4",
|
||||
"i1", "i2", "i3", "i4", "i5",
|
||||
"j1", "j2", "j3", "itime",
|
||||
-- AmneziaWG 参数。字段名与类型逐字段核对自两份一手来源,不靠推断:
|
||||
-- .conf 键名 → amneziawg-tools src/config.c(process_line,大小写不敏感)
|
||||
-- 规范名/类型 → mihomo adapter/outbound/wireguard.go 的 AmneziaWGOption(proxy tag)
|
||||
-- 内部规范名 = mihomo 的 proxy tag(小写连字符):Clash 输出直接透传这些键名,
|
||||
-- 所以必须与 mihomo 一致;.conf 导出时再用显式映射换成 CamelCase。
|
||||
-- 未列入的键一律丢弃(不猜语义,§110)。
|
||||
local AWG_CONF_FIELDS = {
|
||||
-- v1.0
|
||||
jc = "jc", jmin = "jmin", jmax = "jmax",
|
||||
s1 = "s1", s2 = "s2",
|
||||
h1 = "h1", h2 = "h2", h3 = "h3", h4 = "h4",
|
||||
-- v1.5
|
||||
s3 = "s3", s4 = "s4",
|
||||
i1 = "i1", i2 = "i2", i3 = "i3", i4 = "i4", i5 = "i5",
|
||||
j1 = "j1", j2 = "j2", j3 = "j3", itime = "itime",
|
||||
-- v3.0
|
||||
headerprotectionkey = "header-protection-key",
|
||||
contentpaddingaddition = "content-padding-addition",
|
||||
rekeyaftertime = "rekey-after-time",
|
||||
rekeytimeout = "rekey-timeout",
|
||||
rejectaftertime = "reject-after-time",
|
||||
keepalivetimeout = "keepalive-timeout",
|
||||
maxhandshakeattempts = "max-handshake-attempts",
|
||||
-- v3.1
|
||||
randomtrailers = "random-trailers",
|
||||
disablecookies = "disable-cookies",
|
||||
}
|
||||
|
||||
-- 布尔字段(mihomo 侧类型为 bool)。amneziawg-tools 的 parse_bool 只认
|
||||
-- on/off(大小写不敏感)或十进制数(0 假、非 0 真);true/false/yes/no 都是非法值。
|
||||
-- 非法值一律丢弃:留着会让 mihomo 解析该字段时报错。
|
||||
local AWG_BOOL_FIELDS = {
|
||||
["random-trailers"] = true,
|
||||
["disable-cookies"] = true,
|
||||
}
|
||||
|
||||
-- on/off 或十进制数 → 布尔;其余返回 nil(非法值,丢弃)
|
||||
local function awg_bool(raw)
|
||||
local s = tostring(raw or ""):lower()
|
||||
if s == "on" then return true end
|
||||
if s == "off" then return false end
|
||||
if s:match("^%d+$") then return tonumber(s) ~= 0 end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- wg-quick / AmneziaWG .conf 解析([Interface] / [Peer] 分段,Key = Value)
|
||||
-- AmneziaWG 客户端导出的 .conf 即标准 wg-quick 格式 + Jc/Jmin/Jmax/S1/S2/H1..H4 等键
|
||||
-- 一个 .conf 可以含多个 [Peer](wg-quick 的多对端隧道)。本项目的节点模型是
|
||||
-- 「一个节点 = 一个对端」,无法表达多对端隧道;旧实现把所有 [Peer] 写进同一个表,
|
||||
-- 后一段覆盖前一段,结果既丢掉前面的对端,又可能把 A 段的 PresharedKey 和
|
||||
-- B 段的 Endpoint 拼成一个并不存在的节点。这里改为每个 [Peer] 生成一个节点,
|
||||
-- 共享同一份 [Interface] 设置。返回 nodes 数组;失败返回 nil, err。
|
||||
local function parse_wireguard_conf(content)
|
||||
local iface, peer = {}, {}
|
||||
local iface = {}
|
||||
local peers = {}
|
||||
local section = nil
|
||||
for line in content:gmatch("[^\r\n]+") do
|
||||
local s = util.trim(line)
|
||||
@@ -413,9 +496,15 @@ local function parse_wireguard_conf(content)
|
||||
elseif s:sub(1, 1) == "[" then
|
||||
local name = s:match("^%[%s*(.-)%s*%]")
|
||||
name = name and name:lower() or ""
|
||||
if name == "interface" then section = iface
|
||||
elseif name == "peer" then section = peer
|
||||
else section = nil end
|
||||
if name == "interface" then
|
||||
section = iface
|
||||
elseif name == "peer" then
|
||||
local p = {}
|
||||
peers[#peers + 1] = p
|
||||
section = p
|
||||
else
|
||||
section = nil
|
||||
end
|
||||
elseif section then
|
||||
local k, v = s:match("^([^=]+)=(.*)$")
|
||||
if k then
|
||||
@@ -426,17 +515,12 @@ local function parse_wireguard_conf(content)
|
||||
end
|
||||
end
|
||||
|
||||
local host, port = util.split_hostport(peer["endpoint"] or "")
|
||||
if not host or host == "" or not port then return nil, "bad wireguard conf: no endpoint" end
|
||||
if #peers == 0 then return nil, "bad wireguard conf: no [Peer]" end
|
||||
|
||||
local out = {
|
||||
-- [Interface] 由各节点共享
|
||||
local common = {
|
||||
proto = "wireguard",
|
||||
server = host,
|
||||
port = tonumber(port),
|
||||
["private-key"] = iface["privatekey"],
|
||||
["public-key"] = peer["publickey"],
|
||||
["pre-shared-key"] = peer["presharedkey"],
|
||||
["persistent-keepalive"] = tonumber(peer["persistentkeepalive"]),
|
||||
["listen-port"] = tonumber(iface["listenport"]),
|
||||
mtu = tonumber(iface["mtu"]),
|
||||
}
|
||||
@@ -446,48 +530,79 @@ local function parse_wireguard_conf(content)
|
||||
a = util.trim(a)
|
||||
if a ~= "" then
|
||||
if a:find(":", 1, true) then
|
||||
if out.ipv6 == nil then out.ipv6 = a end
|
||||
if common.ipv6 == nil then common.ipv6 = a end
|
||||
else
|
||||
if out.ip == nil then out.ip = a end
|
||||
if common.ip == nil then common.ip = a end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- AllowedIPs 拆分数组
|
||||
local allowed = {}
|
||||
for a in (peer["allowedips"] or ""):gmatch("[^,]+") do
|
||||
a = util.trim(a)
|
||||
if a ~= "" then allowed[#allowed + 1] = a end
|
||||
end
|
||||
if #allowed > 0 then out["allowed-ips"] = allowed end
|
||||
|
||||
-- DNS 拆分:单值存字符串,多值存数组
|
||||
local dns = {}
|
||||
for a in (iface["dns"] or ""):gmatch("[^,]+") do
|
||||
a = util.trim(a)
|
||||
if a ~= "" then dns[#dns + 1] = a end
|
||||
end
|
||||
if #dns == 1 then out.dns = dns[1]
|
||||
elseif #dns > 1 then out.dns = dns end
|
||||
if #dns == 1 then common.dns = dns[1]
|
||||
elseif #dns > 1 then common.dns = dns end
|
||||
|
||||
-- Reserved(部分客户端写法,逗号或空格分隔)
|
||||
local reserved = {}
|
||||
for a in (iface["reserved"] or ""):gmatch("[^,%s]+") do
|
||||
reserved[#reserved + 1] = tonumber(a) or a
|
||||
end
|
||||
if #reserved > 0 then out.reserved = reserved end
|
||||
if #reserved > 0 then common.reserved = reserved end
|
||||
|
||||
-- AmneziaWG 参数
|
||||
-- AmneziaWG [Interface] 参数:按 AWG_CONF_FIELDS 映射成规范名
|
||||
local awg = {}
|
||||
for _, k in ipairs(AWG_CONF_KEYS) do
|
||||
local raw = iface[k]
|
||||
for ck, canon in pairs(AWG_CONF_FIELDS) do
|
||||
local raw = iface[ck]
|
||||
if raw ~= nil and raw ~= "" then
|
||||
awg[k] = tonumber(raw) or raw
|
||||
if AWG_BOOL_FIELDS[canon] then
|
||||
local b = awg_bool(raw)
|
||||
if b ~= nil then awg[canon] = b end
|
||||
else
|
||||
awg[canon] = tonumber(raw) or raw
|
||||
end
|
||||
end
|
||||
end
|
||||
if next(awg) then out["amnezia-wg-option"] = awg end
|
||||
|
||||
return node.normalize(out)
|
||||
local nodes = {}
|
||||
for _, p in ipairs(peers) do
|
||||
local host, port = util.split_hostport(p["endpoint"] or "")
|
||||
if not host or host == "" or not port then return nil, "bad wireguard conf: no endpoint" end
|
||||
|
||||
local out = {}
|
||||
for k, v in pairs(common) do out[k] = v end
|
||||
out.server = host
|
||||
out.port = tonumber(port)
|
||||
out["public-key"] = p["publickey"]
|
||||
out["pre-shared-key"] = p["presharedkey"]
|
||||
out["persistent-keepalive"] = tonumber(p["persistentkeepalive"])
|
||||
|
||||
-- AllowedIPs 拆分数组
|
||||
local allowed = {}
|
||||
for a in (p["allowedips"] or ""):gmatch("[^,]+") do
|
||||
a = util.trim(a)
|
||||
if a ~= "" then allowed[#allowed + 1] = a end
|
||||
end
|
||||
if #allowed > 0 then out["allowed-ips"] = allowed end
|
||||
|
||||
-- [Peer] 段的 AmneziaWG 参数(AdvancedSecurity)不在此处解析:
|
||||
-- 它属于 [Peer] 段,而本项目的 amnezia-wg-option 只对应 [Interface] 段,
|
||||
-- 合并进来会在导出时被写到 [Interface] 下(错误段落)。宁可不支持,
|
||||
-- 也不要产出一份键位置错误的 .conf(§110)。
|
||||
-- 每个节点各拿一份副本:共享同一个表会让后续修改一处影响全部节点。
|
||||
if next(awg) then
|
||||
local o = {}
|
||||
for k, v in pairs(awg) do o[k] = v end
|
||||
out["amnezia-wg-option"] = o
|
||||
end
|
||||
|
||||
nodes[#nodes + 1] = node.normalize(out)
|
||||
end
|
||||
|
||||
return nodes
|
||||
end
|
||||
|
||||
-- 解析单条节点 URI,返回节点表或 nil, err
|
||||
@@ -785,9 +900,9 @@ function M.parse(content)
|
||||
local nodes = parser_surge.parse(content)
|
||||
return { nodes = nodes, format = "surge" }
|
||||
elseif format == "wireguard-conf" then
|
||||
local n, err = parse_wireguard_conf(content)
|
||||
if not n then return nil, err end
|
||||
return { nodes = { n }, format = "wireguard-conf" }
|
||||
local nodes, err = parse_wireguard_conf(content)
|
||||
if not nodes then return nil, err end
|
||||
return { nodes = nodes, format = "wireguard-conf" }
|
||||
end
|
||||
return nil, "无法识别的订阅格式"
|
||||
end
|
||||
@@ -828,6 +943,49 @@ function M.parse_local(content, mode)
|
||||
-- SSR 参数别名
|
||||
if n.obfs_param == nil and n["obfs-param"] ~= nil then n.obfs_param = n["obfs-param"] end
|
||||
if n.protocol_param == nil and n["protocol-param"] ~= nil then n.protocol_param = n["protocol-param"] end
|
||||
|
||||
-- 表单里所有输入框的值都是字符串,但统一模型里这几个字段是数组
|
||||
-- (见 parse_wireguard_conf:AllowedIPs/Reserved/DNS 都拆成数组)。
|
||||
-- 不归一的话,从 UI 编辑 WireGuard 节点会把数组写成标量字符串,
|
||||
-- 而 mihomo / sing-box 的对应字段是列表类型,导出结果非法。
|
||||
for _, lk in ipairs({ "allowed-ips", "reserved", "dns" }) do
|
||||
if type(n[lk]) == "string" then
|
||||
local arr = {}
|
||||
for piece in n[lk]:gmatch("[^,%s]+") do
|
||||
if piece ~= "" then arr[#arr + 1] = piece end
|
||||
end
|
||||
-- reserved 是端口保留位,语义上是数字
|
||||
if lk == "reserved" then
|
||||
for i, piece in ipairs(arr) do arr[i] = tonumber(piece) or piece end
|
||||
end
|
||||
if #arr == 0 then
|
||||
n[lk] = nil
|
||||
elseif #arr == 1 and lk == "dns" then
|
||||
n[lk] = arr[1] -- 单值 DNS 保持字符串,与 .conf 解析一致
|
||||
else
|
||||
n[lk] = arr
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- amnezia-wg-option 在表单里是一个 JSON 文本框,提交上来是字符串。
|
||||
-- 下游(output_wireguard_conf / output_clash_meta / output_uri)
|
||||
-- 一律要求它是 table,字符串会被静默忽略 —— 即从 UI 编辑
|
||||
-- WireGuard 节点会丢掉全部 AmneziaWG 参数。这里统一解码;
|
||||
-- 解不开就明确报错,而不是留个字符串让它在导出时无声消失。
|
||||
if type(n["amnezia-wg-option"]) == "string" then
|
||||
local s = util.trim(n["amnezia-wg-option"])
|
||||
if s == "" then
|
||||
n["amnezia-wg-option"] = nil
|
||||
else
|
||||
local opt = util.json_decode(s)
|
||||
if type(opt) ~= "table" then
|
||||
return nil, "AmneziaWG 参数必须是合法的 JSON 对象"
|
||||
end
|
||||
n["amnezia-wg-option"] = opt
|
||||
end
|
||||
end
|
||||
|
||||
if n.name == nil or n.name == "" then
|
||||
n.name = (n.server or "") .. ":" .. tostring(n.port or "")
|
||||
end
|
||||
|
||||
@@ -184,8 +184,12 @@ local function map_clash_node(p)
|
||||
end
|
||||
|
||||
-- 保留其余字段(wireguard 的 private-key、peer-public-key 等)
|
||||
-- 但 type 要排除:Clash 的 type 是协议判别字段(vmess/ss/trojan),
|
||||
-- 已经在上面映射成 proto;原样拷进来会让节点多出一个 type,
|
||||
-- 而 output_uri 把它当成 vmess 的 header type 写出("type":"vmess"),
|
||||
-- 生成客户端无法识别的 vmess:// 链接。
|
||||
for k, v in pairs(p) do
|
||||
if n[k] == nil then n[k] = v end
|
||||
if k ~= "type" and n[k] == nil then n[k] = v end
|
||||
end
|
||||
|
||||
return node.normalize(n)
|
||||
|
||||
@@ -8,14 +8,25 @@ local M = {}
|
||||
|
||||
M.TIMEOUT = 2 -- 每个目标的超时(秒)
|
||||
|
||||
-- shell 引号(%q 双引号转义),配合 safe_host 校验,双重防命令注入
|
||||
local function q(s) return string.format("%q", s) end
|
||||
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
|
||||
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
|
||||
-- 命令替换,实测可注入。
|
||||
local function q(s) return util.shq(s) end
|
||||
|
||||
-- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入
|
||||
local function safe_host(host)
|
||||
return type(host) == "string" and host ~= "" and host:match("^[%w%.%-%:]+$") ~= nil
|
||||
end
|
||||
|
||||
-- 构造 http URL:IPv6 字面量必须写成 [addr]:port,否则 "2001:db8::1:443" 不是合法 URL,
|
||||
-- curl 直接报 URL rejected(code 000),导致所有 IPv6 节点的 URL 测试恒为失败。
|
||||
local function http_url(server, port)
|
||||
if server:find(":", 1, true) then
|
||||
return "http://[" .. server .. "]:" .. port .. "/"
|
||||
end
|
||||
return "http://" .. server .. ":" .. port .. "/"
|
||||
end
|
||||
|
||||
local function safe_port(port)
|
||||
port = tonumber(port)
|
||||
if port and port >= 1 and port <= 65535 then return port end
|
||||
@@ -94,7 +105,7 @@ function M.url_test(server, port)
|
||||
server = util.trim(server or "")
|
||||
port = safe_port(port)
|
||||
if not safe_host(server) or not port then return nil end
|
||||
local url = "http://" .. server .. ":" .. port .. "/"
|
||||
local url = http_url(server, port)
|
||||
local cmd, parse = build_url_job(url)
|
||||
local f = io.popen(cmd)
|
||||
local out = f and f:read("*a") or ""
|
||||
@@ -117,7 +128,7 @@ local function build_job(mode, server, port)
|
||||
elseif mode == "url" then
|
||||
port = safe_port(port)
|
||||
if not port then return nil end
|
||||
return build_url_job("http://" .. server .. ":" .. port .. "/")
|
||||
return build_url_job(http_url(server, port))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -69,6 +69,14 @@ function M.base64_url_decode(s)
|
||||
return M.base64_decode(s)
|
||||
end
|
||||
|
||||
-- shell 单引号转义。拼进 shell 命令行的**一切外部数据**都必须走这里。
|
||||
-- 不能用 string.format("%q"):它生成的是双引号字符串,而 /bin/sh 在双引号内
|
||||
-- 仍然会做 $() 和 `` 命令替换,所以 format("%q", "$(rm -rf /)") 会被真的执行。
|
||||
-- 单引号内除 ' 之外所有字符都是字面量,' 本身写作 '\'' 即可闭合再转义。
|
||||
function M.shq(s)
|
||||
return "'" .. tostring(s or ""):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- 生成随机十六进制 token(用于下载链接访问控制)
|
||||
function M.rnd_hex(len)
|
||||
len = len or 16
|
||||
@@ -353,7 +361,7 @@ function M.atomic_write(path, content)
|
||||
end
|
||||
|
||||
function M.ensure_dir(path)
|
||||
os.execute("mkdir -p " .. string.format("%q", path))
|
||||
os.execute("mkdir -p " .. M.shq(path))
|
||||
end
|
||||
|
||||
-- ---------- 人性化格式 ----------
|
||||
|
||||
@@ -155,5 +155,93 @@ ctl.action_local_create()
|
||||
check("no token still redirects", type(LAST_REDIRECT) == "string")
|
||||
check("no token no error banner", not has_err())
|
||||
|
||||
-- ---------- §18 单节点保存:解析/写入失败必须可见 ----------
|
||||
-- 修复前 action_node_save 只在成功分支做事,其余情况一律静默重定向,
|
||||
-- 用户提交了坏数据却看到「已保存」的样子。
|
||||
local NODES = {}
|
||||
local WRITE_CALLS = 0
|
||||
local CORE_STUB = package.loaded["substore.core"]
|
||||
CORE_STUB.read_nodes = function() return NODES end
|
||||
CORE_STUB.write_nodes = function(_, ns)
|
||||
WRITE_CALLS = WRITE_CALLS + 1
|
||||
NODES = ns
|
||||
return true
|
||||
end
|
||||
CORE_STUB.refresh_combos = function() end
|
||||
-- 真实的 merge_form_node 在 substore.core 里(会碰 /etc/substore),这里只要
|
||||
-- 「表单字段覆盖原节点」的语义即可
|
||||
CORE_STUB.merge_form_node = function(orig, form)
|
||||
local out = {}
|
||||
for k, v in pairs(orig or {}) do out[k] = v end
|
||||
for k, v in pairs(form or {}) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
local VALID_NODE = '[{"type":"vmess","server":"1.2.3.4","port":"443","uuid":"u1"}]'
|
||||
|
||||
reset()
|
||||
NODES = { { name = "old", proto = "vmess", server = "1.2.3.4", port = 443 } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { token = "t", id = "s00000001", idx = "1", content = VALID_NODE }
|
||||
ctl.action_node_save()
|
||||
check("node_save valid writes", WRITE_CALLS == 1)
|
||||
check("node_save valid has no err", not has_err())
|
||||
|
||||
-- 解析失败:必须报错且不写盘
|
||||
reset()
|
||||
NODES = { { name = "old", proto = "vmess", server = "1.2.3.4", port = 443 } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { token = "t", id = "s00000001", idx = "1", content = "not json at all" }
|
||||
ctl.action_node_save()
|
||||
check("node_save bad json reports error", has_err())
|
||||
check("node_save bad json does not write", WRITE_CALLS == 0)
|
||||
|
||||
-- 下标无效
|
||||
reset()
|
||||
NODES = { { name = "old" } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { token = "t", id = "s00000001", idx = "9", content = VALID_NODE }
|
||||
ctl.action_node_save()
|
||||
check("node_save bad idx reports error", has_err())
|
||||
check("node_save bad idx does not write", WRITE_CALLS == 0)
|
||||
|
||||
-- 缺 idx
|
||||
reset()
|
||||
NODES = { { name = "old" } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { token = "t", id = "s00000001", content = VALID_NODE }
|
||||
ctl.action_node_save()
|
||||
check("node_save missing idx reports error", has_err())
|
||||
check("node_save missing idx does not write", WRITE_CALLS == 0)
|
||||
|
||||
-- 空内容
|
||||
reset()
|
||||
NODES = { { name = "old" } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { token = "t", id = "s00000001", idx = "1", content = "" }
|
||||
ctl.action_node_save()
|
||||
check("node_save empty content reports error", has_err())
|
||||
check("node_save empty content does not write", WRITE_CALLS == 0)
|
||||
|
||||
-- 写入失败
|
||||
reset()
|
||||
NODES = { { name = "old" } }
|
||||
WRITE_CALLS = 0
|
||||
CORE_STUB.write_nodes = function() WRITE_CALLS = WRITE_CALLS + 1; return false end
|
||||
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] == "写入节点数据失败")
|
||||
|
||||
-- 无 token:不做任何事,也不报错
|
||||
reset()
|
||||
NODES = { { name = "old" } }
|
||||
WRITE_CALLS = 0
|
||||
FORM = { id = "s00000001", idx = "1", content = VALID_NODE }
|
||||
ctl.action_node_save()
|
||||
check("node_save no token redirects", type(LAST_REDIRECT) == "string")
|
||||
check("node_save no token no error banner", not has_err())
|
||||
check("node_save no token does not write", WRITE_CALLS == 0)
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -3,6 +3,7 @@
|
||||
# (a) core.sync 抛异常
|
||||
# (b) core.sync 返回 nil, err ← pcall 第一层仍为 true(修复前被统计成成功)
|
||||
# (c) core.sync 返回节点数(成功,含 0)
|
||||
# 以及退出码必须反映本轮结果(有失败 → 非 0),供 cron MAILTO / 外部监控判断。
|
||||
#
|
||||
# 通过桩 core 模块 + SUBSTORE_DATA_DIR 覆盖驱动真实脚本,不依赖公网。
|
||||
# 用法:sh tests/cron_result_test.sh
|
||||
@@ -38,6 +39,9 @@ contains() {
|
||||
esac
|
||||
}
|
||||
|
||||
OUT=""
|
||||
RC=0
|
||||
|
||||
run_case() {
|
||||
# $1 = M.sync 的 Lua 实现体
|
||||
cat >"$TMP/stub/substore/core.lua" <<EOF
|
||||
@@ -46,20 +50,24 @@ M.list = function() return { { id = "s00000001", enabled = true } } end
|
||||
M.sync = function(id) $1 end
|
||||
return M
|
||||
EOF
|
||||
SUBSTORE_DATA_DIR="$TMP/data" LUA_PATH="$TMP/stub/?.lua" \
|
||||
sh "$SCRIPT" 2>/dev/null
|
||||
OUT=$(SUBSTORE_DATA_DIR="$TMP/data" LUA_PATH="$TMP/stub/?.lua" \
|
||||
sh "$SCRIPT" 2>/dev/null)
|
||||
RC=$?
|
||||
}
|
||||
|
||||
last_line() { printf '%s\n' "$OUT" | tail -1; }
|
||||
|
||||
# (a) 抛异常 → 失败
|
||||
OUT=$(run_case 'error("boom")' | tail -1)
|
||||
check "exception counted as failure" "$OUT" "substore cron done: 0 ok, 1 failed"
|
||||
run_case 'error("boom")'
|
||||
check "exception counted as failure" "$(last_line)" "substore cron done: 0 ok, 1 failed"
|
||||
check "exception yields non-zero exit" "$RC" "1"
|
||||
|
||||
# (b) 返回 nil, err → 失败(这是修复的核心:pcall 第一层为 true)
|
||||
OUT=$(run_case 'return nil, "下载失败"' | tail -1)
|
||||
check "nil,err counted as failure" "$OUT" "substore cron done: 0 ok, 1 failed"
|
||||
run_case 'return nil, "下载失败"'
|
||||
check "nil,err counted as failure" "$(last_line)" "substore cron done: 0 ok, 1 failed"
|
||||
check "nil,err yields non-zero exit" "$RC" "1"
|
||||
|
||||
# (b2) 失败原因必须被打印出来
|
||||
OUT=$(run_case 'return nil, "下载失败"')
|
||||
if contains "failure reason surfaced" "$OUT" "下载失败"; then
|
||||
pass=$((pass + 1))
|
||||
echo "PASS failure reason surfaced"
|
||||
@@ -69,12 +77,28 @@ else
|
||||
fi
|
||||
|
||||
# (c) 成功返回节点数 → 成功
|
||||
OUT=$(run_case 'return 3' | tail -1)
|
||||
check "node count counted as success" "$OUT" "substore cron done: 1 ok, 0 failed"
|
||||
run_case 'return 3'
|
||||
check "node count counted as success" "$(last_line)" "substore cron done: 1 ok, 0 failed"
|
||||
check "success yields zero exit" "$RC" "0"
|
||||
|
||||
# (c2) 成功但 0 节点 → 仍然是成功(0 在 Lua 中为真)
|
||||
OUT=$(run_case 'return 0' | tail -1)
|
||||
check "zero nodes still success" "$OUT" "substore cron done: 1 ok, 0 failed"
|
||||
run_case 'return 0'
|
||||
check "zero nodes still success" "$(last_line)" "substore cron done: 1 ok, 0 failed"
|
||||
check "zero nodes yields zero exit" "$RC" "0"
|
||||
|
||||
# (d) 缺少 lua 解释器 → 必须非 0(整条链路不可用,不能静默成功)
|
||||
# 用空 PATH 让 command -v 找不到任何解释器。注意此时连 sh 本身都无法按名
|
||||
# 解析,必须用绝对路径调用,否则拿到的是「找不到 sh」的 127 而非脚本退出码。
|
||||
# logger 也一并消失,脚本对 logger 已做 command -v 保护,不会因此报错。
|
||||
mkdir -p "$TMP/emptybin"
|
||||
OUT=$(SUBSTORE_DATA_DIR="$TMP/data" PATH="$TMP/emptybin" /bin/sh "$SCRIPT" 2>/dev/null)
|
||||
RC=$?
|
||||
check "missing interpreter yields non-zero exit" "$RC" "1"
|
||||
|
||||
# (e) 订阅文件不存在 → 无事可做,仍是 0
|
||||
OUT=$(SUBSTORE_DATA_DIR="$TMP/nodata" LUA_PATH="$TMP/stub/?.lua" sh "$SCRIPT" 2>/dev/null)
|
||||
RC=$?
|
||||
check "no subscriptions file yields zero exit" "$RC" "0"
|
||||
|
||||
echo ""
|
||||
echo "$pass passed, $fail failed"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- security_fixes_test.lua — 安全/健壮性修复的回归测试
|
||||
-- 用法:lua5.1 tests/security_fixes_test.lua
|
||||
--
|
||||
-- 覆盖本轮修复:
|
||||
-- 1. util.shq 单引号转义(string.format("%q") 不是 shell 安全的)
|
||||
-- 2. http.parse_url 剥离 query / fragment(SSRF fail-open 绕过)
|
||||
-- 3. core.save_meta 的 CLEAR 语义(无法表达「清空字段」)
|
||||
-- 4. node 改名:Lua 模式里的 `-` 是惰性量词、`|` 无 alternation
|
||||
-- 5. 模板改名:替换值里的 `%` 必须转义
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local util = require("substore.util")
|
||||
local http = require("substore.http")
|
||||
local node = require("substore.node")
|
||||
local core = require("substore.core")
|
||||
|
||||
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
|
||||
|
||||
-- ---------- 1. util.shq ----------
|
||||
-- 关键性质:拼进 shell 命令行后,命令替换不会发生。
|
||||
-- 用真实的 sh -c 验证,而不是只看字符串长相。
|
||||
local function sh_echo(s)
|
||||
local f = os.tmpname()
|
||||
-- 两层转义:外层 shq 把「echo <内层 shq(s)>」整体交给 sh -c,
|
||||
-- 内层 shq 才是被测对象。若 shq 失效,内层替换就会真的执行。
|
||||
os.execute("sh -c " .. util.shq("echo " .. util.shq(s)) .. " > " .. util.shq(f))
|
||||
local fh = io.open(f, "r")
|
||||
local out = fh and fh:read("*a") or ""
|
||||
if fh then fh:close() end
|
||||
os.remove(f)
|
||||
return (out:gsub("\n$", ""))
|
||||
end
|
||||
|
||||
check("shq wraps in single quotes", util.shq("abc") == "'abc'")
|
||||
check("shq escapes embedded single quote", util.shq("a'b") == "'a'\\''b'")
|
||||
check("shq nil becomes empty", util.shq(nil) == "''")
|
||||
check("shq number coerced", util.shq(42) == "'42'")
|
||||
|
||||
-- 双引号包裹(format("%q") 的做法)在 /bin/sh 里仍会做 $() 与 `` 替换
|
||||
check("shq neutralizes $() substitution", sh_echo("$(echo pwned)") == "$(echo pwned)")
|
||||
check("shq neutralizes backtick substitution", sh_echo("`echo pwned`") == "`echo pwned`")
|
||||
check("shq neutralizes quote breakout", sh_echo("'; echo pwned; '") == "'; echo pwned; '")
|
||||
check("shq neutralizes semicolon", sh_echo("a; echo pwned") == "a; echo pwned")
|
||||
|
||||
-- ---------- 2. http.parse_url:query / fragment ----------
|
||||
-- 这些 URL 的 host 是回环/内网,必须解析成回环/内网地址,
|
||||
-- 交给 check_public 时才会被拦下;解析成 "127.0.0.1?a=1" 会因 DNS 失败而放行。
|
||||
local u1 = http.parse_url("http://127.0.0.1?a=1")
|
||||
check("query stripped from host", u1 and u1.host == "127.0.0.1")
|
||||
check("query stripped keeps default port", u1 and u1.port == "80")
|
||||
|
||||
local u2 = http.parse_url("http://127.0.0.1#frag")
|
||||
check("fragment stripped from host", u2 and u2.host == "127.0.0.1")
|
||||
|
||||
local u3 = http.parse_url("http://127.0.0.1:8080/path?x=1#y")
|
||||
check("port survives path/query/fragment", u3 and u3.host == "127.0.0.1" and u3.port == "8080")
|
||||
|
||||
local u4 = http.parse_url("http://evil@127.0.0.1/")
|
||||
check("userinfo stripped", u4 and u4.host == "127.0.0.1")
|
||||
|
||||
local u5 = http.parse_url("http://user:pw@127.0.0.1:8080/")
|
||||
check("userinfo with password stripped", u5 and u5.host == "127.0.0.1" and u5.port == "8080")
|
||||
|
||||
local u6 = http.parse_url("https://example.com/x")
|
||||
check("https default port", u6 and u6.host == "example.com" and u6.port == "443")
|
||||
|
||||
local u7 = http.parse_url("http://[::1]:8080/x")
|
||||
check("ipv6 literal parsed", u7 and u7.host == "::1" and u7.port == "8080")
|
||||
|
||||
local u8 = http.parse_url("ftp://example.com/")
|
||||
check("non-http scheme rejected", u8 == nil)
|
||||
|
||||
-- ---------- 3. core.save_meta 的 CLEAR ----------
|
||||
local tmp = os.tmpname() .. "_substore_sec"
|
||||
os.remove(tmp)
|
||||
core.DATA_DIR = tmp
|
||||
core.LIST_FILE = tmp .. "/subscriptions.json"
|
||||
core.NODES_DIR = tmp .. "/nodes"
|
||||
core.ensure_dirs()
|
||||
|
||||
local id = core.add("sec", "http://example.com/sub")
|
||||
check("add returns id", type(id) == "string")
|
||||
|
||||
core.save_meta(id, { upload = 100, download = 200, total = 300, expire = 400 })
|
||||
local m1 = core.get(id)
|
||||
check("save_meta writes fields", m1.upload == 100 and m1.expire == 400)
|
||||
|
||||
-- 传 nil 无法表达删除:pairs 永远不会给出 nil 值
|
||||
core.save_meta(id, { upload = nil, download = nil, total = nil, expire = nil })
|
||||
local m2 = core.get(id)
|
||||
check("nil patch cannot clear (documents why CLEAR exists)", m2.upload == 100)
|
||||
|
||||
core.save_meta(id, {
|
||||
upload = core.CLEAR, download = core.CLEAR,
|
||||
total = core.CLEAR, expire = core.CLEAR,
|
||||
})
|
||||
local m3 = core.get(id)
|
||||
check("CLEAR removes upload", m3.upload == nil)
|
||||
check("CLEAR removes download", m3.download == nil)
|
||||
check("CLEAR removes total", m3.total == nil)
|
||||
check("CLEAR removes expire", m3.expire == nil)
|
||||
check("CLEAR keeps other fields", m3.name == "sec" and m3.url == "http://example.com/sub")
|
||||
|
||||
os.execute("rm -rf " .. util.shq(tmp))
|
||||
|
||||
-- ---------- 4. 改名:`-` 与 `|` ----------
|
||||
local function rename_one(name, rules)
|
||||
local ns = node.rename_with_rules({ { name = name, proto = "vmess", server = "1.2.3.4", port = 443 } }, rules)
|
||||
return ns[1].name
|
||||
end
|
||||
|
||||
-- Lua 模式里 `-` 是惰性量词:不转义则 "Node-42" 永远匹配不上 "Node-(%d+)"
|
||||
local r1 = { { type = "regex", pattern = "Node-(\\d+)", replacement = "N$1" } }
|
||||
check("dash in pattern is literal", rename_one("Node-42", r1) == "N42")
|
||||
check("dash pattern still matches without dash", rename_one("Node42", r1) == "Node42")
|
||||
|
||||
-- Lua 模式没有 alternation:顶层 `|` 必须拆成多条候选依次替换
|
||||
local r2 = { { type = "regex", pattern = "^HK-|^US-", replacement = "R-" } }
|
||||
check("alternation first branch", rename_one("HK-a", r2) == "R-a")
|
||||
check("alternation second branch", rename_one("US-b", r2) == "R-b")
|
||||
check("alternation non-matching branch untouched", rename_one("JP-c", r2) == "JP-c")
|
||||
|
||||
-- 分组内的 `|` 不支持(Lua 的 () 只是捕获,没有「或」语义)。
|
||||
-- 关键是不能把它当顶层 | 拆开:拆开会产生 `^(HK` / `US` / `JP)%-(.*)$`
|
||||
-- 这种残缺模式,三段都匹配不上;此处只要求「不误伤」——按字面处理、不匹配。
|
||||
local r2b = { { type = "regex", pattern = "^(HK|US)-(.*)$", replacement = "R-$2" } }
|
||||
check("group alternation is literal (unsupported)", rename_one("HK-a", r2b) == "HK-a")
|
||||
check("group alternation does not corrupt name", rename_one("US-b", r2b) == "US-b")
|
||||
|
||||
-- 字符类里的 `|` 是字面字符,不能被当分隔符拆开
|
||||
local r2c = { { type = "regex", pattern = "^[|]x$", replacement = "PIPE" } }
|
||||
check("pipe inside class is literal", rename_one("|x", r2c) == "PIPE")
|
||||
|
||||
-- 字符类里的 `-` 是范围符号,不能被转义成 %-
|
||||
local r3 = { { type = "regex", pattern = "^[a-z]+(\\d+)$", replacement = "X$1" } }
|
||||
check("dash inside class stays a range", rename_one("abc123", r3) == "X123")
|
||||
|
||||
-- 反向引用仍然可用
|
||||
local r4 = { { type = "regex", pattern = "^(.*):(\\d+)$", replacement = "$1" } }
|
||||
check("backreference still works", rename_one("1.2.3.4:443", r4) == "1.2.3.4")
|
||||
|
||||
-- ---------- 5. 模板改名的 % 转义 ----------
|
||||
local r5 = { { type = "template", template = "[50% OFF] {server}" } }
|
||||
check("percent preserved in template", rename_one("x", r5) == "[50% OFF] 1.2.3.4")
|
||||
|
||||
local r6 = { { type = "template", template = "{name}-{port}" } }
|
||||
check("template fields substituted", rename_one("zz", r6) == "zz-443")
|
||||
|
||||
print("")
|
||||
print(passed .. " passed, " .. failed .. " failed")
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -247,12 +247,25 @@ check("awg conf Itime", awg_out and awg_out:find("Itime = 30", 1, true) ~= nil)
|
||||
-- range 必须原样保留(不能变成数字)
|
||||
check("awg conf H1 range kept", awg_out and awg_out:find("H1 = 1000-2000", 1, true) ~= nil)
|
||||
check("awg conf H4 range kept", awg_out and awg_out:find("H4 = 7000-8000", 1, true) ~= nil)
|
||||
-- 多词键:宁可不输出,也不能输出错误键名
|
||||
-- 多词键:名字必须与 amneziawg-tools 的写法逐字符一致,不能靠「首字母大写」猜
|
||||
check("awg conf no mangled Header-protection-key",
|
||||
awg_out and awg_out:find("Header-protection-key", 1, true) == nil)
|
||||
check("awg conf no mangled Random-trailers",
|
||||
awg_out and awg_out:find("Random-trailers", 1, true) == nil)
|
||||
check("awg conf unmappable value not leaked", awg_out and awg_out:find("c2VjcmV0", 1, true) == nil)
|
||||
-- AWG 3.0/3.1 多词键名核对自 amneziawg-tools src/config.c,因此应当输出
|
||||
check("awg conf HeaderProtectionKey",
|
||||
awg_out and awg_out:find("HeaderProtectionKey = c2VjcmV0", 1, true) ~= nil)
|
||||
check("awg conf RandomTrailers on/off", awg_out and awg_out:find("RandomTrailers = on", 1, true) ~= nil)
|
||||
-- 真正未知的键仍然必须丢弃(不猜语义)
|
||||
local awg_unknown = wgconf.generate({ {
|
||||
proto = "wireguard", name = "awg", server = "wg.example.com", port = 51820,
|
||||
["private-key"] = "PRIV", ["public-key"] = "PUB",
|
||||
["amnezia-wg-option"] = { jc = 5, ["totally-unknown-key"] = "SECRETVALUE" },
|
||||
} })
|
||||
check("awg conf unknown key dropped",
|
||||
awg_unknown and awg_unknown:find("SECRETVALUE", 1, true) == nil)
|
||||
check("awg conf unknown key name absent",
|
||||
awg_unknown and awg_unknown:find("totally-unknown-key", 1, true) == nil)
|
||||
-- 已知键按 .conf 名排序,保证可 diff
|
||||
local iH1 = awg_out and awg_out:find("H1 = ", 1, true)
|
||||
local iJc = awg_out and awg_out:find("Jc = ", 1, true)
|
||||
@@ -271,6 +284,203 @@ if art and art.nodes and art.nodes[1] then
|
||||
check("awg conf roundtrip itime", ao.itime == 30)
|
||||
end
|
||||
|
||||
-- ---------- AmneziaWG 3.0 / 3.1 字段导入 ----------
|
||||
-- 键名与类型核对自 amneziawg-tools src/config.c 与 mihomo AmneziaWGOption:
|
||||
-- 规范名用 mihomo 的 proxy tag(小写连字符),布尔键只认 on/off/十进制数。
|
||||
local AWG3_CONF = [[
|
||||
[Interface]
|
||||
PrivateKey = PRIV3
|
||||
Address = 10.0.0.2/32
|
||||
Jc = 4
|
||||
HeaderProtectionKey = AAAA
|
||||
ContentPaddingAddition = 16
|
||||
RekeyAfterTime = 120
|
||||
RekeyTimeout = 5
|
||||
RejectAfterTime = 180
|
||||
KeepaliveTimeout = 10
|
||||
MaxHandshakeAttempts = 18
|
||||
RandomTrailers = on
|
||||
DisableCookies = off
|
||||
|
||||
[Peer]
|
||||
PublicKey = PUB3
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
]]
|
||||
local r3 = parser.parse(AWG3_CONF, "wireguard-conf")
|
||||
check("awg3 parses", r3 ~= nil and r3.nodes and #r3.nodes == 1)
|
||||
local o3 = r3 and r3.nodes[1] and r3.nodes[1]["amnezia-wg-option"] or {}
|
||||
check("awg3 HeaderProtectionKey", o3["header-protection-key"] == "AAAA")
|
||||
check("awg3 ContentPaddingAddition", o3["content-padding-addition"] == 16)
|
||||
check("awg3 RekeyAfterTime", o3["rekey-after-time"] == 120)
|
||||
check("awg3 RekeyTimeout", o3["rekey-timeout"] == 5)
|
||||
check("awg3 RejectAfterTime", o3["reject-after-time"] == 180)
|
||||
check("awg3 KeepaliveTimeout", o3["keepalive-timeout"] == 10)
|
||||
check("awg3 MaxHandshakeAttempts", o3["max-handshake-attempts"] == 18)
|
||||
check("awg3 RandomTrailers bool true", o3["random-trailers"] == true)
|
||||
check("awg3 DisableCookies bool false", o3["disable-cookies"] == false)
|
||||
check("awg3 legacy jc still parsed", o3.jc == 4)
|
||||
-- 大小写不敏感(config.c 的 process_line)
|
||||
check("awg3 case-insensitive key",
|
||||
(parser.parse((AWG3_CONF:gsub("RandomTrailers", "RANDOMTRAILERS")), "wireguard-conf")
|
||||
or {}).nodes[1]["amnezia-wg-option"]["random-trailers"] == true)
|
||||
-- 十进制布尔(0 假 / 非 0 真)
|
||||
local r3d = parser.parse((AWG3_CONF:gsub("RandomTrailers = on", "RandomTrailers = 1")
|
||||
:gsub("DisableCookies = off", "DisableCookies = 0")), "wireguard-conf")
|
||||
check("awg3 decimal bool true",
|
||||
r3d.nodes[1]["amnezia-wg-option"]["random-trailers"] == true)
|
||||
check("awg3 decimal bool false",
|
||||
r3d.nodes[1]["amnezia-wg-option"]["disable-cookies"] == false)
|
||||
-- 非法布尔值(true/false/yes/no 都不是 config.c 认的写法)必须丢弃,不能留给 mihomo 报错
|
||||
local r3b = parser.parse((AWG3_CONF:gsub("RandomTrailers = on", "RandomTrailers = yes")),
|
||||
"wireguard-conf")
|
||||
check("awg3 invalid bool dropped",
|
||||
r3b.nodes[1]["amnezia-wg-option"]["random-trailers"] == nil)
|
||||
-- 往返:导出后能被自家解析器读回,值一致
|
||||
local r3out = wgconf.generate({ r3.nodes[1] })
|
||||
check("awg3 export HeaderProtectionKey",
|
||||
r3out and r3out:find("HeaderProtectionKey = AAAA", 1, true) ~= nil)
|
||||
check("awg3 export RandomTrailers on",
|
||||
r3out and r3out:find("RandomTrailers = on", 1, true) ~= nil)
|
||||
check("awg3 export DisableCookies off",
|
||||
r3out and r3out:find("DisableCookies = off", 1, true) ~= nil)
|
||||
check("awg3 export MaxHandshakeAttempts",
|
||||
r3out and r3out:find("MaxHandshakeAttempts = 18", 1, true) ~= nil)
|
||||
local r3back = parser.parse(r3out)
|
||||
check("awg3 roundtrip bool preserved",
|
||||
r3back.nodes[1]["amnezia-wg-option"]["random-trailers"] == true
|
||||
and r3back.nodes[1]["amnezia-wg-option"]["disable-cookies"] == false)
|
||||
check("awg3 roundtrip string preserved",
|
||||
r3back.nodes[1]["amnezia-wg-option"]["header-protection-key"] == "AAAA")
|
||||
-- [Peer] 段独有的 AdvancedSecurity 不得被并进 amnezia-wg-option:
|
||||
-- 并进来会在导出时写到 [Interface] 下(错误段落),宁可不支持
|
||||
local r3peer = parser.parse(AWG3_CONF:gsub("Endpoint = 1%.2%.3%.4:51820",
|
||||
"Endpoint = 1.2.3.4:51820\nAdvancedSecurity = on"), "wireguard-conf")
|
||||
check("awg3 peer-only key not merged",
|
||||
r3peer.nodes[1]["amnezia-wg-option"]["advanced-security"] == nil)
|
||||
check("awg3 peer-only key not exported",
|
||||
(wgconf.generate({ r3peer.nodes[1] }) or ""):find("AdvancedSecurity", 1, true) == nil)
|
||||
|
||||
-- ---------- 多个 [Peer]:每个对端一个节点 ----------
|
||||
local MULTI_PEER = [[
|
||||
[Interface]
|
||||
PrivateKey = PRIVM
|
||||
Address = 10.0.0.2/32
|
||||
Jc = 7
|
||||
|
||||
[Peer]
|
||||
PublicKey = PEER1
|
||||
PresharedKey = PSK1
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 1.2.3.4:51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = PEER2
|
||||
AllowedIPs = 10.0.0.0/8
|
||||
Endpoint = 5.6.7.8:51820
|
||||
]]
|
||||
local mp = parser.parse(MULTI_PEER, "wireguard-conf")
|
||||
check("multi-peer yields two nodes", mp ~= nil and mp.nodes and #mp.nodes == 2)
|
||||
if mp and mp.nodes and #mp.nodes == 2 then
|
||||
local a, b = mp.nodes[1], mp.nodes[2]
|
||||
check("multi-peer peer1 server", a.server == "1.2.3.4" and a.port == 51820)
|
||||
check("multi-peer peer2 server", b.server == "5.6.7.8" and b.port == 51820)
|
||||
check("multi-peer peer1 public-key", a["public-key"] == "PEER1")
|
||||
check("multi-peer peer2 public-key", b["public-key"] == "PEER2")
|
||||
-- 关键回归:不能把 A 段的 PresharedKey 拼到 B 段上
|
||||
check("multi-peer peer1 psk", a["pre-shared-key"] == "PSK1")
|
||||
check("multi-peer peer2 psk not inherited", b["pre-shared-key"] == nil)
|
||||
check("multi-peer peer1 allowed-ips", table.concat(a["allowed-ips"], ",") == "0.0.0.0/0")
|
||||
check("multi-peer peer2 allowed-ips", table.concat(b["allowed-ips"], ",") == "10.0.0.0/8")
|
||||
-- [Interface] 设置由两个节点共享
|
||||
check("multi-peer shared private-key", a["private-key"] == "PRIVM" and b["private-key"] == "PRIVM")
|
||||
check("multi-peer shared ip", a.ip == "10.0.0.2/32" and b.ip == "10.0.0.2/32")
|
||||
check("multi-peer shared awg", a["amnezia-wg-option"].jc == 7 and b["amnezia-wg-option"].jc == 7)
|
||||
-- 不能共享同一个表:改一个不能影响另一个
|
||||
check("multi-peer awg tables distinct",
|
||||
a["amnezia-wg-option"] ~= b["amnezia-wg-option"])
|
||||
-- 多节点导出仍按 §55/§56 明确报错,而不是静默拼接
|
||||
local merr = wgconf.generate({ a, b })
|
||||
check("multi-peer export still refuses", merr == nil)
|
||||
end
|
||||
check("conf with no Peer rejected",
|
||||
(parser.parse("[Interface]\nPrivateKey = X\n", "wireguard-conf")) == nil)
|
||||
|
||||
-- ---------- LuCI 表单路径:字符串 → 统一模型 ----------
|
||||
-- 表单里所有输入框的值都是字符串,而统一模型(见 .conf 解析)里
|
||||
-- allowed-ips / reserved / dns 是数组、amnezia-wg-option 是 table。
|
||||
-- 不归一就会出现两种症状:
|
||||
-- (a) amnezia-wg-option 是字符串 → 下游三处 type(...)=="table" 全部失败,
|
||||
-- UI 里编辑过的 WireGuard 节点导出时丢掉全部 AmneziaWG 参数(静默)
|
||||
-- (b) allowed-ips / reserved / dns 是标量字符串 → mihomo / sing-box
|
||||
-- 的对应字段是列表类型,导出结果非法
|
||||
do
|
||||
local util = require("substore.util")
|
||||
local wgconf = require("substore.output_wireguard_conf")
|
||||
local payload = util.json_encode({ {
|
||||
type = "wireguard", name = "wgform", server = "1.2.3.4", port = "51820",
|
||||
["private-key"] = "PRIVF", ["public-key"] = "PUBF",
|
||||
ip = "10.0.0.2/32", ipv6 = "fd00::2/128",
|
||||
["allowed-ips"] = "0.0.0.0/0, ::/0",
|
||||
reserved = "1, 2, 3",
|
||||
dns = "1.1.1.1, 8.8.8.8",
|
||||
["amnezia-wg-option"] = '{"jc":4,"jmin":40,"jmax":70,"s1":30,"s2":40,"h1":1234,"random-trailers":true}',
|
||||
} })
|
||||
local res = parser.parse_local(payload, "form")
|
||||
local n = res and res.nodes and res.nodes[1]
|
||||
check("form: node parsed", n ~= nil)
|
||||
check("form: awg decoded to table", type(n["amnezia-wg-option"]) == "table")
|
||||
check("form: awg jc value", n["amnezia-wg-option"].jc == 4)
|
||||
check("form: awg bool preserved", n["amnezia-wg-option"]["random-trailers"] == true)
|
||||
check("form: allowed-ips is array",
|
||||
type(n["allowed-ips"]) == "table" and #n["allowed-ips"] == 2)
|
||||
check("form: reserved is numeric array",
|
||||
type(n.reserved) == "table" and n.reserved[3] == 3)
|
||||
check("form: dns multi becomes array", type(n.dns) == "table" and #n.dns == 2)
|
||||
|
||||
-- (a) 导出 .conf:AmneziaWG 参数必须在
|
||||
local conf = wgconf.generate({ n })
|
||||
check("form: .conf keeps Jc", conf ~= nil and conf:find("Jc = 4", 1, true) ~= nil)
|
||||
check("form: .conf keeps RandomTrailers as on/off",
|
||||
conf ~= nil and conf:find("RandomTrailers = on", 1, true) ~= nil)
|
||||
|
||||
-- (b) 导出 clash:必须是 YAML 列表而不是带引号的标量
|
||||
local cm = output_clash_meta.generate({ n })
|
||||
check("form: clash allowed-ips is a list",
|
||||
cm:find("allowed%-ips:%s*\n%s+%- 0%.0%.0%.0/0") ~= nil)
|
||||
check("form: clash has no scalar allowed-ips",
|
||||
cm:find('allowed%-ips: "') == nil)
|
||||
check("form: clash amnezia-wg-option emitted",
|
||||
cm:find("amnezia%-wg%-option:") ~= nil and cm:find("jc: 4") ~= nil)
|
||||
|
||||
-- sing-box 的 allowed_ips / reserved / dns 都是数组
|
||||
local sb = output_singbox.generate({ n })
|
||||
check("form: singbox allowed_ips array", sb:find('"allowed_ips":%[') ~= nil)
|
||||
check("form: singbox reserved array", sb:find('"reserved":%[1,2,3%]') ~= nil)
|
||||
check("form: singbox dns array", sb:find('"dns":%[') ~= nil)
|
||||
|
||||
-- 单值 DNS 仍保持字符串(与 .conf 解析一致)
|
||||
local single = parser.parse_local(util.json_encode({ {
|
||||
type = "wireguard", server = "1.2.3.4", port = "51820", dns = "1.1.1.1",
|
||||
} }), "form").nodes[1]
|
||||
check("form: single dns stays string", single.dns == "1.1.1.1")
|
||||
|
||||
-- 非法 JSON 必须明确报错,不能留个字符串让它在导出时无声消失
|
||||
local bad, berr = parser.parse_local(util.json_encode({ {
|
||||
type = "wireguard", server = "1.2.3.4", port = "51820",
|
||||
["amnezia-wg-option"] = "{not json",
|
||||
} }), "form")
|
||||
check("form: invalid awg json rejected", bad == nil)
|
||||
check("form: invalid awg json error message", type(berr) == "string" and #berr > 0)
|
||||
|
||||
-- 空字符串等于「未设置」
|
||||
local empty = parser.parse_local(util.json_encode({ {
|
||||
type = "wireguard", server = "1.2.3.4", port = "51820",
|
||||
["amnezia-wg-option"] = "",
|
||||
} }), "form").nodes[1]
|
||||
check("form: empty awg json cleared", empty["amnezia-wg-option"] == nil)
|
||||
end
|
||||
|
||||
-- ---------- 结果 ----------
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -10,7 +10,7 @@ include $(INCLUDE_DIR)/kernel.mk
|
||||
|
||||
PKG_NAME:=my-default-settings
|
||||
PKG_VERSION:=2
|
||||
PKG_RELEASE:=26
|
||||
PKG_RELEASE:=27
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ c=1024; nf=16384
|
||||
[ "$mem" -ge 524288 ] && { c=10000; nf=131072; }
|
||||
[ "$mem" -ge 1048576 ] && { c=10000; nf=262144; }
|
||||
|
||||
if ! grep -q '/etc/firewall.user' /etc/config/firewall 2>/dev/null; then
|
||||
uci -q set firewall.firewall_user=include
|
||||
uci -q set firewall.firewall_user.path='/etc/firewall.user'
|
||||
uci -q set firewall.firewall_user.type='script'
|
||||
uci -q set firewall.firewall_user.reload='1'
|
||||
fi
|
||||
|
||||
# 4. 首次开机参数统一批量预设
|
||||
if [ "$version" -lt 1 ]; then
|
||||
uci -q batch <<-EOF
|
||||
|
||||
Reference in new issue
Block a user