small-packages/watchdog/files/watchdog.share
2026-05-10 09:48:30 +08:00

534 lines
18 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "$@"