🐶 Sync 2026-10-03 11:32:07

This commit is contained in:
github-actions[bot] committed 2026-10-03 11:32:07 +08:00
1 parent a5bb91e6ed
commit 66e3d835c4
72 files changed
+5107 -1799

No files matched your search

@@ -26,9 +26,81 @@
'use strict';
import { isEmpty, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, ruleSetInitialFallback, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag } from './common.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) ----------------- */
@@ -41,11 +113,15 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
continue;
const extra_tags = cfg.extra_tags || [];
let rs_tag = 'cfg-' + cfg.name + '-rule';
/* 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 = [rs_tag];
for (let t in extra_tags)
push(rs_tag, 'cfg-' + t + '-rule');
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
@@ -61,24 +137,65 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
die(sprintf("homeproxy-pro: rule-set '%s' uses extra tags but its initial_path lacks a {tag} placeholder.", cfg.name));
}
/* Local rule-set path is read by sing-box as root. The whitelist
* is enforced here because the LuCI form's datatype='file' is
* only UX - UCI can be set from anywhere on the LAN, and an
* arbitrary /etc/passwd would leak the file to anyone who
* could write UCI. die() early (same as get_resolver on a
* missing/disabled dns_server): silently dropping path left
* sing-box checking whatever the field evaluated to, and the
* user only saw the reload keep the previous config. */
if (cfg.type === 'local' && cfg.path && !validateHomeProxyPath(cfg.path))
die(sprintf("homeproxy-pro: rule-set '%s' path '%s' is outside the homeproxy-pro whitelist; choose a path under /etc/homeproxy-pro/.", cfg.name, cfg.path));
/* 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: cfg.format,
format: resolveFormat(cfg, ctx),
path: cfg.path,
url: cfg.url,
update_interval: cfg.update_interval
/* 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
@@ -86,10 +203,49 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
right below. */
if (cfg.type === 'remote')
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
/* initial_path is read off the local disk as root; same
* whitelist as the local ruleset path. */
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
/* 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;
} else if (cfg.type === 'remote' && ctx.ruleset_safe_start === '1') {
/* No initial file of the user's own, and the opt-in is on: point at
* the empty fallback the CLI wrote before this generator ran.
*
* ctx.ruleset_initial records PRESENCE, not a path, and every tag
* has to be present. sing-box substitutes {tag} and opens every
* resulting file, so a multi-tag rule-set whose second file is
* missing blocks startup exactly as surely as one whose first is -
* measured on 1.14.2, where a literal "{tag}.srs" on disk makes
* it fall back to downloading and fail. All-or-nothing is the
* only correct answer here.
*
* Nothing is emitted when the answer is no: the rule-set then
* behaves exactly as it does today, which is also what happens if
* the CLI could not write the file. */
const fallback = ruleSetInitialFallback(all_tags, cfg.format, cfg.url);
if (fallback) {
let ready = true;
for (let t in all_tags)
if (!ctx.ruleset_initial[t]) {
ready = false;
break;
}
if (ready)
ruleset.initial_path = fallback;
else
warn(sprintf("homeproxy-pro: rule-set '%s' has no complete empty fallback, so it will be fetched during startup as before.", cfg.name));
}
}
push(rule_set_array, ruleset);
}
};