#!/bin/sh
# GOGOGO VPN client — UCI config; defaults in /usr/share/gogogo/defaults.sh

. /lib/functions/network.sh
[ -f /usr/share/gogogo/defaults.sh ] && . /usr/share/gogogo/defaults.sh
[ -f /usr/share/gogogo/procmgr.sh ] && . /usr/share/gogogo/procmgr.sh
[ -f /usr/share/gogogo/curl-resolve.sh ] && . /usr/share/gogogo/curl-resolve.sh
[ -f /usr/share/gogogo/wan-dns.sh ] && . /usr/share/gogogo/wan-dns.sh

CPE_UCI="${CPE_UCI:-gogogo}"
PID_FILE="${CPE_MAIN_PIDFILE:-/tmp/gogogo.pid}"
DNSMASQ_DROPIN="/tmp/dnsmasq.d/gogogo-vpn.conf"
DNSMASQ_DROPIN_LEGACY="/etc/dnsmasq.d/gogogo-vpn.conf"
DNSMASQ_APPLIED_STAMP="/tmp/gogogo_dnsmasq.applied"
SPLIT_ROUTES_STAMP="/tmp/gogogo_split_routes.stamp"
CHNROUTES_FETCH_STAMP="/tmp/gogogo_chnroutes_fetch.stamp"
RT_TABLES_FILE="/etc/iproute2/rt_tables"
ROUTE_GUARD_FILE="/etc/gogogo_route_guard_hosts"
API_HASH_FILE="/tmp/gogogo_api_hash"
ACCOUNT_EXPIRED_STAMP="/var/run/gogogo/account.expired"
ACCOUNT_EXPIRED_MSG="账号已过期，请续费后重试"
ENDPOINT_HOST_FILE="/tmp/gogogo_endpoint_host"
ACCOUNT_REFRESH_LOCK="/tmp/gogogo_account_refresh.lock"
ACCOUNT_REFRESH_RATE_STAMP="/tmp/gogogo_account_refresh.stamp"
ACCOUNT_REFRESH_MIN_SEC="${CPE_ACCOUNT_REFRESH_MIN_SEC:-60}"
PORTAL_CJ="/var/run/gogogo/portal.cj"
PORTAL_CAPTCHA="/var/run/gogogo/portal_captcha.png"
PORTAL_USER_HTML="/var/run/gogogo/portal_user.html"
PORTAL_PAY_FORM="/var/run/gogogo/pay_form.html"
PORTAL_LOCK="/var/run/gogogo/portal.lock"
PORTAL_BOUND="/var/run/gogogo/portal_account"
PORTAL_UA="Mozilla/5.0 (Linux; OpenWrt) AppleWebKit/537.36 GogogoCPE"
BOSS_RESP="/var/run/gogogo/boss_engress.json"
EXIT_IP_FILE="/var/run/gogogo/exit_ip"
EXIT_IP_PID="/var/run/gogogo/exit_ip.pid"
EXIT_IP_GEN="/var/run/gogogo/exit_ip.gen"

_load_defaults() {
	vpname="${VPNAME:-GOGOGO}"
	base_url="${CPE_DEFAULT_URL_FALLBACK:-https://cpe.gogogofuture.com}"
	api_user_path="${API_USER_PATH:-/api/v1/user}"
	lan_bridge="${LAN_BRIDGE:-br-lan}"
	dns_hijack_to="${DNS_HIJACK_TO:-192.168.10.1}"
	main_loop_sec="${MAIN_LOOP_SEC:-30}"
	curl_timeout="${CURL_TIMEOUT:-3}"
	chnroute_file="${CHNROUTE_FILE:-/etc/gogogo_chnroutes}"
	route_chn_metric="${ROUTE_CHN_METRIC:-200}"
	route_table_id="${ROUTE_TABLE_ID:-100}"
	route_table_name="${ROUTE_TABLE_NAME:-gogogo_cn}"
	route_rule_pref="${ROUTE_RULE_PREF:-1000}"
	route_reserved_pref="${ROUTE_RESERVED_PREF:-900}"
	route_reserved_cidrs="${ROUTE_RESERVED_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
	wan_route_metric="${WAN_ROUTE_METRIC:-100}"
	wg_keepalive="${WG_KEEPALIVE:-25}"
	wg_hs_max_age="${WG_HANDSHAKE_MAX_AGE:-180}"
	wg_endpoint_port="${CPE_WG_ENDPOINT_PORT:-2049}"
	firewall_zone="${FIREWALL_ZONE:-wan}"
	portal_url="${CPE_PORTAL_URL:-https://www.gogogofuture.com}"
	portal_timeout="${CPE_PORTAL_TIMEOUT:-10}"
	boss_url="${CPE_BOSS_URL:-https://boss.gogogofuture.com}"
	boss_set_engress_path="${CPE_BOSS_SET_ENGRESS_PATH:-/api/router/account/set_engress}"
	boss_url=$(echo "$boss_url" | sed 's|/*$||')
	case "$boss_set_engress_path" in
		/*) ;;
		*) boss_set_engress_path="/${boss_set_engress_path}" ;;
	esac
	dns_local_1="${DNS_LOCAL_1:-180.76.76.76}"
	dns_remote_1="${DNS_REMOTE_1:-8.8.8.8}"
	dns_local_fallback="${DNS_LOCAL_FALLBACK:-180.76.76.76}"
}

# 分流国内 DNS：WAN 口 DNS（DHCP/手动）；无可用国内 DNS 时用 180.76.76.76
_probe_dns_local_1() {
	local picked
	_load_api_dns
	[ -x /usr/sbin/gogogo-dns-probe ] || {
		dns_local_1="$dns_local_fallback"
		return 0
	}
	picked=$(/usr/sbin/gogogo-dns-probe pick 2>/dev/null)
	[ -n "$picked" ] || picked="$dns_local_fallback"
	if [ -n "$dns_remote_1" ] && [ "$picked" = "$dns_remote_1" ]; then
		logger -t gogogo "wan dns $picked is API overseas DNS, use fallback $dns_local_fallback"
		picked="$dns_local_fallback"
	fi
	dns_local_1="$picked"
}

config_get() {
	local option="$1" value
	value=$(uci -q get "${CPE_UCI}.@default[0].${option}")
	echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}

_uci_flag() {
	case "$(config_get "$1")" in
		1 | true | yes | on) return 0 ;;
	esac
	return 1
}

_load_api_dns() {
	local d
	d=$(config_get dns_server)
	[ -n "$d" ] && dns_remote_1="$d"
}

_get_base_url() {
	gogogo_get_base_url
}

_apply_uci_overrides() {
	local u lip
	u=$(config_get vpname); [ -n "$u" ] && vpname="$u"
	base_url=$(_get_base_url)
	lip=$(uci -q get network.lan.ipaddr | awk '{print $1}')
	[ -n "$lip" ] && dns_hijack_to="$lip"
}

_uci_set() {
	# $1=full uci path (pkg.sec.opt), $2=value
	uci -q set "$1=$2" 2>/dev/null || uci set "$1=$2"
}

_json_user_payload() {
	command -v jq >/dev/null 2>&1 || { echo "Error: jq required" >&2; return 1; }
	jq -n \
		--arg account "$username" \
		--arg password "$password" \
		'{account: $account, password: $password}'
}

_get_connect_info() {
	local payload max_time
	_probe_dns_local_1
	payload=$(_json_user_payload) || return 1
	max_time=$((curl_timeout + 2))
	[ -n "$CPE_DIAG_CURL_TIMEOUT" ] && max_time=$((CPE_DIAG_CURL_TIMEOUT + 2))
	gogogo_curl "${base_url}${api_user_path}" \
		--connect-timeout "$curl_timeout" --max-time "$max_time" \
		--silent --location \
		-H 'Content-Type: application/json' \
		-d "$payload"
}

_find_firewall_zone() {
	local want="${1:-wan}" z name
	for z in $(uci show firewall 2>/dev/null | grep '=zone$' | cut -d= -f1); do
		name=$(uci -q get "${z}.name")
		[ "$name" = "$want" ] && echo "$z" && return 0
	done
	return 1
}

# uci get zone.network 仅返回列表第一项，必须用 network[i] 遍历
_firewall_zone_has_iface() {
	local z="$1" iface="$2" i=0 n
	while n=$(uci -q get "${z}.network[$i]"); do
		[ "$n" = "$iface" ] && return 0
		i=$((i + 1))
	done
	return 1
}

_firewall_zone_del_iface() {
	local z="$1" iface="$2" i=0 n
	while n=$(uci -q get "${z}.network[$i]"); do
		if [ "$n" = "$iface" ]; then
			uci -q delete "${z}.network[$i]"
			i=0
			continue
		fi
		i=$((i + 1))
	done
}

_firewall_add_vpn_to_wan() {
	local z wan_z name
	wan_z=$(_find_firewall_zone "$firewall_zone") || return 1
	_firewall_zone_has_iface "$wan_z" "$vpname" && return 0
	# 若曾在其它 zone，仅删除该接口项，保留 zone 内原有 network
	for z in $(uci show firewall 2>/dev/null | grep '=zone$' | cut -d= -f1); do
		[ "$z" = "$wan_z" ] && continue
		_firewall_zone_has_iface "$z" "$vpname" || continue
		_firewall_zone_del_iface "$z" "$vpname"
	done
	uci add_list "${wan_z}.network=$vpname"
	uci commit firewall
}

_firewall_del_vpn() {
	local z
	for z in $(uci show firewall 2>/dev/null | grep '=zone$' | cut -d= -f1); do
		_firewall_zone_has_iface "$z" "$vpname" || continue
		_firewall_zone_del_iface "$z" "$vpname"
	done
	uci commit firewall 2>/dev/null
}

_del_wg_peers() {
	local s
	for s in $(uci show network 2>/dev/null | sed -n "s/^\(network\.[^=]*\)=wireguard_${vpname}\$/\1/p"); do
		uci -q delete "$s" 2>/dev/null
	done
}

_del_endpoint_route() {
	local host
	[ -f "$ENDPOINT_HOST_FILE" ] || return 0
	host=$(cat "$ENDPOINT_HOST_FILE" 2>/dev/null)
	[ -n "$host" ] && ip route del "$host" 2>/dev/null
	rm -f "$ENDPOINT_HOST_FILE"
}

_stop_legacy_quagga() {
	[ -x /etc/init.d/quagga ] && /etc/init.d/quagga stop >/dev/null 2>&1
	rm -f /tmp/gogogo_quagga.stamp
}

_setup_rt_table() {
	[ -f "$RT_TABLES_FILE" ] || mkdir -p /etc/iproute2
	grep -qE "^${route_table_id}[[:space:]]+${route_table_name}([[:space:]]|$)" \
		"$RT_TABLES_FILE" 2>/dev/null && return 0
	grep -qE "^${route_table_id}[[:space:]]" "$RT_TABLES_FILE" 2>/dev/null && return 0
	echo "${route_table_id}	${route_table_name}" >> "$RT_TABLES_FILE"
}

_cleanup_legacy_route_guard_rules() {
	# 旧版 pref 50 的 per-host lookup main（会导致策略表前抢路由）
	ip rule del pref 50 lookup main 2>/dev/null
}

_is_reserved_prefix() {
	local p="$1" c
	for c in $route_reserved_cidrs; do
		[ "$p" = "$c" ] && return 0
	done
	return 1
}

# 私网/CGNAT/链路本地钉到 WAN。仅 ip rule lookup main 不够：
# main 里若没有比 VPN 0.0.0.0/1 更长的前缀，10/8 等仍走 GOGOGO。
_is_wan_pin_cidr() {
	case "$1" in
		10.0.0.0/8 | 100.64.0.0/10 | 169.254.0.0/16 | 172.16.0.0/12 | 192.168.0.0/16)
			return 0
			;;
	esac
	return 1
}

_pin_reserved_to_wan() {
	local NET_IF wan_dev gw cidr ok=0
	network_flush_cache 2>/dev/null
	network_find_wan NET_IF || NET_IF="wan"
	network_get_device wan_dev "${NET_IF}"
	[ -z "$wan_dev" ] && network_get_physdev wan_dev "${NET_IF}" 2>/dev/null
	[ -z "$wan_dev" ] && wan_dev=$(uci -q get "network.${NET_IF}.device")
	[ -z "$wan_dev" ] && wan_dev=$(uci -q get "network.${NET_IF}.ifname")
	network_get_gateway gw "${NET_IF}"
	[ -n "$wan_dev" ] || {
		logger -t gogogo "pin reserved: no WAN device"
		return 1
	}
	for cidr in $route_reserved_cidrs; do
		_is_wan_pin_cidr "$cidr" || continue
		if [ -n "$gw" ]; then
			ip route replace "$cidr" via "$gw" dev "$wan_dev" metric "$wan_route_metric" 2>/dev/null || continue
		else
			ip route replace "$cidr" dev "$wan_dev" metric "$wan_route_metric" 2>/dev/null || continue
		fi
		ok=1
	done
	[ "$ok" = 1 ] && logger -t gogogo "pin reserved to WAN dev=$wan_dev gw=${gw:-none}"
	if [ -x /usr/sbin/gogogo-pin-github ]; then
		/usr/sbin/gogogo-pin-github apply >/dev/null 2>&1
		/usr/sbin/gogogo-pin-github refresh >/dev/null 2>&1 &
	fi
}

_ensure_policy_reserved_rules() {
	local cidr
	_cleanup_legacy_route_guard_rules
	for cidr in $route_reserved_cidrs; do
		[ -n "$cidr" ] || continue
		ip rule show pref "$route_reserved_pref" 2>/dev/null | grep -qF "to ${cidr} " && continue
		ip rule add pref "$route_reserved_pref" to "$cidr" lookup main 2>/dev/null
	done
	_pin_reserved_to_wan
}

_del_policy_reserved_rules() {
	local cidr
	for cidr in $route_reserved_cidrs; do
		ip rule del pref "$route_reserved_pref" to "$cidr" lookup main 2>/dev/null
	done
}

_ensure_policy_rule() {
	_setup_rt_table
	_ensure_policy_reserved_rules
	# 删除误用的 iif-only 规则
	while ip rule del pref "$route_rule_pref" iif "$lan_bridge" lookup "$route_table_id" 2>/dev/null; do :; done
	ip rule show pref "$route_rule_pref" 2>/dev/null | grep -qE "from all.*lookup (${route_table_name}|${route_table_id})" && \
		return 0
	ip rule add pref "$route_rule_pref" lookup "$route_table_id" 2>/dev/null
}

_del_policy_rule() {
	while ip rule del pref "$route_rule_pref" lookup "$route_table_id" 2>/dev/null; do :; done
	while ip rule del pref "$route_rule_pref" iif "$lan_bridge" lookup "$route_table_id" 2>/dev/null; do :; done
	_del_policy_reserved_rules
}

_clear_split_routes() {
	ip route flush table "$route_table_id" 2>/dev/null
	rm -f "$SPLIT_ROUTES_STAMP"
}

_route_guard_hosts_list() {
	{
		config_get route_guard_hosts | tr ' ' '\n'
		[ -f "$ROUTE_GUARD_FILE" ] && grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' "$ROUTE_GUARD_FILE"
	} | awk 'NF && !seen[$1]++ { print $1 }'
}

_route_guard_stamp_part() {
	if [ -f "$ROUTE_GUARD_FILE" ]; then
		md5sum "$ROUTE_GUARD_FILE" 2>/dev/null | awk '{print $1}'
	else
		config_get route_guard_hosts | md5sum 2>/dev/null | awk '{print $1}'
	fi
}

# 管理 IP 写入策略表（metric 1 优先于 cpe_chnroutes），并在主表钉 /32，避免 VPN /1 吸走回程
_apply_route_guard_in_table() {
	local gw="$1" wan_dev="$2" ip
	[ -n "$gw" ] || return 0
	[ -n "$wan_dev" ] || return 0
	for ip in $(_route_guard_hosts_list); do
		ip=$(echo "$ip" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
		[ -n "$ip" ] || continue
		ip route replace "${ip}/32" via "$gw" dev "$wan_dev" metric 1 table "$route_table_id" 2>/dev/null
		ip route replace "${ip}/32" via "$gw" dev "$wan_dev" metric "$wan_route_metric" 2>/dev/null
	done
}

_add_route_guard_host() {
	local ip="$1" hosts h found=0
	[ -n "$ip" ] || return 1
	touch "$ROUTE_GUARD_FILE"
	grep -qFx "$ip" "$ROUTE_GUARD_FILE" 2>/dev/null && return 0
	echo "$ip" >> "$ROUTE_GUARD_FILE"
	hosts=$(config_get route_guard_hosts)
	for h in $hosts; do
		[ "$h" = "$ip" ] && found=1 && break
	done
	if [ "$found" = 0 ]; then
		[ -n "$hosts" ] && hosts="$hosts $ip" || hosts="$ip"
		uci set "${CPE_UCI}.@default[0].route_guard_hosts=${hosts}"
		uci commit "$CPE_UCI"
	fi
}

_del_route_guard_host() {
	local ip="$1" hosts new_hosts h
	[ -n "$ip" ] || return 0
	if [ -f "$ROUTE_GUARD_FILE" ]; then
		grep -vFx "$ip" "$ROUTE_GUARD_FILE" > "${ROUTE_GUARD_FILE}.tmp" 2>/dev/null && \
			mv "${ROUTE_GUARD_FILE}.tmp" "$ROUTE_GUARD_FILE"
	fi
	hosts=$(config_get route_guard_hosts)
	new_hosts=""
	for h in $hosts; do
		[ "$h" = "$ip" ] && continue
		new_hosts="$new_hosts $h"
	done
	uci set "${CPE_UCI}.@default[0].route_guard_hosts=${new_hosts# }"
	uci commit "$CPE_UCI"
	if [ -f "$ENDPOINT_HOST_FILE" ] && [ "$(cat "$ENDPOINT_HOST_FILE" 2>/dev/null)" = "$ip" ]; then
		:
	else
		ip route del "${ip}/32" 2>/dev/null
	fi
}

_is_ipv4_host() {
	local ip="$1"
	echo "$ip" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || return 1
	[ "$ip" = "0.0.0.0" ] && return 1
	[ "$ip" = "255.255.255.255" ] && return 1
	return 0
}

_is_loopback_ip() {
	case "$1" in
		127.*) return 0 ;;
	esac
	return 1
}

_own_ipv4_list() {
	ip -4 -o addr show 2>/dev/null | awk '{
		split($4, a, "/")
		if (a[1] ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) print a[1]
	}'
}

_is_own_ip() {
	local ip="$1" a
	for a in $(_own_ipv4_list); do
		[ "$a" = "$ip" ] && return 0
	done
	return 1
}

_is_wg_endpoint_ip() {
	local ip="$1" ep
	[ -n "$server_ip" ] && [ "$ip" = "$server_ip" ] && return 0
	[ -f "$ENDPOINT_HOST_FILE" ] || return 1
	ep=$(cat "$ENDPOINT_HOST_FILE" 2>/dev/null)
	[ -n "$ep" ] && [ "$ip" = "$ep" ]
}

# 本机直连网段（scope link）上的对端不必钉 WAN，否则 /32 via gw 会抢走 LAN 回程
_is_connected_ip() {
	local ip="$1"
	[ -n "$ip" ] || return 1
	ip -4 route show scope link 2>/dev/null | awk -v ip="$ip" '
	function iptoint(s,   a) {
		split(s, a, ".")
		return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4]
	}
	function in_cidr(addr, cidr,   p, n, net, pfx, chunk) {
		n = split(cidr, p, "/")
		net = p[1]
		pfx = (n > 1) ? p[2]+0 : 32
		if (pfx <= 0) return 1
		if (pfx >= 32) return (addr == net)
		chunk = 2^(32-pfx)
		return int(iptoint(addr)/chunk) == int(iptoint(net)/chunk)
	}
	$2 == "dev" && $1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {
		if (in_cidr(ip, $1)) { found=1; exit }
	}
	END { exit found ? 0 : 1 }
	'
}

_emit_mgmt_ip() {
	local ip="$1"
	ip=$(echo "$ip" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
	_is_ipv4_host "$ip" || return 1
	_is_loopback_ip "$ip" && return 1
	_is_own_ip "$ip" && return 1
	_is_wg_endpoint_ip "$ip" && return 1
	_is_connected_ip "$ip" && return 1
	echo "$ip"
}

_hex_le32_to_ip() {
	local h="$1"
	[ ${#h} -eq 8 ] || return 1
	printf '%d.%d.%d.%d\n' \
		"$((0x$(printf '%s' "$h" | cut -c7-8)))" \
		"$((0x$(printf '%s' "$h" | cut -c5-6)))" \
		"$((0x$(printf '%s' "$h" | cut -c3-4)))" \
		"$((0x$(printf '%s' "$h" | cut -c1-2)))"
}

_mgmt_peers_from_ssh_env() {
	[ -n "$SSH_CLIENT" ] && echo "$SSH_CLIENT" | awk '{print $1}'
	[ -n "$SSH_CONNECTION" ] && echo "$SSH_CONNECTION" | awk '{print $1}'
}

_mgmt_peers_from_sockets() {
	{
		command -v ss >/dev/null 2>&1 && ss -tn 2>/dev/null
		command -v netstat >/dev/null 2>&1 && netstat -tn 2>/dev/null
	} | awk '
	BEGIN { ports[22]=1; ports[80]=1; ports[443]=1 }
	$0 !~ /ESTAB/ { next }
	{
		loc=""; rem=""
		for (i = 1; i <= NF; i++) {
			if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+$/) {
				if (loc == "") loc = $i
				else { rem = $i; break }
			}
		}
		if (loc == "" || rem == "") next
		split(loc, l, ":")
		split(rem, r, ":")
		if (l[2] in ports) print r[1]
	}'
}

_mgmt_peers_from_proc_tcp() {
	local loc rem st lport hip
	[ -f /proc/net/tcp ] || return 0
	while read -r _ loc rem st _; do
		[ "$st" = "01" ] || continue
		lport="${loc##*:}"
		case "$lport" in
			0016|0050|01BB|01bb) ;;
			*) continue ;;
		esac
		hip="${rem%%:*}"
		_hex_le32_to_ip "$hip"
	done < /proc/net/tcp
}

_discover_mgmt_peers() {
	{
		_mgmt_peers_from_ssh_env
		_mgmt_peers_from_sockets
		_mgmt_peers_from_proc_tcp
	} | while read -r ip; do
		_emit_mgmt_ip "$ip"
	done | awk 'NF && !seen[$1]++ { print $1 }'
}

_pin_mgmt_host() {
	local ip="$1" gw="$2" wan_dev="$3"
	[ -n "$ip" ] && [ -n "$gw" ] && [ -n "$wan_dev" ] || return 1
	ip route replace "${ip}/32" via "$gw" dev "$wan_dev" metric 1 table "$route_table_id" 2>/dev/null
	ip route replace "${ip}/32" via "$gw" dev "$wan_dev" metric "$wan_route_metric" 2>/dev/null
}

# Connect 前发现 WAN 管理对端。仅 SSH 会话写入 route_guard（持久）；
# 当前 22/80/443 已建立连接只钉临时 /32，避免主循环把任意 WAN TCP 写入 flash。
_protect_mgmt_sessions() {
	local ip gw wan_dev NET_IF
	for ip in $(_mgmt_peers_from_ssh_env | while read -r p; do _emit_mgmt_ip "$p"; done | awk 'NF && !seen[$1]++'); do
		_add_route_guard_host "$ip"
	done
	gw=$(_getwan)
	[ -z "$gw" ] && return 0
	network_find_wan NET_IF
	network_get_device wan_dev "${NET_IF}"
	[ -z "$wan_dev" ] && wan_dev="$NET_IF"
	_apply_route_guard_in_table "$gw" "$wan_dev"
	for ip in $(_discover_mgmt_peers); do
		_pin_mgmt_host "$ip" "$gw" "$wan_dev"
	done
}

_add_endpoint_route() {
	local host="$1" gw wan_dev NET_IF
	[ -z "$host" ] && return 0
	gw=$(_getwan)
	[ -z "$gw" ] && return 1
	network_find_wan NET_IF
	network_get_device wan_dev "${NET_IF}"
	[ -z "$wan_dev" ] && wan_dev="$NET_IF"
	while ip route show "$host" 2>/dev/null | grep -q .; do
		ip route del "$host" 2>/dev/null
	done
	ip route replace "$host" via "$gw" dev "$wan_dev" metric "$wan_route_metric" 2>/dev/null && \
		echo "$host" > "$ENDPOINT_HOST_FILE"
}

_apply_split_routes_policy() {
	local gw="$1" wan_dev="$2" batch="/tmp/gogogo-routes.batch" newstamp cnt prefix
	[ -f "$chnroute_file" ] || return 1
	[ -n "$gw" ] || return 1
	[ -n "$wan_dev" ] || return 1

	_stop_legacy_quagga
	_ensure_policy_rule

	newstamp=$(md5sum "$chnroute_file" 2>/dev/null | awk '{print $1}')
	newstamp="${newstamp}_${gw}_${wan_dev}_$(_route_guard_stamp_part)"
	if [ -f "$SPLIT_ROUTES_STAMP" ] && [ "$(cat "$SPLIT_ROUTES_STAMP" 2>/dev/null)" = "$newstamp" ]; then
		cnt=$(ip route show table "$route_table_id" 2>/dev/null | grep -cE '^[0-9]' || echo 0)
		[ "$cnt" -ge 100 ] && return 0
	fi

	ip route flush table "$route_table_id" 2>/dev/null
	_apply_route_guard_in_table "$gw" "$wan_dev"
	awk -v gw="$gw" -v dev="$wan_dev" -v metric="$route_chn_metric" -v tid="$route_table_id" \
		-v reserved="$route_reserved_cidrs" \
		'function is_reserved(p,    i, n, a) {
			n = split(reserved, a, " ")
			for (i = 1; i <= n; i++)
				if (p == a[i]) return 1
			return 0
		}
		/^[0-9]/ {
			if (is_reserved($1)) next
			printf "route replace %s via %s dev %s metric %s table %s\n", $1, gw, dev, metric, tid
		}' "$chnroute_file" > "$batch"
	ip -batch "$batch" 2>/dev/null || return 1
	_apply_route_guard_in_table "$gw" "$wan_dev"
	echo "$newstamp" > "$SPLIT_ROUTES_STAMP"
}

_refresh_split_routes() {
	local NET_IF NET_GATEWAY NET_DEV
	network_find_wan NET_IF
	network_get_gateway NET_GATEWAY "${NET_IF}"
	network_get_device NET_DEV "${NET_IF}"
	[ -z "$NET_DEV" ] && NET_DEV="$NET_IF"
	[ -n "$NET_GATEWAY" ] && _apply_split_routes_policy "$NET_GATEWAY" "$NET_DEV"
	_protect_mgmt_sessions
}

_del_vpn_routes() {
	ip route del 0.0.0.0/1 dev "$vpname" 2>/dev/null
	ip route del 128.0.0.0/1 dev "$vpname" 2>/dev/null
}

# 握手失效时撤销 VPN 数据面路由（保留接口与 endpoint 路由以便重连）
_teardown_vpn_datapath() {
	_del_vpn_routes
	_clear_split_routes
	_del_policy_rule
}

_add_vpn_routes() {
	ip route show 0.0.0.0/1 2>/dev/null | grep -q "dev $vpname" || \
		ip route add 0.0.0.0/1 dev "$vpname"
	ip route show 128.0.0.0/1 2>/dev/null | grep -q "dev $vpname" || \
		ip route add 128.0.0.0/1 dev "$vpname"
	# /1 会盖住私网；必须在其后写入更长前缀到 WAN
	_pin_reserved_to_wan
}

_deletevpn() {
	public_key=""
	server_ip=""
	_invalidate_exit_ip

	# 已拆除时不要每轮 restart dnsmasq
	if ! ip link show "$vpname" >/dev/null 2>&1 && [ ! -f "$DNSMASQ_DROPIN" ]; then
		_del_vpn_routes
		_clear_split_routes
		_del_policy_rule
		_del_endpoint_route
		_pin_reserved_to_wan
		return 0
	fi

	iptables -t nat -D PREROUTING -i "$lan_bridge" -p udp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null
	iptables -t nat -D PREROUTING -i "$lan_bridge" -p tcp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null

	rm -f "$DNSMASQ_DROPIN" "$DNSMASQ_DROPIN_LEGACY" "$DNSMASQ_APPLIED_STAMP" \
		/tmp/gogogo_dns_mode /tmp/dnsmasq.gogogo.conf 2>/dev/null
	_dnsmasq_cleanup_legacy
	/etc/init.d/dnsmasq restart 2>/dev/null

	_del_vpn_routes
	_clear_split_routes
	_del_policy_rule
	_pin_reserved_to_wan
	_stop_legacy_quagga
	_del_endpoint_route
	_firewall_del_vpn
	_del_wg_peers
	uci -q del "network.${vpname}" 2>/dev/null
	uci commit network 2>/dev/null
	ip link del "$vpname" 2>/dev/null
}

_load_peer_pubkey() {
	public_key=$(uci -q get "network.@wireguard_${vpname}[-1].public_key" 2>/dev/null)
}

_ensure_server_ip() {
	[ -n "$server_ip" ] && return 0
	[ -f "$ENDPOINT_HOST_FILE" ] && server_ip=$(cat "$ENDPOINT_HOST_FILE" 2>/dev/null)
	[ -n "$server_ip" ] && return 0
	server_ip=$(uci -q get "network.@wireguard_${vpname}[-1].endpoint_host" 2>/dev/null)
}

_vpn_handshake_ok() {
	[ "$(_checkvpnstatus)" = "0" ]
}

_wait_vpn_handshake() {
	local i=0 max="${1:-15}"
	while [ "$i" -lt "$max" ]; do
		_vpn_handshake_ok && return 0
		sleep 1
		i=$((i + 1))
	done
	return 1
}

_json_select_server() {
	json_select server
	json_get_var ip ip
	json_get_var port port
	json_get_var public_key public_key
	json_get_var dns_server dns_server
	if [ -z "$ip" ]; then
		json_select 1
		json_get_var ip ip
		json_get_var port port
		json_get_var public_key public_key
		json_get_var dns_server dns_server
	fi
	if [ -n "$dns_server" ]; then
		dns_remote_1="$dns_server"
		uci set "${CPE_UCI}.@default[0].dns_server=${dns_server}"
		uci commit "$CPE_UCI"
	fi
	[ -z "$port" ] && port="$wg_endpoint_port"
	json_select ..
	json_select ..
}

# 只看响应体 code，不看 HTTP 状态。过期样本是 code 412 + msg「账号已到期」。
_api_account_expired() {
	local info="$1" code msg
	echo "$info" | jq -e 'type == "object"' >/dev/null 2>&1 || return 1
	code=$(echo "$info" | jq -r '.code // empty' 2>/dev/null)
	[ "$code" = "200" ] && return 1
	[ "$code" = "412" ] && return 0
	msg=$(echo "$info" | jq -r '.message // .msg // .error // empty' 2>/dev/null)
	echo "$msg" | grep -qiE '过期|到期|expir' && return 0
	return 1
}

_clear_account_expired_hold() {
	rm -f "$ACCOUNT_EXPIRED_STAMP"
}

# 拆隧道、写页面文案，并停掉主循环里的重复登录请求
_mark_account_expired() {
	if [ "$(config_get description)" != "$ACCOUNT_EXPIRED_MSG" ]; then
		uci set "${CPE_UCI}.@default[0].description=${ACCOUNT_EXPIRED_MSG}"
	fi
	uci set "${CPE_UCI}.@default[0].login_status=0"
	uci commit "$CPE_UCI"
	_status_dir
	echo 1 > "$ACCOUNT_EXPIRED_STAMP"
	rm -f "$API_HASH_FILE"
	_deletevpn
}

_connectvpn() {
	. /usr/share/libubox/jshn.sh
	json_load "$connect_info" || return 1

	json_get_var code code
	if [ "$code" != "200" ]; then
		if _api_account_expired "$connect_info"; then
			_mark_account_expired
			return 1
		fi
		# 防 IP 泄露：通讯异常时不拆除已有 VPN
		echo "Error: API code $code"
		return 1
	fi

	json_get_var private_key private_key
	json_get_var private_ip private_ip
	json_get_var login_status login_status

	if [ "$login_status" != "0" ] && [ -n "$login_status" ]; then
		uci set "${CPE_UCI}.@default[0].login_status=$login_status"
		uci commit "$CPE_UCI"
		_deletevpn
		return 1
	fi
	uci set "${CPE_UCI}.@default[0].login_status=0"
	uci commit "$CPE_UCI"
	_invalidate_exit_ip

	_json_select_server
	server_ip="$ip"

	[ -z "$ip" ] || [ -z "$public_key" ] && {
		_deletevpn
		return 1
	}

	uci set "network.${vpname}=interface"
	uci set "network.${vpname}.proto=wireguard"

	local_private_key=$(uci -q get "network.${vpname}.private_key")
	[ "$local_private_key" != "$private_key" ] && {
		_uci_set "network.${vpname}.private_key" "$private_key"
		uci set "${CPE_UCI}.@default[0].update_info=1"
	}

	local_ip=$(uci -q get "network.${vpname}.addresses" 2>/dev/null | head -n1)
	local_ip_expected="${private_ip}/32"
	if [ -z "$local_ip" ]; then
		uci add_list "network.${vpname}.addresses=${local_ip_expected}"
		uci set "${CPE_UCI}.@default[0].update_info=1"
	elif [ "$local_ip" != "$local_ip_expected" ]; then
		uci -q delete "network.${vpname}.addresses" 2>/dev/null
		uci add_list "network.${vpname}.addresses=${local_ip_expected}"
		uci set "${CPE_UCI}.@default[0].update_info=1"
	fi

	uci set "network.${vpname}.defaultroute=0"
	uci set "network.${vpname}.peerdns=0"
	uci set "network.${vpname}.delegate=0"

	_del_wg_peers
	uci add network "wireguard_${vpname}"
	_uci_set "network.@wireguard_${vpname}[-1].description" "gogogo-peer"
	_uci_set "network.@wireguard_${vpname}[-1].public_key" "$public_key"
	uci add_list "network.@wireguard_${vpname}[-1].allowed_ips=0.0.0.0/0"
	_uci_set "network.@wireguard_${vpname}[-1].endpoint_host" "$ip"
	_uci_set "network.@wireguard_${vpname}[-1].endpoint_port" "$port"
	uci set "network.@wireguard_${vpname}[-1].persistent_keepalive=${wg_keepalive}"
	uci commit network

	_firewall_add_vpn_to_wan

	if ! ip link show "$vpname" >/dev/null 2>&1; then
		ifup "$vpname"
	fi

	vpn_update_info=$(config_get update_info)
	if [ "$vpn_update_info" = "1" ]; then
		ifup "$vpname"
		uci set "${CPE_UCI}.@default[0].update_info=0"
		uci commit "$CPE_UCI"
	fi

	_wait_vpn_handshake 15
	_checkroute
	_checkdns
}

# API/连接异常时：已有 VPN 则保留并维护路由/DNS，否则才拆除
_preserve_or_delete_vpn() {
	if ip link show "$vpname" >/dev/null 2>&1; then
		_ensure_server_ip
		_load_peer_pubkey
		_checkroute
		_checkdns
		return 0
	fi
	_deletevpn
	return 1
}

# API 瞬断保活：仅 server/WG 相关字段变化才触发重连
_api_server_hash() {
	echo "$1" | jq -c '{
		private_key: (.private_key // ""),
		private_ip: (.private_ip // ""),
		ip: (.server.ip // .server[0].ip // ""),
		port: (.server.port // .server[0].port // ""),
		public_key: (.server.public_key // .server[0].public_key // ""),
		dns: (.server.dns_server // .server[0].dns_server // "")
	}' 2>/dev/null | md5sum | awk '{print $1}'
}

_check_config() {
	public_key=""
	enable=$(config_get enable)
	username=$(config_get username)
	password=$(config_get password)

	if [ "$enable" != "1" ] || [ -z "$username" ] || [ -z "$password" ]; then
		_deletevpn
		return 0
	fi

	# 服务器已判定过期：保持拆除，不再请求 /api/v1/user
	if [ -f "$ACCOUNT_EXPIRED_STAMP" ]; then
		_deletevpn
		return 0
	fi

	connect_info=$(_get_connect_info)
	if ! echo "$connect_info" | jq . >/dev/null 2>&1; then
		echo "Error: invalid API response (keep VPN if up)"
		_preserve_or_delete_vpn
		return 1
	fi

	if _api_account_expired "$connect_info"; then
		_mark_account_expired
		return 0
	fi

	# VPN 已通但 LuCI 仍显示旧「账号有误」时，主循环校验成功则刷新到期时间
	_update_account_description "$connect_info" 0

	_load_peer_pubkey
	newhash=$(_api_server_hash "$connect_info")
	[ -z "$newhash" ] && newhash=$(echo "$connect_info" | md5sum | awk '{print $1}')
	oldhash=$(cat "$API_HASH_FILE" 2>/dev/null)
	if [ "$newhash" = "$oldhash" ] && ip link show "$vpname" >/dev/null 2>&1; then
		_ensure_server_ip
		_load_peer_pubkey
		_checkroute
		_checkdns
		return 0
	fi

	if _connectvpn; then
		echo "$newhash" > "$API_HASH_FILE"
	else
		_preserve_or_delete_vpn
		return 1
	fi
}

_checkvpnstatus() {
	local age now handshakes
	_load_peer_pubkey
	[ -z "$public_key" ] && echo 1 && return
	ip link show "$vpname" >/dev/null 2>&1 || { echo 1; return; }

	handshakes=$(wg show "$vpname" latest-handshakes 2>/dev/null | awk -v pk="$public_key" '$1==pk {print $2}')
	[ -z "$handshakes" ] || [ "$handshakes" = "0" ] && { echo 1; return; }

	now=$(date +%s)
	age=$((now - handshakes))
	[ "$age" -gt "$wg_hs_max_age" ] && echo 1 || echo 0
}

_checkroute() {
	local vpnstatus NET_IF NET_GATEWAY NET_DEV

	_ensure_server_ip
	_load_peer_pubkey

	network_find_wan NET_IF
	network_get_gateway NET_GATEWAY "${NET_IF}"
	network_get_device NET_DEV "${NET_IF}"
	[ -z "$NET_DEV" ] && NET_DEV="$NET_IF"
	[ -n "$server_ip" ] && _add_endpoint_route "$server_ip"

	vpnstatus=$(_checkvpnstatus)
	if [ "$vpnstatus" = "1" ]; then
		# 握手未就绪/已过期：撤 VPN 路由与策略表（与 bootstrap DNS 一致），保留 endpoint
		_teardown_vpn_datapath
		_pin_reserved_to_wan
		return
	fi

	if _uci_flag tunnelall; then
		_clear_split_routes
		_stop_legacy_quagga
	else
		_apply_split_routes_policy "$NET_GATEWAY" "$NET_DEV"
	fi

	_protect_mgmt_sessions
	_add_vpn_routes
	_pin_reserved_to_wan
}

_dns_mode_tag() {
	echo "${1:-0}:${2:-0}"
}

_vpn_dns_iface() {
	ip link show "$vpname" >/dev/null 2>&1 || return 1
	ip -4 addr show "$vpname" 2>/dev/null | grep -q 'inet ' || return 1
	echo "$vpname"
}

_wan_dns_iface() {
	local NET_IF d
	network_find_wan NET_IF || NET_IF="wan"
	network_get_device d "${NET_IF}"
	[ -z "$d" ] && network_get_physdev d "${NET_IF}" 2>/dev/null
	[ -z "$d" ] && d=$(uci -q get "network.${NET_IF}.device")
	[ -z "$d" ] && d=$(uci -q get "network.${NET_IF}.ifname")
	[ -n "$d" ] && echo "$d"
}

# $1=ip $2=iface（仅注释用，不写入 server=） $3=trust(0/1，空则不加)
# server= 不加 @iface：接口绑定会让内网 DNS / 分流失效。
_dns_server_line() {
	local ip="$1" trust="$3"
	[ -n "$ip" ] || return 1
	if [ -n "$trust" ]; then
		echo "server=${ip},${trust}"
	else
		echo "server=${ip}"
	fi
}

# 分流国内上游：WAN 全部 DNS；没有可用的才回退单台 180.76.76.76
_wan_direct_dns_list() {
	local dns had=0
	for dns in $(wan_dns_direct 2>/dev/null); do
		[ -n "$dns" ] || continue
		[ -n "$dns_remote_1" ] && [ "$dns" = "$dns_remote_1" ] && continue
		echo "$dns"
		had=1
	done
	[ "$had" = 1 ] || echo "${dns_local_1:-${dns_local_fallback:-180.76.76.76}}"
}

_write_dnsmasq_conf() {
	local tunnelall="$1" dnsleak="$2" tmp="/tmp/dnsmasq.gogogo.conf"
	local wan_dev vpn_dev dns locals
	wan_dev=$(_wan_dns_iface)
	vpn_dev=$(_vpn_dns_iface)
	locals=$(_wan_direct_dns_list)
	# 全局：全部走 API 海外 DNS（必须从 VPN 口出去，走 WAN 会被 GFW 污染）。
	# 分流：国内 WAN 口全部 DNS / 海外 API DNS + chnroutes-file。
	# 防 DNS 泄露不能改成全局写法，否则国内域名被解析成海外 IP，策略分流失效。
	if [ "$tunnelall" = "1" ]; then
		{
			echo "# gogogo-vpn: remote-only (api dns via VPN)"
			echo "no-resolv"
			echo "all-servers"
			if [ -n "$vpn_dev" ]; then
				_dns_server_line "$dns_remote_1" "$vpn_dev"
			else
				# 隧道未就绪：不要把 8.8.8.8 打到 WAN
				logger -t gogogo "dnsmasq: VPN down, skip overseas DNS via WAN"
			fi
		} >"$tmp"
	else
		if ! _dnsmasq_supports_split; then
			logger -t gogogo "dnsmasq lacks chnroutes-file; install dnsmasq with chnroutes support for split DNS"
		fi
		if [ ! -f "$chnroute_file" ]; then
			logger -t gogogo "chnroutes file missing ($chnroute_file); split DNS may not classify China/foreign"
		fi
		{
			echo "# gogogo-vpn: split (国内 $(echo $locals)@${wan_dev:-wan} / 海外 ${dns_remote_1}@${vpn_dev:-vpn})"
			echo "no-resolv"
			echo "all-servers"
			for dns in $locals; do
				_dns_server_line "$dns" "" 0
			done
			if [ -n "$vpn_dev" ]; then
				_dns_server_line "$dns_remote_1" "$vpn_dev" 1
			else
				logger -t gogogo "dnsmasq: VPN down, overseas DNS not bound to WAN (pollution)"
			fi
			[ -f "$chnroute_file" ] && echo "chnroutes-file=${chnroute_file}"
		} >"$tmp"
	fi
	echo "$tmp"
}

_dnsmasq_cleanup_legacy() {
	rm -f "$DNSMASQ_DROPIN_LEGACY" 2>/dev/null
	[ -f /etc/dnsmasq.conf ] || return 0
	grep -q 'conf-dir=/etc/dnsmasq.d' /etc/dnsmasq.conf 2>/dev/null || return 0
	sed -i '/^conf-dir=\/etc\/dnsmasq\.d$/d' /etc/dnsmasq.conf 2>/dev/null
}

_dnsmasq_allow_private_answers() {
	# OpenWrt 默认 --stop-dns-rebind 会丢弃完整保留段（含 RFC1918）应答
	[ "$(uci -q get dhcp.@dnsmasq[0].rebind_protection)" = "0" ] && return 0
	uci -q set dhcp.@dnsmasq[0].rebind_protection=0
	uci commit dhcp
	return 1
}

_dnsmasq_drop_conflict_dropins() {
	local f n=0
	# 主题 WAN DNS 的无标记 server= 会绕过 chnroutes 过滤
	for f in /tmp/dnsmasq.d/nros-wan-dns.conf /etc/dnsmasq.d/nros-wan-dns.conf; do
		[ -f "$f" ] || continue
		rm -f "$f"
		n=1
	done
	rm -f /tmp/dnsmasq.gogogo.conf /tmp/dnsmasq.gogogo.bootstrap.conf
	[ "$n" = 1 ]
}

_dnsmasq_apply() {
	# OpenWrt dnsmasq reload(SIGHUP) 不重读 conf-dir=/tmp/dnsmasq.d，须 restart
	/etc/init.d/dnsmasq restart 2>/dev/null
}

_dnsmasq_supports_split() {
	dnsmasq --help 2>&1 | grep -q chnroutes
}

_apply_dnsmasq() {
	local tunnelall="$1" dnsleak="$2" tmpconf tmp_md5 dns_md5 last_mode mode_tag applied_md5 dropped=0
	mkdir -p /tmp/dnsmasq.d
	_dnsmasq_cleanup_legacy
	_dnsmasq_allow_private_answers || dropped=1
	_dnsmasq_drop_conflict_dropins && dropped=1
	tmpconf=$(_write_dnsmasq_conf "$tunnelall" "$dnsleak")
	tmp_md5=$(md5sum "$tmpconf" | awk '{print $1}')
	dns_md5=$(md5sum "$DNSMASQ_DROPIN" 2>/dev/null | awk '{print $1}')
	applied_md5=$(cat "$DNSMASQ_APPLIED_STAMP" 2>/dev/null)
	last_mode=$(cat /tmp/gogogo_dns_mode 2>/dev/null)
	mode_tag=$(_dns_mode_tag "$tunnelall" "$dnsleak")
	if [ "$dropped" = 1 ] || [ "$tmp_md5" != "$dns_md5" ] || [ "$last_mode" != "$mode_tag" ] || [ "$tmp_md5" != "$applied_md5" ]; then
		cp "$tmpconf" "$DNSMASQ_DROPIN"
		rm -f "$tmpconf"
		echo "$mode_tag" > /tmp/gogogo_dns_mode
		_dnsmasq_apply
		echo "$tmp_md5" > "$DNSMASQ_APPLIED_STAMP"
	else
		rm -f "$tmpconf"
	fi
}

_apply_dnsmasq_bootstrap() {
	local tmp="/tmp/dnsmasq.gogogo.bootstrap.conf" tmp_md5 dns_md5 applied_md5 wan_dev dropped=0
	mkdir -p /tmp/dnsmasq.d
	_dnsmasq_cleanup_legacy
	_dnsmasq_allow_private_answers || dropped=1
	_dnsmasq_drop_conflict_dropins && dropped=1
	_probe_dns_local_1
	wan_dev=$(_wan_dns_iface)
	{
		echo "# gogogo-vpn: bootstrap (wg handshake pending, no chnroutes split)"
		echo "no-resolv"
		echo "all-servers"
		for dns in $(_wan_direct_dns_list); do
			_dns_server_line "$dns" "$wan_dev"
		done
	} >"$tmp"
	tmp_md5=$(md5sum "$tmp" 2>/dev/null | awk '{print $1}')
	dns_md5=$(md5sum "$DNSMASQ_DROPIN" 2>/dev/null | awk '{print $1}')
	applied_md5=$(cat "$DNSMASQ_APPLIED_STAMP" 2>/dev/null)
	if [ "$dropped" = 1 ] || [ "$tmp_md5" != "$dns_md5" ] || [ "$(cat /tmp/gogogo_dns_mode 2>/dev/null)" != "bootstrap:0:0" ] || \
		[ "$tmp_md5" != "$applied_md5" ]; then
		cp "$tmp" "$DNSMASQ_DROPIN"
		rm -f "$tmp"
		echo "bootstrap:0:0" > /tmp/gogogo_dns_mode
		_dnsmasq_apply
		echo "$tmp_md5" > "$DNSMASQ_APPLIED_STAMP"
	else
		rm -f "$tmp"
	fi
}

_refresh_dnsmasq() {
	local tunnelall=0 dnsleak=0
	_load_api_dns
	_probe_dns_local_1
	_uci_flag tunnelall && tunnelall=1
	_uci_flag dnsleak && dnsleak=1
	_apply_dnsmasq "$tunnelall" "$dnsleak"
}

_dnsmasq_clear_vpn() {
	local had=0
	[ -f "$DNSMASQ_DROPIN" ] && had=1
	command -v iptables >/dev/null 2>&1 && {
		while iptables -t nat -D PREROUTING -i "$lan_bridge" -p udp --dport 53 \
			-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null; do :; done
		while iptables -t nat -D PREROUTING -i "$lan_bridge" -p tcp --dport 53 \
			-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null; do :; done
	}
	[ "$had" = 1 ] || [ -f "$DNSMASQ_APPLIED_STAMP" ] || return 0
	rm -f "$DNSMASQ_DROPIN" "$DNSMASQ_DROPIN_LEGACY" "$DNSMASQ_APPLIED_STAMP" \
		/tmp/gogogo_dns_mode /tmp/dnsmasq.gogogo.conf /tmp/dnsmasq.gogogo.bootstrap.conf 2>/dev/null
	_dnsmasq_cleanup_legacy
	if [ -x /usr/sbin/nros-dns-apply ]; then
		/usr/sbin/nros-dns-apply
	elif [ "$had" = 1 ]; then
		/etc/init.d/dnsmasq restart 2>/dev/null
	fi
}

_checkdns() {
	# 未启用加速：不要留下 bootstrap/全局 dropin（WAN DNS 变更会走 refresh_routes）
	if ! _uci_flag enable; then
		_dnsmasq_clear_vpn
		return 0
	fi
	# 分流不依赖 VPN 握手：始终写 chnroutes。握手失败时若走 bootstrap（仅 WAN DNS），
	# 看起来像全局，WAN ifupdate / 保存后会被误判成「分流被改成全局」。
	if _uci_flag tunnelall && ! _vpn_handshake_ok; then
		_apply_dnsmasq_bootstrap
	else
		_refresh_dnsmasq
	fi
	_dnshijack
}

_dnshijack() {
	command -v iptables >/dev/null 2>&1 || return 0
	while iptables -t nat -D PREROUTING -i "$lan_bridge" -p udp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null; do :; done
	while iptables -t nat -D PREROUTING -i "$lan_bridge" -p tcp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack" 2>/dev/null; do :; done
	iptables -t nat -A PREROUTING -i "$lan_bridge" -p udp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack"
	iptables -t nat -A PREROUTING -i "$lan_bridge" -p tcp --dport 53 \
		-j DNAT --to "$dns_hijack_to" -m comment --comment "DNS-Hijack"
}

_getwan() {
	local NET_IF NET_GATEWAY
	network_find_wan NET_IF
	network_get_gateway NET_GATEWAY "${NET_IF}"
	echo "$NET_GATEWAY"
}

# 出厂和在线升级都把仍停在 UTC 的设备改成东八区。已经选过其他时区的不改。
_apply_default_timezone() {
	local tz
	tz=$(uci -q get system.@system[0].timezone)
	case "$tz" in
		""|UTC|GMT|GMT0|UTC0)
			uci set system.@system[0].timezone='CST-8'
			uci set system.@system[0].zonename='Asia/Shanghai'
			uci commit system
			mkdir -p /tmp
			echo 'CST-8' > /tmp/TZ
			ln -sf /tmp/TZ /etc/TZ
			if [ -e /usr/share/zoneinfo/Asia/Shanghai ]; then
				ln -sf /usr/share/zoneinfo/Asia/Shanghai /tmp/localtime
				ln -sf /tmp/localtime /etc/localtime
			fi
			;;
	esac
}

# 到期时间按东八区显示，和后台一致。POSIX 的 CST-8 表示 UTC+8。
_format_expired_time() {
	local ts="$1"
	[ -n "$ts" ] || return 0
	TZ=CST-8 date -d "@$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null ||
		TZ=CST-8 date -r "$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null ||
		echo "$ts"
}

# $2=1 时失败也写入提示（LuCI 保存）；=0 时仅成功刷新（主循环，避免 API 瞬断覆盖正确提示）
_update_account_description() {
	local connect_info="$1" force_err="${2:-0}"
	local code login_status expired_time new_desc old_desc err_msg

	err_msg="账号信息有误。请核对后重新填写。"

	if ! echo "$connect_info" | jq -e . >/dev/null 2>&1; then
		if [ "$force_err" = 1 ]; then
			uci set "${CPE_UCI}.@default[0].description=连接信息获取失败."
			uci commit "$CPE_UCI"
		fi
		return 1
	fi

	code=$(echo "$connect_info" | jq -r '.code // empty' 2>/dev/null)
	login_status=$(echo "$connect_info" | jq -r '
		if .login_status == null then "0"
		else (.login_status | tostring) end' 2>/dev/null)
	expired_time=$(echo "$connect_info" | jq -r '.expired_time // empty' 2>/dev/null)

	if _api_account_expired "$connect_info"; then
		old_desc=$(config_get description)
		if [ "$old_desc" != "$ACCOUNT_EXPIRED_MSG" ]; then
			uci set "${CPE_UCI}.@default[0].description=${ACCOUNT_EXPIRED_MSG}"
			uci commit "$CPE_UCI"
		fi
		return 1
	fi

	if [ "$code" = "200" ] && { [ "$login_status" = "0" ] || [ -z "$login_status" ]; }; then
		_clear_account_expired_hold
		expired_time=$(_format_expired_time "$expired_time")
		new_desc="到期时间：${expired_time}"
		old_desc=$(config_get description)
		[ "$old_desc" = "$new_desc" ] && return 0
		uci set "${CPE_UCI}.@default[0].description=${new_desc}"
		uci commit "$CPE_UCI"
		return 0
	fi

	if [ "$force_err" = 1 ]; then
		uci set "${CPE_UCI}.@default[0].description=${err_msg}"
		uci commit "$CPE_UCI"
	fi
	return 1
}

_refresh_account_json() {
	local status="$1" description="$2"
	command -v jq >/dev/null 2>&1 || {
		printf '{"status":"%s","description":"%s"}\n' "$status" "$description"
		return 0
	}
	jq -n --arg status "$status" --arg description "$description" \
		'{status: $status, description: $description}'
}

# 手动刷新账号/到期提示：flock 单实例 + 60s 内仅一次有效 API 请求
_refresh_account() {
	local now last desc rc
	[ -x /usr/sbin/gogogo-cred ] && /usr/sbin/gogogo-cred sync 2>/dev/null
	(
		flock -n 9 || {
			desc=$(config_get description)
			_refresh_account_json "busy" "$desc"
			exit 0
		}
		now=$(date +%s)
		last=$(cat "$ACCOUNT_REFRESH_RATE_STAMP" 2>/dev/null)
		if [ -n "$last" ] && [ "$((now - last))" -lt "$ACCOUNT_REFRESH_MIN_SEC" ]; then
			desc=$(config_get description)
			_refresh_account_json "rate_limited" "$desc"
			exit 0
		fi
		echo "$now" > "$ACCOUNT_REFRESH_RATE_STAMP"
		_clear_account_expired_hold
		if _check_user_core; then
			desc=$(config_get description)
			_refresh_account_json "ok" "$desc"
		else
			rc=$?
			desc=$(config_get description)
			if [ "$rc" = 2 ]; then
				_refresh_account_json "hint" "$desc"
			else
				_refresh_account_json "error" "$desc"
			fi
		fi
	) 9>"$ACCOUNT_REFRESH_LOCK"
}

_check_user_core() {
	local saved_timeout

	_apply_uci_overrides
	username=$(config_get username)
	password=$(config_get password)
	if [ -z "$username" ] && [ -z "$password" ]; then
		uci set "${CPE_UCI}.@default[0].description=请填写账号和密码后使用。"
		uci commit "$CPE_UCI"
		return 2
	fi
	if [ -z "$username" ]; then
		uci set "${CPE_UCI}.@default[0].description=请填写账号后使用。"
		uci commit "$CPE_UCI"
		return 2
	fi
	if [ -z "$password" ]; then
		uci set "${CPE_UCI}.@default[0].description=请填写密码后使用。"
		uci commit "$CPE_UCI"
		return 2
	fi
	saved_timeout="$curl_timeout"
	curl_timeout=10
	connect_info=$(_get_connect_info)
	curl_timeout="$saved_timeout"
	if _api_account_expired "$connect_info"; then
		_mark_account_expired
		return 1
	fi
	_update_account_description "$connect_info" 1 && return 0
	return 1
}

_check_user() {
	_check_user_core || exit 1
}

# LuCI 诊断：输出 JSON，不暴露 API 地址；账号失败统一提示，不区分密码错误
_account_diag_emit() {
	command -v jq >/dev/null 2>&1 || {
		printf '{"status":"api_error","message":"API服务器通讯异常"}\n'
		return 0
	}
	jq -n --arg status "$1" --arg message "$2" \
		'{status: $status, message: $message}'
}

_account_diag() {
	local connect_info code login_status expired_time msg et saved_timeout

	[ -x /usr/sbin/gogogo-cred ] && /usr/sbin/gogogo-cred sync 2>/dev/null
	_apply_uci_overrides
	username=$(config_get username)
	password=$(config_get password)

	if [ -z "$username" ] && [ -z "$password" ]; then
		_account_diag_emit hint "请填写账号和密码后使用。"
		return 0
	fi
	if [ -z "$username" ]; then
		_account_diag_emit hint "请填写账号后使用。"
		return 0
	fi
	if [ -z "$password" ]; then
		_account_diag_emit hint "请填写密码后使用。"
		return 0
	fi

	if ! command -v jq >/dev/null 2>&1; then
		_account_diag_emit api_error "API服务器通讯异常"
		return 0
	fi

	saved_timeout="$curl_timeout"
	if [ -n "$CPE_DIAG_CURL_TIMEOUT" ]; then
		curl_timeout="$CPE_DIAG_CURL_TIMEOUT"
	else
		curl_timeout=10
	fi
	connect_info=$(_get_connect_info)
	curl_timeout="$saved_timeout"
	unset CPE_DIAG_CURL_TIMEOUT

	if [ -z "$connect_info" ] || ! echo "$connect_info" | jq -e . >/dev/null 2>&1; then
		_update_account_description "$connect_info" 1
		_account_diag_emit api_error "API服务器通讯异常"
		return 0
	fi

	code=$(echo "$connect_info" | jq -r '.code // empty' 2>/dev/null)
	login_status=$(echo "$connect_info" | jq -r '
		if .login_status == null then "0"
		else (.login_status | tostring) end' 2>/dev/null)
	expired_time=$(echo "$connect_info" | jq -r '.expired_time // empty' 2>/dev/null)
	msg=$(echo "$connect_info" | jq -r '.message // .msg // .error // empty' 2>/dev/null)

	if _api_account_expired "$connect_info"; then
		_mark_account_expired
		_account_diag_emit expired "$ACCOUNT_EXPIRED_MSG"
		return 0
	fi

	if [ "$code" != "200" ]; then
		_update_account_description "$connect_info" 1
		case "$code" in
			408|502|503|504|000|"")
				_account_diag_emit api_error "API服务器通讯异常"
				return 0
				;;
		esac
		if [ -n "$login_status" ] && [ "$login_status" != "0" ]; then
			case "$login_status" in
				2 | 3) _account_diag_emit expired "账号已过期，请续费后重试" ;;
				*)
					if echo "$msg" | grep -qiE '过期|到期|expir'; then
						_account_diag_emit expired "账号已过期，请续费后重试"
					else
						_account_diag_emit account_error "账号信息有误，请核对后重新填写"
					fi
					;;
			esac
			return 0
		fi
		if echo "$msg" | grep -qiE '过期|到期|expir'; then
			_account_diag_emit expired "账号已过期，请续费后重试"
			return 0
		fi
		if echo "$msg" | grep -qiE '账号|密码|account|auth|login|invalid|denied'; then
			_account_diag_emit account_error "账号信息有误，请核对后重新填写"
			return 0
		fi
		case "$code" in
			5*|6*) _account_diag_emit api_error "API服务器通讯异常" ;;
			*) _account_diag_emit account_error "账号信息有误，请核对后重新填写" ;;
		esac
		return 0
	fi

	if [ "$login_status" != "0" ] && [ -n "$login_status" ]; then
		_update_account_description "$connect_info" 1
		case "$login_status" in
			2 | 3)
				_account_diag_emit expired "账号已过期，请续费后重试"
				;;
			*)
				if echo "$msg" | grep -qiE '过期|到期|expir'; then
					_account_diag_emit expired "账号已过期，请续费后重试"
				else
					_account_diag_emit account_error "账号信息有误，请核对后重新填写"
				fi
				;;
		esac
		return 0
	fi

	_update_account_description "$connect_info" 1
	et=$(_format_expired_time "$expired_time")
	if [ -n "$et" ]; then
		_account_diag_emit ok "账号有效，到期时间：${et}"
	else
		_account_diag_emit ok "账号鉴权通过"
	fi
}

_update_chnroutes_once() {
	[ -x /usr/sbin/gogogo-chnroutes-update ] && /usr/sbin/gogogo-chnroutes-update force
}

_status_dir() {
	mkdir -p /var/run/gogogo
}

_portal_cookie_init() {
	_status_dir
	touch "$PORTAL_CJ"
	chmod 0600 "$PORTAL_CJ"
}

_portal_drop() {
	rm -f "$PORTAL_CJ" "$PORTAL_CAPTCHA" "$PORTAL_USER_HTML" "$PORTAL_BOUND" "$PORTAL_PAY_FORM"
}

_uci_clear_exit() {
	uci -q delete "${CPE_UCI}.@default[0].two_ways"
	uci -q delete "${CPE_UCI}.@default[0].direction"
	uci -q delete "${CPE_UCI}.@default[0].product_id"
	uci -q delete "${CPE_UCI}.@default[0].product_name"
	uci commit "$CPE_UCI"
}

_reset_exit_state() {
	_status_dir
	_portal_drop
	_uci_clear_exit
	_clear_account_expired_hold
	rm -f "$API_HASH_FILE"
	uci set "${CPE_UCI}.@default[0].last_username=$(config_get username)"
	uci set "${CPE_UCI}.@default[0].update_info=1"
	uci commit "$CPE_UCI"
}

_reset_exit_cmd() {
	_apply_uci_overrides
	_reset_exit_state
	_deletevpn
	_write_status >/dev/null
	printf '{"ok":true,"msg":"已清空上一账号线路与套餐缓存"}\n'
}

_maybe_reset_on_account_change() {
	local cur last
	cur=$(config_get username)
	last=$(config_get last_username)
	[ -n "$cur" ] || return 0
	[ "$cur" = "$last" ] && return 0
	_reset_exit_state
	# 有上一账号才拆隧道；仅缺 last_username（升级后首次）只清线路缓存
	[ -n "$last" ] && _deletevpn
}

# 网站门户请求（与 CPE /api/v1/user 不是同一套登录）
_portal_curl() {
	local url="$1" max_time
	shift
	_portal_cookie_init
	[ -n "$portal_url" ] || portal_url="${CPE_PORTAL_URL:-https://www.gogogofuture.com}"
	max_time=$((portal_timeout + 5))
	gogogo_curl "$url" \
		--silent --show-error \
		--connect-timeout "${portal_timeout:-10}" --max-time "$max_time" \
		-A "$PORTAL_UA" \
		-b "$PORTAL_CJ" -c "$PORTAL_CJ" \
		"$@"
	chmod 0600 "$PORTAL_CJ" 2>/dev/null || true
}

_direction_json() {
	local ok="$1" two_ways="$2" direction="$3" need_captcha="$4" logged_in="$5" msg="$6" reconnect="${7:-false}"
	command -v jq >/dev/null 2>&1 || {
		printf '{"ok":%s,"two_ways":%s,"direction":"%s","need_captcha":%s,"logged_in":%s,"msg":"%s","reconnect":%s}\n' \
			"$ok" "$two_ways" "$(_json_escape "$direction")" "$need_captcha" "$logged_in" "$(_json_escape "$msg")" "$reconnect"
		return 0
	}
	jq -n \
		--argjson ok "$ok" \
		--argjson two_ways "$two_ways" \
		--arg direction "$direction" \
		--argjson need_captcha "$need_captcha" \
		--argjson logged_in "$logged_in" \
		--arg msg "$msg" \
		--argjson reconnect "$reconnect" \
		'{
			ok: $ok,
			two_ways: $two_ways,
			direction: $direction,
			need_captcha: $need_captcha,
			logged_in: $logged_in,
			msg: $msg,
			reconnect: $reconnect,
			options: [
				{id: "us", name: "US 美国"},
				{id: "hk", name: "HK 香港"}
			]
		}'
}

_html_text_after_label() {
	awk -v lab="$2" '
		index($0, lab) { want=1; next }
		want && /label_info/ {
			gsub(/<[^>]+>/, "")
			gsub(/^[ \t]+|[ \t]+$/, "")
			print
			exit
		}
	' "$1"
}

_portal_parse_html() {
	local html_file="$1" want_user
	portal_logged_in=0
	portal_two_ways=0
	portal_direction=""
	portal_account=""
	portal_product_name=""
	portal_product_id=""
	[ -s "$html_file" ] || return 1
	portal_account=$(_html_text_after_label "$html_file" "接入账户" | sed 's/[[:space:]]//g')
	portal_product_name=$(_html_text_after_label "$html_file" "产品名称")
	portal_product_id=$(grep -oE 'product_id[:"]+[0-9]+' "$html_file" | head -n1 | sed 's/[^0-9]//g')
	want_user=$(config_get username)
	echo "$portal_account" | grep -qE '^[A-Za-z0-9._@+-]+$' || portal_account=""
	if [ -n "$portal_account" ] && [ -n "$want_user" ] && [ "$portal_account" != "$want_user" ]; then
		_portal_drop
		portal_logged_in=0
		portal_two_ways=0
		portal_direction=""
		portal_account=""
		portal_product_name=""
		portal_product_id=""
		return 1
	fi
	if grep -qE 'two-ways-direction-item|set_two_ways_direction|路由器默认线路' "$html_file"; then
		portal_logged_in=1
		portal_two_ways=1
		portal_direction=$(grep -oE 'var initial = "[a-z]+"' "$html_file" | head -n1 | sed 's/.*var initial = "\([a-z]*\)".*/\1/')
		case "$portal_direction" in
			us | hk) ;;
			*) portal_direction="" ;;
		esac
		return 0
	fi
	if grep -qE '/index/user/outlogin\.html|接入账户' "$html_file"; then
		portal_logged_in=1
		portal_two_ways=0
		return 0
	fi
	return 1
}

_portal_probe() {
	portal_logged_in=0
	portal_two_ways=0
	portal_direction=""
	_portal_curl "${portal_url}/user/index" \
		-H 'accept: text/html,application/xhtml+xml' \
		--location \
		-o "$PORTAL_USER_HTML" >/dev/null 2>&1 || true
	_portal_parse_html "$PORTAL_USER_HTML"
}

_portal_fetch_captcha() {
	_portal_cookie_init
	_portal_curl "${portal_url}/index/index/verify.html?t=$(date +%s)" \
		-H 'accept: image/png,image/*;q=0.8' \
		-o "$PORTAL_CAPTCHA" >/dev/null 2>&1 || return 1
	[ -s "$PORTAL_CAPTCHA" ]
}

_portal_login() {
	local code="$1" resp rc pmsg
	username=$(config_get username)
	password=$(config_get password)
	portal_login_msg=""
	resp=$(_portal_curl "${portal_url}/index/index/login.html" \
		-H 'accept: application/json, text/javascript, */*; q=0.01' \
		-H 'x-requested-with: XMLHttpRequest' \
		-H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
		-H "origin: ${portal_url}" \
		-H "referer: ${portal_url}/index/index/login" \
		--data-urlencode "username=${username}" \
		--data-urlencode "password=${password}" \
		--data-urlencode "code=${code}")
	rc=$(echo "$resp" | jq -r '.resCode | tostring' 2>/dev/null)
	pmsg=$(echo "$resp" | jq -r '.resMessage // empty' 2>/dev/null)
	if [ "$rc" = "9999" ]; then
		return 0
	fi
	_portal_fetch_captcha || true
	portal_login_msg="${pmsg:-网站登录失败}"
	return 1
}

_portal_post_direction() {
	local dir="$1"
	_portal_curl "${portal_url}/index/User/set_two_ways_direction.html" \
		-H 'accept: application/json, text/javascript, */*; q=0.01' \
		-H 'x-requested-with: XMLHttpRequest' \
		-H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
		-H "origin: ${portal_url}" \
		-H "referer: ${portal_url}/user/index" \
		--data-urlencode "direction=${dir}"
}

_boss_set_engress() {
	local dir="$1" payload max_time url httpc
	boss_http=""
	boss_status=""
	boss_code=""
	boss_msg=""
	username=$(config_get username)
	password=$(config_get password)
	command -v jq >/dev/null 2>&1 || {
		boss_msg="jq missing"
		return 1
	}
	payload=$(jq -n \
		--arg account "$username" \
		--arg password "$password" \
		--arg engress_default "$dir" \
		'{account: $account, password: $password, engress_default: $engress_default}') || {
		boss_msg="出口请求编码失败"
		return 1
	}
	_status_dir
	url="${boss_url}${boss_set_engress_path}"
	max_time=$((${portal_timeout:-10} + 5))
	httpc=$(gogogo_curl "$url" \
		--silent --show-error \
		--connect-timeout "${portal_timeout:-10}" --max-time "$max_time" \
		-H 'Accept: application/json' \
		-H 'Content-Type: application/json' \
		-d "$payload" \
		-o "$BOSS_RESP" \
		-w '%{http_code}') || {
		boss_http="${httpc:-000}"
		boss_msg="出口接口请求失败"
		return 1
	}
	boss_http="$httpc"
	chmod 0600 "$BOSS_RESP" 2>/dev/null || true
	if [ ! -s "$BOSS_RESP" ] || ! jq -e . "$BOSS_RESP" >/dev/null 2>&1; then
		boss_msg="出口接口无有效响应"
		return 1
	fi
	boss_status=$(jq -r '.status // empty' "$BOSS_RESP")
	boss_code=$(jq -r '.code // empty' "$BOSS_RESP")
	boss_msg=$(jq -r '.message // empty' "$BOSS_RESP")
	return 0
}

_direction_label() {
	case "$1" in
		us) echo "美国" ;;
		hk) echo "香港" ;;
		*) echo "$1" ;;
	esac
}

_uci_two_ways_json() {
	case "$(config_get two_ways)" in
		1) echo true ;;
		*) echo false ;;
	esac
}

_portal_save_user_fields() {
	if [ "$portal_two_ways" = 1 ]; then
		uci set "${CPE_UCI}.@default[0].two_ways=1"
		[ -n "$portal_direction" ] && uci set "${CPE_UCI}.@default[0].direction=${portal_direction}"
	else
		uci set "${CPE_UCI}.@default[0].two_ways=0"
		uci -q delete "${CPE_UCI}.@default[0].direction"
	fi
	if [ -n "$portal_product_id" ]; then
		uci set "${CPE_UCI}.@default[0].product_id=${portal_product_id}"
	fi
	if [ -n "$portal_product_name" ]; then
		uci set "${CPE_UCI}.@default[0].product_name=${portal_product_name}"
	fi
	uci commit "$CPE_UCI"
	printf '%s' "$(config_get username)" > "$PORTAL_BOUND"
}

_apply_after_direction() {
	rm -f "$API_HASH_FILE"
	uci set "${CPE_UCI}.@default[0].update_info=1"
	uci commit "$CPE_UCI"
	_invalidate_exit_ip
	[ "$(config_get enable)" = 1 ] || return 1
	sleep 2
	_check_config
	_write_status >/dev/null
	return 0
}

_set_direction_mark_single() {
	local msg="${1:-当前账号无法切换出口}"
	uci set "${CPE_UCI}.@default[0].two_ways=0"
	uci -q delete "${CPE_UCI}.@default[0].direction"
	uci commit "$CPE_UCI"
	_direction_json false false "" false true "$msg" false
}

_set_direction_commit() {
	local dir="$1" reconnect=false label suffix
	uci set "${CPE_UCI}.@default[0].direction=${dir}"
	uci set "${CPE_UCI}.@default[0].two_ways=1"
	uci commit "$CPE_UCI"
	label=$(_direction_label "$dir")
	suffix="，连接后生效"
	if _apply_after_direction; then
		reconnect=true
		suffix="，正在接入新线路"
	fi
	_direction_json true true "$dir" false true "已切换为${label}线路${suffix}" "$reconnect"
}

_set_direction_via_portal() {
	local dir="$1" code="$2" resp rc pmsg
	_portal_probe
	if [ "$portal_logged_in" != 1 ]; then
		if [ -z "$code" ]; then
			_portal_fetch_captcha || true
			_direction_json false true "$dir" true false "切换线路需要网站验证码" false
			return 1
		fi
		_portal_login "$code" || {
			_direction_json false true "$dir" true false "${portal_login_msg:-网站登录失败}" false
			return 1
		}
		_portal_probe
	fi
	if [ "$portal_logged_in" = 1 ] && [ "$portal_two_ways" != 1 ]; then
		_set_direction_mark_single
		return 1
	fi
	resp=$(_portal_post_direction "$dir")
	if [ -z "$resp" ] || ! echo "$resp" | jq -e . >/dev/null 2>&1; then
		_portal_fetch_captcha || true
		_direction_json false true "$dir" true false "网站登录已过期，请输入验证码后重试" false
		return 1
	fi
	rc=$(echo "$resp" | jq -r '.resCode | tostring' 2>/dev/null)
	pmsg=$(echo "$resp" | jq -r '.resMessage // empty' 2>/dev/null)
	if [ "$rc" != "9999" ]; then
		_direction_json false true "$dir" false true "${pmsg:-切换失败}" false
		return 1
	fi
	_set_direction_commit "$dir"
}

_set_direction_core() {
	local dir="$1" code="$2"
	case "$dir" in
		us | hk) ;;
		*)
			_direction_json false false "" false false "线路参数无效" false
			return 1
			;;
	esac
	username=$(config_get username)
	password=$(config_get password)
	if [ -z "$username" ] || [ -z "$password" ]; then
		_direction_json false "$(_uci_two_ways_json)" "$dir" false false "请先填写账号和密码" false
		return 1
	fi
	if _boss_set_engress "$dir"; then
		if [ "$boss_status" = "success" ] || [ "$boss_code" = "200" ]; then
			_set_direction_commit "$dir"
			return $?
		fi
		case "$boss_msg" in
			*非双线* | *无法设置默认出口*)
				_set_direction_mark_single "当前账号无法切换出口"
				return 1
				;;
		esac
		_direction_json false true "$dir" false true "${boss_msg:-切换失败}" false
		return 1
	fi
	_set_direction_via_portal "$dir" "$code"
}

_set_direction() {
	_status_dir
	_apply_uci_overrides
	(
		flock -n 8 || {
			_direction_json false true "" false false "正在切换线路，请稍候" false
			exit 0
		}
		_set_direction_core "$1" "$2"
	) 8>"$PORTAL_LOCK"
}

_portal_captcha_cmd() {
	_apply_uci_overrides
	if _portal_fetch_captcha; then
		printf '{"ok":true}\n'
	else
		printf '{"ok":false,"msg":"验证码获取失败"}\n'
	fi
}

_shop_json() {
	local extra
	if [ -n "$1" ]; then
		extra="$1"
	else
		extra='{}'
	fi
	command -v jq >/dev/null 2>&1 || {
		printf '{"ok":false,"msg":"jq missing"}\n'
		return 0
	}
	jq -n \
		--argjson extra "$extra" \
		--arg product_id "$(config_get product_id)" \
		--arg product_name "$(config_get product_name)" \
		--argjson two_ways "$(_uci_two_ways_json)" \
		--arg direction "$(config_get direction)" \
		'$extra + {
			product_id: $product_id,
			product_name: $product_name,
			two_ways: $two_ways,
			direction: $direction
		}'
}

_shop_need_captcha() {
	_portal_fetch_captcha || true
	_shop_json '{"ok":false,"need_captcha":true,"msg":"订购/续费需要网站验证码"}'
}

_ensure_portal() {
	local code="$1"
	_portal_probe
	if [ "$portal_logged_in" = 1 ]; then
		_portal_save_user_fields
		return 0
	fi
	if [ -z "$code" ]; then
		return 2
	fi
	if _portal_login "$code"; then
		_portal_probe
		if [ "$portal_logged_in" = 1 ]; then
			_portal_save_user_fields
			return 0
		fi
	fi
	return 1
}

_shop_parse_catalog() {
	awk '
		$0 ~ /<h3[^>]*class="[^"]*title/ {
			line=$0
			sub(/.*<h3[^>]*>/, "", line)
			sub(/<\/h3>.*/, "", line)
			gsub(/<[^>]+>/, "", line)
			gsub(/^[ \t]+|[ \t]+$/, "", line)
			title=line
		}
		$0 ~ /<span[^>]*class="[^"]*price/ {
			line=$0
			sub(/.*<span[^>]*>/, "", line)
			sub(/<\/span>.*/, "", line)
			gsub(/<[^>]+>/, "", line)
			gsub(/^[ \t]+|[ \t]+$/, "", line)
			price=line
		}
		$0 ~ /onclick="dobuy\(/ {
			line=$0
			sub(/.*onclick="dobuy\(/, "", line)
			sub(/,.*/, "", line)
			gsub(/[^0-9]/, "", line)
			if (title != "" && line ~ /^[0-9]+$/)
				print title "\t" price "\t" line
		}
	' "$1"
}

_shop_catalog_cmd() {
	local code="$1" rc items
	_apply_uci_overrides
	_maybe_reset_on_account_change
	if [ -z "$(config_get username)" ] || [ -z "$(config_get password)" ]; then
		_shop_json '{"ok":false,"need_captcha":false,"msg":"请先登录加速账号"}'
		return 0
	fi
	_ensure_portal "$code"
	rc=$?
	if [ "$rc" = 2 ]; then
		_shop_need_captcha
		return 0
	fi
	if [ "$rc" != 0 ]; then
		_shop_json "$(jq -n --arg msg "${portal_login_msg:-网站登录失败}" '{ok:false,need_captcha:true,msg:$msg}')"
		return 0
	fi
	_portal_curl "${portal_url}/index/user/product" \
		-H 'accept: text/html,application/xhtml+xml' \
		--location \
		-o "$PORTAL_USER_HTML" >/dev/null 2>&1 || true
	items=$(_shop_parse_catalog "$PORTAL_USER_HTML" | jq -Rsc '
		split("\n") | map(select(length>0)) | map(split("\t")) |
		map(select(length>=3)) | map({name:.[0], price:.[1], id:.[2]})
	')
	rm -f "$PORTAL_USER_HTML"
	[ -n "$items" ] || items='[]'
	_shop_json "$(jq -n --argjson items "$items" '{ok:true,need_captcha:false,msg:"",items:$items}')"
}

_shop_pay_post() {
	local product_id="$1" month="$2" kind="$3"
	if [ "$kind" = "renew" ]; then
		_portal_curl "${portal_url}/index/index/dobuy_wx.html" \
			-H 'accept: application/json, text/javascript, */*; q=0.01' \
			-H 'x-requested-with: XMLHttpRequest' \
			-H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
			-H "origin: ${portal_url}" \
			-H "referer: ${portal_url}/user/index" \
			--data-urlencode "product_id=${product_id}" \
			--data-urlencode "month=${month}" \
			--data-urlencode "country_id=0" \
			--data-urlencode "area_id=0"
	else
		_portal_curl "${portal_url}/index/index/dobuy_wx.html" \
			-H 'accept: application/json, text/javascript, */*; q=0.01' \
			-H 'x-requested-with: XMLHttpRequest' \
			-H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
			-H "origin: ${portal_url}" \
			-H "referer: ${portal_url}/index/user/product" \
			--data-urlencode "product_id=${product_id}" \
			--data-urlencode "month=${month}"
	fi
}

_shop_is_cashier_url() {
	case "$1" in
		*[Aa]lipay.com* | *[Aa]lipaydev.com*) return 0 ;;
	esac
	return 1
}

_shop_html_error() {
	sed -n "s/.*class=['\"]error['\"][^>]*>\([^<]*\).*/\1/p" "$1" 2>/dev/null | head -n 1
}

_shop_is_alipay_form() {
	[ -s "$1" ] || return 1
	grep -qE "alipaysubmit|openapi\.alipay\.com|mapi\.alipay\.com" "$1"
}

_shop_write_pay_form() {
	local src="$1"
	{
		echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
		echo '<title>跳转支付宝</title></head><body>'
		echo '<p style="font-family:sans-serif;padding:16px">正在打开支付宝，请稍候…</p>'
		cat "$src"
		echo '</body></html>'
	} > "$PORTAL_PAY_FORM"
	chmod 0600 "$PORTAL_PAY_FORM"
}

_shop_form_amount() {
	grep -oE 'total_amount":"[0-9.]+' "$1" 2>/dev/null | head -n 1 | sed 's/.*:"//'
}

_shop_try_pay_body() {
	local body="$1"
	local amt
	if _shop_is_alipay_form "$body"; then
		amt=$(_shop_form_amount "$body")
		_shop_write_pay_form "$body"
		shop_pay_amount="$amt"
		return 0
	fi
	return 1
}

_shop_fetch_pay_form() {
	local product_id="$1" month="$2" kind="$3" body err q
	body="/var/run/gogogo/portal_pay.html"
	shop_pay_err=""
	shop_pay_amount=""
	rm -f "$PORTAL_PAY_FORM" "$body"
	[ -n "$portal_url" ] || portal_url="${CPE_PORTAL_URL:-https://www.gogogofuture.com}"
	q="product_id=${product_id}&month=${month}"
	[ "$kind" = "renew" ] && q="${q}&country_id=0&area_id=0"
	_portal_curl "${portal_url}/index/index/dobuy_aliapy.html?${q}" \
		-H "referer: ${portal_url}/index/user/product" \
		-o "$body" >/dev/null 2>&1 || true
	if _shop_try_pay_body "$body"; then
		rm -f "$body"
		return 0
	fi
	if [ "$kind" = "renew" ]; then
		_portal_curl "${portal_url}/index/index/dobuy_aliapy.html" \
			-H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
			-H "origin: ${portal_url}" \
			-H "referer: ${portal_url}/user/index" \
			--data-urlencode "product_id=${product_id}" \
			--data-urlencode "month=${month}" \
			--data-urlencode "country_id=0" \
			--data-urlencode "area_id=0" \
			-o "$body" >/dev/null 2>&1 || true
	else
		_portal_curl "${portal_url}/index/index/dobuy_aliapy.html" \
			-H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
			-H "origin: ${portal_url}" \
			-H "referer: ${portal_url}/index/user/product" \
			--data-urlencode "product_id=${product_id}" \
			--data-urlencode "month=${month}" \
			-o "$body" >/dev/null 2>&1 || true
	fi
	if _shop_try_pay_body "$body"; then
		rm -f "$body"
		return 0
	fi
	err=$(_shop_html_error "$body")
	[ -n "$err" ] && shop_pay_err="$err"
	rm -f "$body"
	return 1
}

_shop_local_pay_json() {
	local msg="${1:-请在新窗口完成支付宝支付}"
	jq -n --arg msg "$msg" --arg amount "${shop_pay_amount:-}" \
		'{ok:true,need_captcha:false,pay_local:true,msg:$msg,amount:$amount}'
}

_shop_pay_result() {
	local resp="$1" product_id="$2" month="$3" kind="$4" status pay_url pmsg extra
	if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
		_shop_json '{"ok":false,"need_captcha":false,"msg":"下单失败"}'
		return 0
	fi
	status=$(echo "$resp" | jq -r '.status // empty' 2>/dev/null)
	pmsg=$(echo "$resp" | jq -r '.resMessage // .msg // empty' 2>/dev/null)
	case "$status" in
		2|1)
			if _shop_fetch_pay_form "$product_id" "$month" "$kind"; then
				extra=$(_shop_local_pay_json "请在新窗口完成支付宝支付")
			else
				extra=$(jq -n --arg msg "${shop_pay_err:-${pmsg:-未能打开与该套餐对应的支付宝订单}}" \
					'{ok:false,need_captcha:false,msg:$msg}')
			fi
			;;
		3)
			pay_url=$(echo "$resp" | jq -r '.data // empty' 2>/dev/null)
			if _shop_is_cashier_url "$pay_url"; then
				extra=$(jq -n --arg url "$pay_url" --arg msg "请在新窗口完成支付" \
					'{ok:true,need_captcha:false,pay_url:$url,msg:$msg}')
			elif _shop_fetch_pay_form "$product_id" "$month" "$kind"; then
				extra=$(_shop_local_pay_json "请在新窗口完成支付宝支付")
			else
				extra=$(jq -n --arg msg "${shop_pay_err:-${pmsg:-未返回支付地址}}" \
					'{ok:false,need_captcha:false,msg:$msg}')
			fi
			;;
		*)
			extra=$(jq -n --arg msg "${pmsg:-下单失败}" '{ok:false,need_captcha:false,msg:$msg}')
			;;
	esac
	_shop_json "$extra"
}

_shop_buy_cmd() {
	local product_id="$1" month="$2" code="$3" rc resp
	_apply_uci_overrides
	_maybe_reset_on_account_change
	case "$product_id" in
		*[!0-9]* | "") _shop_json '{"ok":false,"msg":"套餐参数无效"}'; return 1 ;;
	esac
	case "$month" in
		[1-9] | 1[0-2]) ;;
		*) month=1 ;;
	esac
	_ensure_portal "$code"
	rc=$?
	if [ "$rc" = 2 ]; then
		_shop_need_captcha
		return 0
	fi
	if [ "$rc" != 0 ]; then
		_shop_json "$(jq -n --arg msg "${portal_login_msg:-网站登录失败}" '{ok:false,need_captcha:true,msg:$msg}')"
		return 0
	fi
	resp=$(_shop_pay_post "$product_id" "$month" "buy")
	_shop_pay_result "$resp" "$product_id" "$month" "buy"
}

_shop_renew_cmd() {
	local month="$1" code="$2" pid resp
	_apply_uci_overrides
	_maybe_reset_on_account_change
	_ensure_portal "$code"
	case $? in
		2) _shop_need_captcha; return 0 ;;
		0) ;;
		*)
			_shop_json "$(jq -n --arg msg "${portal_login_msg:-网站登录失败}" '{ok:false,need_captcha:true,msg:$msg}')"
			return 0
			;;
	esac
	pid=$(config_get product_id)
	if [ -z "$pid" ]; then
		_shop_json '{"ok":false,"msg":"当前账号没有可续费套餐，请先订购"}'
		return 0
	fi
	case "$month" in
		[1-9] | 1[0-2]) ;;
		*) month=1 ;;
	esac
	resp=$(_shop_pay_post "$pid" "$month" "renew")
	_shop_pay_result "$resp" "$pid" "$month" "renew"
}

_shop_status_cmd() {
	_apply_uci_overrides
	_maybe_reset_on_account_change
	_shop_json '{"ok":true,"need_captcha":false,"msg":""}'
}

_json_escape() {
	printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g'
}

_is_public_ipv4() {
	local ip a b
	ip=$(echo "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
	echo "$ip" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || return 1
	a=$(echo "$ip" | cut -d. -f1)
	b=$(echo "$ip" | cut -d. -f2)
	case "$a" in
		0 | 10 | 127) return 1 ;;
		169) [ "$b" = "254" ] && return 1 ;;
		172) [ "$b" -ge 16 ] 2>/dev/null && [ "$b" -le 31 ] 2>/dev/null && return 1 ;;
		192) [ "$b" = "168" ] && return 1 ;;
		224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 255) return 1 ;;
	esac
	return 0
}

_exit_ip_gen() {
	cat "$EXIT_IP_GEN" 2>/dev/null || echo 0
}

_invalidate_exit_ip() {
	local n pid
	_status_dir
	n=$(_exit_ip_gen)
	echo $((n + 1)) > "$EXIT_IP_GEN"
	pid=$(cat "$EXIT_IP_PID" 2>/dev/null)
	if [ -n "$pid" ]; then
		kill "$pid" 2>/dev/null || true
		kill -9 "$pid" 2>/dev/null || true
	fi
	rm -f "$EXIT_IP_FILE" "$EXIT_IP_PID"
}

_iface_private_ip() {
	local a
	a=$(uci -q get "network.${vpname}.addresses" | awk '{print $1}')
	[ -z "$a" ] && a=$(ip -4 -o addr show dev "$vpname" 2>/dev/null | awk '{print $4}' | head -n1)
	echo "$a" | sed 's|/.*||'
}

_live_region_of() {
	case "$(config_get direction)" in
		us | hk) echo "$(config_get direction)" ;;
		*) echo assigned ;;
	esac
}

_exit_ip_need_fetch() {
	local ip region ts ifaddr gen now cur
	ip link show "$vpname" >/dev/null 2>&1 || return 1
	_vpn_handshake_ok || return 1
	cur=$(_iface_private_ip)
	[ -n "$cur" ] || return 1
	now=$(date +%s)
	[ -s "$EXIT_IP_FILE" ] || return 0
	read -r ip region ts ifaddr gen < "$EXIT_IP_FILE" || return 0
	[ "$ifaddr" = "$cur" ] && [ "$(_exit_ip_gen)" = "$gen" ] || return 0
	[ -n "$ts" ] || return 0
	if _is_public_ipv4 "$ip"; then
		[ $((now - ts)) -ge 600 ] && return 0
		return 1
	fi
	[ $((now - ts)) -ge 15 ] && return 0
	return 1
}

_fetch_exit_ip() {
	local url ip gen addr live
	ip link show "$vpname" >/dev/null 2>&1 || return 1
	_vpn_handshake_ok || return 1
	_status_dir
	gen=$(_exit_ip_gen)
	addr=$(_iface_private_ip)
	live=$(_live_region_of)
	[ -n "$addr" ] || return 1
	for url in \
		https://ipinfo.in/ \
		http://ipinfo.io/ip \
		http://ifconfig.me \
		http://icanhazip.com
	do
		ip=$(curl -sS -L --http1.1 -m 6 --connect-timeout 4 \
			--interface "$vpname" "$url" 2>/dev/null \
			| tr -d '\r' | sed -n '1s/[[:space:]]*$//p')
		_is_public_ipv4 "$ip" || continue
		[ "$(_exit_ip_gen)" = "$gen" ] || return 1
		[ "$(_iface_private_ip)" = "$addr" ] || return 1
		printf '%s %s %s %s %s\n' "$ip" "$live" "$(date +%s)" "$addr" "$gen" > "$EXIT_IP_FILE"
		return 0
	done
	[ "$(_exit_ip_gen)" = "$gen" ] || return 1
	[ "$(_iface_private_ip)" = "$addr" ] || return 1
	printf '%s %s %s %s %s\n' "-" "$live" "$(date +%s)" "$addr" "$gen" > "$EXIT_IP_FILE"
	return 1
}

_exit_ip_busy() {
	local pid
	pid=$(cat "$EXIT_IP_PID" 2>/dev/null)
	[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}

_maybe_refresh_exit_ip() {
	ip link show "$vpname" >/dev/null 2>&1 || { rm -f "$EXIT_IP_FILE"; return 0; }
	_vpn_handshake_ok || return 0
	_exit_ip_need_fetch || return 0
	_exit_ip_busy && return 0
	_status_dir
	(
		echo $$ > "$EXIT_IP_PID"
		_fetch_exit_ip
		rm -f "$EXIT_IP_PID"
		_write_status >/dev/null
	) >/dev/null 2>&1 &
}

_write_status() {
	_status_dir
	_apply_uci_overrides
	_maybe_reset_on_account_change
	local enable username description login_status tunnelall dnsleak
	local connected=0 handshake=0 rx=0 tx=0 private_ip="" iface=""
	local want=0 split=1 killswitch=0 block_reason="" expired_text=""
	local hs now login_pending=0 login_result_json="null"
	local two_ways_json=null direction="" product_name="" product_id=""
	local exit_ip="" exit_ip_region="" live_region="" _eip _ereg _ets _eaddr _egen

	enable=$(config_get enable)
	username=$(config_get username)
	description=$(config_get description)
	login_status=$(config_get login_status)
	tunnelall=$(config_get tunnelall)
	dnsleak=$(config_get dnsleak)
	product_name=$(config_get product_name)
	product_id=$(config_get product_id)
	case "$(config_get two_ways)" in
		1)
			two_ways_json=true
			direction=$(config_get direction)
			;;
		0)
			two_ways_json=false
			;;
		*)
			two_ways_json=null
			;;
	esac
	iface=$(config_get vpname)
	[ -z "$iface" ] && iface="$vpname"
	[ "$enable" = "1" ] && want=1
	[ "$tunnelall" = "1" ] && split=0 || split=1
	[ "$dnsleak" = "1" ] && killswitch=1
	expired_text="$description"

	if [ "$description" = "$ACCOUNT_EXPIRED_MSG" ]; then
		block_reason="$ACCOUNT_EXPIRED_MSG"
	elif [ -n "$login_status" ] && [ "$login_status" != "0" ]; then
		block_reason="系统检测到该账号已在客户端登录，请先退出客户端登录，再继续使用路由器服务。"
	fi

	if command -v wg >/dev/null 2>&1 && wg show "$iface" >/dev/null 2>&1; then
		connected=1
		private_ip=$(uci -q get "network.${iface}.addresses" | awk '{print $1}')
		rx=$(wg show "$iface" transfer 2>/dev/null | awk 'NF>=3 {print $2; exit}')
		tx=$(wg show "$iface" transfer 2>/dev/null | awk 'NF>=3 {print $3; exit}')
		hs=$(wg show "$iface" latest-handshakes 2>/dev/null | awk 'NF>=2 {print $2; exit}')
		now=$(date +%s)
		if [ -n "$hs" ] && [ "$hs" != "0" ] && [ $((now - hs)) -lt "${wg_hs_max_age:-180}" ]; then
			handshake=1
		fi
	fi
	[ -z "$rx" ] && rx=0
	[ -z "$tx" ] && tx=0
	private_ip=$(echo "$private_ip" | sed 's|/.*||')

	if [ "$connected" = 1 ]; then
		live_region=$(_live_region_of)
		if [ "$handshake" = 1 ]; then
			_maybe_refresh_exit_ip
			if [ -s "$EXIT_IP_FILE" ]; then
				read -r _eip _ereg _ets _eaddr _egen < "$EXIT_IP_FILE" || true
				if _is_public_ipv4 "$_eip" && [ "$_eaddr" = "$private_ip" ] && \
					[ "$(_exit_ip_gen)" = "$_egen" ]; then
					exit_ip=$_eip
					exit_ip_region=${_ereg:-$live_region}
				fi
			fi
		fi
	else
		rm -f "$EXIT_IP_FILE" 2>/dev/null || true
	fi

	if [ -f /var/run/gogogo/login.job ]; then
		login_pending=1
	fi
	if [ -f /var/run/gogogo/login.result ]; then
		login_result_json=$(cat /var/run/gogogo/login.result)
		[ -z "$login_result_json" ] && login_result_json="null"
	fi

	cat > /var/run/gogogo/status.json <<EOF
{"ok":true,"enable":$want,"want":$want,"connected":$connected,"handshake":$handshake,"split":$split,"killswitch":$killswitch,"account":"$(_json_escape "$username")","login_account":"$(_json_escape "$username")","description":"$(_json_escape "$description")","expired_text":"$(_json_escape "$expired_text")","login_status":$([ -n "$login_status" ] && echo "$login_status" || echo 0),"block_reason":"$(_json_escape "$block_reason")","iface":"$(_json_escape "$iface")","private_ip":"$(_json_escape "$private_ip")","rx":${rx:-0},"tx":${tx:-0},"region":"$(_json_escape "${live_region:-assigned}")","live_region":"$(_json_escape "$live_region")","exit_ip":"$(_json_escape "$exit_ip")","exit_ip_region":"$(_json_escape "$exit_ip_region")","two_ways":$two_ways_json,"direction":"$(_json_escape "$direction")","product_id":"$(_json_escape "$product_id")","product_name":"$(_json_escape "$product_name")","login_pending":$login_pending,"login_result":$login_result_json}
EOF
	cat /var/run/gogogo/status.json
}

_login_job() {
	_status_dir
	_apply_uci_overrides
	_clear_account_expired_hold
	_maybe_reset_on_account_change
	date +%s > /var/run/gogogo/login.job
	rm -f /var/run/gogogo/login.result
	if _check_user_core; then
		uci set "${CPE_UCI}.@default[0].last_username=$(config_get username)"
		uci commit "$CPE_UCI"
		printf '{"ok":true,"msg":"登录成功","account":"%s"}\n' "$(_json_escape "$(config_get username)")" > /var/run/gogogo/login.result
		_write_status >/dev/null
	else
		printf '{"ok":false,"msg":"%s","account":"%s"}\n' "$(_json_escape "$(config_get description)")" "$(_json_escape "$(config_get username)")" > /var/run/gogogo/login.result
		_write_status >/dev/null
	fi
	rm -f /var/run/gogogo/login.job
}

_connect_now() {
	_clear_account_expired_hold
	uci set "${CPE_UCI}.@default[0].enable=1"
	uci commit "$CPE_UCI"
	_apply_uci_overrides
	_maybe_reset_on_account_change
	_check_config
	_write_status >/dev/null
}

_disconnect_now() {
	uci set "${CPE_UCI}.@default[0].enable=0"
	uci commit "$CPE_UCI"
	_deletevpn
	_write_status >/dev/null
}

_clear_account_cmd() {
	_apply_uci_overrides
	uci set "${CPE_UCI}.@default[0].enable=0"
	uci commit "$CPE_UCI"
	_deletevpn
	_portal_drop
	_uci_clear_exit
	uci -q set "${CPE_UCI}.@default[0].username="
	uci -q set "${CPE_UCI}.@default[0].password="
	uci -q delete "${CPE_UCI}.@default[0].last_username"
	uci -q set "${CPE_UCI}.@default[0].description="
	uci -q set "${CPE_UCI}.@default[0].login_status=0"
	uci commit "$CPE_UCI"
	[ -x /usr/sbin/gogogo-cred ] && /usr/sbin/gogogo-cred clear
	rm -f /var/run/gogogo/login.job /var/run/gogogo/login.result "$API_HASH_FILE" "$ACCOUNT_EXPIRED_STAMP"
	_write_status >/dev/null
	printf '{"ok":true,"msg":"已退出登录"}\n'
}

_start() {
	if ! command -v jq >/dev/null 2>&1; then
		echo "Error: jq required for gogogo" >&2
		logger -t gogogo "jq missing, cannot start"
		exit 1
	fi
	if ! _gogogo_main_acquire_lock; then
		echo "Process ${CPE_SERVICE:-gogogo} already running (pid $(_gogogo_read_pidfile "$CPE_MAIN_PIDFILE" 2>/dev/null || _gogogo_pick_main_pid))."
		exit 0
	fi
	_apply_uci_overrides
	_apply_default_timezone
	trap '_gogogo_main_release_lock' INT TERM EXIT
	# 启动即写入 /tmp/dnsmasq.d/gogogo-vpn.conf 并 restart dnsmasq（不等到主循环/API）
	_checkdns
	while true; do
		_apply_uci_overrides
		_check_config
		_write_status >/dev/null
		sleep "$main_loop_sec"
	done
}

_load_defaults
_apply_uci_overrides

action="${1:-start}"
case "$action" in
	stop)
		_gogogo_main_stop_all
		_deletevpn
		rm -f "$API_HASH_FILE" "$CHNROUTES_FETCH_STAMP"
		;;
	getwan)
		_getwan
		;;
	apply_timezone)
		_apply_default_timezone
		;;
	check_user)
		_check_user
		;;
	account_diag)
		_account_diag
		;;
	refresh_account)
		_refresh_account
		;;
	refresh_dns)
		_apply_uci_overrides
		rm -f /tmp/gogogo_dns_probe.stamp /tmp/gogogo_dns_local_selected "$DNSMASQ_APPLIED_STAMP"
		_checkdns
		;;
	probe_dns)
		_apply_uci_overrides
		rm -f /tmp/gogogo_dns_probe.stamp /tmp/gogogo_dns_local_selected
		_probe_dns_local_1
		echo "dns_local_1=${dns_local_1}"
		;;
	refresh_routes)
		_apply_uci_overrides
		_ensure_policy_reserved_rules
		if _uci_flag tunnelall; then
			_clear_split_routes
			_stop_legacy_quagga
			_protect_mgmt_sessions
		else
			_refresh_split_routes
		fi
		_add_vpn_routes 2>/dev/null
		_pin_reserved_to_wan
		_checkdns
		;;
	refresh_chnroutes)
		_apply_uci_overrides
		rm -f "$CHNROUTES_FETCH_STAMP"
		[ -x /usr/sbin/gogogo-chnroutes-update ] && /usr/sbin/gogogo-chnroutes-update force
		_uci_flag tunnelall || _refresh_split_routes
		_checkdns
		;;
	route_guard_add)
		_apply_uci_overrides
		_add_route_guard_host "$2"
		_refresh_split_routes
		;;
	route_guard_del)
		_apply_uci_overrides
		_del_route_guard_host "$2"
		_refresh_split_routes
		;;
	status)
		_write_status
		;;
	set_direction)
		_maybe_reset_on_account_change
		_set_direction "$2" "$3"
		;;
	portal_captcha)
		_portal_captcha_cmd
		;;
	reset_exit)
		_reset_exit_cmd
		;;
	shop_status)
		_shop_status_cmd
		;;
	shop_catalog)
		_shop_catalog_cmd "$2"
		;;
	shop_buy)
		_shop_buy_cmd "$2" "$3" "$4"
		;;
	shop_renew)
		_shop_renew_cmd "$2" "$3"
		;;
	login)
		_login_job
		;;
	connect)
		_connect_now
		;;
	disconnect)
		_disconnect_now
		;;
	clear_account)
		_clear_account_cmd
		;;
	save)
		_write_status
		;;
	pin_reserved)
		_apply_uci_overrides
		_ensure_policy_reserved_rules
		;;
	*)
		_start
		;;
esac
