mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-10 06:37:51 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
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;
|
||||
};
|
||||
Reference in new issue
Block a user