mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 10:15:13 +08:00
🍓 Sync 2026-10-01 00:51:52
This commit is contained in:
1 parent
57dc97412f
commit
d45a1cb4ad
60 files changed
+2630
-1193
No files matched your search
@@ -281,7 +281,12 @@ set homeproxy_mainland_addr_v4 {
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% if ((ipv6_support === '1') || china_dns_server): %}
|
||||
{# The v6 mainland set is only ever matched inside `ipv6_support === '1'`
|
||||
blocks - every reference to it below is gated on that - so the old
|
||||
`|| china_dns_server` alternative only ever built a set nothing pointed
|
||||
at: 1033 prefixes loaded into the kernel on every fw4 reload for nothing.
|
||||
china_dns_server is a DNS-side setting and has no bearing on v6 routing. #}
|
||||
{% if (ipv6_support === '1'): %}
|
||||
set homeproxy_mainland_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
@@ -347,6 +352,25 @@ set homeproxy_routing_port {
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# IPv6 forwarding is refused outright when the switch is off.
|
||||
The DNS side already answers AAAA with REFUSED, but that only covers clients
|
||||
that ask *this* router: a cached address, a client with its own resolver, or
|
||||
an IPv6-only destination still reaches the WAN and bypasses both the proxy
|
||||
and the mainland split.
|
||||
fw4's own forward chain cannot be used for this - it jumps into the zone
|
||||
chains, whose accept rules end the traversal before anything appended at the
|
||||
end of the chain is reached - so this is a base chain of its own, ordered
|
||||
before fw4's forward (priority filter == 0).
|
||||
Intra-LAN IPv6 is covered as well, which is what "this router does not do
|
||||
IPv6" means for `ipv6_support` off. #}
|
||||
{% if (ipv6_support !== '1'): %}
|
||||
chain homeproxy_ipv6_guard {
|
||||
type filter hook forward priority mangle; policy accept
|
||||
|
||||
meta nfproto ipv6 counter reject comment "!{{ cfgname }}: IPv6 forwarding is disabled (ipv6_support is off)"
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# DNS hijack & TCP redirect #}
|
||||
chain dstnat {
|
||||
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||
@@ -562,14 +586,14 @@ chain homeproxy_mangle_prerouting {
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
@@ -723,14 +747,14 @@ chain homeproxy_mangle_tun {
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (ipv6_support === '1'): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* ipv6_support */ %}
|
||||
{% if (routing_mode !== 'custom'): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
|
||||
{% endif /* routing_mode */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
|
||||
@@ -31,6 +31,7 @@ HP_FW4_CHAINS="
|
||||
homeproxy_mangle_mark
|
||||
homeproxy_mangle_tun
|
||||
homeproxy_mangle_tun_mark
|
||||
homeproxy_ipv6_guard
|
||||
"
|
||||
|
||||
HP_FW4_SETS="
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
'use strict';
|
||||
|
||||
import { connect } from 'ubus';
|
||||
import { mkdtemp, readfile, writefile } from 'fs';
|
||||
import { mkdtemp, open, readfile, writefile } from 'fs';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate } from './generator/client.uc';
|
||||
@@ -52,9 +52,23 @@ function resolve_env(dm) {
|
||||
const env = {
|
||||
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
|
||||
direct_domain_list: [],
|
||||
proxy_domain_list: []
|
||||
proxy_domain_list: [],
|
||||
china_ip_ruleset_available: false
|
||||
};
|
||||
|
||||
/* The route block references this file as a `type: local` rule-set, and
|
||||
* sing-box refuses the whole configuration when it cannot open the path.
|
||||
* service.sh generates it before the client configuration, so it is
|
||||
* normally there; a fresh install whose generation failed (no ucode, a
|
||||
* read-only filesystem, a full disk) is what this answers. The generator
|
||||
* is a pure function, so the existence check belongs on this side of the
|
||||
* boundary. open() rather than readfile(): the file is a few megabytes
|
||||
* and only its existence matters. */
|
||||
const china_ip_json = open(HP_DIR + '/resources/china_ip4.json');
|
||||
env.china_ip_ruleset_available = china_ip_json != null;
|
||||
if (china_ip_json)
|
||||
china_ip_json.close();
|
||||
|
||||
if (routing_mode !== 'custom') {
|
||||
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
|
||||
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
|
||||
|
||||
@@ -52,6 +52,15 @@ export function get_outbound(cfg, dm) {
|
||||
return cfg;
|
||||
default:
|
||||
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
|
||||
/* A routing_node that was disabled after something started referring
|
||||
* to it is skipped by generate_outbound(), so the tag returned below
|
||||
* would exist nowhere: sing-box refuses the whole configuration with
|
||||
* "outbound not found: cfg-<name>-out", which does not name the node
|
||||
* the user actually turned off. Fail here instead, the way
|
||||
* get_resolver()/get_ruleset() already do. The caller passes the
|
||||
* section name, so it is what the message names. */
|
||||
if (rn && (rn.enabled === false || rn.enabled === '0'))
|
||||
die(sprintf("the routing node '%s' is disabled; enable it or point the rule at another outbound.", cfg));
|
||||
if (!rn || isEmpty(rn.node))
|
||||
die(sprintf("%s's node is missing, please check your configuration.", cfg));
|
||||
else if (rn.node === 'urltest')
|
||||
|
||||
@@ -153,11 +153,11 @@ export function build_context(dm, env) {
|
||||
* its own outbound/rule tables (routing.default_outbound plus the
|
||||
* routing_node / routing_rule / ruleset sections). A residual
|
||||
* config.main_node must therefore not switch the generator back to
|
||||
* the preset path: LuCI hides that field in custom mode but does not
|
||||
* clear it (`rmempty = false`), so a router that was configured in a
|
||||
* preset mode and then switched to custom keeps the old value in
|
||||
* UCI - and the pre-refactor code read it only when the mode was not
|
||||
* custom. Reading it here anyway silently dropped every custom
|
||||
* the preset path. LuCI itself does clear a field the user can no
|
||||
* longer see (depends() makes parse() remove the value), but UCI is
|
||||
* writable from anywhere - a script, an older version, a hand edit -
|
||||
* and the pre-refactor code read this value only when the mode was
|
||||
* not custom. Reading it here anyway silently dropped every custom
|
||||
* routing rule and made route.final 'main-out'. */
|
||||
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
|
||||
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
|
||||
@@ -248,6 +248,14 @@ export function build_context(dm, env) {
|
||||
ctx.proxy_domain_list = env.proxy_domain_list || [];
|
||||
}
|
||||
|
||||
/* Whether the local china-ip rule-set file exists (the CLI resolved it;
|
||||
* see generate_client.uc). route.uc emits that rule-set - and the rule
|
||||
* that matches it - only when it does, so a file that is missing degrades
|
||||
* the mainland split to geoip-cn instead of making sing-box refuse the
|
||||
* whole configuration over a path it cannot open. A caller that does not
|
||||
* supply the flag keeps the previous behaviour. */
|
||||
ctx.china_ip_ruleset_available = env.china_ip_ruleset_available !== false;
|
||||
|
||||
/* `dedicated_udp_node` means main_udp_node was set to a different
|
||||
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
|
||||
* qualify). Kept on ctx so both build_outbounds and build_route
|
||||
|
||||
@@ -72,6 +72,50 @@ function parse_dnsquery(strquery) {
|
||||
return querys;
|
||||
}
|
||||
|
||||
/* Build the sing-box `headers` object from the UCI string list.
|
||||
*
|
||||
* sing-box types this field as `badoption.HTTPHeader`, i.e.
|
||||
* map[string][]string; an array (or a bare string) fails to decode and the
|
||||
* whole configuration is rejected. The LuCI form is a DynamicList, so every
|
||||
* entry lands in UCI as a string and the two sides disagreed on the shape:
|
||||
* `"headers": ["Name: value"]` is not a map.
|
||||
*
|
||||
* The form keeps its list widget and one entry is written `Name: Value`;
|
||||
* repeating a name appends to that name's value list, which is how HTTP
|
||||
* expresses a multi-valued header. An entry that does not fit the form is
|
||||
* named and rejected here rather than left for sing-box to report as
|
||||
* "cannot unmarshal array into Go value of type badoption.HTTPHeader". */
|
||||
function parse_http_headers(list, where) {
|
||||
if (isEmpty(list))
|
||||
return null;
|
||||
|
||||
/* A single hand-written UCI option arrives as a string, not a list. */
|
||||
if (type(list) !== 'array')
|
||||
list = [ list ];
|
||||
|
||||
const headers = {};
|
||||
for (let item in list) {
|
||||
const line = trim(item);
|
||||
if (line === '')
|
||||
continue;
|
||||
|
||||
const sep = index(line, ':');
|
||||
if (sep <= 0)
|
||||
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
|
||||
|
||||
const name = trim(substr(line, 0, sep));
|
||||
const value = trim(substr(line, sep + 1));
|
||||
if (isEmpty(name) || isEmpty(value))
|
||||
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
|
||||
|
||||
if (!headers[name])
|
||||
headers[name] = [];
|
||||
push(headers[name], value);
|
||||
}
|
||||
|
||||
return isEmpty(keys(headers)) ? null : headers;
|
||||
}
|
||||
|
||||
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
|
||||
|
||||
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
|
||||
@@ -87,7 +131,6 @@ function parse_dnsquery(strquery) {
|
||||
* someone reads the comment and thinks "I know a domain that should
|
||||
* go direct". */
|
||||
const NAPTR_BYPASS_SUFFIXES = [
|
||||
'r.10086.cn',
|
||||
'10086.cn',
|
||||
'pub.3gppnetwork.org'
|
||||
];
|
||||
@@ -194,7 +237,16 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
server: 'main-dns'
|
||||
});
|
||||
|
||||
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
|
||||
/* Reject SVCB/HTTPS queries: a client that learns the address from these
|
||||
* record types dials the embedded IP without asking again, which is what
|
||||
* lets a query slip past the DNS split (and, in the custom path, past
|
||||
* Fake-IP).
|
||||
*
|
||||
* It sits after the two domain lists on purpose, so a domain the user put
|
||||
* on a list keeps whatever resolver that list chose - the custom path
|
||||
* below puts the same reject first instead, because there the user's own
|
||||
* rules are the ones that must not be shadowed. The two orders are
|
||||
* deliberate; do not "fix" one into the other. */
|
||||
push(config.dns.rules, {
|
||||
query_type: [64, 65],
|
||||
action: 'reject'
|
||||
@@ -317,7 +369,7 @@ function append_custom_dns(config, dm, ctx) {
|
||||
server: cfg.server,
|
||||
server_port: strToInt(cfg.server_port),
|
||||
path: cfg.path,
|
||||
headers: cfg.headers,
|
||||
headers: parse_http_headers(cfg.headers, sprintf("dns server '%s'", cfg.name)),
|
||||
tls: cfg.tls_sni ? {
|
||||
enabled: true,
|
||||
server_name: cfg.tls_sni
|
||||
@@ -385,24 +437,71 @@ function append_custom_dns(config, dm, ctx) {
|
||||
rule_set: get_ruleset(cfg.rule_set, dm),
|
||||
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
|
||||
invert: strToBool(cfg.invert),
|
||||
race: strToBool(cfg.race),
|
||||
speculative: strToBool(cfg.speculative),
|
||||
action: cfg.action
|
||||
};
|
||||
|
||||
/* `route` (and `evaluate`, which needs a server for the eval step).
|
||||
* `resolve` is also a possibility but is not currently exposed in
|
||||
* the UI; if it ever is, this branch is where it lands. */
|
||||
if (cfg.action === 'route' || cfg.action === 'evaluate' || cfg.action === 'resolve') {
|
||||
/* race/speculative are action fields, not match fields, and their
|
||||
* legality is decided at load time by sing-box's
|
||||
* validateDNSRuleAction() (route/rule/rule_dns.go):
|
||||
*
|
||||
* - race and speculative are mutually exclusive ("`race` and
|
||||
* `speculative` cannot be combined on the same rule");
|
||||
* - race is only allowed on a final action - route, respond,
|
||||
* reject, predefined - and "`race` requires a final action"
|
||||
* otherwise;
|
||||
* - speculative exists only in the route and evaluate option
|
||||
* structs, so on any other action it is an unknown field and
|
||||
* DisallowUnknownFields rejects the whole configuration.
|
||||
*
|
||||
* The old code emitted both unconditionally. The form hides them
|
||||
* with depends(), but LuCI does not delete the value of a hidden
|
||||
* option, so "tick race on a route rule, then switch the rule to
|
||||
* evaluate" left an illegal value behind. Gate by action and die
|
||||
* on the combination, naming the field rather than letting sing-box
|
||||
* answer with "unknown field". */
|
||||
const want_race = strToBool(cfg.race) === true,
|
||||
want_speculative = strToBool(cfg.speculative) === true;
|
||||
|
||||
if (want_race && want_speculative)
|
||||
die(sprintf("homeproxy-pro: DNS rule '%s' enables both race and speculative; sing-box refuses that combination.", cfg.name));
|
||||
|
||||
if (want_race) {
|
||||
if (!(cfg.action in ['route', 'reject', 'predefined']))
|
||||
die(sprintf("homeproxy-pro: DNS rule '%s' uses race with action '%s'; race is only allowed on a final action (route, reject, predefined).", cfg.name, cfg.action));
|
||||
rule.race = true;
|
||||
}
|
||||
|
||||
if (want_speculative) {
|
||||
if (!(cfg.action in ['route', 'evaluate']))
|
||||
die(sprintf("homeproxy-pro: DNS rule '%s' uses speculative with action '%s'; speculative is only valid for route and evaluate.", cfg.name, cfg.action));
|
||||
rule.speculative = true;
|
||||
}
|
||||
|
||||
/* `route` and `evaluate` (which needs a server for the eval step).
|
||||
* DNS rules have no `resolve` action - that one belongs to the route
|
||||
* rules - and the form does not offer it here. It used to be handled
|
||||
* anyway, which would have emitted an action sing-box does not know
|
||||
* the moment a stale UCI value carried it. */
|
||||
if (cfg.action === 'route' || cfg.action === 'evaluate') {
|
||||
rule.server = get_resolver(cfg.server, dm);
|
||||
rule.disable_cache = strToBool(cfg.dns_disable_cache);
|
||||
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
|
||||
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
|
||||
/* Load-time mutual exclusion in validateDNSRuleAction():
|
||||
* "`client_subnet` and `remove_client_subnet` are mutually
|
||||
* exclusive". The form draws them as two independent
|
||||
* controls, so both can be left set. */
|
||||
if (!isEmpty(cfg.client_subnet) && strToBool(cfg.remove_client_subnet) === true)
|
||||
die(sprintf("homeproxy-pro: DNS rule '%s' sets both client_subnet and remove_client_subnet; sing-box refuses that combination.", cfg.name));
|
||||
rule.client_subnet = cfg.client_subnet;
|
||||
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
|
||||
}
|
||||
|
||||
if (cfg.action === 'route' || cfg.action === 'resolve')
|
||||
/* 1.14's evaluate action carries the same timeout override as route
|
||||
* (DNSEvaluateActionOptions embeds AbstractDNSRouteActionOptions), and
|
||||
* the form shows the field for both actions. Emitting it for route
|
||||
* only made the value disappear silently on an evaluate rule. */
|
||||
if (cfg.action === 'route' || cfg.action === 'evaluate')
|
||||
rule.timeout = strToTime(cfg.dns_timeout);
|
||||
|
||||
/* `reject` only accepts method + no_drop. Anything else here is
|
||||
@@ -476,6 +575,13 @@ function append_custom_dns(config, dm, ctx) {
|
||||
rule.response_extra = cfg.response_extra;
|
||||
}
|
||||
|
||||
/* race also requires match_response in 1.14. This check has to sit
|
||||
* after every match_response assignment: the legacy address-filter
|
||||
* wrapping above gives one to those rules too, and that satisfies
|
||||
* sing-box just as well. */
|
||||
if (want_race && !rule.match_response)
|
||||
die(sprintf("homeproxy-pro: DNS rule '%s' enables race without match_response; sing-box requires one.", cfg.name));
|
||||
|
||||
push(builtin_dns_rules, rule);
|
||||
}
|
||||
config.dns.rules = builtin_dns_rules;
|
||||
@@ -483,6 +589,31 @@ function append_custom_dns(config, dm, ctx) {
|
||||
config.dns.final = get_resolver(ctx.dns_default_server, dm);
|
||||
}
|
||||
|
||||
/* Reject AAAA when this router does not proxy IPv6.
|
||||
*
|
||||
* Every IPv6 rule-set and rule in firewall_post.ut sits inside
|
||||
* `ipv6_support === '1'`, so with the switch off the router does not
|
||||
* intercept v6 at all - and nothing in this DNS block ever refused a AAAA
|
||||
* query. dns.strategy=ipv4_only on the preset paths is a resolution
|
||||
* preference, not a query block, and custom routing takes the UCI default
|
||||
* (prefer_ipv4). A dual-stack LAN therefore still received AAAA records and
|
||||
* connected straight out over the WAN, bypassing both the proxied resolver
|
||||
* and the destination-based split.
|
||||
*
|
||||
* Appended last so a user rule keeps precedence over it. A client with its
|
||||
* own DoH/DoT can still bypass this layer; a hard block would have to be an
|
||||
* nft rule, which changes the user-visible "IPv6 still works" behaviour of
|
||||
* the switch and is therefore not done here. */
|
||||
function append_ipv6_guard(config, ctx) {
|
||||
if (ctx.ipv6_support === '1')
|
||||
return;
|
||||
|
||||
push(config.dns.rules, {
|
||||
query_type: [28],
|
||||
action: 'reject'
|
||||
});
|
||||
}
|
||||
|
||||
/* --- public entry ------------------------------------------------------ */
|
||||
|
||||
/* The always-on DNS block. Every routing mode (proxy and custom) gets
|
||||
@@ -554,5 +685,7 @@ export function build_dns(config, dm, ctx) {
|
||||
append_proxy_dns(config, dm, ctx);
|
||||
}
|
||||
|
||||
append_ipv6_guard(config, ctx);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -253,7 +253,7 @@ function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
|
||||
* comes from a routing_node. `|| {}` made it look non-null and the
|
||||
* failure surfaced as a null dereference two lines later. */
|
||||
if (isEmpty(outbound))
|
||||
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
|
||||
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg.name));
|
||||
|
||||
if (outbound.type === 'wireguard') {
|
||||
/* WireGuard goes through generate_endpoint() which tags
|
||||
@@ -274,7 +274,7 @@ function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
|
||||
const ob = generate_outbound(outbound, mark, direct_overrides);
|
||||
|
||||
if (ob == null)
|
||||
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
|
||||
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg.name, cfg.node));
|
||||
|
||||
ob.bind_interface = cfg.bind_interface;
|
||||
ob.detour = get_outbound(cfg.outbound, dm);
|
||||
|
||||
@@ -120,17 +120,23 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
|
||||
});
|
||||
}
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/* geoip-cn.srs is upstream's list; the firewall's mainland set is
|
||||
* built from our own china_ip4.txt (firewall_post.ut reads it
|
||||
* directly). The two disagree - the bundled list has 8.152.0.0/13
|
||||
* (Alibaba Cloud) and geoip-cn.srs does not - so a connection into
|
||||
* that range passes the firewall as "not mainland" and would then be
|
||||
* sent direct by the rule above instead of to the proxy. This local
|
||||
* rule-set is generated from the same china_ip4.txt, which is what
|
||||
* makes both sides decide from one list. In
|
||||
* proxy_mainland_china it is not needed twice: the geoip-cn rule
|
||||
* already selects the mainland side for the proxy. */
|
||||
/* geoip-cn.srs is upstream's list; the firewall's mainland set is
|
||||
* built from our own china_ip4.txt (firewall_post.ut reads it
|
||||
* directly). The two disagree - the bundled list has 8.152.0.0/13
|
||||
* (Alibaba Cloud) and geoip-cn.srs does not - so a connection into
|
||||
* that range passes the firewall as "not mainland" and would then be
|
||||
* sent direct by the rule above instead of to the proxy. This local
|
||||
* rule-set is generated from the same china_ip4.txt, which is what
|
||||
* makes both sides decide from one list. In
|
||||
* proxy_mainland_china it is not needed twice: the geoip-cn rule
|
||||
* already selects the mainland side for the proxy.
|
||||
*
|
||||
* Only when the file is actually there: service.sh generates it before
|
||||
* the client configuration, but a fresh install whose generation failed
|
||||
* would otherwise leave a `type: local` rule-set pointing at a path
|
||||
* sing-box cannot open, which fails the whole configuration rather than
|
||||
* this one rule. The geoip-cn rule above still covers the split. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china' && ctx.china_ip_ruleset_available) {
|
||||
push(config.route.rules, {
|
||||
rule_set: 'china-ip',
|
||||
action: 'route',
|
||||
@@ -252,13 +258,14 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
* file the running instance reloads it in place - no restart and,
|
||||
* more importantly, no second copy of the list to keep in sync.
|
||||
* China IPv6 is deliberately absent: the firewall only fills the v6
|
||||
* set when ipv6_support is on, and the client refuses to resolve
|
||||
* AAAA when it is off. */
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-ip',
|
||||
path: HP_DIR + '/resources/china_ip4.json'
|
||||
});
|
||||
* set when ipv6_support is on. Declared only when the file exists,
|
||||
* for the same reason as the rule that matches it (see above). */
|
||||
if (ctx.china_ip_ruleset_available)
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-ip',
|
||||
path: HP_DIR + '/resources/china_ip4.json'
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
@@ -303,7 +310,6 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
|
||||
ip_version: strToInt(cfg.ip_version),
|
||||
protocol: cfg.protocol,
|
||||
network: cfg.network,
|
||||
client: cfg.client,
|
||||
domain: cfg.domain,
|
||||
domain_suffix: cfg.domain_suffix,
|
||||
domain_keyword: cfg.domain_keyword,
|
||||
@@ -328,6 +334,15 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
|
||||
source_hostname: cfg.source_hostname
|
||||
};
|
||||
|
||||
/* The sniffed client type is only set for QUIC and SSH traffic; the
|
||||
* form offers the field under those two protocols only, but a value
|
||||
* left behind by an earlier protocol would make the rule match
|
||||
* nothing, so it is emitted only when it can apply. */
|
||||
const protocols = type(cfg.protocol) === 'array' ? cfg.protocol
|
||||
: (isEmpty(cfg.protocol) ? [] : [ cfg.protocol ]);
|
||||
if ('quic' in protocols || 'ssh' in protocols)
|
||||
rule.client = cfg.client;
|
||||
|
||||
/* `route-options` only takes the override_* fields; `outbound` is the
|
||||
* `route` action's field. The two used to share one branch and
|
||||
* `route-options` ended up carrying an `outbound` sing-box then
|
||||
@@ -347,6 +362,39 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
|
||||
rule.tls_spoof = cfg.tls_spoof || null;
|
||||
rule.tls_spoof_method = cfg.tls_spoof_method || null;
|
||||
}
|
||||
|
||||
/* Load-time mutual exclusion in RouteOptionsActionOptions.UnmarshalJSON()
|
||||
* (option/rule_action.go): "`tls_fragment` and `tls_record_fragment`
|
||||
* are mutually exclusive". The form draws both switches for the
|
||||
* route action and neither hides the other, so a user following the
|
||||
* description ("try TLS record fragment first") can easily tick
|
||||
* both and lose the whole configuration. */
|
||||
if (strToBool(cfg.tls_fragment) === true && strToBool(cfg.tls_record_fragment) === true)
|
||||
die(sprintf("homeproxy-pro: routing rule '%s' enables both tls_fragment and tls_record_fragment; sing-box refuses that combination.", cfg.name));
|
||||
|
||||
/* The same unmarshaller rejects an empty action object with "empty
|
||||
* route option action". Match fields belong to the rule, not to the
|
||||
* action payload, so a rule that selects Route options and fills in
|
||||
* none of the fields above fails the whole configuration with a
|
||||
* message that names nothing. The fields are judged on their parsed
|
||||
* values, not on the raw UCI strings: a UCI '0' becomes `false`,
|
||||
* which is the Go zero value and is not payload. */
|
||||
if (cfg.action === 'route-options') {
|
||||
const has_payload =
|
||||
!isEmpty(rule_override_address) ||
|
||||
!isEmpty(rule_override_port) ||
|
||||
strToBool(cfg.udp_disable_domain_unmapping) === true ||
|
||||
strToBool(cfg.udp_connect) === true ||
|
||||
!isEmpty(cfg.udp_timeout) ||
|
||||
strToBool(cfg.tls_fragment) === true ||
|
||||
!isEmpty(cfg.tls_fragment_fallback_delay) ||
|
||||
strToBool(cfg.tls_record_fragment) === true ||
|
||||
!isEmpty(cfg.tls_spoof) ||
|
||||
!isEmpty(cfg.tls_spoof_method);
|
||||
|
||||
if (!has_payload)
|
||||
die(sprintf("homeproxy-pro: routing rule '%s' selects the route-options action but sets none of its fields; sing-box rejects an empty route option action.", cfg.name));
|
||||
}
|
||||
/* `route` alone is the action that picks a destination - `route-options`
|
||||
* adjusts the existing connection, so it does not take `outbound`. */
|
||||
if (cfg.action === 'route')
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, validateHomeProxyPath } from '../homeproxy-pro.uc';
|
||||
import { isEmpty, strToTime, validateHomeProxyPath } from '../homeproxy-pro.uc';
|
||||
|
||||
import { get_outbound, isDirectOutboundTag } from './common.uc';
|
||||
|
||||
@@ -40,6 +40,13 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
|
||||
if (!cfg.enabled)
|
||||
continue;
|
||||
|
||||
/* An inline rule-set is a `rules` array, and nothing in this package
|
||||
* ever writes one: the form only offers local and remote. Emitting
|
||||
* `{type: 'inline'}` without `rules` is rejected by sing-box, so a
|
||||
* hand-written UCI section is refused by name here. */
|
||||
if (cfg.type === 'inline')
|
||||
die(sprintf("homeproxy-pro: rule-set '%s' is declared inline, which this package cannot edit; use a local or remote rule-set.", cfg.name));
|
||||
|
||||
const extra_tags = cfg.extra_tags || [];
|
||||
let rs_tag = 'cfg-' + cfg.name + '-rule';
|
||||
if (length(extra_tags) && cfg.type !== 'inline') {
|
||||
@@ -78,18 +85,29 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
|
||||
format: cfg.format,
|
||||
path: cfg.path,
|
||||
url: cfg.url,
|
||||
update_interval: cfg.update_interval
|
||||
/* A duration to sing-box: a bare number is rejected with
|
||||
* "time: missing unit in duration", taking the whole
|
||||
* configuration down, and the form only offers a placeholder
|
||||
* ("1d"), not a format check. Same treatment as the DNS
|
||||
* timeouts. */
|
||||
update_interval: strToTime(cfg.update_interval)
|
||||
};
|
||||
/* download_detour is a pre-1.14 option that only makes sense for
|
||||
remote rule-sets; emitting it for local/inline ones makes sing-box
|
||||
* 1.14 reject the whole config. It is translated into http_clients
|
||||
right below. */
|
||||
/* download_detour is deprecated since 1.14 (removed in 1.16) and only
|
||||
* ever applied to remote rule-sets; emitting it at all makes sing-box
|
||||
* refuse the configuration. It is translated into http_clients right
|
||||
* below. */
|
||||
if (cfg.type === 'remote')
|
||||
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
|
||||
/* initial_path is read off the local disk as root; same
|
||||
* whitelist as the local ruleset path. */
|
||||
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
|
||||
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path)) {
|
||||
/* initial_path is read off the local disk as root; same whitelist
|
||||
* as the local ruleset path - and now the same fail-loud
|
||||
* treatment. Dropping it silently made a rule-set the user
|
||||
* believed had a local copy depend on a download at first start,
|
||||
* with nothing said about why. */
|
||||
if (!validateHomeProxyPath(cfg.initial_path))
|
||||
die(sprintf("homeproxy-pro: rule-set '%s' initial_path '%s' is outside the homeproxy-pro whitelist; choose a path under /etc/homeproxy-pro/.", cfg.name, cfg.initial_path));
|
||||
ruleset.initial_path = cfg.initial_path;
|
||||
}
|
||||
push(rule_set_array, ruleset);
|
||||
}
|
||||
};
|
||||
@@ -114,8 +132,21 @@ export function build_http_clients(rule_set_array, dm, ctx) {
|
||||
if (rs.type !== 'remote')
|
||||
continue;
|
||||
|
||||
if (isEmpty(detour))
|
||||
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
|
||||
if (isEmpty(detour)) {
|
||||
/* A remote rule-set still has to be fetched through something.
|
||||
* The preset modes used to fall back to direct-out, which fails
|
||||
* on a router that can only reach GitHub through the proxy - and
|
||||
* the only trace is a line in the sing-box log. Follow the
|
||||
* mode's main outbound instead, the way the built-in geoip-cn /
|
||||
* geosite-cn rule-sets already do (route.uc gives them
|
||||
* download_detour: main-out). */
|
||||
if (ctx.routing_mode === 'custom')
|
||||
detour = get_outbound(ctx.default_outbound, dm) || 'direct-out';
|
||||
else if (!isEmpty(ctx.main_node))
|
||||
detour = 'main-out';
|
||||
else
|
||||
detour = 'direct-out';
|
||||
}
|
||||
|
||||
const tag = 'hp-' + detour;
|
||||
rs.http_client = tag;
|
||||
|
||||
@@ -149,3 +149,29 @@ hp_restore_known_good_uci() {
|
||||
[ "$saved" = "1" ] && uci commit homeproxy-pro </dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_file_digest <file>
|
||||
# Digest of <file>, or nothing when it is missing or empty. The reload path
|
||||
# uses it to tell "the generator produced the same bytes as what is running"
|
||||
# from "something actually changed".
|
||||
hp_file_digest() {
|
||||
[ -s "$1" ] || return 1
|
||||
|
||||
md5sum "$1" 2>"/dev/null" | awk '{ print $1 }'
|
||||
}
|
||||
|
||||
# hp_config_changed <live> <digest-before>
|
||||
# 0 when <live> differs from <digest-before> (or does not exist), 1 when the
|
||||
# two are byte-identical. An empty <digest-before> means there was nothing
|
||||
# running to compare against, which counts as a change.
|
||||
hp_config_changed() {
|
||||
local live="$1"
|
||||
local before="$2"
|
||||
local now
|
||||
|
||||
now="$(hp_file_digest "$live")" || return 0
|
||||
|
||||
[ -n "$before" ] && [ "$now" = "$before" ] && return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -115,7 +115,19 @@ hp_dnsmasq_render_snippets() {
|
||||
hp_dnsmasq_has_nftset || nftset_ok=0
|
||||
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom"|"global")
|
||||
"bypass_mainland_china"|"custom"|"global"|"proxy_mainland_china")
|
||||
# Every one of these hands all queries to sing-box, which is the only
|
||||
# DNS front end the mode needs.
|
||||
#
|
||||
# proxy_mainland_china used to be the exception: it expanded
|
||||
# china_list.txt (111353 domains in the bundled list) into one
|
||||
# `server=` line each, so that only those domains reached sing-box.
|
||||
# Measured on a real device (ImmortalWrt 25.12.2, x86-64, dnsmasq
|
||||
# 2.93) that costs about 25 MB of RSS - 1.0 MB empty, 27.2 MB with
|
||||
# the list - while the outcome is the same: this mode's dns.final is
|
||||
# default-dns (the WAN resolver), so a mainland domain resolves
|
||||
# through the ISP either way. The split that actually matters is
|
||||
# decided on the route side, by geoip-cn/china-ip.
|
||||
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
|
||||
no-poll
|
||||
no-resolv
|
||||
@@ -135,12 +147,6 @@ hp_dnsmasq_render_snippets() {
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"proxy_mainland_china")
|
||||
if [ -s "$hp_dir/resources/china_list.txt" ]; then
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
|
||||
|
||||
@@ -515,6 +515,21 @@ reload_service() {
|
||||
HP_CANDIDATE_CLIENT=0
|
||||
HP_CANDIDATE_SERVER=0
|
||||
|
||||
# Digest what is running *before* the generators overwrite it: a reload
|
||||
# that produces the same bytes must not stop and start the instances.
|
||||
# service_triggers() reloads on every `interface.*.up` for wan, and a
|
||||
# flapping PPPoE link would otherwise interrupt every connection even when
|
||||
# the WAN resolver - the only thing that trigger can change - came back
|
||||
# the same.
|
||||
local digest_c="" digest_s="" server_enabled
|
||||
if [ "$outbound_node" != "nil" ]; then
|
||||
digest_c="$(hp_file_digest "$RUN_DIR/sing-box-c.json")"
|
||||
fi
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
digest_s="$(hp_file_digest "$RUN_DIR/sing-box-s.json")"
|
||||
fi
|
||||
|
||||
if [ "$outbound_node" != "nil" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
|
||||
@@ -523,8 +538,6 @@ reload_service() {
|
||||
HP_CANDIDATE_CLIENT=1
|
||||
fi
|
||||
|
||||
local server_enabled
|
||||
config_get_bool server_enabled "server" "enabled" "0"
|
||||
if [ "$server_enabled" = "1" ]; then
|
||||
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
|
||||
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
|
||||
@@ -533,6 +546,23 @@ reload_service() {
|
||||
HP_CANDIDATE_SERVER=1
|
||||
fi
|
||||
|
||||
# Both sides regenerated to what is already live: keep them running. The
|
||||
# staged candidates are dropped too, so a later start that never validated
|
||||
# anything cannot pick them up.
|
||||
local reload_needed=0
|
||||
if [ "$outbound_node" != "nil" ] && hp_config_changed "$RUN_DIR/sing-box-c.json" "$digest_c"; then
|
||||
reload_needed=1
|
||||
fi
|
||||
if [ "$server_enabled" = "1" ] && hp_config_changed "$RUN_DIR/sing-box-s.json" "$digest_s"; then
|
||||
reload_needed=1
|
||||
fi
|
||||
|
||||
if [ "$reload_needed" = "0" ]; then
|
||||
rm -rf "$RUN_DIR/candidate"
|
||||
log "Reload: the generated configuration is unchanged; the running instances are kept."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
|
||||
# file that was just validated would not survive to start_service. Stage a
|
||||
# copy of each validated side first; the stop that follows cannot reach it,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"luci.homeproxy-pro": [
|
||||
"acllist_read",
|
||||
"connection_check",
|
||||
"gfw_set_counts",
|
||||
"node_parse",
|
||||
"resources_get_version",
|
||||
"singbox_get_features",
|
||||
|
||||
@@ -18,6 +18,11 @@ function hasKernelModule(kmod) {
|
||||
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
|
||||
}
|
||||
|
||||
/* The nft sets gfw_set_counts() reports on. A named constant rather than an
|
||||
* `in [...]` literal: this file keeps its membership tests on index() and
|
||||
* named arrays (arch-guard checks both). */
|
||||
const GFW_SET_FAMILIES = ['v4', 'v6'];
|
||||
|
||||
const HP_DIR = '/etc/homeproxy-pro';
|
||||
const RUN_DIR = '/var/run/homeproxy-pro';
|
||||
const LOG_PATH = `${RUN_DIR}/homeproxy-pro.log`;
|
||||
@@ -51,6 +56,37 @@ function collectFeatures() {
|
||||
return features;
|
||||
}
|
||||
|
||||
/* Element count of an nft set, or null when the set does not exist. Used by
|
||||
* gfw_set_counts(): the gfwlist routing mode decides with a set that dnsmasq
|
||||
* fills through `nftset=`, so an empty set means the mode is silently routing
|
||||
* everything direct. */
|
||||
function nft_set_size(name) {
|
||||
const ret = executeCommand('nft', '-j', 'list', 'set', 'inet', 'fw4', shellQuote(name));
|
||||
|
||||
if (ret == null || ret.exitcode !== 0 || isEmpty(ret.stdout))
|
||||
return null;
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = json(ret.stdout);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* `elem` is what nft 1.1.6 emits (`nft -j list set`); older builds used
|
||||
* `elements`. Accepting both keeps the count right on either, because
|
||||
* the wrong key silently reads as "0 elements" - the very answer this
|
||||
* method exists to distinguish from "not filled". */
|
||||
for (let item in (doc?.nftables || [])) {
|
||||
const elements = item.set ? (item.set.elem || item.set.elements) : null;
|
||||
|
||||
if (elements)
|
||||
return length(elements);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
acllist_read: {
|
||||
args: { type: 'type' },
|
||||
@@ -436,6 +472,23 @@ const methods = {
|
||||
|
||||
return { running, log_tail: tail };
|
||||
}
|
||||
},
|
||||
|
||||
/* The gfwlist mode routes by an nft set that dnsmasq populates through
|
||||
* `nftset=`. A LAN client with its own DoH/DoT never asks this router's
|
||||
* DNS, the set stays empty, and the mode degrades to direct routing with
|
||||
* no visible symptom - the count is what separates "the mode is working"
|
||||
* from "the mode is doing nothing". */
|
||||
gfw_set_counts: {
|
||||
args: {},
|
||||
call: function() {
|
||||
let counts = {};
|
||||
|
||||
for (let family in GFW_SET_FAMILIES)
|
||||
counts[family] = nft_set_size('homeproxy_gfw_list_' + family);
|
||||
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user