Files

268 lines
11 KiB
Ucode

/*
* 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, HP_DIR } 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;
};
/* rule_set_tags(cfg) - the rule_set tags ONE `dm.routing.rulesets` section
* produces: `cfg-<name>-rule`, plus one per entry in extra_tags.
*
* The generator needs it to build the tag; the CLI's environment resolution
* needs it too, because a `{tag}` placeholder in a source path has to be
* expanded to the real per-tag files before their existence can be checked.
* Two copies of this list would be free to drift, and the failure mode is
* quiet: the pre-check would stat files the running configuration never
* names, and a genuinely missing one would go unreported.
*
* Declared after get_ruleset() but before its callers for the same reason that
* function carries its own note: ucode resolves references at call time from
* what has already been evaluated. */
export function rule_set_tags(cfg) {
const tags = [ 'cfg-' + cfg.name + '-rule' ];
for (let t in (cfg.extra_tags || []))
push(tags, 'cfg-' + t + '-rule');
return tags;
};
/* The routing modes that split on mainland China, and the rule-sets they
* declare.
*
* The three of them - china-ip, china-ip6, china-domain - are local files the
* resource updater generates, one per list, and each is declared only when
* something references it and its file is on disk. route.uc owns that
* decision because the conditions are per-tag (the address list in both
* mainland modes, the v6 half only with IPv6 on, the domain list only in
* bypass mode), so there is nothing here for a shared list to keep in step
* with.
*
* They were `type: remote` entries pointing at SagerNet's sing-geoip and
* sing-geosite repositories, and two things followed from that which the local
* arrangement does not have:
*
* - a cold start had to download them before the inbounds bind, over the
* node (download_detour), so a first start - or a start after the cache
* was cleared - depended on GitHub being reachable through the proxy it
* was trying to bring up;
* - the two halves of the same split read two different files. The kernel's
* came from china_ip4.txt and the resolver's from a .srs, and the route.uc
* comment about them disagreeing is the scar of patching that instead of
* fixing it.
*
* Now the nft set and the sing-box rule-set are both generated from the same
* list, on the same schedule, with nothing to download.
*
* Source JSON, not a compiled .srs: the updater writes the canonical form and
* sing-box watches the file, so a new list is picked up in place. Compiling
* first was measured rather than assumed - 111k domain suffixes cost 3.7 MB of
* RSS in source form (44.9 MB baseline vs 48.7 MB loaded), which is not worth
* a second artifact and a second failure mode. */
/* The routing modes that split on mainland China. The China domain list is
* only meaningful where the split is by domain: proxy_mainland_china decides
* by address on the route side and leaves the resolver to the mode default. */
export function declaresBuiltinRuleSets(routing_mode) {
return routing_mode === 'bypass_mainland_china' || routing_mode === 'proxy_mainland_china';
};
/* 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: the reference configuration this was compared against 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;
};