🎈 Sync 2026-10-04 14:38:18

This commit is contained in:
github-actions[bot] committed 2026-10-04 14:38:18 +08:00
1 parent 0e6611f767
commit aba12667c5
88 files changed
+4600 -1458

No files matched your search

@@ -55,7 +55,6 @@ config homeproxy-pro 'config'
# With it off, a cold cache on a fresh install waits for
# raw.githubusercontent.com (through the selected node) before the instance
# will listen at all, and a failure there fails the whole start.
option ruleset_safe_start '0'
option tun_dns_mode ''
option udp_mapping ''
option udp_filtering ''
@@ -1 +0,0 @@
{"version":3,"rules":[]}
@@ -404,7 +404,6 @@ export const Loader = {
* configuration generated cleanly, `sing-box check` passed, and the
* feature simply never turned on. Guard 53 now compares the two
* lists so that class cannot come back quietly. */
ruleset_safe_start: opt(uci, 'main', 'ruleset_safe_start'),
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
@@ -59,6 +59,49 @@ function resolve_ipv6(str) {
return ipv6.addr;
}
/* The node server addresses the generator exported (generate_client.uc's
* export_node_addresses()), as an nftables anonymous set - or null when there
* is nothing to protect.
*
* Only the literal-address form is rendered here. A node configured with a
* host name reaches the same set at runtime: the dnsmasq snippet runtime/dns.sh
* writes resolves the name and back-fills the set through `nftset=`, so one
* mechanism covers both without this template needing a resolver of its own.
* A literal address has no name to resolve, so it can only come from here.
*
* readfile() rather than a UCI read on purpose: the generator is the only
* place that knows which nodes are actually in use (main_node, main_udp_node,
* urltest membership, custom-mode routing_nodes, wireguard endpoints), and a
* copy of that selection in this template is a copy that can drift. A missing
* file renders null, which is the behaviour before this existed. */
function node_addr_to_nftarr(run_dir, family) {
let raw = readfile(run_dir + '/node-addr-ips.txt');
if (isEmpty(raw))
return null;
let out = [];
for (let entry in split(trim(raw), /[\r\n]/)) {
let parsed = fw4.parse_subnet(entry)?.[0];
if (!parsed || parsed.family !== family)
continue;
push(out, (parsed.bits > -1) ? `${parsed.addr}/${parsed.bits}` : parsed.addr);
}
return isEmpty(out) ? null : array_to_nftarr(out);
}
/* Whether the node bypass has anything to protect at all.
Either export file counts. The domains file is the one that matters most:
a host-name node - the common case, and the one the device this was written
for uses - appears only there, and its set has to exist before dnsmasq can
put anything in it. Gating on the literal-address file instead is what left
the whole mechanism doing nothing. */
function node_addr_protected(run_dir) {
return !isEmpty(readfile(run_dir + '/node-addr-ips.txt'))
|| !isEmpty(readfile(run_dir + '/node-addr-domains.txt'));
}
/* Normalize a list of IPv6 entries into an nftables anonymous set
(or null when empty), so IPv6 ACLs match in a single set lookup
instead of one rule per address. */
@@ -140,6 +183,26 @@ function dnsmasq_nftset() {
/* Misc config */
const resources_dir = '/etc/homeproxy-pro/resources';
const run_dir = '/var/run/homeproxy-pro';
/* Resolved here, in the ucode block, rather than with `{% set %}` in the
* template body below: utpl's `set` tag takes a plain expression and rejects a
* call outright - `{% set x = f(); %}` is a parse error ("Unexpected token,
* Expecting ';'"). A parse error kills the whole render, fw4_post.nft comes
* out empty, and the health gate silently keeps the previous ruleset, so a
* template that fails this way does not look broken at all. r41 shipped
* `{% set node_addr_v4 = node_addr_to_nftarr(4); %}` and never rendered once.
*
* Both helpers take run_dir as a parameter rather than closing over it. In
* this ucode a closure captures the block variables that already exist where
* the function is DEFINED, not where it is called: a function defined above
* `const run_dir` cannot see run_dir even when it is called far below, and
* the error is "Reference error: access to undeclared variable run_dir" -
* which reads as though run_dir were missing, when it is in fact declared 80
* lines above the call. Passing it in is the only spelling that works. */
const node_protect = node_addr_protected(run_dir);
const node_addr_v4 = node_addr_to_nftarr(run_dir, 4);
const node_addr_v6 = node_addr_to_nftarr(run_dir, 6);
/* UCI config start */
const cfgname = 'homeproxy-pro';
@@ -399,6 +462,42 @@ set homeproxy_routing_port {
}
{% endif %}
{# Node server addresses.
Declared when the generator exported any node address at all - and NOT
only when there is a literal address to put in it.
That distinction is the whole point of the set existing. A node configured
with a host name contributes no element here; dnsmasq resolves the name and
back-fills this set through the nftset= snippet runtime/dns.sh writes. A
set that is declared only when it already has elements is therefore never
declared for a host-name node, the back-fill has nothing to land in, and the
return rules below - which are what actually exclude the address - are not
emitted either. r41 gated both on the literal form and was inert for the
configuration it was written for.
An empty set is harmless: nft accepts one with no elements, and the cost is
one lookup per packet until the first answer back-fills it. #}
{% if (node_protect): %}
set homeproxy_node_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (node_addr_v4): %}
elements = { {{ node_addr_v4 }} }
{% endif %}
}
{% endif %}
{% if (node_protect && v6_handled): %}
set homeproxy_node_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (node_addr_v6): %}
elements = { {{ node_addr_v6 }} }
{% endif %}
}
{% endif /* node_addr_v6 */ %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
@@ -461,6 +560,13 @@ chain homeproxy_redirect_lanac {
chain homeproxy_redirect {
meta mark {{ self_mark }} counter return
{% 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_redirect_proxy_port
{% if (v6_handled): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
@@ -582,6 +688,13 @@ chain homeproxy_mangle_lanac {
}
chain homeproxy_mangle_prerouting {
{% 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_tproxy_port
{% if (v6_handled): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
@@ -646,6 +759,13 @@ chain homeproxy_mangle_prerouting {
chain homeproxy_mangle_output {
meta mark {{ self_mark }} counter return
{% 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_mark
{% if (v6_handled): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
@@ -44,5 +44,7 @@ HP_FW4_SETS="
homeproxy_wan_proxy_addr_v6
homeproxy_wan_direct_addr_v4
homeproxy_wan_direct_addr_v6
homeproxy_node_addr_v4
homeproxy_node_addr_v6
homeproxy_routing_port
"
@@ -29,8 +29,8 @@ import { lstat, mkdtemp, readfile, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate } from './generator/client.uc';
import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, rule_set_tags } from './generator/common.uc';
import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ruleSetFormatFromPath, ruleSetInitialFallback, RULESET_EMPTY_BINARY, RULESET_EMPTY_SOURCE, RULESET_INITIAL_DIR, shellQuote, validateRuleSetPath, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc';
import { rule_set_tags } from './generator/common.uc';
import { removeBlankAttrs, isEmpty, isValidCIDR, probeRuleSetFile, ruleSetFormatFromPath, shellQuote, validateRuleSetPath, validation, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc';
/* Resolve the GenerationContext inputs. This is the only impure step on the
* client generation path, and it is deliberately here rather than under
@@ -46,69 +46,87 @@ import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ru
*
* The extra parentheses around the ubus call keep the ?. chain guarded when
* connect() returns null. */
/* Write one EMPTY rule-set to <path>, in <format>.
/* Normalise one server address into `host`, or return null when there is
* nothing usable. WireGuard peers and the plain outbounds disagree on the
* field name and the IPv6 form, so this is the only place that knows both. */
function node_host_of(v) {
if (isEmpty(v))
return null;
let host = trim(v);
/* `[fdfe::1]` is how an IPv6 literal arrives from a UCI value; the set
* syntax wants the bare address. */
if (length(host) > 2 && substr(host, 0, 1) === '[' && substr(host, -1, 1) === ']')
host = substr(host, 1, -2);
return isEmpty(host) ? null : host;
}
/* Export the node server addresses, split by form, into two files the
* intercept layer reads:
*
* The fallback exists so that a remote rule-set with no initial file of the
* user's own does not have to be downloaded before the inbounds bind - and on
* a cold cache that is the difference between a router that comes up and one
* that waits on raw.githubusercontent.com (through the node) before it will
* listen at all.
* RUN_DIR/node-addr-ips.txt literal addresses, rendered straight into
* the nft set by firewall_post.ut
* RUN_DIR/node-addr-domains.txt host names, handed to dnsmasq as
* `server=` + `nftset=` so the addresses they
* resolve to land in the same set
*
* binary is produced by compiling, not by writing bytes: a file produced by
* the sing-box that is running cannot drift from it, and the compiler is
* always there because the very next thing this script does is run it. The
* shipped 14-byte constant is the fallback for the fallback - a router where
* the compile cannot run - and it is a file in the package rather than a byte
* string in the source, so `hexdump -C` on a device can confirm it.
* Why the set exists at all: sing-box's own outbound already carries
* `routing_mark: self_mark` and every chain returns on that mark, so the
* tunnel's connection to the node is never re-intercepted. The LAN's is not.
* A client opening a connection to the node's own address - the panel, an SSH
* session to the landing box, anything on a port the proxy covers - was
* redirected into sing-box, which then dialled the very address it was asked
* for through the tunnel to that same node: one extra hairpin hop, and the
* node sees the connection arriving from itself.
*
* Returns true when <path> holds a usable file afterwards. A false return is
* not fatal: the caller skips the initial_path, and the rule-set then behaves
* exactly as it does today (fetched during initialization, blocking on
* failure). That is the point of the whole opt-in - it must never make a
* situation worse than not having it. */
function writeEmptyRuleSet(path, format) {
const ext = ruleSetFormatExtension(format);
* The generator is the only place that knows which nodes are actually in use
* (main_node, main_udp_node, urltest membership, custom-mode routing_nodes,
* wireguard endpoints), so it is the only place that can produce this list
* without the firewall template re-deriving that selection and drifting.
*
* Failure is not fatal. A missing file means the set renders empty, which is
* exactly the behaviour before this existed. */
function export_node_addresses(config) {
const addrs = [];
const domains = [];
if (!ext)
return false;
const add = (v) => {
const host = node_host_of(v);
if (host === null)
return;
if (isValidCIDR(host, 4) || isValidCIDR(host, 6))
push(addrs, host);
else if (validation('hostname', host))
push(domains, host);
};
system('mkdir -p ' + shellQuote(RULESET_INITIAL_DIR));
for (let ob in (config.outbounds || []))
add(ob.server);
if (format === 'source') {
/* No compile needed, and no temp file: the source form is the JSON
* itself. The shipped empty.source.json is the source of record, so
* a missing one means the install is incomplete and the copy is the
* thing that says so. */
if (system('cp -f ' + shellQuote(RULESET_EMPTY_SOURCE) + ' ' + shellQuote(path)) !== 0)
return false;
} else {
/* Compile into the destination directly. A partial output is
* possible if the compile dies, so the file is only accepted when the
* compile succeeded AND something is there - sing-box opening a
* truncated .srs would block startup, which is the outcome this is
* all meant to prevent.
*
* `-o` rather than `--output`, because that is the form
* tests/ucode/test_generators.sh already exercises against this
* binary, and a flag spelling that only ever runs in production is a
* flag spelling nobody has ever seen work. */
if (system('sing-box rule-set compile ' + shellQuote(RULESET_EMPTY_SOURCE)
+ ' -o ' + shellQuote(path) + ' >/dev/null 2>&1') !== 0) {
if (system('cp -f ' + shellQuote(RULESET_EMPTY_BINARY) + ' ' + shellQuote(path)) !== 0)
return false;
}
/* WireGuard moved from `outbounds` to `endpoints` in 1.13, and the peer
* address is the node there. */
for (let ep in (config.endpoints || []))
for (let peer in (ep.peers || []))
add(peer.address);
const st = lstat(path);
if (!st || st.type !== 'file' || st.size <= 0)
return false;
}
const dump = (path, list) => {
if (isEmpty(list))
return;
/* ucode's join() takes the separator FIRST: join(sep, list). With
* the arguments the other way round it does not raise, it returns
* null - and null + '\n' is the four-character string "null\n", so
* the file comes out non-empty and every "is it there?" check on
* it passes. That is exactly how a released r41 ended up with
* dnsmasq resolving a host literally named "null". */
if (writefile(path, join('\n', uniq(list)) + '\n') == null)
warn(sprintf('homeproxy-pro: could not write %s\n', path));
};
/* The jailed client reads this as the sing-box user. writefile() has no
* mode argument and cp preserves the source's, so the mode is set
* explicitly rather than inherited from whatever umask was in force. */
system('chmod 644 ' + shellQuote(path));
return true;
dump(RUN_DIR + '/node-addr-ips.txt', addrs);
dump(RUN_DIR + '/node-addr-domains.txt', domains);
}
function resolve_env(dm) {
@@ -147,6 +165,11 @@ function resolve_env(dm) {
* argument to get wrong - and because the neighbouring stderr-size
* check in homeproxy-pro.uc already reads sizes through it. */
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
* missing file has to leave the DNS rule out rather than name a path
* that does not exist and fail the whole start. */
china_domain_ready: lstat(HP_DIR + '/resources/china-domain.json') !== null,
/* Which enabled `type: local` rule-sets have a usable file on disk,
* keyed by UCI section name.
*
@@ -205,7 +228,6 @@ function resolve_env(dm) {
/* Which empty startup fallbacks are actually on disk, keyed by rule_set
* tag: { '<tag>': true }. Presence, not a path - see the block at the
* end of this function. */
ruleset_initial: {}
};
if (routing_mode === 'custom') {
@@ -302,92 +324,6 @@ function resolve_env(dm) {
}
}
/* ruleset_safe_start: write the empty fallbacks.
*
* Recorded as PRESENCE, not as a path: env.ruleset_initial[tag] is true
* only once a file for that tag is on disk and readable. The generator
* then emits `initial_path` for exactly the tags it can point at, which
* is what keeps the two sides from disagreeing - a file that could not be
* written is simply not offered, and that rule-set keeps today's
* behaviour (fetched during initialization) instead of pointing at
* something that is not there. sing-box ignores a missing initial_path
* and blocks startup exactly as if none had been configured, so this
* cannot make a broken install worse - but the point of the opt-in is
* that it helps, and a pointer to a nonexistent file helps nobody.
*
* Built-ins first, then the user's own. Both come from the same helper
* the generator uses, so the format the file was written in and the
* format declared in the config are decided by one question asked once. */
if (dm.general.ruleset_safe_start === '1') {
if (declaresBuiltinRemoteRuleSets(routing_mode)) {
for (let rs in BUILTIN_REMOTE_RULE_SETS) {
const path = ruleSetInitialFallback([ rs.tag ], rs.format, rs.url);
if (path && writeEmptyRuleSet(path, rs.format))
env.ruleset_initial[rs.tag] = true;
else
warn(sprintf("homeproxy-pro: could not write the empty fallback for '%s'; this rule-set will be fetched during startup, as before.", rs.tag));
}
}
/* User rule-sets are custom-mode only - build_user_rulesets() is not
* called in any other mode - so writing fallbacks for them anywhere
* else would create files nothing ever points at. A rule-set with an
* initial file of its own is left completely alone. */
if (routing_mode === 'custom') {
for (let cfg in (dm.routing.rulesets || [])) {
if (!cfg.enabled || cfg.type !== 'remote' || !isEmpty(cfg.initial_path))
continue;
const format = cfg.format || ruleSetFormatFromPath(cfg.url);
const tags = rule_set_tags(cfg);
const template = ruleSetInitialFallback(tags, format, cfg.url);
if (!template) {
warn(sprintf("homeproxy-pro: rule-set '%s' has no usable format for the startup fallback (format '%s', url '%s'); it will be fetched during startup, as before.", cfg.name, cfg.format || '', cfg.url || ''));
continue;
}
for (let tag in tags) {
if (env.ruleset_initial[tag])
continue;
/* A single-tag rule-set's template IS the path; a
* multi-tag one carries {tag} and needs one file each -
* sing-box opens every tag's file, so a missing second
* one blocks startup as surely as a missing first. */
const path = (length(tags) > 1) ? replace(template, '{tag}', tag) : template;
if (writeEmptyRuleSet(path, format))
env.ruleset_initial[tag] = true;
else
warn(sprintf("homeproxy-pro: could not write the empty fallback for rule-set '%s' (tag %s); it will be fetched during startup, as before.", cfg.name, tag));
}
}
}
}
/* One line naming what is running on an empty fallback.
*
* The CLI already warns per rule-set when a fallback could not be written,
* but "which rule-sets are currently on an EMPTY one" is the fact a user
* needs and nothing else records it: the running configuration is not
* readable by the browser, the health gate does not look at rule-sets, and
* sing-box's own log only says the download failed. This line is the
* durable answer, and it goes to homeproxy-pro.log, which the status page can
* read with the permission it already has.
*
* Emitted only when the opt-in is on AND something actually got a
* fallback, so the log does not gain a line on every generation of a
* configuration that is not using the feature. */
if (dm.general.ruleset_safe_start === '1' && !isEmpty(env.ruleset_initial)) {
const tags = [];
for (let tag in env.ruleset_initial)
push(tags, tag);
warn(sprintf("homeproxy-pro: ruleset_safe_start is on; these rule-sets have an EMPTY initial file and match nothing until their download succeeds: %s.", join(', ', tags)));
}
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]/) : [];
@@ -432,6 +368,11 @@ if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
exit(1);
}
/* Only now, with a configuration sing-box has accepted: the firewall and the
* dnsmasq snippets read the exported list, and they must never be pointed at
* addresses from a configuration that does not start. */
export_node_addresses(config);
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
system('rm -rf ' + shellQuote(work_dir));
exit(1);
@@ -18,7 +18,7 @@
'use strict';
import { isEmpty } from '../homeproxy-pro.uc';
import { isEmpty, HP_DIR } from '../homeproxy-pro.uc';
import { ConfigQuery } from '../config/model.uc';
@@ -142,43 +142,42 @@ export function rule_set_tags(cfg) {
return tags;
};
/* BUILTIN_REMOTE_RULE_SETS - the remote rule-sets the generator declares
* itself, in the modes that split on mainland China.
/* The routing modes that split on mainland China, and the rule-sets they
* declare.
*
* geoip-cn referenced by the route rule to split destinations by IP
* geosite-cn referenced only by the DNS rule (server: china-dns); the
* route layer never matches it, but sing-box still loads and
* keeps it, so removing it would break the DNS split
* The three of them - china-ip, china-ip6, china-domain - are local files the
* resource updater generates, one per list, and each is declared only when
* something references it and its file is on disk. route.uc owns that
* decision because the conditions are per-tag (the address list in both
* mainland modes, the v6 half only with IPv6 on, the domain list only in
* bypass mode), so there is nothing here for a shared list to keep in step
* with.
*
* This list is shared with generate_client.uc, which has to create the
* "do not block startup" fallback file for each of them before the generator
* runs. The two used to be independent: route.uc hard-coded the entries and
* the CLI knew nothing about them, which is why a cold-cache first start
* blocked on downloading them and why a change to either side could not be
* checked against the other. One list, two readers.
* They were `type: remote` entries pointing at SagerNet's sing-geoip and
* sing-geosite repositories, and two things followed from that which the local
* arrangement does not have:
*
* Not a conffile-order dependency - this is the generator's own declaration,
* in the order the generated rule_set array must keep so the emitted bytes
* stay stable. */
export const BUILTIN_REMOTE_RULE_SETS = [
{
tag: 'geoip-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h'
},
{
tag: 'geosite-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h'
}
];
/* The routing modes that declare them. Kept beside the list so the CLI can
* decide whether it needs to create their fallbacks without repeating the
* condition route.uc branches on - and so the two cannot drift. */
export function declaresBuiltinRemoteRuleSets(routing_mode) {
* - a cold start had to download them before the inbounds bind, over the
* node (download_detour), so a first start - or a start after the cache
* was cleared - depended on GitHub being reachable through the proxy it
* was trying to bring up;
* - the two halves of the same split read two different files. The kernel's
* came from china_ip4.txt and the resolver's from a .srs, and the route.uc
* comment about them disagreeing is the scar of patching that instead of
* fixing it.
*
* Now the nft set and the sing-box rule-set are both generated from the same
* list, on the same schedule, with nothing to download.
*
* Source JSON, not a compiled .srs: the updater writes the canonical form and
* sing-box watches the file, so a new list is picked up in place. Compiling
* first was measured rather than assumed - 111k domain suffixes cost 3.7 MB of
* RSS in source form (44.9 MB baseline vs 48.7 MB loaded), which is not worth
* a second artifact and a second failure mode. */
/* The routing modes that split on mainland China. The China domain list is
* only meaningful where the split is by domain: proxy_mainland_china decides
* by address on the route side and leaves the resolver to the mode default. */
export function declaresBuiltinRuleSets(routing_mode) {
return routing_mode === 'bypass_mainland_china' || routing_mode === 'proxy_mainland_china';
};
@@ -138,6 +138,11 @@ export function build_context(dm, env) {
* whole config. Default false - an absent env must not turn into an
* optimistic "assume the file is there". */
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
* CLI resolves, and an absent env must not be read as "assume the
* file is there". */
china_domain_ready: (env?.china_domain_ready === true),
/* Which enabled `type: local` rule-sets have a usable file on disk,
* keyed by UCI section name: { '<section>': true }.
*
@@ -256,29 +261,6 @@ export function build_context(dm, env) {
* Default '0' preserves the
* 300ms / default-list behaviour so an upgrade is invisible. */
sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0',
/* ruleset_safe_start: when '1' (opt-in), every enabled remote
* rule-set that has no initial_path of its own gets one, pointing at
* an EMPTY rule-set the generator writes before the config is
* checked. A remote rule-set with no initial file is fetched during
* initialization, before the inbounds bind, so on a cold cache the
* first start depends on the CDN being reachable - and on the node,
* because the fetch goes through http_clients.
*
* The fallback is empty on purpose and that is the trade: a rule-set
* with no rules matches nothing, so its traffic falls through to
* `final`. That is a routing change, not a robustness tweak, which
* is why the default is '0' and why the whole repository stays
* fail-loud by default. Guard 52 pins the default, the way guard 39
* pins sniffer_advanced_mode's. */
ruleset_safe_start: dm.general.ruleset_safe_start || '0',
/* Which empty startup fallbacks the CLI actually wrote, keyed by
* rule_set tag. Presence, not a path: the generator emits
* `initial_path` only for tags it can point at, so a fallback that
* could not be written leaves that rule-set on today's behaviour
* rather than pointing at a file that is not there. Pessimistic by
* construction - an env that forgot the field (the server path) has
* no fallbacks, and the server declares no remote rule-sets anyway. */
ruleset_initial: (env?.ruleset_initial || {}),
main_urltest_nodes: dm.general.main_urltest_nodes || [],
main_urltest_interval: dm.general.main_urltest_interval,
main_urltest_tolerance: dm.general.main_urltest_tolerance,
@@ -278,11 +278,22 @@ function append_proxy_dns(config, dm, ctx) {
server: 'default-dns'
});
push(config.dns.rules, {
rule_set: 'geosite-cn',
action: 'route',
server: 'china-dns'
});
/* The China domain list, generated from china_list.txt - the same
* schedule and the same updater as the address list the route and
* firewall halves read. It replaced geosite-geolocation-cn.srs,
* which had to be downloaded before the inbounds could bind.
*
* Only with the file there: a rule naming a tag nothing declares
* fails the config, and a rule-set whose path is missing fails it
* too, so both the declaration (route.uc) and this reference are
* gated on the same lstat. */
if (ctx.china_domain_ready) {
push(config.dns.rules, {
rule_set: 'china-domain',
action: 'route',
server: 'china-dns'
});
}
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
if (ctx.cn_ip_fallback === '1') {
@@ -292,18 +303,17 @@ function append_proxy_dns(config, dm, ctx) {
tag: 'cn-fallback'
});
/* The response is matched by the addresses the upstream returned,
* and geoip-cn has no IPv6 in it, so with IPv6 support on a
* mainland domain that resolved to an AAAA address was never
* recognised as mainland and its answer kept coming from the
* proxy resolver. Adding the v6 rule-set here is what makes the
* DNS half of the split agree with the route half, which
* classifies the same destinations through the same list
* (route.uc: china-ip6). Both tags have to be declared before
* this rule runs, which is why the list is built here rather than
* inlined: a rule_set naming an undeclared tag fails the config.
* china-ip6 is only declared when its file exists, so it is only
* named here under the same condition. */
const cn_fallback_sets = ['geoip-cn'];
* and the address list has to be the one the route half uses for
* the same decision to agree with it - so this names china-ip, not
* a list of its own. It is the same reason the declaration lives
* in common.uc.
*
* china-ip6 is added only when it is declared: the rule_set list
* has to name declared tags only, or the config is rejected.
* Without it a mainland domain that resolved to an AAAA address
* is never recognised as mainland and its answer keeps coming
* from the proxy resolver. */
const cn_fallback_sets = ['china-ip'];
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready)
push(cn_fallback_sets, 'china-ip6');
push(config.dns.rules, {
@@ -26,9 +26,9 @@
'use strict';
import { isEmpty, strToInt, strToTime, strToBool, parse_port, ruleSetInitialFallback, HP_DIR } from '../homeproxy-pro.uc';
import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc';
import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
import { declaresBuiltinRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
/* --- shared initial block (every routing mode) ------------------------- */
@@ -93,10 +93,10 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
outbound: 'direct-out'
});
/* Split by destination IP, in both directions.
/* Split by destination address, in both directions.
*
* sing-box does not match an IP-based rule set (geoip-cn) against a
* domain destination unless it is resolved first, so an explicit resolve
* sing-box does not match an address-based rule set against a domain
* destination unless it is resolved first, so an explicit resolve
* action comes first. Which way the split points is the mode's default
* policy (ctx.proxy_fallback):
*
@@ -105,10 +105,17 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
* proxy_mainland_china mainland -> proxy, the rest falls through to
* direct-out
*
* Using geoip-cn rather than the geosite-* domain lists is deliberate:
* those mis-classify foreign domains (Google's gvt2.com beacons) as "cn"
* and would send them direct to time out. Keep the direct-domain
* fast-path above for known direct domains. */
* Using china-ip rather than a domain list is deliberate: those
* mis-classify foreign domains (Google's gvt2.com beacons) as "cn" and
* would send them direct to time out. Keep the direct-domain fast-path
* above for known direct domains.
*
* One list for both halves of the split: china-ip is generated from the
* same china_ip4.txt the firewall renders homeproxy_mainland_addr_v4
* from, so the kernel and the resolver cannot disagree about what counts
* 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') {
push(config.route.rules, {
@@ -116,37 +123,20 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
});
push(config.route.rules, {
rule_set: 'geoip-cn',
rule_set: 'china-ip',
action: 'route',
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. */
push(config.route.rules, {
rule_set: 'china-ip',
action: 'route',
outbound: 'direct-out'
});
/* IPv6 half of the same split, and the reason a mainland site opened
* over IPv6 used to go through the proxy even with IPv6 support on.
* geoip-cn.srs carries no IPv6 and china_ip4.json is generated from an
* IPv4 list, so a destination that resolved to an AAAA address matched
* neither and fell through to `final` - which in this mode is main-out.
* The rule is emitted in both mainland modes (unlike china-ip, which
* proxy_mainland_china does not need twice because geoip-cn already
* picks the mainland side there): with no IPv6 in geoip-cn there is
* nothing left to pick it, so the side has to be named explicitly.
/* The IPv6 half of the same split, and the reason a mainland site
* opened over IPv6 used to go through the proxy even with IPv6
* support on. china_ip4.json is generated from an IPv4 list, so a
* destination that resolved to an AAAA address matched nothing and
* fell through to `final` - which in bypass mode is main-out. The
* rule is emitted in both mainland modes for that reason. Declared
* only when china_ip6.json is actually there: a rule_set pointing at
* a missing file takes the whole config down with it, and a router
* whose v6 list is unusable must still start (the firewall has
* already degraded to passing IPv6 through, with a warning).
* `prefer_ipv4` on the resolve rule above is what lets both families
* through - it biases, it does not filter. */
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) {
@@ -211,90 +201,36 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
]
});
/* Both mainland modes split on geoip-cn, so both need it declared - it is
* what the route rule added above matches. `proxy_mainland_china` used
* to reach this point without it and sing-box refused the whole config
* with "initialize rule[3]: rule-set not found: geoip-cn", which the
* health gate turned into a rollback and an unproxied network. */
if (declaresBuiltinRemoteRuleSets(ctx.routing_mode)) {
/*
* Fetched straight from the upstream SagerNet repositories and
* downloaded through the selected node. A direct fetch depends on
* the CDN staying reachable from mainland China, where DNS pollution
* makes it fail intermittently; the files total ~250 KB per
* day, so proxying the download costs almost nothing.
*
* Both rule_sets are emitted, but they are consumed by different
* blocks:
*
* geoip-cn - referenced by the route rule above to split
* destinations by IP: mainland -> direct-out in
* bypass_mainland_china, mainland -> main-out in
* proxy_mainland_china.
* geosite-cn - referenced only by the DNS rule in
* generator/dns.uc (rule_set: geosite-cn ->
* server: china-dns); the route layer never matches
* it. Sing-box still loads and keeps the rule-set in
* memory even when only one block references it, so
* removing it would also break the DNS split.
*
* History: a third geosite-noncn used to be declared here as well,
* but no rule referenced it and sing-box still loaded and updated
* it daily, so it was removed (the comment above was rewritten
* during the 2026-09-29 review to clarify that geosite-cn is still
* in use - the DNS side - just not from this block).
*
* The list itself lives in common.uc, because the CLI has to create
* an "initial" fallback file for each of these before this generator
* runs - see the note on BUILTIN_REMOTE_RULE_SETS. That is the whole
* reason this block is a loop: these two are the rule-sets a cold
* install blocks on, so a change to them that only reached one of the
* two readers would put the fallback out of step with the config
* that points at it.
*/
for (let rs in BUILTIN_REMOTE_RULE_SETS) {
push(config.route.rule_set, {
type: 'remote',
tag: rs.tag,
format: rs.format,
url: rs.url,
update_interval: rs.update_interval,
/* Only with the opt-in on, and only for a fallback the CLI
* actually wrote. A remote rule-set with no initial_path is
* fetched during initialization, before the inbounds bind, so
* a cold cache makes the first start depend on the CDN - and
* on the node, since these go through http_clients. The
* fallback is an EMPTY rule-set, which changes routing for
* everything it would have matched, so it stays off unless
* the user asks for it.
*
* These two are the rule-sets a fresh install blocks on, so
* they are also the whole point of the feature: an earlier
* version of this block only knew how to build the entries,
* and the CLI had no idea they existed. */
initial_path: (ctx.ruleset_safe_start === '1' && ctx.ruleset_initial[rs.tag])
? ruleSetInitialFallback([ rs.tag ], rs.format, rs.url) : null,
download_detour: 'main-out'
});
}
/* Local, and not downloaded: it is generated from the same
* china_ip4.txt the firewall renders homeproxy_mainland_addr_v4
* from, so the two cannot drift apart. `type: local` is watched by
* sing-box with fswatch, so when the resource updater replaces the
* file the running instance reloads it in place - no restart and,
* more importantly, no second copy of the list to keep in sync. */
/* Both mainland modes split on the China address list, so both need it
* declared - it is what the route rule added above matches.
* `proxy_mainland_china` used to reach this point without a declaration and
* sing-box refused the whole config with "initialize rule[3]: rule-set not
* found: geoip-cn", which the health gate turned into a rollback and an
* unproxied network.
*
* All three are `type: local` and none of them is downloaded. Each is
* generated by runtime/{china_ip,domain}_ruleset.uc from a list the resource
* updater maintains, and sing-box watches the file with fswatch, so a new
* list is picked up in place - no restart, and no second copy of the data to
* keep in step with the firewall's nft set.
*
* Which ones are declared depends on what references them:
* china-ip the route rule above, in both mainland modes
* china-ip6 the IPv6 route rule, when there is one
* china-domain the DNS rule in generator/dns.uc, bypass mode only -
* proxy_mainland_china decides by address and leaves the
* 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'
});
/* The IPv6 counterpart, from china_ip6.txt - the same list the
* firewall renders homeproxy_mainland_addr_v6 from, and the only
* IPv6 mainland data either side of the split has. 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 whose v6
* list is unusable must still start (the firewall has already
/* 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
* whose v6 list is unusable must still start (the firewall has already
* degraded to passing IPv6 through, with a warning). */
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) {
push(config.route.rule_set, {
@@ -303,6 +239,18 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
path: HP_DIR + '/resources/china_ip6.json'
});
}
/* The DNS half. Same "only when it is there" rule as the v6 one
* above, for the same reason: generate_client.uc's dns rule names this
* tag, and a declaration whose file is missing fails the start rather
* than degrading. */
if (ctx.routing_mode === 'bypass_mainland_china' && ctx.china_domain_ready) {
push(config.route.rule_set, {
type: 'local',
tag: 'china-domain',
path: HP_DIR + '/resources/china-domain.json'
});
}
}
if (isEmpty(config.route.rule_set))
@@ -26,7 +26,7 @@
'use strict';
import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, ruleSetInitialFallback, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag, rule_set_tags } from './common.uc';
@@ -214,37 +214,6 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
if (!validateRuleSetPath(cfg.initial_path))
die(sprintf("homeproxy-pro: rule-set '%s' initial_path '%s' is outside the allowed rule-set roots (%s); move the file into one of them, or clear the field to let sing-box fetch the rule-set on its own.", cfg.name, cfg.initial_path, rulePathRootsText()));
ruleset.initial_path = cfg.initial_path;
} else if (cfg.type === 'remote' && ctx.ruleset_safe_start === '1') {
/* No initial file of the user's own, and the opt-in is on: point at
* the empty fallback the CLI wrote before this generator ran.
*
* ctx.ruleset_initial records PRESENCE, not a path, and every tag
* has to be present. sing-box substitutes {tag} and opens every
* resulting file, so a multi-tag rule-set whose second file is
* missing blocks startup exactly as surely as one whose first is -
* measured on 1.14.2, where a literal "{tag}.srs" on disk makes
* it fall back to downloading and fail. All-or-nothing is the
* only correct answer here.
*
* Nothing is emitted when the answer is no: the rule-set then
* behaves exactly as it does today, which is also what happens if
* the CLI could not write the file. */
const fallback = ruleSetInitialFallback(all_tags, cfg.format, cfg.url);
if (fallback) {
let ready = true;
for (let t in all_tags)
if (!ctx.ruleset_initial[t]) {
ready = false;
break;
}
if (ready)
ruleset.initial_path = fallback;
else
warn(sprintf("homeproxy-pro: rule-set '%s' has no complete empty fallback, so it will be fetched during startup as before.", cfg.name));
}
}
push(rule_set_array, ruleset);
}
@@ -300,116 +300,6 @@ export function ruleSetFormatFromPath(path) {
return null;
};
/* --- the "do not block startup on the first download" fallback ---------- *
*
* A remote rule-set with no `initial_path` is fetched during
* initialization, BEFORE the inbounds bind. On a cold cache that means a
* fresh install cannot start until raw.githubusercontent.com answers - and if
* it does not, the whole instance dies with a FATAL and the health gate turns
* that into a rollback or a released intercept layer. The node has to be up
* too, because these downloads go through http_clients, so a working
* configuration can be kept off the air by an unreachable CDN.
*
* `initial_path` is the way out: sing-box reads the file first, starts
* immediately, and refreshes from the URL in the background. A download that
* keeps failing then becomes an ERROR line in the log instead of a failed
* start - and the health gate does not look at rule-sets at all, so a
* degraded rule-set is promoted like any other healthy start.
*
* What the file holds is an EMPTY rule-set, and that is the whole trade: a
* rule-set with no rules matches nothing, so its traffic falls through to
* `final`. That changes routing, so this is opt-in (config.ruleset_safe_start,
* default off) and never the default.
*
* The format has to match the declared one. A binary rule-set fed a source
* JSON is not read at all - sing-box ignores the mismatch and blocks exactly
* as it does with no initial_path - so the decision of whether a fallback is
* even possible is made once, here, from the same inputs on both sides. */
/* Where the generated fallbacks live. Inside the rule-set archive, so
* validateRuleSetPath() admits it and the jail's read-only mount of HP_DIR
* already exposes it to the sing-box user. */
export const RULESET_INITIAL_DIR = HP_DIR + '/ruleset/initial';
/* The two files the package ships for this. empty.source.json is what
* `sing-box rule-set compile` consumes (24 bytes, readable, and the form
* every version agrees on); empty.srs is the compiled result of THAT source
* on 1.14.2, kept as the last resort for a router where the compile cannot
* run.
*
* The .srs is committed rather than built at package time on purpose: it is a
* 14-byte binary, and having it in the tree means `hexdump -C` on a router
* can confirm byte for byte that the shipped constant is the documented one.
* It is deliberately NOT a conffile - it is ours, not the user's, and an
* upgrade may replace it freely.
*
* Compatibility note, since a hard-coded binary artifact invites the question:
* sing-box only rejects an SRS whose version is NEWER than its own, so a
* version-2 file stays readable. The compile path exists anyway, because a
* file produced by the kernel that is running it cannot drift from it. */
export const RULESET_EMPTY_SOURCE = RULESET_INITIAL_DIR + '/empty.source.json';
export const RULESET_EMPTY_BINARY = RULESET_INITIAL_DIR + '/empty.srs';
/* A tag becomes a file name, so it has to be one. UCI section names are
* already restricted, but a rule_set `tag` is a sing-box identifier that UCI
* does not fully own, and this value is about to be concatenated into a path
* the generator writes as root. Anything outside this set is refused, and
* the caller skips the fallback rather than sanitising it into a name that
* could collide with another rule-set's. */
export function isSafeRuleSetTag(tag) {
if (!tag || type(tag) !== 'string')
return false;
return match(tag, /^[A-Za-z0-9][A-Za-z0-9._-]*$/) != null;
};
/* The extension a format is stored under. Null for anything that is not one
* of the two formats, which is the caller's signal that no fallback is
* possible rather than something to guess at. */
export function ruleSetFormatExtension(format) {
if (format === 'binary')
return 'srs';
if (format === 'source')
return 'json';
return null;
};
/* ruleSetInitialFallback(tags, declared, url) -> the initial_path to emit, or
* null when no fallback is possible.
*
* One function, called from both sides on purpose. The CLI asks it to
* decide which files to create and the generator asks it where to point
* `initial_path`; if they each worked it out separately they could disagree
* about the format, and a mismatch is the failure this whole mechanism exists
* to avoid - sing-box ignores a wrong-format initial file and blocks startup
* exactly as if there were none.
*
* `declared` is the rule-set's own `format`, which for a rule-set with no
* initial file is also the format the generator emits (nothing to probe, so
* nothing is corrected). With no declared format the URL's extension is the
* only remaining evidence; with neither, the answer is null and the rule-set
* keeps today's behaviour rather than being handed a file of the wrong shape.
*
* More than one tag gets a `{tag}` placeholder, because that is what sing-box
* substitutes and it requires every tag's file to exist - P3 on 1.14.2: a
* literal "{tag}.srs" on disk makes it try to download and block. */
export function ruleSetInitialFallback(tags, declared, url) {
const format = declared || ruleSetFormatFromPath(url);
const ext = ruleSetFormatExtension(format);
if (!ext)
return null;
for (let tag in (tags || []))
if (!isSafeRuleSetTag(tag))
return null;
const stem = (length(tags || []) > 1) ? '{tag}' : tags[0];
return RULESET_INITIAL_DIR + '/' + stem + '.' + ext;
};
/* probeRuleSetFile(path) -> 'binary' | 'source' | null.
*
@@ -0,0 +1,444 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*
* A single self-contained diagnostic report, built in-process for the
* `debug_report` rpc method (see luci.homeproxy-pro). It exists because the
* failure modes this plugin has are not visible from the log page: a route
* that stopped splitting, an nft rule that is not in the fw4 table, a
* rule-set that never downloaded, a mark that no longer matches anything.
* Each of those needs a different command to confirm, and collecting them
* by hand over ssh is slow and easy to get wrong - the commands are spread
* across `ip rule`, `nft`, the procd state and the resource version files.
*
* Two properties are deliberate and are the reason this is not a shell
* script dropped in beside the other runtime helpers:
*
* 1. REDACTION IS DEFAULT-DENY. A node section is a credential store: it
* holds passwords, uuids, private keys, SNI and the server address. A
* report that is going to be pasted into an issue tracker has to be safe
* by construction, so the rule is "if the key looks like a secret, mask
* it" rather than an enumerated list of keys to mask - the enumerated
* kind is exactly the one that goes stale when a field is added. The
* pattern is applied to the key name, so a value is masked no matter
* which section it came from.
*
* 2. EVERY PROBE DEGRADES. A report that aborts because `nft` is missing,
* or that silently omits a section when a command fails, is worse than
* no report: the reader cannot tell "not applicable" from "not
* collected". Each probe therefore reports one of three states -
* the output, "(unavailable)" for a missing command, or
* "(no output, exit N)" for one that ran and failed. This is the same
* distinction health.sh draws between "probe failed" and "cannot probe
* here", and it is why a testbed without netfilter still produces a
* usable report.
*
* The commands below are fixed literals: nothing in this file is built from
* configuration, so there is no shell interpolation to get wrong and no
* argument that would need shellQuote().
*/
'use strict';
import { popen } from 'fs';
import { HP_DIR, RUN_DIR, shellQuote } from '../homeproxy-pro.uc';
export const DEBUG_REPORT_PATH = RUN_DIR + '/debug.log';
/* Per-section output cap. The nft dump is the one section that is expected
* to be large - a populated fw4 table runs to a few thousand lines, and
* truncating it is worse than useless because a truncated chain looks like
* a chain that ends there. The cap is high enough that a real router never
* reaches it, and reaching it is stated in the output rather than hidden. */
const SECTION_MAX = 128 * 1024;
/* A key whose name matches any of these is masked. Deliberately broad: a
* false positive costs a line of a report, a false negative ships someone's
* private key. `method` and `path` are in the list because obfs parameters
* and websocket paths are credentials in practice; `user` catches both
* `username` and the access-control user lists; `short` is there for
* `tls_reality_short_id`, which matched nothing until a canary run put a
* value in it and found it in the report verbatim.
*
* `url` is the one pattern that is NOT a bare substring, and it has to stay
* that way: a plain `url` alternative matches inside `main_urltest_nodes` and
* `main_urltest_tolerance` (`main_u-r-l-test`), which silently blanked the
* urltest pool out of the report. Those lists are how you tell "this node
* is still in the test pool" from "this node was dropped", which is a
* question the report exists to answer. Anchoring on a word boundary keeps
* `url` / `urls` / `subscription_urls` covered and leaves the pool visible. */
const SECRET_KEY_RE = /pass|secret|token|key|uuid|(^|_)urls?$|address|server|host|sni|user|psk|path|method|param|seed|salt|auth|hosts|short/i;
/* Exported for the rpc behaviour test: the redaction rule is the one part of
* this file with no I/O to observe, so it is the one part that can be
* asserted directly - a key that stops being recognised is a silent leak. */
export function isSecretKey(name) {
/* ucode has no RegExp.prototype.test - the module-level match() is the
* only way to apply a pattern, and every other file in this tree does it
* the same way. */
return type(name) === 'string' && match(name, SECRET_KEY_RE) != null;
};
function maskValue(value) {
/* A list keeps its length visible: "3 masked entries" tells the reader
* the subscription imported something, which is a fact worth having,
* without disclosing any of it. */
if (type(value) === 'array')
return value === null || length(value) === 0
? value
: sprintf('[%d masked]', length(value));
return '***';
};
/* Run one collector. Returns { state, text } where state is 'ok',
* 'unavailable' or 'failed', so the caller can label it honestly instead of
* printing an empty block that reads like "nothing to see". */
function probe(command) {
if (command == null)
return { state: 'unavailable', text: '' };
const fd = popen(command);
if (fd == null)
return { state: 'unavailable', text: '' };
let out = fd.read('all');
fd.close();
if (out == null)
return { state: 'failed', text: '' };
out = trim(out);
/* popen() gives no exit status, so an empty result is the only signal
* that the command produced nothing. Whether that is a problem
* depends on the command, and the section header says which. */
if (out === '')
return { state: 'failed', text: '' };
if (length(out) > SECTION_MAX)
out = substr(out, 0, SECTION_MAX) +
sprintf('\n... [truncated at %d bytes; rerun the command by hand for the full output]\n', SECTION_MAX);
return { state: 'ok', text: out };
};
/* The value form of the same collector: trimmed stdout, or null when the
* command produced nothing. Used where the caller wants a value rather
* than a rendered block. Deliberately not written with the optional-chaining
* shorthand, which no other file in this tree uses. */
function runCommand(command) {
const fd = popen(command);
if (fd == null)
return null;
const out = fd.read('all');
fd.close();
if (out == null)
return null;
const t = trim(out);
return t === '' ? null : t;
};
function runDate() {
return runCommand('date "+%Y-%m-%d %H:%M:%S %Z"') || 'unknown';
};
/* Render one ```bash fenced block for a command, with the state spelled out
* when there is no output to show. */
function block(command, unavailable_label) {
const r = probe(command);
if (r.state === 'ok')
return sprintf('```shell\n# %s\n%s\n```\n', command, r.text);
if (r.state === 'unavailable')
return sprintf('```\n(unavailable: %s)\n```\n', unavailable_label || command);
return '```\n(no output)\n```\n';
};
/* Render a two-column table, or a single "(unavailable)" row. */
function table(rows, unavailable_label) {
if (rows == null)
return sprintf('_%s_\n', unavailable_label || 'unavailable');
let out = '| 项目 | 值 |\n|------|-----|\n';
for (let r in rows)
out += sprintf('| %s | %s |\n', r[0], r[1]);
return out;
};
/* `apk list -I <pkg>` prints one line:
*
* sing-box-1.14.2-r1 x86_64 {feeds/.../sing-box} (GPL-3.0-or-later) [installed]
*
* The package name is the argument, so the version is simply what follows
* that name's trailing dash up to the first space. A pattern anchored on
* a negated class instead - `^[^ -]+-([0-9][^ ]*)` - stops at the FIRST
* dash, so it works for `firewall4` and silently fails for every package
* whose own name contains one (`sing-box`, `luci-app-homeproxy-pro`), which is
* most of them. Splitting on the known prefix has no such case.
*
* This also avoids `\\s` inside a negated class, whose meaning inside `[...]`
* is not what it is outside: `homeproxy-pro.uc`'s `replace(reason, /\s+/g, ' ')`
* collapses whitespace correctly, yet the same `\s` inside `[^\\s-]` did not
* exclude whitespace, and the version came back with the architecture glued
* to it. */
function packageVersion(name) {
if (system('command -v apk >/dev/null 2>&1') !== 0)
return null;
const fd = popen(sprintf('apk list -I %s 2>/dev/null', shellQuote(name)));
if (fd == null)
return null;
const line = trim(fd.read('line'));
fd.close();
if (line == null || line === '')
return null;
const prefix = name + '-';
if (index(line, prefix) !== 0)
return null;
const rest = substr(line, length(prefix));
const sp = index(rest, ' ');
const v = (sp == -1) ? rest : substr(rest, 0, sp);
return v === '' ? null : v;
};
/* --- UCI summary --------------------------------------------------------- */
/* A structural, redacted view of the configuration: counts and switch
* positions, never a credential. Sections are summarised by type rather
* than dumped, because a node section has no field that is safe to print
* and a subscription section has exactly one. */
function configSummary(uci) {
const all = uci.get_all('homeproxy-pro') || {};
const byType = {};
for (let sid in all) {
const section = all[sid];
const stype = section['.type'] || 'unknown';
if (byType[stype] == null)
byType[stype] = { count: 0, sample: section['.name'] || sid };
byType[stype].count++;
}
if (byType == null || length(keys(byType)) === 0)
return '_configuration is empty_\n';
let out = '';
for (let t in keys(byType)) {
out += sprintf('- `%s`: %d section(s)\n', t, byType[t].count);
}
/* The switches that decide what the report is FOR: which mode, which
* ports, and the mark - these are the first things anyone reading a
* routing problem looks for, and none of them is sensitive. Each entry
* is [section, option]. */
const switches = [
['config', 'proxy_mode'],
['config', 'routing_mode'],
['config', 'main_node'],
['config', 'main_udp_node'],
['config', 'ipv6_support'],
['config', 'log_level'],
['config', 'bypass_cn_traffic'],
['infra', 'mixed_port'],
['infra', 'redirect_port'],
['infra', 'tproxy_port'],
['infra', 'dns_port'],
['infra', 'self_mark'],
['infra', 'tproxy_mark'],
['infra', 'tun_mark'],
['infra', 'table_mark'],
['infra', 'tun_name'],
['server', 'enabled']
];
out += '\n| 开关 | 值 |\n|------|-----|\n';
for (let sw in switches) {
const v = uci.get('homeproxy-pro', sw[0], sw[1]);
out += sprintf('| `%s.%s` | %s |\n', sw[0], sw[1],
(v == null || v === '') ? '_(unset)_' : (isSecretKey(sw[1]) ? maskValue(v) : v));
}
return out;
};
/* Every non-secret option, flattened, with anything that looks like a
* credential masked. This is the section that makes the report useful for
* "which option did I set wrong" - the summary above cannot show them. */
function fullConfig(uci) {
const all = uci.get_all('homeproxy-pro') || {};
let out = '';
for (let sid in keys(all)) {
const section = all[sid];
const stype = section['.type'] || 'unknown';
const sname = section['.name'] || sid;
out += sprintf('\n### %s `%s`\n\n```\n', stype, sname);
for (let k in keys(section)) {
if (substr(k, 0, 1) === '.')
continue;
const v = section[k];
if (isSecretKey(k)) {
out += sprintf('option %s %s\n', k, maskValue(v));
} else if (type(v) === 'array') {
out += sprintf('list %s %s\n', k,
length(v) === 0 ? '' : join(' ', v));
} else {
out += sprintf('option %s %s\n', k, v);
}
}
out += '```\n';
}
return out == '' ? '_configuration is empty_\n' : out;
};
/* --- resource versions --------------------------------------------------- */
/* The .ver files next to each shipped list. A rule-set that has been
* failing to download shows up here as a version that never moves, which is
* the cheapest way to tell "the list is stale" from "the list is missing". */
function resourceVersions() {
const names = ['china_ip4', 'china_ip6', 'china_list', 'gfw_list'];
let out = '';
for (let n in names) {
const path = sprintf('%s/resources/%s.ver', HP_DIR, n);
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(path)));
if (fd != null) {
const v = trim(fd.read('all'));
fd.close();
out += sprintf('- `%s`: %s\n', n, (v == null || v === '') ? '_(no version file)_' : v);
} else {
out += sprintf('- `%s`: _(unavailable)_\n', n);
}
}
/* The dnsmasq nftset capability marker: init.d writes this at start
* and firewall_post.ut gates the gfwlist mode on it, so a device whose
* dnsmasq was built without HAVE_NFTSET silently routes everything
* direct while DNS still goes through the proxy. */
const marker = sprintf('%s/resources/.dnsmasq-nftset', HP_DIR);
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(marker)));
let cap = 'unknown';
if (fd != null) {
cap = trim(fd.read('all')) || 'unknown';
fd.close();
}
out += sprintf('- `dnsmasq nftset`: %s', cap == '1' ? 'yes' : (cap == '0' ? 'no (gfwlist mode cannot work)' : cap));
return out + '\n';
};
/* --- the report ---------------------------------------------------------- */
export function buildDebugReport(uci) {
const stamp = trim(runDate());
let out = '';
out += '# HomeProxy 诊断报告\n\n';
out += sprintf('> 生成时间: %s\n', stamp == '' ? '(unknown)' : stamp);
out += sprintf('> 插件版本: %s\n', packageVersion('luci-app-homeproxy-pro') || 'unknown');
out += '> 隐私: 凭据字段已按名称脱敏,但本报告仍含公网 IP、内网拓扑和 LAN 主机地址。\n';
out += '> 外发前请自行确认。\n';
/* 1. System */
out += '\n## 1. 系统\n\n';
out += block('cat /etc/openwrt_release 2>/dev/null', 'uname -a');
out += block('uname -a');
out += block('uptime');
out += block('df -h / /tmp /etc/homeproxy-pro 2>/dev/null', 'df unavailable');
out += block('free -m 2>/dev/null', 'free unavailable');
/* 2. Dependencies - the table, not a "is it there" yes/no, because the
* version is what a reader needs when reporting "it does not work on
* this build". */
out += '\n## 2. 依赖检查\n\n';
const deps = ['sing-box', 'firewall4', 'kmod-nft-tproxy', 'kmod-tun',
'ip-full', 'uclient-fetch', 'ucode', 'ucode-mod-digest'];
const dep_rows = [];
for (let d in deps) {
const v = packageVersion(d);
push(dep_rows, [d, v == null ? '_(not reported by apk; may be built in)_' : v]);
}
out += table(dep_rows, 'apk not available on this system');
/* 3. Core capabilities */
out += '\n## 3. 内核与能力\n\n';
out += block('/usr/bin/sing-box version 2>/dev/null', 'sing-box not installed');
out += block('lsmod 2>/dev/null | grep -E "tun|nft_tproxy|inet_diag"', 'lsmod unavailable');
out += block('command -v nft fw4 utpl ucode uci 2>/dev/null', 'PATH lookup unavailable');
/* 4. Configuration */
out += '\n## 4. 配置摘要(脱敏)\n\n';
out += configSummary(uci);
/* 5. Resources */
out += '\n## 5. 资源版本\n\n';
out += resourceVersions();
/* 6. Service */
out += '\n## 6. 服务状态\n\n';
out += block('/etc/init.d/homeproxy-pro info 2>&1', 'init script unavailable');
out += block('ubus call service list \'"name":"homeproxy-pro"\' 2>/dev/null',
'ubus unavailable');
/* 7. Routing - `ip rule` first, because the mark that routes a
* connection into the proxy is set there, and a rule that is missing
* explains a proxy that binds its ports and then times out. */
out += '\n## 7. 路由策略\n\n';
out += '### ip rule\n\n';
out += block('ip rule list');
out += '### ip route\n\n';
out += block('ip -4 route list');
out += block('ip -6 route list');
out += sprintf('\n**策略路由表**(table_mark / tproxy_mark / tun_mark):\n\n```shell\n# ip route list table 100\n%s\n```\n',
trim(runCommand('ip route list table 100 2>/dev/null') || '(empty or unavailable)'));
/* 8. Firewall - the full fw4 table, not just homeproxy-pro's own chains.
* The failure this covers is "homeproxy-pro's rules are present and
* correct but something in fw4 jumps to another chain first", and
* that is only visible with the surrounding rules in view. */
out += '\n## 8. 防火墙\n\n';
out += '_The full `inet fw4` table. homeproxy-pro\'s chains are the ones named '
+ '`homeproxy_*`; everything else is fw4\'s own._\n\n';
out += block('nft -a list table inet fw4 2>/dev/null', 'nft unavailable (is firewall4 installed?)');
/* 9. Log excerpts - the tail of each log, not the whole file: the
* interesting part of a failure is the last thing that happened. */
out += '\n## 9. 日志尾部\n\n';
for (let l in ['homeproxy-pro', 'sing-box-c', 'sing-box-s']) {
out += sprintf('### %s.log\n\n', l);
out += block(sprintf('tail -n 60 %s/%s.log 2>/dev/null', RUN_DIR, l),
sprintf('%s.log not present', l));
}
/* 10. Full redacted configuration */
out += '\n## 10. 完整配置(脱敏)\n\n';
out += fullConfig(uci);
return out;
};
@@ -73,7 +73,7 @@ hp_dnsmasq_has_nftset() {
[ "$HP_DNSMASQ_NFTSET" = "1" ]
}
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6> [run-dir]
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
# restarts nothing: the caller decides whether the result differs from what is
# already installed.
@@ -89,6 +89,7 @@ hp_dnsmasq_render_snippets() {
local routing_mode="$3"
local dns_port="$4"
local ipv6="$5"
local run_dir="${6:-/var/run/homeproxy-pro}"
local gfw_nftset_v6="" wan_nftset_v6=""
local nftset_ok=1
@@ -154,6 +155,33 @@ hp_dnsmasq_render_snippets() {
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
fi
fi
# Node server addresses, host-name form.
#
# This snippet carries no `server=`: the routing mode's own snippet above
# already decides which upstream answers, and in every proxied mode that is
# sing-box's dns-in. The generator resolves the node through the WAN
# resolver either way (route.default_domain_resolver), so the address that
# comes back is the same one sing-box will dial - which is the point. Only
# the `nftset=` is added here, and dnsmasq back-fills it from the answer it
# relays, forwarded or not.
#
# Without it, a LAN client opening a connection to the node's own address
# was redirected into sing-box, which dialled that same address through the
# tunnel to that same node. sing-box's own outbound is already exempt
# (routing_mark / self_mark); this covers the LAN, which is not.
#
# The literal-address form cannot come from here - there is no name to
# resolve - so firewall_post.ut renders it into the same set directly.
if [ "$nftset_ok" = "1" ] && [ -s "$run_dir/node-addr-domains.txt" ]; then
local node_nftset_v6=""
[ "$ipv6" -eq "0" ] || node_nftset_v6=",6#inet#fw4#homeproxy_node_addr_v6"
sed -r -e '/^\s*$/d' \
-e "s/(.*)/nftset=\/.\1\/4#inet#fw4#homeproxy_node_addr_v4$node_nftset_v6/g" \
"$run_dir/node-addr-domains.txt" > "$stage/node_addr.conf" || return 1
elif [ -s "$run_dir/node-addr-domains.txt" ]; then
log "Warning: this dnsmasq has no nftset support; LAN connections to the node's address are not excluded from the proxy."
fi
}
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
@@ -179,7 +207,7 @@ hp_dnsmasq_dir_differs() {
return 1
}
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode> [run-dir]
# Write the include file plus the mode-specific snippet, then restart
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
#
@@ -192,6 +220,7 @@ hp_dnsmasq_write_snippets() {
local dnsmasq_dir="$1"
local hp_dir="$2"
local routing_mode="$3"
local run_dir="${4:-/var/run/homeproxy-pro}"
local ipv6_support dns_port
local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf"
local stage changed=0
@@ -208,7 +237,7 @@ hp_dnsmasq_write_snippets() {
# installed snippet set untouched: the staged directory is discarded, so
# the caller keeps the snippets that are already in place instead of
# installing a truncated one.
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support" "$run_dir"; then
rm -rf "$stage"
return 1
fi
@@ -0,0 +1,157 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* runtime/domain_ruleset.uc <source .txt> <destination .json>
*
* The domain-list counterpart of china_ip_ruleset.uc: turn a bare host-name
* list into a sing-box source rule-set of `domain_suffix` rules, so the DNS
* side of the China split reads a local file instead of downloading
* geosite-geolocation-cn.srs from GitHub on a schedule.
*
* Why this exists, in the terms that decided it: the two consumers of the
* China split - the nft mainland set and the sing-box rule-set - were reading
* two different files. The kernel's came from china_ip4.txt, the resolver's
* from a remote .srs, and the remote copy is what a cold start has to wait for
* (through the node, when download_detour says so) before the inbounds bind.
* With the local copy there is nothing to wait for and nothing to keep in step.
*
* The entry is a suffix, not an exact domain: china_list.txt holds registrable
* domains ("163.com", "taobao.com"), and the DNS rule has to match every name
* under them, which is what `domain_suffix` means to sing-box. `domain` would
* match only the name itself and quietly stop matching www.<domain> for the
* entire list.
*
* One rule holding the whole list, for the same reason china_ip_ruleset.uc
* gives: sing-box matches rule-sets by walking a prefix-compressed structure,
* and splitting the list would only add rules to walk per query.
*
* Entries are validated here rather than trusted. The strict parser on the
* other side is the authority for what a valid name is, and one bad entry
* makes it reject the *whole* rule-set - which would take the client's start
* with it. A malformed line is skipped and reported, exactly like the blank
* lines and comments a list may carry.
*/
'use strict';
import { readfile, writefile, rename, unlink } from 'fs';
if (length(ARGV) < 2 || ARGV[0] == null || ARGV[1] == null) {
warn(sprintf('usage: domain_ruleset.uc <source .txt> <destination .json>\n'));
exit(1);
}
const src = ARGV[0];
const dst = ARGV[1];
const data = readfile(src);
if (type(data) !== 'string') {
warn(sprintf('cannot read %s\n', src));
exit(1);
}
const suffixes = [];
let skipped = 0;
/* A structural hostname check, in process.
*
* validation() from homeproxy-pro.uc is the obvious tool and the wrong one here:
* it shells out to /sbin/validate_data once per call, and this list is 111k
* lines. That is 111k process spawns. china_ip_ruleset.uc makes the same call
* for the same reason - its is_v4/is_v6 are hand-written rather than delegated.
*
* Deliberately structural, not exhaustive: the goal is to keep a line that
* cannot be a domain out of the file, since one bad entry makes sing-box reject
* the whole rule-set and take the client's start with it. The strict parser on
* the other side stays the authority for what is really valid. */
function is_hostname(entry) {
if (length(entry) < 1 || length(entry) > 253)
return false;
/* Anything outside this set is a path, a URL, a port, an email, or a
* comment marker that survived the updater's filter. */
if (!match(entry, /^[A-Za-z0-9.-]+$/))
return false;
/* A leading dot, a doubled dot, or a trailing dot all mean the list is
* not in the bare form this emits; sing-box treats them differently from
* what the DNS rule expects to match. */
if (substr(entry, 0, 1) == '.' || substr(entry, -1, 1) == '.')
return false;
if (match(entry, /\.\./))
return false;
for (let label in split(entry, '.')) {
if (length(label) < 1 || length(label) > 63)
return false;
/* A label may not start or end with a hyphen (RFC 1035). */
if (substr(label, 0, 1) == '-' || substr(label, -1, 1) == '-')
return false;
}
/* An all-numeric set of labels is a bare IPv4 address, not a domain:
* the structure above accepts "192.168.1.1" quite happily, and a
* domain_suffix rule holding an address never matches a query. No TLD
* is all digits, so this cannot cost a real name. IPv6 never reaches
* here at all - it carries colons and was rejected above. */
let all_numeric = true;
for (let label in split(entry, '.')) {
if (!match(label, /^[0-9]+$/)) {
all_numeric = false;
break;
}
}
if (all_numeric)
return false;
return true;
}
for (let line in split(data, /\n/)) {
const entry = trim(line);
if (entry == '')
continue;
if (match(entry, /^[#;]/))
continue;
/* The updater already strips `full:` prefixes and anything with a colon
* (update_resources.sh's china_list case), so what arrives here is a bare
* name. Anything that is not still a hostname is dropped rather than
* passed on. */
if (!is_hostname(entry)) {
skipped++;
continue;
}
push(suffixes, entry);
}
if (skipped > 0)
warn(sprintf('skipped %d malformed entries in %s\n', skipped, src));
/* An empty rule-set is worse than no rule-set: it matches nothing, so every
* name the list should have covered falls through to whatever the DNS side
* defaults to. Refuse rather than install one - the previous file stays. */
if (length(suffixes) == 0) {
warn(sprintf('no usable domain entries in %s\n', src));
exit(1);
}
/* The temporary name is a sibling so the rename stays on one filesystem: the
* watcher must never read a half-written file. `%.J` is the same serializer
* the config generator uses, so the output is the canonical sing-box form. */
const rendered = sprintf('%.J\n', {
version: 3,
rules: [
{ domain_suffix: suffixes }
]
});
const tmp = dst + '.tmp';
if (!writefile(tmp, rendered)) {
warn(sprintf('cannot write %s\n', tmp));
exit(1);
}
if (!rename(tmp, dst)) {
unlink(tmp);
warn(sprintf('cannot install %s\n', dst));
exit(1);
}
printf('%d domain suffixes\n', length(suffixes));
@@ -87,8 +87,8 @@ hp_require_ucode() {
return 1
}
# hp_crontab_drop <crontab>
# Remove the auto-update entry from <crontab>.
# hp_crontab_drop <crontab> [marker]
# Remove the cron entries tagged <marker> from <crontab>.
#
# Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with
# BSD sed it fails ("invalid command code"), which silently left the stale entry
@@ -96,11 +96,16 @@ hp_require_ucode() {
# behaves identically on busybox, GNU and BSD sed, and it is what lets this
# module be exercised off-target at all
# (tests/runtime/test_runtime_extraction.sh stages and drives it).
#
# The marker is a parameter because there are two independent entries now: the
# resource lists and the subscriptions. A shared marker would let dropping one
# take the other with it.
hp_crontab_drop() {
local crontab="$1"
local marker="${2:-${CONF}_autosetup}"
local tmp="${crontab}.hp-new"
sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
sed "/#${marker}/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
# mv replaces the *file*, not its contents: the temporary was created by the
@@ -115,6 +120,36 @@ hp_crontab_drop() {
return 0
}
# hp_sync_resource_cron <hour>
# Install the resource-list update cron entry. Unconditional.
#
# The resource lists were previously refreshed by the same cron line as the
# subscription update, behind the *subscription's* auto_update switch. Two
# unrelated things behind one switch, and the consequence was silent: a router
# with subscription auto-update off kept a months-old china_ip4.txt, and with it
# a months-old `homeproxy_mainland_addr_v4` nft set - the firewall was deciding
# "mainland or not" from a list nobody was maintaining. The subscription switch
# says nothing about the user wanting stale routing data, so the two are now
# scheduled independently.
#
# The hour is a constant rather than a setting. This is a maintenance job with
# nothing to configure, and adding an option for it is exactly the kind of
# switch that ends up defaulted and never touched. Two hours earlier than the
# subscription default (2) so the two runs of uclient-fetch do not collide.
#
# The entry runs update_resources_cron.sh, which reloads the service only when a
# list actually changed - so a day where the upstream lists did not move costs
# one round of requests and no interruption.
HP_RESOURCE_CRON_HOUR=3
hp_sync_resource_cron() {
hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \
|| log "Warning: failed to drop the previous resource-update cron entry."
printf '0 %s * * * %s/scripts/update_resources_cron.sh #%s_resource_cron\n' \
"$HP_RESOURCE_CRON_HOUR" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|| log "Warning: failed to install the resource-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
}
# hp_sync_autoupdate_cron <enabled> <hour>
# Install (or drop) the subscription auto-update cron entry. <enabled> is a
# config_get_bool result; the entry is only touched when it is "1".
@@ -140,7 +175,7 @@ hp_sync_autoupdate_cron() {
return 1
fi
hp_crontab_drop "/etc/crontabs/root" \
hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \
|| log "Warning: failed to drop the previous auto-update cron entry."
printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \
"$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
@@ -149,10 +184,16 @@ hp_sync_autoupdate_cron() {
}
# hp_clear_autoupdate_cron
# Drop the auto-update cron entry and restart cron. Used by stop_service.
# Drop both cron entries and restart cron. Used by stop_service.
#
# Both markers: stop_service has to leave no homeproxy-pro cron entry behind, and
# the resource entry is installed unconditionally, so dropping only the
# subscription one would leak a daily job on every stop.
hp_clear_autoupdate_cron() {
hp_crontab_drop "/etc/crontabs/root" \
|| log "Warning: failed to drop the auto-update cron entry."
hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \
|| log "Warning: failed to drop the previous auto-update cron entry."
hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \
|| log "Warning: failed to drop the previous resource-update cron entry."
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
}
@@ -251,6 +292,29 @@ 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.
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then
if ucode -S "$hp_dir/scripts/runtime/domain_ruleset.uc" \
"$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json" >>"$LOG_PATH" 2>&1; then
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
# cache_file is enabled for bypass_mainland_china and custom alike
# (generator/common.uc:attachExperimental). The file has to exist before
# the client jail binds it, or sing-box cannot create it in there.
@@ -1,12 +1,14 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2023 ImmortalWrt.org
# Subscription auto-update, run from the crontab entry that
# runtime/service.sh installs when subscription.auto_update is on.
#
# The resource lists used to be refreshed from this same script. They are not
# any more: they have their own entry (update_resources_cron.sh), because a
# subscription switch had been deciding whether the firewall's mainland address
# set was still current. See hp_sync_resource_cron() in runtime/service.sh.
SCRIPTS_DIR="/etc/homeproxy-pro/scripts"
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
done
"$SCRIPTS_DIR"/update_subscriptions.uc
@@ -236,6 +236,23 @@ check_list_update() {
log "[$(to_upper "$listtype")] Warning: could not regenerate $listtype.json (list has no usable CIDR entry?); the route side keeps the previous list."
fi
;;
"china_list")
# The DNS half of the same split. It reads this file rather than
# a remote geosite-geolocation-cn.srs for the same reasons
# china_ip4.json exists: one list with two readers, and nothing for
# a cold start to download before the inbounds bind.
#
# The sed above rewrote the file in place, so this runs after it
# and sees the cleaned list. `full:` prefixes and anything with a
# colon are gone by now; what is left is what the helper accepts.
if ucode -S "$SCRIPT_DIR/runtime/domain_ruleset.uc" \
"$RESOURCES_DIR/$listtype.txt" "$RESOURCES_DIR/china-domain.json" >>"$LOG_PATH" 2>&1; then
log "[CHINA_LIST] DNS-side rule-set regenerated."
chown sing-box:sing-box "$RESOURCES_DIR/china-domain.json" 2>"/dev/null"
else
log "[CHINA_LIST] Warning: could not regenerate china-domain.json (list has no usable domain entry?); the DNS side keeps the previous list."
fi
;;
esac
else
rm -f "$RUN_DIR/$listname"
@@ -0,0 +1,47 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Refresh the four resource lists (china_ip4 / china_ip6 / gfw_list /
# china_list) and reload the service only when one of them actually moved.
#
# Scheduled unconditionally - see hp_sync_resource_cron() in runtime/service.sh
# for why this is not behind the subscription auto_update switch.
#
# Why a reload is needed at all, when the sing-box side reloads itself: the
# lists feed two consumers. update_resources.sh regenerates the sing-box
# rule-sets from them, and sing-box watches those files and picks a new one up
# in place. The firewall reads the same lists directly when firewall_post.ut is
# rendered into the nft sets, and that only happens on an fw4 reload - so
# without this the kernel kept routing from the previous list while sing-box
# had already moved to the new one, and the two halves of the same split
# disagreed. That window is invisible: mainland traffic still works, it just
# stops being mainland for whatever the new entries added.
#
# update_resources.sh exits 3 when a list is already current, so a day with no
# upstream movement costs one round of requests and no interruption.
SCRIPTS_DIR="/etc/homeproxy-pro/scripts"
NAME="homeproxy-pro"
RUN_DIR="/var/run/$NAME"
LOG_PATH="$RUN_DIR/$NAME.log"
mkdir -p "$RUN_DIR" 2>/dev/null
log() {
printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH"
}
changed=0
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
"$SCRIPTS_DIR"/update_resources.sh "$i"
case "$?" in
0) changed=1 ;;
esac
done
[ "$changed" = "1" ] || exit 0
log "A resource list changed; reloading $NAME so the nft sets catch up."
if ! /etc/init.d/$NAME reload >>"$LOG_PATH" 2>&1; then
log "Warning: the $NAME reload after a resource update failed; the firewall still uses the previous list."
fi
@@ -157,7 +157,7 @@ hp_rearm_intercept() {
[ -f "$RUN_DIR/intercept-released" ] || return 0
rm -f "$RUN_DIR/intercept-released"
log "Client is up again; reinstalling the intercept layer."
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2"
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2" "$RUN_DIR"
hp_net_setup "$1" "$2"
# hp_firewall_apply already writes $RUN_DIR/failed-firewall on its own
# failure, but the helper's caller is service_started, which only reads
@@ -265,6 +265,11 @@ start_service() {
config_get_bool auto_update "subscription" "auto_update" "0"
config_get auto_update_time "subscription" "auto_update_time" "2"
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
# Unconditional, and independent of the switch above: the resource
# lists decide the firewall's mainland set, and that going stale is
# not something "update my subscriptions" should have been able to
# cause.
hp_sync_resource_cron
# The DNS snippets are the other half of the intercept layer: without
# them the LAN resolves through the ISP while the nft rules still send
@@ -273,7 +278,7 @@ start_service() {
# leaving an empty snippet behind; record it the same way a firewall
# failure is recorded, so the configuration is not promoted to
# known-good and the log names the real problem.
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode" "$RUN_DIR"; then
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
fi