🎨 Sync 2026-10-09 01:04:17

This commit is contained in:
github-actions[bot] committed 2026-10-09 01:04:17 +08:00
1 parent eb76e89012
commit 812da7f08b
154 files changed
+10613 -3987

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy-pro
PKG_VERSION:=28.10.1.14
PKG_RELEASE:=25
PKG_RELEASE:=26
LUCI_BASENAME:=homeproxy-pro
File diff suppressed because it is too large. Load diff
@@ -1 +1 @@
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
2026-10-08 32bbb6c45c63a491c571f98cd9d863d7fd6771dc
File diff suppressed because it is too large. Load diff
@@ -1 +1 @@
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
2026-10-08 32bbb6c45c63a491c571f98cd9d863d7fd6771dc
@@ -57,9 +57,9 @@ pick_mirror() {
# GitHub raw serves paths from the repo root, not the
# /gh/<repo>@<sha>/<file> shape jsdelivr uses. The caller
# passes the suffix already split out, so we re-stitch it.
local probe_url="https://$base/$listrepo/$list_sha/$listname"
local probe_url="https://$base/$listrepo/$list_sha/$api_path"
else
local probe_url="https://$base/gh/$listrepo@$list_sha/$listname"
local probe_url="https://$base/gh/$listrepo@$list_sha/$api_path"
fi
# uclient-fetch, not wget. wget is whichever implementation the
# buildroot compiled, and the two share almost no options: on a
@@ -82,11 +82,116 @@ pick_mirror() {
return 1
}
# Put a verified download in place as $listtype.<ext>.
#
# With a `family` set the upstream file is one combined list - cn.list carries
# both address families - and each listtype is one half of it, so the bytes are
# split rather than moved. That also overrides the "<listtype>.<upstream
# extension>" naming: cn.list would otherwise land as china_ip4.list, while
# every reader - firewall_post.ut's nft set and china_ip_ruleset.uc's generated
# rule-set - has always read .txt. Splitting writes $listtype.txt directly, so
# nothing downstream changes name or location.
#
# The split writes a sibling temp file and renames it, so a half-written list
# can never become the installed one. An empty half is refused: an empty
# china_ip6.txt turns v6_handled off outright (see the comment on
# cn_ipv6_ready in firewall_post.ut), and a router that silently stops proxying
# IPv6 is worse than one that keeps the list it had.
#
# The family test is "does the address contain a colon", which is exactly what
# separates an IPv6 CIDR from an IPv4 one, and it is what the rest of the
# pipeline already keys off - see isValidCIDR() in homeproxy-pro.uc.
install_download() {
local src="$1"
local listtype="$2"
local listname="$3"
local family="$4"
if [ -z "$family" ]; then
mv -f "$src" "$RESOURCES_DIR/$listtype.${listname##*.}"
return $?
fi
local dest="$RESOURCES_DIR/$listtype.txt"
if ! awk -v want="$family" '
NF == 0 { next }
{ if ((index($1, ":") > 0) == (want == "v6")) print }
' "$src" > "$dest.hp-new"; then
rm -f "$dest.hp-new"
return 1
fi
if [ ! -s "$dest.hp-new" ]; then
rm -f "$dest.hp-new"
log "[$(to_upper "$listtype")] Refusing to install: $listname carries no $family entries."
return 1
fi
mv -f "$dest.hp-new" "$dest"
}
# Re-render the firewall's mainland sets from the list that just changed.
#
# The route side goes live on its own - sing-box watches the generated
# rule-set with fswatch - but homeproxy_mainland_addr_v4/v6 live in
# fw4_post.nft, which firewall_post.ut renders and only a start or a restart
# puts in place. reload does not re-render it (measured on the device: the
# command returns 0, the file's mtime does not move, the set keeps its old
# elements). So the two halves would read the same file on different days,
# and the gap grows with every nightly update.
#
# The direction that matters is the one where the kernel is *ahead* of the
# file: a segment the list has since dropped still matches the set, and
# `ip daddr @homeproxy_mainland_addr_v4 counter return` returns before the
# redirect - so the connection never reaches the route side that would have
# corrected it. The other direction (the set missing a newly added segment)
# costs one extra hop into sing-box and still routes correctly.
#
# This mirrors hp_firewall_apply() in runtime/firewall.sh minus the pre-script
# and the upnp restore: only the post-template carries these sets, and neither
# of the other two has anything to do with a resource list. It deliberately
# does NOT test client_enabled the way start_service does - that check reads
# a routing-mode-dependent helper, and duplicating it here would let the two
# copies drift. With no client the template renders what start would have
# rendered, so re-rendering is a no-op rather than a risk.
#
# Every failure is non-fatal on purpose. A list that is installed but not yet
# in the firewall is the one state this script is allowed to leave behind:
# the next nightly run, or the next service start, closes it. Failing the
# update instead would turn a cosmetic lag into a resource that can never
# refresh.
sync_firewall_sets() {
local dest="$RUN_DIR/fw4_post.nft"
if ! utpl -S "$SCRIPT_DIR/firewall_post.ut" > "$dest.new" 2>"/dev/null"; then
rm -f "$dest.new"
log "[$(to_upper "$listtype")] Warning: could not re-render fw4_post.nft; the firewall keeps its previous ruleset. Run /etc/init.d/homeproxy-pro restart to pick this list up."
return 1
fi
mv -f "$dest.new" "$dest"
if ! fw4 reload >"/dev/null" 2>&1; then
log "[$(to_upper "$listtype")] Warning: fw4 reload failed; the new set is rendered but the running ruleset was not updated. Run /etc/init.d/homeproxy-pro restart."
return 1
fi
log "[$(to_upper "$listtype")] Firewall mainland sets re-rendered."
}
check_list_update() {
local listtype="$1"
local listrepo="$2"
local listref="$3"
local listname="$4"
# v4 / v6 when the upstream file holds both families and has to be split;
# empty when it is already exactly what listtype wants.
local family="${5:-}"
# Where the file lives *inside* the repo, for the commits?path= filter.
# Equal to the file name for every root-level list, which is all of them
# used to be - that is why this went unnoticed until a source shipped its
# list in a subdirectory. MetaCubeX keeps cn.list under geo/geoip/, and
# asking the API about "cn.list" there is not a narrower question, it is a
# different one: it matches no commit, returns [], and the update reports
# "Failed to get the latest version" on every run while the file itself is
# perfectly reachable. Downloaded as $listname regardless.
local api_path="${6:-$listname}"
local lock="$RUN_DIR/update_resources-$listtype.lock"
local github_token="$(uci -q get homeproxy-pro.config.github_token)"
local fetch="uclient-fetch -q --timeout=10"
@@ -121,7 +226,7 @@ check_list_update() {
# failure as "Failed to get the latest version, please retry later", which
# loses the difference between "could not fetch" and "fetched, got nothing".
local list_info
list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$listname&per_page=1")"
list_info="$($fetch ${github_header:+--header="$github_header"} -O- "https://api.github.com/repos/$listrepo/commits?sha=$listref&path=$api_path&per_page=1")"
local fetch_exit=$?
if [ $fetch_exit -ne 0 ]; then
@@ -159,9 +264,9 @@ check_list_update() {
fi
local mirror_url
if [ "$mirror" = "raw.githubusercontent.com" ]; then
mirror_url="https://raw.githubusercontent.com/$listrepo/$list_sha/$listname"
mirror_url="https://raw.githubusercontent.com/$listrepo/$list_sha/$api_path"
else
mirror_url="https://$mirror/gh/$listrepo@$list_sha/$listname"
mirror_url="https://$mirror/gh/$listrepo@$list_sha/$api_path"
fi
log "[$(to_upper "$listtype")] Downloading from $mirror."
@@ -184,7 +289,7 @@ check_list_update() {
# nobody vouched for - is the thing being fixed.
local api_blob local_blob
api_blob="$($fetch ${github_header:+--header="$github_header"} -O- \
"https://api.github.com/repos/$listrepo/contents/$listname?ref=$list_sha" \
"https://api.github.com/repos/$listrepo/contents/$api_path?ref=$list_sha" \
| jsonfilter -qe '@.sha')"
if [ -z "$api_blob" ]; then
rm -f "$RUN_DIR/$listname"
@@ -205,7 +310,11 @@ check_list_update() {
return 1
fi
if mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"; then
if install_download "$RUN_DIR/$listname" "$listtype" "$listname" "$family"; then
# The split path reads the download and writes the halves, so the
# download itself is still lying in RUN_DIR; the plain move consumed
# it. Either way nothing may be left there for the next run.
rm -f "$RUN_DIR/$listname"
printf '%s\n' "$list_ver" > "$RESOURCES_DIR/$listtype.ver"
# Review M7: persist the time *this router* last succeeded.
# $list_date is the upstream commit date and can be months old
@@ -241,6 +350,12 @@ check_list_update() {
# "mainland IPv6 goes through the proxy" inversion.
log "[$(to_upper "$listtype")] Warning: could not regenerate $listtype.json (list has no usable CIDR entry?); the route side keeps the previous list."
fi
# The kernel half. Unconditional, and deliberately outside the
# if/else above: the nft set is rendered from the .txt, not from
# the .json, so it can be right even when the generator failed -
# and a failed generator is exactly when the list on disk is most
# likely to have changed. See sync_firewall_sets().
sync_firewall_sets
;;
"china_list")
# The DNS half of the same split. It reads this file rather than
@@ -288,7 +403,8 @@ check_list_update() {
esac
else
rm -f "$RUN_DIR/$listname"
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
rm -f "$RESOURCES_DIR/$listtype.txt.hp-new"
log "[$(to_upper "$listtype")] Failed to install update."
return 1
fi
@@ -296,11 +412,52 @@ check_list_update() {
}
case "$1" in
"china_ip4")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv4.txt"
;;
"china_ip6")
check_list_update "$1" "1715173329/IPCIDR-CHINA" "master" "ipv6.txt"
"china_ip4"|"china_ip6")
# Both come out of one upstream file and are split by address family - see
# install_download(). Upstream is MetaCubeX/meta-rules-dat's cn.list, pure
# CIDR text, which is what keeps the whole existing design intact: it is a
# file in a git repository, so the blob-id check above vouches for it the
# same way it vouches for every other list, and one text file feeds both the
# nft set and the generated route rule-set, so the two readers cannot drift.
#
# Why this source and not the 1715173329/IPCIDR-CHINA lists r46 shipped:
# the old pair missed 62,927,616 CN IPv4 addresses while cn.list misses
# 1,519,872. The biggest of those gaps are 59.192.0.0/21 and
# 175.48.0.0/21, Beijing Telecom backbone ranges, so the cost was not
# theoretical: destinations resolving into them missed the mainland rule
# and went to the proxy.
#
# cn.list's precision is slightly looser than the old lists' (99.22% of the
# addresses it lists are CN, against 98.92% before - i.e. it is better on
# both axes), because it merges neighbouring blocks a little more coarsely
# than MaxMind does. IPv6 goes from 1031 to 3446 entries, which also closes
# an overlap the old ipv6.txt had.
#
# CORRECTION (2026-10-08). The two paragraphs this replaces measured both
# sources against the APNIC **delegated** statistics and called that the
# authority - "the registration data every other list is derived from".
# It is not. That file's `cc` column is the registry's coarse summary and
# loses sub-allocations: it reports 8.128.0.0/10 as SG, while the registry's
# own RDAP object for that range says CN (ALICLOUD). So the "0.44% missing"
# figure was measured against a yardstick that is itself wrong, and cannot
# be used to argue this source is accurate. Against RDAP the two lists
# each have errors in OPPOSITE directions - of cn.list's 6163 IPv4 CIDRs,
# 1054 (about 100 million addresses) are CN per RDAP but absent here, while
# e.g. 117.134.222.0/23 is PK per RDAP and is wrongly listed as CN.
#
# What justifies this source is therefore narrower and more honest than the
# numbers it replaced: it measurably beats the r46 pair on both recall and
# precision, it is a file in a git repository so the blob-id check above
# vouches for it, and one text file feeds both readers so they cannot drift.
# The delegated statistics were evaluated as a *replacement* source and
# rejected - deriving the lists from them loses the ~100 million addresses
# above. To re-judge any source here, measure against RDAP
# (https://rdap.apnic.net/ip/<address>), which is the registry database
# rather than a summary of it.
# $api_path, not $listname: the commits filter asks where the file is in
# the repo, which for this source is not the repo root.
check_list_update "$1" "MetaCubeX/meta-rules-dat" "meta" "cn.list" \
"$([ "$1" = "china_ip4" ] && echo v4 || echo v6)" "geo/geoip/cn.list"
;;
"gfw_list")
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
+2 -2
View File
@@ -269,7 +269,7 @@ Two further host differences are bridged so the remaining checks still run:
| `tests/i18n-coverage.py` | Reports how many `po/templates/homeproxy-pro.pot` strings have a non-fuzzy, non-empty `po/zh_Hans/homeproxy-pro.po` translation. Technical tokens that stay as-is are listed in `tests/i18n-ignore.txt`; anything else missing fails the run (`--fail-below 100`) and adds a job-summary entry. The release workflow (`build.yml`) deliberately warns instead of failing, so a translation gap cannot block packaging - the PR gates already catch it. |
| `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node, client and server views. Diffs against `tests/snapshots/{node,client,server}.json`, so a refactor that changes a field or its visibility fails. |
| `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). |
| `tests/ucode/test_china_ip_ruleset.sh` | `runtime/china_ip_ruleset.uc`, the generator that turns `china_ip4.txt` into the `type: local` rule-set the route side matches mainland destinations with — the same list `firewall_post.ut` renders `homeproxy_mainland_addr_v4` from, so both sides decide from one source. The cases are the ones that break a router silently: a malformed line must be skipped (sing-box rejects the *whole* rule-set over one bad entry, and the client then will not start), the file must be replaced by rename (the running instance watches it with fswatch), and an empty or unreadable source must fail instead of installing a rule-set that matches nothing. Pins the bundled list's `8.152.0.0/13` — the range `geoip-cn.srs` does not carry, and the reason this exists. |
| `tests/ucode/test_china_ip_ruleset.sh` | `runtime/china_ip_ruleset.uc`, the generator that turns `china_ip4.txt` into the `type: local` rule-set the route side matches mainland destinations with — the same list `firewall_post.ut` renders `homeproxy_mainland_addr_v4` from, so both sides decide from one source. The cases are the ones that break a router silently: a malformed line must be skipped (sing-box rejects the *whole* rule-set over one bad entry, and the client then will not start), the file must be replaced by rename (the running instance watches it with fswatch), and an empty or unreadable source must fail instead of installing a rule-set that matches nothing. Pins the bundled list's `8.152.0.0/13` — the range `geoip-cn.srs` does not carry, and the reason this exists — and, since the list changed source, that same assertion now also pins that the new upstream still carries it. The v6 half gets the same treatment: it is generated and checked for the absence of IPv4 entries, because `firewall_post.ut` reads a zero prefix count in `china_ip6.txt` as "IPv6 cannot be classified" and turns `v6_handled` off with nothing logged. |
| `tests/ucode/test_resource_blob_sha.sh` | `resource_blob_sha.uc`, the digest the resource updater verifies downloads with. The expected values are git's own (`git hash-object` on the same bytes), covering the two cases an implementation gets wrong quietly: the empty file (a real blob id, not "no digest") and content whose byte length differs from its character count. Unreadable input must produce *no* digest and exit non-zero - the updater treats an empty answer as "cannot verify" and refuses, so a made-up digest would turn an unreadable file into a passing check. Re-checks the repository's real resource files against git when the host has it. |
| `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy-pro.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. |
| `tests/ucode/test_tls_transport.uc` | Direct calls into `buildTLSObject()` / `buildTransportObject()`: client-vs-server-only fields (no `insecure` on a server inbound, no `utls` on a server, `public_key` only on the client and `private_key` only on the server for reality), the ECH client/server split, and the `validateHomeProxyPath()` gate on `cert_path` / `key_path`. This is the boundary the generator fixtures only exercised end-to-end, so it is where the "server emitted insecure=true" and "reality key on the wrong side" classes of bug get named. |
@@ -299,7 +299,7 @@ Two further host differences are bridged so the remaining checks still run:
| `tests/runtime/test_dns_snippets.sh` | The dnsmasq snippet writer's incremental behaviour (review L6). Counts `init.d/dnsmasq restart` calls through a stub and asserts that an identical second write does *not* restart (a restart flushes every client's DNS cache), while a changed resource list, routing mode, DNS port or `ipv6_support` setting does. Also pins that a mode change drops the snippet the previous mode produced, and that removing a resource list removes its snippet so the next call is stable again. Pure shell, runs anywhere. |
| `tests/runtime/test_health_probe.sh` | The functional health probes (`runtime/health.sh`): `hp_probe_dns` / `hp_probe_tcp` answer 0 / 1 / 2 and keep "cannot probe here" (no tool, no port) distinct from "the probe failed"; `hp_run_probes` *reports* by default and only fails the gate when `health_probe_strict` is on, and never fails it for a probe that could not run; `hp_config_port_for_tag` reads the probe ports out of the running configuration in both the generator's pretty shape and the compact one. `nslookup` and `nc` are stubbed, so it is pure shell and runs anywhere. |
| `tests/runtime/test_crontab_perms.sh` | `hp_crontab_drop()` (runtime/service.sh) edits `/etc/crontabs/root` through a temporary file, and `mv` replaces the file rather than its contents - so the new one inherited the shell's umask (0644) while procd creates the root crontab as 0600. The test drives the function against a sandboxed crontab and pins: the entry is dropped, other jobs survive, the mode is 0600 (including a file an older version had already loosened), a missing crontab is reported as a failure and is not created. Pure shell, runs anywhere. |
| `tests/runtime/test_resource_update.sh` | The content-verification path of `scripts/update_resources.sh`: the download URL is pinned to a commit, and the file is installed only when its git blob id matches the one GitHub reports for that path and commit. Drives all four outcomes (match, mismatch, no digest from the API, no local digest) with `uclient-fetch` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox, asserting in each failure case that the installed list and its `.ver` are left untouched - the point of the check is that it refuses, so a driver that only walked the happy path would keep passing with the check deleted. Pure shell, runs anywhere. |
| `tests/runtime/test_resource_update.sh` | The content-verification path of `scripts/update_resources.sh`: the download URL is pinned to a commit, and the file is installed only when its git blob id matches the one GitHub reports for that path and commit. Drives all four outcomes (match, mismatch, no digest from the API, no local digest) with `uclient-fetch` / `jsonfilter` / `ucode` / `uci` / `flock` stubbed and the script's absolute paths rewritten into a sandbox, asserting in each failure case that the installed list and its `.ver` are left untouched - the point of the check is that it refuses, so a driver that only walked the happy path would keep passing with the check deleted. Also covers the two post-processing steps that carry a failure silently: `china_list`'s normalisation ordering, the address-family split (both IP lists come out of one upstream file, so each has to land at its own `.txt` holding only its own family, the combined download must not be left in `RUN_DIR`, and a family with no entries has to be refused rather than installed empty), and the firewall re-render that closes the loop on the nft half. That last one is the reason a test exists at all: the route side goes live on fswatch while `homeproxy_mainland_addr_v4/v6` are rendered into `fw4_post.nft` and otherwise only a start/restart installs them, so "the list updated" and "the kernel sees it" are different events. Both directions are asserted — a successful update must re-render and call `fw4 reload`, and a failed render must leave the previous ruleset in place, must not reach `fw4`, and must not fail the update (a resource that can never refresh is worse than one briefly missing from the firewall). Pure shell, runs anywhere. |
| `tests/runtime/test_runtime_extraction.sh` | Drives `init.d/homeproxy-pro` through a stubbed environment (fake `ip` / `nft` / `fw4` / `ucode` / `sing-box` / `uci` / `netstat` / `nslookup` / `nc`, fake procd and jsonfilter state, fixture UCI) across five scenarios and diffs the resulting command + file trace against a baseline. Two baselines exist and are captured with the SAME harness, so the diff between them is exactly the intentional change: `tests/fixtures/runtime/trace.pre-pr05.txt` (the 517-line init script before PHASE 7 moved the plumbing out — the record that the extraction was behaviour-preserving) and `tests/fixtures/runtime/trace.golden.txt` (after the health-gate fix). Scenario D is the P0 regression: a candidate whose `mixed_port` is already taken must be rejected by the health gate, the previous known-good must survive, and the reload must roll back onto it. The generated fixture configuration is pretty-printed like the generator's own `%.J` output, because the runtime reads that file (`hp_config_ports`, `hp_config_port_for_tag`) and a compact fixture would test a shape production never writes. Pure shell, no ucode needed. Regenerate with `HP_UPDATE_GOLDEN=1` (optionally `HP_GOLDEN=` / `HP_INITD=` to target another baseline or revision). |
**`HP_UPDATE_GOLDEN=1` is a baseline-update mode, not a test.** It copies the
@@ -106,7 +106,24 @@ emit() {
fi
}
case "$url" in
*api.github.com/*/commits*) emit "$(cat "$HP_T_API_COMMITS")" ;;
*api.github.com/*/commits*)
# Filtered by path, the way the real endpoint is. Asking for a file
# that is not at that path is not a narrower question, it is a different
# one: it matches no commit and comes back []. That is the whole reason
# the in-repo path is a separate argument from the file name, and it is
# what this branch can now catch - every list this project ships used to
# live in the repo root, so path= and the file name were the same string
# and nothing could tell them apart.
case "$url" in
*repos/MetaCubeX/*)
case "$url" in
*"path=geo/geoip/cn.list"*) emit "$(cat "$HP_T_API_COMMITS")" ;;
*) emit '[]' ;;
esac
;;
*) emit "$(cat "$HP_T_API_COMMITS")" ;;
esac
;;
*api.github.com/*/contents*) emit "$(cat "$HP_T_API_CONTENTS")" ;;
*)
# A spider probe (-s) checks reachability only, and its stdout is captured
@@ -115,6 +132,19 @@ case "$url" in
# applet prints nothing for a probe.
[ "$probe" = "0" ] || exit 0
if [ -n "${HP_T_FETCH_FAIL:-}" ]; then exit 1; fi
# The same wrong-path mistake reaches the download as a 404, and
# pick_mirror turns that into "all mirrors unreachable" - a second
# symptom of the one bug, reported as an unrelated-looking network
# failure. Scoped to this source so the other lists, whose file name is
# their in-repo path, keep resolving.
case "$url" in
*MetaCubeX/meta-rules-dat*)
case "$url" in
*geo/geoip/cn.list*) ;;
*) exit 1 ;;
esac
;;
esac
emit "$(cat "$HP_T_BODY")"
;;
esac
@@ -179,6 +209,21 @@ cat > "$WORK/bin/flock" <<'EOF'
#!/bin/sh
exit 0
EOF
# utpl renders firewall_post.ut into the file named by -O-less stdout; the stub
# writes a marker line and honours a failure switch, which is all the two
# cases below need. fw4 records that it was called and honours its own.
cat > "$WORK/bin/utpl" <<'EOF'
#!/bin/sh
[ -n "${HP_T_UTPL_FAIL:-}" ] && exit 1
printf 'rendered-from-utpl\n'
exit 0
EOF
cat > "$WORK/bin/fw4" <<'EOF'
#!/bin/sh
[ -n "$HP_T_FW4_CALLED" ] && printf 'called\n' >> "$HP_T_FW4_CALLED"
[ -n "${HP_T_FW4_FAIL:-}" ] && exit 1
exit 0
EOF
chmod +x "$WORK/bin/"*
PATH="$WORK/bin:$PATH"
@@ -205,14 +250,20 @@ EOF
write_contents_json() {
# write_contents_json <blob-sha-or-empty>
# The upstream file is cn.list, not ipv4.txt: it carries both address
# families and each listtype takes one half of it (install_download).
if [ -n "$1" ]; then
printf '{"name":"ipv4.txt","size":6,"sha":"%s"}' "$1" > "$WORK/api-contents.json"
printf '{"name":"cn.list","size":6,"sha":"%s"}' "$1" > "$WORK/api-contents.json"
else
printf '{"name":"ipv4.txt","size":6}' > "$WORK/api-contents.json"
printf '{"name":"cn.list","size":6}' > "$WORK/api-contents.json"
fi
}
printf 'fresh\n' > "$WORK/body.txt"
# Mixed on purpose: china_ip4 must end up holding only the first line, and the
# IPv6 line is what the china_ip6 cases below split out. Keeping "fresh" as the
# single IPv4 line means the cases that only care about the blob-id handshake
# keep asserting on "fresh" unchanged.
printf 'fresh\n2001:db8::/32\n' > "$WORK/body.txt"
printf 'stale-installed-copy\n' > "$WORK/resources/china_ip4.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip4.ver"
@@ -269,7 +320,7 @@ expect "the .ver is untouched" "$(cat "$WORK/resources/china_ip4.ver")" "OLDVERS
expect "no .updated_at was written" "$([ -e "$WORK/resources/china_ip4.updated_at" ] && echo yes || echo no)" "no"
expect "the refusal is logged with both ids" \
"$(grep -c "does not match the content of commit $API_SHA" "$WORK/run/homeproxy-pro.log")" "1"
expect "the downloaded copy was discarded" "$([ -e "$WORK/run/ipv4.txt" ] && echo yes || echo no)" "no"
expect "the downloaded copy was discarded" "$([ -e "$WORK/run/cn.list" ] && echo yes || echo no)" "no"
echo "== case 3: no blob id from the API -> refused =="
reset_state
@@ -373,6 +424,109 @@ expect "the .ver is untouched" "$(cat "$WORK/resources/china_list.ver")" "OLDVER
expect "the generator was never called" \
"$([ -e "$HP_T_SEEN_SOURCE" ] && echo yes || echo no)" "no"
echo "== case 8: cn.list is split by address family =="
# Both IP lists come out of one upstream file now (MetaCubeX cn.list), so the
# install step splits rather than moves. What has to hold: each listtype ends
# up at its own .txt name with only its own family in it, and the combined
# download is not left lying in RUN_DIR for the next run to trip over.
reset_state
printf 'OLDVERSION' > "$WORK/resources/china_ip6.ver"
rm -f "$WORK/resources/china_ip6.txt"
# Set here rather than relying on the top-of-file fixture: case 6 overwrote
# body.txt with its own domain lines.
printf '1.0.0.0/24\n2001:db8::/32\n' > "$WORK/body.txt"
: > "$WORK/run/homeproxy-pro.log"
printf '%s' "$LOCAL_SHA" > "$HP_T_LOCAL_BLOB"
rc="$("$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip6 > "$WORK/stdout" 2>&1; echo $?)"
expect "exit status 0" "$rc" "0"
expect "china_ip6.txt was created by the split" \
"$(cat "$WORK/resources/china_ip6.txt" 2>/dev/null)" "2001:db8::/32"
expect "it holds no IPv4 entry" \
"$(grep -cE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/' "$WORK/resources/china_ip6.txt" || true)" "0"
expect "its .ver advanced like the v4 one" \
"$(cat "$WORK/resources/china_ip6.ver")" "2026-01-02 $API_SHA"
expect "the combined download was cleaned out of RUN_DIR" \
"$([ -e "$WORK/run/cn.list" ] && echo yes || echo no)" "no"
expect "no half-written temp file survived" \
"$([ -e "$WORK/resources/china_ip6.txt.hp-new" ] && echo yes || echo no)" "no"
echo "== case 9: a family with no entries is refused, not installed empty =="
# The failure this guards is silent: an empty china_ip6.txt makes
# firewall_post.ut count zero usable prefixes and turn v6_handled off, after
# which every mainland IPv6 connection goes to the proxy - with no error
# anywhere. Keeping the previous list is the only acceptable outcome.
reset_state
printf 'stale-v6-copy\n' > "$WORK/resources/china_ip6.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip6.ver"
printf 'only-v4-here/24\n' > "$WORK/body.txt"
: > "$WORK/run/homeproxy-pro.log"
printf '%s' "$LOCAL_SHA" > "$HP_T_LOCAL_BLOB"
rc="$("$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip6 > "$WORK/stdout" 2>&1; echo $?)"
expect "exit status non-zero" "$rc" "1"
expect "the previous v6 list is untouched" \
"$(cat "$WORK/resources/china_ip6.txt")" "stale-v6-copy"
expect "its .ver is untouched" "$(cat "$WORK/resources/china_ip6.ver")" "OLDVERSION"
expect "the reason is logged" \
"$(grep -c 'carries no v6 entries' "$WORK/run/homeproxy-pro.log")" "1"
expect "no empty temp file survived" \
"$([ -e "$WORK/resources/china_ip6.txt.hp-new" ] && echo yes || echo no)" "no"
echo "== case 10: a successful IP update re-renders the firewall sets =="
# The two halves of the mainland split read the same file but go live on
# different triggers: sing-box watches the generated rule-set, while the nft
# set is rendered into fw4_post.nft and otherwise only a start/restart puts
# it in place. Without this step the kernel's copy silently ages past the
# file's, and a segment the list has since dropped keeps matching
# `ip daddr @homeproxy_mainland_addr_v4 counter return` - which returns before
# the redirect, so the route side never gets to correct it.
reset_state
printf 'v4-here/24\n2001:db8::/32\n' > "$WORK/body.txt"
printf 'stale-rendered-ruleset\n' > "$WORK/run/fw4_post.nft"
rm -f "$WORK/run/fw4_post.nft.new"
export HP_T_FW4_CALLED="$WORK/fw4-called"
rm -f "$HP_T_FW4_CALLED"
: > "$WORK/run/homeproxy-pro.log"
rc="$("$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip4 > "$WORK/stdout" 2>&1; echo $?)"
expect "exit status 0" "$rc" "0"
expect "fw4_post.nft was re-rendered from the new list" \
"$(cat "$WORK/run/fw4_post.nft" 2>/dev/null)" "rendered-from-utpl"
expect "fw4 reload ran" \
"$(grep -c 'called' "$HP_T_FW4_CALLED" 2>/dev/null || echo 0)" "1"
expect "no temp file left behind" \
"$([ -e "$WORK/run/fw4_post.nft.new" ] && echo yes || echo no)" "no"
expect "the success is logged" \
"$(grep -c 'Firewall mainland sets re-rendered' "$WORK/run/homeproxy-pro.log")" "1"
echo "== case 11: a failed render is warned about, not fatal =="
# Failing the update would be worse than the lag it is reporting: the list on
# disk is already the one the commit vouched for, and a resource that can
# never refresh is a far bigger problem than one that is briefly not in the
# firewall. The previous ruleset has to survive, or a bad render takes the
# whole intercept layer down with it.
reset_state
printf 'v4-here/24\n2001:db8::/32\n' > "$WORK/body.txt"
printf 'known-good-ruleset\n' > "$WORK/run/fw4_post.nft"
rm -f "$WORK/run/fw4_post.nft.new"
export HP_T_UTPL_FAIL=1
: > "$WORK/run/homeproxy-pro.log"
# Cleared because the point of this case is that a failed render must not
# reach fw4 at all - inheriting case 10's record would make the assertion
# pass for the wrong reason if the branch started reloading anyway.
rm -f "$HP_T_FW4_CALLED"
rc="$("$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip4 > "$WORK/stdout" 2>&1; echo $?)"
unset HP_T_UTPL_FAIL
expect "the update still succeeded" "$rc" "0"
expect "the list was still installed" \
"$(cat "$WORK/resources/china_ip4.txt")" "v4-here/24"
expect "the previous ruleset is untouched" \
"$(cat "$WORK/run/fw4_post.nft")" "known-good-ruleset"
expect "no half-written temp file survived" \
"$([ -e "$WORK/run/fw4_post.nft.new" ] && echo yes || echo no)" "no"
expect "fw4 was NOT reloaded" \
"$(grep -c 'called' "$HP_T_FW4_CALLED" 2>/dev/null || echo 0)" "0"
expect "the warning names the manual recovery" \
"$(grep -c 'could not re-render fw4_post.nft' "$WORK/run/homeproxy-pro.log")" "1"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != "0" ]; then
echo "RESOURCE UPDATE TESTS FAILED"
@@ -104,12 +104,24 @@ expect "output carries a version" \
# The list this ships with is what the firewall renders its nft set from; a
# generator that mangles it would break both sides at once.
expect "the bundled china_ip4.txt generates" \
"$(generate "$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$WORK/real.json")" "4068 prefixes"
"$(generate "$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$WORK/real.json")" "6163 prefixes"
# 8.152.0.0/13 is the range geoip-cn.srs does *not* carry, and the one that
# made segmentfault.com and ctrip.com resolve into the proxy path.
# made segmentfault.com and ctrip.com resolve into the proxy path. The list
# changed source since, so this is now also the assertion that the new upstream
# still carries it rather than having merged it away.
expect "the bundled list carries 8.152.0.0/13" \
"$(grep -c '8\.152\.0\.0/13' "$WORK/real.json")" "1"
# The v6 half, which used to be the untested one. It matters for the same
# reason: firewall_post.ut counts usable prefixes in china_ip6.txt and turns
# v6_handled off entirely when the count is zero, which silently sends every
# mainland IPv6 connection down the proxy. A generator that emitted an empty
# v6 rule-set would look identical from the config.
expect "the bundled china_ip6.txt generates" \
"$(generate "$ROOT/root/etc/homeproxy-pro/resources/china_ip6.txt" "$WORK/real6.json")" "3446 prefixes"
expect "the v6 rule-set carries no IPv4 entry" \
"$(grep -cE '"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/' "$WORK/real6.json")" "0"
# --- failure paths ----------------------------------------------------------
# An empty source must not install an empty rule-set: the caller would then be