mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 11:03:56 +08:00
736 lines
33 KiB
Bash
Executable File
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
|
|
}
|