Files

404 lines
16 KiB
Ucode

/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/route.uc: build the sing-box `route` block.
*
* The route block has the same routing-mode split as the DNS block:
*
* proxy mode - dns-in hijack, sniff, the bypass_mainland_china
* resolve + geoip-cn split, dedicated UDP route,
* main_override route-options, main-out final,
* plus the static inline + remote rule_sets
* (direct-domain / proxy-domain / geoip-cn /
* geosite-cn).
* custom mode - the user-defined routing_node -> outbound graph,
* routing_rule iteration with the 1.14 action-specific
* fields, find_neighbor, the optional resolve prefix,
* final_outbound resolution, final_override
* route-options, and the rule_set user-defined loop
* (the loop itself is in ruleset.uc).
*
* The orchestrator owns config.route.rule_set: route.uc may push static
* entries (proxy mode), ruleset.uc may push user entries (custom mode),
* and build_http_clients() runs at the end to normalise the legacy
* download_detour into 1.14's http_clients / http_client pairing.
*/
'use strict';
import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc';
import { declaresBuiltinRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
/* --- shared initial block (every routing mode) ------------------------- */
/* Initialise config.route with the always-on rules and the defaults
* that every mode shares (dns-in hijack, sniff, optional auto_detect).
* The caller must have created an empty config.route; we mutate it.
*
* Two sniff profiles, and the default is deliberately the conservative one:
* 300ms with no sniffer list, so sing-box applies its own default set. The
* alternative - 100ms plus an explicit
* ['http','tls','stun','quic','dns'] - is cheaper on CPU but changes what a
* connection is sniffed as, which is a behaviour change, not a tuning knob.
* sniffer_advanced_mode is the opt-in that flips to it, so an upgrade from an
* earlier release is invisible. Guard 39 pins the default at '0'. */
function initRoute(config, ctx) {
const sniff_rule = (ctx.sniffer_advanced_mode === '1')
? {
action: 'sniff',
sniffer: ['http', 'tls', 'stun', 'quic', 'dns'],
timeout: '100ms'
}
: {
action: 'sniff',
timeout: '300ms'
};
config.route = {
rules: [
{
inbound: 'dns-in',
action: 'hijack-dns'
},
sniff_rule
],
rule_set: [],
/* auto_detect_interface: explicit true when the user did not
* pin a default_interface, explicit false when they did - not
* null (the previous "null means no field" shape silently kept
* the auto-detect on when the user expected it off). */
auto_detect_interface: isEmpty(ctx.default_interface) ? true : false,
default_interface: ctx.default_interface
};
}
/* --- proxy mode route rules + rule_set --------------------------------- */
function build_route_proxy(config, dm, ctx, direct_overrides) {
/* Resolve outbound server domains through the WAN default resolver.
Do not use china-dns here: china-dns is for resolving mainland China
destinations, not the proxy node itself. Coupling node bootstrap to
china_dns_server can break dialing when that resolver is polluted or
unsuitable for the node domain. */
config.route.default_domain_resolver = {
server: 'default-dns',
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
};
/* Direct list */
if (length(ctx.direct_domain_list))
push(config.route.rules, {
rule_set: 'direct-domain',
action: 'route',
outbound: 'direct-out'
});
/* Split by destination address, in both directions.
*
* 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):
*
* bypass/global/gfwlist mainland -> direct, the rest falls through to
* main-out (proxy)
* proxy_mainland_china mainland -> proxy, the rest falls through to
* direct-out
*
* 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.china_ip4_ready
&& (ctx.routing_mode === 'bypass_mainland_china'
|| ctx.routing_mode === 'proxy_mainland_china')) {
push(config.route.rules, {
action: 'resolve',
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
});
push(config.route.rules, {
rule_set: 'china-ip',
action: 'route',
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
});
/* 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) {
push(config.route.rules, {
rule_set: 'china-ip6',
action: 'route',
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
});
}
}
/* Main UDP out */
if (ctx.dedicated_udp_node) {
const udp_override = direct_overrides[ctx.main_udp_node] || null;
push(config.route.rules, {
network: 'udp',
action: 'route',
outbound: 'main-udp-out',
override_address: udp_override ? udp_override.override_address : null,
override_port: udp_override ? udp_override.override_port : null
});
}
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const main_override = direct_overrides[ctx.main_node] || null;
if (main_override)
push(config.route.rules, {
action: 'route-options',
override_address: main_override.override_address,
override_port: main_override.override_port
});
/* The route half of the mode's default policy - the other consumer of
* ctx.proxy_fallback, and the reason dns.final and this line are
* asserted together per mode. */
/* TUN mode carries no per-outbound `routing_mark` (self_mark is null there,
* see context.uc), so sing-box's own dials - including the one to the node -
* were unmarked and the firewall's TUN output chain captured them; the
* node-address bypass existed for tproxy only. `default_mark` stamps every
* outbound socket with the same value the firewall's TUN chain exempts. */
if (match(ctx.proxy_mode, /tun/))
config.route.default_mark = strToInt(ctx.tun_self_mark);
config.route.final = ctx.proxy_fallback ? 'main-out' : 'direct-out';
/* --- proxy-mode rule_set block ----------------------------------- */
/* Direct list */
if (length(ctx.direct_domain_list))
push(config.route.rule_set, {
type: 'inline',
tag: 'direct-domain',
rules: [
{
domain_keyword: ctx.direct_domain_list,
}
]
});
/* Proxy list */
if (length(ctx.proxy_domain_list))
push(config.route.rule_set, {
type: 'inline',
tag: 'proxy-domain',
rules: [
{
domain_keyword: ctx.proxy_domain_list,
}
]
});
/* 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)) {
/* Declared only when the file is actually there - the same rule the v6
* and domain halves already follow, and the reason a fresh install no
* longer dies: a `path` that does not exist fails `sing-box check` and
* takes the whole configuration (and the service) with it. The
* rule-set is produced before generation now, so this is the
* belt-and-braces case (a failed generation, a read-only fs). */
if (ctx.china_ip4_ready) {
push(config.route.rule_set, {
type: 'local',
tag: 'china-ip',
path: HP_DIR + '/resources/china_ip4.json'
});
}
/* Declared only when the file is actually there: a rule_set pointing
* at a missing file takes the whole config down with it, and a router
* 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, {
type: 'local',
tag: 'china-ip6',
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))
config.route.rule_set = null;
}
/* --- custom-mode route rules ------------------------------------------- */
function build_route_custom(config, dm, ctx, direct_overrides) {
config.route.default_domain_resolver = {
server: get_resolver(ctx.default_outbound_dns, dm)
};
if (ctx.find_neighbor === '1')
config.route.find_neighbor = true;
if (ctx.domain_strategy)
push(config.route.rules, {
action: 'resolve',
strategy: ctx.domain_strategy
});
for (let cfg in dm.routing.rules) {
if (!cfg.enabled)
continue;
const rule_outbound = get_outbound(cfg.outbound, dm);
const rule_direct_override = get_direct_override(cfg.outbound, dm, direct_overrides);
let rule_override_address = cfg.override_address,
rule_override_port = strToInt(cfg.override_port);
if (isEmpty(rule_override_address) && isEmpty(rule_override_port) && rule_direct_override) {
rule_override_address = rule_direct_override.override_address;
rule_override_port = rule_direct_override.override_port;
}
/* sing-box routing rule: emit match fields plus action-specific fields.
client is the sniffed client type (only set with protocol quic/ssh);
resolve adds server/strategy/disable_cache/rewrite_ttl/client_subnet;
reject adds method/no_drop. Emitted conditionally so a given action
never carries fields sing-box rejects for it. */
const rule = {
ip_version: strToInt(cfg.ip_version),
protocol: cfg.protocol,
network: cfg.network,
client: cfg.client,
domain: cfg.domain,
domain_suffix: cfg.domain_suffix,
domain_keyword: cfg.domain_keyword,
domain_regex: cfg.domain_regex,
source_ip_cidr: cfg.source_ip_cidr,
source_ip_is_private: strToBool(cfg.source_ip_is_private),
ip_cidr: cfg.ip_cidr,
ip_is_private: strToBool(cfg.ip_is_private),
source_port: parse_port(cfg.source_port),
source_port_range: cfg.source_port_range,
port: parse_port(cfg.port),
port_range: cfg.port_range,
process_name: cfg.process_name,
process_path: cfg.process_path,
process_path_regex: cfg.process_path_regex,
user: cfg.user,
rule_set: get_ruleset(cfg.rule_set, dm),
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
invert: strToBool(cfg.invert),
action: cfg.action,
source_mac_address: cfg.source_mac_address,
source_hostname: cfg.source_hostname
};
/* `route-options` only takes the override_* fields; `outbound` is the
* `route` action's field. The two used to share one branch and
* `route-options` ended up carrying an `outbound` sing-box then
* refused as "unknown field" - the configuration as a whole was
* rejected and the user only saw "setting did not take".
* Match fields above are valid for every action, so the gates below
* cover the action-specific payload only. */
if (cfg.action === 'route' || cfg.action === 'route-options') {
rule.override_address = rule_override_address;
rule.override_port = rule_override_port;
rule.udp_disable_domain_unmapping = strToBool(cfg.udp_disable_domain_unmapping);
rule.udp_connect = strToBool(cfg.udp_connect);
rule.udp_timeout = strToTime(cfg.udp_timeout);
rule.tls_fragment = strToBool(cfg.tls_fragment);
rule.tls_fragment_fallback_delay = strToTime(cfg.tls_fragment_fallback_delay);
rule.tls_record_fragment = strToBool(cfg.tls_record_fragment);
rule.tls_spoof = cfg.tls_spoof || null;
rule.tls_spoof_method = cfg.tls_spoof_method || null;
}
/* `route` alone is the action that picks a destination - `route-options`
* adjusts the existing connection, so it does not take `outbound`. */
if (cfg.action === 'route')
rule.outbound = rule_outbound;
if (cfg.action === 'resolve') {
rule.server = get_resolver(cfg.resolve_server, dm);
rule.strategy = cfg.resolve_strategy;
rule.disable_cache = strToBool(cfg.resolve_disable_cache);
rule.disable_optimistic_cache = strToBool(cfg.resolve_disable_optimistic_cache);
rule.rewrite_ttl = strToInt(cfg.resolve_rewrite_ttl);
rule.timeout = strToTime(cfg.resolve_timeout);
rule.client_subnet = cfg.resolve_client_subnet;
}
if (cfg.action === 'reject') {
rule.method = cfg.reject_method;
rule.no_drop = strToBool(cfg.reject_no_drop);
}
push(config.route.rules, rule);
}
/* Direct-node destination override, emitted as route-options action
(direct outbound options removed since sing-box 1.13) */
const final_override = get_direct_override(ctx.default_outbound, dm, direct_overrides);
if (final_override)
push(config.route.rules, {
action: 'route-options',
override_address: final_override.override_address,
override_port: final_override.override_port
});
config.route.final = get_outbound(ctx.default_outbound, dm);
}
/* --- public entry ------------------------------------------------------ */
export function build_route(config, dm, ctx, direct_overrides) {
initRoute(config, ctx);
if (!isEmpty(ctx.main_node))
build_route_proxy(config, dm, ctx, direct_overrides);
else if (!isEmpty(ctx.default_outbound))
build_route_custom(config, dm, ctx, direct_overrides);
return config;
};