mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-07 20:54:14 +08:00
⛅ Sync 2026-09-29 21:45:00
This commit is contained in:
1 parent
a4d6e3e4b9
commit
da77daf88e
191 files changed
+8077
-6247
No files matched your search
@@ -0,0 +1,151 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Configuration transaction helpers for homeproxy-pro.
|
||||
#
|
||||
# The generated sing-box configuration is the only thing standing between the
|
||||
# router and a working service, so it is handled as a transaction:
|
||||
#
|
||||
# generate the generator writes atomically and only after
|
||||
# `sing-box check` accepted the result, so a failed
|
||||
# generation leaves the previous file in place
|
||||
# -> known-good every accepted file is copied aside, so there is
|
||||
# always something to fall back to
|
||||
# -> ensure-live if generation produced nothing, restore the
|
||||
# known-good copy instead of failing the start
|
||||
# -> rollback a reload that comes up unhealthy puts the known-good
|
||||
# copy back (see reload_service in init.d/homeproxy-pro)
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy-pro. The functions only take explicit paths
|
||||
# and never call procd or ubus, so tests/runtime/test_config_transaction.sh
|
||||
# can exercise them without a router.
|
||||
|
||||
# hp_known_good <live> <known-good>
|
||||
# Record <live> as the new known-good copy. Non-zero when <live> is missing
|
||||
# or empty, in which case the previous known-good copy is left untouched.
|
||||
hp_known_good() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] || return 1
|
||||
|
||||
mkdir -p "$(dirname "$good")" 2>/dev/null
|
||||
cp -f "$live" "$good" 2>/dev/null || return 1
|
||||
# The known-good copy holds the same credentials as the live file, so the
|
||||
# two have to stay in step: otherwise a rollback restores a copy that is
|
||||
# world-readable whenever the source was.
|
||||
chmod 600 "$good" 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_ensure_live <live> <known-good>
|
||||
# 0 <live> is present and usable
|
||||
# 1 <live> was missing and the known-good copy was restored
|
||||
# 2 nothing usable: neither file exists
|
||||
hp_ensure_live() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$live" ] && return 0
|
||||
|
||||
if [ -s "$good" ]; then
|
||||
cp -f "$good" "$live" 2>/dev/null && return 1
|
||||
fi
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
# hp_rollback <live> <known-good>
|
||||
# Replace <live> with the known-good copy. Non-zero when there is nothing to
|
||||
# roll back to, so the caller can distinguish "restored" from "no fallback".
|
||||
hp_rollback() {
|
||||
local live="$1"
|
||||
local good="$2"
|
||||
|
||||
[ -s "$good" ] || return 1
|
||||
|
||||
cp -f "$good" "$live" 2>/dev/null || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_same_file <a> <b>
|
||||
# 0 when both files exist and have identical content. Used to skip the
|
||||
# rollback when the failing configuration is already the known-good one.
|
||||
hp_same_file() {
|
||||
[ -s "$1" ] && [ -s "$2" ] || return 1
|
||||
cmp -s "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# hp_restore_known_good_uci <good-dir>
|
||||
# Re-apply the UCI snapshot written by hp_promote_known_good (the keys that
|
||||
# steer the firewall / dnsmasq / nft layer - see HP_INTERCEPT_SCALARS in
|
||||
# runtime/service.sh). Returns 0 on success, 1 when the snapshot is missing -
|
||||
# the rollback path logs and continues, since a missing snapshot just means
|
||||
# the install is older than the snapshot feature (best-effort).
|
||||
#
|
||||
# Two passes over the file, because the snapshot carries two shapes:
|
||||
#
|
||||
# homeproxy-pro.<section>.<option>=<value> scalar, replayed with `uci set`
|
||||
# list homeproxy-pro.<section>.<option>=a,b UCI list, replayed with
|
||||
# `uci delete` + `uci add_list`
|
||||
#
|
||||
# A list replayed through `uci set` becomes a *string*, and the firewall
|
||||
# validators (ipv4_to_nftarr / iface_to_nftarr) reject a non-array by
|
||||
# returning null - so the ACL rules reading it would silently disappear and
|
||||
# the rollback would come back with the wrong clients exempted.
|
||||
#
|
||||
# The commit below is what makes this safe: every write is staged and only
|
||||
# becomes visible when uci commit succeeds. A partial failure leaves UCI
|
||||
# unchanged; the rollback caller logs and the next reload path will see the
|
||||
# same failing configuration it just rolled back from, so the failure mode
|
||||
# stays bounded (no new corruption).
|
||||
hp_restore_known_good_uci() {
|
||||
local good_dir="$1"
|
||||
local snap="$good_dir/uci-snapshot.txt"
|
||||
|
||||
[ -s "$snap" ] || return 1
|
||||
|
||||
local key value lkey el saved=0
|
||||
|
||||
# Pass 1: scalars. `uci set key=` with an empty value is how UCI spells
|
||||
# "the option was not set", so a key that was absent in the known-good
|
||||
# state is removed again here rather than surviving as a failing value.
|
||||
# stdin is closed for every uci call so none of them can swallow the
|
||||
# file the loop is reading.
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
""|list\ *|"#"*) continue ;;
|
||||
esac
|
||||
uci set "$key=$value" </dev/null || return 1
|
||||
saved=1
|
||||
done < "$snap"
|
||||
|
||||
# Pass 2: list options. `uci delete` on an option that is not there is
|
||||
# expected - the known-good state may simply never have had this list -
|
||||
# so its exit status is deliberately ignored. An empty recorded value
|
||||
# therefore means "absent or empty", and the delete is the whole
|
||||
# operation: a list the failing configuration added does not survive.
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
list\ *) lkey="${key#list }" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
uci delete "$lkey" >/dev/null 2>&1 </dev/null
|
||||
saved=1
|
||||
[ -n "$value" ] || continue
|
||||
while :; do
|
||||
case "$value" in
|
||||
*,*) el="${value%%,*}"; value="${value#*,}" ;;
|
||||
*) el="$value"; value="" ;;
|
||||
esac
|
||||
uci add_list "$lkey=$el" </dev/null || return 1
|
||||
[ -n "$value" ] || break
|
||||
done
|
||||
done < "$snap"
|
||||
|
||||
[ "$saved" = "1" ] && uci commit homeproxy-pro </dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# dnsmasq integration for homeproxy-pro.
|
||||
#
|
||||
# homeproxy-pro does not run its own DNS front end: it points dnsmasq at the
|
||||
# sing-box DNS listener and, in the list-based routing modes, feeds it the
|
||||
# per-domain `server=` / `nftset=` mappings. All of that used to live inline
|
||||
# in /etc/init.d/homeproxy-pro.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, in
|
||||
# hp_dnsmasq_write_snippets, relies on `config_load` having run (see
|
||||
# runtime/service.sh's header for the rationale).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy-pro.
|
||||
|
||||
# hp_dnsmasq_resolve_dir
|
||||
# Locate the directory dnsmasq reads the homeproxy-pro snippets from and store it
|
||||
# in the global DNSMASQ_DIR.
|
||||
#
|
||||
# The UCI section name of the dnsmasq config is part of
|
||||
# /tmp/etc/dnsmasq.conf.<name>, but `uci get` cannot return a section name
|
||||
# (there is no `__name__`), so it is derived from the first `uci show` line.
|
||||
# Either step can fail on an unusual setup; fall back to the generic
|
||||
# directory and say so in the log instead of silently writing dnsmasq
|
||||
# snippets nobody reads.
|
||||
#
|
||||
# Called ONCE, at source time. stop_service deliberately reuses the value
|
||||
# instead of recomputing it: the directory must be the one the snippets were
|
||||
# written to, and a second resolution could log the warning twice or pick a
|
||||
# different directory after a dnsmasq reconfiguration.
|
||||
hp_dnsmasq_resolve_dir() {
|
||||
local dnsmasq_uci_config dnsmasq_conf_dir
|
||||
|
||||
dnsmasq_uci_config="$(uci -q show "dhcp.@dnsmasq[0]" 2>"/dev/null" | head -n1)"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config#*.}"
|
||||
dnsmasq_uci_config="${dnsmasq_uci_config%%=*}"
|
||||
|
||||
DNSMASQ_DIR=""
|
||||
if [ -n "$dnsmasq_uci_config" ] && [ -f "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config" ]; then
|
||||
dnsmasq_conf_dir="$(awk -F '=' '/^conf-dir=/ {print $2; exit}' "/tmp/etc/dnsmasq.conf.$dnsmasq_uci_config")"
|
||||
[ -n "$dnsmasq_conf_dir" ] && DNSMASQ_DIR="$dnsmasq_conf_dir/dnsmasq-homeproxy-pro.d"
|
||||
fi
|
||||
|
||||
if [ -z "$DNSMASQ_DIR" ]; then
|
||||
log "WARNING: unable to derive the dnsmasq conf-dir (section '${dnsmasq_uci_config:-unknown}'), using /tmp/dnsmasq.d/dnsmasq-homeproxy-pro.d."
|
||||
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy-pro.d"
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_has_nftset
|
||||
# 0 when the running dnsmasq understands `nftset=`. The base dnsmasq variant
|
||||
# is built without HAVE_NFTSET, and it does not ignore the directive: it aborts
|
||||
# with "recompile with HAVE_NFTSET defined", and because the snippet is read
|
||||
# through conf-dir that abort takes dnsmasq itself down - the LAN loses DNS and
|
||||
# DHCP, not just the domain routing. Probe once and degrade instead.
|
||||
hp_dnsmasq_has_nftset() {
|
||||
if [ -z "${HP_DNSMASQ_NFTSET:-}" ]; then
|
||||
# Look it up through PATH so the off-target harness can stand in a
|
||||
# dnsmasq of either flavour; fall back to the absolute path for an
|
||||
# init environment with a bare PATH.
|
||||
local bin
|
||||
bin="$(command -v dnsmasq 2>"/dev/null" || echo /usr/sbin/dnsmasq)"
|
||||
|
||||
if "$bin" --version 2>"/dev/null" | grep -qw nftset; then
|
||||
HP_DNSMASQ_NFTSET=1
|
||||
else
|
||||
HP_DNSMASQ_NFTSET=0
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "$HP_DNSMASQ_NFTSET" = "1" ]
|
||||
}
|
||||
|
||||
# hp_dnsmasq_render_snippets <stage-dir> <hp-dir> <routing-mode> <dns-port> <ipv6>
|
||||
# Render the desired snippet set into <stage-dir>. Writes nothing outside it,
|
||||
# restarts nothing: the caller decides whether the result differs from what is
|
||||
# already installed.
|
||||
#
|
||||
# The input list gates are `-s` rather than the original unconditional sed:
|
||||
# when the resource file is missing the old code produced an empty snippet
|
||||
# (or a stale one never got removed), which made "is this the same as last
|
||||
# time" undecidable. An empty list now means "no snippet for this mode",
|
||||
# which is also what a fresh install means.
|
||||
hp_dnsmasq_render_snippets() {
|
||||
local stage="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local dns_port="$4"
|
||||
local ipv6="$5"
|
||||
local gfw_nftset_v6="" wan_nftset_v6=""
|
||||
local nftset_ok=1
|
||||
|
||||
# dns_port comes from `infra`, a section with no UI and no validator, and
|
||||
# it is interpolated both into the snippet text and into sed's replacement
|
||||
# below. A value carrying a newline reaches sed as `\n` and becomes a real
|
||||
# dnsmasq directive (the generator accepts it too: int() is strtoll-based,
|
||||
# so '5333\nlog-queries' is simply 5333 there), and a value with a `/`
|
||||
# makes sed fail - while `> "$stage/x.conf"` has already truncated the
|
||||
# file, so an empty snippet used to be installed and then cached by the
|
||||
# "unchanged" comparison. Keep the numeric prefix, refuse the rest.
|
||||
dns_port="$(printf '%s' "$dns_port" | sed -n 's/^\([0-9]\{1,\}\).*/\1/p')"
|
||||
case "$dns_port" in
|
||||
''|*[!0-9]*)
|
||||
log "Warning: infra.dns_port is not a port (${4:-}); skipping the dnsmasq snippets."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$dns_port" -lt 1 ] || [ "$dns_port" -gt 65535 ]; then
|
||||
log "Warning: infra.dns_port=$dns_port is out of range; skipping the dnsmasq snippets."
|
||||
return 1
|
||||
fi
|
||||
|
||||
hp_dnsmasq_has_nftset || nftset_ok=0
|
||||
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom"|"global")
|
||||
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
|
||||
no-poll
|
||||
no-resolv
|
||||
server=127.0.0.1#$dns_port
|
||||
EOF
|
||||
;;
|
||||
"gfwlist")
|
||||
if [ -s "$hp_dir/resources/gfw_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || gfw_nftset_v6=",6#inet#fw4#homeproxy_gfw_list_v6"
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_gfw_list_v4$gfw_nftset_v6/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; gfwlist routing degrades to plain server= entries (no address sets)."
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/gfw_list.txt" > "$stage/gfw_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"proxy_mainland_china")
|
||||
if [ -s "$hp_dir/resources/china_list.txt" ]; then
|
||||
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
|
||||
if [ "$nftset_ok" = "1" ]; then
|
||||
[ "$ipv6" -eq "0" ] || wan_nftset_v6=",6#inet#fw4#homeproxy_wan_proxy_addr_v6"
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port\nnftset=\/\1\\/4#inet#fw4#homeproxy_wan_proxy_addr_v4$wan_nftset_v6/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
else
|
||||
log "Warning: this dnsmasq has no nftset support; the proxy list degrades to plain server= entries."
|
||||
sed -r -e '/^\s*$/d' -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
|
||||
"$hp_dir/resources/proxy_list.txt" > "$stage/proxy_list.conf" || return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_dnsmasq_dir_differs <stage-dir> <live-dir>
|
||||
# 0 when the installed snippet set is not exactly the staged one, 1 when it is
|
||||
# identical. Compares file names as well as contents, so a snippet that the
|
||||
# current routing mode no longer produces (but a previous one did) counts as a
|
||||
# change and gets cleaned up.
|
||||
hp_dnsmasq_dir_differs() {
|
||||
local stage="$1"
|
||||
local live="$2"
|
||||
local staged installed f
|
||||
|
||||
[ -d "$live" ] || return 0
|
||||
|
||||
staged="$(ls -1 "$stage" 2>"/dev/null" | sort)"
|
||||
installed="$(ls -1 "$live" 2>"/dev/null" | sort)"
|
||||
[ "$staged" = "$installed" ] || return 0
|
||||
|
||||
for f in $staged; do
|
||||
cmp -s "$stage/$f" "$live/$f" || return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_dnsmasq_write_snippets <dns-dir> <hp-dir> <routing-mode>
|
||||
# Write the include file plus the mode-specific snippet, then restart
|
||||
# dnsmasq. Requires config_load (reads ipv6_support and dns_port).
|
||||
#
|
||||
# The snippets are rendered into a staging directory first and compared with
|
||||
# what is installed. dnsmasq restart drops every client's DNS cache, and the
|
||||
# list files it feeds on change once a day at most while the routing mode and
|
||||
# the port stay the same - so the previous unconditional rewrite-and-restart
|
||||
# flushed the LAN's DNS cache on every reload for no reason.
|
||||
hp_dnsmasq_write_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local hp_dir="$2"
|
||||
local routing_mode="$3"
|
||||
local ipv6_support dns_port
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf"
|
||||
local stage changed=0
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get dns_port "infra" "dns_port" "5333"
|
||||
|
||||
stage="$(mktemp -d "${TMPDIR:-/tmp}/hp-dnsmasq.XXXXXX")" || {
|
||||
log "Warning: failed to create a staging directory for the dnsmasq snippets."
|
||||
return 1
|
||||
}
|
||||
|
||||
# A refused render (an unusable dns_port, or sed failing) must leave the
|
||||
# installed snippet set untouched: the staged directory is discarded, so
|
||||
# the caller keeps the snippets that are already in place instead of
|
||||
# installing a truncated one.
|
||||
if ! hp_dnsmasq_render_snippets "$stage" "$hp_dir" "$routing_mode" "$dns_port" "$ipv6_support"; then
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$include" ] || [ "$(cat "$include" 2>"/dev/null")" != "conf-dir=$dnsmasq_dir" ]; then
|
||||
changed=1
|
||||
fi
|
||||
if hp_dnsmasq_dir_differs "$stage" "$dnsmasq_dir"; then
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
rm -rf "$stage"
|
||||
log "dnsmasq snippets unchanged, skipping the restart."
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dnsmasq_dir")" 2>"/dev/null" \
|
||||
|| log "Warning: failed to create the dnsmasq conf-dir parent."
|
||||
rm -rf "$dnsmasq_dir"
|
||||
if ! mv "$stage" "$dnsmasq_dir"; then
|
||||
log "Warning: failed to install the dnsmasq snippets into ${dnsmasq_dir}."
|
||||
rm -rf "$stage"
|
||||
return 1
|
||||
fi
|
||||
# printf, not `echo -e`: busybox ash honours -e but a POSIX sh does not,
|
||||
# and on one the file would start with a literal "-e " and dnsmasq would
|
||||
# refuse to parse it. The file is one line of conf-dir= either way.
|
||||
printf 'conf-dir=%s\n' "$dnsmasq_dir" > "$include" \
|
||||
|| log "Warning: failed to write the dnsmasq conf-dir file."
|
||||
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq, DNS-based routing may be stale."
|
||||
}
|
||||
|
||||
# hp_dnsmasq_remove_snippets <dns-dir>
|
||||
# Remove the include file and the snippet directory, then restart dnsmasq.
|
||||
# The snippets are removed rather than restored: nothing ever captures the
|
||||
# pre-existing state, so there is no original to put back.
|
||||
hp_dnsmasq_remove_snippets() {
|
||||
local dnsmasq_dir="$1"
|
||||
local include="$dnsmasq_dir/../dnsmasq-homeproxy-pro.conf"
|
||||
|
||||
# Nothing to undo when nothing was written - which is the normal case on a
|
||||
# router whose outbound node is 'nil', or one that never started. Removing
|
||||
# paths that do not exist is not an error, and restarting dnsmasq for it is
|
||||
# pure noise (it drops every client's DNS cache for no reason).
|
||||
if [ ! -e "$include" ] && [ ! -d "$dnsmasq_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$include" "$dnsmasq_dir"
|
||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1 \
|
||||
|| log "Warning: failed to restart dnsmasq after removing the homeproxy-pro snippets."
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# fw4 integration for homeproxy-pro.
|
||||
#
|
||||
# homeproxy-pro owns a fixed set of nft chains and sets inside the `fw4` table.
|
||||
# They are created by the rendered template (firewall_post.ut, applied by
|
||||
# `fw4 reload`) and torn down one object at a time on stop. All of that used
|
||||
# to live inline in /etc/init.d/homeproxy-pro.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, for the
|
||||
# teardown, requires the HP_FW4_CHAINS / HP_FW4_SETS inventory to be in scope
|
||||
# (root/etc/homeproxy-pro/scripts/fw4_names.sh, sourced by init.d).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy-pro.
|
||||
|
||||
# hp_restore_upnp_mappings
|
||||
# fw4 reload rewrites the whole ruleset, which drops the miniupnpd mappings,
|
||||
# so ask miniupnpd to reinstall them - but only when it is actually enabled
|
||||
# and running.
|
||||
hp_restore_upnp_mappings() {
|
||||
[ -x /etc/init.d/miniupnpd ] && /etc/init.d/miniupnpd enabled && /etc/init.d/miniupnpd running \
|
||||
&& /etc/init.d/miniupnpd restart >"/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_firewall_apply <hp-dir> <run-dir> <client-enabled>
|
||||
# Run the pre-script, render the post-template for the client side, reload
|
||||
# fw4 and restore the upnp mappings. <client-enabled> is "1" when a client
|
||||
# outbound exists; the post-template is client-only.
|
||||
hp_firewall_apply() {
|
||||
local hp_dir="$1"
|
||||
local run_dir="$2"
|
||||
local client_enabled="$3"
|
||||
|
||||
local failed=0
|
||||
|
||||
# Truncate the include fragments first. firewall_pre.uc only writes them
|
||||
# when it has something to put in them, so a start that produces no
|
||||
# forward/input rules would leave the previous run's files in place and fw4
|
||||
# would keep loading them. teardown already truncates for the same reason.
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
|
||||
ucode "$hp_dir/scripts/firewall_pre.uc" 2>"/dev/null" \
|
||||
|| { log "Error: firewall pre-script failed."; failed=1; }
|
||||
|
||||
if [ "$client_enabled" = "1" ]; then
|
||||
# Render to a temp file and install it only on success. The old
|
||||
# `> "$run_dir/fw4_post.nft"` truncated the very file fw4 is about to
|
||||
# include, so a failed render left fw4 loading an empty ruleset.
|
||||
if utpl -S "$hp_dir/scripts/firewall_post.ut" > "$run_dir/fw4_post.nft.new" 2>"/dev/null"; then
|
||||
mv -f "$run_dir/fw4_post.nft.new" "$run_dir/fw4_post.nft"
|
||||
else
|
||||
rm -f "$run_dir/fw4_post.nft.new"
|
||||
log "Error: firewall post-script failed."
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
fw4 reload >"/dev/null" 2>&1 \
|
||||
|| { log "Error: fw4 reload failed."; failed=1; }
|
||||
|
||||
# The return value has to describe this function, not whatever
|
||||
# hp_restore_upnp_mappings happened to return last.
|
||||
hp_restore_upnp_mappings
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# hp_firewall_teardown <run-dir> [keep-pre]
|
||||
# Flush and delete every fw4 object homeproxy-pro owns. Deleting one at a time
|
||||
# matters: `nft -f` batches are atomic, so a single object that does not
|
||||
# exist in the current proxy mode would roll back the rest.
|
||||
#
|
||||
# "keep-pre" leaves fw4_forward.nft / fw4_input.nft alone. Those two are the
|
||||
# firewall_pre.uc fragments - the server's inbound accepts and the TUN
|
||||
# forwards - and they are not what black-holes the LAN when the client fails.
|
||||
# A failed client has to release the redirect/tproxy layer without closing
|
||||
# the server's ports, so that path passes keep-pre; stop_service does not.
|
||||
hp_firewall_teardown() {
|
||||
local run_dir="$1"
|
||||
local keep_pre="${2:-}"
|
||||
local fw4_name
|
||||
|
||||
for fw4_name in $HP_FW4_CHAINS; do
|
||||
nft flush chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete chain inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
for fw4_name in $HP_FW4_SETS; do
|
||||
nft flush set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
nft delete set inet fw4 "$fw4_name" 2>"/dev/null"
|
||||
done
|
||||
|
||||
if [ "$keep_pre" != "keep-pre" ]; then
|
||||
: > "$run_dir/fw4_forward.nft"
|
||||
: > "$run_dir/fw4_input.nft"
|
||||
fi
|
||||
: > "$run_dir/fw4_post.nft"
|
||||
|
||||
# Stop-time fw4 reload is best-effort on purpose: every homeproxy-pro-owned
|
||||
# chain and set has already been flushed and deleted above, so the
|
||||
# live ruleset no longer references them. A reload that succeeds here
|
||||
# also re-loads /etc/config/firewall; a reload that fails leaves the
|
||||
# rest of the user's ruleset untouched, which is the safer outcome -
|
||||
# logging a Warning used to suggest the stop had lost something when
|
||||
# in fact nothing homeproxy-pro-owned was left to lose.
|
||||
fw4 reload >"/dev/null" 2>&1 || true
|
||||
hp_restore_upnp_mappings
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Runtime health helpers for homeproxy-pro.
|
||||
#
|
||||
# After a reload the service must be able to answer "did the instance
|
||||
# actually come up with the new configuration?". That answer decides whether
|
||||
# the transaction in runtime/config.sh rolls back, so it is kept here instead
|
||||
# of being inlined in init.d/homeproxy-pro.
|
||||
#
|
||||
# A single "is a matching process alive?" probe is NOT a health check, and the
|
||||
# first version of this file learned that the hard way. Measured on the test
|
||||
# device with a configuration whose mixed_port was already taken:
|
||||
#
|
||||
# * procd restarts the crashing instance about once a second, and a STALE
|
||||
# sing-box process from an earlier run kept matching `pgrep -f`, so the
|
||||
# process check returned success for the whole observation window while
|
||||
# the service was dead;
|
||||
# * `ubus service list` was correct throughout: `running` flipped false and
|
||||
# `exit_code` was 1 whenever it was down;
|
||||
# * the configured port WAS listening - owned by the process that had taken
|
||||
# it, not by sing-box. Checking "the port is in the listen table" would
|
||||
# therefore have passed too; the listener has to be attributed to an owner.
|
||||
#
|
||||
# So the gate is: procd says running, procd does not report a failed exit
|
||||
# code, the declared listeners are owned by sing-box, and that holds for
|
||||
# several consecutive samples (a service that binds and then dies must not be
|
||||
# able to pass on a single lucky poll).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy-pro; testable off-target by stubbing pgrep /
|
||||
# ubus / jsonfilter / netstat (see tests/runtime/).
|
||||
|
||||
: "${HP_SERVICE:=homeproxy-pro}"
|
||||
|
||||
# How many consecutive healthy samples hp_wait_service requires, and how many
|
||||
# seconds it may spend trying.
|
||||
#
|
||||
# Measured on the test device: procd waits out `procd_set_param respawn`'s
|
||||
# timeout (5s) before restarting a dead instance, and after a crash-restart
|
||||
# sequence has exhausted its retries the next start takes longer still. A
|
||||
# crash-restart loop produced at most one healthy sample in a row, so 3
|
||||
# separates the two cases while a healthy start only costs ~3s; but the BUDGET
|
||||
# has to cover procd's backoff, otherwise the gate gives up on an instance that
|
||||
# is still coming up (observed with a 15s budget: the rollback restart was
|
||||
# healthy moments after the gate had already declared failure).
|
||||
: "${HP_HEALTH_STABLE:=3}"
|
||||
: "${HP_HEALTH_BUDGET:=30}"
|
||||
|
||||
# The rollback restart runs right after a crash-restart sequence has exhausted
|
||||
# procd's retries, so it gets a longer budget than a normal start.
|
||||
: "${HP_HEALTH_ROLLBACK_BUDGET:=60}"
|
||||
|
||||
# The process name a listening socket must be attributed to.
|
||||
: "${HP_HEALTH_OWNER:=sing-box}"
|
||||
|
||||
# hp_procd_available
|
||||
# 0 when the procd/ubus state can be queried at all.
|
||||
hp_procd_available() {
|
||||
command -v ubus > "/dev/null" 2>&1 && command -v jsonfilter > "/dev/null" 2>&1
|
||||
}
|
||||
|
||||
# hp_procd_running <instance-name>
|
||||
# 0 when procd reports the instance as running AND without a non-zero exit
|
||||
# code. Both halves matter: during a crash-restart loop `running` flickers
|
||||
# and `exit_code` is set, and a `running: true` sample taken between two
|
||||
# failures must not be read as success on its own.
|
||||
hp_procd_running() {
|
||||
local name="$1"
|
||||
local state running exit_code
|
||||
|
||||
hp_procd_available || return 1
|
||||
|
||||
state="$(ubus call service list "{\"name\":\"$HP_SERVICE\"}" 2>"/dev/null" \
|
||||
| jsonfilter -e "@['$HP_SERVICE'].instances['$name']" 2>"/dev/null")"
|
||||
[ -n "$state" ] || return 1
|
||||
|
||||
running="$(printf '%s' "$state" | jsonfilter -e '@.running' 2>"/dev/null")"
|
||||
[ "$running" = "true" ] || return 1
|
||||
|
||||
exit_code="$(printf '%s' "$state" | jsonfilter -e '@.exit_code' 2>"/dev/null")"
|
||||
case "$exit_code" in
|
||||
""|"0") return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_instance_running <instance-name> <config-path>
|
||||
# 0 when the instance is alive.
|
||||
#
|
||||
# procd's view comes first: it is the authority on whether the instance it
|
||||
# supervises is up, and unlike a process scan it cannot be satisfied by a
|
||||
# stale or orphaned process. The process scan is only a fallback for hosts
|
||||
# without ubus, and it is deliberately the weaker evidence.
|
||||
hp_instance_running() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
|
||||
if hp_procd_running "$name"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fallback: accept a matching process only when procd could not be asked
|
||||
# at all. When procd *was* asked and said "not running", that answer
|
||||
# wins - a stale process must not override it.
|
||||
if ! hp_procd_available; then
|
||||
if command -v pgrep > "/dev/null" 2>&1; then
|
||||
pgrep -f "run --config $config" > "/dev/null" 2>&1 && return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_listener_owned <owner> <port>...
|
||||
# 0 every port is listening and attributed to <owner>
|
||||
# 1 a port is missing, or is listening but belongs to something else
|
||||
# 2 the check cannot be performed here (no netstat, or no owner column)
|
||||
#
|
||||
# Attribution is the whole point: in the failure this gate exists for, the
|
||||
# port was listening - because the process that had taken it was still
|
||||
# running. "The port is in the listen table" would have reported success for
|
||||
# a service that could not bind it.
|
||||
hp_listener_owned() {
|
||||
local owner="$1"
|
||||
shift
|
||||
local table port
|
||||
|
||||
command -v netstat > "/dev/null" 2>&1 || return 2
|
||||
|
||||
# -p adds the owning pid/name; it needs root, which is how init.d runs.
|
||||
table="$(netstat -ltnup 2>"/dev/null")"
|
||||
[ -n "$table" ] || return 2
|
||||
# Without a "<pid>/<name>" column the attribution cannot be made at all.
|
||||
# The header row says "PID/Program name", so the probe has to require a
|
||||
# digit before the slash - matching a bare "/" would accept the header.
|
||||
case "$table" in
|
||||
*[0-9]/*) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
|
||||
for port in "$@"; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! printf '%s\n' "$table" | awk -v p="$port" -v o="$owner" '
|
||||
$1 ~ /^(tcp|udp)/ {
|
||||
# Local address is field 4; its last :/. component
|
||||
# is the port. This reads both ":::5330" and
|
||||
# "127.0.0.1.5330".
|
||||
n = split($4, a, /[:.]/)
|
||||
if (a[n] != p)
|
||||
next
|
||||
if ($NF ~ ("/" o "$")) {
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
END { exit(found ? 0 : 1) }
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_config_ports <config-file>
|
||||
# Print the ports a generated configuration declares as listeners.
|
||||
#
|
||||
# The gate must validate the configuration that is ACTUALLY running, not the
|
||||
# one UCI currently describes. After a rollback the live file is the older
|
||||
# known-good copy, and checking it against the candidate's ports made the gate
|
||||
# wait forever for a port the restored configuration never binds - a rollback
|
||||
# that had already brought the service back was reported as failed.
|
||||
hp_config_ports() {
|
||||
grep -o '"listen_port"[[:space:]]*:[[:space:]]*[0-9]\+' "$1" 2>"/dev/null" \
|
||||
| grep -o '[0-9]\+$'
|
||||
}
|
||||
|
||||
# hp_config_port_for_tag <config-file> <tag>
|
||||
# Print the listen_port of the inbound whose "tag" is <tag>, or nothing.
|
||||
#
|
||||
# The functional probes need to know which listener is the DNS inbound and
|
||||
# which is the mixed one, and they have to read that from the same place the
|
||||
# gate does - the running configuration. Using the UCI values instead would
|
||||
# point the probes at the candidate's ports after a rollback restored the older
|
||||
# known-good file: a warning in the log at best, and a rollback of a healthy
|
||||
# service when health_probe_strict is on.
|
||||
#
|
||||
# The generated JSON is pretty-printed with one field per line and `tag`
|
||||
# precedes `listen_port` inside each inbound object (generator/inbound.uc), so
|
||||
# a two-state walk is enough. The sed first splits any compact JSON onto the
|
||||
# same one-field-per-line shape, so the helper does not silently depend on the
|
||||
# generator's formatting: with both keys on one line the greedy tag match would
|
||||
# keep the LAST tag and find no port at all.
|
||||
hp_config_port_for_tag() {
|
||||
sed -e 's/[{},]/\n/g' "$1" 2>"/dev/null" | awk -v tag="$2" '
|
||||
/"tag"[[:space:]]*:/ {
|
||||
t = $0
|
||||
sub(/.*"tag"[[:space:]]*:[[:space:]]*"/, "", t)
|
||||
sub(/".*/, "", t)
|
||||
}
|
||||
/"listen_port"[[:space:]]*:/ && t == tag {
|
||||
p = $0
|
||||
sub(/.*"listen_port"[[:space:]]*:[[:space:]]*/, "", p)
|
||||
sub(/[^0-9].*/, "", p)
|
||||
print p
|
||||
exit
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# hp_service_healthy <instance-name> <config-path> [port...]
|
||||
# One sample of the gate. Ports are optional (the server side has no fixed
|
||||
# listening port worth checking).
|
||||
hp_service_healthy() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
shift 2
|
||||
|
||||
hp_instance_running "$name" "$config" || return 1
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
hp_listener_owned "$HP_HEALTH_OWNER" "$@"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
2)
|
||||
# No netstat owner column here. Say so once instead of
|
||||
# silently pretending the listeners were verified.
|
||||
if [ -z "${HP_HEALTH_LISTENER_WARNED:-}" ]; then
|
||||
HP_HEALTH_LISTENER_WARNED=1
|
||||
export HP_HEALTH_LISTENER_WARNED
|
||||
log "Warning: cannot verify the listeners on this target (netstat -p unavailable); relying on the procd check only."
|
||||
fi
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# hp_wait_service <instance-name> <config-path> [budget] [stable] [port...]
|
||||
# 0 once the instance has been healthy for <stable> consecutive samples,
|
||||
# within <budget> seconds. 1 on timeout.
|
||||
hp_wait_service() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local budget="${3:-$HP_HEALTH_BUDGET}"
|
||||
local stable="${4:-$HP_HEALTH_STABLE}"
|
||||
local good=0 tries=0
|
||||
|
||||
[ "$#" -ge 4 ] && shift 4 || shift "$#"
|
||||
|
||||
while [ "$tries" -lt "$budget" ]; do
|
||||
if hp_service_healthy "$name" "$config" "$@"; then
|
||||
good=$((good + 1))
|
||||
[ "$good" -ge "$stable" ] && return 0
|
||||
else
|
||||
good=0
|
||||
fi
|
||||
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -ge "$budget" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_wait_instance <instance-name> <config-path> [seconds]
|
||||
# Kept for callers and tests that only want the plain liveness poll. New code
|
||||
# should use hp_wait_service: this one cannot tell a crash-restart loop from a
|
||||
# healthy service.
|
||||
hp_wait_instance() {
|
||||
local name="$1"
|
||||
local config="$2"
|
||||
local tries="${3:-10}"
|
||||
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
hp_instance_running "$name" "$config" && return 0
|
||||
sleep 1
|
||||
tries=$((tries - 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- functional probes -----------------------------------------------------
|
||||
#
|
||||
# Everything above answers "is the instance up, and does it own the ports it
|
||||
# declared?". None of it answers "does it actually serve?". A configuration
|
||||
# can pass `sing-box check`, bind every listener, hold them for the whole
|
||||
# observation window - and still not resolve a name, because the DNS router
|
||||
# failed to initialize or a jail mount is missing.
|
||||
#
|
||||
# The probes below add that layer, deliberately as *evidence* first and as a
|
||||
# gate only when asked for:
|
||||
#
|
||||
# * "cannot probe" is reported separately from "the probe failed", so a
|
||||
# target without nslookup/nc does not look like a broken service;
|
||||
# * a failure only fails the gate when health_probe_strict is on. A probe
|
||||
# can fail for reasons that have nothing to do with sing-box - no uplink,
|
||||
# a captive portal, an upstream resolver outage - and rolling a perfectly
|
||||
# good configuration back because of that is worse than the condition
|
||||
# being probed for. A rollback loop is the failure mode to avoid here.
|
||||
#
|
||||
# The client side is the side with a fixed capability worth probing (its DNS
|
||||
# inbound and the mixed inbound). The server side's inbounds are
|
||||
# protocol-specific: a bare TCP connect to a REALITY/trojan port proves
|
||||
# nothing, so it is not probed.
|
||||
: "${HP_PROBE_STRICT:=0}"
|
||||
: "${HP_PROBE_NAME:=example.com}"
|
||||
|
||||
# hp_probe_dns <port> [name]
|
||||
# 0 the DNS inbound answered, 1 it did not, 2 cannot probe here.
|
||||
hp_probe_dns() {
|
||||
local port="$1"
|
||||
local name="${2:-$HP_PROBE_NAME}"
|
||||
|
||||
command -v nslookup > "/dev/null" 2>&1 || return 2
|
||||
[ -n "$port" ] || return 2
|
||||
|
||||
nslookup -port="$port" "$name" 127.0.0.1 > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_probe_tcp <port>
|
||||
# 0 something accepted a connection on loopback:<port>, 1 nothing did,
|
||||
# 2 cannot probe here.
|
||||
hp_probe_tcp() {
|
||||
local port="$1"
|
||||
|
||||
[ -n "$port" ] || return 2
|
||||
command -v nc > "/dev/null" 2>&1 || return 2
|
||||
|
||||
nc 127.0.0.1 "$port" < "/dev/null" > "/dev/null" 2>&1 && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_run_probes <label> <dns-port> <mixed-port>
|
||||
# Report the probe results for one side. Returns 1 only when a probe failed
|
||||
# AND HP_PROBE_STRICT=1; a probe that could not be performed never fails it.
|
||||
hp_run_probes() {
|
||||
local label="$1"
|
||||
local dns_port="$2"
|
||||
local mixed_port="$3"
|
||||
local failed=0 rc
|
||||
|
||||
hp_probe_dns "$dns_port" "$HP_PROBE_NAME"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: the ${label} DNS inbound (127.0.0.1:${dns_port}) did not answer a query for ${HP_PROBE_NAME}." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} DNS inbound (no nslookup, or no dns-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
hp_probe_tcp "$mixed_port"
|
||||
case "$?" in
|
||||
0) ;;
|
||||
1)
|
||||
failed=1
|
||||
log "Warning: nothing accepted a connection on the ${label} mixed inbound (127.0.0.1:${mixed_port})." ;;
|
||||
2)
|
||||
log "Warning: cannot probe the ${label} mixed inbound (no nc, or no mixed-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
if [ "$failed" = "1" ] && [ "${HP_PROBE_STRICT:-0}" = "1" ]; then
|
||||
log "Error: the ${label} functional probes failed and health_probe_strict is on."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 ImmortalWrt.org
|
||||
#
|
||||
# Routing/network plumbing for homeproxy-pro.
|
||||
#
|
||||
# The tproxy and TUN paths need ip rules, a routing table and (for TUN) the
|
||||
# device itself before procd starts sing-box, and they need to be torn down
|
||||
# again on stop. All of that used to live inline in /etc/init.d/homeproxy-pro.
|
||||
#
|
||||
# Layering note: this is a device-side module. It calls `log` and, like the
|
||||
# other runtime modules, requires `config_load` to have run (it reads the
|
||||
# infra marks and the TUN device name itself).
|
||||
#
|
||||
# Sourced by /etc/init.d/homeproxy-pro.
|
||||
|
||||
# hp_net_wait_wan [seconds]
|
||||
# Block until a default route or an "up" wan interface exists. Bounded: after
|
||||
# the timeout sing-box is started anyway, because a missing default route is
|
||||
# a common transient state at boot and refusing to start would be worse.
|
||||
hp_net_wait_wan() {
|
||||
local limit="${1:-60}"
|
||||
local wan_waited=0
|
||||
|
||||
while ! { ip route show default 2>"/dev/null" | grep -q . \
|
||||
|| ip -6 route show default 2>"/dev/null" | grep -q . \
|
||||
|| ifstatus wan 2>"/dev/null" | grep -q '"up": true'; }; do
|
||||
wan_waited=$((wan_waited + 1))
|
||||
if [ "$wan_waited" -ge "$limit" ]; then
|
||||
log "WARNING: No network route after ${limit}s, starting sing-box anyway."
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# hp_net_try <描述> <命令...>
|
||||
# Run a step that may legitimately fail, logging the wrapped message.
|
||||
hp_net_try() {
|
||||
local message="$1"; shift
|
||||
"$@" 2>"/dev/null" || log "Error: ${message}"
|
||||
}
|
||||
|
||||
# hp_net_rule_add <family> <fwmark> <table> <描述>
|
||||
# Install one ip rule idempotently. `ip rule add` has no replace form and the
|
||||
# kernel appends a second identical rule instead of reporting a conflict, so a
|
||||
# repeated start (rc.common's `start` never calls stop_service) used to leave
|
||||
# duplicates behind. The teardown side already drains them with `while del`;
|
||||
# this is the matching install side. family is "" for ip, "-6" for ip -6.
|
||||
hp_net_rule_add() {
|
||||
local family="$1" mark="$2" table="$3" message="$4"
|
||||
|
||||
while ip $family rule del fwmark "$mark" table "$table" 2>"/dev/null"; do :; done
|
||||
hp_net_try "$message" ip $family rule add fwmark "$mark" table "$table"
|
||||
}
|
||||
|
||||
# hp_net_setup <proxy-mode> <routing-mode>
|
||||
# Create the tproxy or TUN routing state for the configured proxy mode.
|
||||
# Requires config_load.
|
||||
#
|
||||
# Every step is idempotent on purpose: `start` on an already-running service
|
||||
# re-runs this function without a preceding teardown. The old code reported
|
||||
# "failed to add the tproxy local route (table 100)" on every such start
|
||||
# (EEXIST from `ip route add`) while silently appending a duplicate ip rule.
|
||||
# Routes use `replace`, rules are drained before they are added, and the TUN
|
||||
# device is only created when it does not already exist.
|
||||
hp_net_setup() {
|
||||
local proxy_mode="$1"
|
||||
local routing_mode="$2"
|
||||
local ipv6_support table_mark outbound_udp_node tproxy_mark tun_name tun_mark
|
||||
|
||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
|
||||
case "$proxy_mode" in
|
||||
"redirect_tproxy")
|
||||
config_get outbound_udp_node "config" "main_udp_node" "nil"
|
||||
if [ "$outbound_udp_node" != "nil" ] || [ "$routing_mode" = "custom" ]; then
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
|
||||
hp_net_rule_add "" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy ip rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy local route (table ${table_mark})." \
|
||||
ip route replace local 0.0.0.0/0 dev lo table "$table_mark"
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_rule_add "-6" "$tproxy_mark" "$table_mark" \
|
||||
"failed to add the tproxy IPv6 rule (fwmark ${tproxy_mark}, table ${table_mark})."
|
||||
hp_net_try "failed to add the tproxy IPv6 local route (table ${table_mark})." \
|
||||
ip -6 route replace local ::/0 dev lo table "$table_mark"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
"redirect_tun"|"tun")
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
|
||||
# Only wait after a real creation: the old unconditional `sleep 1s`
|
||||
# delayed every start (including reloads) even when the device was
|
||||
# already present.
|
||||
if ! ip link show "$tun_name" >"/dev/null" 2>&1; then
|
||||
hp_net_try "failed to create the TUN device ${tun_name}." \
|
||||
ip tuntap add mode tun user root name "$tun_name"
|
||||
sleep 1s
|
||||
fi
|
||||
hp_net_try "failed to bring up the TUN device ${tun_name}." \
|
||||
ip link set "$tun_name" up
|
||||
|
||||
hp_net_try "failed to set the TUN default route (table ${table_mark})." \
|
||||
ip route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN ip rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
|
||||
if [ "$ipv6_support" -eq "1" ]; then
|
||||
hp_net_try "failed to set the TUN IPv6 default route (table ${table_mark})." \
|
||||
ip -6 route replace default dev "$tun_name" table "$table_mark"
|
||||
hp_net_rule_add "-6" "$tun_mark" "$table_mark" \
|
||||
"failed to add the TUN IPv6 rule (fwmark ${tun_mark}, table ${table_mark})."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# hp_net_teardown
|
||||
# Remove every tproxy/TUN rule and route homeproxy-pro added. Requires
|
||||
# config_load.
|
||||
#
|
||||
# ip rule del removes a single matching rule, so a repeated start (or an
|
||||
# upgrade that starts before the old instance stopped) leaves duplicates
|
||||
# behind. Delete every match instead of only the first one.
|
||||
hp_net_teardown() {
|
||||
local table_mark tproxy_mark tun_mark tun_name
|
||||
|
||||
config_get table_mark "infra" "table_mark" "100"
|
||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
||||
config_get tun_mark "infra" "tun_mark" "102"
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
while ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
|
||||
|
||||
ip route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
|
||||
ip -6 route del default dev "$tun_name" table "$table_mark" 2>"/dev/null"
|
||||
while ip -6 rule del fwmark "$tun_mark" table "$table_mark" 2>"/dev/null"; do :; done
|
||||
}
|
||||
|
||||
# hp_net_remove_tun
|
||||
# service_stopped() hook on the genuine-stop path only (HP_RELOAD_IN_PROGRESS=0):
|
||||
# procd calls it after the instances are gone, so the TUN device can be removed.
|
||||
# Requires config_load.
|
||||
#
|
||||
# On the reload path (HP_RELOAD_IN_PROGRESS=1) the caller skips this entirely,
|
||||
# so the device survives across the stop;start that reload_service drives; the
|
||||
# table-100 default route is therefore not pulled mid-reload - the
|
||||
# "tun mode reload exposes the LAN with the real IP" failure that the previous
|
||||
# TODO documented (review P2, 2026-09-28) is the issue the caller now closes.
|
||||
hp_net_remove_tun() {
|
||||
local tun_name
|
||||
|
||||
config_get tun_name "infra" "tun_name" "singtun0"
|
||||
|
||||
ip link set "$tun_name" down 2>"/dev/null"
|
||||
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
# 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_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"
|
||||
|
||||
# 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 enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental), so both need the file
|
||||
# writable inside the jail.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
|
||||
esac
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user