🍓 Sync 2026-10-01 00:51:52

This commit is contained in:
github-actions[bot] committed 2026-10-01 00:51:52 +08:00
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;
}
}
};