#!/bin/sh /etc/rc.common
# /etc/init.d/shater-armor — the fail-closed plane, before the daemon exists.
#
# WHAT THIS CLOSES
#
# /etc/init.d/shater is START=99. By then fw4 (START=19) has long since loaded
# `lan -> wan ACCEPT` and netifd (START=20) has brought the LAN bridge up, so the
# router forwards LAN traffic to the WAN in the clear from the moment the link
# comes up until `shaterd run` has been decompressed off flash, has waited out any
# predecessor, has migrated UCI, has read the config and has installed its first
# table. On router-class hardware with a UPX-packed binary that is seconds — and
# they are exactly the seconds in which Wi-Fi finishes associating and every
# client on the network reconnects and starts talking. `kill_switch=closed` was
# configured the whole time and covered none of it.
#
# There was nothing in the package that could cover it either: no /etc/nftables.d
# include, no `nft -f` in uci-defaults. Protection existed only inside a Go
# process that had not started yet.
#
# HOW
#
# The daemon persists a copy of its fail-closed HOLDING plane (the same ruleset it
# installs when the engine is down: one forward chain, LAN-to-LAN and router
# traffic accepted, everything else from the diverted devices dropped) to
# $ARMOR on every apply. This script loads it early. When the daemon comes up it
# replaces the table atomically — the ruleset begins with `delete table` and adds
# its own in one netlink transaction — so there is never a moment with no table.
#
# `iifname` matches by NAME at packet time, not by ifindex at load time, so
# loading this before netifd has created br-lan is fine: the rules simply start
# matching when the device appears. That is why START can sit here rather than
# racing netifd.
#
# START=21: after fw4 (19) and netifd (20), because fw4's own start tears its
# table down and rebuilds it and we do not want to be in the middle of that, and
# because there is nothing to protect before the LAN device is being created. The
# residual exposure is the fraction of a second between netifd's `ifup` and this
# script, against seconds-to-a-minute before.
#
# THE ESCAPE HATCHES (a kill switch that cannot be switched off is a brick)
#
# These are STATE checks, evaluated here, at the moment of arming — not a record
# of something that happened on the way down. That distinction is the whole
# lesson of v0.2.17: the arm token was deleted by an EVENT on the shutdown path
# ("this looks like a stop"), and since `reboot` also runs the K-links, the
# mechanism reliably erased itself on the one transition it was built for. An
# event on the way down cannot be trusted to describe the world on the way up; a
# question asked on the way up can be.
#
#   * $ARMOR only exists while the daemon's last applied config was BOTH enabled
#     and fail-closed. `globals.enabled=0` and `kill_switch=open` each remove it
#     at the next apply, and an operator typing `/etc/init.d/shater stop` removes
#     it there and then. Powering the box off does NOT.
#   * We refuse to arm when the main service is disabled in rc.d, or when the
#     daemon binary is gone — in either case nothing would ever come along to
#     replace the armor with a real data plane. These two are what makes a
#     genuinely uninstalled/disabled product safe REGARDLESS of what the file
#     says, which is why they are checked here rather than trusted to have been
#     acted on earlier.
#   * We refuse to arm when UCI can be read AND says the stack is disabled. A
#     config that cannot be read is NOT a refusal: that case is precisely why the
#     armor is a file rather than a query.
#   * The chain hooks `forward` only, so SSH, LuCI and the admin panel (all input
#     hook, to the router's own addresses) stay reachable. The operator can always
#     get in and undo this.
#
# Note what a bare `/etc/init.d/shater stop` does NOT mean: it does not survive a
# reboot, because S99shater is still linked and procd starts the daemon again. So
# "stopped" is not a durable off-state and this script must not be designed as if
# it were — the durable ones are `disable` (no S??shater) and `globals.enabled=0`,
# and those are the two refusals above.
#
# busybox ash only — no bashisms.

START=21          # after firewall (19) and network (20), long before shater (99)
STOP=89

ARMOR=/etc/shater/boot.nft
PROG=/usr/bin/shaterd

# Syslog line that honors globals.log_syslog, like the other two inits. An
# unreadable UCI leaves the option empty => ON, which is what we want here: the
# one boot where the config cannot be read is the boot worth logging.
_slog() {
	[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater-armor "$@"
}

# Is the MAIN service enabled at boot? Answered by looking for its rc.d symlink
# rather than by running `/etc/init.d/shater enabled`: that is a USE_PROCD script,
# so every action of it sources procd.sh, which takes a blocking flock — and this
# runs at START=21, in the middle of boot, for a question a glob answers exactly
# as well. The START number is not hardcoded; any S<NN>shater counts.
shater_service_enabled() {
	local f
	for f in /etc/rc.d/S[0-9][0-9]shater; do
		[ -e "$f" ] && return 0
	done
	return 1
}

start() {
	# No saved plane => the stack has never applied an enabled, fail-closed config
	# (or it was explicitly switched off). Nothing to do, and nothing to say.
	[ -f "$ARMOR" ] || return 0
	[ -s "$ARMOR" ] || {
		_slog -p daemon.err "$ARMOR is empty — NOT arming; the LAN is unprotected until shaterd starts"
		return 0
	}

	# Never arm something nothing can disarm.
	[ -x "$PROG" ] || {
		_slog -p daemon.err \
			"$PROG is missing — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
		return 0
	}
	shater_service_enabled || {
		_slog -p daemon.warn \
			"the shater service is disabled in rc.d — NOT arming (nothing would replace the block with a working data plane); the LAN stays on plain routing"
		return 0
	}

	# A READABLE config that says "off" wins over the saved plane (it means the
	# daemon was stopped before it could disarm). An UNREADABLE config does not:
	# that is the case this whole mechanism exists for.
	en=$(uci -q get shater.globals.enabled 2>/dev/null)
	if [ -n "$en" ] && [ "$en" != "1" ]; then
		rm -f "$ARMOR"
		_slog -p daemon.info "globals.enabled=$en — boot armor removed, not arming"
		return 0
	fi

	command -v nft >/dev/null 2>&1 || {
		_slog -p daemon.err "nft is not installed — cannot arm; the LAN is unprotected until shaterd starts"
		return 0
	}

	# Validate before loading: a truncated/incompatible snapshot must not leave a
	# half-built table behind on the one boot it is needed.
	if ! nft -c -f "$ARMOR" >/dev/null 2>&1; then
		_slog -p daemon.err \
			"$ARMOR did not validate (nft -c) — NOT arming; the LAN is unprotected until shaterd starts"
		return 0
	fi
	if nft -f "$ARMOR" >/dev/null 2>&1; then
		_slog -p daemon.warn \
			"fail-closed plane armed from $ARMOR: LAN->WAN forwarding is BLOCKED until shaterd applies. SSH, LuCI and the admin panel stay reachable."
	else
		_slog -p daemon.err \
			"could not load $ARMOR — the LAN is unprotected until shaterd starts"
	fi
	return 0
}

stop() {
	# Deliberately a NO-OP. By the time anything stops this service the daemon owns
	# `inet shater`, and deleting the table here would dismantle a LIVE data plane
	# on the strength of a service that only ever ran for one second at boot. The
	# disarm paths that matter live where the decision is actually made:
	# /etc/init.d/shater stop (operator switched it off) and the daemon itself
	# (globals.enabled=0 / kill_switch=open).
	return 0
}
