Files

189 lines
7.2 KiB
Ucode

/*
* 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);
/* Everything the client prints lands in one file, and not all of it was
* decided here: when the proxy node cannot reach a destination, sing-box
* reports it through the same logger, so a line like
*
* open connection to 1.2.3.4:443 using outbound/direct[direct]:
* connect: connection refused
*
* can be the *node's* failure rather than this router sending the traffic
* direct. Reading it as a local routing decision already produced one
* wrong conclusion during review. What the router actually did is in the
* forwarding layer: `nft list chain inet fw4 homeproxy_redirect` (a
* `return` branch is direct, the final `goto` is the proxy port) and
* `/proc/net/nf_conntrack` (`sport=5331` on the reply tuple). See the
* README's troubleshooting section. */
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;
};