#!/bin/sh /etc/rc.common
# /etc/init.d/shater — procd supervisor for the Shater data plane (v0.2).
#
# v0.2 collapses the old xray + xrayctl + dnsmasq trio into ONE long-lived Go
# process: `shaterd run`. That process embeds the sing-box engine, the
# control-plane AND the in-process DNS server; it OWNS the nft table
# `inet shater`, the reserved policy-routing tables, and the :53 hijack. So this
# init no longer generates a run.json or drives an external engine — it just
# supervises `shaterd run` and lets the daemon apply/teardown its own data plane.
#
# DAEMON CONTRACT (see docs-shater/PORTING.md "Wave 3 daemon contract"):
#   * `shaterd run`       — the supervised daemon. Reads UCI on startup; if
#                           globals.enabled it applies engine+netplane, else it
#                           stays inert. Then blocks.
#   * SIGTERM (procd stop)-> honest teardown (engine.Close + netplane teardown),
#                           then exit. We do NOT tear nft/routing down from the
#                           shell on a normal stop — the daemon owns that.
#   * SIGHUP  (reconcile) -> re-read UCI + idempotent re-apply (config-hash gate
#                           means an unchanged apply does NOT churn the tunnel).
#
# RELIABILITY CONTRACT (the "железно" layer):
#   * The DATA PLANE is completely INERT unless globals.enabled=1. The daemon
#     itself is supervised either way (it needs to be up to serve the admin panel
#     and the control socket — that is the ONLY way to configure the box), but
#     with globals.enabled=0 `shaterd run` opens no engine, emits no nft rules and
#     touches no routing, so boot connectivity is NEVER affected.
#     (Bootstrap: the panel is served BY the daemon. Refusing to start it while
#     disabled made a fresh install unconfigurable — the LuCI "Open panel" button
#     needs a live daemon to mint a handoff token, and the daemon only becomes
#     enable-able THROUGH the panel. Hence: supervise always, intercept never
#     until enabled.)
#   * The interception data plane may only exist while this service is meant to
#     be running. `start` raises ACTIVE_FLAG, `stop` clears it; hotplug/cron
#     reconcile ONLY while the flag is up, so an admin `stop` STICKS — no
#     background actor may resurrect interception behind a stopped daemon.
#   * The engine must never be permanently abandoned while interception stands:
#     respawn retries are infinite (procd never gives up); a sustained-dead
#     daemon is additionally escalated by the shater-cron watchdog.
#   * busybox ash only — no bashisms.

USE_PROCD=1
START=99          # after network + firewall
STOP=10

PROG=/usr/bin/shaterd
# Raised while the service is meant to be running; the ONLY token that lets
# hotplug/shater-cron touch the data plane. tmpfs => cleared by reboot, so
# nothing reconciles before this init has run at boot.
ACTIVE_FLAG=/var/run/shater.active
# Written by `shaterd run`; the single-owner token this init waits on so a
# restart never overlaps a new data plane with the previous one's teardown.
PIDFILE=/var/run/shaterd.pid
# Seconds `start` will wait for a predecessor to finish its teardown. Must be
# >= term_timeout below (procd's hard cap on a predecessor's life after SIGTERM)
# so we never give up while procd is still letting it shut down cleanly.
STOP_WAIT_SECS=40

# --- helpers ---------------------------------------------------------------

# True only when the stack is explicitly enabled in UCI.
shater_enabled() {
	local en
	en=$(uci -q get shater.globals.enabled) || return 1
	[ "$en" = "1" ]
}

# Syslog line that honors globals.log_syslog — the SAME toggle that silences the
# daemon's own stderr->logread stream. With log_syslog=0 the operator asked for
# a silent syslog, and shell status lines emitted AROUND the binary must not
# leak past the binary's silence. Absent option (or unreadable UCI) = ON, which
# matches the daemon's default.
_slog() {
	[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater "$@"
}

# Echo the pid of a LIVE `shaterd run`, or fail. The pidfile is written by the
# daemon itself and removed only by the daemon that owns it, AFTER its teardown
# has completed — so "pidfile names a live process" is precisely "the previous
# data plane has not been dismantled yet".
shater_daemon_pid() {
	local pid
	pid=$(cat "$PIDFILE" 2>/dev/null) || return 1
	[ -n "$pid" ] || return 1
	kill -0 "$pid" 2>/dev/null || return 1
	echo "$pid"
}

# Block until no predecessor daemon is left, bounded by STOP_WAIT_SECS.
#
# WHY THIS EXISTS. procd's `stop` is ASYNCHRONOUS: rc.common's `restart` is
# literally `stop; start`, and the `service delete` ubus call returns the moment
# procd has SENT SIGTERM — not when the instance is gone. `start` therefore
# re-adds the instance while the outgoing `shaterd run` is still executing its
# honest teardown (engine close, then `nft delete table`, `ip rule`/`ip route`
# removal and the per-iface sysctl restore). The result is that `restart` is NOT
# equivalent to `stop` + pause + `start`: the new plane is stood up on top of
# kernel state the old one has not finished removing, which is what B3 (DNS to
# the router's own LAN address dead after a restart, and never recovering) came
# out of. Waiting here restores the equivalence, and costs literally nothing when
# there is no predecessor — the check runs before the first sleep.
#
# Returning non-zero does NOT abort the start: the daemon carries its own
# single-owner guard and will refuse (or wait) on its side. Better to hand the
# decision to the process that can actually see the plane than to leave the box
# with no service at all.
shater_wait_stopped() {
	local i=0 pid
	pid=$(shater_daemon_pid) || return 0
	_slog -p daemon.info \
		"restart: waiting for the previous shaterd (pid $pid) to finish tearing the data plane down"
	while [ "$i" -lt "$STOP_WAIT_SECS" ]; do
		sleep 1
		i=$((i + 1))
		shater_daemon_pid >/dev/null || {
			_slog -p daemon.info "restart: previous shaterd exited after ${i}s; starting a fresh one"
			return 0
		}
	done
	_slog -p daemon.warn \
		"restart: previous shaterd (pid $pid) still alive after ${STOP_WAIT_SECS}s — starting anyway"
	return 1
}

# --- procd lifecycle -------------------------------------------------------

start_service() {
	# NOTE: deliberately NO `shater_enabled` guard here. The daemon is the box's
	# only configuration surface (it serves the admin panel + the control socket
	# that `shaterd apply`/`mint-token` talk to), so it must be reachable BEFORE
	# the stack is enabled — otherwise a fresh install can only be configured by
	# hand-editing UCI over SSH. With globals.enabled=0 the daemon logs
	# "staying inert" and applies NOTHING (no engine, no nft, no ip rules), so
	# supervising it here cannot affect connectivity. Interception is still gated
	# on globals.enabled — see ACTIVE_FLAG below.

	# Guard: never claim to run without the daemon binary. A half-removed/failed
	# shaterd upgrade must degrade to "plugin off", not to a box that thinks
	# interception is live with nothing behind it.
	if [ ! -x "$PROG" ]; then
		_slog -p daemon.err \
			"shaterd binary missing/not executable at $PROG — refusing to start (LAN stays on plain routing)"
		return 0
	fi

	# Do not stand a new data plane up on top of one that is still being taken
	# down. On `restart` procd has only just SIGTERMed the previous instance and
	# returned; this is the handshake that makes `restart` == `stop` + pause +
	# `start`. It also keeps `migrate` below from rewriting UCI underneath a
	# daemon that is still reading it. No-op (and no delay) when nothing is
	# running, which is the boot case.
	shater_wait_stopped

	# Bring the UCI schema forward before the daemon reads it (idempotent;
	# refuses a newer schema) so an upgraded package never applies a stale config.
	"$PROG" migrate >/dev/null 2>&1

	procd_open_instance shater
	# shaterd runs in the FOREGROUND under procd (must never daemonize). `run` is
	# the long-lived daemon that owns the in-process engine + netplane. It reads
	# UCI itself — there is NO -c config file to point at.
	procd_set_param command "$PROG" run
	# threshold(3600) timeout(5) retries(0=INFINITE): procd must NEVER give up on
	# the daemon while our interception rules stand — an abandoned engine with
	# live TPROXY+DNS-hijack rules is a permanent LAN blackout. A genuine crash
	# loop retries every 5s (cheap); sustained death is escalated by the
	# shater-cron watchdog (which fails open / alerts per kill_switch policy).
	procd_set_param respawn 3600 5 0
	# NOTE: deliberately NO `procd_set_param file` config watch. A file-watch
	# would bounce the tunnel (dropping every proxied connection) on EVERY UCI
	# commit, even when nothing relevant changed. Config changes reach the daemon
	# through reload_service (below) instead, and the daemon's config-hash gate
	# decides whether an apply actually rebuilds the engine.
	procd_set_param stdout 1                                # -> logread
	procd_set_param stderr 1
	# Give the daemon room to run its honest teardown (engine.Close + netplane
	# restore) before procd SIGKILLs it.
	#
	# 30s, not 10s: an engine holding a few hundred outbounds closes its
	# urltest/observatory goroutines and flushes experimental.cache_file to FLASH
	# before the netplane teardown even starts, and on eMMC/NAND that alone can
	# outlast 10s. A SIGKILL there aborts the teardown at an arbitrary point and
	# leaves the plane HALF removed — the nft table gone but the policy routing
	# still installed, or vice versa — which is precisely the class of leftover
	# state the successor's idempotent fast-path cannot see and never repairs.
	# Shutdown is bounded by procd either way; we are only choosing where.
	procd_set_param term_timeout 30
	procd_close_instance

	# Mark the stack live for hotplug/cron — but ONLY when interception is
	# actually meant to stand. While globals.enabled=0 the daemon applies nothing,
	# so raising the flag would licence hotplug/cron to poke a data plane that does
	# not exist. The daemon raises/clears the flag itself around apply/teardown;
	# this is just the boot-time seed for the enabled case.
	if shater_enabled; then
		mkdir -p "$(dirname "$ACTIVE_FLAG")"
		: > "$ACTIVE_FLAG"
	else
		rm -f "$ACTIVE_FLAG"
	fi
}

stop_service() {
	# Drop the live-flag FIRST so a concurrent hotplug/cron tick cannot rebuild
	# what we are about to tear down. procd then sends SIGTERM to `shaterd run`,
	# which runs its OWN honest teardown (engine.Close + netplane restore) — we
	# deliberately do NOT tear nft/routing down from the shell here, both to
	# avoid racing the daemon and because the daemon is the single owner of that
	# state. A crashed daemon that left rules standing is caught by the
	# shater-cron watchdog.
	rm -f "$ACTIVE_FLAG"
}

reload_service() {
	# Fired by the `shater` config.change reload-trigger (LuCI Save & Apply /
	# reload_config). Simplest correct behaviour: stop + start. `stop` clears the
	# flag and SIGTERMs the daemon (honest teardown); `start` WAITS for that
	# teardown to actually finish (shater_wait_stopped) and then launches a fresh
	# `shaterd run` that reads the new UCI and applies it. When the stack is
	# disabled, `start` is a no-op, so a disable+apply cleanly tears everything
	# down. Because the wait lives in start_service, this path gets the same
	# stop-then-start ordering guarantee as `restart`.
	stop
	start
}

service_triggers() {
	procd_add_reload_trigger "shater"
}
