Files

736 lines
33 KiB
Bash
Executable File

#!/bin/sh /etc/rc.common
# rpcd's `rc list` parses only the first 11 lines of an init script for
# USE_PROCD/START/STOP, and LuCI's Startup page then drops every entry that
# came back without a `start`. These three must stay at the top, ahead of the
# design notes below, or HomeProxy silently disappears from that page.
USE_PROCD=1
START=99
STOP=10
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
#
# This file is the lifecycle orchestrator only. Everything
# that is not "read the configuration, decide, and drive procd" now lives in
# /etc/homeproxy-pro/scripts/runtime/:
#
# config.sh generated-configuration transaction (known-good, restore,
# rollback) - pure, takes explicit paths
# health.sh the health gate: procd state + listener ownership, sustained
# over several samples
# service.sh sing-box version gate, auto-update cron, runtime-file
# preparation, procd instance registration, the
# generate->known-good transaction
# dns.sh dnsmasq snippet directory, snippet writing, teardown
# firewall.sh fw4 pre/post scripts, reload, owned-object teardown, upnp
# net.sh tproxy / TUN ip rules and routes, teardown
#
# What deliberately stays here: USE_PROCD and the start/stop/reload/
# service_triggers skeletons, the configuration reads that choose between
# client, server and neither, and the ordering that makes a reload safe
# (generate first, tear down later, prove it came up, roll back otherwise).
#
# The transaction, in order:
#
# start_service generate -> register with procd -> PROVE IT RUNS ->
# record known-good
# reload_service generate (abort before teardown if invalid) -> stop ->
# start -> if start could not prove the new configuration,
# roll back to the known-good copy and restart
#
# A3: on the reload path the generation happens exactly once. reload_service
# generates each enabled side and hands start_service the per-side
# HP_CANDIDATE_* flags; hp_start_generated_config() then activates that exact
# file instead of generating a second one. The generator's env (WAN resolver,
# domain-resource lists) can change between two invocations, so "validate A,
# discard A, generate B, run B" is no longer possible.
#
# "Prove it runs" is the health gate in runtime/health.sh. It used to be a
# single "is a matching process alive?" probe, which was satisfied by a stale
# process and by procd's own respawn loop while the service was dead - and
# because the generated file was recorded as known-good *before* that probe
# ran, the rollback had nothing left to restore.
#
# What the rollback does NOT cover: the dnsmasq snippets, the ip rules/routes
# and the nft rules are rebuilt from the *current* UCI on every start, so a
# reload that fails because of a UCI-level change (a port that is now taken, a
# proxy-mode switch) restores the sing-box configuration and leaves the network
# layer describing the new settings. Rolling that back as well would mean
# snapshotting UCI at promotion time; that is deliberately not attempted here,
# which is why this paragraph exists rather than a note in a plan document.
CONF="homeproxy-pro"
PROG="/usr/bin/sing-box"
HP_DIR="/etc/homeproxy-pro"
RUN_DIR="/var/run/homeproxy-pro"
LOG_PATH="$RUN_DIR/homeproxy-pro.log"
# Last configuration that was proven to run. start_service records it only
# after the health gate passed, and reload_service restores it when a new
# configuration fails to come up.
GOOD_DIR="$RUN_DIR/known-good"
# fw4 chain/set inventory; single source of truth shared with firewall_post.ut
[ -f "$HP_DIR/scripts/fw4_names.sh" ] && . "$HP_DIR/scripts/fw4_names.sh"
# Configuration transaction (known-good copy, restore, rollback) and the
# health gate that decides whether a configuration may be recorded or must be
# rolled back.
[ -f "$HP_DIR/scripts/runtime/config.sh" ] && . "$HP_DIR/scripts/runtime/config.sh"
[ -f "$HP_DIR/scripts/runtime/health.sh" ] && . "$HP_DIR/scripts/runtime/health.sh"
# Device-side orchestration extracted from the init script.
[ -f "$HP_DIR/scripts/runtime/service.sh" ] && . "$HP_DIR/scripts/runtime/service.sh"
[ -f "$HP_DIR/scripts/runtime/dns.sh" ] && . "$HP_DIR/scripts/runtime/dns.sh"
[ -f "$HP_DIR/scripts/runtime/firewall.sh" ] && . "$HP_DIR/scripts/runtime/firewall.sh"
[ -f "$HP_DIR/scripts/runtime/net.sh" ] && . "$HP_DIR/scripts/runtime/net.sh"
log() {
# The run directory lives in tmpfs and does not exist on a cold boot, but
# the first thing this script logs can be a refusal *before* start_service
# reaches its mkdir - the ucode/sing-box version gates and "neither client
# nor server is configured" all write here first. Without this the operator
# got rc=1 and no reason at all: measured on the device, /var/run ->
# /tmp/run (tmpfs), and the gate lines were appended to a path that did not
# exist yet.
[ -d "$RUN_DIR" ] || mkdir -p "$RUN_DIR" 2>"/dev/null"
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
}
# hp_client_outbound_node <routing-mode> <dest-var>
# Read the client's "main node" reference into <dest-var>.
#
# The reference is mode-dependent: the built-in routing modes keep it on
# config.main_node, while custom routing keeps it on routing.default_outbound.
# start_service, service_started and reload_service all need this value to
# decide whether a client instance exists at all, and each of them used to
# spell the branch out itself.
hp_client_outbound_node() {
if [ "$1" != "custom" ]; then
config_get "$2" "config" "main_node" "nil"
else
config_get "$2" "routing" "default_outbound" "nil"
fi
}
# Resolve the dnsmasq snippet directory once, at load time. stop_service
# reuses the stored value rather than recomputing it, so a dnsmasq
# reconfiguration between start and stop cannot make it delete the wrong
# directory (see runtime/dns.sh).
hp_dnsmasq_resolve_dir
# hp_intercept_installed
# True when a previous start actually installed the intercept layer: the fw4
# post fragment holds the redirect/tproxy chains, the dnsmasq include file
# points at the snippets. Both live in tmpfs, so after a reboot there is
# nothing stale to release.
hp_intercept_installed() {
[ -s "$RUN_DIR/fw4_post.nft" ] || [ -e "$DNSMASQ_DIR/../dnsmasq-homeproxy-pro.conf" ]
}
# hp_release_intercept
# Take the intercept layer back down, without touching the service's own
# instances (procd keeps supervising them).
#
# A start that cannot prove the client came up must not leave the redirect
# rules and the DNS snippets in place: they point the LAN's TCP and DNS at
# listeners that are not there, so the routers' clients lose both instead of
# merely not being proxied. The reload path gets that for free through its
# rollback; the start path (boot, a manual start, LuCI's Start button) has no
# rollback at all, which is the asymmetry this closes.
#
# The server's inbound accepts (fw4_forward/fw4_input) are left in place: a
# failed *client* does not make the server unreachable, and the two sides are
# independent instances.
#
# The release is recorded in $RUN_DIR/intercept-released so that service_started
# can put the layer back once the instance does come up. Without that marker the
# release is one-way: procd keeps respawning the instance, the health check can
# start passing on a later attempt, and nothing reinstalls the rules - the router
# then reports the service as running while the LAN is not proxied at all.
hp_release_intercept() {
log "Reverting the intercept layer; traffic will go out unproxied until the client is up."
hp_net_teardown
hp_firewall_teardown "$RUN_DIR" keep-pre
hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
printf '%s\n' "1" > "$RUN_DIR/intercept-released"
}
# hp_rearm_intercept <proxy-mode> <routing-mode>
# Reinstall the layer a failed start released, once the client is proven up.
# It only runs when that failure actually happened - on a healthy start there is
# nothing to redo - which is what makes the release in hp_release_intercept()
# reversible instead of a silent, permanent downgrade to direct routing.
hp_rearm_intercept() {
[ -f "$RUN_DIR/intercept-released" ] || return 0
rm -f "$RUN_DIR/intercept-released"
log "Client is up again; reinstalling the intercept layer."
hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$2" "$RUN_DIR"
hp_net_setup "$1" "$2"
# hp_firewall_apply already writes $RUN_DIR/failed-firewall on its own
# failure, but the helper's caller is service_started, which only reads
# that marker when it comes from the candidate path. A failed rearm
# must surface the same way: writing the file here makes the run a
# "firewall did not come up" candidate, the health gate will not pass,
# and the user sees a definite failure rather than "running but LAN
# not proxied". The marker is cleared by service_started once the run
# is otherwise healthy (see the rm -f near $RUN_DIR/failed-firewall below).
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "1"; then
log "Error: the firewall rules could not be reinstalled; the proxy may not be enforced."
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
return 1
fi
}
# hp_release_stale_intercept
# The same, but only when a previous start left something installed. Without
# the check every start of an unconfigured router would pay a full fw4 reload
# and a dnsmasq restart to undo rules that were never installed.
hp_release_stale_intercept() {
hp_intercept_installed || return 0
hp_release_intercept
}
start_service() {
config_load "$CONF"
# Every refusal below happens *before* this run installs anything, so a
# stale intercept layer is released rather than re-applied. Without it,
# a router whose node was removed (or whose sing-box just failed its
# version gate) keeps redirecting the LAN into a listener that no instance
# is serving any more.
#
# The ucode gate comes first because it is the more fundamental one: when
# the interpreter cannot load this package's modules, the generator below
# would fail too, and the sing-box version line is the less useful of the
# two messages.
if ! hp_require_ucode; then
hp_release_stale_intercept
return 1
fi
if ! hp_require_singbox; then
hp_release_stale_intercept
return 1
fi
local routing_mode proxy_mode outbound_node server_enabled
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
hp_client_outbound_node "$routing_mode" outbound_node
config_get_bool server_enabled "server" "enabled" "0"
if [ "$outbound_node" = "nil" ] && [ "$server_enabled" = "0" ]; then
log "Neither client nor server is configured. Service not started."
hp_release_stale_intercept
return 1
fi
local client_enabled=0
[ "$outbound_node" = "nil" ] || client_enabled=1
# Client switched off (main_node cleared, or custom routing without a
# default outbound): the redirect rules and the DNS snippets from the
# previous start have to go, or the LAN keeps pointing at listeners no
# instance is serving. This matters more since a reload keeps the DNS
# layer across its stop (see stop_service): without it, a reload that
# disables the client would leave the snippets installed forever.
# The server's accepts (fw4_forward/fw4_input) are left alone.
if [ "$client_enabled" != "1" ]; then
hp_release_stale_intercept
fi
mkdir -p "$RUN_DIR"
# Kernel 6.6.x has a TUN/GSO bug that needs GSO turned off for the tun
# inbound; other releases either carry the fix or never had the problem.
local disable_gso=0
uname -r | grep -Eq "^6\.6" && disable_gso=1
# The health gate reads the ports the running configuration declares from
# sing-box-c.json (service_started -> hp_config_ports), not from UCI: after
# a rollback the live file is the older known-good copy and may bind
# different ports. Reading mixed_port/dns_port here as well only produced
# two unused locals.
if [ "$client_enabled" = "1" ]; then
hp_net_wait_wan 60
# The rule-set archive has to exist before the generator runs, not
# after: `sing-box check` opens every local rule_set path, so a
# missing directory used to surface as sing-box's own
# "parse rule-set[0]: open ...: no such file" and nothing else. The
# generator also refuses a rule-set whose file is absent, with a
# message that names the rule-set and the directory to copy into.
hp_prepare_ruleset_dir "$HP_DIR"
# Every rule-set the generated configuration references has to exist
# BEFORE the generator runs: `sing-box check` opens each local path, and
# a missing china_ip4.json used to fail the whole start on a fresh
# install (see hp_prepare_generated_inputs).
hp_prepare_generated_inputs "$HP_DIR" "1"
if ! hp_start_generated_config c "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
hp_release_stale_intercept
return 1
fi
local auto_update auto_update_time
config_get_bool auto_update "subscription" "auto_update" "0"
config_get auto_update_time "subscription" "auto_update_time" "2"
hp_sync_autoupdate_cron "$auto_update" "$auto_update_time"
# Unconditional, and independent of the switch above: the resource
# lists decide the firewall's mainland set, and that going stale is
# not something "update my subscriptions" should have been able to
# cause.
hp_sync_resource_cron
# The DNS snippets are the other half of the intercept layer: without
# them the LAN resolves through the ISP while the nft rules still send
# its DNS to sing-box's dns-in, which the redirect chain does not cover.
# The writer already reports failures (render or install) instead of
# leaving an empty snippet behind; record it the same way a firewall
# failure is recorded, so the configuration is not promoted to
# known-good and the log names the real problem.
if ! hp_dnsmasq_write_snippets "$DNSMASQ_DIR" "$HP_DIR" "$routing_mode" "$RUN_DIR"; then
log "Error: the dnsmasq snippets were not installed; LAN DNS keeps using the previous configuration."
printf '%s\n' "dns" > "$RUN_DIR/failed-dns"
fi
hp_net_setup "$proxy_mode" "$routing_mode"
# R-08: gfwlist mode routes by the nft set that dnsmasq populates
# (`nftset=`), and the post-template gates every proxy chain on
# `ip daddr != @homeproxy_gfw_list_v4`. On a dnsmasq without nftset the
# set is never filled, so that gate is true for every packet and the
# whole mode silently degrades to direct routing. Record the capability
# where the template can read it (it renders before this point on the
# next start, so the file has to exist by then) and let the template
# omit the gate instead: traffic then reaches sing-box, which routes it
# by geoip exactly like the nftset gate would have.
hp_dnsmasq_has_nftset && printf '%s\n' "1" > "$HP_DIR/resources/.dnsmasq-nftset" \
|| rm -f "$HP_DIR/resources/.dnsmasq-nftset"
hp_procd_client_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$routing_mode" "$disable_gso"
fi
if [ "$server_enabled" = "1" ]; then
if ! hp_start_generated_config s "$HP_DIR" "$RUN_DIR" "$GOOD_DIR"; then
# Only the server side failed. hp_release_stale_intercept() tears
# down the *client* layer (net rules + redirect chains + DNS
# snippets), so calling it here took a healthy, already-verified
# client off the air because a server certificate or port was bad -
# the opposite of the "the two sides are independent instances"
# rule this file states above. service_started()'s
# failed="sing-box-s" path handles the server side and already
# leaves the client layer alone.
return 1
fi
hp_procd_server_instance "$PROG" "$HP_DIR" "$RUN_DIR" "$disable_gso"
fi
hp_procd_log_cleaner "$HP_DIR"
hp_prepare_runtime_files "$HP_DIR" "$RUN_DIR" "$routing_mode" "$client_enabled" "$server_enabled"
# A firewall that did not apply means traffic is not actually being
# intercepted. sing-box still runs (the service stays up), but the
# configuration must not become the rollback target - service_started()
# reads this marker before promoting.
if ! hp_firewall_apply "$HP_DIR" "$RUN_DIR" "$client_enabled"; then
log "Error: the firewall rules were not applied completely; the proxy may not be enforced."
printf '%s\n' "firewall" > "$RUN_DIR/failed-firewall"
fi
# Nothing is verified here on purpose. With USE_PROCD=1 the
# procd_open_instance/procd_close_instance calls above only *register* the
# instances; procd starts them when rc.common closes the service, which
# happens after this function returns. Gating here would sample a service
# that has not been started yet and always time out - the health gate
# therefore lives in service_started(), which rc.common calls at the point
# where the instances are actually up.
return 0
}
# service_started() is rc.common's post-start hook:
#
# start() { rc_procd start_service "$@"; service_started; }
#
# so this runs after procd_close_service. It is where a configuration earns
# the right to be called good.
service_started() {
config_load "$CONF"
local routing_mode outbound_node server_enabled proxy_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
hp_client_outbound_node "$routing_mode" outbound_node
config_get_bool server_enabled "server" "enabled" "0"
local client_enabled=0
[ "$outbound_node" = "nil" ] || client_enabled=1
[ "$client_enabled" = "1" ] || [ "$server_enabled" = "1" ] || return 0
# The listeners to verify are the ones the running configuration declares,
# read from the file rather than from UCI: after a rollback the live file
# is the older known-good copy, so UCI describes ports this configuration
# does not use.
#
# The gate checks that sing-box OWNS them: a port that is listening but
# belongs to another process is exactly the failure it has to catch.
#
# `sing-box check` cannot see a port that is already taken or a
# certificate it cannot read, and a single process probe cannot tell a
# healthy instance from procd restarting a dying one.
local failed="" client_ports server_ports
if [ "$client_enabled" = "1" ]; then
client_ports="$(hp_config_ports "$RUN_DIR/sing-box-c.json")"
hp_wait_service "sing-box-c" "$RUN_DIR/sing-box-c.json" \
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $client_ports \
|| failed="sing-box-c"
# The probes read their ports from the running configuration, not from
# UCI: after a rollback the live file is the older known-good copy and
# may bind different ports, and probing the candidate's ports would
# warn about a healthy service (or, with health_probe_strict on, roll
# it back again).
if [ -z "$failed" ]; then
local probe_dns_port probe_mixed_port probe_strict probe_name
probe_dns_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'dns-in')"
probe_mixed_port="$(hp_config_port_for_tag "$RUN_DIR/sing-box-c.json" 'mixed-in')"
config_get_bool probe_strict "config" "health_probe_strict" "0"
config_get probe_name "config" "health_probe_name" "example.com"
HP_PROBE_STRICT="$probe_strict"
HP_PROBE_NAME="$probe_name"
export HP_PROBE_STRICT HP_PROBE_NAME
hp_run_probes "client" "$probe_dns_port" "$probe_mixed_port" \
|| failed="sing-box-c"
fi
fi
if [ "$server_enabled" = "1" ] && [ -z "$failed" ]; then
server_ports="$(hp_config_ports "$RUN_DIR/sing-box-s.json")"
hp_wait_service "sing-box-s" "$RUN_DIR/sing-box-s.json" \
"$HP_HEALTH_BUDGET" "$HP_HEALTH_STABLE" $server_ports \
|| failed="sing-box-s"
fi
if [ -n "$failed" ]; then
# start() returns procd's status, not start_service's, so the failure
# is recorded where reload_service cannot miss it. A file is used
# rather than a shell variable because service_started and
# reload_service are not the same shell invocation.
printf '%s\n' "$failed" > "$RUN_DIR/failed-side"
log "Error: $failed did not come up with the new configuration."
# The intercept rules were installed by start_service before this
# gate ran, and on the *start* path nothing else takes them back
# down: the LAN would keep sending TCP to the redirect port and DNS
# to the dnsmasq snippets while no instance is listening. Only a
# failed client black-holes the LAN - the server's accepts are
# inbound and a failed *server* leaves the client working - so this
# is scoped to the client side. On the reload path the rollback
# below re-applies everything on its way to the old configuration.
[ "$failed" = "sing-box-c" ] && hp_release_intercept
# The rollback is driven by reload_service, which can stop and
# restart the service.
return 1
fi
rm -f "$RUN_DIR/failed-side"
# A previous start may have released the intercept layer because the client
# did not come up (hp_release_intercept). procd respawns the instance, and
# now that it is proven healthy the rules have to come back - otherwise the
# service runs and reports "running" while the LAN is not proxied.
[ "$client_enabled" = "1" ] && hp_rearm_intercept "$proxy_mode" "$routing_mode"
# Proven good: this is what a later failed reload rolls back to. A
# configuration whose intercept layer is incomplete is not "good" - traffic
# is not being intercepted the way the configuration says - so it is not
# recorded, and the previous known-good survives as the rollback target.
# Two independent markers, because a successful hp_firewall_apply() must not
# be able to mask a failed snippet install (or the other way round).
if [ -s "$RUN_DIR/failed-firewall" ] || [ -s "$RUN_DIR/failed-dns" ]; then
[ -s "$RUN_DIR/failed-firewall" ] \
&& log "Warning: the firewall rules did not apply; keeping the previous known-good configuration."
[ -s "$RUN_DIR/failed-dns" ] \
&& log "Warning: the DNS snippets were not installed; keeping the previous known-good configuration."
rm -f "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
else
[ "$client_enabled" = "1" ] && hp_promote_known_good c "$RUN_DIR" "$GOOD_DIR"
[ "$server_enabled" = "1" ] && hp_promote_known_good s "$RUN_DIR" "$GOOD_DIR"
fi
log "sing-box $(sing-box version -n 2>/dev/null || echo "unknown") started."
return 0
}
stop_service() {
hp_clear_autoupdate_cron
config_load "$CONF"
# P1-8: on a reload the network layer is left in place too. The ip rules and
# the routing table are the same before and after a configuration change
# (they derive from the infra marks, not from the nodes), hp_net_setup() is
# idempotent since r20, and tearing them down here only opened a window in
# which the tproxy rules were gone. A real stop still removes them.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_net_teardown
hp_firewall_teardown "$RUN_DIR"
# A reload is a restart, not a shutdown, and it keeps the DNS layer: the
# snippets are re-rendered by start_service moments later, and their writer
# compares content before restarting dnsmasq. Taking them down here would
# leak the LAN's queries to the ISP resolvers for the whole stop/start
# window and restart dnsmasq twice (flushing every client's cache) for a
# configuration that did not change. The cost is that DNS points at
# sing-box's dns-in while the instances are down - a few seconds of failed
# lookups instead of a few seconds of plaintext queries. A real stop (no
# flag) removes them, and a reload that disables the client releases them
# through hp_release_stale_intercept() in start_service.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || hp_dnsmasq_remove_snippets "$DNSMASQ_DIR"
rm -f "$RUN_DIR/sing-box-c.json" "$RUN_DIR/sing-box-c.log" \
"$RUN_DIR/sing-box-s.json" "$RUN_DIR/sing-box-s.log" \
"$RUN_DIR/failed-side" "$RUN_DIR/failed-firewall" "$RUN_DIR/failed-dns"
# A released marker only means something within one start; a stop clears it
# so the next start does not reinstall a layer it is about to install anyway.
[ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ] || rm -f "$RUN_DIR/intercept-released"
log "Service stopped."
}
service_stopped() {
config_load "$CONF"
# Review P2 (2026-09-28): a reload stops then starts the service
# inside the same procd invocation, and HP_RELOAD_IN_PROGRESS is set
# during that window (set by reload_service just before the
# `stop; start`). When the reload is a tun -> tun reload, removing
# the tun device here pulls the default route out of table 100, so
# marked traffic falls back to the main table while the new sing-box
# is starting up - the LAN hits the WAN with the real IP. Skip the
# tuntap del on the reload path; the next start will reuse the
# existing device if its name and address match (hp_net_setup checks).
# A genuine stop (HP_RELOAD_IN_PROGRESS=0) keeps the original
# behaviour: the device is torn down so a subsequent start can
# create a fresh one with whatever the new UCI asked for.
if [ "${HP_RELOAD_IN_PROGRESS:-0}" = "1" ]; then
:
else
hp_net_remove_tun
fi
}
reload_service() {
log "Reloading service..."
config_load "$CONF"
local routing_mode
config_get routing_mode "config" "routing_mode" "bypass_mainland_china"
local outbound_node
hp_client_outbound_node "$routing_mode" outbound_node
# Generate first, tear down later. If the new configuration is invalid
# the generator leaves the running one in place, so the reload is
# aborted *before* stop_service() - the old configuration keeps running
# instead of the router ending up with no service at all.
#
# The rule-set archive is created before the preflight, for the same
# reason start_service does it: the generator's `sing-box check` opens
# every local rule_set path, so a missing directory would fail the
# preflight for a reason the user cannot act on.
hp_prepare_ruleset_dir "$HP_DIR"
#
# A3: whichever sides are generated here are the candidates start_service
# must activate - it must not generate them a second time. Two invocations
# of the generator are not guaranteed to write the same bytes (its env
# carries the WAN resolver and the domain-resource lists), so regenerating
# in start_service would run a file that was never `sing-box check`ed:
# validate A -> discard A -> generate B -> run B.
#
# The flags are per side on purpose: reload_service and start_service each
# do their own config_load, and if the two disagree (a concurrent UCI
# change) the side this preflight did not generate must still be generated
# rather than activated from a stale file.
HP_CANDIDATE_CLIENT=0
HP_CANDIDATE_SERVER=0
if [ "$outbound_node" != "nil" ]; then
# Same reason as in start_service: the preflight runs `sing-box check`,
# which opens every local rule-set path. Doing this here also means a
# reload after a resource-list update regenerate the files before they
# are validated, instead of failing on a missing china_ip4.json.
hp_prepare_generated_inputs "$HP_DIR" "1"
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
return 1
fi
HP_CANDIDATE_CLIENT=1
fi
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
if [ "$server_enabled" = "1" ]; then
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
return 1
fi
HP_CANDIDATE_SERVER=1
fi
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
# file that was just validated would not survive to start_service. Stage a
# copy of each validated side first; the stop that follows cannot reach it,
# and start_service activates those exact bytes. A staging failure aborts
# the reload while the running configuration is still untouched.
if [ "$HP_CANDIDATE_CLIENT" = "1" ]; then
if ! hp_capture_candidate "$RUN_DIR" c; then
log "Error: could not stage the validated client configuration; reload aborted; the running configuration is kept."
return 1
fi
fi
if [ "$HP_CANDIDATE_SERVER" = "1" ]; then
if ! hp_capture_candidate "$RUN_DIR" s; then
log "Error: could not stage the validated server configuration; reload aborted; the running configuration is kept."
return 1
fi
fi
# `start` runs start_service in this same shell, but export them anyway so
# the contract does not depend on that: HP_USE_KNOWN_GOOD is exported the
# same way in the rollback path below.
export HP_CANDIDATE_CLIENT HP_CANDIDATE_SERVER
# Nothing to do? The candidates are byte-identical to what is already
# running and the running instance is healthy, so the stop;start below
# would interrupt every connection to install a file that is already
# installed. Checked AFTER the preflight, never before: the point is to
# skip a restart, not to skip the validation - a configuration that fails
# `sing-box check` has to be reported as such whether or not it would have
# been a no-op, and the candidate has to exist for anything to compare.
#
# Every "cannot prove it is a no-op" case falls through to the normal
# reload, so the worst outcome of this helper is the behaviour that
# existed before it.
#
# `HP_CANDIDATE_CLIENT` is the same flag the preflight set, so the two
# sides cannot disagree about which configuration is being activated.
if [ "$HP_CANDIDATE_CLIENT" = "1" ] || [ "$HP_CANDIDATE_SERVER" = "1" ]; then
if hp_reload_is_noop "$RUN_DIR" "$GOOD_DIR" "$HP_CANDIDATE_CLIENT" "$HP_CANDIDATE_SERVER"; then
log "Configuration is unchanged and the service is healthy; nothing to restart."
return 0
fi
fi
# Both configurations passed `sing-box check`; now it is safe to restart.
# start_service owns the rest of the transaction: it proves the instances
# came up and only then records the new known-good, so the previous one
# survives until that point - which is what makes the rollback below
# possible.
#
# The flag tells stop_service that this stop belongs to a reload, so the
# DNS layer is left in place for start_service to re-render (it compares
# content and only restarts dnsmasq when something actually changed). It
# stays set for the rollback path below, which is also a reload.
HP_RELOAD_IN_PROGRESS=1
export HP_RELOAD_IN_PROGRESS
stop
start
# start_service proved the candidate does not run (it left the marker).
# The known-good copy still holds the previous configuration, because a
# candidate is no longer recorded before it has proven itself.
local failed=""
[ -s "$RUN_DIR/failed-side" ] && failed="$(cat "$RUN_DIR/failed-side")"
# reload_service and start_service each do their own config_load. If the
# two disagree - a concurrent reload, or a read that fell back to the UCI
# defaults - the marker can be missing while nothing was started at all.
# The filesystem is the tie-breaker: a side this reload believed was
# enabled must have produced a live configuration.
if [ -z "$failed" ] && [ "$outbound_node" != "nil" ] && [ ! -s "$RUN_DIR/sing-box-c.json" ]; then
failed="sing-box-c"
fi
if [ -z "$failed" ] && [ "$server_enabled" = "1" ] && [ ! -s "$RUN_DIR/sing-box-s.json" ]; then
failed="sing-box-s"
fi
if [ -z "$failed" ]; then
log "Reload completed."
return 0
fi
log "Error: $failed did not come up with the new configuration, rolling back."
# Review P1-3 (2026-09-28): restore the nft-layer-driving UCI keys
# *before* the rolled-back stop;start. Without this, start_service()
# re-reads CURRENT UCI (the failing, new configuration) and installs
# the wrong layer - the classic "rollback worked but LAN still not
# proxied" symptom. hp_promote_known_good snapshots these keys when
# the candidate is healthy; this is the symmetric restore.
if ! hp_restore_known_good_uci "$GOOD_DIR"; then
log "Warning: no uci-snapshot alongside the known-good copy; the firewall/dnsmasq layer will be reinstalled from the failing UCI on the rollback restart. This snapshot exists from r27 onwards; older installs fall back to the previous behaviour."
fi
local good="$GOOD_DIR/$failed.json"
local live="$RUN_DIR/$failed.json"
local rolled_back=0
if hp_same_file "$live" "$good"; then
log "Error: the failing configuration is already the known-good one; nothing to roll back to."
# Review P1-4 (2026-09-28): the failing candidate is byte-equal to
# known-good and we are skipping stop;start, so the
# intercept-released marker set by hp_release_intercept during
# the failed start would otherwise survive until a manual reload.
# Clear it here and let the next reload or stop:start rearm the
# intercept layer; the proxy still does not work in this exact
# moment, but at least no marker lies about its state.
[ -f "$RUN_DIR/intercept-released" ] && rm -f "$RUN_DIR/intercept-released"
elif hp_rollback "$live" "$good"; then
rolled_back=1
else
log "Error: no known-good configuration to roll back to."
fi
if [ "$rolled_back" = "0" ]; then
log "Error: rollback was not possible; the service may be down. Check $LOG_PATH."
return 1
fi
# Start from the recorded file rather than regenerating: the generator
# is a pure function of UCI, so it would rebuild the config that just
# failed.
HP_USE_KNOWN_GOOD=1
# The failed candidate has just exhausted procd's respawn retries, so the
# restart below can take noticeably longer than a normal start.
HP_HEALTH_BUDGET="$HP_HEALTH_ROLLBACK_BUDGET"
export HP_USE_KNOWN_GOOD HP_HEALTH_BUDGET
stop
start
if [ ! -s "$RUN_DIR/failed-side" ]; then
log "Rollback completed; the previous configuration is running."
return 0
fi
log "Error: rollback did not restore the service; manual intervention required."
return 1
}
service_triggers() {
procd_add_reload_trigger "$CONF"
# `wan` is the logical name netifd and the firewall use on every target
# (a PPPoE uplink is `wan` at the logical layer, `pppoe-wan` only at the
# device layer), so this does not need a per-device lookup.
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
}