Files

980 lines
44 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> [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
# 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).
#
# 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 "/#${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
# 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_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 schedule 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.
#
# Weekly (Monday 03:00), not daily, and the reason is the cost rather than the
# freshness. The upstream lists really do move most days - a Monday run on a
# router whose last run was the previous Monday typically finds something new -
# so a daily schedule meant a `fw4 reload` roughly every other day, and
# `fw4 reload` rewrites the whole ruleset: every connection in flight is
# dropped, and miniupnpd's mappings have to be reinstalled afterwards
# (hp_restore_upnp_mappings). Paying that once a week for routing data that
# drifts by a few hundred CIDRs a day is the right trade; a user who wants a
# fresher list can still press the update button in the LuCI status page, which
# runs the same script.
#
# 03:00 rather than the hour-2 default the subscription update uses, so the two
# runs of uclient-fetch do not land on the same minute.
#
# The entry runs update_resources_cron.sh, which reloads the service only when a
# list actually changed - so a week where the upstream lists did not move costs
# one round of requests and no interruption.
#
# cron fields, for the record: `0 3 * * 1` is minute 0, hour 3, any day of the
# month, any month, day-of-week 1 = Monday. Times are the router's local time,
# which is the same clock the log lines above are stamped with.
HP_RESOURCE_CRON_HOUR=3
HP_RESOURCE_CRON_DOW=1
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 %s/scripts/update_resources_cron.sh #%s_resource_cron\n' \
"$HP_RESOURCE_CRON_HOUR" "$HP_RESOURCE_CRON_DOW" "$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".
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" "${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" \
|| 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 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" "${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."
}
# hp_prepare_ruleset_dir <hp-dir>
# Create the rule-set archive, /etc/homeproxy-pro/ruleset.
#
# Called BEFORE the client configuration is generated, not only from
# hp_prepare_runtime_files. Generation runs `sing-box check` over the result
# and check opens every local rule_set path, so on a fresh install the most
# natural path - "switch to custom routing, add a local rule-set, point it at
# /etc/homeproxy-pro/ruleset/example.srs" - was guaranteed to fail: hp_prepare_
# runtime_files only created the directory for a custom-mode start that had
# already got that far, and it runs after the generation, not before it. The
# user got sing-box's own
#
# parse rule-set[0]: open /etc/homeproxy-pro/ruleset/example.srs: no such file
#
# and the reload reported only "new client configuration is invalid, reload
# aborted" - with the directory the UI points at not existing yet.
#
# The directory is also created at install time (uci-defaults/luci-homeproxy-pro)
# so a router that has never started the service still has it; this call is
# what covers an upgrade from a version that predates that, and a user who
# removed it by hand.
#
# Ownership is NOT done here. The jailed client runs as the sing-box user and
# has to read the files, but it starts after hp_prepare_runtime_files, which is
# where the recursive chown lives; doing it here as well would only add a
# second pass over the directory on every start.
hp_prepare_ruleset_dir() {
local hp_dir="$1"
[ -d "$hp_dir/ruleset" ] || mkdir -p "$hp_dir/ruleset" \
|| log "Warning: failed to create ${hp_dir}/ruleset."
}
# hp_rule_set_current <source> <output>
# 0 when <output> is already the up-to-date rendering of <source>, 1 when it
# has to be generated.
#
# Generating china-domain.json measured 3540 ms on the x86-64 target
# (111,353 suffixes, 2.2 MB), and the source lists are replaced at most daily
# by update_resources.sh. Regenerating unconditionally therefore spent a
# third of every reload - every Save & Apply, every node switch - rebuilding a
# byte-identical file, which is the single largest cost in a reload.
#
# The three conditions below are exactly the cases the unconditional version
# had to cover:
#
# [ -f "$out" ] a fresh install predates the file, or a previous run
# produced nothing
# [ -s "$out" ] the previous run was interrupted after truncating it; an
# empty rule-set would make the route-side set match nothing,
# which is the silent inversion this whole file exists to
# prevent
# [ "$out" -nt "$src" ] the list was replaced while the service was stopped
#
# A newer-but-corrupt output is deliberately NOT detected here. It does not
# need to be: the generated config names the file, a rule-set sing-box cannot
# parse fails the start, and the health gate rolls back to the known-good
# configuration. The check is cheap (<1 ms) and cannot mask a real failure -
# it can only skip work that would rebuild identical bytes.
hp_rule_set_current() {
local src="$1" out="$2"
[ -f "$out" ] && [ -s "$out" ] && [ "$out" -nt "$src" ]
}
# hp_prepare_generated_inputs <hp-dir> <client-enabled>
# Produce every rule-set the generated configuration references, BEFORE the
# generator runs.
#
# Why before: `sing-box check` opens every local rule_set path, and the client
# generator declares china-ip from china_ip4.json (route.uc, gated on
# china_ip4_ready). When these files were produced *after* the generation step
# (they used to live at the end of hp_prepare_runtime_files), a fresh install had
# no china_ip4.json at generation time, `sing-box check` failed, and the client
# could not start at all - the reload preflight aborts before the rule-set step,
# so it could not heal itself either. Measured on the device: generation exit=1
# with "parse rule-set[0]: open .../china_ip4.json: no such file or directory",
# and exit=0 once the three files had been produced.
#
# Cheap when nothing changed: hp_rule_set_current skips an unchanged source.
hp_prepare_generated_inputs() {
local hp_dir="$1"
local client_enabled="$2"
# china-ip4: the route rule keeping mainland IPv4 destinations direct.
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then
if hp_rule_set_current "$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json"; then
# Unchanged source: keep the file, but re-assert ownership anyway -
# chown costs nothing and survives an install that reset it.
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."
elif 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
# china-ip6, plus the only user-facing report of "IPv6 is on but cannot be
# classified". Neither geoip-cn.srs nor china_ip4.json carries IPv6, so
# without a v6 rule-set a mainland destination reached over IPv6 matches no
# route rule and falls through to `final`; the firewall's v6 set is filled
# from this same china_ip6.txt, so a missing or prefixless list leaves
# neither side able to classify it. cn_ipv6 counts usable prefixes first -
# the generator needs at least one, so a zero-count list must skip straight
# to the warning rather than call ucode and log a second failure.
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 ] && hp_rule_set_current "$hp_dir/resources/china_ip6.txt" "$hp_dir/resources/china_ip6.json"; 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."
elif [ "$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
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
# china-domain: the DNS half, sending the list's domains to china-dns.
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_list.txt" ]; then
if hp_rule_set_current "$hp_dir/resources/china_list.txt" "$hp_dir/resources/china-domain.json"; 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."
elif 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
log "Warning: could not generate ${hp_dir}/resources/china-domain.json; the DNS side keeps the previous list."
fi
fi
}
# 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".
#
# The three China rule-sets below (china-ip4, china-ip6, china-domain) share one
# arrangement, and the reasons for it are stated once here rather than three
# times in the branches:
#
# * Each is generated locally from a .txt the resource updater maintains, not
# downloaded as an upstream .srs (geoip-cn.srs / geosite-geolocation-cn.srs),
# so a cold start has nothing to download before the inbounds bind. The
# firewall reads the same .txt for its nft set, so both halves of the split
# decide from one source and cannot drift.
# * Generated here as well as by the updater: an install that predates the
# .json, or a list replaced while the service was stopped, must still get
# one - otherwise the generated config names a file that does not exist and
# `sing-box check` refuses the start.
# * A missing source .txt is NOT an error. The generator then emits no
# route-side / DNS-side rule for it and the split degrades to the default,
# rather than failing the start.
# * A FAILED generation keeps the previous .json rather than writing an empty
# one. This is the trap all three branches exist to avoid: an empty
# rule-set matches nothing, so a mainland destination silently falls through
# to `final` (i.e. the proxy) and the mode does the exact opposite of what it
# promises - an inversion that produces no error anywhere. Both helper
# scripts (china_ip_ruleset.uc / domain_ruleset.uc) refuse to write an empty
# rule-set and exit non-zero, which is what the else-branches log.
# * The generated rule-set also carries this same text as a rule comment, so
# `nft list ruleset` shows the degraded state too.
#
# NOTE: the three China rule-sets are produced by hp_prepare_generated_inputs(),
# which runs BEFORE the generator (see init.d) - this function no longer creates
# them; it only re-asserts them for a caller that arrives here directly.
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 rule-sets the generated configuration references. Also called before
# generation (init.d), which is what makes a fresh install work; idempotent
# and cheap when the sources did not change.
hp_prepare_generated_inputs "$hp_dir" "$client_enabled"
# 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
# The directory itself is created earlier - see hp_prepare_ruleset_dir()
# for why generation cannot wait for this function. Re-running the
# idempotent mkdir here costs nothing and keeps this function
# self-contained for any caller that reaches it directly.
hp_prepare_ruleset_dir "$hp_dir"
# 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 archive is
# claimed here, and it is the only directory validateRuleSetPath()
# admits; a rule-set placed elsewhere under HP_DIR is not a rule-set
# the generator will emit any more.
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_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"):
#
# 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"
gen_rc=$?
# 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)
# A live file exists, but it may be the *previous* configuration: a
# failed generation leaves it in place. That used to be reported as a
# plain success with nothing in the log - `start` on a service whose
# UCI had gone invalid returned 0 while the old file kept running.
[ "$gen_rc" = "0" ] \
|| log "Warning: could not regenerate the ${label} configuration (generator exit ${gen_rc}); the previous configuration stays in place."
;;
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.
#
# The 2026-09-29 review 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"
}