#!/bin/sh /etc/rc.common

USE_PROCD=1
START=99
STOP=10

NAME=tailscale-settings

# State files live on tmpfs so they are rebuilt after every boot.
STATE_DIR=/var/run/tailscale-settings
DAEMON_SIG_FILE="$STATE_DIR/daemon"
ENABLED_FILE="$STATE_DIR/enabled"
DISABLE_REQUEST_FILE="$STATE_DIR/disable-requested"
DNS_MODE_FILE="$STATE_DIR/dns_mode"

TAILSCALE_INIT=/etc/init.d/tailscale

# Compare version strings: return 0 (true) if $1 >= $2
version_gte() {
	awk -v a="${1%%-*}" -v b="${2%%-*}" 'BEGIN {
		split(a, av, ".")
		split(b, bv, ".")
		for (i=1; i<=3; i++) {
			ai = int(av[i]+0); bi = int(bv[i]+0)
			if (ai > bi) { exit 0 }
			if (ai < bi) { exit 1 }
		}
		exit 0
	}'
	return $?
}

# Path of the tailscale daemon state file, as configured for the daemon init.
tailscale_state_file() {
	local state_file
	config_get state_file settings state_file '/etc/tailscale/tailscaled.state'
	echo "$state_file"
}

# Fingerprint of the UCI options that are only read by the daemon init script
# (they end up as procd env/command arguments, so changing them needs a restart).
daemon_signature() {
	local fw_mode port state_file log_stdout log_stderr
	config_get      fw_mode    settings fw_mode     'nftables'
	config_get      port       settings port        '41641'
	config_get      state_file settings state_file  '/etc/tailscale/tailscaled.state'
	config_get_bool log_stdout settings log_stdout   1
	config_get_bool log_stderr settings log_stderr   1
	echo "$fw_mode|$port|$state_file|$log_stdout|$log_stderr"
}

# Returns 0 when the tailscale daemon is managed and currently running.
tailscale_running() {
	[ -x "$TAILSCALE_INIT" ] || return 1
	"$TAILSCALE_INIT" running >/dev/null 2>&1
}

# Add/remove DNS forward entry in dnsmasq for MagicDNS via OpenWrt
setup_tailscale_dns_forward() {
	if [ ! -f /etc/config/dhcp ] || [ ! -x /etc/init.d/dnsmasq ]; then
		return 0
	fi

	if [ ! -x /usr/sbin/tailscale ] && [ ! -x /usr/bin/tailscale ]; then
		return 0
	fi

	local entry="/.ts.net/100.100.100.100"
	local s
	for s in $(uci -q get dhcp.@dnsmasq[0].server); do
		[ "$s" = "$entry" ] && return 0
	done

	uci add_list dhcp.@dnsmasq[0].server="$entry"
	uci commit dhcp
	/etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null
	logger -t "$NAME" "Added DNS forward: $entry"
}

remove_tailscale_dns_forward() {
	if [ ! -f /etc/config/dhcp ] || [ ! -x /etc/init.d/dnsmasq ]; then
		return 0
	fi

	local entry="/.ts.net/100.100.100.100"
	local found=0 s
	for s in $(uci -q get dhcp.@dnsmasq[0].server); do
		[ "$s" = "$entry" ] && found=1
	done

	if [ "$found" = "1" ]; then
		uci -q del_list dhcp.@dnsmasq[0].server="$entry"
		uci commit dhcp
		/etc/init.d/dnsmasq reload 2>/dev/null || /etc/init.d/dnsmasq restart 2>/dev/null
		logger -t "$NAME" "Removed Tailscale DNS forward entry: $entry"
	fi
}

# Keep the dnsmasq forward record in sync with dns_mode.
#
# Only magicdns takes resolution away from dnsmasq; both openwrt_forward and
# disabled leave dnsmasq authoritative, so the forward entry stays in place
# unless the user actually switches to magicdns. This mirrors what the old
# code did for openwrt_forward but stops deleting the entry when the mode goes
# back to disabled, which used to silently break *.ts.net resolution.
apply_dns_forward() {
	local dns_mode prev=''
	config_get dns_mode settings dns_mode 'disabled'

	[ -f "$DNS_MODE_FILE" ] && prev=$(cat "$DNS_MODE_FILE")

	if [ "$dns_mode" = "$prev" ]; then
		return 0
	fi

	case "$dns_mode" in
		magicdns)
			remove_tailscale_dns_forward
			;;
		openwrt_forward)
			setup_tailscale_dns_forward
			;;
		*)
			# disabled: leave whatever the previous mode put in place,
			# dnsmasq is authoritative either way
			;;
	esac

	echo "$dns_mode" > "$DNS_MODE_FILE"
}

# Run 'tailscale set' and recover from one known failure: a daemon built
# without auto-update support (ts_omit_clientupdate) rejects every edit whose
# result still has AutoUpdate.Apply=true, so while that flag is saved none of
# the settings below can be applied. Reset it once and retry. Any other
# failure, and the result of the retry, is reported unchanged.
run_tailscale_set() {
	local ts_bin="$1" err ret
	shift

	err=$("$ts_bin" "$@" 2>&1)
	ret=$?

	if [ $ret -ne 0 ]; then
		case "$err" in
			*'Auto-update support is disabled in this build'*)
				logger -t "$NAME" "AutoUpdate.Apply=true is unsupported in this build, resetting it"
				if "$ts_bin" set --auto-update=false; then
					err=$("$ts_bin" "$@" 2>&1)
					ret=$?
				else
					logger -t "$NAME" "Failed to reset auto-update"
				fi
				;;
		esac
	fi

	[ -z "$err" ] || printf '%s\n' "$err" >&2
	return $ret
}

apply_settings() {
	local ts_bin
	if [ -x /usr/sbin/tailscale ]; then
		ts_bin=/usr/sbin/tailscale
	elif [ -x /usr/bin/tailscale ]; then
		ts_bin=/usr/bin/tailscale
	else
		logger -t "$NAME" "tailscale binary not found, skipping settings apply"
		return 0
	fi

	config_load tailscale

	if [ -x "$TAILSCALE_INIT" ] && ! tailscale_running; then
		logger -t "$NAME" "tailscale is not running, skip applying settings"
		return 0
	fi

	local accept_routes advertise_exit_node exit_node exit_node_allow_lan_access
	local ssh shields_up runwebclient nosnat hostname
	local enable_relay relay_server_port disable_fw_config

	config_get accept_routes              settings accept_routes              '0'
	config_get advertise_exit_node        settings advertise_exit_node        '0'
	config_get exit_node                  settings exit_node                  ''
	config_get exit_node_allow_lan_access settings exit_node_allow_lan_access '0'
	config_get ssh                        settings ssh                        '0'
	config_get dns_mode                   settings dns_mode                   'disabled'
	config_get shields_up                 settings shields_up                 '0'
	config_get runwebclient               settings runwebclient               '0'
	config_get nosnat                     settings nosnat                     '0'
	config_get hostname                   settings hostname                   ''
	config_get enable_relay               settings enable_relay               '0'
	config_get relay_server_port          settings relay_server_port          '40000'
	config_get disable_fw_config          settings disable_fw_config          '0'

	# Collect advertise_routes UCI list into comma-separated string
	local routes=""
	append_route() {
		if [ -z "$routes" ]; then
			routes="$1"
		else
			routes="$routes,$1"
		fi
	}
	config_list_foreach settings advertise_routes append_route

	# Build argument list for 'tailscale set'
	set -- set

	# --accept-routes
	[ "$accept_routes" = "1" ] \
		&& set -- "$@" --accept-routes=true \
		|| set -- "$@" --accept-routes=false

	# --advertise-exit-node (only when no exit node is selected)
	if [ "$advertise_exit_node" = "1" ] && [ -z "$exit_node" ]; then
		set -- "$@" --advertise-exit-node=true
	else
		set -- "$@" --advertise-exit-node=false
	fi

	# --exit-node-allow-lan-access (only when no exit node is selected)
	if [ -z "$exit_node" ]; then
		[ "$exit_node_allow_lan_access" = "1" ] \
			&& set -- "$@" --exit-node-allow-lan-access=true \
			|| set -- "$@" --exit-node-allow-lan-access=false
	fi

	# --ssh
	[ "$ssh" = "1" ] \
		&& set -- "$@" --ssh=true \
		|| set -- "$@" --ssh=false

	# --accept-dns based on dns_mode
	case "$dns_mode" in
		magicdns)
			set -- "$@" --accept-dns=true
			;;
		*)
			# openwrt_forward and disabled both let dnsmasq own resolution
			set -- "$@" --accept-dns=false
			;;
	esac

	# --shields-up
	[ "$shields_up" = "1" ] \
		&& set -- "$@" --shields-up=true \
		|| set -- "$@" --shields-up=false

	# --webclient
	[ "$runwebclient" = "1" ] \
		&& set -- "$@" --webclient=true \
		|| set -- "$@" --webclient=false

	# Everything below is handed to 'tailscale set' in one call, and 'set'
	# aborts on an unknown flag. Gate the newer options on the first release
	# that accepts them, so an older CLI still applies the rest.
	local ts_ver
	ts_ver=$("$ts_bin" version 2>/dev/null | head -n1)

	# --snat-subnet-routes (requires tailscale >= 1.66.1)
	if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.66.1"; then
		[ "$nosnat" = "1" ] \
			&& set -- "$@" --snat-subnet-routes=false \
			|| set -- "$@" --snat-subnet-routes=true
	fi

	# --advertise-routes (comma-separated, empty clears routes)
	set -- "$@" "--advertise-routes=$routes"

	# --exit-node (empty clears the exit node)
	set -- "$@" "--exit-node=$exit_node"

	# when an exit node is configured, always allow LAN access
	[ -n "$exit_node" ] && set -- "$@" --exit-node-allow-lan-access=true

	# --hostname (empty resets it to the OS hostname)
	set -- "$@" "--hostname=$hostname"

	# --relay-server-port (requires tailscale >= 1.84.0)
	if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.84.0"; then
		if [ "$enable_relay" = "1" ]; then
			set -- "$@" "--relay-server-port=${relay_server_port:-40000}"
		else
			set -- "$@" "--relay-server-port="
		fi
	fi

	# --netfilter-mode (off when disable_fw_config is enabled, back to on
	# otherwise; requires tailscale >= 1.66.1)
	if [ -n "$ts_ver" ] && version_gte "$ts_ver" "1.66.1"; then
		if [ "$disable_fw_config" = "1" ]; then
			set -- "$@" --netfilter-mode=off
		else
			set -- "$@" --netfilter-mode=on
		fi
	elif [ "$disable_fw_config" = "1" ]; then
		logger -t "$NAME" "tailscale ${ts_ver:-before 1.66.1} has no --netfilter-mode in 'set', ignoring disable_fw_config"
	fi

	logger -t "$NAME" "Applying settings"
	run_tailscale_set "$ts_bin" "$@"
	local ret=$?
	[ $ret -eq 0 ] \
		&& logger -t "$NAME" "Settings applied successfully" \
		|| logger -t "$NAME" "Failed to apply settings (exit code: $ret)"
	return $ret
}

# Enable or disable autostart and the running daemon.
#
# The enable direction is applied here because starting a stopped daemon is
# harmless. The disable direction is only *armed* from the RPC (before the
# commit), because stopping is not reversible and the UCI rollback timer can
# still fire afterwards: rpcd commits first and arms the timer last, and a
# rollback only restores files. Keeping the actual stop in the reload handler
# means a rolled back config lands on service_enabled=1 while the daemon is
# still up, and the next apply simply drops the request.
sync_service_enabled() {
	local service_enabled=1 prev=''
	local requested=0
	config_get service_enabled settings service_enabled '1'

	prev=$(cat "$ENABLED_FILE" 2>/dev/null)
	case "$prev" in
		0|1) ;;
		*)
			# No usable record of the last applied value (first apply after
			# an upgrade, or a truncated file). Adopt the configuration as
			# the baseline instead of treating it as a transition: starting
			# a daemon here would run start_service, which arms another UCI
			# rollback window right after the one this apply is inside.
			#
			# An armed disable request is the exception: the user just asked
			# for the stop, so the baseline has to be "was running", not "was
			# stopped", or the request would be dropped without stopping.
			if [ -f "$DISABLE_REQUEST_FILE" ]; then
				prev=1
			else
				echo "$service_enabled" > "$ENABLED_FILE"
				prev="$service_enabled"
			fi
			;;
	esac

	if [ -f "$DISABLE_REQUEST_FILE" ]; then
		requested=1
	fi

	if [ "$service_enabled" = "0" ]; then
		if [ "$prev" = "0" ]; then
			return 0
		fi

		# Only stop when the UI asked for it, a hand edited config is
		# respected for autostart but never kills a running tunnel.
		if [ "$requested" -eq 0 ]; then
			logger -t "$NAME" "service_enabled=0 for the first time, disabling autostart only"
			"$TAILSCALE_INIT" disable 2>/dev/null
			echo "0" > "$ENABLED_FILE"
			return 0
		fi

		logger -t "$NAME" "Service disabled, stopping tailscale"
		"$TAILSCALE_INIT" stop 2>/dev/null
		"$TAILSCALE_INIT" disable 2>/dev/null
		rm -f "$DISABLE_REQUEST_FILE"
		echo "0" > "$ENABLED_FILE"
		return 0
	fi

	rm -f "$DISABLE_REQUEST_FILE"

	# The service should run. A daemon that is already up must not be touched:
	# that is the whole point of this handler. A daemon that is down is only
	# brought back when the user explicitly turned the service on in this
	# apply, not because some unrelated option happened to be saved, and not
	# because it crashed or was stopped by hand.
	if tailscale_running; then
		[ "$prev" = "1" ] || echo "1" > "$ENABLED_FILE"
		return 0
	fi

	if [ "$prev" = "1" ]; then
		return 0
	fi

	logger -t "$NAME" "Service enabled, starting tailscale"
	"$TAILSCALE_INIT" enable 2>/dev/null
	tailscale_running || "$TAILSCALE_INIT" start 2>/dev/null
	echo "1" > "$ENABLED_FILE"
}

# Wait briefly for procd to bring the daemon back after a restart. Called from
# reload_service, which rc.common runs outside of any function, so the loop
# counter has to live in a function of its own to stay out of the global scope.
wait_for_daemon() {
	local waited=0
	while [ "$waited" -lt 2 ]; do
		tailscale_running && return 0
		sleep 1
		waited=$((waited + 1))
	done
	return 1
}

handle_service_state() {
	if [ ! -x "$TAILSCALE_INIT" ]; then
		logger -t "$NAME" "tailscale init script not found, keeping settings only"
		apply_settings
		apply_dns_forward
		return 0
	fi

	sync_service_enabled

	# Nothing may touch a running daemon here. The upstream init runs
	# 'tailscaled --cleanup' before handing the instance to procd, which tears
	# down tailscale0 addresses, table 52 routes and the netfilter rules while
	# the old process is still alive. Running 'start' on a live service (or
	# 'restart' without reason) therefore leaves a node that still answers
	# 'tailscale status' and 'tailscale ping' but has no working kernel state.
	if ! tailscale_running; then
		return 0
	fi

	local desired
	desired=$(daemon_signature)

	if [ -f "$DAEMON_SIG_FILE" ] && [ "$(cat "$DAEMON_SIG_FILE")" != "$desired" ]; then
		logger -t "$NAME" "Daemon options changed, restarting tailscale"
		"$TAILSCALE_INIT" restart 2>/dev/null
		wait_for_daemon || logger -t "$NAME" "tailscale did not come back within 2s"
	fi

	echo "$desired" > "$DAEMON_SIG_FILE"

	apply_settings
	apply_dns_forward
}

start_service() {
	mkdir -p "$STATE_DIR"
	config_load tailscale
	daemon_signature > "$DAEMON_SIG_FILE"
	# Record the desired flag without acting on it: at boot the service state
	# is already correct and acting here would start or stop a daemon we do
	# not own. Only seed the file when there is nothing to inherit, so a
	# manual `disable` in the shell survives until the reload handler sees a
	# real transition.
	if [ ! -f "$ENABLED_FILE" ]; then
		local service_enabled=1
		config_get service_enabled settings service_enabled '1'
		echo "$service_enabled" > "$ENABLED_FILE"
	fi
	apply_settings
	apply_dns_forward
}

service_triggers() {
	procd_add_reload_trigger "tailscale"
}

reload_service() {
	mkdir -p "$STATE_DIR"
	config_load tailscale
	handle_service_state
}
