🎈 Sync 2026-10-04 14:38:18

This commit is contained in:
github-actions[bot] committed 2026-10-04 14:38:18 +08:00
1 parent 0e6611f767
commit aba12667c5
88 files changed
+4600 -1458

No files matched your search

@@ -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.