⛅ Sync 2026-09-29 21:45:00

This commit is contained in:
github-actions[bot] committed 2026-09-29 21:45:00 +08:00
1 parent a4d6e3e4b9
commit da77daf88e
191 files changed
+8077 -6247

No files matched your search

@@ -0,0 +1,175 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/client.uc: client-side orchestrator.
*
* This file is the only place the client generator still knows the
* overall shape. Each section builder lives in its own module under
* generator/; this file glues them together.
*
* generate(dm, env)
* -> build_context(dm, env)
* -> build_dns
* -> build_inbounds
* -> build_outbounds (mutates direct_overrides)
* -> build_route
* -> build_user_rulesets (custom mode only)
* -> build_http_clients
* -> attachSchema, attachExperimental
* -> final config object (caller writes + sing-box check)
*
* Every section builder takes (config, ctx, ...) where `ctx` carries
* the routing-mode- and proxy-mode-derived scalars (main_node,
* dns_server, proxy_mode, ...) plus the mutable accumulators (outbounds,
* endpoints, ...) the builders append to. The orchestrator owns them
* and threads them through, which is what kept the original
* generate_client.uc at 1240 lines: a 1100-line config blob with a
* single closure scope.
*
* The output is the final config object: no `$schema`, no atomic write,
* no `sing-box check`. The CLI shell (`scripts/generate_client.uc`)
* is responsible for those - per the architecture guide, the generator
* must not double as the runner.
*
* It is also responsible for resolving `env` (the WAN DNS via ubus and the
* two resource lists) and passing it in. This module imports no ubus and no
* fs: `generate(dm, env)` is a pure function, so the same arguments always
* produce the same bytes. tests/arch-guard.sh guard 27 keeps it that way.
*/
'use strict';
import { isEmpty, strToInt, RUN_DIR } from '../homeproxy-pro.uc';
import { build_context } from './context.uc';
import { build_dns } from './dns.uc';
import { build_inbounds } from './inbound.uc';
import { build_outbounds } from './outbound.uc';
import { build_route } from './route.uc';
import { build_user_rulesets, build_http_clients } from './ruleset.uc';
import { attachSchema, attachExperimental } from './common.uc';
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box client config object. Pure function: takes the
* HomeProxyConfig from the Loader plus the GenerationContext inputs the CLI
* resolved (`env`), and returns the JSON object the caller writes and
* `sing-box check`s. No UCI, no file I/O, no ubus, no procd. */
export function generate(dm, env) {
const ctx = build_context(dm, env);
const config = {
log: {
disabled: false,
level: ctx.log_level,
output: RUN_DIR + '/sing-box-c.log',
timestamp: true
}
};
if (!isEmpty(ctx.ntp_server))
config.ntp = {
enabled: true,
server: ctx.ntp_server,
detour: 'direct-out',
domain_resolver: 'default-dns',
};
/* `direct_overrides` is the single side effect that survives the
* split: a direct node with override_address / override_port
* records the override here so build_route can emit the
* route-options action later. The orchestrator owns the map and
* passes it to both builders. */
const direct_overrides = {};
build_dns(config, dm, ctx);
build_inbounds(config, ctx);
/* direct_outbounds live on config.outbounds regardless of mode:
* the DNS path always references them, and a custom-mode config
* without any direct-out/block-out would not have a fallback for
* a routing rule that emits a missing tag. Initialised AFTER
* build_dns / build_inbounds so the JSON key order is
* log, ntp, dns, inbounds, outbounds, ... matching the
* pre-refactor generator. The builder appends to the same array
* (no reassignment), which preserves the key order that
* removeBlankAttrs() emits and keeps golden snapshots stable. */
config.outbounds = [
{
type: 'direct',
tag: 'direct-out',
routing_mark: strToInt(ctx.self_mark)
},
{
type: 'block',
tag: 'block-out'
}
];
config.endpoints = [];
build_outbounds(config, dm, ctx, direct_overrides);
build_route(config, dm, ctx, direct_overrides);
/* User-defined rulesets are custom-mode only; passing an empty
* dm.routing.rulesets through build_user_rulesets is a no-op
* because every cfg in it is filtered by !cfg.enabled. */
if (ctx.routing_mode === 'custom')
build_user_rulesets(config.route.rule_set, dm, ctx);
const http_clients = build_http_clients(config.route.rule_set, dm, ctx);
if (length(http_clients))
config.http_clients = http_clients;
/* A direct node used as the main node yields a main-out with no fields of
* its own: generate_outbound() turns its override_address / override_port
* into a route action rather than outbound fields, leaving a bare
* {"type":"direct","tag":"main-out"}. sing-box refuses to detour into
* such an outbound and the service never starts:
*
* FATAL start service: start dns/tcp[main-dns]: detour to an empty
* direct outbound makes no sense
* FATAL start service: initialize rule-set[0]: ... detour to an empty
* direct outbound makes no sense
*
* Dropping the detour is the equivalent behaviour - the query or the
* rule-set download leaves through the host's own network, which is what a
* direct outbound does - and mirrors the `ctx.self_mark ? 'direct-out' :
* null` that build_dns already applies to the other DNS servers. Only
* main-out is touched: direct-out is deliberate and carries a routing
* mark.
*
* This has to run after build_http_clients(): the rule-set downloads
* reach main-out through an http_client, not through a detour of their
* own. */
let bare_main_out = false;
for (let i = 0; i < length(config.outbounds); i++) {
const ob = config.outbounds[i];
/* build_outbounds leaves a null in the array for a node it could not
* emit (a pruned urltest candidate); skip those. */
if (ob && ob.tag === 'main-out' && ob.type === 'direct' && length(keys(ob)) === 2)
bare_main_out = true;
}
if (bare_main_out) {
for (let i = 0; i < length(config.dns.servers); i++) {
const s = config.dns.servers[i];
if (s && s.detour === 'main-out')
delete s.detour;
}
for (let i = 0; i < length(config.http_clients || []); i++) {
const c = config.http_clients[i];
if (c && c.detour === 'main-out')
delete c.detour;
}
}
if (isEmpty(config.route.rule_set))
config.route.rule_set = null;
if (isEmpty(config.endpoints))
config.endpoints = null;
attachExperimental(config, ctx.routing_mode, ctx.dns_store_dns);
attachSchema(config);
return config;
};
@@ -0,0 +1,207 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/common.uc: shared helpers used by every generator module.
*
* Every "select a tag from a UCI-style reference" function lives in this
* file because the same selector logic (a routing_node name, an
* array-of-names, a direct/block/default marker, a ruleset id list) is
* reached by the DNS section, the route section, the outbound/endpoint
* builder and the ruleset section. Duplicating them per-module would
* drift; consolidating them here keeps the convention "one place to
* change a tag shape".
*
* The Loader owns UCI; nothing in here reads UCI directly. The orchestrator
* passes `dm` for the routing_node lookups that need to resolve a name into
* the underlying node id.
*/
'use strict';
import { isEmpty } from '../homeproxy-pro.uc';
import { ConfigQuery } from '../config/model.uc';
/* parse_port() lives in homeproxy-pro.uc. The Adapter layer needs it
* too (EndpointFactory builds the WireGuard endpoint) and an adapter must
* not import from generator/, so the util moved to the module both layers
* already share. Callers import it from '../homeproxy-pro.uc' now. */
/* Resolve a UCI outbound-style reference (string or array of strings)
* into the sing-box tag the generator should emit. The arrays carry
* either 'block-out' / 'direct-out' sentinels or routing_node section
* names; a single string is the same thing with one entry.
*
* `dm` is needed for the routing_node -> node id lookup; we deliberately
* do not read `dm.routing.nodes` until we know we need it (the common
* case is the sentinel). */
export function get_outbound(cfg, dm) {
if (isEmpty(cfg))
return null;
if (type(cfg) === 'array') {
let outbounds = [];
for (let i in cfg)
push(outbounds, get_outbound(i, dm));
return outbounds;
}
switch (cfg) {
case 'block-out':
case 'direct-out':
return cfg;
default:
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
if (!rn || isEmpty(rn.node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else if (rn.node === 'urltest')
return 'cfg-' + cfg + '-out';
else
return 'cfg-' + rn.node + '-out';
}
};
/* Find a loaded section by name, treating `enabled` as a gate. The Loader
* normalises `enabled` on every list section; a section with the field unset
* is enabled (boolean sections default to 0 in UCI, but a dns_server/ruleset
* without the option is a valid, enabled row).
*
* Declared before its callers on purpose: ucode resolves function references
* at call time from what has already been evaluated, so a `function` statement
* placed after get_resolver() fails with "access to undeclared variable". */
function find_enabled(items, name) {
const item = ConfigQuery.find_by_name(items || [], name);
if (!item)
return null;
if (item.enabled === false || item.enabled === '0')
return null;
return item;
};
/* Resolve a UCI resolver reference (a dns_server section name or one of
* the sentinels) into a sing-box resolver tag.
*
* `dm` is required for the same reason get_outbound() needs it: a reference to
* a section that was deleted or disabled produces `cfg-<name>-dns`, which
* nothing defines - sing-box then rejects the whole configuration with
* "dns server not found", the reload silently keeps the previous one, and the
* user only sees that their change did not take. Fail with the name instead. */
export function get_resolver(cfg, dm) {
if (isEmpty(cfg))
return null;
switch (cfg) {
case 'default-dns':
case 'system-dns':
return cfg;
default:
if (dm && !find_enabled((dm.dns || {}).servers, cfg))
die(sprintf("the DNS server '%s' does not exist or is disabled; check the rule that refers to it.", cfg));
return 'cfg-' + cfg + '-dns';
}
};
/* Resolve a UCI ruleset reference list (array of ruleset section names)
* into the corresponding sing-box rule_set tag list. Same existence gate as
* get_resolver(): ruleset.uc skips disabled entries, so a reference to one
* would leave a dangling `cfg-<name>-rule` tag behind. */
export function get_ruleset(cfg, dm) {
if (isEmpty(cfg))
return null;
let rules = [];
for (let i in cfg) {
if (isEmpty(i))
continue;
if (dm && !find_enabled((dm.routing || {}).rulesets, i))
die(sprintf("the rule-set '%s' does not exist or is disabled; check the rule that refers to it.", i));
push(rules, 'cfg-' + i + '-rule');
}
return rules;
};
/* Resolve the direct-node destination override that the route builder
* needs for a `direct` routing_node target. The override is recorded
* earlier by generate_outbound() (see outbound.uc) when a direct node
* carries override_address / override_port; this helper just looks it
* up. Returns null when the target is not a direct node or no override
* is configured. */
export function get_direct_override(outbound_selector, dm, direct_overrides) {
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
return null;
switch (outbound_selector) {
case 'direct-out':
case 'block-out':
return null;
default:
const rn = ConfigQuery.find_by_name(dm.routing.nodes, outbound_selector);
const node = rn && rn.node;
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
}
};
/* True when `tag` (the sing-box outbound tag) belongs to a direct outbound.
* Used by the http_clients builder to drop the detour field on a pure-TUN
* setup: sing-box 1.14 rejects detouring to a direct outbound that has
* nothing to detour through. */
export function isDirectOutboundTag(tag, dm) {
if (isEmpty(tag) || tag === 'block-out')
return false;
if (tag === 'direct-out')
return true;
/* The caller passes the tag the generator emits - get_outbound() builds
* `cfg-<node>-out` - not the routing_node section name, so that wrapper has
* to be undone before the lookup. Without this the two queries below were
* given a name that matches neither table, and the function returned false
* for every routing_node: build_http_clients() then kept a `detour` on a
* rule-set download in pure TUN mode, where sing-box refuses it ("detour to
* an empty direct outbound makes no sense") and rejects the whole
* configuration. */
const unwrapped = match(tag, /^cfg-(.+)-out$/);
const name = unwrapped ? unwrapped[1] : tag;
const rn = ConfigQuery.find_by_name(dm.routing.nodes, name);
const node_name = (rn && rn.node) || name;
const node = ConfigQuery.node_by_id(dm, node_name);
return !!(node && node.type === 'direct');
};
/* Append the sing-box JSON $schema field. Kept here because the value is
* fixed and the two generators used to spell it the same way; if it ever
* changes, both should pick up the new URL automatically. */
export function attachSchema(config) {
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
return config;
};
/* Attach the experimental cache_file block. Every routing mode benefits
* from it: bypass_mainland_china and custom match a lot of DNS, and the
* other modes still resolve the proxy node hostname and resolve any
* domain the user clicks through. cache_file is a no-op on a fresh
* install (no cache to read) and the file is the only piece of state the
* generator writes outside /etc/config/homeproxy-pro. Routing-mode gating
* used to limit it to bypass_mainland_china + custom, which left
* gfwlist / proxy_mainland_china / global with cold-start DNS on every
* reload and made the difference between modes hard to explain.
*
* Note: docs/linux.json 与 pro 的差距分析.md §2.5 once asked for an
* explicit `reverse_mapping: true` here (so a future sing-box change to
* the default would not silently move us off the mapping table). The
* sing-box 1.14.0-r1 we test against rejects that field as
* "json: unknown field" - the field is recognised by newer builds but
* not the floor pinned by arch-guard 31. sing-box's default is already
* `true`, so leaving it implicit matches runtime behaviour and keeps the
* generated config compatible with the test target. Revisit if the
* floor moves past the release where the field was introduced. */
export function attachExperimental(config, routing_mode, dns_store_dns) {
config.experimental = {
cache_file: {
enabled: true,
path: '/etc/homeproxy-pro/cache.db',
store_dns: (dns_store_dns === '1') ? true : null
}
};
return config;
};
@@ -0,0 +1,240 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* GenerationContext: the routing-mode/proxy-mode scalars every section
* builder consumes, built as a *pure* function of the domain model plus a
* small, explicit environment record.
*
* build_context(dm, env) -> ctx
*
* env = {
* wan_dns, the WAN resolver the CLI resolved via ubus
* direct_domain_list, /etc/homeproxy-pro/resources/direct_list.txt
* proxy_domain_list, /etc/homeproxy-pro/resources/proxy_list.txt
* }
*
* Why the split exists: this module used to sit inside generator/client.uc
* and resolve `env` itself - it called `ubus network.interface status` for
* the WAN DNS and `readfile()` for the two resource lists. The generator was
* documented as a pure function while depending on live router state, so
* "generate the same config twice" was not guaranteed. That is what made
* reload's preflight generation and start_service's generation two different
* artifacts in principle: the WAN lease can change between them.
*
* Now the impure part (ubus call, resource read, mode-dependent fallback for
* a router with no lease) belongs to the CLI shells - scripts/
* generate_client.uc and scripts/generate_server.uc - which are the
* process-level boundary and already own the atomic write and
* `sing-box check`. Everything under generator/ is a pure function of its
* arguments, which tests/arch-guard.sh enforces (guard 27: no ubus/fs import
* in generator/).
*
* `env` is allowed to be partial. A missing wan_dns still yields the same
* public fallback the pre-split code used, as a pure function of the routing
* mode - not a probe - so a caller that has no ubus keeps producing a valid
* config instead of failing.
*/
'use strict';
import { isEmpty, strToInt } from '../homeproxy-pro.uc';
/* Build the routing-mode-independent ctx the section builders consume.
* Mode-dependent defaults are filled in here so each builder can stay a
* straight consumer of its fields without recomputing them. */
export function build_context(dm, env) {
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
const proxy_mode = dm.general.proxy_mode || 'redirect_tproxy';
/* Mode-shared scalars */
const ipv6_support = dm.general.ipv6_support || '0';
const log_level = dm.general.log_level || 'warn';
const ntp_server = dm.infra.ntp_server || 'time.apple.com';
const dns_port = dm.infra.dns_port || '5333';
const mixed_port = dm.infra.mixed_port || '5330';
const udp_timeout = (routing_mode === 'custom')
? (dm.routing.settings.udp_timeout)
: dm.infra.udp_timeout;
/* self_mark is intentionally null for proxy_mode 'tun' only:
* the DNS server detour (default-dns / system-dns / china-dns) is
* gated on it, and the old generator kept the same code shape -
* any non-empty string is truthy, so the test fixture's
* self_mark='100' would leak a 'detour: direct-out' onto every
* DNS server when proxy_mode is tun. removeBlankAttrs() then
* strips the null detour, so the generated JSON stays identical
* to the pre-refactor output. */
const self_mark = match(proxy_mode, /redirect/)
? (dm.infra.self_mark || '100') : null;
/* proxy-mode-derived ports / TUN params */
let redirect_port, tproxy_port, tun_name, tun_addr4, tun_addr6,
tun_mtu, tcpip_stack, endpoint_independent_nat;
if (match(proxy_mode, /redirect/))
redirect_port = dm.infra.redirect_port || '5331';
if (match(proxy_mode, /tproxy/))
if ((dm.general.main_udp_node || 'nil') !== 'nil' || routing_mode === 'custom')
tproxy_port = dm.infra.tproxy_port || '5332';
if (match(proxy_mode, /tun/)) {
tun_name = dm.infra.tun_name || 'singtun0';
tun_addr4 = dm.infra.tun_addr4 || '172.19.0.1/30';
tun_addr6 = dm.infra.tun_addr6 || 'fdfe:dcba:9876::1/126';
tun_mtu = dm.infra.tun_mtu || '9000';
tcpip_stack = 'system';
if (routing_mode === 'custom') {
tcpip_stack = dm.routing.settings.tcpip_stack || 'system';
endpoint_independent_nat = dm.routing.settings.endpoint_independent_nat;
}
}
/* The WAN resolver is an input, not a lookup. See the module header for
* the fallback's provenance - it is the same mode-dependent public
* resolver the pre-split generator chose when ubus had nothing to say. */
const wan_dns = !isEmpty(env.wan_dns)
? env.wan_dns
: ((routing_mode in ['proxy_mainland_china', 'global']) ? '8.8.8.8' : '223.5.5.5');
/* dns/routing fields used by both sections */
const dns_optimistic_cache = (dm.dns.settings || {}).optimistic_cache || '0';
const dns_optimistic_timeout = (dm.dns.settings || {}).optimistic_timeout;
const dns_query_timeout = (dm.dns.settings || {}).dns_timeout;
const dns_store_dns = (dm.dns.settings || {}).cache_file_store_dns || '0';
const tun_dns_mode_raw = dm.general.tun_dns_mode,
tun_dns_address = dm.general.tun_dns_address,
udp_mapping_raw = dm.general.udp_mapping,
udp_filtering_raw = dm.general.udp_filtering,
udp_nat_max = strToInt(dm.general.udp_nat_max);
const tun_dns_mode = (tun_dns_mode_raw === 'default') ? '' : tun_dns_mode_raw,
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
/* Routing-mode-dependent defaults */
const ctx = {
routing_mode, proxy_mode, ipv6_support, log_level,
self_mark, ntp_server, dns_port, mixed_port,
redirect_port, tproxy_port,
tun_name, tun_addr4, tun_addr6, tun_mtu,
tcpip_stack, endpoint_independent_nat,
udp_timeout, udp_mapping, udp_filtering, udp_nat_max,
tun_dns_mode, tun_dns_address,
wan_dns, dns_optimistic_cache, dns_optimistic_timeout,
dns_query_timeout, dns_store_dns,
default_interface: (dm.access_control.control || {}).bind_interface,
/* udp/tcp shaping knobs the endpoint builder reads */
endpoint_options: {
udp_mapping, udp_filtering, udp_nat_max
},
/* Routing-mode-specific scalars; the dns/route modules consult
* only the fields their branch needs.
*
* The main-node reference is mode-dependent, and custom routing has
* its own outbound/rule tables (routing.default_outbound plus the
* routing_node / routing_rule / ruleset sections). A residual
* config.main_node must therefore not switch the generator back to
* the preset path: LuCI hides that field in custom mode but does not
* clear it (`rmempty = false`), so a router that was configured in a
* preset mode and then switched to custom keeps the old value in
* UCI - and the pre-refactor code read it only when the mode was not
* custom. Reading it here anyway silently dropped every custom
* routing rule and made route.final 'main-out'. */
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
/* Custom-only scalars. The gate is not cosmetic: route.uc picks its
* branch with `isEmpty(default_outbound)` and outbound.uc does the
* same, while the preset branch keys off main_node. A residual
* default_outbound from an earlier custom configuration therefore
* switched a preset-mode router onto the custom branch with the
* preset DNS builder bailing out (it keys off main_node) - the DNS
* block then ended up with only default-dns/system-dns and no
* main-dns/china-dns, the route block with leftover custom rules -
* and the rule-set tags those rules reference are only built in
* custom mode, so `sing-box check` rejected the whole generation.
* Preset modes read custom-only fields nowhere; gate them all.
*
* The proxy/custom asymmetry the generator then exposes to users:
* proxy mode's route.default_domain_resolver is the literal
* 'default-dns' (route.uc line 78-81), while custom mode's is the
* get_resolver(default_outbound_dns, dm) lookup (route.uc line
* 217-220). The two are deliberately different: proxy mode does
* not surface a user-facing "default outbound DNS" choice (the
* preset path always resolves through the WAN DNS), while custom
* mode lets the user pick. README does not promise otherwise. */
default_outbound: (routing_mode === 'custom') ? (dm.routing.settings || {}).default_outbound : null,
default_outbound_dns: (routing_mode === 'custom')
? ((dm.routing.settings || {}).default_outbound_dns || 'default-dns') : null,
domain_strategy: (routing_mode === 'custom') ? (dm.routing.settings || {}).domain_strategy : null,
find_neighbor: (routing_mode === 'custom') ? (dm.routing.settings || {}).find_neighbor : null,
/* Main-line defaults, mirroring what the pre-refactor
* generate_client.uc did inline: an empty UCI value becomes 'wan'
* for the main DNS server, and 'wan' - the value the UI writes for
* "WAN DNS (read from interface)" - is replaced by the resolver the
* CLI read off the WAN interface (`wan_dns`, which itself falls back
* to a public resolver when ubus has no answer). Leaving the
* literal 'wan' in place makes sing-box resolve a host by that name.
* The China DNS server falls back to the Aliyun public resolver
* instead of leaking into the default-dns pipeline. */
dns_server: (isEmpty(dm.general.dns_server) || dm.general.dns_server === 'wan')
? wan_dns : dm.general.dns_server,
china_dns_server: (dm.general.china_dns_server && dm.general.china_dns_server !== 'wan')
? dm.general.china_dns_server : '223.5.5.5',
/* dns_default_strategy: in proxy mode, ipv4_only is the safe
* default whenever ipv6_support is off; in custom mode the
* UCI default_strategy field is the source of truth (no
* implicit fallback - missing UCI => strategy: null =>
* removeBlankAttrs strips it). */
dns_default_strategy: (routing_mode === 'custom')
? (dm.dns.settings || {}).default_strategy
: ((ipv6_support !== '1') ? 'ipv4_only' : null),
/* cn_ip_fallback: evaluate/match_response fallback for unknown-host
* queries (tutorial §2.1 / docs/linux.json 与 pro 的差距分析.md).
* The v28.9.1.16 flip turns this on by default for fresh installs;
* migrate_config.uc writes '0' explicitly for upgrade-existing users
* so they keep the prior behaviour until they opt in. '0' is truthy
* in JS so the || picks it over '1' when UCI sets it to '0' - a
* user who has cn_ip_fallback='0' written (the upgrade path) gets
* exactly what they had. */
cn_ip_fallback: dm.general.cn_ip_fallback || '1',
/* sniffer_advanced_mode: when '1' (opt-in), the route sniff rule
* gets the universal protocol list + 100ms timeout (tutorial §2.7 /
* docs/linux.json 与 pro 的差距分析.md). Default '0' preserves the
* 300ms / default-list behaviour so an upgrade is invisible. */
sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0',
main_urltest_nodes: dm.general.main_urltest_nodes || [],
main_urltest_interval: dm.general.main_urltest_interval,
main_urltest_tolerance: dm.general.main_urltest_tolerance,
main_udp_urltest_nodes: dm.general.main_udp_urltest_nodes || [],
main_udp_urltest_interval: dm.general.main_udp_urltest_interval,
main_udp_urltest_tolerance: dm.general.main_udp_urltest_tolerance,
/* Custom-mode DNS settings */
dns_default_server: (dm.dns.settings || {}).default_server,
dns_disable_cache: (dm.dns.settings || {}).disable_cache,
dns_disable_cache_expire: (dm.dns.settings || {}).disable_cache_expire,
dns_client_subnet: (dm.dns.settings || {}).client_subnet,
};
/* Inline static rule-sets (direct-domain, proxy-domain) only fire
* when the routing mode has a non-empty domain list. Both lists are
* inputs now: the CLI reads the two files under
* /etc/homeproxy-pro/resources/ and passes them in. Custom mode ignores
* them entirely, exactly as the pre-split read did. */
ctx.direct_domain_list = [];
ctx.proxy_domain_list = [];
if (routing_mode !== 'custom') {
ctx.direct_domain_list = env.direct_domain_list || [];
ctx.proxy_domain_list = env.proxy_domain_list || [];
}
/* `dedicated_udp_node` means main_udp_node was set to a different
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
* qualify). Kept on ctx so both build_outbounds and build_route
* see the same value. */
ctx.dedicated_udp_node = !isEmpty(ctx.main_udp_node) && !(ctx.main_udp_node in ['same', ctx.main_node]);
return ctx;
};
@@ -0,0 +1,482 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/dns.uc: build the sing-box `dns` block.
*
* Two routing-mode families exist:
*
* 1. bypass_mainland_china / proxy_mainland_china / global /
* gfwlist - the proxy path. The orchestrator passes main_node
* (possibly 'urltest'), main_udp_node, china_dns_server,
* direct_domain_list and proxy_domain_list in ctx; the legacy
* NAPTR trick (qtype 35) and the CN-IP fallback (cn_ip_fallback)
* are part of this path.
*
* 2. custom - the user-defined rules path. The orchestrator passes
* default_outbound, default_outbound_dns, the dns settings
* (default_strategy, default_server, disable_cache, ...) and the
* dns_server / dns_rule section lists under dm.dns.
*
* The dns_server / dns_rule iteration is the part that is mode-agnostic
* and lives in this module rather than the orchestrator because every
* field comes straight from UCI and the legacy 1.14 evaluate/match_response
* rewrite is the per-field payload.
*/
'use strict';
import { isEmpty, strToBool, strToInt, strToTime, parseURL, validation, parse_port } from '../homeproxy-pro.uc';
import { get_outbound, get_resolver, get_ruleset } from './common.uc';
/* Build a DNS server spec from a UCI address string. sing-box wants the
* server name, the protocol kind, an optional path, and an integer port;
* the UCI representation is a single string, optionally without a
* scheme, which is why a default_protocol is supplied. */
function parse_dnsserver(server_addr, default_protocol) {
if (isEmpty(server_addr))
return null;
if (!match(server_addr, /:\/\//))
server_addr = (default_protocol || 'udp') + '://' + (validation('ip6addr', server_addr) ? `[${server_addr}]` : server_addr);
/* parseURL() returns null for an address it cannot make sense of (a bad
* host, an unknown scheme). The old code dereferenced the result straight
* away, so one malformed UCI value took the whole generator down with
* "left-hand side expression is null" instead of naming the dns_server. */
server_addr = parseURL(server_addr);
if (!server_addr)
return null;
return {
type: server_addr.protocol,
server: server_addr.hostname,
server_port: strToInt(server_addr.port),
path: (server_addr.pathname !== '/') ? server_addr.pathname : null,
}
}
/* Parse the DNS rule query_type list UCI emits. A single value comes as
* a string, a list as an array of strings; both forms are turned into an
* array of strings or integers (mixing the two is intentional - the type
* can be a name or a numeric code in UCI). */
function parse_dnsquery(strquery) {
if (type(strquery) !== 'array' || isEmpty(strquery))
return null;
let querys = [];
for (let i in strquery)
isnan(int(i)) ? push(querys, i) : push(querys, int(i));
return querys;
}
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
* (china-dns's default backend, 223.5.5.5) returns NAPTR with multi-
* second first responses for these suffixes, which sing-box reports as
* 'context deadline exceeded' (sipgz12.hbq.r.10086.cn being the
* reproducible offender); routing the qtype to default-dns (the ISP
* resolver or the wan_dns fallback) keeps IMS/VoLTE registration fast.
*
* The list is deliberately small: every entry is a suffix match, so
* adding a single host name can broaden the rule by accident. Add new
* suffixes here when a fresh NAPTR offender is observed, not when
* someone reads the comment and thinks "I know a domain that should
* go direct". */
const NAPTR_BYPASS_SUFFIXES = [
'r.10086.cn',
'10086.cn',
'pub.3gppnetwork.org'
];
function append_proxy_dns(config, dm, ctx) {
if (isEmpty(ctx.main_node))
return;
/* Main DNS */
push(config.dns.servers, {
tag: 'main-dns',
domain_resolver: {
server: 'default-dns',
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : null
},
detour: 'main-out',
...parse_dnsserver(ctx.dns_server, 'tcp')
});
config.dns.final = 'main-dns';
if (length(ctx.direct_domain_list))
push(config.dns.rules, {
rule_set: 'direct-domain',
action: 'route',
server: (ctx.routing_mode === 'bypass_mainland_china') ? 'china-dns' : 'default-dns'
});
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
push(config.dns.rules, {
query_type: [64, 65],
action: 'reject'
});
if (ctx.routing_mode === 'bypass_mainland_china') {
push(config.dns.servers, {
tag: 'china-dns',
domain_resolver: {
server: 'default-dns',
/* Mirror default-dns's strategy: china-dns itself is
* overwhelmingly IPv4-only in practice (223.5.5.5 etc.),
* so the field mostly affects the upstream chain; the
* two should still track each other for the same reason
* default-dns follows ipv6_support. */
strategy: (ctx.ipv6_support !== '1') ? 'ipv4_only' : 'prefer_ipv6'
},
detour: ctx.self_mark ? 'direct-out' : null,
...parse_dnsserver(ctx.china_dns_server)
});
/* Route NAPTR (qtype 35) queries for SIP/ENUM domains to the ISP
default-dns directly: see NAPTR_BYPASS_SUFFIXES for the
rationale and the explicit offenders. */
push(config.dns.rules, {
query_type: [35],
domain_suffix: NAPTR_BYPASS_SUFFIXES,
action: 'route',
server: 'default-dns'
});
if (length(ctx.proxy_domain_list))
push(config.dns.rules, {
rule_set: 'proxy-domain',
action: 'route',
server: 'main-dns'
});
push(config.dns.rules, {
rule_set: 'geosite-cn',
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') {
push(config.dns.rules, {
action: 'evaluate',
server: 'main-dns',
tag: 'cn-fallback'
});
push(config.dns.rules, {
match_response: 'cn-fallback',
rule_set: 'geoip-cn',
action: 'route',
server: 'china-dns'
});
}
}
}
/* Known DoH/DoT/DoQ hostnames and the IPs we should pin them to. The
* table is consulted by append_custom_dns() to detect when a user
* configured one of these encrypted upstreams; without the predefined
* entry, the resolver itself has to be resolved through the system DNS,
* and a DNS outage of any kind taken once out leaves the resolver
* unreachable - a chicken-and-egg loop. Mirrors the hosts server in
* linux.json line 10; see docs/linux.json 与 pro 的差距分析.md §2.4.
*
* Additions go here when the user's pool of encrypted resolvers grows;
* a new entry needs no code change beyond this table. */
const KNOWN_ENCRYPTED_DNS_HOSTS = {
'doh.pub': ['1.12.12.21', '120.53.53.53'],
'dns.pub': ['1.12.12.21', '120.53.53.53'],
'dns.alidns.com': ['223.5.5.5', '223.6.6.6'],
'dns.google': ['8.8.8.8', '8.8.4.4'],
'one.one.one.one': ['1.1.1.1', '1.0.0.1']
};
/* --- custom routing-mode path (user-defined dns_server / dns_rule) ----- */
function append_custom_dns(config, dm, ctx) {
/* §2.4 DoH fallback. Pre-emit a hosts-type DNS server pinning any
* encrypted-resolver hostname the user configured to its real IPs. The
* loop also remembers which server sections hit the table, so the user
* server loop below can stamp `domain_resolver: 'hp-dns-hosts'` onto
* them - sing-box only consults the hosts table for resolvers that
* explicitly point at it, mirroring linux.json line 7
* (`{"domain_resolver": "hosts"}`). Without the stamp, the resolver
* still has to look up its own hostname through the system DNS and the
* chicken-and-egg loop we are trying to break stays open. */
let doh_predefined = null;
const doh_resolved_hosts = {};
for (let cfg in (dm.dns.servers || [])) {
if (!cfg.enabled)
continue;
if (cfg.type !== 'https' && cfg.type !== 'tls' && cfg.type !== 'quic')
continue;
const host = cfg.server;
if (!host || !KNOWN_ENCRYPTED_DNS_HOSTS[host])
continue;
if (!doh_predefined)
doh_predefined = {};
doh_predefined[host] = KNOWN_ENCRYPTED_DNS_HOSTS[host];
doh_resolved_hosts[cfg.name] = true;
}
if (doh_predefined) {
push(config.dns.servers, {
tag: 'hp-dns-hosts',
type: 'hosts',
predefined: doh_predefined
});
}
/* DNS servers */
for (let cfg in dm.dns.servers) {
if (!cfg.enabled)
continue;
let outbound = get_outbound(cfg.outbound, dm);
if (outbound === 'direct-out' && isEmpty(ctx.self_mark))
outbound = null;
push(config.dns.servers, {
tag: 'cfg-' + cfg.name + '-dns',
type: cfg.type,
server: cfg.server,
server_port: strToInt(cfg.server_port),
path: cfg.path,
headers: cfg.headers,
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
} : null,
domain_resolver: doh_resolved_hosts[cfg.name]
? 'hp-dns-hosts'
: ((cfg.address_resolver || cfg.address_strategy) ? {
server: get_resolver(cfg.address_resolver || ctx.dns_default_server, dm),
strategy: cfg.address_strategy
} : null),
detour: outbound
});
}
/* DNS rules */
/* sing-box >= 1.14: legacy address-filter rules are auto-wrapped with an
evaluate action; deprecated strategy/accept_empty fields are dropped. */
const builtin_dns_rules = [];
/* §2.2 First safe rule: reject SVCB (qtype 64) and HTTPS (qtype 65)
* queries. A client that learns the answer from these record types
* connects to the embedded IP without going through the resolver again,
* which makes Fake-IP and any route that hinges on A/AAAA resolution
* bypass. Mirrors append_proxy_dns() line 102; emit here as the FIRST
* entry so a user rule that targets the same query_type cannot shadow
* it (sing-box walks the array in order and stops at the first match).
* Without this prefix, a user who adds a higher-priority rule on the
* same query_type in custom mode loses Fake-IP integrity. */
push(builtin_dns_rules, {
query_type: [64, 65],
action: 'reject'
});
for (let cfg in dm.dns.rules) {
if (!cfg.enabled)
continue;
/* Match fields are valid for every action; action-specific fields
* are emitted below per sing-box 1.14's per-action schema. The
* previous code dumped server/method/no_drop/rcode/answer/ns/extra
* (and the cache / TTL / client-subnet family) onto every rule
* regardless of action, and sing-box then rejected the whole
* configuration with "json: unknown field" the moment a user
* changed a rule's action and left the old fields in UCI -
* LuCI's form value parsing does not delete the previous value
* when a depends() guard hides it, the same path route.uc
* already had to fix. */
const rule = {
ip_version: strToInt(cfg.ip_version),
query_type: parse_dnsquery(cfg.query_type),
network: cfg.network,
protocol: cfg.protocol,
domain: cfg.domain,
domain_suffix: cfg.domain_suffix,
domain_keyword: cfg.domain_keyword,
domain_regex: cfg.domain_regex,
port: parse_port(cfg.port),
port_range: cfg.port_range,
source_ip_cidr: cfg.source_ip_cidr,
source_ip_is_private: strToBool(cfg.source_ip_is_private),
source_port: parse_port(cfg.source_port),
source_port_range: cfg.source_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),
race: strToBool(cfg.race),
speculative: strToBool(cfg.speculative),
action: cfg.action
};
/* `route` (and `evaluate`, which needs a server for the eval step).
* `resolve` is also a possibility but is not currently exposed in
* the UI; if it ever is, this branch is where it lands. */
if (cfg.action === 'route' || cfg.action === 'evaluate' || cfg.action === 'resolve') {
rule.server = get_resolver(cfg.server, dm);
rule.disable_cache = strToBool(cfg.dns_disable_cache);
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
rule.client_subnet = cfg.client_subnet;
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
}
if (cfg.action === 'route' || cfg.action === 'resolve')
rule.timeout = strToTime(cfg.dns_timeout);
/* `reject` only accepts method + no_drop. Anything else here is
* an unknown field for the action and the whole config fails. */
if (cfg.action === 'reject') {
rule.method = cfg.reject_method;
rule.no_drop = strToBool(cfg.reject_no_drop);
}
/* `predefined` only accepts rcode + answer + ns + extra. */
if (cfg.action === 'predefined') {
rule.rcode = cfg.predefined_rcode;
rule.answer = cfg.predefined_answer;
rule.ns = cfg.predefined_ns;
rule.extra = cfg.predefined_extra;
}
if (cfg.action === 'evaluate')
rule.tag = cfg.evaluate_tag || null;
if (cfg.match_response && cfg.match_response !== '0')
rule.match_response = (cfg.match_response === '1') ? true : cfg.match_response;
rule.query_client_subnet = cfg.query_client_subnet;
rule.query_dnssec = strToBool(cfg.query_dnssec);
rule.source_mac_address = cfg.source_mac_address;
rule.source_hostname = cfg.source_hostname;
const legacy_filter = !isEmpty(cfg.ip_cidr) || strToBool(cfg.ip_is_private) === true;
/* Every action needs the evaluate prefix to see the response, not just
* `route`: with the old `&& cfg.action === 'route'` an address-filtered
* reject rule lost the filter entirely and became an unconditional
* reject - a DNS black hole for every query. */
if (legacy_filter && !rule.match_response) {
/* Wrap a legacy address-filter rule into the 1.14 evaluate/match_response
paradigm. Carry the original query-matching fields onto the evaluate
prefix rule so only queries that would have hit this rule get
pre-resolved; an unconditional evaluate would resolve every query. */
const eval_tag = '_hp_eval_' + cfg.name;
const eval_rule = {
action: 'evaluate',
server: get_resolver(cfg.server, dm),
tag: eval_tag
};
const eval_match_fields = [
'inbound', 'ip_version', 'query_type', 'network', 'protocol', 'auth_user',
'domain', 'domain_suffix', 'domain_keyword', 'domain_regex',
'port', 'port_range', 'source_ip_cidr', 'source_ip_is_private',
'source_port', 'source_port_range', 'process_name', 'process_path',
'process_path_regex', 'user', 'rule_set', 'rule_set_ip_cidr_match_source',
'invert', 'query_client_subnet', 'query_dnssec', 'source_mac_address',
'source_hostname'
];
for (let f in eval_match_fields)
eval_rule[f] = rule[f];
push(builtin_dns_rules, eval_rule);
rule.match_response = eval_tag;
}
/* Response-match fields are only valid together with match_response in
* 1.14: sing-box rejects the rule with "Response Match Fields (...)
* require match_response to be enabled". ip_cidr / ip_is_private were
* already gated here; the response_* quartet was emitted
* unconditionally next door. */
if (rule.match_response) {
rule.ip_cidr = cfg.ip_cidr;
rule.ip_is_private = strToBool(cfg.ip_is_private);
rule.response_rcode = cfg.response_rcode;
rule.response_answer = cfg.response_answer;
rule.response_ns = cfg.response_ns;
rule.response_extra = cfg.response_extra;
}
push(builtin_dns_rules, rule);
}
config.dns.rules = builtin_dns_rules;
config.dns.final = get_resolver(ctx.dns_default_server, dm);
}
/* --- public entry ------------------------------------------------------ */
/* The always-on DNS block. Every routing mode (proxy and custom) gets
* default-dns (UDP via the WAN resolver) and system-dns (local, used as
* a fallback). The routing-mode-specific builders push additional
* servers/rules on top of this list. Keeping the common prefix here is
* why the proxy-mode and custom-mode paths stay almost mirror images:
* both branches see config.dns already populated. */
function initDns(config, ctx) {
/* sing-box refuses the combination outright:
* FATAL initialize DNS router: `optimistic` is conflict with `disable_cache`
* (reproduced against 1.14.1 on a real config). Both are opt-in switches
* in the UI, so turning both on used to produce a configuration that
* cannot start - the reload fails and rolls back, and the only symptom the
* user sees is "my setting did not take". optimistic wins; the disable_*
* switches are dropped rather than emitted. */
const optimistic = (ctx.dns_optimistic_cache === '1');
config.dns = {
servers: [
{
tag: 'default-dns',
type: 'udp',
server: ctx.wan_dns,
detour: ctx.self_mark ? 'direct-out' : null
},
{
tag: 'system-dns',
type: 'local',
detour: ctx.self_mark ? 'direct-out' : null
}
],
rules: [],
strategy: ctx.dns_default_strategy,
disable_cache: optimistic ? null : strToBool(ctx.dns_disable_cache),
disable_expire: optimistic ? null : strToBool(ctx.dns_disable_cache_expire),
client_subnet: ctx.dns_client_subnet,
optimistic: optimistic ? {
enabled: true,
/* strToTime(), not the raw UCI string: sing-box parses this
* field as a duration and rejects a bare number outright
* ("time: missing unit in duration \"3600\""), so the whole
* generation would fail - and a failed generation keeps the
* previous configuration running, which the user sees as
* "my setting did not take". The UI documents the field as
* "Examples: 3d, 1h" but does not enforce a unit, so both
* forms have to work: strToTime() turns "3600" into "3600s"
* and leaves "3d" alone. dns_query_timeout below already
* does this. */
timeout: !isEmpty(ctx.dns_optimistic_timeout) ? strToTime(ctx.dns_optimistic_timeout) : '3d'
} : null,
timeout: !isEmpty(ctx.dns_query_timeout) ? strToTime(ctx.dns_query_timeout) : null
};
}
/* Build the sing-box `dns` block. Mutates `config` and returns it for
* chaining. Routing mode is determined by ctx.routing_mode: any non-
* 'custom' mode follows the proxy path (the proxy-mode split inside the
* proxy path is just which rules to emit). */
export function build_dns(config, dm, ctx) {
initDns(config, ctx);
if (ctx.routing_mode === 'custom')
append_custom_dns(config, dm, ctx);
else
append_proxy_dns(config, dm, ctx);
return config;
};
@@ -0,0 +1,106 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/inbound.uc: build the sing-box `inbounds` block.
*
* The client always needs the dns-in and mixed-in inbounds, plus
* whichever of redirect-in / tproxy-in / tun-in matches proxy_mode.
* udp_* fields on tproxy-in and tun-in come from the same UCI settings
* (a single source of truth in init.d, but the orchestrator extracts
* them and re-shapes them for sing-box here).
*/
'use strict';
import { isEmpty, strToInt, strToTime, strToBool } from '../homeproxy-pro.uc';
/* Append the always-on inbounds. dns-in is the local listener the DNS chain
* hands queries to - runtime/dns.sh points dnsmasq at 127.0.0.1#<dns_port>,
* so nothing outside this host has any reason to reach it. It used to
* listen on '::', every interface, and a `direct` inbound forwards without
* consulting the route rules: any client that connected to the DNS port got
* its traffic dialled straight out instead of routed. A router log showed
* exactly that - connections to a blocked address timing out through the
* built-in `direct` outbound while every rule pointed at main-out.
* Loopback-only removes the entry point; mixed-in stays on '::' because that
* one is the SOCKS/HTTP listener clients are meant to reach. Their order
* matters because dns-in has to come first when sing-box matches inbounds in
* declaration order. */
function pushBaseInbounds(inbounds, ctx) {
push(inbounds, {
type: 'direct',
tag: 'dns-in',
listen: '127.0.0.1',
listen_port: int(ctx.dns_port)
});
push(inbounds, {
type: 'mixed',
tag: 'mixed-in',
listen: '::',
listen_port: int(ctx.mixed_port),
udp_timeout: strToTime(ctx.udp_timeout),
set_system_proxy: false
});
}
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box `inbounds` block. The caller supplies an empty
* array on `config.inbounds` so this can be a pure builder. */
export function build_inbounds(config, ctx) {
const inbounds = config.inbounds || [];
pushBaseInbounds(inbounds, ctx);
if (match(ctx.proxy_mode, /redirect/))
push(inbounds, {
type: 'redirect',
tag: 'redirect-in',
listen: '::',
listen_port: int(ctx.redirect_port)
});
/* tproxy-in is gated on the port existing, not only on the proxy mode:
* context.uc only assigns tproxy_port when a dedicated UDP node is
* configured (or in custom mode), because that is exactly when
* firewall_post.ut emits the tproxy chain. Emitting the inbound anyway
* bound a UDP socket to listen_port 0 - a random port that no nft rule
* ever points at - so the two layers now share one condition. */
if (match(ctx.proxy_mode, /tproxy/) && !isEmpty(ctx.tproxy_port))
push(inbounds, {
type: 'tproxy',
tag: 'tproxy-in',
listen: '::',
listen_port: int(ctx.tproxy_port),
network: 'udp',
udp_timeout: strToTime(ctx.udp_timeout),
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
udp_nat_max: ctx.udp_nat_max,
});
if (match(ctx.proxy_mode, /tun/))
push(inbounds, {
type: 'tun',
tag: 'tun-in',
interface_name: ctx.tun_name,
address: (ctx.ipv6_support === '1') ? [ctx.tun_addr4, ctx.tun_addr6] : [ctx.tun_addr4],
mtu: strToInt(ctx.tun_mtu),
auto_route: false,
endpoint_independent_nat: strToBool(ctx.endpoint_independent_nat),
udp_timeout: strToTime(ctx.udp_timeout),
dns_mode: !isEmpty(ctx.tun_dns_mode) ? ctx.tun_dns_mode : null,
dns_address: !isEmpty(ctx.tun_dns_address) ? ctx.tun_dns_address : null,
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
udp_nat_max: ctx.udp_nat_max,
stack: ctx.tcpip_stack,
});
config.inbounds = inbounds;
return config;
};
@@ -0,0 +1,323 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/outbound.uc: build the sing-box `outbounds` and `endpoints`
* blocks, plus the urltest pruning the route
* builder depends on.
*
* Two builders live here:
*
* generate_endpoint(node, ctx) -> sing-box endpoint object (WireGuard only
* at the moment; the tag convention is the same as for outbounds).
*
* generate_outbound(node, mark, direct_overrides) -> sing-box outbound
* object. Side effect: a direct node carrying override_address /
* override_port is recorded into the caller-supplied `direct_overrides`
* map so the route builder can later emit the route-options action.
* The Adapter (config/adapter.uc) already handles every protocol's
* payload; this module is just the orchestration and the override side
* effect.
*
* build_outbounds(dm, ctx, direct_overrides) -> { outbounds, endpoints }
* Implements the routing-mode split (proxy vs custom), the urltest
* pruning via keep_candidate / buildable_candidates, and the
* routing_node -> outbound / endpoint resolution.
*
* The `direct_overrides` parameter is a mutable map the orchestrator owns
* and threads through the generators. Making it explicit avoids the
* "module-level side effect" the old generate_client.uc had, which was
* the only thing tying outbound building to route building.
*/
'use strict';
import { isEmpty, strToInt, strToBool, strToTime } from '../homeproxy-pro.uc';
import { ConfigQuery } from '../config/model.uc';
import { OutboundFactory, EndpointFactory } from '../config/adapter.uc';
import { get_outbound, get_resolver } from './common.uc';
/* --- single-endpoint / single-outbound builders ------------------------ */
/* WireGuard goes through the same Node as every other protocol; the
* fields come from node.protocol_options (Loader's wireguard row) and
* the cross-protocol options from node.common.
*
* This used to read the flat UCI keys (node.wireguard_*, and
* node.tcp_fast_open / node.tcp_multi_path / node.udp_fragment) off a
* Node. Those keys only existed on the flat section dict the
* pre-refactor call sites passed; once A2/A3 made every call site pass
* a Node they were simply null, so every WireGuard main / UDP /
* urltest node emitted an endpoint with no private key and a peer with
* no public key. tests/fixtures/generators/wireguard.uci guards it.
*
* The endpoint *shaping* lives in EndpointFactory in
* config/adapter.uc, so the generator no longer holds protocol business
* logic for WireGuard. This stays as the call sites' entry point, which
* keeps the module's endpoint vocabulary without making every caller
* import the adapter directly. */
export function generate_endpoint(node, ctx) {
return EndpointFactory.create(node, ctx);
};
/* Build one outbound from a Node via the Adapter layer. A direct node
* with override_address / override_port also gets recorded into the
* caller's `direct_overrides` map so the route builder can emit the
* route-options action; this is the only side effect of the function
* beyond returning the outbound object. */
export function generate_outbound(node, mark, direct_overrides) {
/* Stage A3+A5: this used to be a 110-line ternary soup that built the
* sing-box outbound by hand for every protocol. It now takes a
* Node (looked up via ConfigQuery.node_by_id) and hands it straight
* to OutboundFactory.create() (the Adapter layer).
*
* The direct-node override table is the one side effect that survives;
* the Adapter layer has no place to record it, and the route builder
* reads it later. */
if (type(node) !== 'object' || isEmpty(node))
return null;
if (node.type === 'direct' && (!isEmpty(node.protocol_options.override_address) || !isEmpty(node.protocol_options.override_port)))
direct_overrides[node.id] = {
override_address: node.protocol_options.override_address,
override_port: strToInt(node.protocol_options.override_port)
};
return OutboundFactory.create(node, mark);
};
/* Keep only the candidate ids the Adapter can actually build.
*
* urltest member lists are the one place a broken node must not be fatal:
* a single bad node in a 200-node subscription used to make the Adapter
* die(), which aborted the whole generator, so the service could not start
* at all. The node stays in UCI (the UI shows it and the next
* subscription update can repair or drop it); it is only left out of the
* urltest group, and the reason is logged.
*
* Nodes that routing rules or DNS actually resolve through are NOT pruned:
* a rule pointing at a missing tag is invalid sing-box config, so those
* stay fatal on purpose. */
function keep_candidate(dm, id) {
const node = ConfigQuery.node_by_id(dm, id);
if (!node) {
warn(sprintf("homeproxy-pro: urltest candidate '%s' no longer exists, skipping.", id));
return false;
}
const problems = OutboundFactory.problems(node);
if (length(problems)) {
warn(sprintf("homeproxy-pro: skipping urltest candidate '%s': %s.", id, join(', ', problems)));
return false;
}
return true;
}
/* Map over a candidate id list, dropping the unbuildable ones. */
function buildable_candidates(dm, ids) {
return filter(ids || [], (id) => keep_candidate(dm, id));
}
/* --- main-outbound building (proxy mode) ------------------------------- */
/* Decide whether a candidate node should be emitted as a sing-box endpoint
* (WireGuard) or as an outbound (everything else). Returns the populated
* object so the caller can retag it. */
function emitNode(node, mark, tag, direct_overrides, ctx) {
if (node && node.type === 'wireguard') {
const endpoint = generate_endpoint(node, ctx);
endpoint.tag = tag;
return { kind: 'endpoint', object: endpoint };
}
const outbound = generate_outbound(node, mark, direct_overrides);
/* generate_outbound() returns null for an empty node, and a reference can
* point at a section that no longer exists: main_node and main_udp_node
* are stored by name, and deleting a node in the UI does not rewrite
* them. Setting `.tag` on null raised "left-hand side expression is
* null", which says nothing about which reference is stale. */
if (outbound == null)
die(sprintf("cannot build %s: the node it refers to does not exist (it may have been deleted, or the reference is stale).\n", tag));
outbound.tag = tag;
return { kind: 'outbound', object: outbound };
}
function buildMainOutbounds(dm, config, ctx, direct_overrides) {
const outbounds = config.outbounds;
const endpoints = config.endpoints;
const mark = ctx.self_mark;
let urltest_nodes = [];
if (ctx.main_node === 'urltest') {
const main_urltest_nodes = buildable_candidates(dm, ctx.main_urltest_nodes);
const main_urltest_interval = ctx.main_urltest_interval;
const main_urltest_tolerance = ctx.main_urltest_tolerance;
/* An empty group means every candidate was unbuildable: there is
* nothing left to route through, so fail with the reason instead
* of emitting an urltest outbound with no members. */
if (isEmpty(main_urltest_nodes))
die("no buildable node in the main urltest group, please check your configuration.\n");
push(outbounds, {
type: 'urltest',
tag: 'main-out',
outbounds: map(main_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_urltest_interval),
tolerance: strToInt(main_urltest_tolerance),
idle_timeout: (strToInt(main_urltest_interval) > 1800) ? `${main_urltest_interval * 2}s` : null,
});
urltest_nodes = main_urltest_nodes;
} else {
const main_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_node);
const emitted = emitNode(main_node_cfg, mark, 'main-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
if (ctx.main_udp_node === 'urltest') {
const main_udp_urltest_nodes = buildable_candidates(dm, ctx.main_udp_urltest_nodes);
const main_udp_urltest_interval = ctx.main_udp_urltest_interval;
const main_udp_urltest_tolerance = ctx.main_udp_urltest_tolerance;
if (isEmpty(main_udp_urltest_nodes))
die("no buildable node in the main UDP urltest group, please check your configuration.\n");
push(outbounds, {
type: 'urltest',
tag: 'main-udp-out',
outbounds: map(main_udp_urltest_nodes, (k) => `cfg-${k}-out`),
interval: strToTime(main_udp_urltest_interval),
tolerance: strToInt(main_udp_urltest_tolerance),
idle_timeout: (strToInt(main_udp_urltest_interval) > 1800) ? `${main_udp_urltest_interval * 2}s` : null,
});
urltest_nodes = [...urltest_nodes, ...filter(main_udp_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else if (ctx.dedicated_udp_node) {
const main_udp_node_cfg = ConfigQuery.node_by_id(dm, ctx.main_udp_node);
const emitted = emitNode(main_udp_node_cfg, mark, 'main-udp-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
for (let i in urltest_nodes) {
const urltest_node = ConfigQuery.node_by_id(dm, i);
if (!urltest_node)
continue;
const emitted = emitNode(urltest_node, mark, 'cfg-' + i + '-out', direct_overrides, ctx);
if (emitted.kind === 'endpoint')
push(endpoints, emitted.object);
else
push(outbounds, emitted.object);
}
}
/* --- custom-mode outbound building (routing_node iteration) ------------- */
function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
const outbounds = config.outbounds;
const endpoints = config.endpoints;
const mark = ctx.self_mark;
let urltest_nodes = [],
routing_nodes = [];
for (let cfg in dm.routing.nodes) {
if (!cfg.enabled)
continue;
if (cfg.node === 'urltest') {
const cfg_urltest_nodes = buildable_candidates(dm, cfg.urltest_nodes);
push(outbounds, {
type: 'urltest',
tag: 'cfg-' + cfg.name + '-out',
outbounds: map(cfg_urltest_nodes, (k) => `cfg-${k}-out`),
url: cfg.urltest_url,
interval: strToTime(cfg.urltest_interval),
tolerance: strToInt(cfg.urltest_tolerance),
idle_timeout: strToTime(cfg.urltest_idle_timeout),
interrupt_exist_connections: strToBool(cfg.urltest_interrupt_exist_connections)
});
urltest_nodes = [...urltest_nodes, ...filter(cfg_urltest_nodes, (l) => !~index(urltest_nodes, l))];
} else {
const outbound = ConfigQuery.node_by_id(dm, cfg.node);
/* Same stale-reference case as emitNode(), but here the reference
* comes from a routing_node. `|| {}` made it look non-null and the
* failure surfaced as a null dereference two lines later. */
if (isEmpty(outbound))
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
if (outbound.type === 'wireguard') {
/* WireGuard goes through generate_endpoint() which tags
* the endpoint as cfg-<node_id>-out (same convention as
* generate_outbound()). The routing_node section name is
* used by routing rules via get_outbound() but not as the
* tag itself; both paths agree on cfg-<node_id>-out. */
const endpoint = generate_endpoint(outbound, ctx);
endpoint.bind_interface = cfg.bind_interface;
endpoint.detour = get_outbound(cfg.outbound, dm);
if (cfg.domain_resolver)
endpoint.domain_resolver = {
server: get_resolver(cfg.domain_resolver, dm),
strategy: cfg.domain_strategy
};
push(endpoints, endpoint);
} else {
const ob = generate_outbound(outbound, mark, direct_overrides);
if (ob == null)
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
ob.bind_interface = cfg.bind_interface;
ob.detour = get_outbound(cfg.outbound, dm);
if (cfg.domain_resolver)
ob.domain_resolver = {
server: get_resolver(cfg.domain_resolver, dm),
strategy: cfg.domain_strategy
};
push(outbounds, ob);
}
push(routing_nodes, cfg.node);
}
}
for (let i in filter (urltest_nodes, (l) => !~index(routing_nodes, l))) {
const urltest_node = ConfigQuery.node_by_id(dm, i) || {};
if (urltest_node.type === 'wireguard')
push(endpoints, generate_endpoint(urltest_node, ctx));
else
push(outbounds, generate_outbound(urltest_node, mark, direct_overrides));
}
}
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box `outbounds` and `endpoints` blocks. Mutates
* config.endpoints and config.outbounds; the caller initialises them
* with the always-on default outbounds (direct-out + block-out) and
* passes the mutable accumulator in `ctx.acc` so the same arrays can
* be threaded through this module, the ruleset module and the route
* module without copying.
*
* `direct_overrides` is the same mutable map the route module will read
* later; populate it here so the override information is available
* when the route builder runs. */
export function build_outbounds(config, dm, ctx, direct_overrides) {
if (isEmpty(ctx.main_node) && isEmpty(ctx.default_outbound))
return config;
if (!isEmpty(ctx.main_node))
buildMainOutbounds(dm, config, ctx, direct_overrides);
else if (!isEmpty(ctx.default_outbound))
buildCustomOutbounds(dm, config, ctx, direct_overrides);
return config;
};
@@ -0,0 +1,341 @@
/*
* 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 } from '../homeproxy-pro.uc';
import { 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.
*
* Sniff tuning follows §2.7 of docs/linux.json 与 pro 的差距分析.md:
* the default rule is the conservative 300ms / unconstrained sniffer list
* (tutorial demo recommends 100ms + ['http','tls','stun','quic','dns'],
* which costs less CPU but is a behaviour change). sniffer_advanced_mode
* is the opt-in switch that flips to the tutorial profile. */
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'
});
/* Bypass CN traffic: resolve the destination first, then route by IP.
sing-box does not match an IP-based rule set (geoip-cn) against a domain
destination unless it is resolved first, so add an explicit resolve
action; geoip-cn then sends China IPs to direct and everything else falls
through to main-out (proxy). This avoids relying on the geosite-* domain
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
beacons) as "cn" and send them direct to time out. Keep the direct-domain
fast-path above for known direct domains. */
if (ctx.routing_mode === 'bypass_mainland_china') {
push(config.route.rules, {
action: 'resolve',
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
});
push(config.route.rules, {
rule_set: 'geoip-cn',
action: 'route',
outbound: 'direct-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
});
config.route.final = 'main-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,
}
]
});
if (ctx.routing_mode === 'bypass_mainland_china') {
/*
* 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 geosite-cn and geoip-cn are still emitted, but they are
* consumed by different blocks:
*
* geoip-cn - referenced by the route rule above (line 105) to
* split CN destinations to direct-out.
* geosite-cn - referenced only by the DNS rule in
* generator/dns.uc line 135-139 (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).
*/
push(config.route.rule_set, {
type: 'remote',
tag: 'geoip-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
push(config.route.rule_set, {
type: 'remote',
tag: 'geosite-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
}
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;
};
@@ -0,0 +1,134 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/ruleset.uc: user-defined rule_set entries + the sing-box
* 1.14 http_clients normalisation.
*
* Two distinct pieces share this module because they both iterate over
* `config.route.rule_set` after every other module has had a chance to
* append:
*
* build_user_rulesets(rule_set_array, dm, ctx)
* The custom-mode `dm.routing.rulesets` loop, which builds the
* user-defined rule_set entries (cfg-<name>-rule tag) and pre-1.14
* download_detour. The legacy field is stripped later, in the same
* module, by build_http_clients(); leaving it here would make
* sing-box 1.14 reject the config.
*
* build_http_clients(rule_set_array, dm, ctx)
* sing-box 1.14 dropped download_detour from local/inline rule-sets
* and moved the remote rule-set fetch into top-level http_clients.
* This walks every entry, strips download_detour (sing-box 1.14
* rejects it on local/inline), and emits a http_clients array whose
* `detour` is dropped for pure-TUN setups (direct outbound with no
* self_mark; sing-box rejects that detour).
*/
'use strict';
import { isEmpty, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag } from './common.uc';
/* --- user-defined rule_set entries (custom mode only) ----------------- */
/* Append user-defined rule_set entries to `rule_set_array`. Each cfg
* produces one entry; `extra_tags` adds additional rule_set tags that
* share the same fetch source (sing-box 1.14 multi-tag). */
export function build_user_rulesets(rule_set_array, dm, ctx) {
for (let cfg in dm.routing.rulesets) {
if (!cfg.enabled)
continue;
const extra_tags = cfg.extra_tags || [];
let rs_tag = 'cfg-' + cfg.name + '-rule';
if (length(extra_tags) && cfg.type !== 'inline') {
rs_tag = [rs_tag];
for (let t in extra_tags)
push(rs_tag, 'cfg-' + t + '-rule');
/* sing-box 1.14: multi-tag requires a {tag} placeholder in the fetch source
(remote: url and initial_path, local: path). A missing
placeholder makes sing-box try to literal-substitute the
first tag name and reject the whole config; the reload then
keeps the previous one and the user only sees "my change did
not take". die() early so the saved UCI is rejected at
apply time, the same way get_resolver/get_ruleset already
fail loud on a missing/disabled reference. */
const fetch_ref = (cfg.type === 'remote') ? (cfg.url || '') : (cfg.path || '');
if (!match(fetch_ref, /\{tag\}/))
die(sprintf("homeproxy-pro: rule-set '%s' uses extra tags but its %s source lacks a {tag} placeholder; add {tag} to the source or drop the extra tags.", cfg.name, cfg.type));
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path) && !match(cfg.initial_path, /\{tag\}/))
die(sprintf("homeproxy-pro: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
}
/* Local rule-set path is read by sing-box as root. The whitelist
* is enforced here because the LuCI form's datatype='file' is
* only UX - UCI can be set from anywhere on the LAN, and an
* arbitrary /etc/passwd would leak the file to anyone who
* could write UCI. die() early (same as get_resolver on a
* missing/disabled dns_server): silently dropping path left
* sing-box checking whatever the field evaluated to, and the
* user only saw the reload keep the previous config. */
if (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
die(sprintf("homeproxy-pro: rule-set '%s' path '%s' is outside the homeproxy-pro whitelist; choose a path under /etc/homeproxy-pro/.", cfg.name, cfg.path));
const ruleset = {
type: cfg.type,
tag: rs_tag,
format: cfg.format,
path: cfg.path,
url: cfg.url,
update_interval: cfg.update_interval
};
/* download_detour is a pre-1.14 option that only makes sense for
remote rule-sets; emitting it for local/inline ones makes sing-box
* 1.14 reject the whole config. It is translated into http_clients
right below. */
if (cfg.type === 'remote')
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
/* initial_path is read off the local disk as root; same
* whitelist as the local ruleset path. */
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
push(rule_set_array, ruleset);
}
};
/* --- http_clients normalisation (every routing mode) ------------------ */
/* Strip the legacy download_detour from every entry (sing-box 1.14
* rejects it on local/inline), and for remote rule-sets translate it
* into the http_client tag pointing at the corresponding http_clients
* entry. Returns the http_clients array (caller attaches it to
* config.http_clients) and mutates the rule_set entries in place. */
export function build_http_clients(rule_set_array, dm, ctx) {
const http_clients = [];
const http_seen = {};
for (let rs in (rule_set_array || [])) {
/* Strip the legacy field from every entry first, including the
local/inline ones: sing-box 1.14 rejects it everywhere except on
remote rule-sets, where it is replaced by http_client below. */
let detour = rs.download_detour;
delete rs.download_detour;
if (rs.type !== 'remote')
continue;
if (isEmpty(detour))
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
const tag = 'hp-' + detour;
rs.http_client = tag;
if (!http_seen[detour]) {
http_seen[detour] = true;
/* sing-box 1.14 rejects detouring to an empty direct outbound
(pure TUN mode has no self_mark on direct-out). Omitting detour
uses the same system direct dialer, so behavior is unchanged. */
const client = { tag: tag };
if (!(isEmpty(ctx.self_mark) && isDirectOutboundTag(detour, dm)))
client.detour = detour;
push(http_clients, client);
}
}
return http_clients;
};
@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* generator/server.uc: server-side orchestrator.
*
* This module no longer shapes
* any protocol. It used to read the flat UCI section directly
* (`cfg.snell_version`, `cfg.shadowsocks_encrypt_method`,
* `cfg.hysteria_obfs_min_packet_size`, ...) and spell out every
* per-protocol sing-box field inline, which meant the server path had
* no domain model and adding a protocol touched the generator.
*
* Now:
*
* UCI -> Loader.load_inbound() -> Inbound (domain) -> InboundFactory
* -> sing-box inbound JSON
*
* The protocol knowledge lives in config/model.uc (the canonical <->
* UCI tables) and config/adapter.uc (InboundFactory's sing-box field
* tables). What is left here is the orchestration: gate on `enabled`,
* build the log block, and decide that "no inbound" means "server
* disabled" (the CLI shell turns the null into exit 1).
*
* generate_server(dm) -> config object. Pure: no UCI, no file I/O.
*/
'use strict';
import { InboundFactory } from '../config/adapter.uc';
/* --- public entry ------------------------------------------------------ */
/* Build the sing-box server config object. Returns null when no
* enabled inbound exists (the caller treats that as "service disabled",
* which the runtime interprets as "do not start the server instance"). */
export function generate_server(dm) {
const log_level = ((dm.server || {}).settings || {}).log_level || 'warn';
const config = {
log: {
disabled: false,
level: log_level,
output: '/var/run/homeproxy-pro/sing-box-s.log',
timestamp: true
},
inbounds: []
};
for (let inbound in ((dm.server || {}).inbounds || [])) {
if (!inbound.enabled)
continue;
/* create() dies on a broken section, which is the previous
* behaviour too: the old code would emit a sing-box config
* that `sing-box check` then rejected, and the runtime's
* candidate/known-good path caught it. Failing here instead
* names the offending section in the log. */
push(config.inbounds, InboundFactory.create(inbound));
}
if (length(config.inbounds) === 0)
return null;
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
return config;
};