#!/bin/bash
#
# Copyright (C) 2025 sirpdboy herboy2008@gmail.com https://github.com/sirpdboy/luci-app-watchdog
# 

APP=watchdog
base_dir="/usr/share/$APP"
api_dir="$base_dir/api"

# 检测防火墙类型
detect_firewall_type() {
    if command -v nft >/dev/null && [ -x /sbin/fw4 ]; then
        echo "nft"
    elif command -v iptables >/dev/null; then
        echo "iptables"
    else
        echo "unknown"
    fi
}

# 读取设置文件
get_config() {
	while [[ "$*" != "" ]]; do
		[[ "$1" == "lang" ]] && {
			lang=$(uci get luci.main.lang 2>/dev/null)
			if [ -z "$lang" ] || [[ "$lang" == "auto" ]]; then
				lang=$(echo "${LANG:-${LANGUAGE:-${LC_ALL:-${LC_MESSAGES:-zh_cn}}}}" | awk -F'[ .@]' '{print tolower($1)}' | sed 's/-/_/' 2>/dev/null)
			fi
		} || {
			eval "${1}='$(uci get $APP.config.$1 2>/dev/null)'"
		}
		shift
	done
}



# 初始化设置信息
read_config() {
	get_config \
		"enable" "sleeptime" "lang" \
		"login_control" "login_max_num" \
        "login_web_black" "login_ip_black_timeout"

	(echo "$login_control" | grep -q "web_logged") && web_logged="true"
	(echo "$login_control" | grep -q "ssh_logged") && ssh_logged="true"
	(echo "$login_control" | grep -q "web_login_failed") && web_login_failed="true"
	(echo "$login_control" | grep -q "ssh_login_failed") && ssh_login_failed="true"

	(opkg list-installed | grep -w -q "^firewall4") && nftables_version="true"
    ip_blacklist_path="$api_dir/ip_blacklist"

	ipv4_urllist=$(cat /usr/share/$APP/api/ipv4.list) 2>/dev/null
	ipv6_urllist=$(cat /usr/share/$APP/api/ipv6.list) 2>/dev/null
	[ -z "$sleeptime" ] && sleeptime="60"
	[ -z "$login_ip_black_timeout" ] && login_ip_black_timeout="86400"
	deltemp
}

# 初始化
init() {
	# 检测程序开关
	enable_test
	[ -f "$logfile" ] && local logrow=$(grep -c "" "$logfile") || local logrow="0"
	[ "$logrow" -ne 0 ] && echo "----------------------------------" >>${logfile}
	log "[INFO] $(translate "Start running")"
	if [ -f "/usr/share/$APP/errlog" ]; then
		cat /usr/share/$APP/errlog >${logfile}
		log "[ERROR] $(translate "Loaded logs from previous restart")"
	fi

	# 文件清理
	rm -f  "/usr/share/$APP/errlog"  >/dev/null 2>&1
	LockFile unlock

	# 防火墙初始化
	[ -n "$login_web_black" ] && [ "$login_web_black" -eq "1" ] && init_ip_black "ipv4"
	[ -n "$login_web_black" ] && [ "$login_web_black" -eq "1" ] && init_ip_black "ipv6"
	set_ip_black

	return 0
}

# 主程序
main() {
	# 限制并发进程
	dir="/tmp/$APP"
	logfile="${dir}/$APP.log"
	mkdir -p "$(dirname "$logfile")"
	get_config "thread_num"
	[ -z "$thread_num" ] || [ "$thread_num" -eq "0" ] && thread_num=5
	[ "$1" ] && [ $1 == "t1" ] && thread_num=1
	max_thread_num="$thread_num"

	FIFO_PATH="${dir}/fifo.$$"
	mkfifo "$FIFO_PATH"
	exec 5<>"$FIFO_PATH"
	rm "$FIFO_PATH" >/dev/null 2>&1

	for i in $(seq 1 "$max_thread_num"); do
		echo >&5
	done
	unset i

	# 定义锁文件
	lock_file="${dir}/$APP.lock"
	touch "$lock_file"

	# 设置信号处理
	trap cleanup SIGINT SIGTERM EXIT
	MAIN_PID=$$
	PROCESS_TAG="{watchdog}_${MAIN_PID}"

	silent_run read_config

	# 载入在线设备
	init
	[ $? -eq 1 ] && log "[ERROR] $(translate "Failed to read settings, please check configuration.")" && exit
	if [ -n "$web_logged" ] || [ -n "$ssh_logged" ] || [ -n "$web_login_failed" ] || [ -n "$ssh_login_failed" ]; then
		# 声明关联数组
		declare -A web_login_counts
		declare -A ssh_login_counts
		declare -A web_failed_counts
		declare -A ssh_failed_counts
	fi

	>"${dir}/send_enable.lock"  && deltemp
	log "[INFO] $(translate "Initialization completed")"
	while [ "$enable" -eq "1" ]; do
		deltemp


		silent_run run_logins
		set_ip_black
		sleep $sleeptime
	done
}

# 隐藏输出
silent_run() {
	"$@" >/dev/null 2>&1
}


# 计算字符串显示宽度
length_str() {
	[ -z "$1" ] && return

	local result
	{
		local str="$1"
		local length=0

		while IFS= read -r -n1 char; do
			local char_width
			char_width=$(printf "%s" "$char" | awk '{
				if (match($0, /[一-龥]/)) print 2;
				else print 1;
			}')

			length=$((length + char_width))
		done <<< "$str"

		result="$length"
	} > /dev/null 2>&1

	echo "$result"
}

# 字符串显示宽度处理
cut_str() {
	[ -z "$1" ] && return
		[ -z "$2" ] && return
	local result
	# 调试模式不要输出信息
	{
		local str="$1"
		local max_width="$2"
		local current_width=0

		# 遍历字符串的每个字符
		for ((i = 0; i < ${#str}; i++)); do
			local char="${str:$i:1}"
			local char_width=$(length_str "$char")

			# 如果当前宽度加上当前字符的宽度超过最大宽度，则停止
			if [ $((current_width + char_width)) -gt "$max_width" ]; then
				break
			fi

			result="${result}${char}"
			current_width=$((current_width + char_width))
		done

		# 如果裁剪了字符串，则添加 ".."
		if [ "$current_width" -lt $(length_str "$str") ]; then
			result=$(echo "$result" | sed 's/ *$//')
			result="${result}.."
		fi
	} > /dev/null 2>&1

	echo "$result"
}

# 翻译
translate() {
	local template="$1"
	shift  # 移出第一个参数，剩余参数作为变量
	
	# 获取基础翻译
	
	local lua_script=$(cat <<LUA
	require "luci.i18n".setlanguage("$lang")
	print(require "luci.i18n".translate([==[$template]==]))
LUA
	)
	local translated=$(lua -e "$lua_script")
	
	# 如果有额外参数则进行格式化
	if [ $# -gt 0 ]; then
		printf "$translated" "$@"
	else
		echo "$translated"
	fi
}

# 随机数
rand() {
	local min=$1
	local max=$(($2 - $min + 1))
	local num=$(date +%s%N)
	echo $(($num % $max + $min))
}
# 初始化日志
log() {
    echo "$(date "+%Y-%m-%d %H:%M:%S") - $1" >> "$logfile"
}



# 文件锁
LockFile() {
	local fd=200

	if [ "$1" = "lock" ]; then
		eval "exec $fd>$lock_file"
		flock -n $fd
		if [ $? -ne 0 ]; then
			while ! flock -n $fd; do
				sleep 1
			done
		fi
	elif [ "$1" = "unlock" ]; then
		eval "exec $fd>&-"
	fi
}

# 检测退出信号
cleanup() {
	local pids=$(ps | grep -E "\{watchdog\}_${MAIN_PID}|\{watchdog-call\}" | grep -v grep | awk '{print $1}')
	[ -n "$pids" ] && echo "$pids" | xargs kill 2>/dev/null
	LockFile unlock
	$EXIT_FLAG && exit 0
}

# 子进程调用
run_with_tag() {
	[ -z "$1" ] && return
	local command_name=$1  # 第一个参数是命令名称
	shift # 移除第一个参数，剩下的参数传递给命令
	local command_path=$(readlink -f "$(which "$command_name")") # 检查命令路径
	
	# 如果是 BusyBox 的 applet，调用 watchdog-call
	if [[ "$command_path" == *"busybox"* ]]; then
		/usr/libexec/watchdog-call child "$command_name" "$@"
	else
		bash -c 'exec -a "$0" "$@"' "${PROCESS_TAG} ${command_name}" "$command_name" "$@"
	fi
}


# 清理临时文件
deltemp() {
	rm -f "${dir}/send_enable.lock"   >/dev/null 2>&1
	[ -f "$logfile" ] && local logrow=$(grep -c "" "$logfile") || local logrow="0"
	[ "$logrow" -gt 500 ] && tail -n 300 "$logfile" >"${logfile}.tmp" && mv "${logfile}.tmp" "$logfile" && log "[DEBUG] $(translate "Log exceeded limit, keeping last 300 entries")"
}

# ------------------------------------
# 信息获取类


# 查询 IP 归属地
get_ip_attribution() {
	ip="$1"
	jq -e --arg ip "$ip" '.devices[] | select(.ip == $ip)' "$devices_json" >/dev/null 2>&1 && echo "本地局域网" && return
	local ip_attribution_urls=$(cat /usr/share/watchdog/api/ip_attribution.list)
	local sorted_attribution_urls=$(echo "$ip_attribution_urls" | awk 'BEGIN {srand()} {print rand() "\t" $0}' | sort -k1,1n | cut -f2-)
	local ip_attribution_url
	while IFS= read -r ip_attribution_url; do
		local login_ip_attribution=$(eval curl --connect-timeout 2 -m 2 -k -s "$ip_attribution_url" 2>/dev/null)
		[ -n "$login_ip_attribution" ] && echo "$login_ip_attribution" && break
	done <<<"$sorted_attribution_urls"
}


# 检测程序开关
enable_test() {
	[ -z "$1" ] && local time_n=1
	for i in $(seq 1 $time_n); do
		get_config enable
		[ -z "$enable" ] || [ "$enable" -eq "0" ] && exit || sleep 1
	done
	unset i
}



# 初始化黑名单规则
init_ip_black() {
	[ -n "$login_web_black" ] && [ "$login_web_black" -eq "1" ] || return
	# 设置 IP 版本变量
	if [ $1 == "ipv4" ]; then
		ipset_name="watchdog_blacklist"
		ip_version="ip"
	elif [ $1 == "ipv6" ]; then
		ipset_name="watchdog_blacklistv6"
		ip_version="ip6"
		nat_table_cmd="family inet6"
	fi

	[ -n "$nftables_version" ] && {
		! nft list set inet fw4 ${ipset_name} >/dev/null 2>&1 && nft add set inet fw4 ${ipset_name} { type ${1}_addr\; flags timeout\; timeout ${login_ip_black_timeout}s\; }
		! nft list ruleset | grep "$ip_version saddr @${ipset_name} counter .* comment \"\!watchdog Drop rule\"" >/dev/null 2>&1 && nft insert rule inet fw4 input $ip_version saddr @${ipset_name} counter drop comment \"\!watchdog Drop rule\" >/dev/null 2>&1
	} || {
		ipset list $ipset_name >/dev/null 2>&1 || ipset create ${ipset_name} hash:ip timeout ${login_ip_black_timeout} ${nat_table_cmd} >/dev/null 2>&1
		${ip_version}tables -C INPUT -m set --match-set ${ipset_name} src -j DROP >/dev/null 2>&1 || ${ip_version}tables -I INPUT -m set --match-set ${ipset_name} src -j DROP >/dev/null 2>&1
	}
}

# 添加黑名单
add_ip_black() {
	local login_ip=$1
	[ -z "$login_ip" ] && return 0
	# 检查 IP 版本
	unset ipset_name
	(echo "$login_ip" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$') && ipset_name="watchdog_blacklist"
	(echo "$login_ip" | grep -Eq '^([\da-fA-F0-9]{1,4}(:{1,2})){1,15}[\da-fA-F0-9]{1,4}$') && ipset_name="watchdog_blacklistv6"
	[ -z "$ipset_name" ] && sed -i "/^$login_ip /d" "$ip_blacklist_path" && log "[WARN] $(translate "Failed to add to blacklist, invalid IP format: %s (removed from list)" "$login_ip")" && return 1

	! cat "$ip_blacklist_path" | grep -q -w -i $login_ip && echo "$login_ip timeout $login_ip_black_timeout" >>"$ip_blacklist_path"
	[ -n "$nftables_version" ] && {
		nft list set inet fw4 ${ipset_name} | grep -qw "${login_ip}" && return 1 # IP 已存在
		nft add element inet fw4 $ipset_name { $login_ip expires ${login_ip_black_timeout}s } >/dev/null 2>&1
	} || {
		ipset -exist add $ipset_name $login_ip timeout ${login_ip_black_timeout} >/dev/null 2>&1
	}
}

# 移出黑名单
del_ip_black() {
	[ -z "$1" ] && return
	sed -i "/^${1}/d" ${ip_blacklist_path}

	# 检查 IP 版本
	unset ipset_name
	(echo "$1" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$') && ipset_name="watchdog_blacklist"
	(echo "$1" | grep -Eq '^([\da-fA-F0-9]{1,4}(:{1,2})){1,15}[\da-fA-F0-9]{1,4}$') && ipset_name="watchdog_blacklistv6"
	[ -z "$ipset_name" ] && return

	[ -n "$nftables_version" ] && {
		nft delete element inet fw4 ${ipset_name} { $1 } >/dev/null 2>&1
	} || {
		ipset list ${ipset_name} >/dev/null 2>&1 && ipset -! del ${ipset_name} ${1}
	}
}

# 设置防火墙列表
set_ip_black() {
	# 检查换行，避免出错
	[ $(tail -n1 "${ip_blacklist_path}" | wc -l) -eq "0" ] && echo -e >>${ip_blacklist_path}

	# 从 ip_blacklist 文件逐行添加黑名单，add_ip_black() 处验证是否重复，此处不在验证
	for ip_black in $(cat ${ip_blacklist_path} | awk '{print $1}'); do
		add_ip_black "$ip_black"
	done
	# 当 ip_blacklist 文件清除 IP 时，从集合中清除 IP
	[ -n "$nftables_version" ] && fw_info_blacklist=$(nft list set inet fw4 watchdog_blacklist | tr -d '\n' | grep -oE 'elements = \{[^}]*\}' | grep -oE '[^{}]+ expires [^,}]+[,\}]' | tr ',}' '\n' | tr -s ' ' | sed -e 's/^[[:space:]]*//')
	[ -n "$nftables_version" ] && fw_info_blacklistv6=$(nft list set inet fw4 watchdog_blacklistv6 | tr -d '\n' | grep -oE 'elements = \{[^}]*\}' | grep -oE '[^{}]+ expires [^,}]+[,\}]' | tr ',}' '\n' | tr -s ' ' | sed -e 's/^[[:space:]]*//')
	[ -z "$nftables_version" ] && fw_info_blacklist=$(ipset list watchdog_blacklist | grep "timeout" 2>/dev/null)
	[ -z "$nftables_version" ] && fw_info_blacklistv6=$(ipset list watchdog_blacklistv6 | grep "timeout" 2>/dev/null)

	[ -n "$fw_info_blacklist" ] && [ -n "$fw_info_blacklistv6" ] && combined_fw_info_blacklist="${fw_info_blacklist}\n${fw_info_blacklistv6}"
	[ -z "$fw_info_blacklist" ] && combined_fw_info_blacklist="${fw_info_blacklistv6}" || combined_fw_info_blacklist="${fw_info_blacklist}"

	while IFS= read -r ip_black_info; do
		ip_black=$(echo "$ip_black_info" | grep -Eo "[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}")
		[ -z "$ip_black" ] && ip_black=$(echo "$ip_black_info" | grep -Eo "([\da-fA-F0-9]{1,4}(:{1,2})){1,15}[\da-fA-F0-9]{1,4}")
		[ -z "$ip_black" ] && continue
		cat ${ip_blacklist_path} | grep -q -w -i ${ip_black} && sed -i "/^${ip_black}/d" ${ip_blacklist_path} && echo ${ip_black_info} >>${ip_blacklist_path} || {
		    del_ip_black ${ip_black}
		    log "$(translate "[Ban information]Cancel the ban IP:%s" "$ip_black")"
		}
	done <<<"$combined_fw_info_blacklist"
}

# 监听登录事件
run_logins() {
	if [ -n "$web_logged" ] || [ -n "$ssh_logged" ] || [ -n "$web_login_failed" ] || [ -n "$ssh_login_failed" ]; then
			# 监听系统日志，-f 表示跟随实时日志，-p 表示日志级别为 notice
			run_with_tag logread -f -p notice | while IFS= read -r line; do
				[ -n "$web_logged" ] && {
					web_login_ip=$(echo "$line" | grep -i "accepted login" | awk '{print $NF}')
					[ -n "$web_login_ip" ] && process_login "$web_login_ip" $(echo "$line" | awk '{print $4}') web_login_counts
				}

				[ -n "$ssh_logged" ] && {
					ssh_login_ip=$(echo "$line" | grep -i "Password auth succeeded\|Pubkey auth succeeded" | awk '{print $NF}' | sed -nr 's#^(.*):.[0-9]{1,5}#\1#gp' | sed -e 's/%.*//')
					[ -n "$ssh_login_ip" ] && process_login "$ssh_login_ip" $(echo "$line" | awk '{print $4}') ssh_login_counts
				}

				[ -n "$web_login_failed" ] && {
					web_failed_ip=$(echo "$line" | grep -i "failed login" | awk '{print $NF}')
					[ -n "$web_failed_ip" ] && process_login "$web_failed_ip" $(echo "$line" | awk '{print $4}') web_failed_counts
				}

				[ -n "$ssh_login_failed" ] && {
					# 匹配特定的 SSH 登录失败情况并提取 IP 地址和时间
					ssh_failed_ip=$(echo "$line" | grep -iE "Bad password attempt|Login attempt for nonexistent user|Max auth tries reached" | awk '{print $NF}' | sed -nr 's#^(.*):[0-9]{1,5}#\1#gp' | sed -e 's/%.*//')

					# 如果未能提取到 IP，从日志标识符提取失败用户的 ID，并再次提取 IP
					if [ -z "$ssh_failed_ip" ]; then
						ssh_failed_num=$(echo "$line" | sed -n 's/.*authpriv\.warn dropbear\[\([0-9]\+\)\]: Login attempt for nonexistent user/\1/p')
						[ -n "$ssh_failed_num" ] && ssh_failed_ip=$(logread notice | grep "authpriv\.info dropbear\[${ssh_failed_num}\].*Child connection from" | awk '{print $NF}' | sed -nr 's#^(.*):[0-9]{1,5}#\1#gp' | sed -e 's/%.*//' | tail -n 1)
					fi

					# 如果成功提取到 IP 地址，调用 process_login 处理
					[ -n "$ssh_failed_ip" ] && process_login "$ssh_failed_ip" $(echo "$line" | awk '{print $4}') ssh_failed_counts
				}

			done
		sleep 1
	fi
}


# 处理登录事件
# 参数:
#   $1: IP
#   $2: 日志时间 - 从日志中读取而不是使用当前时间，避免秒对应不上
#   $3: 数组名 - 记录 IP 和登录次数的关联数组名
process_login() {
	local login_ip=$1
	local login_time=$2
	local -n login_counts=$3

	# 如果数组中不存在此 IP，初始化为 0
	if [ -z "${login_counts["$login_ip"]}" ]; then
		login_counts["$login_ip"]=0
	fi
	# +1
	login_counts["$login_ip"]=$((login_counts["$login_ip"] + 1))
	local count=${login_counts["$login_ip"]}

	# 封禁
	if [[ ("$3" == "web_failed_counts" || "$3" == "ssh_failed_counts") ]]; then
	    if [[ $count -ge $login_max_num ]] ;then
		add_ip_black ${login_ip} && {
			unset login_counts["$login_ip"]
			login_send "$login_ip" "$login_time" "$3"
			log "$(translate "[Block Information]Add to blacklist IP: %s Attempts:%s Time:%s" "$login_ip" "$count" "$login_time" )"
		}
	    else
	     	login_send "$login_ip" "$login_time" "$3"
	    fi
	    
	fi

	# 正常登录
	if [[ "$3" == "web_login_counts" || "$3" == "ssh_login_counts" ]]; then
		del_ip_black ${login_ip} # 白名单已经优先于黑名单，但白名单集合有超时限制，防止下次修改代码忘记，上保险
		unset web_failed_counts["$login_ip"]
		unset ssh_failed_counts["$login_ip"]
		unset login_counts["$login_ip"]
		login_send "$login_ip" "$login_time" "$3"
	fi
	[ "${#login_counts[@]}" -gt "100" ] && login_counts=("${login_counts[@]: -100}")
}

# 登录提醒
login_send() {
	local login_ip=$1
	local login_time=$2
	local log_type=$3

	local login_title
	local login_content

	>"${dir}/send_enable.lock"

	[ -z "$login_ip" ] && return

	[[ "$log_type" == "web"* ]] && local log_type_short="Web" || local log_type_short="SSH"
	[ -f "$logfile" ] && login_log=$(grep -w "$login_ip" "$logfile" | grep -v "\[info\]" | tail -n 1)
	[ -n "$login_log" ] && log_timestamp=$(date -d "$(echo "$login_log" | awk '{print $1, $2}')" +%s) || log_timestamp=0

	# 查询 IP 归属地
	local login_ip_attribution=$(get_ip_attribution "${login_ip}")
	# 登录方式
	if [[ "$log_type" == "web"* ]]; then
		# Web 登录、非法登录
		local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{print $13}' | tail -n 1)
		[ "$login_mode" = "/" ] && login_mode="$(translate "/ (Homepage login)")"
	elif [[ "$log_type" == "ssh_login"* ]]; then
		# SSH 登录
		local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{print $8}' | tail -n 1)
	else
		local login_mode=$(logread notice | grep -E ".* $login_time.*$login_ip.*" | awk '{for(i=8;i<NF;i++) if($i=="from") break; else printf $i " "}' | tail -n 1)
	fi
		if [[ "$log_type" == *"failed"* ]]; then
			local login_title=$(translate "%s frequent %s login attempts" "$login_ip" "$log_type_short")
			local login_content_info="${str_splitline}${str_title_start}$(translate "Block Information")${str_title_end}"
			log "$(translate "Device %s (%s) frequently attempted %s %s login" "$login_ip" "$login_ip_attribution" "$log_type_short" "$login_mode")"
		else
			local login_title=$(translate "%s logged into router via %s" "$login_ip" "$log_type_short")
			local login_content_info="${str_splitline}${str_title_start}$(translate "Login Information")${str_title_end}"
			log "$(translate "Device %s (%s) logged into router via %s %s" "$login_ip" "$login_ip_attribution" "$log_type_short" "$login_mode")"
		fi
}



main "$@"
