🎈 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

@@ -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);
}