#!/bin/sh /etc/rc.common
# /etc/init.d/shater-cron — periodic auto-updater for subscriptions & rulesets,
# plus the data-plane watchdog (v0.2).
#
# A tiny procd-supervised loop that, once per tick, checks every enabled
# subscription and url-ruleset against its per-item `update_interval` and runs
#   shaterd sub update <name>       (subscriptions)
#   shaterd ruleset update <name>   (url rulesets)
# when the item is due, then a single `shaterd reconcile` if anything changed
# (the daemon's config-hash gate rebuilds the engine only on a real change).
#
# RELIABILITY CONTRACT (same "железно" posture as /etc/init.d/shater):
#   * The loop body is fully INERT unless globals.enabled=1 AND the main shater
#     service is live (ACTIVE_FLAG raised by its start, cleared by its stop).
#     An admin `stop` of the main service therefore STICKS — this loop idles.
#   * Only ever invokes shaterd verbs / the shater init — never touches the
#     data plane directly.
#   * Due-ness is tracked with epoch stamp FILES under a tmpfs run dir, so no
#     dependence on `date -r`. Stamps are lost on reboot => every item is due at
#     boot, giving a fetch-at-boot. Fetches are skipped (not stamped) while the
#     box has no default route, so the boot-time fetch actually happens once WAN
#     is up instead of silently burning the attempt.
#   * A stamp is written ONLY on success; a failed fetch is retried after a
#     short backoff (RETRY_SECS) instead of waiting out the full interval.
#   * WATCHDOG: if interception is meant to be live but the daemon has been dead
#     for WATCHDOG_TICKS consecutive ticks, we escalate: with kill_switch=open
#     the main service is STOPPED (tears interception down — fail-open, LAN
#     returns to plain routing); with kill_switch=closed the rules stay
#     (blocked-by-design) and we log loudly.
#   * The loop never self-exits (procd would respawn-churn an exiting body);
#     it idles on its guards instead. busybox ash only — no bashisms.

USE_PROCD=1
START=96          # order vs the main init (99) is irrelevant; loop self-guards
STOP=11

# `loop` is an internal action procd re-execs to run the periodic body.
EXTRA_COMMANDS="loop"
EXTRA_HELP="	loop	internal: run the periodic update loop (invoked by procd)"

INIT_SCRIPT=/etc/init.d/shater-cron
SHATER_INIT=/etc/init.d/shater
SHATERD=/usr/bin/shaterd
ACTIVE_FLAG=/var/run/shater.active
STAMP_DIR=/var/run/shater/cron
TICK=60                          # seconds between due-checks
RETRY_SECS=300                   # backoff before retrying a FAILED fetch
WATCHDOG_TICKS=5                 # consecutive dead-daemon ticks before escalating
DEFAULT_SUB_INTERVAL=6h
DEFAULT_RS_INTERVAL=24h
DEFAULT_BL_INTERVAL=24h          # url blocklist refresh interval (D16)

# --- helpers ---------------------------------------------------------------

shater_enabled() {
	local en
	en=$(uci -q get shater.globals.enabled) || return 1
	[ "$en" = "1" ]
}

# Syslog line that honors globals.log_syslog (same helper as /etc/init.d/shater,
# with this service's tag): with log_syslog=0 the operator asked for a silent
# syslog and the watchdog/updater status lines respect that too. Absent option
# (or unreadable UCI) = ON.
_slog() {
	[ "$(uci -q get shater.globals.log_syslog)" = "0" ] || logger -t shater-cron "$@"
}

# The main service raised its live-flag (start) and has not stopped since.
shater_active() {
	[ -f "$ACTIVE_FLAG" ]
}

# Best-effort "do we have an uplink" check; fetching before WAN is up at boot
# would waste each item's one boot attempt.
shater_has_uplink() {
	ip route show default 2>/dev/null | grep -q '^default' && return 0
	ip -6 route show default 2>/dev/null | grep -q '^default'
}

# Stamp names embed the UCI item name; keep them to one safe path component.
shater_safe_name() {
	echo "$1" | sed 's/[^A-Za-z0-9._-]/_/g'
}

# Convert 30m|6h|24h|2d|3600 -> seconds on stdout. $2 is a fallback token used
# when the input is empty or malformed (the FALLBACK is honored, not a
# hardcoded constant).
shater_ivl_secs() {
	local v="$1" def="$2" n u
	[ -n "$v" ] || v="$def"
	n=$(echo "$v" | sed 's/[^0-9].*$//')
	u=$(echo "$v" | sed 's/^[0-9]*//')
	if [ -z "$n" ]; then
		# Malformed (no leading digits): fall back to the caller's default; if
		# that is somehow malformed too, 6h.
		v="$def"
		n=$(echo "$v" | sed 's/[^0-9].*$//')
		u=$(echo "$v" | sed 's/^[0-9]*//')
		[ -n "$n" ] || { echo 21600; return; }
	fi
	case "$u" in
		s|"") echo "$n" ;;
		m|M)  echo $(( n * 60 )) ;;
		h|H)  echo $(( n * 3600 )) ;;
		d|D)  echo $(( n * 86400 )) ;;
		*)    echo $(( n )) ;;
	esac
}

# Due if now - stamp >= interval. $1 stamp file, $2 interval seconds.
shater_due() {
	local stamp="$1" ivl="$2" now last
	now=$(date +%s)
	last=$(cat "$stamp" 2>/dev/null)
	[ -n "$last" ] || last=0
	[ $(( now - last )) -ge "$ivl" ]
}

shater_stamp() { mkdir -p "$STAMP_DIR"; date +%s > "$1"; }

# Failed fetch: pretend the last success was (interval - RETRY_SECS) ago so the
# item comes due again after a short backoff instead of a full interval — but
# never sooner than RETRY_SECS (guards tiny intervals).
shater_stamp_retry() {
	local stamp="$1" ivl="$2" back
	back=$(( ivl - RETRY_SECS ))
	[ "$back" -gt 0 ] || back=0
	mkdir -p "$STAMP_DIR"
	echo $(( $(date +%s) - back )) > "$stamp"
}

# Walk anonymous `config subscription` / `config ruleset` sections by index and
# run any that are due. Echoes non-empty on stdout if at least one item updated.
shater_run_due() {
	local i name en ivl secs stamp src changed=""

	# Subscriptions.
	i=0
	while uci -q get "shater.@subscription[$i]" >/dev/null 2>&1; do
		name=$(uci -q get "shater.@subscription[$i].name")
		en=$(uci -q get "shater.@subscription[$i].enabled")
		[ -z "$en" ] && en=1
		if [ -n "$name" ] && [ "$en" = "1" ]; then
			ivl=$(uci -q get "shater.@subscription[$i].update_interval")
			secs=$(shater_ivl_secs "$ivl" "$DEFAULT_SUB_INTERVAL")
			stamp="$STAMP_DIR/sub.$(shater_safe_name "$name")"
			if shater_due "$stamp" "$secs"; then
				if "$SHATERD" sub update "$name" >/dev/null 2>&1; then
					shater_stamp "$stamp"
					changed=1
				else
					_slog -p daemon.warn \
						"sub update '$name' failed; retrying in ${RETRY_SECS}s"
					shater_stamp_retry "$stamp" "$secs"
				fi
			fi
		fi
		i=$(( i + 1 ))
	done

	# Rulesets (only url sources auto-update; others have nothing to fetch).
	i=0
	while uci -q get "shater.@ruleset[$i]" >/dev/null 2>&1; do
		name=$(uci -q get "shater.@ruleset[$i].name")
		src=$(uci -q get "shater.@ruleset[$i].source")
		if [ -n "$name" ] && [ "$src" = "url" ]; then
			ivl=$(uci -q get "shater.@ruleset[$i].update_interval")
			secs=$(shater_ivl_secs "$ivl" "$DEFAULT_RS_INTERVAL")
			stamp="$STAMP_DIR/rs.$(shater_safe_name "$name")"
			if shater_due "$stamp" "$secs"; then
				if "$SHATERD" ruleset update "$name" >/dev/null 2>&1; then
					shater_stamp "$stamp"
					changed=1
				else
					_slog -p daemon.warn \
						"ruleset update '$name' failed; retrying in ${RETRY_SECS}s"
					shater_stamp_retry "$stamp" "$secs"
				fi
			fi
		fi
		i=$(( i + 1 ))
	done

	[ -n "$changed" ] && echo 1
}

# Walk anonymous `config blocklist` sections and, when a url blocklist is due,
# force a DNS-filter refresh via `shaterd blocklist update` (D16). Unlike subs /
# rulesets, that verb is a GLOBAL reconcile — it re-fetches EVERY remote rule-set
# on the fresh box — so it is fired at most ONCE per tick even when several url
# blocklists are due, but each due list is still stamped. It does its own
# reconcile, so this function does NOT feed the `changed` reconcile above (no
# double-signal). No-op unless at least one enabled url blocklist exists.
shater_run_due_blocklists() {
	local i name en src ivl secs stamp fired=""
	i=0
	while uci -q get "shater.@blocklist[$i]" >/dev/null 2>&1; do
		name=$(uci -q get "shater.@blocklist[$i].name")
		en=$(uci -q get "shater.@blocklist[$i].enabled")
		[ -z "$en" ] && en=1
		src=$(uci -q get "shater.@blocklist[$i].source")
		if [ -n "$name" ] && [ "$en" = "1" ] && [ "$src" = "url" ]; then
			ivl=$(uci -q get "shater.@blocklist[$i].update_interval")
			secs=$(shater_ivl_secs "$ivl" "$DEFAULT_BL_INTERVAL")
			stamp="$STAMP_DIR/bl.$(shater_safe_name "$name")"
			if shater_due "$stamp" "$secs"; then
				if [ -z "$fired" ]; then
					if "$SHATERD" blocklist update >/dev/null 2>&1; then
						fired=ok
					else
						fired=fail
					fi
				fi
				if [ "$fired" = "ok" ]; then
					shater_stamp "$stamp"
				else
					_slog -p daemon.warn \
						"blocklist update '$name' failed; retrying in ${RETRY_SECS}s"
					shater_stamp_retry "$stamp" "$secs"
				fi
			fi
		fi
		i=$(( i + 1 ))
	done
}

# Watchdog body for one tick: detect "interception live, daemon dead". $1 is the
# running dead-tick count; echoes the updated count. The loop only calls this
# while enabled+active, so a missing `shaterd run` process here means the engine
# that should be holding interception up is gone.
shater_watchdog() {
	local dead="$1" ks
	if pidof shaterd >/dev/null 2>&1; then
		echo 0
		return
	fi
	dead=$(( dead + 1 ))
	if [ "$dead" -eq "$WATCHDOG_TICKS" ]; then
		ks=$(uci -q get shater.globals.kill_switch)
		if [ "$ks" = "closed" ]; then
			# Fail-closed is a POLICY: dead engine + standing rules == traffic
			# blocked, which is what the admin asked for. Keep it, but say so.
			_slog -p daemon.crit \
				"shaterd dead for $(( dead * TICK ))s with interception live; kill_switch=closed keeps LAN blocked — fix the daemon or /etc/init.d/shater stop"
		else
			# Fail-open: durably stop the stack (clears the live-flag; the daemon
			# — or its next respawn — tears interception down) so the LAN returns
			# to plain routing.
			_slog -p daemon.crit \
				"shaterd dead for $(( dead * TICK ))s with interception live; kill_switch=open — stopping shater (fail-open, LAN back to plain routing)"
			"$SHATER_INIT" stop
			dead=0
		fi
	fi
	echo "$dead"
}

# loop: the foreground body supervised by procd. Never exits on its own — it
# idles while disabled/inactive so procd is not respawn-churned by a
# self-exiting body when the stack is off.
loop() {
	# CRITICAL: drop the rc.common per-service flock before entering the eternal
	# body. For a USE_PROCD init script, rc.common sources /lib/functions/procd.sh
	# on EVERY action (enable/start/loop alike), and procd.sh's tail runs
	# `_procd_wrapper` -> `procd_lock`, which takes an EXCLUSIVE BLOCKING flock on
	# fd 1000 -> /var/lock/procd_shater-cron.lock for the lifetime of the process.
	# Every other action releases it by exiting — but `loop` never exits, so it
	# would hold that lock FOREVER. Every later `/etc/init.d/shater-cron <action>`
	# then blocks on the flock — including the `enable`/`start` that base-files'
	# default_postinst runs synchronously inside a live opkg/apk transaction,
	# which deadlocks the package manager (observed: `apk add` hung forever in
	# shater-core's post-install on BananaWRT 25.12). Closing fd 1000 releases
	# the flock immediately and keeps children (sleep/shaterd) from inheriting
	# it. A no-op where fd 1000 is not open (older procd.sh without procd_lock).
	exec 1000>&-
	local changed dead=0
	mkdir -p "$STAMP_DIR"
	while :; do
		if shater_enabled && shater_active; then
			if shater_has_uplink; then
				changed=$(shater_run_due)
				# url blocklists refresh independently (own reconcile, D16).
				shater_run_due_blocklists
			else
				changed=""
			fi
			if [ -n "$changed" ]; then
				# A sub/ruleset fetched new data: reconcile re-runs generate, which
				# ALSO re-evaluates time-scheduled rules for this tick — so no
				# separate `schedule due` is needed when we already reconcile.
				"$SHATERD" reconcile >/dev/null 2>&1
			else
				# Nothing else changed this tick: re-evaluate time-scheduled
				# (bedtime/school-hours) rules against the wall clock. `schedule
				# due` does its OWN reconcile in the daemon, hash-gated so it only
				# rebuilds the engine when a window boundary was actually crossed —
				# cheap to run every minute (a no-op between boundaries).
				"$SHATERD" schedule due >/dev/null 2>&1
			fi
			dead=$(shater_watchdog "$dead")
		else
			dead=0
		fi
		sleep "$TICK"
	done
}

# --- procd lifecycle -------------------------------------------------------

start_service() {
	[ -x "$SHATERD" ] || return 0
	# NOTE: deliberately NO `shater_enabled` guard here. The loop body re-checks
	# `shater_enabled && shater_active` on EVERY tick and idles when either is
	# false, so an outer guard buys nothing — but it costs correctness: the panel
	# enables the stack by writing UCI and applying over the daemon's control
	# socket, which emits no config.change event, so nothing would ever start this
	# loop afterwards. Subscriptions/rulesets/blocklists would then silently never
	# auto-update until the next reboot. Cost of always running: one `sleep 60`.

	procd_open_instance shater-cron
	# Re-exec ourselves as the loop body so procd supervises a single process.
	# Invoke through the rc.common shebang (NOT `/bin/sh $INIT_SCRIPT`) so the
	# `loop` action is dispatched by rc.common and procd tracks the resulting
	# foreground PID — running `/bin/sh <script> loop` detaches the loop from
	# procd (instance shows not-running, and procd respawn-churns it).
	procd_set_param command "$INIT_SCRIPT" loop
	procd_set_param respawn 3600 10 0     # threshold timeout always-retry
	procd_set_param file /etc/config/shater # restart the loop when UCI changes
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_close_instance
}

reload_service() {
	# UCI changed: bounce the loop so new intervals/items take effect. If the
	# stack was disabled, start re-guards to a no-op.
	stop
	start
}

service_triggers() {
	procd_add_reload_trigger "shater"
}
