#!/bin/sh
# The one-tap updater (ADR 0108 4.2-4.5, PRD UPD-1 and UPD-2, AD-11, AD-15, PLAN WP A-5).
#
#   run check            fetch the signed release.json, verify it, write the status (the banner's facts). Tells the feed nothing:
#                        two fixed file names, no version, no identity (UPD-1).
#   run apply [--wait]   the update button. Detaches and returns at once (LuCI's exec must not wait); --wait stays in the foreground.
#   run status           the status document (JSON); run dismiss <version>: the banner for that version is not shown again
#   run vercmp A B       -1, 0 or 1
#   run rpc <method>     what luci.forestvpn's update_check, update_apply and update_dismiss run (the LuCI write role has no `file exec`):
#                        one JSON object, {"ok":true|false}, always exit 0; a dismiss reads {"version":...} from standard input
#
# What apply does, in this order (nothing on the router changes before step 4):
#   1. release.json and its signature from the feed the package manager's own source line names, `fv verify-release` (pinned key,
#      seq never lower than the last one). Not newer than what is installed: stop.
#   2. the rollback target, release.json's `previous`, is downloaded NOW and its SHA-256 compared with the signed file (a rollback
#      must not need the network the update may have broken, and on opkg a downloaded file is otherwise never verified). A
#      `previous` below the minimum safe version is no target; with no target an update is refused (unless the router is itself
#      below the minimum safe version, when it must update).
#   3. the new package through the package manager (opkg update + download, apk update + fetch: both check the repository's
#      signature on the index), then its SHA-256 and its file name against the signed file (AD-15, the second key: a repository key
#      alone cannot push a file through this updater). A file that is not in release.json is refused; so is a hash that differs.
#   4. free storage at least twice the package; then install (opkg install / apk add of the verified file: never --allow-untrusted).
#   5. watch the daemon for 60 s and judge it where it ends. Failed = the daemon is not running, or it is running and is not connected
#      (a connect fails with a code that is not a plan or account code). A person's pause or a refusal for the plan is NOT a failure.
#   6. failed: reinstall the previous version (its hash was checked in step 2) and watch again. If that connects, the new version
#      is the fault and is not offered again until a newer one exists. If it does not connect either, the network was the cause:
#      the update is tried again with growing waits (RETRY_BASE doubling, one day at most) and is never given up for good.
#      A daemon crash is the new version's fault whatever the network does.
#   7. only when NEITHER version can start (the service is not running at the end) is the state hook told `cannot_start`: that is
#      the one release (AD-11). A blocked tunnel never is. The init's `restarting` flag is held across the whole window, so the
#      package manager's stop of the old daemon is half of a restart and releases nothing.
#
#   8. a forward update that held leaves the package UNPINNED. apk's `add <file>` writes `forestvpn><hash>` into /etc/apk/world, and a
#      pinned package is skipped by LuCI's Upgrade button and by `apk upgrade`; so after the watch an offline `apk --no-network add
#      <name>` puts the bare name back (review-router M1). After a rollback the pin STAYS: it is what keeps LuCI's Upgrade from
#      putting back the version this router just marked bad.
# And at every check: a repository key that the signed release.json names as revoked is taken off the router (review-router M2,
# UPD-1): its key file is removed and its id remembered in $STATE/revoked_keys, which feed-place reads so that no later install
# step puts it back. The key ids are the ones feed-place names its files by: the usign fingerprint (16 hex digits, opkg) and the
# first 8 hex digits of the key file's SHA-256 (apk). Order for a key rotation: publish a package that carries the new key first,
# name the old key revoked in a later release.json (a router that has lost its only key cannot read the feed any more).
# SEC-1 TM-002: on opkg a key file is removed only when feed-place recorded its fingerprint in $STATE/placed_keys, so a signed
# release.json can never delete one of OpenWrt's own keys (it still records every id it names). apk's names are ours by construction.
# SEC-1 TM-003: one seq, one document (the SHA-256 of the accepted release.json is kept beside the seq; a different document under the
# same seq is refused before anything in it is acted on), and once this router has acted on a revocation the staleness notice comes
# after 30 days instead of 60: a feed-path attacker who replays the last release.json to withhold the next one is what it is for.
#
# This file is the whole updater; WP A-5b (automatic mode, R1.1) calls `apply` from the same loop (update/daily). No secret passes
# through here; the log lines carry versions and outcome words only.
#
# SEC-1 TM-001: nothing the caller put in the environment is trusted. The installed file (and its RAM copy) ignores every seam: at entry
# it re-executes itself under `env -i` with a fixed PATH, so FVPN_UPDATE_*, PATH, LD_PRELOAD and the rest never reach a path, a command
# or a child, and the paths below are the constants. Nothing before this line may use PATH: only absolute commands. (A loader acts on
# LD_PRELOAD before the first line of any script, so no script can undo it: that is why the LuCI write role has no `file exec` at all and
# the updater is reached through luci.forestvpn's update_* methods, which run this file under `env -i`.)
PROD=
case $0 in /usr/libexec/forestvpn/update/run | /var/run/forestvpn/update/run.ram)
	PROD=1
	if [ "$1" = --clean ]; then shift
	else exec /usr/bin/env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin /bin/sh "$0" --clean "$@"; fi ;;
esac
# Test seams (environment), honoured only by a copy that is NOT at the installed path: FVPN_UPDATE_{RUN,STATE,LIBEXEC,FV,ETC,LISTS,WATCH,
# RETRY}; every other command is found through PATH.
# SEC-1 NEW-1: at the installed path the constants are assigned, not defaulted, and PATH is forced, whatever argv or environment arrived:
# a caller who passes --clean itself skips the re-exec above, and must still get no seam and no PATH of its own.
if [ -n "$PROD" ]; then
	PATH=/usr/sbin:/usr/bin:/sbin:/bin; export PATH
	RUN=/var/run/forestvpn STATE=/etc/forestvpn LIBEXEC=/usr/libexec/forestvpn FV=/usr/sbin/fv ETC=/etc LISTS=/var/opkg-lists WATCH=60 RETRY_BASE=600
else
	RUN=${FVPN_UPDATE_RUN:-/var/run/forestvpn}
	STATE=${FVPN_UPDATE_STATE:-/etc/forestvpn}
	LIBEXEC=${FVPN_UPDATE_LIBEXEC:-/usr/libexec/forestvpn}
	FV=${FVPN_UPDATE_FV:-/usr/sbin/fv}
	ETC=${FVPN_UPDATE_ETC:-/etc}
	LISTS=${FVPN_UPDATE_LISTS:-/var/opkg-lists}
	WATCH=${FVPN_UPDATE_WATCH:-60}
	RETRY_BASE=${FVPN_UPDATE_RETRY:-600}
fi
UP=$RUN/update
STALE_SECS=5184000   # 60 days (ADR 4.2)
STALE_REVOKED_DAYS=30   # SEC-1 TM-003: once a revocation is on record
# Connect refusals that are the person's or the plan's, not the software's. Anything else that keeps the tunnel from connecting for
# the whole watch is a failed update. (cp.client_too_old and cp.epoch_unknown are the software's: a build too old for the control plane.)
PLAN_CODES=" cp.quota_exceeded cp.data_cap_reached cp.tier_required cp.tier_mismatch cp.suspended cp.account_unknown cp.already_linked cp.seat_limit cp.router_limited cp.subscription_owned cp.link_cooldown cp.seat_changes_limited cp.merge_refused cp.rail_disabled session.quota_exceeded session.billing_chargeback "
case $0 in /*) SELF=$0 ;; *) SELF=$PWD/$0 ;; esac

FMT=opkg; command -v apk >/dev/null 2>&1 && FMT=apk
now() { date +%s; }
log() { logger -t forestvpn-update "$*" 2>/dev/null; [ -z "$FVPN_UPDATE_VERBOSE" ] || echo "$*" >&2; return 0; }
put() { echo "$2" > "$1.tmp" && mv -f "$1.tmp" "$1"; }   # a small file, atomically
jf() { jsonfilter -s "$ROW" -e "$1" 2>/dev/null; }
hook() { [ -x "$LIBEXEC/state" ] && "$LIBEXEC/state" "$@" >/dev/null 2>&1; return 0; }

# -1, 0 or 1. Versions are digits and dots, `~hex` for a development build (sorts before the same number without it), `-rN`.
# ponytail: numeric fields, no apk letter suffixes (_rc, _p): the feed's grammar (manifest.py) allows none.
vercmp() {
	awk -v a="$1" -v b="$2" '
	function parse(s, o,   rel, pre, n, i, base) {
		rel = 0; pre = ""
		if (match(s, /-r[0-9]+$/)) { rel = substr(s, RSTART + 2) + 0; s = substr(s, 1, RSTART - 1) }
		if (i = index(s, "~")) { pre = substr(s, i + 1); s = substr(s, 1, i - 1); o["haspre"] = 1 } else o["haspre"] = 0
		o["n"] = split(s, base, "."); for (i = 1; i <= o["n"]; i++) o[i] = base[i] + 0
		o["rel"] = rel; o["pre"] = pre
	}
	BEGIN {
		parse(a, A); parse(b, B); m = (A["n"] > B["n"]) ? A["n"] : B["n"]
		for (i = 1; i <= m; i++) { x = A[i] + 0; y = B[i] + 0; if (x != y) { print (x < y) ? -1 : 1; exit } }
		if (A["haspre"] != B["haspre"]) { print A["haspre"] ? -1 : 1; exit }
		if (A["pre"] != B["pre"]) { print (A["pre"] < B["pre"]) ? -1 : 1; exit }
		if (A["rel"] != B["rel"]) { print (A["rel"] < B["rel"]) ? -1 : 1; exit }
		print 0
	}'
}
newer() { [ "$(vercmp "$1" "$2")" = 1 ]; }   # $1 is newer than $2

# ---- the status document -------------------------------------------------------------------------------------------------------
# progress: idle updating updated rolled_back failed retry; detail: a word (feed_unreachable release_refused index_refused
# file_not_in_release hash_mismatch no_space no_rollback install_failed new_version_failed network_blocked cannot_start locked
# up_to_date no_feed not_installed ok)
# `updating` (with the OLD `installed`) stands until the watch has judged: `updated` is written once, after a good watch. After a
# rollback `undone` is the release that was undone and `undone_reason` the daemon's last reason code while it was watched (empty when
# it never ran or gave none); both are cleared by the next apply. The page says plainly what was undone and why.
S_progress=idle; S_detail=never_checked; S_retry_at=0; S_checked=0; S_undone=; S_undone_reason=
S_installed=; S_latest=; S_min=; S_stale=false; S_age=0; S_cutover=; S_avail=false; S_below=false; S_seq=0
load_status() {
	[ -f "$UP/status.json" ] || return 0
	S_progress=$(jsonfilter -i "$UP/status.json" -e '@.progress' 2>/dev/null); [ -n "$S_progress" ] || S_progress=idle
	S_detail=$(jsonfilter -i "$UP/status.json" -e '@.detail' 2>/dev/null)
	S_stale=$(jsonfilter -i "$UP/status.json" -e '@.stale' 2>/dev/null); [ -n "$S_stale" ] || S_stale=false
	S_installed=$(jsonfilter -i "$UP/status.json" -e '@.installed' 2>/dev/null)
	S_latest=$(jsonfilter -i "$UP/status.json" -e '@.latest' 2>/dev/null)
	S_min=$(jsonfilter -i "$UP/status.json" -e '@.min_safe' 2>/dev/null)
	S_age=$(jsonfilter -i "$UP/status.json" -e '@.age_days' 2>/dev/null); [ -n "$S_age" ] || S_age=0
	S_cutover=$(jsonfilter -i "$UP/status.json" -e '@.cutover_notice' 2>/dev/null)
	S_checked=$(jsonfilter -i "$UP/status.json" -e '@.checked_at' 2>/dev/null); [ -n "$S_checked" ] || S_checked=0
	S_avail=$(jsonfilter -i "$UP/status.json" -e '@.update_available' 2>/dev/null); [ -n "$S_avail" ] || S_avail=false
	S_below=$(jsonfilter -i "$UP/status.json" -e '@.below_min_safe' 2>/dev/null); [ -n "$S_below" ] || S_below=false
	S_retry_at=$(jsonfilter -i "$UP/status.json" -e '@.retry_at' 2>/dev/null); [ -n "$S_retry_at" ] || S_retry_at=0
	S_undone=$(jsonfilter -i "$UP/status.json" -e '@.undone' 2>/dev/null)
	S_undone_reason=$(jsonfilter -i "$UP/status.json" -e '@.undone_reason' 2>/dev/null)
}
write_status() {
	local banner=none dismissed=false d cut
	d=$(cat "$STATE/update_dismissed" 2>/dev/null)
	[ -n "$S_latest" ] && [ "$d" = "$S_latest" ] && dismissed=true
	[ "$S_avail" = true ] && [ $dismissed = false ] && banner=update
	[ "$S_below" = true ] && banner=red   # a red banner is never dismissed
	cut=null; [ -n "$S_cutover" ] && cut="\"$S_cutover\""
	mkdir -p "$UP"
	printf '{"v":1,"checked_at":%s,"installed":"%s","latest":"%s","min_safe":"%s","update_available":%s,"below_min_safe":%s,"banner":"%s","dismissed":%s,"stale":%s,"age_days":%s,"cutover_notice":%s,"progress":"%s","detail":"%s","retry_at":%s,"undone":"%s","undone_reason":"%s"}\n' \
		"$S_checked" "$S_installed" "$S_latest" "$S_min" "$S_avail" "$S_below" "$banner" "$dismissed" "$S_stale" "$S_age" "$cut" "$S_progress" "$S_detail" "$S_retry_at" "$S_undone" "$S_undone_reason" > "$UP/status.json.tmp" \
		&& mv -f "$UP/status.json.tmp" "$UP/status.json"
}
# the progress word and its detail, written at once (the UI polls this file)
progress() { S_progress=$1; S_detail=$2; write_status; log "$1 $2"; }

# ---- the feed ------------------------------------------------------------------------------------------------------------------
# Our source line is the one the package installs (keep list: customfeeds): `src/gz forestvpn <base>/ipk/<arch>` on opkg, one line
# `<base>/apk/<arch>/packages.adb` in a repositories file on apk. The release file lives at <base>/release.json.
feed_line() {
	if [ $FMT = apk ]; then
		cat "$ETC"/apk/repositories.d/*.list 2>/dev/null | grep -E '^https?://[A-Za-z0-9._:/~-]+/apk/[A-Za-z0-9_.-]+/packages\.adb$' | head -n 1
	else
		cat "$ETC"/opkg/*.conf "$ETC/opkg.conf" 2>/dev/null | sed -n 's/^src\/gz[ 	][ 	]*forestvpn[ 	][ 	]*//p' | grep -E '^https?://[A-Za-z0-9._:/~-]+/ipk/[A-Za-z0-9_.-]+$' | head -n 1
	fi
}
arch() { sed -n "s/^DISTRIB_ARCH=//p" "$ETC/openwrt_release" 2>/dev/null | tr -d "'\"" | head -n 1; }
pm_version_once() {
	if [ $FMT = apk ]; then apk list -I forestvpn 2>/dev/null | sed -n 's/^forestvpn-\([0-9][^ ]*\) .*/\1/p' | head -n 1
	else opkg list-installed forestvpn 2>/dev/null | sed -n 's/^forestvpn - //p' | head -n 1; fi
}
# the package manager answers nothing while another one (a person's opkg, LuCI's software page) holds its lock: ask again before
# calling it "not installed"
pm_version() { local v n=0; while [ $n -lt 4 ]; do v=$(pm_version_once); [ -n "$v" ] && break; n=$(( n + 1 )); sleep 1; done; echo "$v"; }
# fetch <url> <file> <cap bytes>: a body, capped (a hostile mirror must not fill a 128 MB router's RAM), or nothing
fetch() {
	uclient-fetch -q -T 30 -O - "$1" 2>/dev/null | head -c "$3" > "$2.part"
	[ -s "$2.part" ] && mv -f "$2.part" "$2" || { rm -f "$2.part"; return 1; }
}
sha() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; }
# "name hash" lines of one build's files, out of the row. fv has already limited the names to [0-9A-Za-z._~-] and the hashes to 64
# lowercase hex digits, which is what makes this parse safe.
files_of() { # current | previous
	jf "@.$1.files" | tr ',{}' '\n\n\n' | sed -n 's/^ *"\([0-9A-Za-z._~-][0-9A-Za-z._~-]*\)" *: *"\([0-9a-f]\{64\}\)" *$/\1 \2/p'
}

# ---- check (UPD-1) ----------------------------------------------------------------------------------------------------------------
# sets ROW and the S_* facts. 0 = a verified document; 1 = the feed did not answer (the status keeps what it knew); 2 = refused.
do_check() {
	local line base a seq issued last lim last_ok rsha
	mkdir -p "$UP"; chmod 700 "$UP" 2>/dev/null
	S_checked=$(now)
	line=$(feed_line); a=$(arch)
	[ -n "$line" ] && [ -n "$a" ] || { S_detail=no_feed; return 2; }
	if [ $FMT = apk ]; then base=${line%/apk/*}; else base=${line%/ipk/*}; fi
	FEEDBASE=$base; FEEDLINE=$line; ARCH=$a
	if ! { fetch "$base/release.json" "$UP/release.json" 1048576 && fetch "$base/release.json.sig" "$UP/release.json.sig" 4096; }; then
		S_detail=feed_unreachable
		# the staleness notice also covers a feed nobody has reached for 60 days
		last=$(cut -d' ' -f2 "$STATE/update_last_ok" 2>/dev/null)
		lim=$STALE_SECS; [ -s "$STATE/revoked_keys" ] && lim=$(( STALE_REVOKED_DAYS * 86400 ))
		[ -n "$last" ] && [ $(( $(now) - last )) -gt $lim ] && S_stale=true
		return 1
	fi
	ROW=$("$FV" verify-release --release "$UP/release.json" --sig "$UP/release.json.sig" --arch "$a" --format "$FMT" --state "$STATE" 2>"$UP/verify.err") || {
		log "verify-release refused: $(head -c 200 "$UP/verify.err" | tr '\n' ' ')"
		S_detail=release_refused; return 2
	}
	# TM-003: one seq, one document. Before anything in the file is acted on.
	seq=$(jf '@.seq'); issued=$(jf '@.issued_at'); S_seq=$seq
	last_ok=$(cat "$STATE/update_last_ok" 2>/dev/null); rsha=$(sha "$UP/release.json")
	if [ "$seq" = "${last_ok%% *}" ] && [ -n "$(echo "$last_ok" | cut -d' ' -f3)" ] && [ "$(echo "$last_ok" | cut -d' ' -f3)" != "$rsha" ]; then
		log "release.json seq $seq is not the document accepted at that seq: refused"
		S_detail=release_refused; return 2
	fi
	revoke_keys
	S_installed=$(pm_version)
	[ -n "$S_installed" ] || { S_detail=not_installed; return 2; }
	S_latest=$(jf '@.latest'); S_min=$(jf '@.min_safe'); S_stale=$(jf '@.stale'); S_age=$(jf '@.age_days')
	[ -s "$STATE/revoked_keys" ] && [ "${S_age:-0}" -ge "$STALE_REVOKED_DAYS" ] 2>/dev/null && S_stale=true
	# a notice date is free text in the signed file: keep it to a short, plain string before it goes into a JSON document
	S_cutover=$(jf '@.cutover_notice' | tr -cd '0-9A-Za-z .:/-' | head -c 64)
	CUR=$(jf '@.current.version'); PREV=$(jf '@.previous.version')
	[ "$last_ok" = "$seq $issued $rsha" ] || { mkdir -p "$STATE"; put "$STATE/update_last_ok" "$seq $issued $rsha"; }   # a flash write per accepted release, not per check
	S_avail=false; newer "$CUR" "$S_installed" && S_avail=true
	S_below=false; [ "$(vercmp "$S_installed" "$S_min")" = -1 ] && S_below=true
	# a version that failed on this router is not offered again; a newer one clears it
	[ -f "$STATE/update_bad" ] && [ "$(cat "$STATE/update_bad")" != "$CUR" ] && rm -f "$STATE/update_bad"
	[ "$(cat "$STATE/update_bad" 2>/dev/null)" = "$CUR" ] && S_avail=false
	return 0
}
# review-router M2: act on the signed file's revoked repository keys. Only ids of the shape feed-place names its files by are acted on,
# and only in the key directory of this format; the release key (the second key) and OpenWrt's own keys are never touched (SEC-1 TM-002:
# on opkg that is enforced, not assumed: a key file goes only if feed-place recorded its fingerprint in placed_keys).
revoke_keys() {
	local id f ok
	for id in $(jf '@.revoked_keys[*]'); do
		f=; ok=
		if [ $FMT = apk ]; then
			case $id in *[!0-9a-f]*) ;; ????????) ok=1; f=$ETC/apk/keys/forestvpn-apk-$id.pem ;; esac
		else
			case $id in *[!0-9a-f]*) ;; ????????????????) ok=1; grep -qxsF "$id" "$STATE/placed_keys" && f=$ETC/opkg/keys/$id ;; esac
		fi
		[ -n "$ok" ] || continue
		if [ -n "$f" ] && [ -e "$f" ]; then rm -f "$f" && log "revoked repository key $id removed"; fi
		grep -qxsF "$id" "$STATE/revoked_keys" || { mkdir -p "$STATE"; echo "$id" >> "$STATE/revoked_keys"; }
	done
}
cmd_check() {
	local rc
	# one at a time with an update: a check that read the status before the update wrote its progress would write it back over it
	lock || { log "an update is running: no check"; return 0; }
	trap unlock EXIT
	load_status
	do_check; rc=$?
	# an outcome of an update (retry, rolled back, updated) stays until the next apply; a failed check or apply gives way to this check
	case $S_progress in idle | failed) S_progress=idle; [ $rc = 0 ] && S_detail=ok ;; esac
	write_status
	return $rc
}

# ---- apply -----------------------------------------------------------------------------------------------------------------------
lock() {
	mkdir -p "$UP"
	if ! mkdir "$UP/lock" 2>/dev/null; then
		kill -0 "$(cat "$UP/lock/pid" 2>/dev/null)" 2>/dev/null && return 1
		rm -rf "$UP/lock"; mkdir "$UP/lock" 2>/dev/null || return 1
	fi
	echo $$ > "$UP/lock/pid"
}
unlock() { rm -rf "$UP/lock"; }

clear_retry() { rm -f "$UP/retry"; S_retry_at=0; }
# growing waits: RETRY_BASE, twice that, ... one day at most. Kept in RAM: a reboot starts the count again, and the banner offers the update again.
schedule_retry() {
	local n w t
	n=$(cut -d' ' -f1 "$UP/retry" 2>/dev/null); n=$(( ${n:-0} + 1 ))
	w=$RETRY_BASE; t=1; while [ $t -lt $n ] && [ $w -lt 86400 ]; do w=$(( w * 2 )); t=$(( t + 1 )); done
	[ $w -le 86400 ] || w=86400
	S_retry_at=$(( $(now) + w )); put "$UP/retry" "$n $S_retry_at"
	log "retry $n in $w s"
}
retry_later() { schedule_retry; progress retry "$1"; }

# The daemon is the one procd instance of the service that has an environment (the init gives it RUST_LOG; the notice server and this
# schedule have none). Its name is not stable: procd counts unnamed instances, so it was instance1 and is instance3 after two swaps.
daemon_running() { ubus call service list '{"name":"forestvpn"}' 2>/dev/null | jsonfilter -e '@.forestvpn.instances[@.env].running' 2>/dev/null | grep -q true; }
# The daemon is watched for the whole of WATCH seconds and judged by where it ends: a restarted daemon reapplies the saved session and
# says `connected` within a second, long before a road that has been cut shows it (unanswered data is noticed after about 15 s).
# 0 = good at the end; 1 = the service's daemon is not running at the end (it never ran, or it ran and died); 2 = it runs and has not
# said it is connected (or stopped for a reason of the person's or the plan's) since the install.
watch() {
	local t0 st rs ok running
	t0=$(now); W_REASON=
	while :; do
		ok=0; running=0
		daemon_running && running=1
		# (a status document older than the install is the old daemon's, left by a crash: not evidence)
		if [ $running = 1 ] && [ -s "$RUN/status.json" ] && [ ! "$RUN/status.json" -ot "$UP/install.mark" ]; then
			st=$(jsonfilter -i "$RUN/status.json" -e '@.state' 2>/dev/null)
			rs=$(jsonfilter -i "$RUN/status.json" -e '@.reason' 2>/dev/null)
			W_REASON=$rs
			# a build too old for the control plane is the software's fault, in whatever state it says so
			case " cp.client_too_old cp.epoch_unknown " in
				*" $rs "*) ;;
				*)
					# a refusal is a good end only for a code of the person's or the plan's (PLAN_CODES, whatever the state): a terminal
					# code fv learns later is not known to be one, so it is a failed update until it is added there (review-router m1)
					case $st in connected | stopped_local) ok=1 ;; esac
					case "$PLAN_CODES" in *" $rs "*) ok=1 ;; esac ;;
			esac
		fi
		[ $(( $(now) - t0 )) -ge "$WATCH" ] && break
		sleep 2
	done
	[ $ok = 1 ] && return 0
	[ $running = 1 ] && return 2
	return 1
}

# the verified files of a build into <dir>: opkg and apk both fetch by package name; the names come from the signed file
pkgs_of() { # <build> -> package names
	local f h
	files_of "$1" | while read -r f h; do
		if [ $FMT = apk ]; then echo "${f%-"$(jf "@.$1.version")".apk}"; else echo "${f%%_*}"; fi
	done | sort -u
}
refresh_index() { # the repository's signature on the index is the package manager's to check; a failure is a refusal
	local out
	if [ $FMT = apk ]; then
		echo "$FEEDLINE" > "$UP/repo.list"
		out=$(apk --repositories-file "$UP/repo.list" update 2>&1) || { log "apk update: $(echo "$out" | head -n 3 | tr '\n' ' ')"; return 1; }
	else
		rm -f "$LISTS/forestvpn" "$LISTS/forestvpn.sig"
		out=$(opkg update 2>&1)   # its status is the base feeds' too: ours is judged by the list it leaves
		case $out in *[Ss]ignature\ check\ failed*) log "opkg update: signature check failed"; return 1 ;; esac
		[ -s "$LISTS/forestvpn" ] || { log "opkg update: no list for our feed"; return 1; }
	fi
	return 0
}
# fetch_current: 0 = $UP/cur holds exactly the signed files. 1 = the feed or the manager failed; 2 = refused (not in release.json,
# a hash that differs, a signature failure on the index)
fetch_current() {
	local p f h want got
	rm -rf "$UP/cur"; mkdir -p "$UP/cur"
	refresh_index || { S_detail=index_refused; return 2; }
	for p in $(pkgs_of current); do
		case $p in forestvpn | luci-app-forestvpn) ;; *) S_detail=file_not_in_release; return 2 ;; esac
		if [ $FMT = apk ]; then apk --repositories-file "$UP/repo.list" fetch -o "$UP/cur" "$p" >/dev/null 2>&1
		else ( cd "$UP/cur" && opkg download "$p" >/dev/null 2>&1 ); fi
	done
	got=$(cd "$UP/cur" && ls 2>/dev/null)
	[ -n "$got" ] || { S_detail=feed_unreachable; return 1; }
	want=$(files_of current | cut -d' ' -f1)
	# the set of files must be the signed set: nothing else, nothing missing
	[ "$(echo "$got" | sort)" = "$(echo "$want" | sort)" ] || { S_detail=file_not_in_release; log "downloaded [$got], release.json lists [$want]"; return 2; }
	files_of current | while read -r f h; do [ "$(sha "$UP/cur/$f")" = "$h" ] || exit 1; done || { S_detail=hash_mismatch; return 2; }
	return 0
}
# the rollback target by direct download: the two-key rule covers it as well
fetch_previous() {
	local f h dir
	rm -rf "$UP/prev"; mkdir -p "$UP/prev"
	[ $FMT = apk ] && dir=apk || dir=ipk
	files_of previous | while read -r f h; do
		fetch "$FEEDBASE/$dir/$ARCH/$f" "$UP/prev/$f" 16777216 && [ "$(sha "$UP/prev/$f")" = "$h" ] || exit 1
	done || { rm -rf "$UP/prev"; return 1; }
	[ -n "$(ls "$UP/prev" 2>/dev/null)" ]
}
free_kb() { local d=/; [ -d /overlay ] && d=/overlay; df -Pk "$d" 2>/dev/null | awk 'NR == 2 { print $4 }'; }
install_dir() { # <dir> <downgrade 0|1>
	if [ $FMT = apk ]; then apk add "$1"/*.apk >"$UP/pm.log" 2>&1
	else
		if [ "$2" = 1 ]; then opkg install --force-downgrade "$1"/* >"$UP/pm.log" 2>&1; else opkg install "$1"/* >"$UP/pm.log" 2>&1; fi
	fi
}
# After a package operation the service must be running the new files. opkg's scripts stop the old daemon and start the new one (the
# init's restarting flag keeps that from releasing anything); apk stops nothing on an upgrade (found in the UPD lab on 25.12.5: the old
# daemon went on running the old binary), so there the updater restarts the service itself, by the init's own `restart`, which keeps
# the same flag. A service the person switched off is left alone.
settle() {
	[ "$ENABLED" = 1 ] || return 0
	if [ $FMT = apk ]; then /etc/init.d/forestvpn restart >/dev/null 2>&1; else /etc/init.d/forestvpn start >/dev/null 2>&1; fi
	return 0
}
# review-router M1: `apk add <file>` pinned the package in /etc/apk/world (`forestvpn><hash>`). After a good forward update the bare names
# go back (offline: the index is not needed to rewrite the world file), so LuCI's Upgrade and `apk upgrade` keep working. Never after a
# rollback: there the pin is what holds the version marked bad back.
unpin() {
	[ $FMT = apk ] || return 0
	apk --no-network add $(pkgs_of current) >"$UP/pm.log" 2>&1 || log "apk unpin: $(head -c 200 "$UP/pm.log" | tr '\n' ' ')"
	return 0
}
give_up_release() { # neither version runs: the one release (AD-11)
	RELEASED=1; echo - > "$RUN/cannot_start"; hook cannot_start -
}

do_apply() {
	local rc kb need f inst bad w1 w2 haveprev=0 t0 fail_reason
	RELEASED=0
	lock || { log "another update is running"; exit 0; }
	trap 'rm -f "$RUN/restarting"; rm -rf "$UP/cur" "$UP/prev"; unlock' EXIT
	load_status
	S_undone=; S_undone_reason=   # a new attempt: what the last one undid is old news
	progress updating ""
	do_check; rc=$?
	case $rc in
		1) retry_later feed_unreachable; return 0 ;;
		2) progress failed "$S_detail"; return 0 ;;
	esac
	write_status   # `updating` with the version that is installed now (a router that never checked has no installed fact yet)
	inst=$S_installed
	newer "$CUR" "$inst" || { clear_retry; progress idle up_to_date; return 0; }
	# A version marked bad by a rollback is not offered (the banner, check), but a person who taps Update now asks for it: the cause (passwall2
	# holding the daemon back) is often something they just turned off. If it fails again it is rolled back again and the mark stays.
	bad=$(cat "$STATE/update_bad" 2>/dev/null)
	[ "$bad" != "$CUR" ] || log "trying $CUR again: it was rolled back before, and a person asked"
	[ "$(vercmp "$CUR" "$S_min")" != -1 ] || { progress failed release_refused; return 0; }
	# 2. the rollback target
	if [ -n "$PREV" ] && [ "$PREV" != "$CUR" ] && [ "$(vercmp "$PREV" "$S_min")" != -1 ]; then
		haveprev=1
		fetch_previous || { retry_later feed_unreachable; return 0; }
	elif [ "$S_below" != true ]; then
		progress failed no_rollback; return 0
	fi
	# 3. the new package
	fetch_current; rc=$?
	case $rc in
		1) retry_later "$S_detail"; return 0 ;;
		2) progress failed "$S_detail"; return 0 ;;
	esac
	# 4. space, then the install
	kb=0; for f in "$UP"/cur/*; do kb=$(( kb + ($(wc -c < "$f") + 1023) / 1024 )); done
	need=$(( kb * 2 ))
	[ "$(free_kb)" -ge "$need" ] 2>/dev/null || { progress failed no_space; return 0; }
	config_enabled
	t0=$(now)
	mkdir -p "$RUN"; : > "$RUN/restarting"; : > "$UP/install.mark"
	install_dir "$UP/cur" 0
	if [ "$(pm_version)" = "$inst" ]; then
		# the manager refused before it changed anything (its lock, a script): nothing to undo, and the daemon was left alone
		log "pm: $(head -c 200 "$UP/pm.log" | tr '\n' ' ')"; settle; retry_later install_failed; return 0
	fi
	settle
	# 5. watch
	w1=0; [ "$ENABLED" = 1 ] && { watch; w1=$?; }
	if [ $w1 = 0 ]; then
		unpin
		clear_retry; rm -f "$STATE/update_bad"
		S_installed=$(pm_version); S_avail=false; S_below=false
		progress updated ok; log "updated $inst to $S_installed in $(( $(now) - t0 )) s"; return 0
	fi
	log "the new version did not hold (exit $w1: 1 = the daemon did not run, 2 = it did not connect)"
	fail_reason=$(printf '%s' "$W_REASON" | grep -E '^[a-z][a-z0-9_.]{0,63}$')   # a code, or nothing: it goes into a JSON document
	# 6. back
	if [ $haveprev = 0 ]; then
		daemon_running || give_up_release
		[ $RELEASED = 1 ] && retry_later cannot_start || retry_later no_rollback
		return 0
	fi
	: > "$UP/install.mark"
	install_dir "$UP/prev" 1
	settle
	watch; w2=$?
	S_installed=$(pm_version)
	if [ $w2 = 0 ] || { [ $w1 = 1 ] && [ $w2 = 2 ]; }; then
		# the previous version runs (and connects, or the new one crashed while the network is down): the new version is the fault
		put "$STATE/update_bad" "$CUR"; clear_retry; S_avail=false
		S_undone=$CUR; S_undone_reason=$fail_reason
		progress rolled_back new_version_failed; log "rolled back to $S_installed in $(( $(now) - t0 )) s"
	elif [ $w2 = 2 ]; then
		# neither connects: the network was the cause. The previous version stays, nothing is released, the update is tried again
		retry_later network_blocked
	else
		give_up_release; retry_later cannot_start
	fi
	return 0
}
config_enabled() { ENABLED=$(uci -q get forestvpn.main.enabled); [ -n "$ENABLED" ] || ENABLED=1; }

cmd_apply() {
	if [ "$1" = --wait ]; then do_apply; return $?; fi
	# one tap, one worker, in a session of its own: the update stops the very service this may have been started from
	# (the shell by its full name: BusyBox's setsid cannot start `sh` as an applet)
	if command -v setsid >/dev/null 2>&1; then setsid /bin/sh "$SELF" worker >/dev/null 2>&1 </dev/null &
	else ( trap '' HUP TERM; /bin/sh "$SELF" worker >/dev/null 2>&1 </dev/null ) & fi
	echo started
}

cmd_status() { if [ -s "$UP/status.json" ]; then cat "$UP/status.json"; else echo '{"v":1,"progress":"idle","detail":"never_checked","banner":"none"}'; fi; }
cmd_dismiss() {
	case ${1:-} in '' | *[!0-9A-Za-z._~-]* | -*) echo "usage: run dismiss <version>" >&2; exit 2 ;; esac
	mkdir -p "$STATE"; put "$STATE/update_dismissed" "$1"
	load_status; write_status
}

# The update replaces this very file (the package carries it): a shell reads its script as it runs, so the update runs from a copy in RAM.
ram_exec() {
	local ram=run.ram
	[ -n "$PROD" ] || ram=run.ram.test   # a test copy must not turn into the installed one through its RAM copy (the seams would be dropped)
	case $0 in */run.ram | */run.ram.test) return 0 ;; esac   # (not an environment variable: the installed file rebuilds its environment at entry)
	mkdir -p "$UP" && cp "$SELF" "$UP/$ram" && exec /bin/sh "$UP/$ram" "$@"
}
# What luci.forestvpn's update_* methods run: rpcd's exec plugin wants one JSON object and a zero exit, whatever happened.
cmd_rpc() {
	local ok=false v
	case ${1:-} in
		update_check) cmd_check && ok=true ;;
		update_apply) cmd_apply >/dev/null && ok=true ;;
		update_dismiss) v=$(head -c 512 | jsonfilter -e '@.version' 2>/dev/null); ( cmd_dismiss "$v" ) 2>/dev/null && ok=true ;;
	esac
	echo "{\"ok\":$ok}"
}

case ${1:-} in
	check) cmd_check ;;
	apply) if [ "${2:-}" = --wait ]; then ram_exec "$@"; fi; shift; cmd_apply "$@" ;;
	worker) ram_exec "$@"; do_apply ;;
	status) cmd_status ;;
	dismiss) shift; cmd_dismiss "$@" ;;
	vercmp) vercmp "$2" "$3" ;;
	rpc) shift; cmd_rpc "$@" ;;
	*) sed -n '2,12p' "$0" >&2; exit 2 ;;
esac
