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