#!/bin/sh
# What the firewall itself must carry for the kill switch (ADR 0108 §2.6.2), as UCI: the zone `forestvpn` (fvpn0, masquerade, MSS
# clamp, masq6 on fw4), the forwarding lan -> forestvpn, and the include. The refusals and the DNS DNAT are the adapter's own nft table
# (adapter.sh, fv_nft_table), which no firewall reload, restart or stop touches (review 1, B1/B2).
#   render apply         write our sections, `fw4 check` / `fw3 print`, reload the firewall, sync; with protect=0 it removes them,
#                        except with routing=other ("Another app routes"): then the zone and the forwarding stay (masq, MSS clamp, masq6
#                        on fw4; pbr needs them) and the include, the Strict hold, the nft table and the routing half do not exist
#   render remove        uninstall: our sections out, offloading restored, the armed flag cleared, both halves released
#   render print [fw3|fw4]   the uci batch and the nft table (blocked) only (golden tests)
# Only settings changes, install and uninstall come here: arming and releasing never reload the firewall (state, arm).
# Every section is named fvpn_*; nothing else in /etc/config/firewall is edited except flow offloading in the defaults (PRO-8),
# whose previous values are kept in forestvpn.main.offload_saved and put back when protection is switched off.
# Drop-ins, each with its reason (ADR §2.6.2): the `config include` (installs the routing half and the table at boot, before netifd,
# and puts them back if anything removed them; fw4 emits no firewall hotplug events, measured on 24.10.8), and `table inet forestvpn`
# (a UCI rule lives in the firewall's own table, which `firewall restart` deletes and `firewall stop` flushes: lookups to the router
# went out in clear in that window; and a DNS DNAT rendered into fw4's dstnat chain loses to a port-53 redirect rendered before it).
. "${FV_LIB:-/lib/forestvpn}/adapter.sh"

HOLD=${FV_HOLD:-/etc/forestvpn-reinstall}   # the kept directory: Strict through a firmware upgrade (review-router M4)
SD=/tmp/.forestvpn-uci                  # our own uci change directory: a pending LuCI change to the firewall is never committed by us

batch() { # <on|other>: the sections, as a uci batch (nothing about deleting the old ones); other = the zone and the forwarding only
	local L="$FV_LAN"
	cat <<B
set firewall.fvpn_zone=zone
set firewall.fvpn_zone.name='forestvpn'
add_list firewall.fvpn_zone.device='$FV_IF'
set firewall.fvpn_zone.input='REJECT'
set firewall.fvpn_zone.output='ACCEPT'
set firewall.fvpn_zone.forward='REJECT'
set firewall.fvpn_zone.masq='1'
set firewall.fvpn_zone.mtu_fix='1'
B
	[ "$FV_FW" = fw4 ] && echo "set firewall.fvpn_zone.masq6='1'"
	cat <<B
set firewall.fvpn_fwd=forwarding
set firewall.fvpn_fwd.src='$L'
set firewall.fvpn_fwd.dest='forestvpn'
B
	[ "$1" = other ] && return 0
	cat <<B
set firewall.fvpn_include=include
set firewall.fvpn_include.type='script'
set firewall.fvpn_include.path='/usr/libexec/forestvpn/arm'
B
	[ "$FV_FW" = fw3 ] && echo "set firewall.fvpn_include.reload='1'"   # fw4 runs script includes on every reload anyway
	# Strict through a firmware upgrade (PRD FWU-2, E7): a second include, in the kept firewall config, whose file and the table's snapshot
	# live in the kept directory. It does nothing while the package is here (`arm` is); after an upgrade it loads the snapshot.
	if [ "$FV_KS" = strict ]; then
		cat <<B
set firewall.fvpn_hold=include
set firewall.fvpn_hold.type='script'
set firewall.fvpn_hold.path='$HOLD/hold'
B
		[ "$FV_FW" = fw3 ] && echo "set firewall.fvpn_hold.reload='1'"
	fi
	return 0
}

# The snapshot the hold include loads: the blocked table, with the drop-and-add header the adapter loads it with, as it would be rendered
# now. Written only under Strict, only when it changed (flash), and taken away with the include. `render apply` is the only writer:
# every settings change comes through it, so the devices it names are the ones the settings name.
hold() { # <write|clear>
	local f=$HOLD/hold.nft
	if [ "$1" = write ] && [ "$FV_KS" = strict ] && [ -x "$HOLD/hold" ]; then
		{ echo "add table inet $FV_TABLE_NAME"; echo "delete table inet $FV_TABLE_NAME"; fv_nft_table blocked; } > "$f.new" || { rm -f "$f.new"; return 1; }
		if cmp -s "$f.new" "$f"; then rm -f "$f.new"; else mv -f "$f.new" "$f"; fi
	else
		rm -f "$f" "$f.new"
	fi
}

offload() { # <off|restore>: the uci lines for the defaults section (PRO-8); "-" in the saved pair = the option was absent
	local sw hw saved o v
	sw=$(uci -q get firewall.@defaults[0].flow_offloading || echo -)
	hw=$(uci -q get firewall.@defaults[0].flow_offloading_hw || echo -)
	saved=$(uci -q get forestvpn.main.offload_saved)
	if [ "$1" = off ]; then
		[ -n "$saved" ] || echo "set forestvpn.main.offload_saved='$sw,$hw'"
		echo "set firewall.@defaults[0].flow_offloading='0'"
		echo "set firewall.@defaults[0].flow_offloading_hw='0'"
	elif [ -n "$saved" ]; then
		for o in flow_offloading flow_offloading_hw; do
			if [ "$o" = flow_offloading ]; then v=${saved%,*}; else v=${saved#*,}; fi
			case $v in 0 | 1) echo "set firewall.@defaults[0].$o='$v'" ;; *) echo "delete firewall.@defaults[0].$o" ;; esac
		done
		echo "delete forestvpn.main.offload_saved"
	fi
}

check() {
	if [ "$FV_FW" = fw4 ]; then fw4 -q check; else fw3 -q print > /dev/null; fi
}

apply() { # <on|other|off>
	local on="$1" bak
	if [ "$on" = on ] && ! command -v nft > /dev/null; then
		echo "forestvpn: the kill switch needs nftables (nftables, kmod-nft-core, kmod-nft-nat); this router has no nft" >&2
		return 3
	fi
	bak=$FV_RUN/firewall.bak
	cp /etc/config/firewall "$bak"
	rm -rf "$SD"; mkdir -p "$SD"
	{
		uci -q show firewall | sed -n 's/^firewall\.\(fvpn_[A-Za-z0-9_]*\)=.*/delete firewall.\1/p'
		case $on in on) batch on; offload off ;; other) batch other; offload restore ;; *) offload restore ;; esac
	} > "$SD/batch"
	uci -t "$SD" batch < "$SD/batch" > /dev/null && uci -t "$SD" commit firewall && uci -t "$SD" commit forestvpn || {
		cp "$bak" /etc/config/firewall; rm -rf "$SD"; echo "forestvpn: writing the firewall settings failed" >&2; return 1
	}
	rm -rf "$SD"
	if ! check; then
		cp "$bak" /etc/config/firewall
		echo "forestvpn: the firewall refused the rendered settings; the previous ones are back" >&2
		return 1
	fi
	if [ "$on" = on ]; then hold write || fv_log "the Strict snapshot for a firmware upgrade was not written"; else hold clear; fi
	/etc/init.d/firewall reload > /dev/null 2>&1
	# fw4's reload flushes its table and keeps every chain, so the zone's chains (input_forestvpn, srcnat_forestvpn...) outlive the
	# zone until a firewall restart: take them out, so that protection off and `opkg remove` leave fw4 as it was (seam S6). A chain
	# something still jumps to is refused by nft and stays.
	if [ "$on" = off ] && [ "$FV_FW" = fw4 ]; then
		nft list table inet fw4 2> /dev/null | sed -n 's/^[[:space:]]*chain \([a-z_]*_forestvpn\) {$/\1/p' |
			while read -r c; do nft delete chain inet fw4 "$c" 2> /dev/null; done
	fi
	[ "$on" = on ] || rm -f "$FV_ARMED"
	# m1: dnsmasq passes --dhcp-script only if /etc/hotplug.d/dhcp held a file when it started; after an install it has none, and the
	# per-host rules would not follow leases until something restarted it
	[ "$on" = on ] && [ "$(fv_lease_hook)" = missing ] && /etc/init.d/dnsmasq restart > /dev/null 2>&1
	fv_locked fv_sync 1
}

cmd=${1:-}
case $cmd in
	print)
		# shellcheck disable=SC2034  # read by fv_load
		FV_FW_FORCE=${2:-}
		fv_load
		fv_members
		if [ "$FV_PROTECT" != 1 ] && [ "$(fv_get routing '')" = other ]; then
			batch other
			echo "# nft: none (another app routes)"
		else
			batch on
			echo "# nft (blocked)"
			fv_nft_table blocked
		fi
		;;
	apply)
		fv_load
		fv_members
		if [ "$FV_PROTECT" = 1 ]; then apply on; elif [ "$(fv_get routing '')" = other ]; then apply other; else apply off; fi
		;;
	remove)
		FV_PROTECT_FORCE=0   # read by fv_load here and in the sync that follows: the package is going, whatever the settings say
		export FV_PROTECT_FORCE
		fv_load
		fv_members
		apply off
		;;
	*) echo "usage: render apply|remove|print [fw3|fw4]" >&2; exit 2 ;;
esac
