#!/bin/sh /etc/rc.common
# ForestVPN's procd init (ADR 0108 section 2, PLAN WP A-1). The daemon is `fv up`; this script starts it with the flags
# below, makes sure it runs when a WAN interface comes up, hands a settings change to the kill switch's render, and puts
# back what the install added. It is not the firewall or routing half (that is WP A-2's arm, render and state hook) and it
# never touches `wan` or `lan`.
# Seam S4: nothing here restarts the daemon. A settings change runs `render apply` and then `start`, and procd swaps the
# instance only when the daemon's own command line changed (enabled, platform, mtu): like a respawn, nothing is released.
# No secret passes through here: not the command line (`ps`), not the environment, not /etc/config/forestvpn.

USE_PROCD=1
START=99
STOP=10
EXTRA_COMMANDS="uninstall"
EXTRA_HELP="	uninstall	take out the kill switch, the interface and the settings the install added (the identity stays)"

FV=/usr/sbin/fv
LIBEXEC=/usr/libexec/forestvpn
RUN=/var/run/forestvpn

# The adapter's state hook (WP A-2). Seam S12: a deliberate stop (`stop`, LuCI's stop, `enabled 0`, opkg remove) tells it
# `cannot_start`: under Standard the devices are released (the internet works without the VPN), under Strict they stay
# blocked. A crash or a kill tells it nothing: procd starts the daemon again and the devices stay held meanwhile. A restart,
# a reboot's shutdown and an upgrade's stop are not stops (the `restarting` flag).
hook() { [ -x "$LIBEXEC/state" ] && "$LIBEXEC/state" "$@" >/dev/null 2>&1; return 0; }

# fv's own help names the options it has: this file is the same before and after the daemon learns `--pair` and
# `--remote-location` (R0-3b, R0-10a), and passes each only when the installed binary knows it.
fv_has() { "$FV" help 2>&1 | grep -q -e "$1"; }

# cannot_start: the adapter state for "the daemon is not running" (ADR 2.4), with the code the user is shown or `-`.
# The file keeps the whole check line (`ne.host_unsupported reason=ram`); the state hook gets the code alone, the only reason
# form it records (wave-2 review M3: the whole line made it exit 2, so a refused router stayed in the boot-time `blocked`).
cannot_start() {
	echo "$1" > "$RUN/cannot_start"
	hook cannot_start "${1%% *}"
}

# The notice page's server (WP A-3): a second uhttpd on the router's LAN addresses and notice_port, apart from LuCI's, answering
# every request with the page the state hook writes. The kill switch redirects a blocked device's plain HTTP here. Its arguments
# are the addresses it was started for, so a changed LAN address or port is a changed command line and `start` swaps it.
# No LAN address yet (the network is not up): no instance; the lan interface trigger starts one. No uhttpd (LuCI-less image):
# no notice page, and the redirect meets a closed port. Only while protection is on (review-router m7): with protect 0 nothing is
# ever redirected to it, and a second uhttpd costs about 1.7 MB of a 128 MB router; a settings change runs `start` again.
notice_instance() {
	local listen
	[ "$1" = 1 ] || return 0
	[ -x "$LIBEXEC/notice/serve" ] && [ -x /usr/sbin/uhttpd ] || return 0
	listen=$("$LIBEXEC/notice/serve" --listen 2>/dev/null)
	[ -n "$listen" ] || return 0
	procd_open_instance notice
	procd_set_param command "$LIBEXEC/notice/serve" $listen
	procd_set_param respawn 3600 5 0
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_close_instance
}

# The updater's schedule (WP A-5): update/daily is one sleeping shell that checks the feed twice a day for the banner and runs the
# retries the updater scheduled itself. It is defined while the service is enabled, also when the start-time check below refuses:
# a router whose installed version cannot start is the one that needs the update. The update itself (update/run apply) detaches, so
# the package manager's stop of this service does not stop the update that caused it.
update_instance() {
	[ -x "$LIBEXEC/update/daily" ] || return 0
	procd_open_instance updater
	procd_set_param command "$LIBEXEC/update/daily"
	procd_set_param respawn 3600 5 0
	procd_close_instance
}

start_service() {
	local enabled platform mtu out protect routing wz hz hi
	config_load forestvpn
	config_get_bool enabled main enabled 1
	config_get platform main platform routeros
	config_get mtu main mtu ""
	config_get_bool protect main protect 0
	config_get routing main routing ""

	mkdir -p "$RUN"; chmod 0700 "$RUN"
	rm -f "$RUN/cannot_start" "$RUN/restarting"
	# The kill switch's firewall sections follow the settings. An upgrade's migration (S2) or a restored backup reaches here
	# without a settings event (the package manager stopped the service, so no trigger ran): render only when the zone
	# disagrees with the settings, since a render reloads the firewall. The zone is wanted while protection is on or another app
	# routes (routing=other, "Another app routes"); the include only while protection is on (so an SSH switch 1 -> other renders).
	if [ -x "$LIBEXEC/render" ]; then
		wz=$protect; [ "$routing" = other ] && wz=1
		hz=0; uci -q get firewall.fvpn_zone > /dev/null && hz=1
		hi=0; uci -q get firewall.fvpn_include > /dev/null && hi=1
		[ "$hz" = "$wz" ] && [ "$hi" = "$protect" ] || "$LIBEXEC/render" apply 2>&1 | logger -t forestvpn
	fi
	# Seam S5: the kill switch from its armed flag and the last state before anything else. The firewall include does this
	# at every firewall start; with the firewall disabled (or a broken user config) this is the only boot path. It takes the
	# adapter lock itself; its code does not matter here, the daemon's hooks follow.
	[ -x "$LIBEXEC/arm" ] && "$LIBEXEC/arm" sync >/dev/null 2>&1
	# Before the enabled check: under Strict a stopped or refused router still blocks, and the page has to say so.
	notice_instance "$protect"
	[ "$enabled" = 1 ] || { cannot_start -; return 0; }
	update_instance

	# The start-time check (WP A-4): CPU, memory, the tunnel device (installed from the router's own feeds if only the
	# module is missing) and fw3's nftables. apk 3 lets a package install although its pre-install refused, so this is where
	# such a router is told no. The package manager holds its lock while this package's own postinst starts the service,
	# and then it cannot fetch the module: one more look later.
	if [ -x "$LIBEXEC/check.sh" ]; then
		out=$("$LIBEXEC/check.sh" ensure 2>&1) || {
			echo "$out" | logger -t forestvpn
			out=$(echo "$out" | grep '^ne.host_unsupported ' | head -n 1)
			cannot_start "$out"
			case $out in *reason=tun) # the one a package manager's lock can cause
				[ -e "$RUN/retry" ] || { : > "$RUN/retry"; ( sleep 20; /etc/init.d/forestvpn restart ) </dev/null >/dev/null 2>&1 & } ;;
			esac
			return 0
		}
	fi
	rm -f "$RUN/retry"

	case $platform in ''|*[!a-z0-9]*) platform=routeros ;; esac
	# The daemon runs in group fvdirect, gid 9912: passwall2's DIRECT_GID. passwall2 inserts `meta skgid 9912 return` (iptables:
	# `--gid-owner 9912`) at the head of its OUTPUT chains, ahead of its router-proxy and DNS redirect. Without it, passwall2's
	# default "Localhost Proxy" captures our own control-plane, DNS and tunnel sockets and hands them to fvpn0, which is the tunnel
	# that is not up: the daemon stays `connecting` for ever (measured). procd ignores a group name that does not exist, so without
	# the line in /etc/group this is today's behaviour. The uid stays 0.
	# The gid alone covers only the passwall2 builds made after 2026-10-14 (the older ones, which opkg never replaces as they share
	# the version number, ignore it and deadlock a restarted daemon: round2 DESIGN-2). /lib/forestvpn/direct.nft covers the rest: it
	# marks gid 9912's packets the way every build returns and pins its loopback DNS. Every start loads it; the file replaces the
	# table atomically. It leaves the daemon's command line alone, so procd does not restart the daemon.
	group_exists fvdirect || group_add fvdirect 9912
	command -v nft > /dev/null && nft -f /lib/forestvpn/direct.nft 2>&1 | logger -t forestvpn
	procd_open_instance
	procd_set_param command /bin/sh -c 'umask 077; exec "$@"' forestvpn "$FV" up \
		--iface fvpn0 --state /etc/forestvpn \
		--ctl --fail-closed --run-dir "$RUN" \
		--http-port 0 --platform "$platform"
	group_exists fvdirect && procd_set_param group fvdirect
	# the daemon retries and warns on every tick about a hook it cannot start: pass it once WP A-2's file is there
	[ -x "$LIBEXEC/state" ] && procd_append_param command --state-hook "$LIBEXEC/state"
	fv_has --pair && procd_append_param command --pair on-demand
	fv_has --remote-location && procd_append_param command --remote-location off
	case $mtu in ''|*[!0-9]*) ;; *) procd_append_param command --mtu "$mtu" ;; esac
	procd_set_param env RUST_LOG=info,fvpn::net::noise=warn
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_set_param respawn
	procd_close_instance
}

# procd drops the triggers with the service: a service the user stopped is not started by a WAN coming up.
# A WAN coming up runs `start`, which starts a daemon that is not running and leaves a running one alone (S4).
service_triggers() {
	local wan i
	procd_add_reload_trigger forestvpn
	config_load forestvpn
	config_get wan main wan wan
	for i in $wan; do procd_add_interface_trigger "interface.*.up" "$i" /etc/init.d/forestvpn start; done
	# the LAN coming up (boot, a changed address): the notice server listens on its addresses (WP A-3). Other LAN networks of the
	# zone are picked up at the next start; the default network is `lan`.
	procd_add_interface_trigger "interface.*.up" lan /etc/init.d/forestvpn start
}

# A restart keeps the devices as they are: only a stop that is not the first half of one releases them.
restart() {
	mkdir -p "$RUN"; : > "$RUN/restarting"
	trap '' TERM; stop "$@"; trap - TERM
	start "$@"
	rm -f "$RUN/restarting"
}
# A settings change (LuCI's apply, reload_config): the kill switch renders it, then the same start as at boot. render's
# own commit (offload_saved) comes back here once more and changes nothing.
reload_service() {
	[ -x "$LIBEXEC/render" ] && "$LIBEXEC/render" apply 2>&1 | logger -t forestvpn
	start "$@"
}
# A reboot is not a release: the devices stay held until the router is down (the state at boot is "blocked" anyway).
shutdown() {
	mkdir -p "$RUN"; : > "$RUN/restarting"
	stop
}

service_stopped() {
	rm -f "$RUN"/*.ctl "$RUN/status.json"
	[ -e "$RUN/restarting" ] || hook cannot_start -
}

# `uninstall pkg` is the package's prerm (opkg remove, apk del): the same, but the settings file is left as shipped for the
# package manager, which removes it next and, finding it unmodified, says nothing (S9).
uninstall() {
	local added f
	/etc/init.d/forestvpn stop
	nft delete table inet forestvpn_direct 2> /dev/null
	sed -i '/^fvdirect:x:9912:$/d' /etc/group
	rm -f "$RUN/cannot_start" "$RUN/retry"
	# Seam S6: the kill switch goes first (its zone, forwarding and include, offloading put back, the armed flag, the ip
	# rules, table T and its nft table), with the daemon already stopped so that no hook arms it again
	[ -x "$LIBEXEC/render" ] && "$LIBEXEC/render" remove 2>&1 | logger -t forestvpn
	config_load forestvpn
	config_get added main added ""
	case " $added " in *" network "*) uci -q delete network.forestvpn ;; esac
	# what the two-schema package added (an upgrade from it removes these; a router that never ran the upgrade's uci-defaults)
	case " $added " in *" zone "*) uci -q delete firewall.forestvpn ;; esac
	case " $added " in *" forwarding "*) uci -q delete firewall.forestvpn_lan ;; esac
	uci -q commit network; uci -q commit firewall
	# opkg keeps the package's own copy beside a conffile the user changed (and apk its .apk-new): both are UCI packages too
	rm -f /etc/config/forestvpn /etc/config/forestvpn-opkg /etc/config/forestvpn.apk-new
	# The identity stays (PRD INS-5): machine.key and nothing else. A session (session.conf holds its private key), its hop,
	# state and reach files are not identity, and a reinstall asks for a new one.
	# SEC-1 TM-003: and the records that keep the update chain honest: the newest release accepted (an older one is refused after a
	# reinstall), the repository keys the release named revoked (a setup file or bundle made before that cannot place one again) and
	# the keys feed-place placed (the only ones a revocation may remove), and update_last_ok (SEC-1 NEW-2: the digest and issue time of the
	# release last accepted; without it a reinstall accepts a different document at the same seq once and the no-answer clock restarts). None is a secret.
	for f in /etc/forestvpn/* /etc/forestvpn/.[!.]*; do
		case $f in /etc/forestvpn/machine.key | /etc/forestvpn/release_seq | /etc/forestvpn/revoked_keys | /etc/forestvpn/placed_keys | /etc/forestvpn/update_last_ok) continue ;; esac
		[ -e "$f" ] && rm -rf "$f"
	done
	# and the run directory (tmpfs, review 2): the linking code and the kill switch's work files (protected IPs and MACs) would
	# otherwise stay until the next reboot
	rm -rf "$RUN"
	[ -d /var/run/ubus ] && [ -x /sbin/reload_config ] && /sbin/reload_config
	[ "$1" = pkg ] && [ -f /usr/share/forestvpn/forestvpn.config ] && cp -p /usr/share/forestvpn/forestvpn.config /etc/config/forestvpn
	return 0
}
