#!/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.
#   * BEING REPLACED IS NOT BEING SWITCHED OFF. `restart`, `reload` (which is
#     stop+start, i.e. every LuCI Save & Apply) and every package upgrade all run
#     through `stop`, and the daemon's SIGTERM teardown removes the fail-closed
#     table unconditionally — it does not consult kill_switch at all. Between that
#     teardown and the successor's first apply the init GUARANTEES a gap: it waits
#     for the old process to exit (shater_wait_stopped), then runs `shaterd
#     migrate`, then starts a daemon that still has to build an engine. So a
#     restart is announced with RESTART_FLAG, which tells the outgoing daemon to
#     leave the fail-closed holding plane behind instead of bare routing. A real
#     `stop` raises no flag and therefore still means what it says.
#   * The FAIL-CLOSED PLANE MUST ALSO EXIST BEFORE THIS SCRIPT DOES. START=99 is
#     after fw4 (19) and netifd (20), so at every boot the LAN forwards to the WAN
#     in the clear for as long as it takes procd to decompress the daemon off
#     flash and get an engine up. /etc/init.d/shater-armor (START=21) loads
#     BOOT_ARMOR — a copy of the holding plane the daemon persists on every apply
#     — to close that window. This script owns the DISARM half: a deliberate
#     `stop`, or a missing daemon binary, removes the armor so it cannot outlive
#     the product it protects.
#   * 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
# Raised around a restart/reload, read by the OUTGOING `shaterd run` at SIGTERM:
# present => "you are being replaced, leave the fail-closed plane standing";
# absent  => "you are being switched off, take everything down". tmpfs, so a
# power cut can never make the next boot look like a restart.
RESTART_FLAG=/var/run/shater.restarting
# The persisted fail-closed holding plane. Written by the daemon on every apply,
# loaded by /etc/init.d/shater-armor at boot. Its PRESENCE is the arm token, so
# removing it here is how a deliberate stop stops the next boot from blocking.
BOOT_ARMOR=/etc/shater/boot.nft
# 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

# WHICH ACTION rc.common was invoked with, frozen at source time.
#
# rc.common sets `action=${2:-help}` before it sources this file, and every action
# then runs as a function in THAT SAME shell — so `stop_service` can see whether it
# was reached by `stop` or as the first half of `restart`/`reload`. That is the one
# distinction procd itself does not expose (`restart` is literally `stop; start`,
# and stop_service is called identically by both).
#
# Frozen into our own variable because `action` is a short, generic name that other
# framework helpers also use as a local; a snapshot taken before any function runs
# cannot be shadowed later. An EMPTY or unexpected value degrades to "real stop",
# which is the pre-existing behaviour and the safe direction to be wrong in: it
# costs a plaintext window on restart, where the other default would leave a
# deliberately stopped router blocked.
SHATER_RC_ACTION="$action"

# --- 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 "$@"
}

# Announce/withdraw "this daemon is being replaced, not switched off". Read by
# `shaterd run` when it receives SIGTERM.
shater_mark_restart() {
	mkdir -p "$(dirname "$RESTART_FLAG")" 2>/dev/null
	: > "$RESTART_FLAG"
}
shater_clear_restart() { rm -f "$RESTART_FLAG"; }

# Remove the persisted boot armor, so the LAN is NOT blocked at the next boot
# before the daemon starts. Called when the operator stops the service and when
# the daemon binary is gone — in both cases nothing is going to come along and
# replace the armor with a real data plane, and a kill switch with nothing behind
# it is just a brick.
shater_disarm_boot() { rm -f "$BOOT_ARMOR"; }

# 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.
	#
	# "Plugin off" now has to include DISARMING. With the boot armor in play, a
	# missing binary is the one case where the fail-closed plane could stand
	# forever with nothing able to replace it: the armor loads at START=21, the
	# daemon never starts, and every later boot repeats it. The product being gone
	# is not a security event — it is an uninstall — so the plane comes down and
	# the LAN returns to plain routing, loudly.
	if [ ! -x "$PROG" ]; then
		shater_clear_restart
		shater_disarm_boot
		rm -f "$ACTIVE_FLAG"
		nft delete table inet shater 2>/dev/null
		_slog -p daemon.err \
			"shaterd binary missing/not executable at $PROG — refusing to start; the fail-closed plane and its boot armor have been REMOVED (LAN back to plain routing, unprotected). Reinstall shaterd."
		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

	# The predecessor is gone and has already consumed the flag (it reads it in its
	# SIGTERM handler). Withdraw it now, so a LATER `stop` is unambiguous even if
	# this start fails further down.
	shater_clear_restart

	# Bring the UCI schema forward before the daemon reads it (idempotent;
	# refuses a newer schema) so an upgraded package never applies a stale config.
	#
	# THE FAILURE IS LOGGED, NOT SWALLOWED. This is the only place the schema
	# migration runs at boot (`shaterd run`, the SIGHUP reconcile and the panel's
	# config write all read UCI directly), so if it fails here it does not get
	# retried until the next start. And it CAN fail for a mundane reason — a full
	# /overlay makes `uci commit` fail — after which the config still carries the
	# schema-v1 `dst_domain`/`dst_ip` options. The daemon holds every rule that
	# still has them DISABLED and reports it, so nothing is silently misrouted, but
	# rules the operator wrote are then not in force and the reason has to be
	# visible somewhere. Hence: log the binary's own stderr, and start anyway —
	# refusing to start would take the admin panel down with it, and the panel is
	# the only way to fix the box.
	local migrate_out
	migrate_out=$("$PROG" migrate 2>&1) || _slog -p daemon.err \
		"UCI schema migration FAILED: ${migrate_out:-no output from $PROG migrate}. Starting anyway; routing rules that still carry the removed dst_domain/dst_ip options stay DISABLED until this succeeds. Free space on /overlay and re-run '$PROG migrate', or restart the service."

	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() {
	# Say WHY we are stopping before procd sends the signal, because the daemon
	# cannot tell from the signal alone and the answer changes what it leaves in
	# the kernel:
	#
	#   restart / reload  -> a successor is coming. Raise RESTART_FLAG so the
	#                        outgoing daemon replaces its data plane with the
	#                        fail-closed HOLDING plane instead of removing it. The
	#                        gap until the successor applies is not a moment: this
	#                        script waits out the old process, runs `shaterd
	#                        migrate`, then starts a daemon that must build an
	#                        engine — all of it, until now, with `lan -> wan
	#                        ACCEPT` and nothing else.
	#   anything else     -> a deliberate `stop`. Everything comes down, and the
	#                        boot armor goes with it so the next boot does not
	#                        quietly reinstate what the operator just switched off.
	#                        An admin `stop` has to STICK; that is the same rule
	#                        ACTIVE_FLAG has always enforced for hotplug/cron.
	case "$SHATER_RC_ACTION" in
		restart|reload)
			shater_mark_restart
			;;
		*)
			shater_clear_restart
			shater_disarm_boot
			;;
	esac

	# 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`.
	#
	# Marked EXPLICITLY as well as via SHATER_RC_ACTION: this is the path a routine
	# Save & Apply takes, so it is the one that must not depend on reading an
	# rc.common variable correctly. Belt and braces, one line.
	shater_mark_restart
	stop
	start
}

service_triggers() {
	procd_add_reload_trigger "shater"
}
