🤞 Sync 2026-09-30 09:28:31

This commit is contained in:
github-actions[bot] committed 2026-09-30 09:28:31 +08:00
1 parent 0108cfa10a
commit 3d9edb1c37
78 files changed
+4594 -1815

No files matched your search

@@ -0,0 +1,147 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Turn a plain CIDR list into a sing-box local rule-set, so the route side and
* the firewall decide "is this destination mainland China" from the same bytes.
*
* The firewall's homeproxy_mainland_addr_v4 set is filled by firewall_post.ut
* straight from /etc/homeproxy-pro/resources/china_ip4.txt, while the route rule
* used the upstream geoip-cn.srs. Those two disagree: the bundled list carries
* 8.152.0.0/13 (Alibaba Cloud) which geoip-cn.srs does not, so a connection to
* segmentfault.com or ctrip.com resolved into that range is redirected by the
* firewall (not in the nft set) and then sent *direct* by the route rule (not
* in geoip-cn.srs) - the opposite of what the mode promises.
*
* Reading the same text file on both sides removes the disagreement, and it
* has to survive the resource updater replacing the list: the generated
* rule-set is written with a local `type: local` entry in the client config,
* which sing-box watches with fswatch, so replacing the file is enough - no
* service restart, no second copy of the data.
*
* Usage: ucode -S china_ip_ruleset.uc <source .txt> <destination .json>
* Exit 0 when the file was written, 1 (with a message on stderr) otherwise.
* An unreadable or empty source is *not* an error: the caller may legitimately
* have no list yet, and a missing rule-set file is handled by the generator
* (it emits the local rule only when the file exists).
*/
'use strict';
import { readfile, writefile, rename, unlink } from 'fs';
if (length(ARGV) < 2 || ARGV[0] == null || ARGV[1] == null) {
warn('usage: china_ip_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);
}
/* One `ip_cidr` array is one netipx.IPSet on the sing-box side, which is a
* prefix-compressed structure with a binary search per query; splitting the
* list into several rules would only add rules to walk per packet.
*
* The entries are validated here rather than trusted. netip.ParsePrefix() is
* what sing-box uses, it is strict (no leading zeros, no out-of-range octets),
* and one bad entry makes it reject the *whole* rule-set - which would take
* the client's start with it. A malformed line is therefore skipped and
* reported, exactly like the blank lines and comments a list may carry. */
function is_v4(addr) {
const parts = split(addr, '.');
if (length(parts) != 4)
return false;
for (let part in parts) {
if (!match(part, /^[0-9]{1,3}$/))
return false;
/* Go's parser rejects "01"; match it so the file it accepts and
* the file we emit cannot disagree. */
if (length(part) > 1 && substr(part, 0, 1) == '0')
return false;
if (int(part) > 255)
return false;
}
return true;
}
function is_v6(addr) {
/* Deliberately structural, not exhaustive: the point is to keep a
* malformed line out, and the strict parser on the other side is the
* authority for what a valid address is. */
if (!match(addr, /^[0-9a-fA-F:]+$/))
return false;
if (!match(addr, /:/))
return false;
if (match(addr, /:::/))
return false;
return true;
}
function valid_cidr(entry) {
const slash = index(entry, '/');
if (slash <= 0)
return false;
const addr = substr(entry, 0, slash);
const bits = substr(entry, slash + 1);
if (!match(bits, /^[0-9]{1,3}$/))
return false;
if (is_v4(addr))
return int(bits) <= 32;
if (is_v6(addr))
return int(bits) <= 128;
return false;
}
const prefixes = [];
let skipped = 0;
for (let line in split(data, /\n/)) {
const entry = trim(line);
if (entry == '')
continue;
/* The updater's lists are bare CIDRs, but a stray comment or a host
* address must not make sing-box reject the whole rule-set at start. */
if (match(entry, /^[#;]/))
continue;
if (!valid_cidr(entry)) {
skipped++;
continue;
}
push(prefixes, entry);
}
if (skipped > 0)
warn(sprintf('skipped %d malformed entries in %s\n', skipped, src));
if (length(prefixes) == 0) {
warn(sprintf('no usable CIDR 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: [
{ ip_cidr: prefixes }
]
});
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 prefixes\n', length(prefixes));
@@ -127,6 +127,24 @@ hp_prepare_runtime_files() {
local client_enabled="$4"
local server_enabled="$5"
# The route rule that keeps mainland destinations direct matches a local
# rule-set generated from china_ip4.txt, the same list the firewall
# renders its nft set from, so both sides decide from one source. It is
# regenerated here as well as by the resource updater: an install that
# predates this file (or a list replaced while the service was stopped)
# must still get one, or the client config would reference a missing
# file and sing-box would refuse to start. A missing source list is not
# an error - the generator then emits no route-side rule at all.
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then
if ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \
"$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json" >>"$LOG_PATH" 2>&1; then
chown sing-box:sing-box "$hp_dir/resources/china_ip4.json" 2>"/dev/null" \
|| log "Warning: failed to hand ${hp_dir}/resources/china_ip4.json to sing-box."
else
log "Warning: could not generate ${hp_dir}/resources/china_ip4.json; the route 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.
@@ -219,13 +237,16 @@ hp_procd_client_instance() {
# guessed subset. procd orders the mounts by path, so this parent is
# bound before the read-write cache.db below and cannot shadow it.
procd_add_jail_mount "$hp_dir/"
# cache_file is enabled for bypass_mainland_china and custom alike
# (generator/common.uc:attachExperimental), so both need the file
# writable inside the jail.
case "$routing_mode" in
"bypass_mainland_china"|"custom")
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
esac
# cache_file is emitted unconditionally (generator/common.uc:
# attachExperimental takes the routing mode and never reads it), so
# every mode's sing-box opens this file and dies with
# initialize cache-file: open /etc/homeproxy-pro/cache.db: read-only file system
# if it is bound read-only - which is what the mode-gated mount here
# did for global / gfwlist / proxy_mainland_china. The instance then
# failed its health gate and the intercept layer was reverted, so
# switching to one of those modes took the whole network off the
# proxy.
procd_add_jail_mount_rw "$hp_dir/cache.db"
procd_add_jail_mount "$hp_dir/certs/"
# The certificate path gate accepts /etc/acme as well (a client TLS
# certificate managed by acme.sh), so the client jail has to carry it