mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
323 lines
13 KiB
Ucode
323 lines
13 KiB
Ucode
/*
|
|
* 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;
|
|
}; |