/* * 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--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--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--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--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; };