mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 22:52:51 +08:00
679 lines
28 KiB
Bash
679 lines
28 KiB
Bash
# SPDX-License-Identifier: GPL-2.0-only
|
|
#
|
|
# Copyright (C) 2025 ImmortalWrt.org
|
|
#
|
|
# Service-side runtime helpers for homeproxy-pro.
|
|
#
|
|
# These are the device-side orchestration pieces that used to be inlined in
|
|
# /etc/init.d/homeproxy-pro: the sing-box version gate, the auto-update cron
|
|
# entry, the runtime-file preparation, the procd instance registrations and
|
|
# the "generate -> known-good" transaction that start_service runs for each
|
|
# side.
|
|
#
|
|
# Layering note: unlike
|
|
# runtime/config.sh and runtime/health.sh - which are deliberately pure and
|
|
# only take explicit paths - the extracted modules are device-side
|
|
# by nature. They call `log`, `config_get` and `procd_*`, so a caller must
|
|
# have run `config_load` and must be inside a procd init context. That is
|
|
# exactly why they were moved out of init.d and not turned into pure helpers.
|
|
#
|
|
# Sourced by /etc/init.d/homeproxy-pro.
|
|
|
|
# hp_require_singbox
|
|
# Refuse to start on a sing-box older than 1.14: the generated configuration
|
|
# uses 1.14-only fields, and an older binary would reject it at run time
|
|
# instead of failing here. Returns 1 (and logs) when unusable.
|
|
#
|
|
# `sing-box` is looked up through PATH, exactly as the previous init script
|
|
# did - it is deliberately not PROG, so a PATH mismatch between the two is
|
|
# not silently introduced by this refactor.
|
|
hp_require_singbox() {
|
|
local sb_ver sb_major sb_minor
|
|
|
|
sb_ver="$(sing-box version -n 2>/dev/null)"
|
|
if [ -z "$sb_ver" ]; then
|
|
log "Error: cannot detect sing-box version, abort."
|
|
return 1
|
|
fi
|
|
|
|
sb_major="${sb_ver%%.*}"
|
|
sb_minor="${sb_ver#*.}"
|
|
sb_minor="${sb_minor%%.*}"
|
|
|
|
if [ "$sb_major" -lt 1 ] || { [ "$sb_major" -eq 1 ] && [ "$sb_minor" -lt 14 ]; }; then
|
|
log "Error: sing-box >= 1.14.0 required, found ${sb_ver}."
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# hp_require_ucode
|
|
# Refuse to start when the installed ucode cannot load this package's own
|
|
# modules at all. Returns 1 (and logs) when the interpreter is unusable.
|
|
#
|
|
# Why a check and not a documented requirement: ucode resolves `import` when a
|
|
# module is LOADED, not when a function is called, and homeproxy-pro.uc imports
|
|
#
|
|
# import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
|
|
#
|
|
# so on a ucode whose fs module has no `mkdtemp` export, *every* entry point
|
|
# into this package dies with the same one-line reference error at load time -
|
|
# the install-time migration (etc/uci-defaults/luci-homeproxy-pro-migration),
|
|
# both config generators, and the LuCI RPC module that imports homeproxy-pro.uc
|
|
# too. The first of those to run is the migration, which is why the only
|
|
# thing an affected user ever saw was a stack trace during `apk add` /
|
|
# `opkg install` (issue #3) and never a sentence naming the requirement.
|
|
#
|
|
# `mkdtemp` entered ucode on 2025-11-07 (lib/fs.c, "fs: add mkdtemp() method
|
|
# for creating temporary directories"). It replaced `mkstemp`, which every
|
|
# older ucode does export - so this is a hard floor, not a preference, and no
|
|
# ImmortalWrt 24.10 release can satisfy it.
|
|
#
|
|
# The probe is `ucode -e`, the same form tests/ucode/run.sh uses for its
|
|
# module syntax checks. Its own output is not discarded: if this ucode is so
|
|
# old that even the probe fails, that is exactly the information the operator
|
|
# needs, so the error goes into the log next to the requirement.
|
|
hp_require_ucode() {
|
|
local probe_out
|
|
|
|
probe_out="$(ucode -e "import * as fs from 'fs'; exit('mkdtemp' in keys(fs) ? 0 : 1)" 2>&1)" && return 0
|
|
|
|
log "Error: this build needs a ucode whose fs module exports mkdtemp (added in ucode 2025-11-07, shipped in ImmortalWrt 25.12); the installed ucode does not provide it."
|
|
log "Error: every script in this package imports it, so the service, the config generators and the LuCI RPC all fail to load on this firmware."
|
|
[ -n "$probe_out" ] && log "Error: the capability check itself reported: ${probe_out}"
|
|
log "Hint: this firmware's ucode is too old for this package; ImmortalWrt 25.12 or newer is required."
|
|
|
|
return 1
|
|
}
|
|
|
|
# hp_crontab_drop <crontab>
|
|
# Remove the auto-update entry 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
|
|
# behind with only a warning in the log. Editing through a temporary file
|
|
# 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).
|
|
hp_crontab_drop() {
|
|
local crontab="$1"
|
|
local tmp="${crontab}.hp-new"
|
|
|
|
sed "/#${CONF}_autosetup/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
|
|
# shell (0666 & ~umask = 0644) while procd creates /etc/crontabs/root as
|
|
# 0600, so every stop/start left the root crontab world-readable - verified
|
|
# on the device (0600 after boot, 0644 after one reload). Only root needs
|
|
# to read it (crond runs as root), and a root crontab can hold other jobs'
|
|
# secrets, so set the mode rather than inherit it - which also repairs a
|
|
# file an older version already loosened.
|
|
chmod 600 "$crontab" 2>"/dev/null"
|
|
|
|
return 0
|
|
}
|
|
|
|
# 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".
|
|
hp_sync_autoupdate_cron() {
|
|
local auto_update="$1"
|
|
local auto_update_time="$2"
|
|
|
|
[ "$auto_update" = "1" ] || return 0
|
|
|
|
# auto_update_time comes from UCI, which anything on the LAN can write, and
|
|
# it used to be interpolated into a root crontab line verbatim: a value
|
|
# like '2 * * * * root echo pwned #' appends a second job. Validate it
|
|
# before the line exists, and write it with printf rather than `echo -e`
|
|
# so no backslash sequence is interpreted either.
|
|
case "$auto_update_time" in
|
|
''|*[!0-9]*)
|
|
log "Warning: auto_update_time='$auto_update_time' is not an hour of the day, skipping the cron entry."
|
|
return 1
|
|
;;
|
|
esac
|
|
if [ "$auto_update_time" -gt 23 ]; then
|
|
log "Warning: auto_update_time=$auto_update_time is out of range (0-23), skipping the cron entry."
|
|
return 1
|
|
fi
|
|
|
|
hp_crontab_drop "/etc/crontabs/root" \
|
|
|| 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" \
|
|
|| log "Warning: failed to install the auto-update cron entry."
|
|
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
|
}
|
|
|
|
# hp_clear_autoupdate_cron
|
|
# Drop the auto-update cron entry and restart cron. Used by stop_service.
|
|
hp_clear_autoupdate_cron() {
|
|
hp_crontab_drop "/etc/crontabs/root" \
|
|
|| log "Warning: failed to drop the auto-update cron entry."
|
|
/etc/init.d/cron restart >"/dev/null" 2>&1 || log "Warning: failed to restart cron."
|
|
}
|
|
|
|
# hp_prepare_runtime_files <hp-dir> <run-dir> <routing-mode> <client> <server>
|
|
# Create the mode-specific working files, truncate the instance logs and hand
|
|
# every runtime file to the sing-box user. <client>/<server> are "1"/"0".
|
|
hp_prepare_runtime_files() {
|
|
local hp_dir="$1"
|
|
local run_dir="$2"
|
|
local routing_mode="$3"
|
|
local client_enabled="$4"
|
|
local server_enabled="$5"
|
|
|
|
# The route rules that keep mainland destinations direct match 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
|
|
|
|
# IPv6 half of the same arrangement, and the only place the "IPv6 is on
|
|
# but cannot be classified" condition is reported in a place a user reads
|
|
# without tcpdump. geoip-cn.srs and china_ip4.json are both IPv4-only, so
|
|
# without a v6 rule-set a mainland destination reached over IPv6 matches
|
|
# no route rule at all and falls through to `final` - i.e. to the proxy.
|
|
# The firewall would normally have returned it first, but that set is
|
|
# filled from the same china_ip6.txt, so a list that is missing or has no
|
|
# usable prefix leaves neither side able to classify it. Say so plainly
|
|
# instead: the generated ruleset also carries the same text as a rule
|
|
# comment, so `nft list ruleset` shows the degraded state too.
|
|
local ipv6_support cn_ipv6
|
|
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
|
cn_ipv6=0
|
|
if [ -f "$hp_dir/resources/china_ip6.txt" ]; then
|
|
cn_ipv6="$(grep -cE '^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]{1,3})?[[:space:]]*$' \
|
|
"$hp_dir/resources/china_ip6.txt" 2>"/dev/null")"
|
|
[ -n "$cn_ipv6" ] || cn_ipv6=0
|
|
if [ "$cn_ipv6" -gt 0 ] && ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \
|
|
"$hp_dir/resources/china_ip6.txt" "$hp_dir/resources/china_ip6.json" >>"$LOG_PATH" 2>&1; then
|
|
chown sing-box:sing-box "$hp_dir/resources/china_ip6.json" 2>"/dev/null" \
|
|
|| log "Warning: failed to hand ${hp_dir}/resources/china_ip6.json to sing-box."
|
|
else
|
|
# Only reached when the source is unusable, which china_ip_ruleset.uc
|
|
# reports as "no usable CIDR entries". Keep the previous file: an
|
|
# empty one would make the route-side set match nothing, which is the
|
|
# same silent inversion this whole branch exists to prevent.
|
|
log "Warning: could not generate ${hp_dir}/resources/china_ip6.json; the route side keeps the previous list."
|
|
fi
|
|
fi
|
|
if [ "$ipv6_support" -eq 1 ] && [ "$cn_ipv6" -eq 0 ]; then
|
|
log "WARNING: IPv6 support is ON but ${hp_dir}/resources/china_ip6.txt has 0 usable prefixes."
|
|
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
|
|
|
|
# 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.
|
|
case "$routing_mode" in
|
|
"bypass_mainland_china"|"custom")
|
|
[ -e "$hp_dir/cache.db" ] || touch "$hp_dir/cache.db" \
|
|
|| log "Warning: failed to create ${hp_dir}/cache.db."
|
|
;;
|
|
esac
|
|
|
|
if [ "$routing_mode" = "custom" ]; then
|
|
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|
|
|| log "Warning: failed to create ${hp_dir}/ruleset."
|
|
# A local rule-set is opened by the client, which no longer runs as
|
|
# root now that the jail is on for this mode too, so the files have to
|
|
# be readable by the sing-box user. Only the documented directory is
|
|
# claimed here; a rule-set placed elsewhere under HP_DIR has to be
|
|
# readable already.
|
|
chown -R sing-box:sing-box "$hp_dir/ruleset" 2>"/dev/null" \
|
|
|| log "Warning: failed to hand ${hp_dir}/ruleset to sing-box."
|
|
fi
|
|
|
|
[ "$client_enabled" = "1" ] && echo > "$run_dir/sing-box-c.log"
|
|
if [ "$server_enabled" = "1" ]; then
|
|
echo > "$run_dir/sing-box-s.log"
|
|
if mkdir -p "$hp_dir/certs" 2>"/dev/null"; then
|
|
# The server runs jailed as the sing-box user and reads its
|
|
# certificate, key and ECH config from this directory, while an
|
|
# upload lands as root:0600 (luci.homeproxy-pro). Hand the directory
|
|
# and its files to that user and keep them 0700/0600: the jailed
|
|
# server can read them and nobody else can reach the private key.
|
|
# Without this the choice was "world-readable key" or "jailed
|
|
# server cannot start".
|
|
chown sing-box:sing-box "$hp_dir/certs" 2>"/dev/null" \
|
|
|| log "Warning: failed to hand ${hp_dir}/certs to sing-box."
|
|
chmod 700 "$hp_dir/certs" 2>"/dev/null"
|
|
for f in "$hp_dir/certs"/*.pem "$hp_dir/certs"/*.crt "$hp_dir/certs"/*.key; do
|
|
[ -f "$f" ] || continue
|
|
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
|
|| log "Warning: failed to hand ${f} to sing-box."
|
|
chmod 600 "$f" 2>"/dev/null"
|
|
done
|
|
else
|
|
log "Warning: failed to create ${hp_dir}/certs."
|
|
fi
|
|
fi
|
|
|
|
# chown each path that is actually there.
|
|
#
|
|
# The old one-shot chown listed every path unconditionally. A mode whose
|
|
# start path does not create one of them (custom mode and cache.db, before
|
|
# that file became shared with it) then failed and logged "failed to
|
|
# change the ownership of the runtime files" on every single start - a
|
|
# warning that was always there and never meant anything. A missing path is
|
|
# not worth reporting; failing to chown a file that exists still is.
|
|
for f in "$run_dir"/sing-box-*.json "$run_dir"/sing-box-*.log "$hp_dir"/cache.db; do
|
|
[ -e "$f" ] || continue
|
|
chown sing-box:sing-box "$f" 2>"/dev/null" \
|
|
|| log "Warning: failed to change the ownership of ${f} to sing-box."
|
|
done
|
|
}
|
|
|
|
# hp_procd_client_instance <prog> <hp-dir> <run-dir> <routing-mode> <disable-gso>
|
|
# Register the sing-box client instance. The ujail gate is client-specific:
|
|
# a wireguard/tun outbound cannot be jailed (it needs the host network stack),
|
|
# and custom routing needs HP_DIR inside the jail because a local rule-set may
|
|
# point at any path validateHomeProxyPath() accepts. The `procd_append_param
|
|
# command` text below is what runtime/health.sh's pgrep pattern matches -
|
|
# change both together.
|
|
hp_procd_client_instance() {
|
|
local prog="$1"
|
|
local hp_dir="$2"
|
|
local run_dir="$3"
|
|
local routing_mode="$4"
|
|
local disable_gso="$5"
|
|
|
|
procd_open_instance "sing-box-c"
|
|
|
|
procd_set_param command "$prog"
|
|
procd_append_param command run --config "$run_dir/sing-box-c.json"
|
|
|
|
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
|
|
|
if [ -x "/sbin/ujail" ] && ! grep -Eq '"type": "(wireguard|tun)"' "$run_dir/sing-box-c.json"; then
|
|
procd_add_jail "sing-box-c" log procfs
|
|
procd_add_jail_mount "$run_dir/sing-box-c.json"
|
|
procd_add_jail_mount_rw "$run_dir/sing-box-c.log"
|
|
# A custom routing table may point a local rule-set anywhere under
|
|
# HP_DIR, so the whole directory goes in read-only rather than a
|
|
# 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 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
|
|
# too - it was missing here while the server jail already had it.
|
|
procd_add_jail_mount "/etc/acme/"
|
|
procd_add_jail_mount "/etc/ssl/"
|
|
# custom routing's find_neighbor resolves LAN hostnames out of the
|
|
# dnsmasq lease file; its MAC lookups go over netlink and need no file,
|
|
# but a `source_hostname` rule silently stops matching without this.
|
|
procd_add_jail_mount "/tmp/dhcp.leases"
|
|
procd_add_jail_mount "/etc/localtime"
|
|
procd_add_jail_mount "/etc/TZ"
|
|
procd_set_param capabilities "/etc/capabilities/homeproxy-pro.json"
|
|
procd_set_param no_new_privs 1
|
|
procd_set_param user sing-box
|
|
procd_set_param group sing-box
|
|
fi
|
|
|
|
procd_set_param limits core="unlimited"
|
|
procd_set_param limits nofile="1000000 1000000"
|
|
procd_set_param stderr 1
|
|
procd_set_param respawn
|
|
|
|
procd_close_instance
|
|
}
|
|
|
|
# hp_procd_server_instance <prog> <hp-dir> <run-dir> <disable-gso>
|
|
# Register the sing-box server instance. The jail only depends on ujail
|
|
# being installed, and it needs the certificate/ACME material instead of the
|
|
# client's cache.db.
|
|
hp_procd_server_instance() {
|
|
local prog="$1"
|
|
local hp_dir="$2"
|
|
local run_dir="$3"
|
|
local disable_gso="$4"
|
|
|
|
procd_open_instance "sing-box-s"
|
|
|
|
procd_set_param command "$prog"
|
|
procd_append_param command run --config "$run_dir/sing-box-s.json"
|
|
|
|
[ "$disable_gso" -eq "1" ] && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
|
|
|
if [ -x "/sbin/ujail" ]; then
|
|
procd_add_jail "sing-box-s" log procfs
|
|
procd_add_jail_mount "$run_dir/sing-box-s.json"
|
|
procd_add_jail_mount_rw "$run_dir/sing-box-s.log"
|
|
procd_add_jail_mount_rw "$hp_dir/certs/"
|
|
procd_add_jail_mount "/etc/acme/"
|
|
procd_add_jail_mount "/etc/ssl/"
|
|
procd_add_jail_mount "/etc/localtime"
|
|
procd_add_jail_mount "/etc/TZ"
|
|
procd_set_param capabilities "/etc/capabilities/homeproxy-pro.json"
|
|
procd_set_param no_new_privs 1
|
|
procd_set_param user sing-box
|
|
procd_set_param group sing-box
|
|
fi
|
|
|
|
procd_set_param limits core="unlimited"
|
|
procd_set_param limits nofile="1000000 1000000"
|
|
procd_set_param stderr 1
|
|
procd_set_param respawn
|
|
|
|
procd_close_instance
|
|
}
|
|
|
|
# hp_procd_log_cleaner <hp-dir>
|
|
# Register the log-rotator instance. It is unconditional: it also has to run
|
|
# when neither client nor server is enabled.
|
|
hp_procd_log_cleaner() {
|
|
local hp_dir="$1"
|
|
|
|
procd_open_instance "log-cleaner"
|
|
procd_set_param command "$hp_dir/scripts/clean_log.sh"
|
|
procd_set_param respawn
|
|
procd_close_instance
|
|
}
|
|
|
|
# hp_capture_candidate <run-dir> <side>
|
|
# Stage the configuration the reload preflight just generated and validated.
|
|
#
|
|
# The copy is necessary because stop_service() removes $RUN_DIR/sing-box-*.json
|
|
# on its way to start_service (they are runtime state, not configuration), so
|
|
# the file the preflight validated is gone by the time the new instance would
|
|
# use it - which is exactly why the old reload regenerated it. The staged copy
|
|
# lives in $RUN_DIR/candidate/, which stop_service() does not touch, and it is
|
|
# what hp_start_generated_config() activates. Non-zero when there is nothing
|
|
# staged, and the caller aborts the reload rather than continuing without a
|
|
# validated candidate.
|
|
hp_capture_candidate() {
|
|
local run_dir="$1"
|
|
local side="$2"
|
|
local live="$run_dir/sing-box-${side}.json"
|
|
local cand_dir="$run_dir/candidate"
|
|
local staged="$cand_dir/sing-box-${side}.json"
|
|
|
|
[ -s "$live" ] || return 1
|
|
|
|
mkdir -p "$cand_dir" 2>/dev/null || return 1
|
|
cp -f "$live" "$staged" 2>/dev/null || return 1
|
|
# The staged file carries the same credentials as the live one; the
|
|
# generator already wrote it 0600 and this copy has to stay that way.
|
|
chmod 600 "$staged" 2>/dev/null
|
|
|
|
return 0
|
|
}
|
|
|
|
# hp_start_generated_config <side> <hp-dir> <run-dir> <good-dir>
|
|
# The start-path transaction for one side ("c" or "s"):
|
|
#
|
|
# HP_USE_KNOWN_GOOD=1 -> copy the recorded configuration over the live one
|
|
# instead of regenerating (a rollback: regenerating
|
|
# would rebuild the very file that just failed)
|
|
# HP_CANDIDATE_<SIDE>=1 -> the reload preflight generated and `sing-box
|
|
# check`ed this side; activate the copy
|
|
# hp_capture_candidate() staged, do not generate again
|
|
# otherwise -> generate, then make sure a live file exists,
|
|
# falling back to the known-good copy when it does not
|
|
#
|
|
# Returns 1 only when there is nothing to run at all (generation failed and
|
|
# no known-good copy exists), which is what made start_service bail out.
|
|
#
|
|
# It deliberately does NOT touch the known-good copy any more. It used to
|
|
# record the freshly generated file immediately, which meant that by the time
|
|
# the health gate ran, the "previous" configuration the rollback would need had
|
|
# already been overwritten by the candidate - so a failed reload had nothing to
|
|
# roll back to. Promotion is now a separate step, taken only after the gate
|
|
# has passed (hp_promote_known_good, called from start_service).
|
|
#
|
|
# A3: the candidate flag exists because the generator is no longer guaranteed to
|
|
# produce the same bytes twice - its env carries the WAN resolver and the two
|
|
# domain-resource lists, either of which can change between two invocations. A
|
|
# reload that generated the config, validated it, and then let start_service
|
|
# generate it *again* would run an artifact that had never been checked:
|
|
# validate A -> discard A -> generate B -> run B. The flag makes the validated
|
|
# bytes the ones that run. It is per side, so a config_load disagreement between
|
|
# reload_service and start_service (a concurrent UCI change) generates the side
|
|
# the preflight did not cover instead of activating a stale file.
|
|
hp_start_generated_config() {
|
|
local side="$1"
|
|
local hp_dir="$2"
|
|
local run_dir="$3"
|
|
local good_dir="$4"
|
|
local label generator live good candidate staged
|
|
|
|
case "$side" in
|
|
c)
|
|
label="client"
|
|
generator="$hp_dir/scripts/generate_client.uc"
|
|
candidate="${HP_CANDIDATE_CLIENT:-0}"
|
|
;;
|
|
s)
|
|
label="server"
|
|
generator="$hp_dir/scripts/generate_server.uc"
|
|
candidate="${HP_CANDIDATE_SERVER:-0}"
|
|
;;
|
|
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
|
esac
|
|
|
|
live="$run_dir/sing-box-${side}.json"
|
|
good="$good_dir/sing-box-${side}.json"
|
|
|
|
if [ "${HP_USE_KNOWN_GOOD:-0}" = "1" ] && [ -s "$good" ]; then
|
|
# Rollback path: start from the recorded configuration instead
|
|
# of regenerating. Regenerating would rebuild the very config
|
|
# that just failed to come up, because the generator is a pure
|
|
# function of UCI.
|
|
log "Starting with the last known-good ${label} configuration."
|
|
cp -f "$good" "$live"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$candidate" = "1" ]; then
|
|
# The reload preflight's artifact is the one that passed `sing-box
|
|
# check`. stop_service() removed the live copy on the way here, so the
|
|
# staged copy is what gets activated - the same bytes that were
|
|
# validated, not a second generation.
|
|
staged="$run_dir/candidate/sing-box-${side}.json"
|
|
|
|
if [ -s "$staged" ] && cp -f "$staged" "$live" 2>/dev/null; then
|
|
# One shot: the staged copy must not survive to be activated by a
|
|
# later start that never validated it.
|
|
rm -f "$staged"
|
|
log "Activating the ${label} configuration validated by the reload preflight."
|
|
return 0
|
|
fi
|
|
|
|
# Defensive: the flag was set but nothing was staged. Generate rather
|
|
# than activate an unvalidated or stale file.
|
|
log "Warning: no validated ${label} candidate is staged; generating the configuration again."
|
|
fi
|
|
|
|
ucode -S "$generator" 2>>"$LOG_PATH"
|
|
|
|
# The generator is transactional on its own: it writes a temporary file
|
|
# and only renames it over the live one once `sing-box check` accepted
|
|
# it. A failed generation therefore leaves the previous file untouched -
|
|
# and that is the only `sing-box check` in this path (the runtime used to
|
|
# run a second one on the same file).
|
|
hp_ensure_live "$live" "$good"
|
|
case "$?" in
|
|
0)
|
|
;;
|
|
1)
|
|
log "Error: failed to generate a valid ${label} configuration; falling back to the last known-good one." ;;
|
|
*)
|
|
log "Error: failed to generate ${label} configuration."
|
|
return 1 ;;
|
|
esac
|
|
|
|
return 0
|
|
}
|
|
|
|
# The UCI keys the intercept layer reads, split by UCI type.
|
|
#
|
|
# hp_restore_known_good_uci() replays the snapshot before the rollback
|
|
# restart, so anything that steers firewall_post.ut / firewall_pre.uc /
|
|
# net.sh / dns.sh has to be in here: a key left out survives the rollback as
|
|
# the *new* (failing) value and start_service reinstalls a layer that no
|
|
# longer matches the sing-box file it just restored.
|
|
#
|
|
# Review 2026-09-29 (docs/review-report-20260929-full.md §1.2) found the
|
|
# previous five-line list covered 4 real keys out of 22, and that the fifth
|
|
# line named `infra.tun_address` - an option that exists nowhere. The real
|
|
# keys are `tun_addr4` / `tun_addr6` (config/loader.uc:411), so that line was
|
|
# always empty and the restore was a silent no-op; the golden trace recorded
|
|
# the empty line as expected, which is how the defect survived. tests/
|
|
# arch-guard.sh guard 43 derives the read set from the layer consumers and
|
|
# compares it against this list in both directions.
|
|
#
|
|
# The two TUN address keys are the one deliberate inclusion that no layer
|
|
# consumer reads: the address is only ever installed by the generated sing-box
|
|
# file, so leaving UCI describing the failed address would make the next
|
|
# reload regenerate the configuration that just failed.
|
|
HP_INTERCEPT_SCALARS="config.main_node
|
|
config.main_udp_node
|
|
config.proxy_mode
|
|
config.routing_mode
|
|
config.routing_port
|
|
config.ipv6_support
|
|
config.china_dns_server
|
|
infra.self_mark
|
|
infra.redirect_port
|
|
infra.tproxy_port
|
|
infra.tproxy_mark
|
|
infra.tun_mark
|
|
infra.tun_name
|
|
infra.tun_addr4
|
|
infra.tun_addr6
|
|
infra.dns_redirect
|
|
infra.dns_port
|
|
infra.common_port
|
|
infra.table_mark
|
|
routing.bypass_cn_traffic
|
|
routing.default_outbound
|
|
server.enabled
|
|
control.lan_proxy_mode"
|
|
|
|
# List options are kept apart because `uci set key=a,b` stores a *string*:
|
|
# ipv4_to_nftarr() / iface_to_nftarr() reject a non-array (they return null),
|
|
# so a snapshot replayed through `uci set` would drop every ACL rule that
|
|
# reads the list - a rollback that silently un-proxies the LAN. The writer
|
|
# joins the elements with ',' and the replay uses `uci delete` +
|
|
# `uci add_list`. No value in this set can contain a comma: they are IPv4 /
|
|
# IPv6 addresses, MAC addresses or interface names.
|
|
HP_INTERCEPT_LISTS="control.listen_interfaces
|
|
control.lan_direct_ipv4_ips
|
|
control.lan_direct_ipv6_ips
|
|
control.lan_direct_mac_addrs
|
|
control.lan_proxy_ipv4_ips
|
|
control.lan_proxy_ipv6_ips
|
|
control.lan_proxy_mac_addrs
|
|
control.lan_gaming_mode_ipv4_ips
|
|
control.lan_gaming_mode_ipv6_ips
|
|
control.lan_gaming_mode_mac_addrs
|
|
control.lan_global_proxy_ipv4_ips
|
|
control.lan_global_proxy_ipv6_ips
|
|
control.lan_global_proxy_mac_addrs
|
|
control.wan_proxy_ipv4_ips
|
|
control.wan_proxy_ipv6_ips
|
|
control.wan_direct_ipv4_ips
|
|
control.wan_direct_ipv6_ips"
|
|
|
|
# hp_write_uci_snapshot <snap>
|
|
# Serialise HP_INTERCEPT_SCALARS / HP_INTERCEPT_LISTS into <snap>.
|
|
#
|
|
# One line per key so a reader without uci-tools can iterate the file:
|
|
#
|
|
# homeproxy-pro.<section>.<option>=<value> scalar
|
|
# list homeproxy-pro.<section>.<option>=a,b list, ','-joined
|
|
# list homeproxy-pro.<section>.<option>= list that was absent or empty
|
|
#
|
|
# An absent scalar is written as an empty value, which is how the replay
|
|
# spells "remove the option again" (UCI deletes an option set to an empty
|
|
# value) - so the snapshot round-trips absence as well as presence.
|
|
hp_write_uci_snapshot() {
|
|
local snap="$1"
|
|
local pair sec opt val el out
|
|
|
|
config_load "$CONF"
|
|
|
|
{
|
|
for pair in $HP_INTERCEPT_SCALARS; do
|
|
sec="${pair%%.*}"
|
|
opt="${pair#*.}"
|
|
config_get val "$sec" "$opt"
|
|
printf 'homeproxy-pro.%s.%s=%s\n' "$sec" "$opt" "$val"
|
|
done
|
|
|
|
for pair in $HP_INTERCEPT_LISTS; do
|
|
sec="${pair%%.*}"
|
|
opt="${pair#*.}"
|
|
config_get val "$sec" "$opt"
|
|
# config_get hands a UCI list over space-separated; rejoin it.
|
|
out=""
|
|
for el in $val; do
|
|
[ -z "$out" ] && out="$el" || out="$out,$el"
|
|
done
|
|
printf 'list homeproxy-pro.%s.%s=%s\n' "$sec" "$opt" "$out"
|
|
done
|
|
} > "$snap.tmp" 2>"/dev/null" && mv -f "$snap.tmp" "$snap"
|
|
}
|
|
|
|
# hp_promote_known_good <side> <run-dir> <good-dir>
|
|
# Record the live configuration as the new known-good copy. Called only after
|
|
# the health gate has proven that the configuration actually runs, so the
|
|
# rollback target is always something that came up.
|
|
#
|
|
# Also writes $good_dir/uci-snapshot.txt (see HP_INTERCEPT_SCALARS above).
|
|
# The rollback path in init.d/homeproxy-pro reads that file back BEFORE its
|
|
# stop;start, otherwise start_service reinstalls the new (failing) layer on
|
|
# top of the rolled-back sing-box bytes - the classic "rollback restored the
|
|
# config but nft is still the new chains" silent failure that took a
|
|
# real-machine repro to spot (review P1-3, 2026-09-28). Snapshotting only
|
|
# the keys that steer the network layer keeps the rollback bounded: a stray
|
|
# key elsewhere never overwrites itself.
|
|
hp_promote_known_good() {
|
|
local side="$1"
|
|
local run_dir="$2"
|
|
local good_dir="$3"
|
|
local label
|
|
|
|
case "$side" in
|
|
c) label="client" ;;
|
|
s) label="server" ;;
|
|
*) log "Error: unknown configuration side '${side}'."; return 1 ;;
|
|
esac
|
|
|
|
hp_known_good "$run_dir/sing-box-${side}.json" "$good_dir/sing-box-${side}.json" \
|
|
|| log "Warning: could not refresh the known-good ${label} configuration."
|
|
|
|
# Written once on every successful promote. Both sides promote the same
|
|
# content, so the two calls cannot disagree.
|
|
hp_write_uci_snapshot "$good_dir/uci-snapshot.txt"
|
|
}
|
|
|