mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 12:33:56 +08:00
🐶 Sync 2026-10-03 11:32:07
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user