🐶 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

@@ -393,6 +393,18 @@ export const Loader = {
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
sniffer_advanced_mode: opt(uci, 'main', 'sniffer_advanced_mode'),
/* The "do not block startup on the first rule-set download"
* opt-in. Listed here because this object is an EXPLICIT key
* list: a field the generators read off dm.general that is not
* in it does not read as "absent", it reads as "never set" - and
* with a `|| '0'` fallback in context.uc that is indistinguishable
* from a user who left it off. The first version of the feature
* added the option to /etc/config/homeproxy-pro and read it in
* context.uc, and nothing anywhere said the key was missing: the
* configuration generated cleanly, `sing-box check` passed, and the
* feature simply never turned on. Guard 53 now compares the two
* lists so that class cannot come back quietly. */
ruleset_safe_start: opt(uci, 'main', 'ruleset_safe_start'),
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
@@ -29,7 +29,8 @@ import { lstat, mkdtemp, readfile, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate } from './generator/client.uc';
import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy-pro.uc';
import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, rule_set_tags } from './generator/common.uc';
import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ruleSetFormatFromPath, ruleSetInitialFallback, RULESET_EMPTY_BINARY, RULESET_EMPTY_SOURCE, RULESET_INITIAL_DIR, shellQuote, validateRuleSetPath, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc';
/* Resolve the GenerationContext inputs. This is the only impure step on the
* client generation path, and it is deliberately here rather than under
@@ -45,10 +46,74 @@ import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './
*
* The extra parentheses around the ubus call keep the ?. chain guarded when
* connect() returns null. */
/* Write one EMPTY rule-set to <path>, in <format>.
*
* The fallback exists so that a remote rule-set with no initial file of the
* user's own does not have to be downloaded before the inbounds bind - and on
* a cold cache that is the difference between a router that comes up and one
* that waits on raw.githubusercontent.com (through the node) before it will
* listen at all.
*
* binary is produced by compiling, not by writing bytes: a file produced by
* the sing-box that is running cannot drift from it, and the compiler is
* always there because the very next thing this script does is run it. The
* shipped 14-byte constant is the fallback for the fallback - a router where
* the compile cannot run - and it is a file in the package rather than a byte
* string in the source, so `hexdump -C` on a device can confirm it.
*
* Returns true when <path> holds a usable file afterwards. A false return is
* not fatal: the caller skips the initial_path, and the rule-set then behaves
* exactly as it does today (fetched during initialization, blocking on
* failure). That is the point of the whole opt-in - it must never make a
* situation worse than not having it. */
function writeEmptyRuleSet(path, format) {
const ext = ruleSetFormatExtension(format);
if (!ext)
return false;
system('mkdir -p ' + shellQuote(RULESET_INITIAL_DIR));
if (format === 'source') {
/* No compile needed, and no temp file: the source form is the JSON
* itself. The shipped empty.source.json is the source of record, so
* a missing one means the install is incomplete and the copy is the
* thing that says so. */
if (system('cp -f ' + shellQuote(RULESET_EMPTY_SOURCE) + ' ' + shellQuote(path)) !== 0)
return false;
} else {
/* Compile into the destination directly. A partial output is
* possible if the compile dies, so the file is only accepted when the
* compile succeeded AND something is there - sing-box opening a
* truncated .srs would block startup, which is the outcome this is
* all meant to prevent.
*
* `-o` rather than `--output`, because that is the form
* tests/ucode/test_generators.sh already exercises against this
* binary, and a flag spelling that only ever runs in production is a
* flag spelling nobody has ever seen work. */
if (system('sing-box rule-set compile ' + shellQuote(RULESET_EMPTY_SOURCE)
+ ' -o ' + shellQuote(path) + ' >/dev/null 2>&1') !== 0) {
if (system('cp -f ' + shellQuote(RULESET_EMPTY_BINARY) + ' ' + shellQuote(path)) !== 0)
return false;
}
const st = lstat(path);
if (!st || st.type !== 'file' || st.size <= 0)
return false;
}
/* The jailed client reads this as the sing-box user. writefile() has no
* mode argument and cp preserves the source's, so the mode is set
* explicitly rather than inherited from whatever umask was in force. */
system('chmod 644 ' + shellQuote(path));
return true;
}
function resolve_env(dm) {
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
const ubus = connect();
const env = {
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
direct_domain_list: [],
@@ -81,9 +146,248 @@ function resolve_env(dm) {
* lstat() is used because it is unambiguous - there is no second
* argument to get wrong - and because the neighbouring stderr-size
* check in homeproxy-pro.uc already reads sizes through it. */
china_ip6_ready: lstat(HP_DIR + '/resources/china_ip6.json') !== null
china_ip6_ready: lstat(HP_DIR + '/resources/china_ip6.json') !== null,
/* Which enabled `type: local` rule-sets have a usable file on disk,
* keyed by UCI section name.
*
* The same boundary as china_ip6_ready above, and for the same reason
* it is resolved here rather than inside generator/: a rule-set whose
* `path` names a file that is not there is a filesystem question, and
* asking it from the generator would break the "generator/ is a pure
* function of its arguments" invariant guard 27 enforces.
*
* Only enabled local rule-sets are looked at, mirroring the filter
* build_user_rulesets() applies first - a disabled entry never reaches
* the generated configuration, so a missing file behind one is not an
* error. The status is three-way on purpose:
*
* missing key the file is not there, or not a regular file, or
* empty - all three make `sing-box check` fail, the
* first two with a filesystem error and the third
* with "invalid sing-box rule-set file"
* true present and non-empty
* never true deliberately absent for a disabled or non-local
* entry, so "not checked" is distinguishable from
* "checked and absent"
*
* A `{tag}` placeholder in a path is expanded to one file per tag
* before the stat, because that is what sing-box does with it: a
* multi-tag rule-set whose second tag has no file fails the same way
* the first one would. rule_set_tags() is imported from
* generator/common.uc so this and the generator agree on the tag
* names; the UI only offers extra_tags on remote rule-sets, so this
* path is reachable through a direct UCI write rather than the form.
*
* This is root's view of the filesystem, which is what the generator
* needs; whether the jailed sing-box user can read the file is the
* runtime's business (hp_prepare_runtime_files hands the archive over
* on every start). */
ruleset_local_ready: {},
/* What each rule-set's own file actually IS, keyed by UCI section
* name: { '<section>': 'binary' | 'source' }.
*
* A verdict about the bytes, so the generator can stop trusting a
* field that describes the file's NAME: sing-box infers `format`
* from the extension and is wrong in three ways that all end the
* same way (`sing-box check` rejects the configuration, the reload
* is aborted, the user sees "my change did not take") - content
* disagreeing with the name, the field naming the wrong format, and
* no extension to infer from at all. See ruleSetFormatFromBytes()
* in homeproxy-pro.uc for the whole argument.
*
* No entry means no opinion, and that is the normal case for a
* remote rule-set with no initial_path: its content comes from a URL
* and generation must not touch the network. A missing entry is
* therefore never read as "source" or "binary" - it is read as
* "leave the declared format alone", which is also what sing-box
* does with a rule-set nobody told anything about. */
ruleset_formats: {},
/* Which empty startup fallbacks are actually on disk, keyed by rule_set
* tag: { '<tag>': true }. Presence, not a path - see the block at the
* end of this function. */
ruleset_initial: {}
};
if (routing_mode === 'custom') {
for (let cfg in (dm.routing.rulesets || [])) {
if (!cfg.enabled)
continue;
/* The file sing-box opens at startup: a local rule-set's `path`,
* and a remote one's `initial_path` when it has one. Both are
* subject to the same questions (is it there, what is it), which
* is why one pass answers both.
*
* A remote rule-set with no initial_path has nothing to look at:
* its content is whatever the URL serves, and fetching that
* during generation is exactly what generation must not do. */
const source = (cfg.type === 'local') ? cfg.path
: ((cfg.type === 'remote') ? cfg.initial_path : null);
if (isEmpty(source))
continue;
/* One path per tag. A single-tag rule-set yields exactly one, so
* this is the plain case and the loop is the only complication -
* and it has to be here, because sing-box substitutes {tag} and
* opens EVERY resulting file. rule_set_tags() is the shared tag
* list (see common.uc): a second copy would be free to drift,
* and the drift would be silent - a pre-check that stats files
* the running configuration never names. */
const paths = [];
if (match(source, /\{tag\}/))
for (let tag in rule_set_tags(cfg))
push(paths, replace(source, '{tag}', tag));
else
push(paths, source);
/* Both answers below are about paths the sing-box jail opens as
* the sing-box user, so an out-of-policy path is not read at all
* here. ruleset.uc refuses it a moment later with the message
* the user can act on; producing no opinion about a file this
* process has no business opening is the whole point of the
* check. */
let in_policy = true;
for (let p in paths)
if (!validateRuleSetPath(p)) {
in_policy = false;
break;
}
if (!in_policy)
continue;
/* Presence: every tag's file, and "present" means a non-empty
* regular file - all three states make `sing-box check` fail,
* the first two with a filesystem error and the third with
* "invalid sing-box rule-set file". Only a local rule-set
* consults this; a remote one is allowed to have no
* initial_path, which is the normal case. */
let all_present = true;
for (let p in paths) {
const st = lstat(p);
if (!st || st.type !== 'file' || st.size <= 0) {
all_present = false;
break;
}
}
if (cfg.type === 'local' && all_present)
env.ruleset_local_ready[cfg.name] = true;
/* Content: one verdict, and only when every file agrees.
*
* A disagreement between tags, or a file the probe cannot
* classify (a truncated download, an HTML error page saved
* under a .srs name), yields no verdict at all rather than the
* first answer that came back. Correcting on ambiguous
* evidence is how an auto-correction turns into a second source
* of wrongness, and sing-box's own error is a better one than a
* guess dressed up as a fix. */
let verdict = null, unanimous = true;
for (let p in paths) {
const seen = probeRuleSetFile(p);
if (!seen) {
unanimous = false;
break;
}
if (!verdict)
verdict = seen;
else if (verdict !== seen) {
unanimous = false;
break;
}
}
if (unanimous && verdict)
env.ruleset_formats[cfg.name] = verdict;
}
}
/* ruleset_safe_start: write the empty fallbacks.
*
* Recorded as PRESENCE, not as a path: env.ruleset_initial[tag] is true
* only once a file for that tag is on disk and readable. The generator
* then emits `initial_path` for exactly the tags it can point at, which
* is what keeps the two sides from disagreeing - a file that could not be
* written is simply not offered, and that rule-set keeps today's
* behaviour (fetched during initialization) instead of pointing at
* something that is not there. sing-box ignores a missing initial_path
* and blocks startup exactly as if none had been configured, so this
* cannot make a broken install worse - but the point of the opt-in is
* that it helps, and a pointer to a nonexistent file helps nobody.
*
* Built-ins first, then the user's own. Both come from the same helper
* the generator uses, so the format the file was written in and the
* format declared in the config are decided by one question asked once. */
if (dm.general.ruleset_safe_start === '1') {
if (declaresBuiltinRemoteRuleSets(routing_mode)) {
for (let rs in BUILTIN_REMOTE_RULE_SETS) {
const path = ruleSetInitialFallback([ rs.tag ], rs.format, rs.url);
if (path && writeEmptyRuleSet(path, rs.format))
env.ruleset_initial[rs.tag] = true;
else
warn(sprintf("homeproxy-pro: could not write the empty fallback for '%s'; this rule-set will be fetched during startup, as before.", rs.tag));
}
}
/* User rule-sets are custom-mode only - build_user_rulesets() is not
* called in any other mode - so writing fallbacks for them anywhere
* else would create files nothing ever points at. A rule-set with an
* initial file of its own is left completely alone. */
if (routing_mode === 'custom') {
for (let cfg in (dm.routing.rulesets || [])) {
if (!cfg.enabled || cfg.type !== 'remote' || !isEmpty(cfg.initial_path))
continue;
const format = cfg.format || ruleSetFormatFromPath(cfg.url);
const tags = rule_set_tags(cfg);
const template = ruleSetInitialFallback(tags, format, cfg.url);
if (!template) {
warn(sprintf("homeproxy-pro: rule-set '%s' has no usable format for the startup fallback (format '%s', url '%s'); it will be fetched during startup, as before.", cfg.name, cfg.format || '', cfg.url || ''));
continue;
}
for (let tag in tags) {
if (env.ruleset_initial[tag])
continue;
/* A single-tag rule-set's template IS the path; a
* multi-tag one carries {tag} and needs one file each -
* sing-box opens every tag's file, so a missing second
* one blocks startup as surely as a missing first. */
const path = (length(tags) > 1) ? replace(template, '{tag}', tag) : template;
if (writeEmptyRuleSet(path, format))
env.ruleset_initial[tag] = true;
else
warn(sprintf("homeproxy-pro: could not write the empty fallback for rule-set '%s' (tag %s); it will be fetched during startup, as before.", cfg.name, tag));
}
}
}
}
/* One line naming what is running on an empty fallback.
*
* The CLI already warns per rule-set when a fallback could not be written,
* but "which rule-sets are currently on an EMPTY one" is the fact a user
* needs and nothing else records it: the running configuration is not
* readable by the browser, the health gate does not look at rule-sets, and
* sing-box's own log only says the download failed. This line is the
* durable answer, and it goes to homeproxy-pro.log, which the status page can
* read with the permission it already has.
*
* Emitted only when the opt-in is on AND something actually got a
* fallback, so the log does not gain a line on every generation of a
* configuration that is not using the feature. */
if (dm.general.ruleset_safe_start === '1' && !isEmpty(env.ruleset_initial)) {
const tags = [];
for (let tag in env.ruleset_initial)
push(tags, tag);
warn(sprintf("homeproxy-pro: ruleset_safe_start is on; these rule-sets have an EMPTY initial file and match nothing until their download succeeds: %s.", join(', ', tags)));
}
if (routing_mode !== 'custom') {
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
@@ -120,6 +120,68 @@ export function get_ruleset(cfg, dm) {
return rules;
};
/* rule_set_tags(cfg) - the rule_set tags ONE `dm.routing.rulesets` section
* produces: `cfg-<name>-rule`, plus one per entry in extra_tags.
*
* The generator needs it to build the tag; the CLI's environment resolution
* needs it too, because a `{tag}` placeholder in a source path has to be
* expanded to the real per-tag files before their existence can be checked.
* Two copies of this list would be free to drift, and the failure mode is
* quiet: the pre-check would stat files the running configuration never
* names, and a genuinely missing one would go unreported.
*
* Declared after get_ruleset() but before its callers for the same reason that
* function carries its own note: ucode resolves references at call time from
* what has already been evaluated. */
export function rule_set_tags(cfg) {
const tags = [ 'cfg-' + cfg.name + '-rule' ];
for (let t in (cfg.extra_tags || []))
push(tags, 'cfg-' + t + '-rule');
return tags;
};
/* BUILTIN_REMOTE_RULE_SETS - the remote rule-sets the generator declares
* itself, in the modes that split on mainland China.
*
* geoip-cn referenced by the route rule to split destinations by IP
* geosite-cn referenced only by the DNS rule (server: china-dns); the
* route layer never matches it, but sing-box still loads and
* keeps it, so removing it would break the DNS split
*
* This list is shared with generate_client.uc, which has to create the
* "do not block startup" fallback file for each of them before the generator
* runs. The two used to be independent: route.uc hard-coded the entries and
* the CLI knew nothing about them, which is why a cold-cache first start
* blocked on downloading them and why a change to either side could not be
* checked against the other. One list, two readers.
*
* Not a conffile-order dependency - this is the generator's own declaration,
* in the order the generated rule_set array must keep so the emitted bytes
* stay stable. */
export const BUILTIN_REMOTE_RULE_SETS = [
{
tag: 'geoip-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h'
},
{
tag: 'geosite-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h'
}
];
/* The routing modes that declare them. Kept beside the list so the CLI can
* decide whether it needs to create their fallbacks without repeating the
* condition route.uc branches on - and so the two cannot drift. */
export function declaresBuiltinRemoteRuleSets(routing_mode) {
return routing_mode === 'bypass_mainland_china' || routing_mode === 'proxy_mainland_china';
};
/* Resolve the direct-node destination override that the route builder
* needs for a `direct` routing_node target. The override is recorded
* earlier by generate_outbound() (see outbound.uc) when a direct node
@@ -138,6 +138,33 @@ export function build_context(dm, env) {
* whole config. Default false - an absent env must not turn into an
* optimistic "assume the file is there". */
china_ip6_ready: (env?.china_ip6_ready === true),
/* Which enabled `type: local` rule-sets have a usable file on disk,
* keyed by UCI section name: { '<section>': true }.
*
* Same shape of problem as china_ip6_ready and for the same reason it
* lives in the CLI's env rather than under generator/ (guard 27): the
* answer is a filesystem fact, and a `path` naming a file that is not
* there makes `sing-box check` reject the whole configuration with
* sing-box's own "parse rule-set[0]: open ...: no such file or
* directory" - which names neither the rule-set the user created nor
* the directory they are supposed to copy files into. The generator
* turns a missing entry into a message that names both (ruleset.uc).
*
* The default is pessimistic for the same reason: an env that forgot
* the field (the server path, a future caller) must not turn "no file"
* into "assume the file is there". */
ruleset_local_ready: (env?.ruleset_local_ready || {}),
/* What each rule-set's file actually is, keyed by UCI section name.
*
* Reaches the generator as an ordinary field for the same reason
* china_ip6_ready does - and with the same pessimistic default, which
* matters more here. For china_ip6_ready a missing field meant "no
* file"; here it means "no opinion about the content", and the
* generator must read that as "leave the declared format alone".
* Defaulting it to, say, 'source' would have every remote rule-set
* with no initial_path silently declared as source JSON, and a binary
* .srs fetched from a URL would then fail to parse at startup. */
ruleset_formats: (env?.ruleset_formats || {}),
self_mark, ntp_server, dns_port, mixed_port,
redirect_port, tproxy_port,
tun_name, tun_addr4, tun_addr6, tun_mtu,
@@ -229,6 +256,29 @@ export function build_context(dm, env) {
* Default '0' preserves the
* 300ms / default-list behaviour so an upgrade is invisible. */
sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0',
/* ruleset_safe_start: when '1' (opt-in), every enabled remote
* rule-set that has no initial_path of its own gets one, pointing at
* an EMPTY rule-set the generator writes before the config is
* checked. A remote rule-set with no initial file is fetched during
* initialization, before the inbounds bind, so on a cold cache the
* first start depends on the CDN being reachable - and on the node,
* because the fetch goes through http_clients.
*
* The fallback is empty on purpose and that is the trade: a rule-set
* with no rules matches nothing, so its traffic falls through to
* `final`. That is a routing change, not a robustness tweak, which
* is why the default is '0' and why the whole repository stays
* fail-loud by default. Guard 52 pins the default, the way guard 39
* pins sniffer_advanced_mode's. */
ruleset_safe_start: dm.general.ruleset_safe_start || '0',
/* Which empty startup fallbacks the CLI actually wrote, keyed by
* rule_set tag. Presence, not a path: the generator emits
* `initial_path` only for tags it can point at, so a fallback that
* could not be written leaves that rule-set on today's behaviour
* rather than pointing at a file that is not there. Pessimistic by
* construction - an env that forgot the field (the server path) has
* no fallbacks, and the server declares no remote rule-sets anyway. */
ruleset_initial: (env?.ruleset_initial || {}),
main_urltest_nodes: dm.general.main_urltest_nodes || [],
main_urltest_interval: dm.general.main_urltest_interval,
main_urltest_tolerance: dm.general.main_urltest_tolerance,
@@ -26,9 +26,9 @@
'use strict';
import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc';
import { isEmpty, strToInt, strToTime, strToBool, parse_port, ruleSetInitialFallback, HP_DIR } from '../homeproxy-pro.uc';
import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
/* --- shared initial block (every routing mode) ------------------------- */
@@ -216,8 +216,7 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
* to reach this point without it and sing-box refused the whole config
* with "initialize rule[3]: rule-set not found: geoip-cn", which the
* health gate turned into a rollback and an unproxied network. */
if (ctx.routing_mode === 'bypass_mainland_china'
|| ctx.routing_mode === 'proxy_mainland_china') {
if (declaresBuiltinRemoteRuleSets(ctx.routing_mode)) {
/*
* Fetched straight from the upstream SagerNet repositories and
* downloaded through the selected node. A direct fetch depends on
@@ -237,34 +236,47 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
* server: china-dns); the route layer never matches
* it. Sing-box still loads and keeps the rule-set in
* memory even when only one block references it, so
* removing it would also break the DNS split. It is
* only declared for bypass_mainland_china because that
* is the only mode whose DNS chain routes through
* china-dns.
* removing it would also break the DNS split.
*
* History: a third geosite-noncn used to be declared here as well,
* but no rule referenced it and sing-box still loaded and updated
* it daily, so it was removed (the comment above was rewritten
* during the 2026-09-29 review to clarify that geosite-cn is still
* in use - the DNS side - just not from this block).
*
* The list itself lives in common.uc, because the CLI has to create
* an "initial" fallback file for each of these before this generator
* runs - see the note on BUILTIN_REMOTE_RULE_SETS. That is the whole
* reason this block is a loop: these two are the rule-sets a cold
* install blocks on, so a change to them that only reached one of the
* two readers would put the fallback out of step with the config
* that points at it.
*/
push(config.route.rule_set, {
type: 'remote',
tag: 'geoip-cn',
format: 'binary',
url: 'https://v4.gh-proxy.org/https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
push(config.route.rule_set, {
type: 'remote',
tag: 'geosite-cn',
format: 'binary',
url: 'https://v4.gh-proxy.org/https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
for (let rs in BUILTIN_REMOTE_RULE_SETS) {
push(config.route.rule_set, {
type: 'remote',
tag: rs.tag,
format: rs.format,
url: rs.url,
update_interval: rs.update_interval,
/* Only with the opt-in on, and only for a fallback the CLI
* actually wrote. A remote rule-set with no initial_path is
* fetched during initialization, before the inbounds bind, so
* a cold cache makes the first start depend on the CDN - and
* on the node, since these go through http_clients. The
* fallback is an EMPTY rule-set, which changes routing for
* everything it would have matched, so it stays off unless
* the user asks for it.
*
* These two are the rule-sets a fresh install blocks on, so
* they are also the whole point of the feature: an earlier
* version of this block only knew how to build the entries,
* and the CLI had no idea they existed. */
initial_path: (ctx.ruleset_safe_start === '1' && ctx.ruleset_initial[rs.tag])
? ruleSetInitialFallback([ rs.tag ], rs.format, rs.url) : null,
download_detour: 'main-out'
});
}
/* Local, and not downloaded: it is generated from the same
* china_ip4.txt the firewall renders homeproxy_mainland_addr_v4
* from, so the two cannot drift apart. `type: local` is watched by
@@ -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);
}
};
@@ -118,6 +118,330 @@ export function validateCertificatePath(p) {
return false;
};
/* Rule-set source files: the `path` of a `type: local` rule_set and the
* `initial_path` of a `type: remote` one. sing-box opens both as root, so
* the threat model is the certificate one - an arbitrary UCI value would leak
* the file to anyone who can write the UCI tree from anywhere on the LAN.
*
* Unlike a certificate, a rule-set has exactly one home: the archive this
* package creates at install time (uci-defaults/luci-homeproxy-pro) and before
* every generation (runtime/service.sh's hp_prepare_ruleset_dir). So the
* policy is a single root rather than the three a certificate needs, and it
* is deliberately NARROWER than validateHomeProxyPath() - that gate also
* accepts /tmp/homeproxy_*, which exists for upload staging, and every path
* under /etc/homeproxy-pro/ including the resource lists the package itself
* rewrites. A rule-set belongs in the archive (issue #7, review 4.4/4.7).
*
* Kept as a third policy rather than folded into validateHomeProxyPath() for
* the reason validateCertificatePath() is: the policies have to be
* independently narrowable, and folding them back together is exactly how the
* certificate one regressed once already - a path the UI offered was accepted
* there and silently dropped here, so the TLS listener failed with nothing
* pointing at the path.
*
* The list is mirrored by HP_RULE_PATH_ROOTS in
* htdocs/luci-static/resources/homeproxy-pro.js; guard 50 in tests/arch-guard.sh
* keeps the two in step across the JS/ucode boundary, which nothing else can
* see. */
export const RULE_PATH_ROOTS = ['/etc/homeproxy-pro/ruleset/'];
/* validateRuleSetPath(p) - the rule-set path gate.
*
* Same shape as validateCertificatePath(): reject traversal and relative
* paths, then require one of RULE_PATH_ROOTS. A bare root ("/etc/homeproxy-pro/
* ruleset/" with nothing after it) is rejected - it names the directory, not
* a file. */
export function validateRuleSetPath(p) {
if (!p || type(p) !== 'string')
return false;
/* Reject traversal *before* the prefix checks: a plain prefix comparison
* accepts '/etc/homeproxy-pro/ruleset/../../etc/shadow', and sing-box reads
* these paths as root. */
if (match(p, /(^|\/)\.\.(\/|$)/))
return false;
/* Reject anything that does not start with '/' - a relative path in
* sing-box resolves against the process CWD. */
if (substr(p, 0, 1) !== '/')
return false;
for (let root in RULE_PATH_ROOTS)
if (length(p) > length(root) && substr(p, 0, length(root)) === root)
return true;
return false;
};
/* RULE_PATH_ROOTS rendered for a diagnostic.
*
* join(', ', RULE_PATH_ROOTS) would do this in one line - it is the form
* firewall_utils.uc uses for its own lists, and ucode's join() does flatten an
* array argument rather than stringifying it. The loop is kept anyway: it
* cannot be wrong about the separator, and this string ends up inside a die()
* a user reads in a log file. */
export function rulePathRootsText() {
let text = '';
for (let root in RULE_PATH_ROOTS)
text = text ? (text + ', ' + root) : root;
return text;
};
/* --- rule-set format probing ---------------------------------------------
*
* A rule-set comes in exactly two formats, and sing-box needs to be told
* which one it is looking at:
*
* source a JSON document, {"version":3,"rules":[...]}
* binary a compiled .srs, which starts with the three ASCII bytes "SRS"
*
* The field is optional, because sing-box infers it from the file extension
* (.json -> source, .srs -> binary). That inference is a guess about the
* NAME, and it is wrong in three ways that all end the same way - `sing-box
* check` rejects the whole configuration, so a reload is aborted and the user
* is left with "my change did not take":
*
* content disagrees with the name example.srs holding JSON -> the file
* is parsed as SRS and fails
* the field says the wrong thing format=binary on a .json -> same
* there is no name to infer from a file with no extension -> "missing
* format", a different error for the
* same underlying mistake
*
* So the correction is to look at the bytes. The functions below are split
* in two on purpose: ruleSetFormatFromBytes() is a pure function of what it
* is handed, so the decision is unit-testable and auditable, and
* probeRuleSetFile() is the only part that touches the disk. It is called
* from the CLI's resolve_env(), never from generator/ (guard 27), and its
* verdict reaches the generator as an ordinary context field.
*
* Nothing here ever *guesses*: an unreadable, empty or unclassifiable file
* yields no verdict at all, and the declared format is left exactly as the
* user wrote it. sing-box stays the authority, and auto-correction can
* never become a second source of wrongness. */
/* How many leading bytes ruleSetFormatFromBytes() needs. Three for the magic,
* plus room to skip leading whitespace before the first JSON brace - a file
* written by a text editor or a Windows tool routinely starts with "\r\n".
* 512 is far more than the decision can use and far less than any real
* rule-set, so reading it is one short read, not a load. */
export const RULESET_PROBE_BYTES = 512;
/* ruleSetFormatFromBytes(head, size) -> 'binary' | 'source' | null.
*
* `head` is the leading bytes as a string (ucode fs.read hands them over as
* one), `size` the whole file's size. Returns null when the bytes do not
* identify a format - an empty file, or content that is neither SRS nor JSON
* (a truncated download, an HTML error page served with a .srs name). */
export function ruleSetFormatFromBytes(head, size) {
/* Empty is not "source" and not "binary": sing-box rejects a 0-byte
* local rule-set with "invalid sing-box rule-set file" whatever format
* is declared, so no verdict is the honest answer here and declaring one
* would not help. */
if (!size || !head)
return null;
/* The SRS magic, compared byte by byte.
*
* isBinary() must NOT be reused here, and the reason is worth being exact
* about, because the obvious one is wrong: isBinary() does not say "not
* SRS", so it cannot be used as a negative test. It answers a different
* question - "does this look like text?" - and for rule-sets that answer
* is only *correlated* with the format, never equal to it:
*
* a full .srs happens to read as binary, because the version byte and
* the deflate stream carry control bytes. That is a property of this
* week's encoder, not of the format, and it only holds because the
* probe happens to read enough of the file;
* a negative is ambiguous - binary-but-not-SRS, or text that is not
* JSON - and those need different answers.
*
* The magic is the format's own identity, costs three bytes, and gives
* null for anything it does not recognise. That last part is the point:
* this probe declines to have an opinion rather than guessing. */
if (length(head) >= 3 &&
ord(head, 0) == 0x53 && ord(head, 1) == 0x52 && ord(head, 2) == 0x53)
return 'binary';
/* Source is JSON, so the first byte that is not whitespace decides.
* Both { (an object) and [ (an array) are accepted: sing-box's own
* source form is an object, but an array is still "this is JSON" and
* refusing to say so would only hand the decision back to a guess. */
const lead = trim(head);
if (length(lead) && (substr(lead, 0, 1) == '{' || substr(lead, 0, 1) == '['))
return 'source';
return null;
};
/* ruleSetFormatFromPath(path) -> 'binary' | 'source' | null.
*
* What sing-box's extension inference would decide for this name, expressed
* as the same two values. null means "no extension sing-box can infer from",
* which is the case that produces "missing format" rather than a wrong parse -
* and the reason a content probe is worth doing at all.
*
* Deliberately NOT a general extension parser: only the two suffixes sing-box
* acts on are named, so this cannot drift into claiming it knows sing-box's
* rules. A {tag} placeholder does not affect the answer, since the suffix
* comes after it. */
export function ruleSetFormatFromPath(path) {
if (!path || type(path) !== 'string')
return null;
if (match(path, /\.json$/))
return 'source';
if (match(path, /\.srs$/))
return 'binary';
return null;
};
/* --- the "do not block startup on the first download" fallback ---------- *
*
* A remote rule-set with no `initial_path` is fetched during
* initialization, BEFORE the inbounds bind. On a cold cache that means a
* fresh install cannot start until raw.githubusercontent.com answers - and if
* it does not, the whole instance dies with a FATAL and the health gate turns
* that into a rollback or a released intercept layer. The node has to be up
* too, because these downloads go through http_clients, so a working
* configuration can be kept off the air by an unreachable CDN.
*
* `initial_path` is the way out: sing-box reads the file first, starts
* immediately, and refreshes from the URL in the background. A download that
* keeps failing then becomes an ERROR line in the log instead of a failed
* start - and the health gate does not look at rule-sets at all, so a
* degraded rule-set is promoted like any other healthy start.
*
* What the file holds is an EMPTY rule-set, and that is the whole trade: a
* rule-set with no rules matches nothing, so its traffic falls through to
* `final`. That changes routing, so this is opt-in (config.ruleset_safe_start,
* default off) and never the default.
*
* The format has to match the declared one. A binary rule-set fed a source
* JSON is not read at all - sing-box ignores the mismatch and blocks exactly
* as it does with no initial_path - so the decision of whether a fallback is
* even possible is made once, here, from the same inputs on both sides. */
/* Where the generated fallbacks live. Inside the rule-set archive, so
* validateRuleSetPath() admits it and the jail's read-only mount of HP_DIR
* already exposes it to the sing-box user. */
export const RULESET_INITIAL_DIR = HP_DIR + '/ruleset/initial';
/* The two files the package ships for this. empty.source.json is what
* `sing-box rule-set compile` consumes (24 bytes, readable, and the form
* every version agrees on); empty.srs is the compiled result of THAT source
* on 1.14.2, kept as the last resort for a router where the compile cannot
* run.
*
* The .srs is committed rather than built at package time on purpose: it is a
* 14-byte binary, and having it in the tree means `hexdump -C` on a router
* can confirm byte for byte that the shipped constant is the documented one.
* It is deliberately NOT a conffile - it is ours, not the user's, and an
* upgrade may replace it freely.
*
* Compatibility note, since a hard-coded binary artifact invites the question:
* sing-box only rejects an SRS whose version is NEWER than its own, so a
* version-2 file stays readable. The compile path exists anyway, because a
* file produced by the kernel that is running it cannot drift from it. */
export const RULESET_EMPTY_SOURCE = RULESET_INITIAL_DIR + '/empty.source.json';
export const RULESET_EMPTY_BINARY = RULESET_INITIAL_DIR + '/empty.srs';
/* A tag becomes a file name, so it has to be one. UCI section names are
* already restricted, but a rule_set `tag` is a sing-box identifier that UCI
* does not fully own, and this value is about to be concatenated into a path
* the generator writes as root. Anything outside this set is refused, and
* the caller skips the fallback rather than sanitising it into a name that
* could collide with another rule-set's. */
export function isSafeRuleSetTag(tag) {
if (!tag || type(tag) !== 'string')
return false;
return match(tag, /^[A-Za-z0-9][A-Za-z0-9._-]*$/) != null;
};
/* The extension a format is stored under. Null for anything that is not one
* of the two formats, which is the caller's signal that no fallback is
* possible rather than something to guess at. */
export function ruleSetFormatExtension(format) {
if (format === 'binary')
return 'srs';
if (format === 'source')
return 'json';
return null;
};
/* ruleSetInitialFallback(tags, declared, url) -> the initial_path to emit, or
* null when no fallback is possible.
*
* One function, called from both sides on purpose. The CLI asks it to
* decide which files to create and the generator asks it where to point
* `initial_path`; if they each worked it out separately they could disagree
* about the format, and a mismatch is the failure this whole mechanism exists
* to avoid - sing-box ignores a wrong-format initial file and blocks startup
* exactly as if there were none.
*
* `declared` is the rule-set's own `format`, which for a rule-set with no
* initial file is also the format the generator emits (nothing to probe, so
* nothing is corrected). With no declared format the URL's extension is the
* only remaining evidence; with neither, the answer is null and the rule-set
* keeps today's behaviour rather than being handed a file of the wrong shape.
*
* More than one tag gets a `{tag}` placeholder, because that is what sing-box
* substitutes and it requires every tag's file to exist - P3 on 1.14.2: a
* literal "{tag}.srs" on disk makes it try to download and block. */
export function ruleSetInitialFallback(tags, declared, url) {
const format = declared || ruleSetFormatFromPath(url);
const ext = ruleSetFormatExtension(format);
if (!ext)
return null;
for (let tag in (tags || []))
if (!isSafeRuleSetTag(tag))
return null;
const stem = (length(tags || []) > 1) ? '{tag}' : tags[0];
return RULESET_INITIAL_DIR + '/' + stem + '.' + ext;
};
/* probeRuleSetFile(path) -> 'binary' | 'source' | null.
*
* The disk half. Returns null - meaning "no opinion" - for a path outside the
* rule-set policy, a missing/empty/non-regular file, an unreadable one, and
* for content it cannot classify. The policy check is not redundant with
* ruleset.uc's: this runs BEFORE the generator, on paths that have not been
* vetted yet, and it is a reader.
*
* lstat() rather than the two-argument access() - see the note in
* generate_client.uc's china_ip6_ready about this ucode build answering only
* in its one-argument form. */
export function probeRuleSetFile(path) {
if (!validateRuleSetPath(path))
return null;
const st = lstat(path);
if (!st || st.type !== 'file' || st.size <= 0)
return null;
const f = open(path);
if (!f)
return null;
/* The `?? ''` is the same defensive read read_capped() uses: a short or
* failed read must not become a null the caller has to reason about. */
const head = f.read(RULESET_PROBE_BYTES) ?? '';
f.close();
return ruleSetFormatFromBytes(head, st.size);
};
/* Read at most `limit` bytes from a file, or '' when it does not exist.
* The cap is deliberate: a command's output is not trustworthy input. */
function read_capped(path, limit) {
@@ -307,6 +631,28 @@ export function redactReason(reason) {
* reason is wget's own stderr (whitespace collapsed, length-capped) so the
* caller can tell a DNS failure from a timeout or a TLS handshake error.
*/
/* fetchBinary() -> the path to invoke the fetch layer with.
*
* uclient-fetch is base OpenWrt, and it lives in /bin on every target measured
* so far - but "base package" is a packaging fact, not a path guarantee, and
* the whole point of the switch is not to hard-code an assumption that some
* buildroot can violate. So: the two known locations first, then PATH as the
* fallback, which is also what makes the test suite able to shadow it with a
* stub.
*
* Declared before wGETVerbose() on purpose - see the note above
* redactReason() about ucode not hoisting exported functions. */
export function fetchBinary() {
for (let p in [ '/bin/uclient-fetch', '/usr/bin/uclient-fetch' ]) {
/* One-argument access() is the only form this ucode build answers;
* see the china_ip6_ready note in generate_client.uc. */
if (access(p))
return p;
}
return 'uclient-fetch';
};
export function wGETVerbose(url, ua) {
if (!url || type(url) !== 'string')
return { content: null, error: 'invalid URL' };
@@ -314,37 +660,73 @@ export function wGETVerbose(url, ua) {
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
/* -nv (not -q) so wget still reports *why* a fetch failed on stderr.
/* Why uclient-fetch and not wget: `wget` is whatever the firmware's
* buildroot happened to compile, and the two implementations do not share
* a single option beyond -O. A command line that works on one of them
* exits 2 on the other with "unrecognized option" *before making a single
* request*, which is how every subscription fetch, every resource-list
* update and the connectivity check failed on a busybox-wget router while
* the suite stayed green - the guard below only ever ran against GNU
* wget, on CI and on the maintainer's own device.
*
* The size cap is enforced by piping through `head -c`, NOT with wget's
* --max-filesize: that option does not exist in busybox wget (the target's
* /usr/bin/wget), which exits 2 with "unrecognized option" before making a
* single request - so every subscription fetch failed. GNU wget has no
* such option either. `head` closing the pipe stops wget early, which
* bounds the download; the cap is therefore CAP+1 bytes rather than
* exactly CAP, and one byte past the limit means "too large".
* uclient-fetch is the fetcher OpenWrt itself ships and drives (opkg,
* sysupgrade, uci), with an option set fixed by the applet rather than by
* the buildroot, so one command line is correct on every target. Its
* interface was read off the applet on the device, not from memory:
*
* -O <file> stdout is "-"
* --user-agent <str> -U
* --timeout=N | -T N seconds, same unit as the --timeout= it replaces
* --spider | -s existence check only
* --header='K: V' note the '=': the space-separated form is not
* accepted, and the value is ONE argv element
*
* No --quiet, deliberately: the point of capturing stderr is to report
* *why* a fetch failed, and uclient-fetch's default stderr is better than
* wget's -nv was - on an HTTP error it prints
*
* Downloading 'https://…/x.srs?token=…'
* Connecting to 185.199.111.133:443
* HTTP error 404
*
* including the resolved address. The URL is in there, so this depends on
* redactReason() below; guard 14 is what keeps that honest. Quiet mode
* would have removed the URL from the message and, with it, the only
* thing distinguishing an HTTP 404 from a connect failure.
*
* The size cap is still enforced by piping through `head -c`, not by a
* fetcher option: there is no such option in either implementation, and
* `head` closing the pipe stops the download early. The cap is therefore
* CAP+1 bytes rather than exactly CAP, and one byte past the limit means
* "too large".
*
* 5 MiB covers a 10 000-node subscription with ~3 KB per node plus the
* base64 inflation. Anything larger is almost certainly an attack or a
* misconfiguration.
*
* The pipeline does cost the exit status: `system()` returns head's, which
* is always 0. A wget failure therefore arrives as an empty body plus
* wget's own message on stderr, and that is reported below. */
* is always 0. A fetch failure therefore arrives as an empty body plus the
* fetcher's own message on stderr, and that is reported below. */
/* The braces matter: executeCommand() appends `>out 2>err` to the command,
* and in `a | b >out 2>err` those redirections bind to b only - wget's
* stderr would go to the caller's terminal and the failure message would
* be lost. Grouping the pipeline makes both stream to the capture files. */
const output = executeCommand(`{ /usr/bin/wget -nv -O- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
* and in `a | b >out 2>err` those redirections bind to b only - the
* fetcher's stderr would go to the caller's terminal and the failure
* message would be lost. Grouping the pipeline makes both stream to the
* capture files.
*
* fetchBinary() is shellQuote()d like everything else here rather than
* being waved through on the grounds that it only ever returns a literal:
* guard 25 exists to make "is this shell-safe" a mechanical check instead
* of a judgement call, and a quoted constant costs nothing. */
const output = executeCommand(`{ ${shellQuote(fetchBinary())} -O - --user-agent=${shellQuote(ua)} --timeout=10 ${shellQuote(url)} | head -c ${HP_FETCH_CAP + 1}; }`) || {};
let reason = trim(output.stderr || '');
reason = reason ? replace(reason, /\s+/g, ' ') : '';
/* Review H3: an HTTP-level wget failure echoes the full URL - query
* string and subscription token included - as in
* `https://host/path?token=secret: 404 Not Found`. (A pure connection
* failure prints only `failed: Connection refused.` and leaks nothing,
* but the 404/403 case is enough.) Redact at the source so every caller
* of wGETVerbose gets a safe `error` whether or not it remembers to call
* redactUrl itself. */
/* An HTTP-level failure prints the requested URL back into the message
* with its query string and subscription token attached, as in
* `Downloading 'https://host/path?token=secret'` + `HTTP error 404`.
* (A pure connection failure prints only `Failed to send request: …` and
* leaks nothing, but the 404/403 case is enough.) Redact at the source
* so every caller of wGETVerbose gets a safe `error` whether or not it
* remembers to call redactUrl itself. */
if (reason)
reason = redactReason(reason);
@@ -355,15 +737,30 @@ export function wGETVerbose(url, ua) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `wget exited with status ${output.exitcode}: ${reason || 'no error output'}` };
return { content: null, error: `fetch exited with status ${output.exitcode}: ${reason || 'no error output'}` };
}
/* head() masks wget's status, so a failed fetch shows up here instead. */
/* A binary body is not a failed fetch.
*
* executeCommand() nulls stdout for binary content - a subscription body
* that is not text cannot be parsed by anything downstream - and without
* this branch the "no content, but stderr said something" test below
* reported the result as
*
* fetch failed: Downloading '…' … Download completed (34185 bytes)
*
* which contradicts itself, and which would send a user looking for a
* network problem they do not have. Measured on a device, with a real
* .srs fetched from a URL that answered 200. */
if (output.binary)
return { content: null, error: 'the fetch succeeded but the response is binary, not a subscription payload' };
/* head() masks the fetcher's status, so a failed fetch shows up here. */
if (!length(trim(output.stdout)) && reason) {
if (length(reason) > 200)
reason = substr(reason, 0, 200) + '...';
return { content: null, error: `wget failed: ${reason}` };
return { content: null, error: `fetch failed: ${reason}` };
}
return { content: trim(output.stdout), error: null };
@@ -156,6 +156,39 @@ hp_clear_autoupdate_cron() {
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
}
# hp_prepare_ruleset_dir <hp-dir>
# Create the rule-set archive, /etc/homeproxy-pro/ruleset.
#
# Called BEFORE the client configuration is generated, not only from
# hp_prepare_runtime_files. Generation runs `sing-box check` over the result
# and check opens every local rule_set path, so on a fresh install the most
# natural path - "switch to custom routing, add a local rule-set, point it at
# /etc/homeproxy-pro/ruleset/example.srs" - was guaranteed to fail: hp_prepare_
# runtime_files only created the directory for a custom-mode start that had
# already got that far, and it runs after the generation, not before it. The
# user got sing-box's own
#
# parse rule-set[0]: open /etc/homeproxy-pro/ruleset/example.srs: no such file
#
# and the reload reported only "new client configuration is invalid, reload
# aborted" - with the directory the UI points at not existing yet.
#
# The directory is also created at install time (uci-defaults/luci-homeproxy-pro)
# so a router that has never started the service still has it; this call is
# what covers an upgrade from a version that predates that, and a user who
# removed it by hand.
#
# Ownership is NOT done here. The jailed client runs as the sing-box user and
# has to read the files, but it starts after hp_prepare_runtime_files, which is
# where the recursive chown lives; doing it here as well would only add a
# second pass over the directory on every start.
hp_prepare_ruleset_dir() {
local hp_dir="$1"
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|| log "Warning: failed to create ${hp_dir}/ruleset."
}
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
# Create the mode-specific working files, truncate the instance logs and hand
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
@@ -229,13 +262,17 @@ hp_prepare_runtime_files() {
esac
if [ "$routing_mode" = "custom" ]; then
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|| log "Warning: failed to create ${hp_dir}/ruleset."
# The directory itself is created earlier - see hp_prepare_ruleset_dir()
# for why generation cannot wait for this function. Re-running the
# idempotent mkdir here costs nothing and keeps this function
# self-contained for any caller that reaches it directly.
hp_prepare_ruleset_dir "$hp_dir"
# A local rule-set is opened by the client, which no longer runs as
# root now that the jail is on for this mode too, so the files have to
# be readable by the sing-box user. Only the documented directory is
# claimed here; a rule-set placed elsewhere under HP_DIR has to be
# readable already.
# be readable by the sing-box user. Only the documented archive is
# claimed here, and it is the only directory validateRuleSetPath()
# admits; a rule-set placed elsewhere under HP_DIR is not a rule-set
# the generator will emit any more.
chown -R sing-box:sing-box "$hp_dir/ruleset" 2>"/dev/null" \
|| log "Warning: failed to hand ${hp_dir}/ruleset to sing-box."
fi
@@ -61,7 +61,20 @@ pick_mirror() {
else
local probe_url="https://$base/gh/$listrepo@$list_sha/$listname"
fi
if wget --timeout=10 --spider -q "$probe_url" 2>"/dev/null"; then
# uclient-fetch, not wget. wget is whichever implementation the
# buildroot compiled, and the two share almost no options: on a
# busybox-wget router every flag here is rejected with "unrecognized
# option" before a request is made, so the resource lists silently
# never updated - pick_mirror returns 1 and the caller carries on.
# uclient-fetch is the fetcher OpenWrt itself uses and its option set
# is fixed by the applet, not by the buildroot. Read off the applet
# on the device: --spider checks existence only, --timeout=N is
# seconds. -q because this probe's stderr goes nowhere anyway.
#
# Resolved through PATH rather than spelled /bin/..., exactly as this
# script used to spell a bare `wget`: it keeps the name overridable in
# the test sandbox, which is how the option list gets exercised at all.
if uclient-fetch -q -s --timeout=10 "$probe_url" 2>"/dev/null"; then
printf '%s\n' "$base"
return 0
fi
@@ -76,7 +89,7 @@ check_list_update() {
local listname="$4"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local github_token="$(uci -q get homeproxy-pro.config.github_token)"
local wget="wget --timeout=10 -q"
local fetch="uclient-fetch -q --timeout=10"
# fd 9, not 200: POSIX only guarantees 0-9, and dash fails the whole
# `exec 200>"$lock"` with "exec: 200: not found". busybox ash and bash
@@ -90,21 +103,23 @@ check_list_update() {
fi
# The token travels in argv, which the previous form avoided by writing it
# to a 0600 file and passing --header-file - an option neither busybox nor
# GNU wget has, so it failed with "unrecognized option" and the version
# query never worked at all with a token configured. There is no
# file-based header option in either wget, so argv is the only way; on a
# to a 0600 file and passing --header-file - an option no fetch
# implementation has, so it failed with "unrecognized option" and the
# version query never worked at all with a token configured. uclient-fetch
# has no file-based header option either, so argv is the only way; on a
# single-user router that is the right trade for a feature that otherwise
# does not function. `wget` is invoked with the header only when a token
# is set, and nothing logs the command line.
# does not function. The header is passed only when a token is set, and
# nothing logs the command line.
local github_header=""
[ -n "$github_token" ] && github_header="Authorization: Bearer $github_token"
local list_info="$($wget ${github_header:+--header "$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local wget_exit=$?
# --header takes '=': the space-separated form is rejected, and the value
# has to stay ONE argv element, which the quoting around it preserves.
local list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
local fetch_exit=$?
if [ $wget_exit -ne 0 ]; then
log "[$(to_upper "$listtype")] Failed to fetch version info (wget exit $wget_exit)."
if [ $fetch_exit -ne 0 ]; then
log "[$(to_upper "$listtype")] Failed to fetch version info (fetch exit $fetch_exit)."
return 1
fi
local list_sha="$(printf '%s' "$list_info" | jsonfilter -qe "@[0].sha")"
@@ -144,7 +159,7 @@ check_list_update() {
fi
log "[$(to_upper "$listtype")] Downloading from $mirror."
if ! $wget -O "$RUN_DIR/$listname" "$mirror_url" || [ ! -s "$RUN_DIR/$listname" ]; then
if ! $fetch -O "$RUN_DIR/$listname" "$mirror_url" || [ ! -s "$RUN_DIR/$listname" ]; then
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Download failed ($mirror)."
return 1
@@ -162,7 +177,7 @@ check_list_update() {
# resources before this check either; the alternative - installing bytes
# nobody vouched for - is the thing being fixed.
local api_blob local_blob
api_blob="$($wget ${github_header:+--header "$github_header"} -O- \
api_blob="$($fetch ${github_header:+--header="$github_header"} -O- \
"https://api.github.com/repos/$listrepo/contents/$listname?ref=$list_sha" \
| jsonfilter -qe '@.sha')"
if [ -z "$api_blob" ]; then
@@ -221,7 +221,7 @@ function main() {
/* Keep the lock's mtime current for as long as this run is alive.
* The stale window (LOCK_STALE) is what lets a later run reclaim a
* lock a killed process left behind, but nothing refreshed it while
* the fetch was in progress - and the fetch is one wget per URL,
* the fetch was in progress - and the fetch is one request per URL,
* ten seconds each. A run that crosses the threshold while still
* working can therefore have its lock broken underneath it, and the
* two runs then interleave their read-modify-write exactly like the