mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 03:23:56 +08:00
🎈 Sync 2026-10-04 14:38:18
This commit is contained in:
1 parent
0e6611f767
commit
aba12667c5
88 files changed
+4600
-1458
No files matched your search
@@ -55,7 +55,6 @@ config homeproxy-pro 'config'
|
||||
# With it off, a cold cache on a fresh install waits for
|
||||
# raw.githubusercontent.com (through the selected node) before the instance
|
||||
# will listen at all, and a failure there fails the whole start.
|
||||
option ruleset_safe_start '0'
|
||||
option tun_dns_mode ''
|
||||
option udp_mapping ''
|
||||
option udp_filtering ''
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"rules":[]}
|
||||
Binary file not shown.
@@ -404,7 +404,6 @@ export const Loader = {
|
||||
* 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'),
|
||||
|
||||
@@ -59,6 +59,49 @@ function resolve_ipv6(str) {
|
||||
return ipv6.addr;
|
||||
}
|
||||
|
||||
/* The node server addresses the generator exported (generate_client.uc's
|
||||
* export_node_addresses()), as an nftables anonymous set - or null when there
|
||||
* is nothing to protect.
|
||||
*
|
||||
* Only the literal-address form is rendered here. A node configured with a
|
||||
* host name reaches the same set at runtime: the dnsmasq snippet runtime/dns.sh
|
||||
* writes resolves the name and back-fills the set through `nftset=`, so one
|
||||
* mechanism covers both without this template needing a resolver of its own.
|
||||
* A literal address has no name to resolve, so it can only come from here.
|
||||
*
|
||||
* readfile() rather than a UCI read on purpose: the generator is the only
|
||||
* place that knows which nodes are actually in use (main_node, main_udp_node,
|
||||
* urltest membership, custom-mode routing_nodes, wireguard endpoints), and a
|
||||
* copy of that selection in this template is a copy that can drift. A missing
|
||||
* file renders null, which is the behaviour before this existed. */
|
||||
function node_addr_to_nftarr(run_dir, family) {
|
||||
let raw = readfile(run_dir + '/node-addr-ips.txt');
|
||||
if (isEmpty(raw))
|
||||
return null;
|
||||
|
||||
let out = [];
|
||||
for (let entry in split(trim(raw), /[\r\n]/)) {
|
||||
let parsed = fw4.parse_subnet(entry)?.[0];
|
||||
if (!parsed || parsed.family !== family)
|
||||
continue;
|
||||
push(out, (parsed.bits > -1) ? `${parsed.addr}/${parsed.bits}` : parsed.addr);
|
||||
}
|
||||
|
||||
return isEmpty(out) ? null : array_to_nftarr(out);
|
||||
}
|
||||
|
||||
/* Whether the node bypass has anything to protect at all.
|
||||
|
||||
Either export file counts. The domains file is the one that matters most:
|
||||
a host-name node - the common case, and the one the device this was written
|
||||
for uses - appears only there, and its set has to exist before dnsmasq can
|
||||
put anything in it. Gating on the literal-address file instead is what left
|
||||
the whole mechanism doing nothing. */
|
||||
function node_addr_protected(run_dir) {
|
||||
return !isEmpty(readfile(run_dir + '/node-addr-ips.txt'))
|
||||
|| !isEmpty(readfile(run_dir + '/node-addr-domains.txt'));
|
||||
}
|
||||
|
||||
/* Normalize a list of IPv6 entries into an nftables anonymous set
|
||||
(or null when empty), so IPv6 ACLs match in a single set lookup
|
||||
instead of one rule per address. */
|
||||
@@ -140,6 +183,26 @@ function dnsmasq_nftset() {
|
||||
|
||||
/* Misc config */
|
||||
const resources_dir = '/etc/homeproxy-pro/resources';
|
||||
const run_dir = '/var/run/homeproxy-pro';
|
||||
|
||||
/* Resolved here, in the ucode block, rather than with `{% set %}` in the
|
||||
* template body below: utpl's `set` tag takes a plain expression and rejects a
|
||||
* call outright - `{% set x = f(); %}` is a parse error ("Unexpected token,
|
||||
* Expecting ';'"). A parse error kills the whole render, fw4_post.nft comes
|
||||
* out empty, and the health gate silently keeps the previous ruleset, so a
|
||||
* template that fails this way does not look broken at all. r41 shipped
|
||||
* `{% set node_addr_v4 = node_addr_to_nftarr(4); %}` and never rendered once.
|
||||
*
|
||||
* Both helpers take run_dir as a parameter rather than closing over it. In
|
||||
* this ucode a closure captures the block variables that already exist where
|
||||
* the function is DEFINED, not where it is called: a function defined above
|
||||
* `const run_dir` cannot see run_dir even when it is called far below, and
|
||||
* the error is "Reference error: access to undeclared variable run_dir" -
|
||||
* which reads as though run_dir were missing, when it is in fact declared 80
|
||||
* lines above the call. Passing it in is the only spelling that works. */
|
||||
const node_protect = node_addr_protected(run_dir);
|
||||
const node_addr_v4 = node_addr_to_nftarr(run_dir, 4);
|
||||
const node_addr_v6 = node_addr_to_nftarr(run_dir, 6);
|
||||
|
||||
/* UCI config start */
|
||||
const cfgname = 'homeproxy-pro';
|
||||
@@ -399,6 +462,42 @@ set homeproxy_routing_port {
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{# Node server addresses.
|
||||
Declared when the generator exported any node address at all - and NOT
|
||||
only when there is a literal address to put in it.
|
||||
|
||||
That distinction is the whole point of the set existing. A node configured
|
||||
with a host name contributes no element here; dnsmasq resolves the name and
|
||||
back-fills this set through the nftset= snippet runtime/dns.sh writes. A
|
||||
set that is declared only when it already has elements is therefore never
|
||||
declared for a host-name node, the back-fill has nothing to land in, and the
|
||||
return rules below - which are what actually exclude the address - are not
|
||||
emitted either. r41 gated both on the literal form and was inert for the
|
||||
configuration it was written for.
|
||||
|
||||
An empty set is harmless: nft accepts one with no elements, and the cost is
|
||||
one lookup per packet until the first answer back-fills it. #}
|
||||
{% if (node_protect): %}
|
||||
set homeproxy_node_addr_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (node_addr_v4): %}
|
||||
elements = { {{ node_addr_v4 }} }
|
||||
{% endif %}
|
||||
}
|
||||
{% endif %}
|
||||
{% if (node_protect && v6_handled): %}
|
||||
set homeproxy_node_addr_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
{% if (node_addr_v6): %}
|
||||
elements = { {{ node_addr_v6 }} }
|
||||
{% endif %}
|
||||
}
|
||||
{% endif /* node_addr_v6 */ %}
|
||||
|
||||
{# DNS hijack & TCP redirect #}
|
||||
chain dstnat {
|
||||
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||
@@ -461,6 +560,13 @@ chain homeproxy_redirect_lanac {
|
||||
chain homeproxy_redirect {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (node_protect): %}
|
||||
ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
{% if (node_protect && v6_handled): %}
|
||||
ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
|
||||
@@ -582,6 +688,13 @@ chain homeproxy_mangle_lanac {
|
||||
}
|
||||
|
||||
chain homeproxy_mangle_prerouting {
|
||||
{% if (node_protect): %}
|
||||
ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
{% if (node_protect && v6_handled): %}
|
||||
ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
|
||||
@@ -646,6 +759,13 @@ chain homeproxy_mangle_prerouting {
|
||||
chain homeproxy_mangle_output {
|
||||
meta mark {{ self_mark }} counter return
|
||||
|
||||
{% if (node_protect): %}
|
||||
ip daddr @homeproxy_node_addr_v4 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
{% if (node_protect && v6_handled): %}
|
||||
ip6 daddr @homeproxy_node_addr_v6 counter return comment "!{{ cfgname }}: node address"
|
||||
{% endif %}
|
||||
|
||||
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
|
||||
|
||||
@@ -44,5 +44,7 @@ HP_FW4_SETS="
|
||||
homeproxy_wan_proxy_addr_v6
|
||||
homeproxy_wan_direct_addr_v4
|
||||
homeproxy_wan_direct_addr_v6
|
||||
homeproxy_node_addr_v4
|
||||
homeproxy_node_addr_v6
|
||||
homeproxy_routing_port
|
||||
"
|
||||
@@ -29,8 +29,8 @@ import { lstat, mkdtemp, readfile, writefile } from 'fs';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate } from './generator/client.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';
|
||||
import { rule_set_tags } from './generator/common.uc';
|
||||
import { removeBlankAttrs, isEmpty, isValidCIDR, probeRuleSetFile, ruleSetFormatFromPath, shellQuote, validateRuleSetPath, validation, 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
|
||||
@@ -46,69 +46,87 @@ import { removeBlankAttrs, isEmpty, probeRuleSetFile, ruleSetFormatExtension, ru
|
||||
*
|
||||
* The extra parentheses around the ubus call keep the ?. chain guarded when
|
||||
* connect() returns null. */
|
||||
/* Write one EMPTY rule-set to <path>, in <format>.
|
||||
|
||||
/* Normalise one server address into `host`, or return null when there is
|
||||
* nothing usable. WireGuard peers and the plain outbounds disagree on the
|
||||
* field name and the IPv6 form, so this is the only place that knows both. */
|
||||
function node_host_of(v) {
|
||||
if (isEmpty(v))
|
||||
return null;
|
||||
|
||||
let host = trim(v);
|
||||
|
||||
/* `[fdfe::1]` is how an IPv6 literal arrives from a UCI value; the set
|
||||
* syntax wants the bare address. */
|
||||
if (length(host) > 2 && substr(host, 0, 1) === '[' && substr(host, -1, 1) === ']')
|
||||
host = substr(host, 1, -2);
|
||||
|
||||
return isEmpty(host) ? null : host;
|
||||
}
|
||||
|
||||
/* Export the node server addresses, split by form, into two files the
|
||||
* intercept layer reads:
|
||||
*
|
||||
* 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.
|
||||
* RUN_DIR/node-addr-ips.txt literal addresses, rendered straight into
|
||||
* the nft set by firewall_post.ut
|
||||
* RUN_DIR/node-addr-domains.txt host names, handed to dnsmasq as
|
||||
* `server=` + `nftset=` so the addresses they
|
||||
* resolve to land in the same set
|
||||
*
|
||||
* 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.
|
||||
* Why the set exists at all: sing-box's own outbound already carries
|
||||
* `routing_mark: self_mark` and every chain returns on that mark, so the
|
||||
* tunnel's connection to the node is never re-intercepted. The LAN's is not.
|
||||
* A client opening a connection to the node's own address - the panel, an SSH
|
||||
* session to the landing box, anything on a port the proxy covers - was
|
||||
* redirected into sing-box, which then dialled the very address it was asked
|
||||
* for through the tunnel to that same node: one extra hairpin hop, and the
|
||||
* node sees the connection arriving from itself.
|
||||
*
|
||||
* 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);
|
||||
* The generator is the only place that knows which nodes are actually in use
|
||||
* (main_node, main_udp_node, urltest membership, custom-mode routing_nodes,
|
||||
* wireguard endpoints), so it is the only place that can produce this list
|
||||
* without the firewall template re-deriving that selection and drifting.
|
||||
*
|
||||
* Failure is not fatal. A missing file means the set renders empty, which is
|
||||
* exactly the behaviour before this existed. */
|
||||
function export_node_addresses(config) {
|
||||
const addrs = [];
|
||||
const domains = [];
|
||||
|
||||
if (!ext)
|
||||
return false;
|
||||
const add = (v) => {
|
||||
const host = node_host_of(v);
|
||||
if (host === null)
|
||||
return;
|
||||
if (isValidCIDR(host, 4) || isValidCIDR(host, 6))
|
||||
push(addrs, host);
|
||||
else if (validation('hostname', host))
|
||||
push(domains, host);
|
||||
};
|
||||
|
||||
system('mkdir -p ' + shellQuote(RULESET_INITIAL_DIR));
|
||||
for (let ob in (config.outbounds || []))
|
||||
add(ob.server);
|
||||
|
||||
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;
|
||||
}
|
||||
/* WireGuard moved from `outbounds` to `endpoints` in 1.13, and the peer
|
||||
* address is the node there. */
|
||||
for (let ep in (config.endpoints || []))
|
||||
for (let peer in (ep.peers || []))
|
||||
add(peer.address);
|
||||
|
||||
const st = lstat(path);
|
||||
if (!st || st.type !== 'file' || st.size <= 0)
|
||||
return false;
|
||||
}
|
||||
const dump = (path, list) => {
|
||||
if (isEmpty(list))
|
||||
return;
|
||||
/* ucode's join() takes the separator FIRST: join(sep, list). With
|
||||
* the arguments the other way round it does not raise, it returns
|
||||
* null - and null + '\n' is the four-character string "null\n", so
|
||||
* the file comes out non-empty and every "is it there?" check on
|
||||
* it passes. That is exactly how a released r41 ended up with
|
||||
* dnsmasq resolving a host literally named "null". */
|
||||
if (writefile(path, join('\n', uniq(list)) + '\n') == null)
|
||||
warn(sprintf('homeproxy-pro: could not write %s\n', path));
|
||||
};
|
||||
|
||||
/* 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;
|
||||
dump(RUN_DIR + '/node-addr-ips.txt', addrs);
|
||||
dump(RUN_DIR + '/node-addr-domains.txt', domains);
|
||||
}
|
||||
|
||||
function resolve_env(dm) {
|
||||
@@ -147,6 +165,11 @@ function resolve_env(dm) {
|
||||
* 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,
|
||||
/* The DNS half of the same split: whether china-domain.json is
|
||||
* there. Same reason, same lstat(), same one-sided default - a
|
||||
* missing file has to leave the DNS rule out rather than name a path
|
||||
* that does not exist and fail the whole start. */
|
||||
china_domain_ready: lstat(HP_DIR + '/resources/china-domain.json') !== null,
|
||||
/* Which enabled `type: local` rule-sets have a usable file on disk,
|
||||
* keyed by UCI section name.
|
||||
*
|
||||
@@ -205,7 +228,6 @@ function resolve_env(dm) {
|
||||
/* 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') {
|
||||
@@ -302,92 +324,6 @@ function resolve_env(dm) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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]/) : [];
|
||||
@@ -432,6 +368,11 @@ if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* Only now, with a configuration sing-box has accepted: the firewall and the
|
||||
* dnsmasq snippets read the exported list, and they must never be pointed at
|
||||
* addresses from a configuration that does not start. */
|
||||
export_node_addresses(config);
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
exit(1);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty } from '../homeproxy-pro.uc';
|
||||
import { isEmpty, HP_DIR } from '../homeproxy-pro.uc';
|
||||
|
||||
import { ConfigQuery } from '../config/model.uc';
|
||||
|
||||
@@ -142,43 +142,42 @@ export function rule_set_tags(cfg) {
|
||||
return tags;
|
||||
};
|
||||
|
||||
/* BUILTIN_REMOTE_RULE_SETS - the remote rule-sets the generator declares
|
||||
* itself, in the modes that split on mainland China.
|
||||
/* The routing modes that split on mainland China, and the rule-sets they
|
||||
* declare.
|
||||
*
|
||||
* 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
|
||||
* The three of them - china-ip, china-ip6, china-domain - are local files the
|
||||
* resource updater generates, one per list, and each is declared only when
|
||||
* something references it and its file is on disk. route.uc owns that
|
||||
* decision because the conditions are per-tag (the address list in both
|
||||
* mainland modes, the v6 half only with IPv6 on, the domain list only in
|
||||
* bypass mode), so there is nothing here for a shared list to keep in step
|
||||
* with.
|
||||
*
|
||||
* 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.
|
||||
* They were `type: remote` entries pointing at SagerNet's sing-geoip and
|
||||
* sing-geosite repositories, and two things followed from that which the local
|
||||
* arrangement does not have:
|
||||
*
|
||||
* 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) {
|
||||
* - a cold start had to download them before the inbounds bind, over the
|
||||
* node (download_detour), so a first start - or a start after the cache
|
||||
* was cleared - depended on GitHub being reachable through the proxy it
|
||||
* was trying to bring up;
|
||||
* - the two halves of the same split read two different files. The kernel's
|
||||
* came from china_ip4.txt and the resolver's from a .srs, and the route.uc
|
||||
* comment about them disagreeing is the scar of patching that instead of
|
||||
* fixing it.
|
||||
*
|
||||
* Now the nft set and the sing-box rule-set are both generated from the same
|
||||
* list, on the same schedule, with nothing to download.
|
||||
*
|
||||
* Source JSON, not a compiled .srs: the updater writes the canonical form and
|
||||
* sing-box watches the file, so a new list is picked up in place. Compiling
|
||||
* first was measured rather than assumed - 111k domain suffixes cost 3.7 MB of
|
||||
* RSS in source form (44.9 MB baseline vs 48.7 MB loaded), which is not worth
|
||||
* a second artifact and a second failure mode. */
|
||||
/* The routing modes that split on mainland China. The China domain list is
|
||||
* only meaningful where the split is by domain: proxy_mainland_china decides
|
||||
* by address on the route side and leaves the resolver to the mode default. */
|
||||
export function declaresBuiltinRuleSets(routing_mode) {
|
||||
return routing_mode === 'bypass_mainland_china' || routing_mode === 'proxy_mainland_china';
|
||||
};
|
||||
|
||||
|
||||
@@ -138,6 +138,11 @@ 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),
|
||||
/* Whether china-domain.json is on disk. Same one-sided default as
|
||||
* the field above, for the same reason: it is a filesystem fact the
|
||||
* CLI resolves, and an absent env must not be read as "assume the
|
||||
* file is there". */
|
||||
china_domain_ready: (env?.china_domain_ready === true),
|
||||
/* Which enabled `type: local` rule-sets have a usable file on disk,
|
||||
* keyed by UCI section name: { '<section>': true }.
|
||||
*
|
||||
@@ -256,29 +261,6 @@ 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,
|
||||
|
||||
@@ -278,11 +278,22 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
server: 'default-dns'
|
||||
});
|
||||
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'geosite-cn',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
/* The China domain list, generated from china_list.txt - the same
|
||||
* schedule and the same updater as the address list the route and
|
||||
* firewall halves read. It replaced geosite-geolocation-cn.srs,
|
||||
* which had to be downloaded before the inbounds could bind.
|
||||
*
|
||||
* Only with the file there: a rule naming a tag nothing declares
|
||||
* fails the config, and a rule-set whose path is missing fails it
|
||||
* too, so both the declaration (route.uc) and this reference are
|
||||
* gated on the same lstat. */
|
||||
if (ctx.china_domain_ready) {
|
||||
push(config.dns.rules, {
|
||||
rule_set: 'china-domain',
|
||||
action: 'route',
|
||||
server: 'china-dns'
|
||||
});
|
||||
}
|
||||
|
||||
/* sing-box 1.14: restore CN-IP fallback via evaluate/match_response (opt-in) */
|
||||
if (ctx.cn_ip_fallback === '1') {
|
||||
@@ -292,18 +303,17 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
tag: 'cn-fallback'
|
||||
});
|
||||
/* The response is matched by the addresses the upstream returned,
|
||||
* and geoip-cn has no IPv6 in it, so with IPv6 support on a
|
||||
* mainland domain that resolved to an AAAA address was never
|
||||
* recognised as mainland and its answer kept coming from the
|
||||
* proxy resolver. Adding the v6 rule-set here is what makes the
|
||||
* DNS half of the split agree with the route half, which
|
||||
* classifies the same destinations through the same list
|
||||
* (route.uc: china-ip6). Both tags have to be declared before
|
||||
* this rule runs, which is why the list is built here rather than
|
||||
* inlined: a rule_set naming an undeclared tag fails the config.
|
||||
* china-ip6 is only declared when its file exists, so it is only
|
||||
* named here under the same condition. */
|
||||
const cn_fallback_sets = ['geoip-cn'];
|
||||
* and the address list has to be the one the route half uses for
|
||||
* the same decision to agree with it - so this names china-ip, not
|
||||
* a list of its own. It is the same reason the declaration lives
|
||||
* in common.uc.
|
||||
*
|
||||
* china-ip6 is added only when it is declared: the rule_set list
|
||||
* has to name declared tags only, or the config is rejected.
|
||||
* Without it a mainland domain that resolved to an AAAA address
|
||||
* is never recognised as mainland and its answer keeps coming
|
||||
* from the proxy resolver. */
|
||||
const cn_fallback_sets = ['china-ip'];
|
||||
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready)
|
||||
push(cn_fallback_sets, 'china-ip6');
|
||||
push(config.dns.rules, {
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port, ruleSetInitialFallback, HP_DIR } from '../homeproxy-pro.uc';
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc';
|
||||
|
||||
import { BUILTIN_REMOTE_RULE_SETS, declaresBuiltinRemoteRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
|
||||
import { declaresBuiltinRuleSets, get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
|
||||
|
||||
/* --- shared initial block (every routing mode) ------------------------- */
|
||||
|
||||
@@ -93,10 +93,10 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
|
||||
/* Split by destination IP, in both directions.
|
||||
/* Split by destination address, in both directions.
|
||||
*
|
||||
* sing-box does not match an IP-based rule set (geoip-cn) against a
|
||||
* domain destination unless it is resolved first, so an explicit resolve
|
||||
* sing-box does not match an address-based rule set against a domain
|
||||
* destination unless it is resolved first, so an explicit resolve
|
||||
* action comes first. Which way the split points is the mode's default
|
||||
* policy (ctx.proxy_fallback):
|
||||
*
|
||||
@@ -105,10 +105,17 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
* proxy_mainland_china mainland -> proxy, the rest falls through to
|
||||
* direct-out
|
||||
*
|
||||
* Using geoip-cn rather than the geosite-* domain lists is deliberate:
|
||||
* those mis-classify foreign domains (Google's gvt2.com beacons) as "cn"
|
||||
* and would send them direct to time out. Keep the direct-domain
|
||||
* fast-path above for known direct domains. */
|
||||
* Using china-ip rather than a domain list is deliberate: those
|
||||
* mis-classify foreign domains (Google's gvt2.com beacons) as "cn" and
|
||||
* would send them direct to time out. Keep the direct-domain fast-path
|
||||
* above for known direct domains.
|
||||
*
|
||||
* One list for both halves of the split: china-ip is generated from the
|
||||
* same china_ip4.txt the firewall renders homeproxy_mainland_addr_v4
|
||||
* from, so the kernel and the resolver cannot disagree about what counts
|
||||
* as mainland. It used to be geoip-cn, with china-ip added later as a
|
||||
* correction for the two disagreeing - two rule-sets, one of them
|
||||
* redundant in this mode, neither able to see the other's data. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china'
|
||||
|| ctx.routing_mode === 'proxy_mainland_china') {
|
||||
push(config.route.rules, {
|
||||
@@ -116,37 +123,20 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
});
|
||||
push(config.route.rules, {
|
||||
rule_set: 'geoip-cn',
|
||||
rule_set: 'china-ip',
|
||||
action: 'route',
|
||||
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
|
||||
});
|
||||
}
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/* geoip-cn.srs is upstream's list; the firewall's mainland set is
|
||||
* built from our own china_ip4.txt (firewall_post.ut reads it
|
||||
* directly). The two disagree - the bundled list has 8.152.0.0/13
|
||||
* (Alibaba Cloud) and geoip-cn.srs does not - so a connection into
|
||||
* that range passes the firewall as "not mainland" and would then be
|
||||
* sent direct by the rule above instead of to the proxy. This local
|
||||
* rule-set is generated from the same china_ip4.txt, which is what
|
||||
* makes both sides decide from one list. In
|
||||
* proxy_mainland_china it is not needed twice: the geoip-cn rule
|
||||
* already selects the mainland side for the proxy. */
|
||||
push(config.route.rules, {
|
||||
rule_set: 'china-ip',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
|
||||
/* IPv6 half of the same split, and the reason a mainland site opened
|
||||
* over IPv6 used to go through the proxy even with IPv6 support on.
|
||||
* geoip-cn.srs carries no IPv6 and china_ip4.json is generated from an
|
||||
* IPv4 list, so a destination that resolved to an AAAA address matched
|
||||
* neither and fell through to `final` - which in this mode is main-out.
|
||||
* The rule is emitted in both mainland modes (unlike china-ip, which
|
||||
* proxy_mainland_china does not need twice because geoip-cn already
|
||||
* picks the mainland side there): with no IPv6 in geoip-cn there is
|
||||
* nothing left to pick it, so the side has to be named explicitly.
|
||||
/* The IPv6 half of the same split, and the reason a mainland site
|
||||
* opened over IPv6 used to go through the proxy even with IPv6
|
||||
* support on. china_ip4.json is generated from an IPv4 list, so a
|
||||
* destination that resolved to an AAAA address matched nothing and
|
||||
* fell through to `final` - which in bypass mode is main-out. The
|
||||
* rule is emitted in both mainland modes for that reason. Declared
|
||||
* only when china_ip6.json is actually there: a rule_set pointing at
|
||||
* a missing file takes the whole config down with it, and a router
|
||||
* whose v6 list is unusable must still start (the firewall has
|
||||
* already degraded to passing IPv6 through, with a warning).
|
||||
* `prefer_ipv4` on the resolve rule above is what lets both families
|
||||
* through - it biases, it does not filter. */
|
||||
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) {
|
||||
@@ -211,90 +201,36 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
]
|
||||
});
|
||||
|
||||
/* Both mainland modes split on geoip-cn, so both need it declared - it is
|
||||
* what the route rule added above matches. `proxy_mainland_china` used
|
||||
* 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 (declaresBuiltinRemoteRuleSets(ctx.routing_mode)) {
|
||||
/*
|
||||
* Fetched straight from the upstream SagerNet repositories and
|
||||
* downloaded through the selected node. A direct fetch depends on
|
||||
* the CDN staying reachable from mainland China, where DNS pollution
|
||||
* makes it fail intermittently; the files total ~250 KB per
|
||||
* day, so proxying the download costs almost nothing.
|
||||
*
|
||||
* Both rule_sets are emitted, but they are consumed by different
|
||||
* blocks:
|
||||
*
|
||||
* geoip-cn - referenced by the route rule above to split
|
||||
* destinations by IP: mainland -> direct-out in
|
||||
* bypass_mainland_china, mainland -> main-out in
|
||||
* proxy_mainland_china.
|
||||
* geosite-cn - referenced only by the DNS rule in
|
||||
* generator/dns.uc (rule_set: geosite-cn ->
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* sing-box with fswatch, so when the resource updater replaces the
|
||||
* file the running instance reloads it in place - no restart and,
|
||||
* more importantly, no second copy of the list to keep in sync. */
|
||||
/* Both mainland modes split on the China address list, so both need it
|
||||
* declared - it is what the route rule added above matches.
|
||||
* `proxy_mainland_china` used to reach this point without a declaration 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.
|
||||
*
|
||||
* All three are `type: local` and none of them is downloaded. Each is
|
||||
* generated by runtime/{china_ip,domain}_ruleset.uc from a list the resource
|
||||
* updater maintains, and sing-box watches the file with fswatch, so a new
|
||||
* list is picked up in place - no restart, and no second copy of the data to
|
||||
* keep in step with the firewall's nft set.
|
||||
*
|
||||
* Which ones are declared depends on what references them:
|
||||
* china-ip the route rule above, in both mainland modes
|
||||
* china-ip6 the IPv6 route rule, when there is one
|
||||
* china-domain the DNS rule in generator/dns.uc, bypass mode only -
|
||||
* proxy_mainland_china decides by address and leaves the
|
||||
* resolver to the mode default, so declaring it there would
|
||||
* load 111k suffixes for a rule that does not exist. */
|
||||
if (declaresBuiltinRuleSets(ctx.routing_mode)) {
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-ip',
|
||||
path: HP_DIR + '/resources/china_ip4.json'
|
||||
});
|
||||
|
||||
/* The IPv6 counterpart, from china_ip6.txt - the same list the
|
||||
* firewall renders homeproxy_mainland_addr_v6 from, and the only
|
||||
* IPv6 mainland data either side of the split has. Declared only
|
||||
* when the file is actually there: a rule_set pointing at a missing
|
||||
* file takes the whole config down with it, and a router whose v6
|
||||
* list is unusable must still start (the firewall has already
|
||||
/* Declared only when the file is actually there: a rule_set pointing
|
||||
* at a missing file takes the whole config down with it, and a router
|
||||
* whose v6 list is unusable must still start (the firewall has already
|
||||
* degraded to passing IPv6 through, with a warning). */
|
||||
if (ctx.ipv6_support === '1' && ctx.china_ip6_ready) {
|
||||
push(config.route.rule_set, {
|
||||
@@ -303,6 +239,18 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
path: HP_DIR + '/resources/china_ip6.json'
|
||||
});
|
||||
}
|
||||
|
||||
/* The DNS half. Same "only when it is there" rule as the v6 one
|
||||
* above, for the same reason: generate_client.uc's dns rule names this
|
||||
* tag, and a declaration whose file is missing fails the start rather
|
||||
* than degrading. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china' && ctx.china_domain_ready) {
|
||||
push(config.route.rule_set, {
|
||||
type: 'local',
|
||||
tag: 'china-domain',
|
||||
path: HP_DIR + '/resources/china-domain.json'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, ruleSetInitialFallback, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
|
||||
import { isEmpty, rulePathRootsText, ruleSetFormatFromPath, strToTime, validateRuleSetPath } from '../homeproxy-pro.uc';
|
||||
|
||||
import { get_outbound, isDirectOutboundTag, rule_set_tags } from './common.uc';
|
||||
|
||||
@@ -214,37 +214,6 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -300,116 +300,6 @@ export function ruleSetFormatFromPath(path) {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* A single self-contained diagnostic report, built in-process for the
|
||||
* `debug_report` rpc method (see luci.homeproxy-pro). It exists because the
|
||||
* failure modes this plugin has are not visible from the log page: a route
|
||||
* that stopped splitting, an nft rule that is not in the fw4 table, a
|
||||
* rule-set that never downloaded, a mark that no longer matches anything.
|
||||
* Each of those needs a different command to confirm, and collecting them
|
||||
* by hand over ssh is slow and easy to get wrong - the commands are spread
|
||||
* across `ip rule`, `nft`, the procd state and the resource version files.
|
||||
*
|
||||
* Two properties are deliberate and are the reason this is not a shell
|
||||
* script dropped in beside the other runtime helpers:
|
||||
*
|
||||
* 1. REDACTION IS DEFAULT-DENY. A node section is a credential store: it
|
||||
* holds passwords, uuids, private keys, SNI and the server address. A
|
||||
* report that is going to be pasted into an issue tracker has to be safe
|
||||
* by construction, so the rule is "if the key looks like a secret, mask
|
||||
* it" rather than an enumerated list of keys to mask - the enumerated
|
||||
* kind is exactly the one that goes stale when a field is added. The
|
||||
* pattern is applied to the key name, so a value is masked no matter
|
||||
* which section it came from.
|
||||
*
|
||||
* 2. EVERY PROBE DEGRADES. A report that aborts because `nft` is missing,
|
||||
* or that silently omits a section when a command fails, is worse than
|
||||
* no report: the reader cannot tell "not applicable" from "not
|
||||
* collected". Each probe therefore reports one of three states -
|
||||
* the output, "(unavailable)" for a missing command, or
|
||||
* "(no output, exit N)" for one that ran and failed. This is the same
|
||||
* distinction health.sh draws between "probe failed" and "cannot probe
|
||||
* here", and it is why a testbed without netfilter still produces a
|
||||
* usable report.
|
||||
*
|
||||
* The commands below are fixed literals: nothing in this file is built from
|
||||
* configuration, so there is no shell interpolation to get wrong and no
|
||||
* argument that would need shellQuote().
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { popen } from 'fs';
|
||||
|
||||
import { HP_DIR, RUN_DIR, shellQuote } from '../homeproxy-pro.uc';
|
||||
|
||||
export const DEBUG_REPORT_PATH = RUN_DIR + '/debug.log';
|
||||
|
||||
/* Per-section output cap. The nft dump is the one section that is expected
|
||||
* to be large - a populated fw4 table runs to a few thousand lines, and
|
||||
* truncating it is worse than useless because a truncated chain looks like
|
||||
* a chain that ends there. The cap is high enough that a real router never
|
||||
* reaches it, and reaching it is stated in the output rather than hidden. */
|
||||
const SECTION_MAX = 128 * 1024;
|
||||
|
||||
/* A key whose name matches any of these is masked. Deliberately broad: a
|
||||
* false positive costs a line of a report, a false negative ships someone's
|
||||
* private key. `method` and `path` are in the list because obfs parameters
|
||||
* and websocket paths are credentials in practice; `user` catches both
|
||||
* `username` and the access-control user lists; `short` is there for
|
||||
* `tls_reality_short_id`, which matched nothing until a canary run put a
|
||||
* value in it and found it in the report verbatim.
|
||||
*
|
||||
* `url` is the one pattern that is NOT a bare substring, and it has to stay
|
||||
* that way: a plain `url` alternative matches inside `main_urltest_nodes` and
|
||||
* `main_urltest_tolerance` (`main_u-r-l-test`), which silently blanked the
|
||||
* urltest pool out of the report. Those lists are how you tell "this node
|
||||
* is still in the test pool" from "this node was dropped", which is a
|
||||
* question the report exists to answer. Anchoring on a word boundary keeps
|
||||
* `url` / `urls` / `subscription_urls` covered and leaves the pool visible. */
|
||||
const SECRET_KEY_RE = /pass|secret|token|key|uuid|(^|_)urls?$|address|server|host|sni|user|psk|path|method|param|seed|salt|auth|hosts|short/i;
|
||||
|
||||
/* Exported for the rpc behaviour test: the redaction rule is the one part of
|
||||
* this file with no I/O to observe, so it is the one part that can be
|
||||
* asserted directly - a key that stops being recognised is a silent leak. */
|
||||
export function isSecretKey(name) {
|
||||
/* ucode has no RegExp.prototype.test - the module-level match() is the
|
||||
* only way to apply a pattern, and every other file in this tree does it
|
||||
* the same way. */
|
||||
return type(name) === 'string' && match(name, SECRET_KEY_RE) != null;
|
||||
};
|
||||
|
||||
function maskValue(value) {
|
||||
/* A list keeps its length visible: "3 masked entries" tells the reader
|
||||
* the subscription imported something, which is a fact worth having,
|
||||
* without disclosing any of it. */
|
||||
if (type(value) === 'array')
|
||||
return value === null || length(value) === 0
|
||||
? value
|
||||
: sprintf('[%d masked]', length(value));
|
||||
|
||||
return '***';
|
||||
};
|
||||
|
||||
/* Run one collector. Returns { state, text } where state is 'ok',
|
||||
* 'unavailable' or 'failed', so the caller can label it honestly instead of
|
||||
* printing an empty block that reads like "nothing to see". */
|
||||
function probe(command) {
|
||||
if (command == null)
|
||||
return { state: 'unavailable', text: '' };
|
||||
|
||||
const fd = popen(command);
|
||||
if (fd == null)
|
||||
return { state: 'unavailable', text: '' };
|
||||
|
||||
let out = fd.read('all');
|
||||
fd.close();
|
||||
|
||||
if (out == null)
|
||||
return { state: 'failed', text: '' };
|
||||
|
||||
out = trim(out);
|
||||
|
||||
/* popen() gives no exit status, so an empty result is the only signal
|
||||
* that the command produced nothing. Whether that is a problem
|
||||
* depends on the command, and the section header says which. */
|
||||
if (out === '')
|
||||
return { state: 'failed', text: '' };
|
||||
|
||||
if (length(out) > SECTION_MAX)
|
||||
out = substr(out, 0, SECTION_MAX) +
|
||||
sprintf('\n... [truncated at %d bytes; rerun the command by hand for the full output]\n', SECTION_MAX);
|
||||
|
||||
return { state: 'ok', text: out };
|
||||
};
|
||||
|
||||
/* The value form of the same collector: trimmed stdout, or null when the
|
||||
* command produced nothing. Used where the caller wants a value rather
|
||||
* than a rendered block. Deliberately not written with the optional-chaining
|
||||
* shorthand, which no other file in this tree uses. */
|
||||
function runCommand(command) {
|
||||
const fd = popen(command);
|
||||
if (fd == null)
|
||||
return null;
|
||||
|
||||
const out = fd.read('all');
|
||||
fd.close();
|
||||
|
||||
if (out == null)
|
||||
return null;
|
||||
|
||||
const t = trim(out);
|
||||
return t === '' ? null : t;
|
||||
};
|
||||
|
||||
function runDate() {
|
||||
return runCommand('date "+%Y-%m-%d %H:%M:%S %Z"') || 'unknown';
|
||||
};
|
||||
|
||||
/* Render one ```bash fenced block for a command, with the state spelled out
|
||||
* when there is no output to show. */
|
||||
function block(command, unavailable_label) {
|
||||
const r = probe(command);
|
||||
|
||||
if (r.state === 'ok')
|
||||
return sprintf('```shell\n# %s\n%s\n```\n', command, r.text);
|
||||
|
||||
if (r.state === 'unavailable')
|
||||
return sprintf('```\n(unavailable: %s)\n```\n', unavailable_label || command);
|
||||
|
||||
return '```\n(no output)\n```\n';
|
||||
};
|
||||
|
||||
/* Render a two-column table, or a single "(unavailable)" row. */
|
||||
function table(rows, unavailable_label) {
|
||||
if (rows == null)
|
||||
return sprintf('_%s_\n', unavailable_label || 'unavailable');
|
||||
|
||||
let out = '| 项目 | 值 |\n|------|-----|\n';
|
||||
for (let r in rows)
|
||||
out += sprintf('| %s | %s |\n', r[0], r[1]);
|
||||
return out;
|
||||
};
|
||||
|
||||
/* `apk list -I <pkg>` prints one line:
|
||||
*
|
||||
* sing-box-1.14.2-r1 x86_64 {feeds/.../sing-box} (GPL-3.0-or-later) [installed]
|
||||
*
|
||||
* The package name is the argument, so the version is simply what follows
|
||||
* that name's trailing dash up to the first space. A pattern anchored on
|
||||
* a negated class instead - `^[^ -]+-([0-9][^ ]*)` - stops at the FIRST
|
||||
* dash, so it works for `firewall4` and silently fails for every package
|
||||
* whose own name contains one (`sing-box`, `luci-app-homeproxy-pro`), which is
|
||||
* most of them. Splitting on the known prefix has no such case.
|
||||
*
|
||||
* This also avoids `\\s` inside a negated class, whose meaning inside `[...]`
|
||||
* is not what it is outside: `homeproxy-pro.uc`'s `replace(reason, /\s+/g, ' ')`
|
||||
* collapses whitespace correctly, yet the same `\s` inside `[^\\s-]` did not
|
||||
* exclude whitespace, and the version came back with the architecture glued
|
||||
* to it. */
|
||||
function packageVersion(name) {
|
||||
if (system('command -v apk >/dev/null 2>&1') !== 0)
|
||||
return null;
|
||||
|
||||
const fd = popen(sprintf('apk list -I %s 2>/dev/null', shellQuote(name)));
|
||||
if (fd == null)
|
||||
return null;
|
||||
|
||||
const line = trim(fd.read('line'));
|
||||
fd.close();
|
||||
|
||||
if (line == null || line === '')
|
||||
return null;
|
||||
|
||||
const prefix = name + '-';
|
||||
if (index(line, prefix) !== 0)
|
||||
return null;
|
||||
|
||||
const rest = substr(line, length(prefix));
|
||||
const sp = index(rest, ' ');
|
||||
const v = (sp == -1) ? rest : substr(rest, 0, sp);
|
||||
|
||||
return v === '' ? null : v;
|
||||
};
|
||||
|
||||
/* --- UCI summary --------------------------------------------------------- */
|
||||
|
||||
/* A structural, redacted view of the configuration: counts and switch
|
||||
* positions, never a credential. Sections are summarised by type rather
|
||||
* than dumped, because a node section has no field that is safe to print
|
||||
* and a subscription section has exactly one. */
|
||||
function configSummary(uci) {
|
||||
const all = uci.get_all('homeproxy-pro') || {};
|
||||
const byType = {};
|
||||
|
||||
for (let sid in all) {
|
||||
const section = all[sid];
|
||||
const stype = section['.type'] || 'unknown';
|
||||
|
||||
if (byType[stype] == null)
|
||||
byType[stype] = { count: 0, sample: section['.name'] || sid };
|
||||
|
||||
byType[stype].count++;
|
||||
}
|
||||
|
||||
if (byType == null || length(keys(byType)) === 0)
|
||||
return '_configuration is empty_\n';
|
||||
|
||||
let out = '';
|
||||
|
||||
for (let t in keys(byType)) {
|
||||
out += sprintf('- `%s`: %d section(s)\n', t, byType[t].count);
|
||||
}
|
||||
|
||||
/* The switches that decide what the report is FOR: which mode, which
|
||||
* ports, and the mark - these are the first things anyone reading a
|
||||
* routing problem looks for, and none of them is sensitive. Each entry
|
||||
* is [section, option]. */
|
||||
const switches = [
|
||||
['config', 'proxy_mode'],
|
||||
['config', 'routing_mode'],
|
||||
['config', 'main_node'],
|
||||
['config', 'main_udp_node'],
|
||||
['config', 'ipv6_support'],
|
||||
['config', 'log_level'],
|
||||
['config', 'bypass_cn_traffic'],
|
||||
['infra', 'mixed_port'],
|
||||
['infra', 'redirect_port'],
|
||||
['infra', 'tproxy_port'],
|
||||
['infra', 'dns_port'],
|
||||
['infra', 'self_mark'],
|
||||
['infra', 'tproxy_mark'],
|
||||
['infra', 'tun_mark'],
|
||||
['infra', 'table_mark'],
|
||||
['infra', 'tun_name'],
|
||||
['server', 'enabled']
|
||||
];
|
||||
|
||||
out += '\n| 开关 | 值 |\n|------|-----|\n';
|
||||
for (let sw in switches) {
|
||||
const v = uci.get('homeproxy-pro', sw[0], sw[1]);
|
||||
out += sprintf('| `%s.%s` | %s |\n', sw[0], sw[1],
|
||||
(v == null || v === '') ? '_(unset)_' : (isSecretKey(sw[1]) ? maskValue(v) : v));
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/* Every non-secret option, flattened, with anything that looks like a
|
||||
* credential masked. This is the section that makes the report useful for
|
||||
* "which option did I set wrong" - the summary above cannot show them. */
|
||||
function fullConfig(uci) {
|
||||
const all = uci.get_all('homeproxy-pro') || {};
|
||||
let out = '';
|
||||
|
||||
for (let sid in keys(all)) {
|
||||
const section = all[sid];
|
||||
const stype = section['.type'] || 'unknown';
|
||||
const sname = section['.name'] || sid;
|
||||
|
||||
out += sprintf('\n### %s `%s`\n\n```\n', stype, sname);
|
||||
|
||||
for (let k in keys(section)) {
|
||||
if (substr(k, 0, 1) === '.')
|
||||
continue;
|
||||
|
||||
const v = section[k];
|
||||
|
||||
if (isSecretKey(k)) {
|
||||
out += sprintf('option %s %s\n', k, maskValue(v));
|
||||
} else if (type(v) === 'array') {
|
||||
out += sprintf('list %s %s\n', k,
|
||||
length(v) === 0 ? '' : join(' ', v));
|
||||
} else {
|
||||
out += sprintf('option %s %s\n', k, v);
|
||||
}
|
||||
}
|
||||
|
||||
out += '```\n';
|
||||
}
|
||||
|
||||
return out == '' ? '_configuration is empty_\n' : out;
|
||||
};
|
||||
|
||||
/* --- resource versions --------------------------------------------------- */
|
||||
|
||||
/* The .ver files next to each shipped list. A rule-set that has been
|
||||
* failing to download shows up here as a version that never moves, which is
|
||||
* the cheapest way to tell "the list is stale" from "the list is missing". */
|
||||
function resourceVersions() {
|
||||
const names = ['china_ip4', 'china_ip6', 'china_list', 'gfw_list'];
|
||||
let out = '';
|
||||
|
||||
for (let n in names) {
|
||||
const path = sprintf('%s/resources/%s.ver', HP_DIR, n);
|
||||
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(path)));
|
||||
|
||||
if (fd != null) {
|
||||
const v = trim(fd.read('all'));
|
||||
fd.close();
|
||||
out += sprintf('- `%s`: %s\n', n, (v == null || v === '') ? '_(no version file)_' : v);
|
||||
} else {
|
||||
out += sprintf('- `%s`: _(unavailable)_\n', n);
|
||||
}
|
||||
}
|
||||
|
||||
/* The dnsmasq nftset capability marker: init.d writes this at start
|
||||
* and firewall_post.ut gates the gfwlist mode on it, so a device whose
|
||||
* dnsmasq was built without HAVE_NFTSET silently routes everything
|
||||
* direct while DNS still goes through the proxy. */
|
||||
const marker = sprintf('%s/resources/.dnsmasq-nftset', HP_DIR);
|
||||
const fd = popen(sprintf('cat %s 2>/dev/null', shellQuote(marker)));
|
||||
let cap = 'unknown';
|
||||
if (fd != null) {
|
||||
cap = trim(fd.read('all')) || 'unknown';
|
||||
fd.close();
|
||||
}
|
||||
out += sprintf('- `dnsmasq nftset`: %s', cap == '1' ? 'yes' : (cap == '0' ? 'no (gfwlist mode cannot work)' : cap));
|
||||
|
||||
return out + '\n';
|
||||
};
|
||||
|
||||
/* --- the report ---------------------------------------------------------- */
|
||||
|
||||
export function buildDebugReport(uci) {
|
||||
const stamp = trim(runDate());
|
||||
|
||||
let out = '';
|
||||
out += '# HomeProxy 诊断报告\n\n';
|
||||
out += sprintf('> 生成时间: %s\n', stamp == '' ? '(unknown)' : stamp);
|
||||
out += sprintf('> 插件版本: %s\n', packageVersion('luci-app-homeproxy-pro') || 'unknown');
|
||||
out += '> 隐私: 凭据字段已按名称脱敏,但本报告仍含公网 IP、内网拓扑和 LAN 主机地址。\n';
|
||||
out += '> 外发前请自行确认。\n';
|
||||
|
||||
/* 1. System */
|
||||
out += '\n## 1. 系统\n\n';
|
||||
out += block('cat /etc/openwrt_release 2>/dev/null', 'uname -a');
|
||||
out += block('uname -a');
|
||||
out += block('uptime');
|
||||
out += block('df -h / /tmp /etc/homeproxy-pro 2>/dev/null', 'df unavailable');
|
||||
out += block('free -m 2>/dev/null', 'free unavailable');
|
||||
|
||||
/* 2. Dependencies - the table, not a "is it there" yes/no, because the
|
||||
* version is what a reader needs when reporting "it does not work on
|
||||
* this build". */
|
||||
out += '\n## 2. 依赖检查\n\n';
|
||||
const deps = ['sing-box', 'firewall4', 'kmod-nft-tproxy', 'kmod-tun',
|
||||
'ip-full', 'uclient-fetch', 'ucode', 'ucode-mod-digest'];
|
||||
const dep_rows = [];
|
||||
|
||||
for (let d in deps) {
|
||||
const v = packageVersion(d);
|
||||
push(dep_rows, [d, v == null ? '_(not reported by apk; may be built in)_' : v]);
|
||||
}
|
||||
|
||||
out += table(dep_rows, 'apk not available on this system');
|
||||
|
||||
/* 3. Core capabilities */
|
||||
out += '\n## 3. 内核与能力\n\n';
|
||||
out += block('/usr/bin/sing-box version 2>/dev/null', 'sing-box not installed');
|
||||
out += block('lsmod 2>/dev/null | grep -E "tun|nft_tproxy|inet_diag"', 'lsmod unavailable');
|
||||
out += block('command -v nft fw4 utpl ucode uci 2>/dev/null', 'PATH lookup unavailable');
|
||||
|
||||
/* 4. Configuration */
|
||||
out += '\n## 4. 配置摘要(脱敏)\n\n';
|
||||
out += configSummary(uci);
|
||||
|
||||
/* 5. Resources */
|
||||
out += '\n## 5. 资源版本\n\n';
|
||||
out += resourceVersions();
|
||||
|
||||
/* 6. Service */
|
||||
out += '\n## 6. 服务状态\n\n';
|
||||
out += block('/etc/init.d/homeproxy-pro info 2>&1', 'init script unavailable');
|
||||
out += block('ubus call service list \'"name":"homeproxy-pro"\' 2>/dev/null',
|
||||
'ubus unavailable');
|
||||
|
||||
/* 7. Routing - `ip rule` first, because the mark that routes a
|
||||
* connection into the proxy is set there, and a rule that is missing
|
||||
* explains a proxy that binds its ports and then times out. */
|
||||
out += '\n## 7. 路由策略\n\n';
|
||||
out += '### ip rule\n\n';
|
||||
out += block('ip rule list');
|
||||
out += '### ip route\n\n';
|
||||
out += block('ip -4 route list');
|
||||
out += block('ip -6 route list');
|
||||
out += sprintf('\n**策略路由表**(table_mark / tproxy_mark / tun_mark):\n\n```shell\n# ip route list table 100\n%s\n```\n',
|
||||
trim(runCommand('ip route list table 100 2>/dev/null') || '(empty or unavailable)'));
|
||||
|
||||
/* 8. Firewall - the full fw4 table, not just homeproxy-pro's own chains.
|
||||
* The failure this covers is "homeproxy-pro's rules are present and
|
||||
* correct but something in fw4 jumps to another chain first", and
|
||||
* that is only visible with the surrounding rules in view. */
|
||||
out += '\n## 8. 防火墙\n\n';
|
||||
out += '_The full `inet fw4` table. homeproxy-pro\'s chains are the ones named '
|
||||
+ '`homeproxy_*`; everything else is fw4\'s own._\n\n';
|
||||
out += block('nft -a list table inet fw4 2>/dev/null', 'nft unavailable (is firewall4 installed?)');
|
||||
|
||||
/* 9. Log excerpts - the tail of each log, not the whole file: the
|
||||
* interesting part of a failure is the last thing that happened. */
|
||||
out += '\n## 9. 日志尾部\n\n';
|
||||
for (let l in ['homeproxy-pro', 'sing-box-c', 'sing-box-s']) {
|
||||
out += sprintf('### %s.log\n\n', l);
|
||||
out += block(sprintf('tail -n 60 %s/%s.log 2>/dev/null', RUN_DIR, l),
|
||||
sprintf('%s.log not present', l));
|
||||
}
|
||||
|
||||
/* 10. Full redacted configuration */
|
||||
out += '\n## 10. 完整配置(脱敏)\n\n';
|
||||
out += fullConfig(uci);
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -73,7 +73,7 @@ hp_dnsmasq_has_nftset() {
|
||||
[ "$HP_DNSMASQ_NFTSET" = "1" ]
|
||||
}
|
||||
|
||||
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
|
||||
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6> [run-dir]
|
||||
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
|
||||
# restarts nothing: the caller decides whether the result differs from what is
|
||||
# already installed.
|
||||
@@ -89,6 +89,7 @@ hp_dnsmasq_render_snippets() {
|
||||
local routing_mode="$3"
|
||||
local dns_port="$4"
|
||||
local ipv6="$5"
|
||||
local run_dir="${6:-/var/run/homeproxy-pro}"
|
||||
local gfw_nftset_v6="" wan_nftset_v6=""
|
||||
local nftset_ok=1
|
||||
|
||||
@@ -154,6 +155,33 @@ hp_dnsmasq_render_snippets() {
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Node server addresses, host-name form.
|
||||
#
|
||||
# This snippet carries no `server=`: the routing mode's own snippet above
|
||||
# already decides which upstream answers, and in every proxied mode that is
|
||||
# sing-box's dns-in. The generator resolves the node through the WAN
|
||||
# resolver either way (route.default_domain_resolver), so the address that
|
||||
# comes back is the same one sing-box will dial - which is the point. Only
|
||||
# the `nftset=` is added here, and dnsmasq back-fills it from the answer it
|
||||
# relays, forwarded or not.
|
||||
#
|
||||
# Without it, a LAN client opening a connection to the node's own address
|
||||
# was redirected into sing-box, which dialled that same address through the
|
||||
# tunnel to that same node. sing-box's own outbound is already exempt
|
||||
# (routing_mark / self_mark); this covers the LAN, which is not.
|
||||
#
|
||||
# The literal-address form cannot come from here - there is no name to
|
||||
# resolve - so firewall_post.ut renders it into the same set directly.
|
||||
if [ "$nftset_ok" = "1" ] && [ -s "$run_dir/node-addr-domains.txt" ]; then
|
||||
local node_nftset_v6=""
|
||||
[ "$ipv6" -eq "0" ] || node_nftset_v6=",6#inet#fw4#homeproxy_node_addr_v6"
|
||||
sed -r -e '/^\s*$/d' \
|
||||
-e "s/(.*)/nftset=\/.\1\/4#inet#fw4#homeproxy_node_addr_v4$node_nftset_v6/g" \
|
||||
"$run_dir/node-addr-domains.txt" > "$stage/node_addr.conf" || return 1
|
||||
elif [ -s "$run_dir/node-addr-domains.txt" ]; then
|
||||
log "Warning: this dnsmasq has no nftset support; LAN connections to the node's address are not excluded from the proxy."
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
|
||||
@@ -179,7 +207,7 @@ hp_dnsmasq_dir_differs() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
|
||||
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode> [run-dir]
|
||||
# Write the include file plus the mode-specific snippet, then restart
|
||||
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
|
||||
#
|
||||
@@ -192,6 +220,7 @@ hp_dnsmasq_write_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local run_dir="${4:-/var/run/homeproxy-pro}"
|
||||
local ipv6_support dns_port
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf"
|
||||
local stage changed=0
|
||||
@@ -208,7 +237,7 @@ hp_dnsmasq_write_snippets() {
|
||||
# installed snippet set untouched: the staged directory is discarded, so
|
||||
# the caller keeps the snippets that are already in place instead of
|
||||
# installing a truncated one.
|
||||
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
|
||||
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support" "$run_dir"; then
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* runtime/domain_ruleset.uc <source .txt> <destination .json>
|
||||
*
|
||||
* The domain-list counterpart of china_ip_ruleset.uc: turn a bare host-name
|
||||
* list into a sing-box source rule-set of `domain_suffix` rules, so the DNS
|
||||
* side of the China split reads a local file instead of downloading
|
||||
* geosite-geolocation-cn.srs from GitHub on a schedule.
|
||||
*
|
||||
* Why this exists, in the terms that decided it: the two consumers of the
|
||||
* China split - the nft mainland set and the sing-box rule-set - were reading
|
||||
* two different files. The kernel's came from china_ip4.txt, the resolver's
|
||||
* from a remote .srs, and the remote copy is what a cold start has to wait for
|
||||
* (through the node, when download_detour says so) before the inbounds bind.
|
||||
* With the local copy there is nothing to wait for and nothing to keep in step.
|
||||
*
|
||||
* The entry is a suffix, not an exact domain: china_list.txt holds registrable
|
||||
* domains ("163.com", "taobao.com"), and the DNS rule has to match every name
|
||||
* under them, which is what `domain_suffix` means to sing-box. `domain` would
|
||||
* match only the name itself and quietly stop matching www.<domain> for the
|
||||
* entire list.
|
||||
*
|
||||
* One rule holding the whole list, for the same reason china_ip_ruleset.uc
|
||||
* gives: sing-box matches rule-sets by walking a prefix-compressed structure,
|
||||
* and splitting the list would only add rules to walk per query.
|
||||
*
|
||||
* Entries are validated here rather than trusted. The strict parser on the
|
||||
* other side is the authority for what a valid name is, and one bad entry
|
||||
* makes it reject the *whole* rule-set - which would take the client's start
|
||||
* with it. A malformed line is skipped and reported, exactly like the blank
|
||||
* lines and comments a list may carry.
|
||||
*/
|
||||
'use strict';
|
||||
import { readfile, writefile, rename, unlink } from 'fs';
|
||||
|
||||
if (length(ARGV) < 2 || ARGV[0] == null || ARGV[1] == null) {
|
||||
warn(sprintf('usage: domain_ruleset.uc <source .txt> <destination .json>\n'));
|
||||
exit(1);
|
||||
}
|
||||
const src = ARGV[0];
|
||||
const dst = ARGV[1];
|
||||
const data = readfile(src);
|
||||
if (type(data) !== 'string') {
|
||||
warn(sprintf('cannot read %s\n', src));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const suffixes = [];
|
||||
let skipped = 0;
|
||||
|
||||
/* A structural hostname check, in process.
|
||||
*
|
||||
* validation() from homeproxy-pro.uc is the obvious tool and the wrong one here:
|
||||
* it shells out to /sbin/validate_data once per call, and this list is 111k
|
||||
* lines. That is 111k process spawns. china_ip_ruleset.uc makes the same call
|
||||
* for the same reason - its is_v4/is_v6 are hand-written rather than delegated.
|
||||
*
|
||||
* Deliberately structural, not exhaustive: the goal is to keep a line that
|
||||
* cannot be a domain out of the file, since one bad entry makes sing-box reject
|
||||
* the whole rule-set and take the client's start with it. The strict parser on
|
||||
* the other side stays the authority for what is really valid. */
|
||||
function is_hostname(entry) {
|
||||
if (length(entry) < 1 || length(entry) > 253)
|
||||
return false;
|
||||
|
||||
/* Anything outside this set is a path, a URL, a port, an email, or a
|
||||
* comment marker that survived the updater's filter. */
|
||||
if (!match(entry, /^[A-Za-z0-9.-]+$/))
|
||||
return false;
|
||||
|
||||
/* A leading dot, a doubled dot, or a trailing dot all mean the list is
|
||||
* not in the bare form this emits; sing-box treats them differently from
|
||||
* what the DNS rule expects to match. */
|
||||
if (substr(entry, 0, 1) == '.' || substr(entry, -1, 1) == '.')
|
||||
return false;
|
||||
if (match(entry, /\.\./))
|
||||
return false;
|
||||
|
||||
for (let label in split(entry, '.')) {
|
||||
if (length(label) < 1 || length(label) > 63)
|
||||
return false;
|
||||
/* A label may not start or end with a hyphen (RFC 1035). */
|
||||
if (substr(label, 0, 1) == '-' || substr(label, -1, 1) == '-')
|
||||
return false;
|
||||
}
|
||||
|
||||
/* An all-numeric set of labels is a bare IPv4 address, not a domain:
|
||||
* the structure above accepts "192.168.1.1" quite happily, and a
|
||||
* domain_suffix rule holding an address never matches a query. No TLD
|
||||
* is all digits, so this cannot cost a real name. IPv6 never reaches
|
||||
* here at all - it carries colons and was rejected above. */
|
||||
let all_numeric = true;
|
||||
for (let label in split(entry, '.')) {
|
||||
if (!match(label, /^[0-9]+$/)) {
|
||||
all_numeric = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (all_numeric)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let line in split(data, /\n/)) {
|
||||
const entry = trim(line);
|
||||
if (entry == '')
|
||||
continue;
|
||||
if (match(entry, /^[#;]/))
|
||||
continue;
|
||||
|
||||
/* The updater already strips `full:` prefixes and anything with a colon
|
||||
* (update_resources.sh's china_list case), so what arrives here is a bare
|
||||
* name. Anything that is not still a hostname is dropped rather than
|
||||
* passed on. */
|
||||
if (!is_hostname(entry)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
push(suffixes, entry);
|
||||
}
|
||||
|
||||
if (skipped > 0)
|
||||
warn(sprintf('skipped %d malformed entries in %s\n', skipped, src));
|
||||
|
||||
/* An empty rule-set is worse than no rule-set: it matches nothing, so every
|
||||
* name the list should have covered falls through to whatever the DNS side
|
||||
* defaults to. Refuse rather than install one - the previous file stays. */
|
||||
if (length(suffixes) == 0) {
|
||||
warn(sprintf('no usable domain entries in %s\n', src));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The temporary name is a sibling so the rename stays on one filesystem: the
|
||||
* watcher must never read a half-written file. `%.J` is the same serializer
|
||||
* the config generator uses, so the output is the canonical sing-box form. */
|
||||
const rendered = sprintf('%.J\n', {
|
||||
version: 3,
|
||||
rules: [
|
||||
{ domain_suffix: suffixes }
|
||||
]
|
||||
});
|
||||
|
||||
const tmp = dst + '.tmp';
|
||||
if (!writefile(tmp, rendered)) {
|
||||
warn(sprintf('cannot write %s\n', tmp));
|
||||
exit(1);
|
||||
}
|
||||
if (!rename(tmp, dst)) {
|
||||
unlink(tmp);
|
||||
warn(sprintf('cannot install %s\n', dst));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
printf('%d domain suffixes\n', length(suffixes));
|
||||
@@ -87,8 +87,8 @@ hp_require_ucode() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_crontab_drop <crontab>
|
||||
# Remove the auto-update entry from <crontab>.
|
||||
# hp_crontab_drop <crontab> [marker]
|
||||
# Remove the cron entries tagged <marker> from <crontab>.
|
||||
#
|
||||
# Not `sed -i`: the bare `-i` form is a busybox/GNU extension, and on a host with
|
||||
# BSD sed it fails ("invalid command code"), which silently left the stale entry
|
||||
@@ -96,11 +96,16 @@ hp_require_ucode() {
|
||||
# behaves identically on busybox, GNU and BSD sed, and it is what lets this
|
||||
# module be exercised off-target at all
|
||||
# (tests/runtime/test_runtime_extraction.sh stages and drives it).
|
||||
#
|
||||
# The marker is a parameter because there are two independent entries now: the
|
||||
# resource lists and the subscriptions. A shared marker would let dropping one
|
||||
# take the other with it.
|
||||
hp_crontab_drop() {
|
||||
local crontab="$1"
|
||||
local marker="${2:-${CONF}_autosetup}"
|
||||
local tmp="${crontab}.hp-new"
|
||||
|
||||
sed "/#${CONF}_autosetup/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
sed "/#${marker}/d" "$crontab" > "$tmp" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
mv -f "$tmp" "$crontab" 2>"/dev/null" || { rm -f "$tmp"; return 1; }
|
||||
|
||||
# mv replaces the *file*, not its contents: the temporary was created by the
|
||||
@@ -115,6 +120,36 @@ hp_crontab_drop() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_sync_resource_cron <hour>
|
||||
# Install the resource-list update cron entry. Unconditional.
|
||||
#
|
||||
# The resource lists were previously refreshed by the same cron line as the
|
||||
# subscription update, behind the *subscription's* auto_update switch. Two
|
||||
# unrelated things behind one switch, and the consequence was silent: a router
|
||||
# with subscription auto-update off kept a months-old china_ip4.txt, and with it
|
||||
# a months-old `homeproxy_mainland_addr_v4` nft set - the firewall was deciding
|
||||
# "mainland or not" from a list nobody was maintaining. The subscription switch
|
||||
# says nothing about the user wanting stale routing data, so the two are now
|
||||
# scheduled independently.
|
||||
#
|
||||
# The hour is a constant rather than a setting. This is a maintenance job with
|
||||
# nothing to configure, and adding an option for it is exactly the kind of
|
||||
# switch that ends up defaulted and never touched. Two hours earlier than the
|
||||
# subscription default (2) so the two runs of uclient-fetch do not collide.
|
||||
#
|
||||
# The entry runs update_resources_cron.sh, which reloads the service only when a
|
||||
# list actually changed - so a day where the upstream lists did not move costs
|
||||
# one round of requests and no interruption.
|
||||
HP_RESOURCE_CRON_HOUR=3
|
||||
hp_sync_resource_cron() {
|
||||
hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \
|
||||
|| log "Warning: failed to drop the previous resource-update cron entry."
|
||||
printf '0 %s * * * %s/scripts/update_resources_cron.sh #%s_resource_cron\n' \
|
||||
"$HP_RESOURCE_CRON_HOUR" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to install the resource-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
# hp_sync_autoupdate_cron <enabled> <hour>
|
||||
# Install (or drop) the subscription auto-update cron entry. <enabled> is a
|
||||
# config_get_bool result; the entry is only touched when it is "1".
|
||||
@@ -140,7 +175,7 @@ hp_sync_autoupdate_cron() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \
|
||||
|| log "Warning: failed to drop the previous auto-update cron entry."
|
||||
printf '0 %s * * * %s/scripts/update_crond.sh #%s_autosetup\n' \
|
||||
"$auto_update_time" "$HP_DIR" "$CONF" >> "/etc/crontabs/root" \
|
||||
@@ -149,10 +184,16 @@ hp_sync_autoupdate_cron() {
|
||||
}
|
||||
|
||||
# hp_clear_autoupdate_cron
|
||||
# Drop the auto-update cron entry and restart cron. Used by stop_service.
|
||||
# Drop both cron entries and restart cron. Used by stop_service.
|
||||
#
|
||||
# Both markers: stop_service has to leave no homeproxy-pro cron entry behind, and
|
||||
# the resource entry is installed unconditionally, so dropping only the
|
||||
# subscription one would leak a daily job on every stop.
|
||||
hp_clear_autoupdate_cron() {
|
||||
hp_crontab_drop "/etc/crontabs/root" \
|
||||
|| log "Warning: failed to drop the auto-update cron entry."
|
||||
hp_crontab_drop "/etc/crontabs/root" "${CONF}_autosetup" \
|
||||
|| log "Warning: failed to drop the previous auto-update cron entry."
|
||||
hp_crontab_drop "/etc/crontabs/root" "${CONF}_resource_cron" \
|
||||
|| log "Warning: failed to drop the previous resource-update cron entry."
|
||||
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
||||
}
|
||||
|
||||
@@ -251,6 +292,29 @@ hp_prepare_runtime_files() {
|
||||
log "WARNING: mainland IPv6 cannot be told apart from foreign IPv6, so IPv6 is passed through UNPROXIED (both sides of the split degrade). Run: ${hp_dir}/scripts/update_resources.sh china_ip6"
|
||||
fi
|
||||
|
||||
# The DNS half of the China split, same arrangement as china_ip4.json above:
|
||||
# the dns rule sends the list's domains to china-dns, and it reads
|
||||
# china-domain.json rather than a remote geosite-geolocation-cn.srs so a
|
||||
# cold start has nothing to download. Generated here for the same reason -
|
||||
# an install that predates the file, or a list replaced while the service
|
||||
# was stopped, must still get one, or the client config names a file that
|
||||
# does not exist and sing-box refuses to start.
|
||||
#
|
||||
# A missing china_list.txt is not an error: the generator then emits no
|
||||
# DNS-side rule for it, and the split degrades to the resolver default
|
||||
# rather than failing the start.
|
||||
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then
|
||||
if ucode -S "$hp_dir/scripts/runtime/domain_ruleset.uc" \
|
||||
"$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json" >>"$LOG_PATH" 2>&1; then
|
||||
chown sing-box:sing-box "$hp_dir/resources/china-domain.json" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/resources/china-domain.json to sing-box."
|
||||
else
|
||||
# Same reasoning as the v6 branch above: the helper refuses to
|
||||
# write an empty rule-set, and the previous one is still valid.
|
||||
log "Warning: could not generate ${hp_dir}/resources/china-domain.json; the DNS side keeps the previous list."
|
||||
fi
|
||||
fi
|
||||
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental). The file has to exist before
|
||||
# the client jail binds it, or sing-box cannot create it in there.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2023 ImmortalWrt.org
|
||||
# Subscription auto-update, run from the crontab entry that
|
||||
# runtime/service.sh installs when subscription.auto_update is on.
|
||||
#
|
||||
# The resource lists used to be refreshed from this same script. They are not
|
||||
# any more: they have their own entry (update_resources_cron.sh), because a
|
||||
# subscription switch had been deciding whether the firewall's mainland address
|
||||
# set was still current. See hp_sync_resource_cron() in runtime/service.sh.
|
||||
|
||||
SCRIPTS_DIR="/etc/homeproxy-pro/scripts"
|
||||
|
||||
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
|
||||
"$SCRIPTS_DIR"/update_resources.sh "$i"
|
||||
done
|
||||
|
||||
"$SCRIPTS_DIR"/update_subscriptions.uc
|
||||
@@ -236,6 +236,23 @@ check_list_update() {
|
||||
log "[$(to_upper "$listtype")] Warning: could not regenerate $listtype.json (list has no usable CIDR entry?); the route side keeps the previous list."
|
||||
fi
|
||||
;;
|
||||
"china_list")
|
||||
# The DNS half of the same split. It reads this file rather than
|
||||
# a remote geosite-geolocation-cn.srs for the same reasons
|
||||
# china_ip4.json exists: one list with two readers, and nothing for
|
||||
# a cold start to download before the inbounds bind.
|
||||
#
|
||||
# The sed above rewrote the file in place, so this runs after it
|
||||
# and sees the cleaned list. `full:` prefixes and anything with a
|
||||
# colon are gone by now; what is left is what the helper accepts.
|
||||
if ucode -S "$SCRIPT_DIR/runtime/domain_ruleset.uc" \
|
||||
"$RESOURCES_DIR/$listtype.txt" "$RESOURCES_DIR/china-domain.json" >>"$LOG_PATH" 2>&1; then
|
||||
log "[CHINA_LIST] DNS-side rule-set regenerated."
|
||||
chown sing-box:sing-box "$RESOURCES_DIR/china-domain.json" 2>"/dev/null"
|
||||
else
|
||||
log "[CHINA_LIST] Warning: could not regenerate china-domain.json (list has no usable domain entry?); the DNS side keeps the previous list."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Refresh the four resource lists (china_ip4 / china_ip6 / gfw_list /
|
||||
# china_list) and reload the service only when one of them actually moved.
|
||||
#
|
||||
# Scheduled unconditionally - see hp_sync_resource_cron() in runtime/service.sh
|
||||
# for why this is not behind the subscription auto_update switch.
|
||||
#
|
||||
# Why a reload is needed at all, when the sing-box side reloads itself: the
|
||||
# lists feed two consumers. update_resources.sh regenerates the sing-box
|
||||
# rule-sets from them, and sing-box watches those files and picks a new one up
|
||||
# in place. The firewall reads the same lists directly when firewall_post.ut is
|
||||
# rendered into the nft sets, and that only happens on an fw4 reload - so
|
||||
# without this the kernel kept routing from the previous list while sing-box
|
||||
# had already moved to the new one, and the two halves of the same split
|
||||
# disagreed. That window is invisible: mainland traffic still works, it just
|
||||
# stops being mainland for whatever the new entries added.
|
||||
#
|
||||
# update_resources.sh exits 3 when a list is already current, so a day with no
|
||||
# upstream movement costs one round of requests and no interruption.
|
||||
|
||||
SCRIPTS_DIR="/etc/homeproxy-pro/scripts"
|
||||
NAME="homeproxy-pro"
|
||||
RUN_DIR="/var/run/$NAME"
|
||||
LOG_PATH="$RUN_DIR/$NAME.log"
|
||||
|
||||
mkdir -p "$RUN_DIR" 2>/dev/null
|
||||
|
||||
log() {
|
||||
printf '%s %s\n' "$(date "+%Y-%m-%d %H:%M:%S")" "$*" >> "$LOG_PATH"
|
||||
}
|
||||
|
||||
changed=0
|
||||
for i in "china_ip4" "china_ip6" "gfw_list" "china_list"; do
|
||||
"$SCRIPTS_DIR"/update_resources.sh "$i"
|
||||
case "$?" in
|
||||
0) changed=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$changed" = "1" ] || exit 0
|
||||
|
||||
log "A resource list changed; reloading $NAME so the nft sets catch up."
|
||||
if ! /etc/init.d/$NAME reload >>"$LOG_PATH" 2>&1; then
|
||||
log "Warning: the $NAME reload after a resource update failed; the firewall still uses the previous list."
|
||||
fi
|
||||
@@ -157,7 +157,7 @@ hp_rearm_intercept() {
|
||||
[ -f "$RUN_DIR/intercept-released" ] || return 0
|
||||
rm -f "$RUN_DIR/intercept-released"
|
||||
log "Client is up again; reinstalling the intercept layer."
|
||||
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2"
|
||||
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2" "$RUN_DIR"
|
||||
hp_net_setup "$1" "$2"
|
||||
# hp_firewall_apply already writes $RUN_DIR/failed-firewall on its own
|
||||
# failure, but the helper's caller is service_started, which only reads
|
||||
@@ -265,6 +265,11 @@ start_service() {
|
||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
||||
config_get auto_update_time "subscription" "auto_update_time" "2"
|
||||
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
|
||||
# Unconditional, and independent of the switch above: the resource
|
||||
# lists decide the firewall's mainland set, and that going stale is
|
||||
# not something "update my subscriptions" should have been able to
|
||||
# cause.
|
||||
hp_sync_resource_cron
|
||||
|
||||
# The DNS snippets are the other half of the intercept layer: without
|
||||
# them the LAN resolves through the ISP while the nft rules still send
|
||||
@@ -273,7 +278,7 @@ start_service() {
|
||||
# leaving an empty snippet behind; record it the same way a firewall
|
||||
# failure is recorded, so the configuration is not promoted to
|
||||
# known-good and the log names the real problem.
|
||||
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode"; then
|
||||
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode" "$RUN_DIR"; then
|
||||
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
|
||||
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user