140 lines
6.6 KiB
Bash
140 lines
6.6 KiB
Bash
#!/bin/bash
|
||
# ============================================================
|
||
# 服务器安全与资源监控脚本(轻量版)
|
||
# 功能:
|
||
# 1. SSH 爆破检测(10分钟内失败认证超阈值;同一波攻击只提醒一次,攻击停止后再犯才重新提醒)
|
||
# 2. 新来源 IP SSH 登录(仅"没见过的新 IP"首次登录时提醒,避免自己日常登录刷屏)
|
||
# 3. fail2ban 新封禁 IP(同一 IP 只提醒一次,永久去重)
|
||
# 4. 根分区磁盘、内存、系统负载告警
|
||
# 5. 未登记的公网监听端口变化告警(同一端口只提醒一次)
|
||
# 告警方式:写日志 /var/log/server-monitor.log + 可选 Bark 推送
|
||
# Bark 配置:/etc/server-monitor.conf 中设置 BARK_URL 后生效
|
||
# 部署:/usr/local/bin/server-monitor.sh,配合 cron 每 10 分钟执行
|
||
# 去重状态目录:/var/lib/server-monitor/
|
||
# - banned-ips.state :已提醒过的封禁 IP(只增不减 → 同一 IP 永不再重复提醒)
|
||
# - ssh-src-ips.state:已提醒过的登录来源 IP(仅新 IP 首次登录提醒)
|
||
# - ports.state :当前已提醒的未登记端口(端口消失后清除,再出现会再提醒)
|
||
# - ssh-brute.state :爆破告警状态(attacking=本轮已提醒;攻击停止后复位为 idle,
|
||
# 之后再出现攻击视为"新一波",重新提醒一次)
|
||
# 说明:完整审计数据仍在 /var/log/fail2ban.log 与 journald,且已由
|
||
# server-security-collector.sh 上报到 admin 后台,去重不影响追溯。
|
||
# ============================================================
|
||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||
|
||
# 读取可选配置(BARK_URL)
|
||
CONF=/etc/server-monitor.conf
|
||
[ -f "$CONF" ] && . "$CONF"
|
||
|
||
LOG=/var/log/server-monitor.log
|
||
NOW=$(date '+%F %T')
|
||
ALERT=""
|
||
BARK_URL=${BARK_URL:-}
|
||
|
||
alert() { ALERT="${ALERT}$1
|
||
"; }
|
||
|
||
# 去重状态目录(第 1、2、3、7 段共用)
|
||
STATE_DIR=/var/lib/server-monitor
|
||
mkdir -p "$STATE_DIR" 2>/dev/null
|
||
BAN_STATE=$STATE_DIR/banned-ips.state
|
||
SSH_STATE=$STATE_DIR/ssh-src-ips.state
|
||
PORT_STATE=$STATE_DIR/ports.state
|
||
BRUTE_STATE=$STATE_DIR/ssh-brute.state
|
||
touch "$BAN_STATE" "$SSH_STATE" "$PORT_STATE" 2>/dev/null
|
||
|
||
# ---------- 1) SSH 爆破检测(同一波攻击只提醒一次;攻击停止后再犯才重新提醒) ----------
|
||
# 逻辑:进入攻击态时提醒一次并置 attacking;攻击停止(10分钟内失败数回落)时静默复位为
|
||
# idle。之后再被攻击即视为"新一波",重新提醒。避免持续攻击每 10 分钟刷屏。
|
||
FAILED=$(sudo journalctl _SYSTEMD_UNIT=sshd.service --since "10 minutes ago" 2>/dev/null | grep -ci "Failed password")
|
||
if [ "${FAILED:-0}" -ge 10 ]; then
|
||
if [ "$(cat "$BRUTE_STATE" 2>/dev/null)" != "attacking" ]; then
|
||
# 取失败次数最多的前 3 个来源 IP,便于判断攻击来源
|
||
BRUTE_TOP=$(sudo journalctl _SYSTEMD_UNIT=sshd.service --since "10 minutes ago" 2>/dev/null \
|
||
| grep -i "Failed password" | awk '{for(i=1;i<=NF;i++) if($i=="from") print $(i+1)}' \
|
||
| sort | uniq -c | sort -rn | head -3 | awk '{printf "%s(%s次) ", $2, $1}')
|
||
alert "SSH爆破风险: 10分钟内 ${FAILED} 次失败认证(攻击进行中,fail2ban 已自动拦截,无需处理)
|
||
主要来源: ${BRUTE_TOP:-未知}"
|
||
echo "attacking" > "$BRUTE_STATE"
|
||
fi
|
||
else
|
||
# 攻击已停止 → 静默复位(只写日志,不推送),下次再犯才重新提醒
|
||
if [ "$(cat "$BRUTE_STATE" 2>/dev/null)" = "attacking" ]; then
|
||
echo "idle" > "$BRUTE_STATE"
|
||
echo "$NOW [INFO] SSH 爆破已停止(10分钟内失败 ${FAILED:-0} 次),爆破告警已复位,下次再犯将重新提醒" >> "$LOG"
|
||
fi
|
||
fi
|
||
|
||
# ---------- 2) 新来源 IP 登录(仅首次出现的 IP 才提醒) ----------
|
||
ACCEPTED=$(sudo journalctl _SYSTEMD_UNIT=sshd.service --since "10 minutes ago" 2>/dev/null | grep -iE "Accepted (password|publickey)")
|
||
if [ -n "$ACCEPTED" ]; then
|
||
NEWIPS=""
|
||
for ip in $(printf '%s\n' "$ACCEPTED" | awk '{for(i=1;i<=NF;i++) if($i=="from") print $(i+1)}' | sort -u); do
|
||
grep -qxF "$ip" "$SSH_STATE" 2>/dev/null || NEWIPS="${NEWIPS} ${ip}"
|
||
done
|
||
if [ -n "$NEWIPS" ]; then
|
||
alert "新来源IP登录:${NEWIPS}
|
||
$(printf '%s\n' "$ACCEPTED" | tail -3)"
|
||
for ip in $NEWIPS; do echo "$ip" >> "$SSH_STATE"; done
|
||
fi
|
||
fi
|
||
|
||
# ---------- 3) fail2ban 新封禁 IP(同一 IP 只提醒一次) ----------
|
||
CUR_BANNED=$(sudo fail2ban-client status sshd 2>/dev/null | sed -n 's/.*Banned IP list:[[:space:]]*//p')
|
||
NEW_BANNED=""
|
||
for ip in $CUR_BANNED; do
|
||
grep -qxF "$ip" "$BAN_STATE" 2>/dev/null || NEW_BANNED="${NEW_BANNED} ${ip}"
|
||
done
|
||
if [ -n "$NEW_BANNED" ]; then
|
||
alert "fail2ban 新封禁IP:${NEW_BANNED}(已自动封禁,无需处理)"
|
||
for ip in $NEW_BANNED; do echo "$ip" >> "$BAN_STATE"; done
|
||
fi
|
||
|
||
# ---------- 4) 根分区磁盘 ----------
|
||
DF=$(df -P / 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%')
|
||
[ "${DF:-0}" -ge 85 ] && alert "根分区使用率已达 ${DF}%"
|
||
|
||
# ---------- 5) 内存可用比 ----------
|
||
MEMFREE=$(free -m 2>/dev/null | awk '/Mem:/{print int($7/$2*100)}')
|
||
[ "${MEMFREE:-100}" -le 10 ] && alert "内存可用仅剩 ${MEMFREE}%"
|
||
|
||
# ---------- 6) 系统负载 ----------
|
||
LOAD=$(awk '{print int($1)}' /proc/loadavg 2>/dev/null)
|
||
[ "${LOAD:-0}" -ge 4 ] && alert "系统负载过高: $LOAD"
|
||
|
||
# ---------- 7) 公网监听端口漂移检测 ----------
|
||
# 基线:当前业务期望对外监听的端口
|
||
# SSH(22025) / Nginx(80,443) / Gitea(3000,2222) / Bark(8081, docker映射,防火墙已挡公网)
|
||
BASE_PORTS="22025 80 443 3000 2222 8081"
|
||
NOW_PORTS=$(ss -tlnH 2>/dev/null | awk '{print $4}' | grep -vE '^127\.' | grep -oE ':[0-9]+$' | tr -d ':' | sort -u)
|
||
EXTRA=""
|
||
for p in $NOW_PORTS; do
|
||
case " $BASE_PORTS " in
|
||
*" $p "*) ;;
|
||
*) EXTRA="${EXTRA} ${p}" ;;
|
||
esac
|
||
done
|
||
# 同一端口只提醒一次(端口消失后再出现视为新事件)
|
||
NEW_EXTRA=""
|
||
for p in $EXTRA; do
|
||
grep -qxF "$p" "$PORT_STATE" 2>/dev/null || NEW_EXTRA="${NEW_EXTRA} ${p}"
|
||
done
|
||
if [ -n "$EXTRA" ]; then
|
||
printf '%s\n' $EXTRA > "$PORT_STATE"
|
||
else
|
||
: > "$PORT_STATE"
|
||
fi
|
||
[ -n "$NEW_EXTRA" ] && alert "发现未登记的公网监听端口:${NEW_EXTRA}(请核查是否为新增服务)"
|
||
|
||
# ---------- 输出:日志 + Bark 推送 ----------
|
||
if [ -n "$ALERT" ]; then
|
||
echo -e "$NOW [ALERT] $HOSTNAME
|
||
$ALERT" >> "$LOG"
|
||
if [ -n "$BARK_URL" ]; then
|
||
# Bark 自建服务端(路径式 key):POST JSON 到 /<device_key>
|
||
BODY=$(printf '%s' "$ALERT" | tr '\n' ' ' | tr '"' "'")
|
||
curl -s -m 8 -X POST "$BARK_URL" -H "Content-Type: application/json" -d "{\"title\":\"服务器告警\",\"body\":\"$BODY\"}" >/dev/null 2>&1
|
||
fi
|
||
else
|
||
echo "$NOW [OK] 登录正常 负载${LOAD:-?} 根分区${DF:-?}% 内存可用${MEMFREE:-?}%" >> "$LOG"
|
||
fi
|