#!/bin/sh /etc/rc.common
# procd init script for the n9 VPN router client (daemon: /usr/sbin/n9d).
#
# Division of labour:
#   - The daemon (n9d -connect) creates the `n9` TUN, pins the node's IP to the real WAN gateway
#     (so the encrypted path never recurses), and routes the configured CIDR into the tunnel. procd
#     owns its lifecycle: stopping the service tears the tunnel down.
#   - THIS script owns the OpenWRT firewall (fw4) integration, because on fw4 a LAN->tun forward is
#     dropped unless the tun lives in a firewall zone:
#       * lan_masq=1, killswitch=0 (default): add the `n9` device to the existing `wan` zone, so the
#         LAN is masqueraded out the tunnel and the stock lan->wan forwarding applies. If the tunnel
#         drops, traffic falls back to the real WAN (a leak, but no lockout).
#       * lan_masq=1, killswitch=1: put `n9` in a dedicated `n9` zone (masq) with lan->n9 forwarding,
#         AND drop lan->wan. The LAN can then ONLY exit through the tunnel — tunnel down => no path
#         => fail closed (no leak). OFF by default; can cut LAN internet if the tunnel is down.
#   Recovery is always possible from the LAN (router-local LAN traffic is never tunnelled):
#   `/etc/init.d/n9 stop` or a reboot restores normal routing.
#
# UCI contract: /etc/config/n9, section type `n9` named `main`.

START=99
STOP=10
USE_PROCD=1

PROG=/usr/sbin/n9d
TUN=n9

load_n9_config() {
	config_load n9
	config_get enabled    main enabled    0
	config_get link       main link       ''
	config_get transport  main transport  'udp'
	config_get node       main node       ''
	config_get route      main route      '0.0.0.0/0'
	config_get lan_masq   main lan_masq    1
	config_get killswitch main killswitch  0
	config_get ru_direct  main ru_direct   1
}

# Path of the fw4 include that DEFINES the n9_direct4 set inside `table inet fw4`. fw4 includes files
# under /etc/nftables.d/table-pre/ INSIDE the inet fw4 table block, so the set lives in the same table
# as the kill-switch DROP — REQUIRED, because nftables set lookups (@set) only resolve within the
# rule's own table and a separate `inet n9` table's ACCEPT cannot override fw4's DROP. The daemon then
# mutates this set at runtime via `nft add/delete/flush element inet fw4 n9_direct4`.
N9_NFT_INCLUDE_DIR=/etc/nftables.d/table-pre
N9_NFT_INCLUDE=$N9_NFT_INCLUDE_DIR/10-n9-direct.nft

# n9_wan_zone prints the uci anchor of the firewall zone named "wan" (e.g. @zone[1]).
n9_wan_zone() {
	local i=0 name
	while name=$(uci -q get firewall.@zone[$i].name); do
		[ "$name" = "wan" ] && { echo "@zone[$i]"; return 0; }
		i=$((i + 1))
	done
	return 1
}

# n9_nft_direct_install drops the fw4 include that DEFINES the `n9_direct4` set inside table inet fw4.
# fw4 includes /etc/nftables.d/table-pre/*.nft inside the fw4 table, so the set shares fw4's namespace
# and can be referenced by the kill-switch ACCEPT exception rule. `flags interval` lets it hold CIDRs
# (the policy's static direct IPs are CIDRs; DNS-learned ones are /32s). Only the SET is defined here;
# the ACCEPT rule is a uci firewall rule (n9_direct, ordered before n9_killswitch) so its evaluation
# order relative to the DROP is explicit. The daemon populates/flushes the set at runtime.
n9_nft_direct_install() {
	mkdir -p "$N9_NFT_INCLUDE_DIR" 2>/dev/null
	cat > "$N9_NFT_INCLUDE" <<-'EOF'
		# Managed by /etc/init.d/n9 — RU-direct kill-switch exception set (table inet fw4).
		set n9_direct4 {
			type ipv4_addr
			flags interval
		}
	EOF
}

# n9_nft_direct_remove deletes the include so fw4 stops recreating the set on reload.
n9_nft_direct_remove() {
	rm -f "$N9_NFT_INCLUDE" 2>/dev/null
}

# n9_fw_up wires the tunnel into fw4 according to lan_masq/killswitch (+ ru_direct).
n9_fw_up() {
	local z changed=0
	[ "$lan_masq" = "1" ] || return 0

	# RU-direct: install the set-definition include FIRST so the n9_direct4 set exists when fw4 loads
	# the kill-switch ruleset below (the ACCEPT rule references @n9_direct4). The daemon adds elements
	# at runtime; on tunnel-down it flushes the set so the exception matches nothing (strict).
	if [ "$ru_direct" = "1" ]; then
		n9_nft_direct_install
	else
		n9_nft_direct_remove
	fi

	# RU-direct DNS capture: hijack LAN plaintext DNS (:53) to the router's own dnsmasq. n9d only
	# learns .ru IPs from DNS answers it can OBSERVE on the tunnel — a LAN device using hardcoded
	# external DNS (8.8.8.8) never has its .ru resolved through the tunnel, so nothing is learned
	# and its .ru rides the tunnel (slow). Forcing LAN :53 to dnsmasq makes every plaintext query's
	# upstream traverse the tunnel where n9d sees it. (Browser DoH/DoT stays a blind spot.)
	if [ "$ru_direct" = "1" ]; then
		if ! uci -q get firewall.n9dns >/dev/null 2>&1; then
			uci -q batch <<-EOF
				set firewall.n9dns=redirect
				set firewall.n9dns.name='n9_dns_hijack'
				set firewall.n9dns.src='lan'
				set firewall.n9dns.proto='tcp udp'
				set firewall.n9dns.src_dport='53'
				set firewall.n9dns.dest_port='53'
				set firewall.n9dns.target='DNAT'
			EOF
			changed=1
		fi
	else
		uci -q get firewall.n9dns >/dev/null 2>&1 && { uci -q delete firewall.n9dns; changed=1; }
	fi

	if [ "$killswitch" = "1" ]; then
		# Dedicated fail-closed zone: LAN exits ONLY through the tunnel.
		if ! uci -q get firewall.n9zone >/dev/null 2>&1; then
			uci -q batch <<-EOF
				set firewall.n9zone=zone
				set firewall.n9zone.name='n9'
				set firewall.n9zone.input='REJECT'
				set firewall.n9zone.output='ACCEPT'
				set firewall.n9zone.forward='REJECT'
				set firewall.n9zone.masq='1'
				set firewall.n9zone.mtu_fix='1'
				add_list firewall.n9zone.device='$TUN'
				set firewall.n9fwd=forwarding
				set firewall.n9fwd.src='lan'
				set firewall.n9fwd.dest='n9'
				set firewall.n9ks=rule
				set firewall.n9ks.name='n9_killswitch'
				set firewall.n9ks.src='lan'
				set firewall.n9ks.dest='wan'
				set firewall.n9ks.proto='all'
				set firewall.n9ks.target='DROP'
			EOF
			changed=1
		fi
		# Kill-switch exception for RU-direct: ACCEPT lan->wan for destinations in @n9_direct4, ordered
		# BEFORE n9_killswitch (uci rules evaluate in section order within fw4's forward chain, so an
		# ACCEPT preceding the DROP is terminal for those packets). The traffic is masqueraded by the
		# existing wan-zone masq on the real WAN path, so .ru reaches the internet while the tunnel is
		# up. On tunnel-down the daemon flushes @n9_direct4, so this rule matches nothing => strict.
		if [ "$ru_direct" = "1" ]; then
			if ! uci -q get firewall.n9direct >/dev/null 2>&1; then
				uci -q batch <<-EOF
					set firewall.n9direct=rule
					set firewall.n9direct.name='n9_ru_direct'
					set firewall.n9direct.src='lan'
					set firewall.n9direct.dest='wan'
					set firewall.n9direct.proto='all'
					set firewall.n9direct.family='ipv4'
					set firewall.n9direct.extra='ip daddr @n9_direct4'
					set firewall.n9direct.target='ACCEPT'
				EOF
				# Order n9direct BEFORE n9ks so the ACCEPT precedes the DROP in fw4's forward chain.
				n9_order_direct_before_killswitch
				changed=1
			fi
		else
			uci -q get firewall.n9direct >/dev/null 2>&1 && { uci -q delete firewall.n9direct; changed=1; }
		fi
	else
		# Leak-on-down: just masquerade the tunnel as part of the wan zone. No kill-switch DROP exists,
		# so no RU-direct exception rule is needed (the daemon's direct routes still bypass the tun via
		# `ip route`; there is nothing to except). Drop a stale exception rule if killswitch was toggled.
		uci -q get firewall.n9direct >/dev/null 2>&1 && { uci -q delete firewall.n9direct; changed=1; }
		z=$(n9_wan_zone) || return 0
		if ! uci -q get firewall.$z.device 2>/dev/null | grep -qw "$TUN"; then
			uci -q add_list firewall.$z.device="$TUN"
			changed=1
		fi
	fi

	[ "$changed" = "1" ] && { uci -q commit firewall; fw4 reload >/dev/null 2>&1; }
}

# n9_order_direct_before_killswitch moves the n9direct ACCEPT rule to the TOP of the firewall rule
# list so fw4 emits it before the n9_killswitch DROP (fw4 preserves uci rule section order within a
# zone's forward chain; an ACCEPT ahead of the DROP is terminal for @n9_direct4 packets). Pinning to
# index 0 is sufficient and robust regardless of how many other rules exist. Best-effort.
n9_order_direct_before_killswitch() {
	uci -q reorder firewall.n9direct=0 2>/dev/null || true
}

# n9_fw_down removes everything n9_fw_up may have added (both modes), best-effort.
n9_fw_down() {
	local z changed=0
	z=$(n9_wan_zone) && {
		if uci -q get firewall.$z.device 2>/dev/null | grep -qw "$TUN"; then
			uci -q del_list firewall.$z.device="$TUN"
			changed=1
		fi
	}
	for s in n9dns n9direct n9ks n9fwd n9zone; do
		if uci -q get firewall.$s >/dev/null 2>&1; then
			uci -q delete firewall.$s
			changed=1
		fi
	done
	# Drop the RU-direct set-definition include and clean the set so nothing lingers in fw4 after the
	# subsequent reload (the daemon already flushed it on exit; this removes the empty set + rule too).
	n9_nft_direct_remove
	nft flush set inet fw4 n9_direct4 2>/dev/null
	nft delete set inet fw4 n9_direct4 2>/dev/null
	[ "$changed" = "1" ] && { uci -q commit firewall; fw4 reload >/dev/null 2>&1; }
}

start_service() {
	load_n9_config

	# Master switch / unconfigured guard: do not start a tunnel we cannot bring up.
	[ "$enabled" = "1" ] || return 0
	[ -n "$link" ] || return 0

	n9_fw_up

	procd_open_instance n9
	# Pass -ru-direct ONLY when uci ru_direct=1, so n9d routes the server's direct policy (e.g. .ru)
	# out the WAN and populates the @n9_direct4 set. When 0, the flag is omitted and n9d tunnels
	# everything (no WAN bypass, no set mutations).
	if [ "$ru_direct" = "1" ]; then
		procd_set_param command "$PROG" -connect \
			-link "$link" \
			-transport "$transport" \
			-node "$node" \
			-route "$route" \
			-ru-direct
	else
		procd_set_param command "$PROG" -connect \
			-link "$link" \
			-transport "$transport" \
			-node "$node" \
			-route "$route"
	fi
	# Robust auto-reconnect: within a 3600s window, wait 5s before respawn, unlimited retries (0).
	# When the node drops, the data loop errors out, n9d exits, and procd reconnects fresh
	# (re-resolving + re-pinning the node) — no in-daemon supervisor needed.
	procd_set_param respawn 3600 5 0
	procd_set_param stdout 1
	procd_set_param stderr 1
	procd_close_instance
}

stop_service() {
	load_n9_config
	n9_fw_down
	# Status file is advisory for the LuCI UI; remove it on a clean stop so the UI shows "stopped"
	# rather than a stale "connected"/"disconnected". (On a crash/respawn n9d rewrites it itself.)
	rm -f /var/run/n9d.status 2>/dev/null
}

service_triggers() {
	procd_add_reload_trigger "n9"
}

reload_service() {
	# n9d does not hot-reload its argv and the firewall wiring may change; restart cleanly.
	restart
}

# `status` prints the account/profile by running n9d WITHOUT -connect (control round-trip only).
# Used by the LuCI «Проверить доступ» button.
status() {
	load_n9_config
	if [ -z "$link" ]; then
		echo "n9: link not configured (set it in Services -> n9 VPN)"
		return 1
	fi
	"$PROG" -link "$link"
}

# `list-nodes` prints the profile's nodes as TSV (id\tname\thost\tport\tonline\tload) for the LuCI
# node picker. Empty output when the link is unset or the control round-trip fails.
list_nodes() {
	load_n9_config
	[ -n "$link" ] || return 0
	"$PROG" -list-nodes -link "$link"
}

# n9d_arch maps the router CPU to the published binary suffix (same mapping as n9-install.sh).
n9d_arch() {
	case "$(uname -m)" in
		aarch64) echo arm64 ;;
		armv7l | armv6l | arm) echo armv7 ;;
		mips) echo mips ;;
		mipsel) echo mipsle ;;
		x86_64 | amd64) echo x86_64 ;;
		*) echo "" ;;
	esac
}

# `update` self-updates n9d from n9witch.com: it compares the installed binary's SHA256 against the
# published manifest (n9d-latest.json) for this router's arch and, only if they differ, downloads
# the new binary, verifies its checksum, atomically replaces /usr/sbin/n9d, refreshes the LuCI web app,
# and restarts the tunnel. No-op when already current (binary sha AND release marker match). Safe to
# run from cron or the LuCI «Обновить» button. Checksum-gated, so a corrupted/MITM'd binary download is
# rejected before it can replace the running binary.

# n9_luci_refresh <base>: bring the LuCI web app (views/menu/acl) up to date from the release tarball.
# The binary swaps separately; without this a plain `update` leaves the old web UI (e.g. a removed
# kill-switch view) in place. Extracted to a temp dir and copied file-by-file — we deliberately do NOT
# overwrite /etc/init.d/n9 from here (this script is running from it), so init.d changes still need the
# installer. Stale views are pruned and the LuCI cache busted so the new menu shows without a reboot.
n9_luci_refresh() {
	local base="$1" td=/tmp/n9-luci tf=/tmp/n9-files.tar.gz vdir=/www/luci-static/resources/view/n9
	wget -qO "$tf" "$base/n9-openwrt-files.tar.gz" 2>/dev/null || { rm -f "$tf"; return 1; }
	rm -rf "$td"; mkdir -p "$td"
	tar -xzf "$tf" -C "$td" 2>/dev/null || { rm -rf "$td" "$tf"; return 1; }
	mkdir -p "$vdir" /usr/share/luci/menu.d /usr/share/rpcd/acl.d
	cp -f "$td$vdir/"*.js "$vdir/" 2>/dev/null
	cp -f "$td/usr/share/luci/menu.d/luci-app-n9.json" /usr/share/luci/menu.d/ 2>/dev/null
	cp -f "$td/usr/share/rpcd/acl.d/luci-app-n9.json" /usr/share/rpcd/acl.d/ 2>/dev/null
	# Normalize perms/owner EVERY refresh: uhttpd 403s any static file missing the world-read bit,
	# and `cp -f` onto an existing file keeps the DESTINATION's old mode — so one bad historical
	# install would otherwise keep 403ing forever no matter how many updates land on top of it.
	chmod 0644 "$vdir"/*.js /usr/share/luci/menu.d/luci-app-n9.json /usr/share/rpcd/acl.d/luci-app-n9.json 2>/dev/null
	chown 0:0 "$vdir"/*.js /usr/share/luci/menu.d/luci-app-n9.json /usr/share/rpcd/acl.d/luci-app-n9.json 2>/dev/null
	rm -f "$vdir/settings.js"                      # stale kill-switch view (now general/connection/logs)
	rm -f /tmp/luci-indexcache; rm -rf /tmp/luci-modulecache 2>/dev/null
	/etc/init.d/rpcd reload 2>/dev/null || true
	rm -rf "$td" "$tf"
	return 0
}

update() {
	local base="https://n9witch.com/downloads/openwrt" arch mf want have tmp got mver iver
	arch=$(n9d_arch)
	[ -n "$arch" ] || { echo "n9d update: unsupported arch $(uname -m)"; return 1; }
	mf=$(wget -qO- "$base/n9d-latest.json" 2>/dev/null) || { echo "n9d update: manifest fetch failed"; return 1; }
	want=$(echo "$mf" | jsonfilter -e "@.sha256.$arch" 2>/dev/null)
	[ -n "$want" ] || want=$(echo "$mf" | sed -n 's/.*"'"$arch"'"[^"]*"\([0-9a-f]\{64\}\)".*/\1/p')
	[ -n "$want" ] || { echo "n9d update: no checksum for arch $arch"; return 1; }
	mver=$(echo "$mf" | jsonfilter -e '@.version' 2>/dev/null)
	[ -n "$mver" ] || mver=$(echo "$mf" | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')
	have=$(sha256sum "$PROG" 2>/dev/null | cut -d' ' -f1)
	iver=$(cat /etc/n9d/version 2>/dev/null)
	# "Already latest" only when BOTH the binary sha and the installed release marker match, so a stale
	# web UI (binary current but app old) still triggers a refresh instead of a false "already latest".
	[ "$have" = "$want" ] && [ -n "$mver" ] && [ "$iver" = "$mver" ] && { echo "n9d update: already latest ($mver)"; return 0; }

	if [ "$have" != "$want" ]; then
		tmp=/tmp/n9d.update
		wget -qO "$tmp" "$base/n9d-$arch" 2>/dev/null || { echo "n9d update: download failed"; rm -f "$tmp"; return 1; }
		got=$(sha256sum "$tmp" 2>/dev/null | cut -d' ' -f1)
		[ "$got" = "$want" ] || { echo "n9d update: checksum mismatch (got $got want $want)"; rm -f "$tmp"; return 1; }
		chmod 0755 "$tmp"
		# Same-filesystem rename so the swap works while the daemon runs (a cross-fs mv from /tmp, or a
		# direct write to $PROG, hits ETXTBSY on the live binary; rename over it does not).
		cp -f "$tmp" "$PROG.new" && mv -f "$PROG.new" "$PROG"
		rm -f "$tmp"
		echo "n9d update: installed binary $want"
	fi

	# Bring the LuCI web app up to date too — the binary swap alone leaves the old UI in place.
	if n9_luci_refresh "$base"; then
		echo "n9d update: web UI refreshed"
	else
		echo "n9d update: web UI refresh skipped (tarball fetch failed)"
	fi

	mkdir -p /etc/n9d; [ -n "$mver" ] && echo "$mver" > /etc/n9d/version
	echo "n9d update: now at ${mver:-$want} — restarting"
	stop >/dev/null 2>&1
	start
}

extra_command "status" "Check access: print the n9 account/profile (no tunnel)"
extra_command "list_nodes" "List profile nodes as TSV (id,name,host,port,online,load) for the UI"
extra_command "update" "Self-update n9d from n9witch.com (checksum-verified) and restart"
