💐 Sync 2026-10-06 12:33:44

This commit is contained in:
github-actions[bot] committed 2026-10-06 12:33:44 +08:00
1 parent 8be03ce939
commit 7fe69dbb3a
246 files changed
+50330 -3414

No files matched your search

@@ -601,6 +601,108 @@ hp_capture_candidate() {
return 0
}
# hp_reload_is_noop <run-dir> <good-dir> <client-enabled> <server-enabled>
# 0 when this reload would change nothing that is currently running, so the
# caller can skip the stop;start entirely.
#
# Why this exists: reload_service used to stop and start unconditionally, and
# hp_same_file was only ever used on the rollback path. Measured on the device,
# one reload costs about eight seconds (09:17:40 -> 09:17:48 in the log) and
# interrupts every connection, even when the generated configuration is
# byte-identical to the one already running. That is the common case for
# "Save & Apply" with nothing edited, for a node switch that lands on the same
# node, and for the nightly resource refresh.
#
# THE COMPARISON IS AGAINST known-good, NOT AGAINST THE CANDIDATE, and getting
# that wrong silently disables the whole mechanism. Measured on the device:
# the generator writes straight to $RUN_DIR/sing-box-c.json and
# hp_capture_candidate() copies that same file afterwards, so after a preflight
# the candidate and the live file are the SAME BYTES BY CONSTRUCTION - comparing
# them reports "unchanged" for every reload, including one that switched the main
# node. The first version of this helper did exactly that, and the only reason
# it was caught is an on-device test that switched nodes and watched the PID.
#
# known-good is the right counterpart for the same reason the rollback path uses
# it: hp_promote_known_good writes it ONLY after the health gate has passed, so
# it is by definition "the configuration that is currently running and working".
# live vs known-good answers the question this helper is actually asking - "would
# the running configuration change?" - and a missing known-good simply falls
# through to a real reload.
#
# Every condition is a *precondition for the skip being safe*, and the default
# on any doubt is to reload:
#
# [1] the service is actually running. A live file left over from a crashed
# instance is not a running service, and skipping the restart would
# report success over a dead proxy. Asked of ubus, the same source the
# status page reads.
# [2] the side has both a live file and a known-good copy to compare against.
# A side the user just enabled has neither, and that is a change, not a
# no-op.
# [3] the intercept layer is installed. A released layer (the marker
# hp_release_stale_intercept writes) means the firewall is NOT proxying
# right now even though sing-box is up. Skipping the restart would leave
# it that way and log "nothing to restart" - the exact silent state
# hp_rearm_intercept exists to undo. Reloading is what re-arms it.
hp_reload_is_noop() {
local run_dir="$1"
local good_dir="$2"
local client_enabled="$3"
local server_enabled="$4"
local live good
# [3] first, because it is the cheapest and the most dangerous to skip.
[ ! -f "$run_dir/intercept-released" ] || return 1
# [1] the instances. `command -v ubus` because this module is also sourced
# by the off-target test driver, where ubus does not exist and the honest
# answer there is "cannot prove it is a no-op".
#
# The check is on `"running": true`, NOT on the instance merely appearing
# in the list: procd keeps a stopped instance in `service list` with
# running=false, so matching the key alone reports a dead proxy as running
# and the skip then claims success over it. That was not theoretical - the
# first version of this grep did exactly that and its own test caught it.
command -v ubus > "/dev/null" 2>&1 || return 1
local svc_list
svc_list="$(ubus call service list "{\"name\":\"$CONF\"}" 2>/dev/null)"
if [ "$client_enabled" = "1" ]; then
printf '%s' "$svc_list" \
| grep -A 3 '"sing-box-c"' \
| grep -q '"running"[[:space:]]*:[[:space:]]*true' || return 1
fi
if [ "$server_enabled" = "1" ]; then
printf '%s' "$svc_list" \
| grep -A 3 '"sing-box-s"' \
| grep -q '"running"[[:space:]]*:[[:space:]]*true' || return 1
fi
# [2] and the byte comparison, per side.
# [2] and the byte comparison, per side, against what is known to be running
# rather than against the candidate (see the note at the top of this
# function - the two are the same bytes by construction).
if [ "$client_enabled" = "1" ]; then
live="$run_dir/sing-box-c.json"
good="$good_dir/sing-box-c.json"
[ -s "$live" ] && [ -s "$good" ] || return 1
cmp -s "$live" "$good" 2>"/dev/null" || return 1
fi
if [ "$server_enabled" = "1" ]; then
live="$run_dir/sing-box-s.json"
good="$good_dir/sing-box-s.json"
[ -s "$live" ] && [ -s "$good" ] || return 1
cmp -s "$live" "$good" 2>"/dev/null" || return 1
fi
return 0
}
# hp_start_generated_config <side> <hp-dir> <run-dir> <good-dir>
# The start-path transaction for one side ("c" or "s"):
#
@@ -248,15 +248,41 @@ check_list_update() {
# 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"
# Normalise FIRST, generate second - and both inside this branch.
# The order is load-bearing, and it was previously wrong: the sed
# lived in the `case "china_list"` arm, i.e. after this function had
# already returned, so the generator was handed the RAW download.
# That is not a theoretical difference. Upstream ships `full:`
# prefixes and the colon-carrying `regexp:` / `keyword:` forms
# still in the file - 562 of 111,361 lines in the release measured
# here - and domain_ruleset.uc rejects every entry containing a
# colon, so 554 real domains were dropped from the DNS split and
# the only trace was a "skipped N malformed entries" line naming a
# list that was perfectly well formed.
#
# Not `sed -i`: that form is a busybox/GNU extension, and the same
# script is exercised off-device where a non-busybox sed fails it
# with "invalid command code". Edit through a temp file, the way
# the crontab helper in runtime/service.sh does.
#
# A normalisation that fails does not undo the install - the list
# on disk is the one the upstream commit vouched for, and the
# firewall renders its nft set from that very file. The generator
# is skipped instead, so the DNS side keeps the previous rule-set
# rather than being rebuilt from a list the sed only half-processed.
if sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/$listtype.txt" \
> "$RESOURCES_DIR/$listtype.txt.hp-new" \
&& mv -f "$RESOURCES_DIR/$listtype.txt.hp-new" "$RESOURCES_DIR/$listtype.txt"; then
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
else
log "[CHINA_LIST] Warning: could not regenerate china-domain.json (list has no usable domain entry?); the DNS side keeps the previous list."
rm -f "$RESOURCES_DIR/$listtype.txt.hp-new"
log "[CHINA_LIST] Warning: could not normalise the downloaded list; it is installed as downloaded and the DNS side keeps the previous rule-set."
fi
;;
esac
@@ -280,10 +306,13 @@ case "$1" in
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "gfw.txt"
;;
"china_list")
# Not `sed -i`: the bare -i form is a busybox/GNU extension, and the same
# script is exercised off-device where a non-busybox sed fails it with
# "invalid command code". Edit through a temp file, the way the crontab
# helper in runtime/service.sh does.
# The `full:` prefixes and the colon-carrying regexp:/keyword: forms are
# stripped inside check_list_update, BEFORE the DNS-side rule-set is
# generated from the list - domain_ruleset.uc rejects any entry containing
# a colon, so the order decides whether 554 real domains are in the split
# or silently skipped. See the case branch there; this arm is only the
# list's own upstream coordinates.
#
# The status has to survive the post-processing. As one &&...|| chain it
# did not: when check_list_update returned 3 ("already current") or 1
# (fetch failed) the chain short-circuited into `rm -f`, whose status 0
@@ -293,18 +322,7 @@ case "$1" in
# Measured on the device: china_list exited 0 for both the failure and the
# up-to-date case, while china_ip4 exited 1 / 3 correctly.
check_list_update "$1" "Loyalsoldier/v2ray-rules-dat" "release" "direct-list.txt"
rc=$?
[ "$rc" = "0" ] || exit "$rc"
# 0 means a new list was just installed; strip the `full:` prefixes and the
# colon-carrying entries before anything else reads it.
if sed -e "s/full://g" -e "/:/d" "$RESOURCES_DIR/china_list.txt" > "$RESOURCES_DIR/china_list.txt.hp-new" \
&& mv -f "$RESOURCES_DIR/china_list.txt.hp-new" "$RESOURCES_DIR/china_list.txt"; then
exit 0
fi
rm -f "$RESOURCES_DIR/china_list.txt.hp-new"
log "[CHINA_LIST] Failed to normalise the downloaded list; leaving it as downloaded."
exit 1
exit $?
;;
*)
printf '%s\n' "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
@@ -77,10 +77,32 @@ import { Loader } from './config/loader.uc';
* A2: the run performs exactly one uci.commit(). The Repository modules stage
* mutations only.
*
* UCICONFIG_DIR (homeproxy-pro.uc) is the config *directory* Loader.load() takes,
* and the sandboxed test points it at its own config instead of /etc/config
* (tests/ucode/test_subscription_updater_runs.sh rewrites that line in
* homeproxy-pro.uc and fails loudly if the rewrite did not apply). */
* UCICONFIG_DIR (homeproxy-pro.uc) is the config *directory* this script reads
* and writes, and the sandboxed test points it at its own config instead of
* /etc/config (tests/ucode/test_subscription_updater_runs.sh rewrites that
* line in homeproxy-pro.uc and fails loudly if the rewrite did not apply).
*
* That rewrite is only half the sandbox, and the half that was missing is the
* dangerous one. `cursor()` with no argument is a cursor on the REAL
* /etc/config, not on UCICONFIG_DIR - the constant and the cursor were never
* connected. Measured on the device:
*
* cursor() -> reads infra, config, control, subscription, and the
* four live node sections
* cursor('/tmp/t8cfg') -> reads nothing
*
* So the updater was reading the live configuration whatever the test staged,
* and `uci.commit(uciconfig)` - the one commit this script performs, at the
* end of a successful run - would have written the LIVE /etc/config/homeproxy-pro.
* The committed test only ever drove a run whose fetch failed before that
* commit, which is why nothing had touched the device yet. Any future case
* that lets a fetch succeed would have committed the test's sandbox nodes over
* the user's real configuration.
*
* Hence the same `dir` injection Loader.load() already uses (loader.uc:385):
* the cursor is built on UCICONFIG_DIR, so the constant the test already
* rewrites is finally the thing that decides where both the read and the write
* land. Production is unchanged - UCICONFIG_DIR is /etc/config there. */
const CONFIG_FILE = UCICONFIG_DIR + '/homeproxy-pro';
const uciconfig = 'homeproxy-pro';
@@ -97,7 +119,10 @@ let uci, loaded, sub, routing_mode,
/* Read everything the run needs, under the lock. Read-only: nothing here
* mutates, so a failure cannot leave a partial state behind. */
function load_locked_state() {
uci = cursor();
/* On UCICONFIG_DIR, not on the default. See the note on CONFIG_FILE:
* a bare cursor() here reads - and, at the end of a successful run,
* commits - the live /etc/config, no matter what the test staged. */
uci = cursor(UCICONFIG_DIR);
uci.load(uciconfig);
/* The orchestrator used to hold its own