#!/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
}
