mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 00:03:56 +08:00
🌴 Sync 2026-10-06 02:20:56
This commit is contained in:
1 parent
5c201dd3af
commit
487c4fad50
111 files changed
+4641
-746
No files matched your search
@@ -277,6 +277,17 @@ function load_routing(uci) {
|
||||
/* access_control is two single sections: `control` (lan_proxy_mode,
|
||||
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
|
||||
function load_access_control(uci) {
|
||||
/* Normalised to an array. uci.get() hands a single-value option over as a
|
||||
* *string* and a list as an array, and `for (let url in <string>)` iterates
|
||||
* zero times in ucode - so a URL written with `uci set` instead of
|
||||
* `add_list` (or carried over from an older config) made the whole
|
||||
* subscription update a silent no-op: exit 0, no log, nothing fetched.
|
||||
* Measured on the device: list -> array, one iteration; option -> string,
|
||||
* zero. */
|
||||
const sub_urls_raw = uci.get(UCICONFIG, SECTION.subscription, 'subscription_url');
|
||||
const sub_urls = sub_urls_raw == null ? []
|
||||
: (type(sub_urls_raw) === 'array' ? sub_urls_raw : [ sub_urls_raw ]);
|
||||
|
||||
return {
|
||||
control: load_settings(uci, SECTION.control, [
|
||||
'bind_interface', 'lan_proxy_mode'
|
||||
@@ -290,7 +301,13 @@ function load_access_control(uci) {
|
||||
'packet_encoding', 'update_via_proxy',
|
||||
'filter_nodes', 'user_agent'
|
||||
]),
|
||||
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
|
||||
/* Normalised to an array. uci.get() hands a single-value option over as a
|
||||
* *string* and a list as an array, and `for (let url in <string>)` iterates
|
||||
* zero times in ucode - so a URL written with `uci set` instead of
|
||||
* `add_list` (or migrated from an older config) made the whole subscription
|
||||
* update a silent no-op: exit 0, no log, nothing fetched. Measured on the
|
||||
* device: list -> array, one iteration; option -> string, zero. */
|
||||
subscription_urls: sub_urls,
|
||||
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,12 +282,22 @@ const v6_warn_comment = ((ipv6_support === '1') && !cn_ipv6_ready)
|
||||
|
||||
let self_mark, redirect_port,
|
||||
tproxy_port, tproxy_mark,
|
||||
tun_name, tun_mark;
|
||||
tun_name, tun_mark, tun_self_mark;
|
||||
|
||||
if (match(proxy_mode, /redirect/)) {
|
||||
self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||
redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
|
||||
}
|
||||
/* Whether UDP can actually be proxied in this configuration: there is a
|
||||
* UDP-capable node, or the user took the decision over with custom rules.
|
||||
* The UDP tproxy chain already uses exactly this condition, and the QUIC reject
|
||||
* has to agree with it - it keyed off `routing_mode !== 'custom'` only, so a
|
||||
* router with `main_udp_node` set still rejected QUIC on UDP 443 (measured on
|
||||
* the device: 1722 QUIC packets rejected in one sample) and browsers that had
|
||||
* learned HTTP/3 fell back to TCP needlessly. Now the reject is emitted only
|
||||
* when UDP really is not proxyable. */
|
||||
const udp_proxyable = (outbound_udp_node !== 'nil' || routing_mode === 'custom');
|
||||
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||
tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332');
|
||||
@@ -296,6 +306,12 @@ if (match(proxy_mode, /tproxy/))
|
||||
if (match(proxy_mode, /tun/)) {
|
||||
tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0');
|
||||
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_mark('102');
|
||||
/* The mark sing-box stamps on its own outbound sockets in tun mode
|
||||
* (generator: route.default_mark, same UCI option as self_mark elsewhere).
|
||||
* The TUN output chain has to return on it, otherwise the tunnel captures
|
||||
* sing-box's own dial - including the one to the node - and the only guard
|
||||
* it had was `iifname <tun>`, which cannot match on the output hook. */
|
||||
tun_self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||
}
|
||||
|
||||
const control_options = [
|
||||
@@ -312,6 +328,98 @@ const control_info = {};
|
||||
for (let i in control_options)
|
||||
control_info[i] = uci.get(cfgname, 'control', i);
|
||||
|
||||
/* Devices of the wan zone, resolved by fw4 itself.
|
||||
*
|
||||
* The intercept layer must not cover the wan side. When the user names
|
||||
* interfaces the guards below use exactly those; when the option is empty the
|
||||
* UI says "all", and "all" used to include wan - so wan-inbound traffic to the
|
||||
* router's own address on a routing_port was captured by the redirect / tproxy
|
||||
* jump and by the DNS hijack as well. It happened to be masked on the device
|
||||
* (dnsmasq's own dns_redirect suppressed the DNS half, fw4's input policy
|
||||
* dropped the rest), but the hole was structural: any wan-side service on one
|
||||
* of the routing ports would have been hijacked. Empty now means "everything
|
||||
* except the wan zone".
|
||||
*
|
||||
* fw4's own ruleset template renders zone devices from `zone.match_devices`,
|
||||
* so this asks the same source. A render outside fw4 (the off-target test
|
||||
* harness has no zone state) leaves the list empty and the guards below fall
|
||||
* back to the previous behaviour. */
|
||||
let wan_devices = [];
|
||||
|
||||
try {
|
||||
/* fw4.load() is what resolves a zone's *logical* networks to the devices
|
||||
* that actually appear in `iifname` - the wan zone is `network wan` but
|
||||
* the device is `pppoe-wan`, and this template runs OUTSIDE fw4's own
|
||||
* process, where the module has no state at all. Two wrong answers were
|
||||
* measured on the device before this:
|
||||
*
|
||||
* - no load at all: `zones()` is null, the loop threw, the catch left
|
||||
* the list empty and the guard silently disappeared from the rendered
|
||||
* ruleset (that is the state this package shipped in r46);
|
||||
* - fw4.read_state(): parses /var/run/fw4.state, which carries the
|
||||
* LOGICAL names ("wan", "wan_6"), so the guard rendered as
|
||||
* `meta iifname { wan }` and could never match - a guard that looks
|
||||
* present and does nothing.
|
||||
*
|
||||
* load() builds the model through ubus the same way fw4 itself does, and
|
||||
* yields match_devices = [ "pppoe-wan" ]. It is read-only, ~tens of ms,
|
||||
* and its warnings go to stderr, which the caller discards. */
|
||||
fw4.load();
|
||||
|
||||
for (let zone in (fw4.zones() || []))
|
||||
if (zone.name === 'wan')
|
||||
for (let device in zone.match_devices)
|
||||
push(wan_devices, device);
|
||||
}
|
||||
catch (e) { }
|
||||
|
||||
wan_devices = sort(uniq(wan_devices));
|
||||
|
||||
/* Validated, nft-ready form of every UCI list, computed once.
|
||||
*
|
||||
* The template used to gate on the *raw* UCI value and interpolate the
|
||||
* validator-backed helper. A list that is non-empty but has no usable entry -
|
||||
* hand-edited UCI, an address in the wrong family - therefore rendered
|
||||
* `ip daddr counter return`, an nft syntax error that fails the whole fw4
|
||||
* transaction, or `elements = { }`. Gating on the helper's result instead
|
||||
* means an unusable list simply emits no rule. The rendered output for a
|
||||
* usable configuration is unchanged (the helpers already dropped invalid
|
||||
* entries one by one). */
|
||||
const NFTARR_FIELDS = {
|
||||
listen_interfaces: iface_to_nftarr,
|
||||
lan_proxy_ipv4_ips: ipv4_to_nftarr,
|
||||
lan_proxy_ipv6_ips: ipv6_to_nftarr,
|
||||
lan_proxy_mac_addrs: mac_to_nftarr,
|
||||
lan_direct_ipv4_ips: ipv4_to_nftarr,
|
||||
lan_direct_ipv6_ips: ipv6_to_nftarr,
|
||||
lan_direct_mac_addrs: mac_to_nftarr,
|
||||
lan_gaming_mode_ipv4_ips: ipv4_to_nftarr,
|
||||
lan_gaming_mode_ipv6_ips: ipv6_to_nftarr,
|
||||
lan_gaming_mode_mac_addrs: mac_to_nftarr,
|
||||
lan_global_proxy_ipv4_ips: ipv4_to_nftarr,
|
||||
lan_global_proxy_ipv6_ips: ipv6_to_nftarr,
|
||||
lan_global_proxy_mac_addrs: mac_to_nftarr,
|
||||
wan_proxy_ipv4_ips: ipv4_to_nftarr,
|
||||
wan_proxy_ipv6_ips: ipv6_to_nftarr,
|
||||
wan_direct_ipv4_ips: ipv4_to_nftarr,
|
||||
wan_direct_ipv6_ips: ipv6_to_nftarr
|
||||
};
|
||||
const nftarr = {};
|
||||
|
||||
for (let field in NFTARR_FIELDS)
|
||||
nftarr[field] = NFTARR_FIELDS[field](control_info[field] || []);
|
||||
|
||||
/* The local-output chain also exempts `lo`, which the tproxy guard adds by
|
||||
* hand. Same precomputation, so the guard cannot disagree with the list. */
|
||||
nftarr.listen_interfaces_lo = iface_to_nftarr([ ...(control_info.listen_interfaces || []), 'lo' ]);
|
||||
|
||||
/* The routing-port filter. Gating on the raw option while interpolating
|
||||
* ports_to_nftarr() had the same defect, and there the fallback direction
|
||||
* matters: an empty rendering would make `tcp dport != @homeproxy_routing_port`
|
||||
* match everything and send all TCP traffic direct. An unusable list
|
||||
* therefore disables the filter (ports are proxied) instead of widening it. */
|
||||
const routing_port_set = routing_port ? ports_to_nftarr(routing_port) : null;
|
||||
|
||||
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
|
||||
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
|
||||
/* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port,
|
||||
@@ -417,8 +525,8 @@ set homeproxy_wan_proxy_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_proxy_ipv4_ips) }} }
|
||||
{% if (nftarr.wan_proxy_ipv4_ips): %}
|
||||
elements = { {{ nftarr.wan_proxy_ipv4_ips }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
@@ -427,8 +535,8 @@ set homeproxy_wan_proxy_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_proxy_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_proxy_ipv6_ips) }} }
|
||||
{% if (nftarr.wan_proxy_ipv6_ips): %}
|
||||
elements = { {{ nftarr.wan_proxy_ipv6_ips }} }
|
||||
{% endif /* wan_proxy_ipv6_ips*/ %}
|
||||
}
|
||||
{% endif /* v6_handled */ %}
|
||||
@@ -437,8 +545,8 @@ set homeproxy_wan_direct_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
elements = { {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} }
|
||||
{% if (nftarr.wan_direct_ipv4_ips): %}
|
||||
elements = { {{ nftarr.wan_direct_ipv4_ips }} }
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
@@ -447,18 +555,18 @@ set homeproxy_wan_direct_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
elements = { {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} }
|
||||
{% if (nftarr.wan_direct_ipv6_ips): %}
|
||||
elements = { {{ nftarr.wan_direct_ipv6_ips }} }
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
}
|
||||
{% endif /* v6_handled */ %}
|
||||
|
||||
{% if (routing_port): %}
|
||||
{% if (routing_port_set): %}
|
||||
set homeproxy_routing_port {
|
||||
type inet_service
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = { {{ ports_to_nftarr(routing_port) }} }
|
||||
elements = { {{ routing_port_set }} }
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
@@ -501,8 +609,10 @@ set homeproxy_node_addr_v6 {
|
||||
{# DNS hijack & TCP redirect #}
|
||||
chain dstnat {
|
||||
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }}
|
||||
{% if (nftarr.listen_interfaces): %}
|
||||
meta iifname {{ nftarr.listen_interfaces }}
|
||||
{%- elif (!isEmpty(wan_devices)): %}
|
||||
meta iifname != {{ array_to_nftarr(wan_devices) }}
|
||||
{%- endif /* listen_interfaces */ %}
|
||||
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_port }} comment "!{{ cfgname }}: DNS hijack"
|
||||
{% endif /* dns_hijacked */ %}
|
||||
@@ -518,37 +628,39 @@ chain homeproxy_redirect_proxy {
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_proxy_port {
|
||||
{% if (routing_port): %}
|
||||
{% if (routing_port_set): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_redirect_proxy
|
||||
}
|
||||
|
||||
chain homeproxy_redirect_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% if (nftarr.listen_interfaces): %}
|
||||
meta iifname != {{ nftarr.listen_interfaces }} counter return
|
||||
{% elif (!isEmpty(wan_devices)): %}
|
||||
meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side"
|
||||
{% endif %}
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
|
||||
{% if (nftarr.lan_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
|
||||
{% if (nftarr.lan_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
|
||||
{% if (nftarr.lan_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_redirect
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% if (nftarr.lan_direct_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
@@ -578,14 +690,14 @@ chain homeproxy_redirect {
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% if (nftarr.lan_global_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% if (nftarr.lan_global_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
|
||||
{% if (nftarr.lan_global_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_redirect_proxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
@@ -594,6 +706,11 @@ chain homeproxy_redirect {
|
||||
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
|
||||
{# No QUIC reject here: this whole block only renders when UDP is
|
||||
proxyable (see the gate above), so rejecting QUIC on UDP 443 would
|
||||
break HTTP/3 for traffic that can be proxied. The TUN block below,
|
||||
which renders either way, carries the reject when UDP cannot be
|
||||
proxied. #}
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
@@ -611,14 +728,14 @@ chain homeproxy_redirect {
|
||||
{% endif /* v6_handled */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% if (nftarr.lan_gaming_mode_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
|
||||
{% if (nftarr.lan_gaming_mode_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
|
||||
{% if (nftarr.lan_gaming_mode_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_redirect_proxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_redirect_proxy_port
|
||||
@@ -640,45 +757,47 @@ chain homeproxy_mangle_tproxy {
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tproxy_port {
|
||||
{% if (routing_port): %}
|
||||
{% if (routing_port_set): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
goto homeproxy_mangle_tproxy
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_mark {
|
||||
{% if (routing_port): %}
|
||||
{% if (routing_port_set): %}
|
||||
udp dport != @homeproxy_routing_port counter return
|
||||
{% endif %}
|
||||
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_lanac {
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr([...control_info.listen_interfaces, 'lo']) }} counter return
|
||||
{% if (nftarr.listen_interfaces): %}
|
||||
meta iifname != {{ nftarr.listen_interfaces_lo }} counter return
|
||||
{% elif (!isEmpty(wan_devices)): %}
|
||||
meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side"
|
||||
{% endif %}
|
||||
meta iifname != lo udp dport 53 counter return
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% if (nftarr.lan_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
|
||||
{% if (nftarr.lan_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
|
||||
{% if (nftarr.lan_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_prerouting
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% if (nftarr.lan_direct_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
@@ -706,14 +825,14 @@ chain homeproxy_mangle_prerouting {
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (nftarr.lan_global_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (nftarr.lan_global_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (nftarr.lan_global_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tproxy_port
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
@@ -727,15 +846,11 @@ chain homeproxy_mangle_prerouting {
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
@@ -743,14 +858,14 @@ chain homeproxy_mangle_prerouting {
|
||||
{% endif /* v6_handled */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% if (nftarr.lan_gaming_mode_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
|
||||
{% if (nftarr.lan_gaming_mode_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
|
||||
{% if (nftarr.lan_gaming_mode_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter goto homeproxy_mangle_tproxy
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tproxy_port
|
||||
@@ -816,29 +931,31 @@ chain homeproxy_mangle_lanac {
|
||||
iifname {{ tun_name }} counter return
|
||||
udp dport 53 counter return
|
||||
|
||||
{% if (control_info.listen_interfaces): %}
|
||||
meta iifname != {{ iface_to_nftarr(control_info.listen_interfaces) }} counter return
|
||||
{% if (nftarr.listen_interfaces): %}
|
||||
meta iifname != {{ nftarr.listen_interfaces }} counter return
|
||||
{% elif (!isEmpty(wan_devices)): %}
|
||||
meta iifname {{ array_to_nftarr(wan_devices) }} counter return comment "!{{ cfgname }}: not the WAN side"
|
||||
{% endif %}
|
||||
|
||||
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% if (nftarr.lan_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
|
||||
{% if (nftarr.lan_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
|
||||
{% if (nftarr.lan_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_proxy_mac_addrs }} counter goto homeproxy_mangle_tun
|
||||
{% endif /* lan_proxy_mac_addrs */ %}
|
||||
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_direct_ipv4_ips }} counter return
|
||||
{% endif /* lan_direct_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||
{% if (nftarr.lan_direct_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_direct_ipv6_ips }} counter return
|
||||
{% endif /* lan_direct_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
|
||||
{% if (nftarr.lan_direct_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_direct_mac_addrs }} counter return
|
||||
{% endif /* lan_direct_mac_addrs */ %}
|
||||
{% endif /* lan_proxy_mode */ %}
|
||||
|
||||
@@ -848,7 +965,7 @@ chain homeproxy_mangle_lanac {
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_tun_mark {
|
||||
{% if (routing_port): %}
|
||||
{% if (routing_port_set): %}
|
||||
{% if (proxy_mode === 'tun'): %}
|
||||
tcp dport != @homeproxy_routing_port counter return
|
||||
{% endif /* proxy_mode */ %}
|
||||
@@ -860,6 +977,16 @@ chain homeproxy_mangle_tun_mark {
|
||||
|
||||
chain homeproxy_mangle_tun {
|
||||
iifname {{ tun_name }} counter return
|
||||
{% if (tun_self_mark): %}
|
||||
meta mark {{ tun_self_mark }} counter return comment "!{{ cfgname }}: sing-box's own traffic"
|
||||
{% endif %}
|
||||
|
||||
{% if (node_protect): %}
|
||||
ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
{% if (node_protect && v6_handled): %}
|
||||
ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
|
||||
{% if (v6_handled): %}
|
||||
@@ -872,22 +999,22 @@ chain homeproxy_mangle_tun {
|
||||
{% endif %}
|
||||
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% if (nftarr.lan_global_proxy_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_global_proxy_ipv4_ips }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% if (nftarr.lan_global_proxy_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_global_proxy_ipv6_ips }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
|
||||
{% if (nftarr.lan_global_proxy_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_global_proxy_mac_addrs }} counter goto homeproxy_mangle_tun_mark
|
||||
{% endif /* lan_global_proxy_mac_addrs */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (control_info.wan_direct_ipv4_ips): %}
|
||||
ip daddr {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
|
||||
{% if (nftarr.wan_direct_ipv4_ips): %}
|
||||
ip daddr {{ nftarr.wan_direct_ipv4_ips }} counter return
|
||||
{% endif /* wan_direct_ipv4_ips */ %}
|
||||
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||
ip6 daddr {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
|
||||
{% if (nftarr.wan_direct_ipv6_ips): %}
|
||||
ip6 daddr {{ nftarr.wan_direct_ipv6_ips }} counter return
|
||||
{% endif /* wan_direct_ipv6_ips */ %}
|
||||
|
||||
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||
@@ -895,15 +1022,17 @@ chain homeproxy_mangle_tun {
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
{% if (!udp_proxyable): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* udp_proxyable */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
{% if (!udp_proxyable): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% endif /* udp_proxyable */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
@@ -911,14 +1040,14 @@ chain homeproxy_mangle_tun {
|
||||
{% endif /* v6_handled */ %}
|
||||
{% endif /* routing_mode */ %}
|
||||
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% if (nftarr.lan_gaming_mode_ipv4_ips): %}
|
||||
ip saddr {{ nftarr.lan_gaming_mode_ipv4_ips }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
|
||||
{% if (nftarr.lan_gaming_mode_ipv6_ips): %}
|
||||
ip6 saddr {{ nftarr.lan_gaming_mode_ipv6_ips }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||
ether saddr {{ mac_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
|
||||
{% if (nftarr.lan_gaming_mode_mac_addrs): %}
|
||||
ether saddr {{ nftarr.lan_gaming_mode_mac_addrs }} counter meta mark set {{ tun_mark }}
|
||||
{% endif /* lan_gaming_mode_mac_addrs */ %}
|
||||
|
||||
counter goto homeproxy_mangle_tun_mark
|
||||
|
||||
@@ -164,6 +164,14 @@ function resolve_env(dm) {
|
||||
* lstat() is used because it is unambiguous - there is no second
|
||||
* argument to get wrong - and because the neighbouring stderr-size
|
||||
* check in homeproxy-pro.uc already reads sizes through it. */
|
||||
/* The IPv4 half of the same split. Declared (and referenced) only when the
|
||||
* file is there: the generator used to push china-ip unconditionally, which
|
||||
* made a fresh install - where hp_prepare_runtime_files only produced the
|
||||
* file *after* this ran - fail `sing-box check` with a missing rule-set and
|
||||
* never start. The rule-set is now produced before generation
|
||||
* (hp_prepare_generated_inputs), and this flag keeps a failed generation
|
||||
* degrading to "no mainland address split" instead of a dead service. */
|
||||
china_ip4_ready: lstat(HP_DIR + '/resources/china_ip4.json') !== null,
|
||||
china_ip6_ready: lstat(HP_DIR + '/resources/china_ip6.json') !== null,
|
||||
/* The DNS half of the same split: whether china-domain.json is
|
||||
* there. Same reason, same lstat(), same one-sided default - a
|
||||
|
||||
@@ -67,6 +67,18 @@ export function build_context(dm, env) {
|
||||
const self_mark = match(proxy_mode, /redirect/)
|
||||
? (dm.infra.self_mark || '100') : null;
|
||||
|
||||
/* The same mark, but resolved for every mode.
|
||||
*
|
||||
* In TUN mode self_mark is null (see above), so no outbound carries
|
||||
* `routing_mark` and sing-box's own sockets carried no mark at all - which
|
||||
* meant the firewall's TUN output chain could not exempt them, and the
|
||||
* node-address bypass that exists for tproxy was missing for tun. A LAN
|
||||
* client (or the router itself) dialling the node's own address went back
|
||||
* into the tunnel; measured earlier for tproxy ("wget hangs on the node's
|
||||
* address"), fixed there, never for tun. route.default_mark stamps every
|
||||
* outbound socket with this value, and the TUN chain returns on it. */
|
||||
const tun_self_mark = (dm.infra.self_mark || '100');
|
||||
|
||||
/* proxy-mode-derived ports / TUN params */
|
||||
let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6,
|
||||
tun_mtu, tcpip_stack, endpoint_independent_nat;
|
||||
@@ -137,6 +149,7 @@ export function build_context(dm, env) {
|
||||
* and one of them naming a tag the other never declared fails the
|
||||
* whole config. Default false - an absent env must not turn into an
|
||||
* optimistic "assume the file is there". */
|
||||
china_ip4_ready: (env?.china_ip4_ready === true),
|
||||
china_ip6_ready: (env?.china_ip6_ready === true),
|
||||
/* Whether china-domain.json is on disk. Same one-sided default as
|
||||
* the field above, for the same reason: it is a filesystem fact the
|
||||
@@ -170,7 +183,7 @@ export function build_context(dm, env) {
|
||||
* with no initial_path silently declared as source JSON, and a binary
|
||||
* .srs fetched from a URL would then fail to parse at startup. */
|
||||
ruleset_formats: (env?.ruleset_formats || {}),
|
||||
self_mark, ntp_server, dns_port, mixed_port,
|
||||
self_mark, tun_self_mark, ntp_server, dns_port, mixed_port,
|
||||
redirect_port, tproxy_port,
|
||||
tun_name, tun_addr4, tun_addr6, tun_mtu,
|
||||
tcpip_stack, endpoint_independent_nat,
|
||||
|
||||
@@ -295,12 +295,27 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
||||
if (ctx.cn_ip_fallback === '1') {
|
||||
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in).
|
||||
* Gated on china_ip4_ready as well: the response-match rule below names
|
||||
* the china-ip tag, and a rule_set list may only name declared tags -
|
||||
* with the file absent the declaration is skipped too, so emitting the
|
||||
* reference would make sing-box reject the whole configuration. */
|
||||
if (ctx.cn_ip_fallback === '1' && ctx.china_ip4_ready) {
|
||||
/* The evaluate query goes through main-dns, i.e. through the
|
||||
* tunnel, and it sits on the cold path of every domain that is
|
||||
* not in the direct/proxy/china lists - so it is the one DNS step
|
||||
* that can add a whole round trip to a page load. Emitted bare,
|
||||
* as it was, its timeout fell back to the 10 s DNS default and it
|
||||
* carried no ECS; the package's own reference notes call that out
|
||||
* ("拖慢首包"). A short bound plus the China ECS prefix keeps the
|
||||
* fallback cheap and lets the upstream answer with a domestic
|
||||
* address when it has one. */
|
||||
push(config.dns.rules, {
|
||||
action: 'evaluate',
|
||||
server: 'main-dns',
|
||||
tag: 'cn-fallback'
|
||||
tag: 'cn-fallback',
|
||||
timeout: '2s',
|
||||
client_subnet: '223.5.5.0/24'
|
||||
});
|
||||
/* The response is matched by the addresses the upstream returned,
|
||||
* and the address list has to be the one the route half uses for
|
||||
|
||||
@@ -116,8 +116,9 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
* as mainland. It used to be geoip-cn, with china-ip added later as a
|
||||
* correction for the two disagreeing - two rule-sets, one of them
|
||||
* redundant in this mode, neither able to see the other's data. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china'
|
||||
|| ctx.routing_mode === 'proxy_mainland_china') {
|
||||
if (ctx.china_ip4_ready
|
||||
&& (ctx.routing_mode === 'bypass_mainland_china'
|
||||
|| ctx.routing_mode === 'proxy_mainland_china')) {
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
@@ -173,6 +174,14 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
/* The route half of the mode's default policy - the other consumer of
|
||||
* ctx.proxy_fallback, and the reason dns.final and this line are
|
||||
* asserted together per mode. */
|
||||
/* TUN mode carries no per-outbound `routing_mark` (self_mark is null there,
|
||||
* see context.uc), so sing-box's own dials - including the one to the node -
|
||||
* were unmarked and the firewall's TUN output chain captured them; the
|
||||
* node-address bypass existed for tproxy only. `default_mark` stamps every
|
||||
* outbound socket with the same value the firewall's TUN chain exempts. */
|
||||
if (match(ctx.proxy_mode, /tun/))
|
||||
config.route.default_mark = strToInt(ctx.tun_self_mark);
|
||||
|
||||
config.route.final = ctx.proxy_fallback ? 'main-out' : 'direct-out';
|
||||
|
||||
/* --- proxy-mode rule_set block ----------------------------------- */
|
||||
@@ -222,11 +231,19 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
* resolver to the mode default, so declaring it there would
|
||||
* load 111k suffixes for a rule that does not exist. */
|
||||
if (declaresBuiltinRuleSets(ctx.routing_mode)) {
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-ip',
|
||||
path: HP_DIR + '/resources/china_ip4.json'
|
||||
});
|
||||
/* Declared only when the file is actually there - the same rule the v6
|
||||
* and domain halves already follow, and the reason a fresh install no
|
||||
* longer dies: a `path` that does not exist fails `sing-box check` and
|
||||
* takes the whole configuration (and the service) with it. The
|
||||
* rule-set is produced before generation now, so this is the
|
||||
* belt-and-braces case (a failed generation, a read-only fs). */
|
||||
if (ctx.china_ip4_ready) {
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-ip',
|
||||
path: HP_DIR + '/resources/china_ip4.json'
|
||||
});
|
||||
}
|
||||
|
||||
/* Declared only when the file is actually there: a rule_set pointing
|
||||
* at a missing file takes the whole config down with it, and a router
|
||||
|
||||
@@ -594,9 +594,13 @@ export function wGETVerbose(url, ua) {
|
||||
* base64 inflation. Anything larger is almost certainly an attack or a
|
||||
* misconfiguration.
|
||||
*
|
||||
* The pipeline does cost the exit status: `system()` returns head's, which
|
||||
* is always 0. A fetch failure therefore arrives as an empty body plus the
|
||||
* fetcher's own message on stderr, and that is reported below. */
|
||||
* The pipeline costs the exit status - `system()` returns head's, which is
|
||||
* always 0 - so the fetcher's own status is carried out of it through a
|
||||
* file (see the command below). Without that, a *truncated* transfer was
|
||||
* indistinguishable from a complete one: uclient-fetch exits 4 with
|
||||
* "Connection reset prematurely", and measured on the device a response
|
||||
* that declared 244 bytes and delivered 80 was handed to the parser as a
|
||||
* valid subscription body. */
|
||||
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
|
||||
* and in `a | b >out 2>err` those redirections bind to b only - the
|
||||
* fetcher's stderr would go to the caller's terminal and the failure
|
||||
@@ -607,7 +611,16 @@ export function wGETVerbose(url, ua) {
|
||||
* being waved through on the grounds that it only ever returns a literal:
|
||||
* guard 25 exists to make "is this shell-safe" a mechanical check instead
|
||||
* of a judgement call, and a quoted constant costs nothing. */
|
||||
const output = executeCommand(`{ ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
|
||||
const dir = mkdtemp();
|
||||
const rcpath = dir + '/rc';
|
||||
const output = executeCommand(`{ { ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)}; echo $? >${shellQuote(rcpath)}; } | head -c ${HP_FETCH_CAP + 1}; }`) || {};
|
||||
|
||||
/* The fetcher's status, recovered from the file the pipeline wrote it to.
|
||||
* `head -c` closing the pipe makes the fetcher exit on SIGPIPE (141), which
|
||||
* is why the size check below runs first: a too-large body is reported as
|
||||
* too large, not as a signal. */
|
||||
const fetch_rc = int(trim(read_capped(rcpath, 32))) || 0;
|
||||
cleanup_exec_dir(dir, rcpath, dir + '/unused');
|
||||
let reason = trim(output.stderr || '');
|
||||
reason = reason ? replace(reason, /\s+/g, ' ') : '';
|
||||
/* An HTTP-level failure prints the requested URL back into the message
|
||||
@@ -623,11 +636,13 @@ export function wGETVerbose(url, ua) {
|
||||
if (length(output.stdout || '') > HP_FETCH_CAP)
|
||||
return { content: null, error: `response exceeds the ${HP_FETCH_CAP} byte limit` };
|
||||
|
||||
if (output.exitcode !== 0) {
|
||||
const exitcode = (output.exitcode !== 0) ? output.exitcode : fetch_rc;
|
||||
|
||||
if (exitcode !== 0) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
return { content: null, error: `fetch exited with status ${output.exitcode}: ${reason || 'no error output'}` };
|
||||
return { content: null, error: `fetch exited with status ${exitcode}: ${reason || 'no error output'}` };
|
||||
}
|
||||
|
||||
/* A binary body is not a failed fetch.
|
||||
@@ -645,7 +660,9 @@ export function wGETVerbose(url, ua) {
|
||||
if (output.binary)
|
||||
return { content: null, error: 'the fetch succeeded but the response is binary, not a subscription payload' };
|
||||
|
||||
/* head() masks the fetcher's status, so a failed fetch shows up here. */
|
||||
/* Reached only when the fetcher exited 0 (the status is recovered above),
|
||||
* so an empty body here means a 200 with nothing in it - reported with the
|
||||
* fetcher's own message rather than as a parse failure. */
|
||||
if (!length(trim(output.stdout)) && reason) {
|
||||
if (length(reason) > 200)
|
||||
reason = substr(reason, 0, 200) + '...';
|
||||
|
||||
@@ -262,24 +262,26 @@ hp_rule_set_current() {
|
||||
[ -f "$out" ] && [ -s "$out" ] && [ "$out" -nt "$src" ]
|
||||
}
|
||||
|
||||
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
||||
hp_prepare_runtime_files() {
|
||||
# hp_prepare_generated_inputs <hp-dir> <client-enabled>
|
||||
# Produce every rule-set the generated configuration references, BEFORE the
|
||||
# generator runs.
|
||||
#
|
||||
# Why before: `sing-box check` opens every local rule_set path, and the client
|
||||
# generator declares china-ip from china_ip4.json (route.uc, gated on
|
||||
# china_ip4_ready). When these files were produced *after* the generation step
|
||||
# (they used to live at the end of hp_prepare_runtime_files), a fresh install had
|
||||
# no china_ip4.json at generation time, `sing-box check` failed, and the client
|
||||
# could not start at all - the reload preflight aborts before the rule-set step,
|
||||
# so it could not heal itself either. Measured on the device: generation exit=1
|
||||
# with "parse rule-set[0]: open .../china_ip4.json: no such file or directory",
|
||||
# and exit=0 once the three files had been produced.
|
||||
#
|
||||
# Cheap when nothing changed: hp_rule_set_current skips an unchanged source.
|
||||
hp_prepare_generated_inputs() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local client_enabled="$4"
|
||||
local server_enabled="$5"
|
||||
local client_enabled="$2"
|
||||
|
||||
# The route rules that keep mainland destinations direct match a local
|
||||
# rule-set generated from china_ip4.txt, the same list the firewall
|
||||
# renders its nft set from, so both sides decide from one source. It is
|
||||
# regenerated here as well as by the resource updater: an install that
|
||||
# predates this file (or a list replaced while the service was stopped)
|
||||
# must still get one, or the client config would reference a missing
|
||||
# file and sing-box would refuse to start. A missing source list is not
|
||||
# an error - the generator then emits no route-side rule at all.
|
||||
# china-ip4: the route rule keeping mainland IPv4 destinations direct.
|
||||
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then
|
||||
if hp_rule_set_current "$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json"; then
|
||||
# Unchanged source: keep the file, but re-assert ownership anyway -
|
||||
@@ -295,16 +297,14 @@ hp_prepare_runtime_files() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# IPv6 half of the same arrangement, and the only place the "IPv6 is on
|
||||
# but cannot be classified" condition is reported in a place a user reads
|
||||
# without tcpdump. geoip-cn.srs and china_ip4.json are both IPv4-only, so
|
||||
# without a v6 rule-set a mainland destination reached over IPv6 matches
|
||||
# no route rule at all and falls through to `final` - i.e. to the proxy.
|
||||
# The firewall would normally have returned it first, but that set is
|
||||
# filled from the same china_ip6.txt, so a list that is missing or has no
|
||||
# usable prefix leaves neither side able to classify it. Say so plainly
|
||||
# instead: the generated ruleset also carries the same text as a rule
|
||||
# comment, so `nft list ruleset` shows the degraded state too.
|
||||
# china-ip6, plus the only user-facing report of "IPv6 is on but cannot be
|
||||
# classified". Neither geoip-cn.srs nor china_ip4.json carries IPv6, so
|
||||
# without a v6 rule-set a mainland destination reached over IPv6 matches no
|
||||
# route rule and falls through to `final`; the firewall's v6 set is filled
|
||||
# from this same china_ip6.txt, so a missing or prefixless list leaves
|
||||
# neither side able to classify it. cn_ipv6 counts usable prefixes first -
|
||||
# the generator needs at least one, so a zero-count list must skip straight
|
||||
# to the warning rather than call ucode and log a second failure.
|
||||
local ipv6_support cn_ipv6
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
cn_ipv6=0
|
||||
@@ -320,10 +320,6 @@ hp_prepare_runtime_files() {
|
||||
chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box."
|
||||
else
|
||||
# Only reached when the source is unusable, which china_ip_ruleset.uc
|
||||
# reports as "no usable CIDR entries". Keep the previous file: an
|
||||
# empty one would make the route-side set match nothing, which is the
|
||||
# same silent inversion this whole branch exists to prevent.
|
||||
log "Warning: could not generate ${hp_dir}/resources/china_ip6.json; the route side keeps the previous list."
|
||||
fi
|
||||
fi
|
||||
@@ -332,17 +328,7 @@ hp_prepare_runtime_files() {
|
||||
log "WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: ${hp_dir}/scripts/update_resources.sh china_ip6"
|
||||
fi
|
||||
|
||||
# The DNS half of the China split, same arrangement as china_ip4.json above:
|
||||
# the dns rule sends the list's domains to china-dns, and it reads
|
||||
# china-domain.json rather than a remote geosite-geolocation-cn.srs so a
|
||||
# cold start has nothing to download. Generated here for the same reason -
|
||||
# an install that predates the file, or a list replaced while the service
|
||||
# was stopped, must still get one, or the client config names a file that
|
||||
# does not exist and sing-box refuses to start.
|
||||
#
|
||||
# A missing china_list.txt is not an error: the generator then emits no
|
||||
# DNS-side rule for it, and the split degrades to the resolver default
|
||||
# rather than failing the start.
|
||||
# china-domain: the DNS half, sending the list's domains to china-dns.
|
||||
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then
|
||||
if hp_rule_set_current "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json"; then
|
||||
chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \
|
||||
@@ -352,11 +338,55 @@ hp_prepare_runtime_files() {
|
||||
chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/resources/china-domain.json to sing-box."
|
||||
else
|
||||
# Same reasoning as the v6 branch above: the helper refuses to
|
||||
# write an empty rule-set, and the previous one is still valid.
|
||||
log "Warning: could not generate ${hp_dir}/resources/china-domain.json; the DNS side keeps the previous list."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
||||
# Create the mode-specific working files, truncate the instance logs and hand
|
||||
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
||||
#
|
||||
# The three China rule-sets below (china-ip4, china-ip6, china-domain) share one
|
||||
# arrangement, and the reasons for it are stated once here rather than three
|
||||
# times in the branches:
|
||||
#
|
||||
# * Each is generated locally from a .txt the resource updater maintains, not
|
||||
# downloaded as an upstream .srs (geoip-cn.srs / geosite-geolocation-cn.srs),
|
||||
# so a cold start has nothing to download before the inbounds bind. The
|
||||
# firewall reads the same .txt for its nft set, so both halves of the split
|
||||
# decide from one source and cannot drift.
|
||||
# * Generated here as well as by the updater: an install that predates the
|
||||
# .json, or a list replaced while the service was stopped, must still get
|
||||
# one - otherwise the generated config names a file that does not exist and
|
||||
# `sing-box check` refuses the start.
|
||||
# * A missing source .txt is NOT an error. The generator then emits no
|
||||
# route-side / DNS-side rule for it and the split degrades to the default,
|
||||
# rather than failing the start.
|
||||
# * A FAILED generation keeps the previous .json rather than writing an empty
|
||||
# one. This is the trap all three branches exist to avoid: an empty
|
||||
# rule-set matches nothing, so a mainland destination silently falls through
|
||||
# to `final` (i.e. the proxy) and the mode does the exact opposite of what it
|
||||
# promises - an inversion that produces no error anywhere. Both helper
|
||||
# scripts (china_ip_ruleset.uc / domain_ruleset.uc) refuse to write an empty
|
||||
# rule-set and exit non-zero, which is what the else-branches log.
|
||||
# * The generated rule-set also carries this same text as a rule comment, so
|
||||
# `nft list ruleset` shows the degraded state too.
|
||||
#
|
||||
# NOTE: the three China rule-sets are produced by hp_prepare_generated_inputs(),
|
||||
# which runs BEFORE the generator (see init.d) - this function no longer creates
|
||||
# them; it only re-asserts them for a caller that arrives here directly.
|
||||
hp_prepare_runtime_files() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local client_enabled="$4"
|
||||
local server_enabled="$5"
|
||||
|
||||
# The rule-sets the generated configuration references. Also called before
|
||||
# generation (init.d), which is what makes a fresh install work; idempotent
|
||||
# and cheap when the sources did not change.
|
||||
hp_prepare_generated_inputs "$hp_dir" "$client_enabled"
|
||||
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental). The file has to exist before
|
||||
@@ -657,6 +687,7 @@ hp_start_generated_config() {
|
||||
fi
|
||||
|
||||
ucode -S "$generator" 2>>"$LOG_PATH"
|
||||
gen_rc=$?
|
||||
|
||||
# The generator is transactional on its own: it writes a temporary file
|
||||
# and only renames it over the live one once `sing-box check` accepted
|
||||
@@ -666,6 +697,12 @@ hp_start_generated_config() {
|
||||
hp_ensure_live "$live" "$good"
|
||||
case "$?" in
|
||||
0)
|
||||
# A live file exists, but it may be the *previous* configuration: a
|
||||
# failed generation leaves it in place. That used to be reported as a
|
||||
# plain success with nothing in the log - `start` on a service whose
|
||||
# UCI had gone invalid returned 0 while the old file kept running.
|
||||
[ "$gen_rc" = "0" ] \
|
||||
|| log "Warning: could not regenerate the ${label} configuration (generator exit ${gen_rc}); the previous configuration stays in place."
|
||||
;;
|
||||
1)
|
||||
log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;;
|
||||
|
||||
@@ -78,6 +78,74 @@ import { flatten } from '../parser/flatten.uc';
|
||||
*
|
||||
* Returns the { added, removed } counts. No commit here: the caller's
|
||||
* single commit is the boundary of "this run wrote something". */
|
||||
/* --- content identity ---------------------------------------------------- */
|
||||
|
||||
/* A fingerprint of a node's *content*: the same server with the same
|
||||
* credentials and options, whatever it is called.
|
||||
*
|
||||
* Section names are md5(grouphash + label), so a subscription that renames a
|
||||
* node used to make the repository delete the old section and create a new one
|
||||
* - and every reference to it (`main_node`, `main_udp_node`, the urltest lists)
|
||||
* was switched away or dropped along with it, silently. The label is not part
|
||||
* of a node's identity, so it is excluded here along with the section metadata
|
||||
* and the group hash (the group is already the lookup key).
|
||||
*
|
||||
* Keys are sorted so the fingerprint cannot depend on insertion order, and the
|
||||
* values are serialized with %J because UCI options can be lists. */
|
||||
function content_key(flat) {
|
||||
const parts = [];
|
||||
|
||||
for (let k in sort(keys(flat))) {
|
||||
if (k === 'label' || k === 'grouphash' || substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
push(parts, k + '=' + sprintf('%J', flat[k]));
|
||||
}
|
||||
|
||||
return md5(join('\n', parts));
|
||||
}
|
||||
|
||||
/* The section name the orchestrator keyed this node under.
|
||||
*
|
||||
* Normally md5(grouphash + label). When two servers of one subscription share
|
||||
* a label the orchestrator disambiguates the key with the content fingerprint,
|
||||
* and the section has to follow it - otherwise both nodes are written into the
|
||||
* same section and one of them is lost. Returns null for a node the caller did
|
||||
* not put in the cache (the unit tests hand nodes in directly), which keeps the
|
||||
* previous behaviour as the fallback. */
|
||||
function section_name_for(node, cache) {
|
||||
if (!cache)
|
||||
return null;
|
||||
|
||||
for (let key in keys(cache))
|
||||
if (cache[key] === node)
|
||||
return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* group hash -> { content_key: canonical node }, built once and only when a
|
||||
* section was not found by name: a run without renames pays nothing. */
|
||||
function build_content_index(node_cache) {
|
||||
const index = {};
|
||||
|
||||
for (let group in keys(node_cache)) {
|
||||
const group_index = index[group] = {};
|
||||
const done = {};
|
||||
const cache = node_cache[group];
|
||||
|
||||
for (let key in keys(cache)) {
|
||||
const node = cache[key];
|
||||
|
||||
if (done[node])
|
||||
continue;
|
||||
done[node] = true;
|
||||
group_index[content_key(flatten(node))] = node;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
let added = 0, removed = 0;
|
||||
/* Mark the canonical Node objects the foreach loop above has
|
||||
@@ -89,6 +157,9 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
* object means a stale string key cannot match a renamed node. */
|
||||
const seen = {};
|
||||
|
||||
/* Built on the first section that is not found by name (see below). */
|
||||
let content_index = null;
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
/* User-created nodes do not have a grouphash. The
|
||||
* repository must never touch them, even if the
|
||||
@@ -103,7 +174,31 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
if (!node_cache[cfg.grouphash] || length(node_cache[cfg.grouphash]) === 0)
|
||||
return null;
|
||||
|
||||
const incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
let incoming = node_cache[cfg.grouphash][cfg['.name']];
|
||||
|
||||
if (!incoming) {
|
||||
/* Not found by section name: the node may simply have been
|
||||
* renamed upstream. Section names hash the label, so without
|
||||
* this the repository would delete the section and add an
|
||||
* identical one under a new name, taking every reference to it
|
||||
* (`main_node`, `main_udp_node`, urltest members) with it - the
|
||||
* user's node selection silently moves to another server. */
|
||||
if (content_index === null)
|
||||
content_index = build_content_index(node_cache);
|
||||
|
||||
const key = content_key(cfg);
|
||||
|
||||
incoming = content_index[cfg.grouphash][key];
|
||||
|
||||
if (incoming) {
|
||||
/* Claim it: two stored sections with the same content must
|
||||
* not both update themselves into the same node, or the
|
||||
* duplicate would never be pruned. */
|
||||
delete content_index[cfg.grouphash][key];
|
||||
log(sprintf('Node was renamed upstream: %s -> %s; keeping its section and every reference to it.',
|
||||
cfg.label || cfg['.name'], incoming.label || incoming.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (!incoming) {
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
@@ -154,7 +249,12 @@ function apply_nodes(uci, uciconfig, ucinode, node_cache, node_result, log) {
|
||||
* identically - four nodes collapsed into one section mixing
|
||||
* several protocols' options. Accept either. */
|
||||
const label = node.label || node.name;
|
||||
const nameHash = md5(node.grouphash + label);
|
||||
|
||||
/* The orchestrator's key is the section name: identical to
|
||||
* md5(grouphash + label) for every node but the ones whose
|
||||
* label is shared by a different server. */
|
||||
const nameHash = section_name_for(node, node_cache[node.grouphash])
|
||||
|| md5(node.grouphash + label);
|
||||
|
||||
/* The one boundary where a canonical Node becomes UCI keys.
|
||||
*
|
||||
@@ -324,8 +424,48 @@ function scrub_stale_urltest_refs(uci, uciconfig, log) {
|
||||
return { changed };
|
||||
}
|
||||
|
||||
/* Remove subscription nodes whose group is no longer configured at all.
|
||||
*
|
||||
* apply_nodes() deliberately leaves a group alone when the fetch produced
|
||||
* nothing for it - that is the transient-blip protection (a failed fetch must
|
||||
* not delete the user's nodes). The same "no cache entry" state also arises
|
||||
* when the user deletes the subscription URL from the configuration, and then
|
||||
* the nodes stayed in /etc/config/homeproxy-pro forever, still compiled into the
|
||||
* sing-box outbounds and urltest pools while the UI showed them as ordinary
|
||||
* nodes. Only the orchestrator knows which URLs are still configured, so the
|
||||
* set is passed in explicitly: a group that is configured but produced no cache
|
||||
* is kept (blip), a group that is not configured is pruned (subscription
|
||||
* removed).
|
||||
*
|
||||
* A section without a grouphash is a user-created node and is never touched.
|
||||
* Returns the number of pruned sections.
|
||||
*/
|
||||
function prune_orphan_nodes(uci, uciconfig, ucinode, configured_groups, log) {
|
||||
let pruned = 0;
|
||||
const configured = {};
|
||||
|
||||
for (let group in configured_groups)
|
||||
configured[group] = true;
|
||||
|
||||
uci.foreach(uciconfig, ucinode, (cfg) => {
|
||||
if (!cfg.grouphash)
|
||||
return null;
|
||||
|
||||
if (configured[cfg.grouphash])
|
||||
return null;
|
||||
|
||||
uci.delete(uciconfig, cfg['.name']);
|
||||
pruned++;
|
||||
log(sprintf('Removing node of a removed subscription: %s.', cfg.label || cfg['.name']));
|
||||
return null;
|
||||
});
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export const Repository = {
|
||||
apply_nodes,
|
||||
apply_main_node_refs,
|
||||
scrub_stale_urltest_refs
|
||||
scrub_stale_urltest_refs,
|
||||
prune_orphan_nodes
|
||||
};
|
||||
@@ -115,7 +115,13 @@ check_list_update() {
|
||||
|
||||
# --header takes '=': the space-separated form is rejected, and the value
|
||||
# has to stay ONE argv element, which the quoting around it preserves.
|
||||
local list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
|
||||
# `local x="$(...)"` returns the status of `local`, not of the command
|
||||
# substitution - measured on the device: busybox ash gives 0 even when the
|
||||
# fetch failed. That made the branch below dead and reported a network
|
||||
# failure as "Failed to get the latest version, please retry later", which
|
||||
# loses the difference between "could not fetch" and "fetched, got nothing".
|
||||
local list_info
|
||||
list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
|
||||
local fetch_exit=$?
|
||||
|
||||
if [ $fetch_exit -ne 0 ]; then
|
||||
@@ -278,10 +284,27 @@ case "$1" in
|
||||
# script is exercised off-device where a non-busybox sed fails it with
|
||||
# "invalid command code". Edit through a temp file, the way the crontab
|
||||
# helper in runtime/service.sh does.
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt" && \
|
||||
sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" && \
|
||||
mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt" || \
|
||||
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
|
||||
# The status has to survive the post-processing. As one &&...|| chain it
|
||||
# did not: when check_list_update returned 3 ("already current") or 1
|
||||
# (fetch failed) the chain short-circuited into `rm -f`, whose status 0
|
||||
# became the script's - so update_resources_cron.sh counted "no change" as
|
||||
# a change and reloaded the service every single day, and the LuCI button
|
||||
# reported "Successfully updated." for a fetch that never happened.
|
||||
# Measured on the device: china_list exited 0 for both the failure and the
|
||||
# up-to-date case, while china_ip4 exited 1 / 3 correctly.
|
||||
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt"
|
||||
rc=$?
|
||||
[ "$rc" = "0" ] || exit "$rc"
|
||||
|
||||
# 0 means a new list was just installed; strip the `full:` prefixes and the
|
||||
# colon-carrying entries before anything else reads it.
|
||||
if sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" \
|
||||
&& mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt"; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
|
||||
log "[CHINA_LIST] Failed to normalise the downloaded list; leaving it as downloaded."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
|
||||
|
||||
@@ -196,6 +196,13 @@ function release_lock() {
|
||||
|
||||
function main() {
|
||||
const node_cache = {};
|
||||
|
||||
/* Duplicate detection, kept apart from node_cache on purpose: node_cache
|
||||
* is keyed by *section name* now (the Repository uses that key as the
|
||||
* section it writes), so folding a content fingerprint into it would make
|
||||
* the fingerprint look like a section. */
|
||||
const conf_seen = {};
|
||||
const name_seen = {};
|
||||
const node_result = [];
|
||||
|
||||
const ubus = connect();
|
||||
@@ -217,6 +224,8 @@ function main() {
|
||||
url = replace(url, /#.*$/, '');
|
||||
const groupHash = md5(url);
|
||||
node_cache[groupHash] = {};
|
||||
conf_seen[groupHash] = {};
|
||||
name_seen[groupHash] = {};
|
||||
|
||||
/* Keep the lock's mtime current for as long as this run is alive.
|
||||
* The stale window (LOCK_STALE) is what lets a later run reclaim a
|
||||
@@ -250,7 +259,7 @@ function main() {
|
||||
|
||||
if (filter_check(flat.label, filter_mode, filter_keywords, log))
|
||||
log(sprintf('Skipping blacklist node: %s.', flat.label));
|
||||
else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash])
|
||||
else if (conf_seen[groupHash][confHash])
|
||||
log(sprintf('Skipping duplicate node: %s.', flat.label));
|
||||
else {
|
||||
/* normalize() first: the canonical Node is the only
|
||||
@@ -283,9 +292,28 @@ function main() {
|
||||
|
||||
apply_policy(node_canonical, { allow_insecure, packet_encoding });
|
||||
|
||||
/* Two different servers can carry the same label - it is
|
||||
* whatever the provider put after '#' - and section names
|
||||
* hash the label, so the second one used to be dropped
|
||||
* here ("Skipping duplicate node") and, even if it were
|
||||
* let through, would have been written into the first
|
||||
* one's section. Disambiguate with the content
|
||||
* fingerprint: the first keeps md5(group + label) (so
|
||||
* existing sections and every reference to them survive
|
||||
* an upgrade), the collision gets a key of its own. */
|
||||
let section_key = nameHash;
|
||||
|
||||
if (name_seen[groupHash][nameHash]) {
|
||||
section_key = md5(groupHash + label + '\x00' + confHash);
|
||||
log(sprintf('Two servers are labelled %s; keeping both (section %s).',
|
||||
flat.label, section_key));
|
||||
}
|
||||
|
||||
conf_seen[groupHash][confHash] = true;
|
||||
name_seen[groupHash][nameHash] = true;
|
||||
|
||||
push(node_result, [ node_canonical ]);
|
||||
node_cache[groupHash][confHash] = node_canonical;
|
||||
node_cache[groupHash][nameHash] = node_canonical;
|
||||
node_cache[groupHash][section_key] = node_canonical;
|
||||
|
||||
count++;
|
||||
}
|
||||
@@ -316,6 +344,17 @@ function main() {
|
||||
const added = repository_result.added,
|
||||
removed = repository_result.removed;
|
||||
|
||||
/* A subscription URL the user deleted leaves its nodes without a cache
|
||||
* entry, which apply_nodes() treats as "the fetch failed, keep them" - so
|
||||
* they stayed in the configuration forever (still in the sing-box outbounds
|
||||
* and urltest pools, and shown in the Nodes tab like a user node). The
|
||||
* configured URL set is the one thing that tells the two cases apart, and
|
||||
* this function has it; `node_cache` is keyed by every URL this run
|
||||
* processed, including the ones whose fetch failed. */
|
||||
const orphaned = Repository.prune_orphan_nodes(
|
||||
uci, uciconfig, ucinode, keys(node_cache), log
|
||||
);
|
||||
|
||||
/* The 6 inline uci.set/commit sites
|
||||
* (main_urltest_nodes cleanup, main_node switch on missing
|
||||
* target, main_udp_urltest_nodes cleanup, main_udp_node
|
||||
@@ -348,11 +387,43 @@ function main() {
|
||||
*
|
||||
* commit() returns true on success, so a failure is detected here rather
|
||||
* than by generating a configuration from a half-written file. */
|
||||
const config_before_commit = readfile(CONFIG_FILE);
|
||||
|
||||
if (uci.commit(uciconfig) !== true) {
|
||||
log('FAILED to commit the new configuration; the previous configuration is kept.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Nothing changed: the commit rewrote the same bytes. Every mutation was
|
||||
* a no-op - the subscription returned exactly the nodes that are already
|
||||
* stored, none was added, removed, renamed or updated, no reference
|
||||
* needed repairing.
|
||||
*
|
||||
* Reloading here means stop+start of both instances for a configuration
|
||||
* that is already running, and during that window stop_service keeps the
|
||||
* DNS layer pointing at sing-box's dns-in while the intercept layer is
|
||||
* gone - "a few seconds of failed lookups" in its own words - for
|
||||
* nothing. Measured on the device: a client configuration that differs
|
||||
* only in `control.listen_interfaces`, `control.lan_direct_ipv4_ips` or
|
||||
* `control.wan_direct_ipv4_ips` renders byte-identical, so the generated
|
||||
* bytes are the strongest signal available here (they also catch an
|
||||
* option updated in place, which the added/removed counts cannot see).
|
||||
*
|
||||
* Not reloading is safe *because* nothing changed: this script's only
|
||||
* side effect is this file. The rule-sets, the nft sets, the DNS
|
||||
* snippets and the crontab all derive from it, and the Repository staged
|
||||
* every mutation on the one cursor above - so there is nothing that
|
||||
* could have been left unapplied. (The init script must NOT grow the
|
||||
* same shortcut: a reload also re-applies layers that do not reach the
|
||||
* generated file, so "bytes equal" is not a licence to skip it there.) */
|
||||
if ((readfile(CONFIG_FILE) || '') === (config_before_commit || '')) {
|
||||
log(sprintf('%s nodes added, %s removed, %s orphaned; nothing changed, the service was not reloaded.',
|
||||
added, removed, orphaned));
|
||||
log('Successfully updated subscriptions.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Reload once, after the whole candidate set is committed
|
||||
* and stale references are scrubbed. The old code stopped
|
||||
* the service before fetching and then did stop+start; the
|
||||
@@ -383,7 +454,7 @@ function main() {
|
||||
* sing-box check warning that was truncated out of view. */
|
||||
log('Warning: reload stderr was truncated (>512 KiB); the captured output is incomplete.');
|
||||
|
||||
log(sprintf('%s nodes added, %s removed.', added, removed));
|
||||
log(sprintf('%s nodes added, %s removed, %s orphaned.', added, removed, orphaned));
|
||||
log('Successfully updated subscriptions.');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user