Files

259 lines
12 KiB
Ucode

/*
* 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, rulePathRootsText, ruleSetFormatFromPath, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag, rule_set_tags } from './common.uc';
/* --- the `format` a rule-set is emitted with ----------------------------- */
/* resolveFormat(cfg, ctx) -> 'binary' | 'source' | the declared value | null
*
* The declared `format` describes the file's NAME; sing-box infers it from
* the extension and is wrong in three ways that each reject the whole
* configuration at `sing-box check`, so the reload is aborted and the user
* sees only "my change did not take":
*
* 1. an explicit format that names the wrong one
* (`format: binary` on a file holding JSON) -> "invalid sing-box
* rule-set file"
* 2. no explicit format, but the content disagrees with the extension
* (example.srs holding JSON) -> the same error, and here the extension
* is what misled sing-box
* 3. no explicit format and no extension to infer from
* -> "route.rule_set[0]: missing format", a different error for the same
* underlying mistake
*
* ctx.ruleset_formats carries what the file's bytes actually are, resolved by
* the CLI (guard 27 keeps generator/ off the filesystem). All three cases are
* answered by comparing it against what the current arrangement would produce:
*
* - no verdict -> emit the declared value untouched. The
* dominant case (any remote rule-set), and the one that must stay a
* no-op: sing-box keeps being the authority.
* - declared, and wrong -> the verdict wins, loudly.
* - undeclared, and the extension would have been right -> emit nothing.
* Adding an explicit value that says what the extension already said
* would change the generated bytes of every working rule-set for no
* behavioural gain.
* - undeclared, and the extension would have been wrong (or there is none)
* -> the verdict wins, loudly.
*
* The correction is announced either way. warn() reaches the user through
* $RUN_DIR/homeproxy-pro.log, which is the only place that can say "the field you
* filled in does not describe the file you pointed at" - and unlike the
* reload's own message, it names the rule-set.
*/
function resolveFormat(cfg, ctx) {
const declared = cfg.format;
const probed = ctx.ruleset_formats[cfg.name];
if (!probed)
return declared;
/* The extension sing-box would have inferred from. initial_path is the
* fallback because a remote rule-set with no `path` still has a file the
* verdict is about. */
const inferred = ruleSetFormatFromPath(cfg.path || cfg.initial_path);
if (declared) {
if (declared === probed)
return declared;
warn(sprintf("homeproxy-pro: rule-set '%s' declares format '%s' but its file is %s; using '%s'.", cfg.name, declared, probed, probed));
return probed;
}
if (inferred === probed)
return null;
if (inferred) {
warn(sprintf("homeproxy-pro: rule-set '%s' is named '*.%s' (which sing-box reads as %s) but its file is %s; declaring format '%s'.", cfg.name, inferred == 'source' ? 'json' : 'srs', inferred, probed, probed));
return probed;
}
/* No extension at all: sing-box has nothing to infer from and reports
* "missing format". The verdict is the only thing that can save it. */
return probed;
};
/* --- 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 || [];
/* One source of truth for the tag list, shared with the CLI that has
* to expand a {tag} placeholder before it can check that the files
* exist (common.uc's rule_set_tags). A second copy would be free to
* drift, and the failure would be quiet: the pre-check would stat
* files the running configuration never names. */
const all_tags = rule_set_tags(cfg);
let rs_tag = all_tags[0];
if (length(extra_tags) && cfg.type !== 'inline') {
rs_tag = all_tags;
/* 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));
}
/* A local rule-set is opened by sing-box as root, so its path is a
* file-disclosure surface: the LuCI form's datatype='file' is only
* UX, and UCI can be set from anywhere on the LAN. The policy is
* RULE_PATH_ROOTS (homeproxy-pro.uc) - the archive this package creates
* at install time - and it is deliberately NARROWER than the general
* validateHomeProxyPath() gate, which also admits /tmp/homeproxy_*
* upload staging and the package's own resource lists.
*
* die() early, for the reason get_resolver() gives: dropping the
* field silently left sing-box checking whatever the field evaluated
* to, and the user only saw the reload keep the previous config. */
if (cfg.type === 'local' && !isEmpty(cfg.path)) {
if (!validateRuleSetPath(cfg.path))
die(sprintf("homeproxy-pro: rule-set '%s' path '%s' is outside the allowed rule-set roots (%s); move the file into one of them, or point the path at a file that is already there.", cfg.name, cfg.path, rulePathRootsText()));
/* The file has to be there too. `sing-box check` runs over this
* configuration before it is ever started, and it opens every
* local rule_set path - so a rule-set pointing at a file nobody
* has put there produced sing-box's own
*
* parse rule-set[0]: open /etc/homeproxy-pro/ruleset/x.srs: no such file
*
* which names neither the rule-set nor anything the user can act
* on, and surfaced to the user as "new client configuration is
* invalid, reload aborted". The archive is created at install
* time and before every generation precisely so that this is a
* "you have not copied the file yet" message instead of a raw
* filesystem error.
*
* The stat is root's view, taken by the one layer allowed to
* touch the filesystem (guard 27) and handed over as
* ctx.ruleset_local_ready. Whether the *jailed* sing-box user
* can read the file is the runtime's job:
* hp_prepare_runtime_files hands the archive to that user on
* every start. */
if (!ctx.ruleset_local_ready[cfg.name])
die(sprintf("homeproxy-pro: rule-set '%s' points at '%s', which is missing, not a regular file, or empty. Put the file in %s and reload.", cfg.name, cfg.path, rulePathRootsText()));
}
const ruleset = {
type: cfg.type,
tag: rs_tag,
format: resolveFormat(cfg, ctx),
path: cfg.path,
url: cfg.url,
/* sing-box parses this as a Go duration, which has no bare-number
* form: `update_interval: "3600"` is a hard `time: missing unit
* in duration "3600"` that rejects the whole configuration. That
* one is measured, on 1.14.2. strToTime() fixes it for the bare
* number without inventing a unit table.
*
* It cannot fix a wrong unit, and the placeholder the form used
* to show ("1d") is one: Go's duration units are ns/us/ms/s/m/h
* and there is no day - which is why the generator's own
* built-in rule-sets say 24h. That is a claim about Go's parser
* rather than a measurement of sing-box, so the frontend rejects
* the shapes Go cannot read instead of relying on this comment
* being right. */
update_interval: strToTime(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);
/* initial_path is read off the local disk as root, so it answers to
* the same rule-set policy as `path` - and it fails LOUD rather than
* quietly becoming null. The old form (`... ? value : null`) was the
* last asymmetry left in this file: an out-of-policy initial_path was
* dropped, the download went back to blocking startup, and the user
* was left with a configuration that looked configured and behaved as
* if the field were empty. */
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path)) {
if (!validateRuleSetPath(cfg.initial_path))
die(sprintf("homeproxy-pro: rule-set '%s' initial_path '%s' is outside the allowed rule-set roots (%s); move the file into one of them, or clear the field to let sing-box fetch the rule-set on its own.", cfg.name, cfg.initial_path, rulePathRootsText()));
ruleset.initial_path = cfg.initial_path;
}
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;
};