Files
ssh-monitor/ssh-monitor
T
PapaTramp 454a220890 fix: daily report HTML line breaks without double spacing
Use single <br> in report_html and document DAILY_REPORT_ENABLED toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:52:09 +10:00

2088 lines
71 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
set -Eeuo pipefail
IFS=$'\n\t'
# ============================================
# КОНФИГУРАЦИЯ
# ============================================
CONFIG_FILE="/etc/ssh-monitor.conf"
SSH_MONITOR_VERSION="1.2.7-SAC"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/sac-client.sh" ]; then
# shellcheck source=sac-client.sh
source "$SCRIPT_DIR/sac-client.sh"
else
sac_normalize_mode() { printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]'; }
sac_is_configured() { return 1; }
sac_check_health() { return 1; }
notify_or_sac() { notify_send "${5:-$4}"; }
run_check_sac() { printf 'sac-client.sh не найден\n' >&2; return 1; }
fi
TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID=""
BACKUP_WEBHOOK_URL=""
# Очередь оповещений: CSV из имён telegram, email (или tg, mail).
# Пусто = авто: только настроенные каналы, порядок telegram → email.
NOTIFY_ORDER=""
# Security Alert Center: off | exclusive | dual | fallback
UseSAC="off"
SAC_URL=""
SAC_API_KEY=""
SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool"
SAC_TIMEOUT_SEC="12"
SAC_SEND_HEARTBEAT="1"
SAC_FALLBACK_FAILURES="5"
# Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO.
MAIL_SMTP_HOST=""
MAIL_SMTP_PORT="587"
MAIL_SMTP_USER=""
MAIL_SMTP_PASSWORD=""
MAIL_FROM=""
MAIL_TO=""
MAIL_SMTP_STARTTLS="1"
MAIL_SMTP_SSL="0"
# Конфигурация логов
LOG_FILE="/var/log/ssh_monitor.log"
LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt"
LAST_REPORT_FILE="/var/log/last_daily_report.txt"
DAILY_REPORT_ENABLED=1
DAILY_REPORT_HOUR=9
DAILY_REPORT_TOP_IPS=5
# Пусто = календарь и час отчёта в зоне процесса (как `date`, обычно совпадает с /etc/localtime).
# Иначе IANA, например Europe/Moscow — явная зона для DAILY_REPORT_HOUR и даты «сегодня» в отчёте.
DAILY_REPORT_TZ=""
# IANA-зона для строк «🕐 Время» в Telegram/email. Пусто = тогда DAILY_REPORT_TZ, если она задана, иначе зона процесса (часто UTC у systemd — см. README).
NOTIFY_TZ=""
# Подпись сервера в уведомлениях: пусто = hostname (+ IPv4); иначе фиксированное имя (удобно при одинаковых hostname).
SERVER_DISPLAY_NAME=""
# Явный IPv4 хоста для SAC payload (host.ipv4). Пусто = автоопределение.
SERVER_IPV4=""
# Не слать повтор «Accepted» для той же пары user+IP чаще, чем раз в N секунд (разные port в журнале — один вход).
SSH_ACCEPT_NOTIFY_DEDUP_SEC=5
# Временные файлы для отслеживания уже обработанных событий
LAST_SSH_CHECK_FILE="/var/log/last_ssh_check"
LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check"
LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_check"
LAST_LOGIND_CHECK_FILE="/var/log/last_logind_check"
# systemd-logind (консоль, графика, seat; SSH тоже создаёт сессии — см. LOGIND_SKIP_REMOTE)
ENABLE_LOGIND_MONITOR=1
LOGIND_NOTIFY_NEW=1
LOGIND_NOTIFY_REMOVED=0
LOGIND_NOTIFY_FAILED=1
# 1 = не слать дубль Telegram для SSH-сессий logind (Type=ssh или Service=*sshd*; см. logind_session_is_ssh_duplicate_for_notify)
LOGIND_SKIP_REMOTE=1
# Эксплуатация / health
PROMETHEUS_TEXTFILE_DIR=""
HEALTHCHECK_STATUS_FILE=""
# Порог «массового» брутфорса (окно journalctl/auth за N секунд)
BRUTE_WINDOW_SEC=900
BRUTE_MIN_FAILS=30
BRUTE_NOTIFY_COOLDOWN_SEC=3600
# ============================================
# НАСТРОЙКИ БЛОКИРОВКИ IP
# ============================================
# 1 = мониторинг + автобан IP (ipset); 0 = только мониторинг и уведомления (ipset/iptables не трогаем).
ENABLE_IP_BAN=1
BAN_TIME=3600
MAX_ATTEMPTS=3
BAN_CHECK_INTERVAL=60
MONITOR_INTERVAL=10
BAN_LIST_FILE="/var/log/ssh_bans.txt"
# Блокировка через ipset (одно правило iptables/ip6tables + таймаут элемента в ядре).
# Имена наборов можно переопределить в /etc/ssh-monitor.conf
IPSET_NAME_V4="ssh_monitor_ban"
IPSET_NAME_V6="ssh_monitor_ban_v6"
IPSET_MAXELEM=1048576
WHITELIST_IPS=(
"127.0.0.1"
)
WHITELIST_SUBNETS=(
)
declare -A FAIL_COUNTS
declare -A BRUTE_LAST_NOTIFY
declare -A SSH_ACCEPT_LAST_NOTIFY
CHECK_CONFIG_ONLY=0
DRY_RUN=0
# Защита от двойного вызова: при SIGTERM срабатывает trap TERM, затем при выходе — trap EXIT
SHUTDOWN_NOTIFIED=0
declare -a NOTIFY_CHAIN=()
trim_value() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
echo "$value"
}
csv_to_array() {
local csv="$1"
local -n out_ref="$2"
local item=""
out_ref=()
[ -z "$csv" ] && return 0
IFS=',' read -r -a raw_items <<< "$csv"
for item in "${raw_items[@]}"; do
item="$(trim_value "$item")"
[ -n "$item" ] && out_ref+=("$item")
done
}
validate_numeric_or_default() {
local name="$1"
local default_value="$2"
local current_value="${!name:-}"
if [[ "$current_value" =~ ^[0-9]+$ ]] && [ "$current_value" -gt 0 ]; then
return 0
fi
printf 'WARN: Некорректное значение %s="%s", использую %s\n' "$name" "$current_value" "$default_value" >&2
printf -v "$name" '%s' "$default_value"
}
validate_report_hour() {
local current_value="${DAILY_REPORT_HOUR:-}"
if [[ "$current_value" =~ ^[0-9]+$ ]] && [ "$current_value" -ge 0 ] && [ "$current_value" -le 23 ]; then
return 0
fi
printf 'WARN: DAILY_REPORT_HOUR="%s" вне диапазона 0..23, использую 9\n' "$current_value" >&2
DAILY_REPORT_HOUR=9
}
load_config() {
local default_whitelist_ips_csv
local default_whitelist_subnets_csv
default_whitelist_ips_csv=$(IFS=','; echo "${WHITELIST_IPS[*]}")
default_whitelist_subnets_csv=$(IFS=','; echo "${WHITELIST_SUBNETS[*]}")
local cfg_telegram_bot_token="${TELEGRAM_BOT_TOKEN}"
local cfg_telegram_chat_id="${TELEGRAM_CHAT_ID}"
local cfg_backup_webhook_url="${BACKUP_WEBHOOK_URL}"
local cfg_log_file="${LOG_FILE}"
local cfg_last_heartbeat_file="${LAST_HEARTBEAT_FILE}"
local cfg_last_report_file="${LAST_REPORT_FILE}"
local cfg_daily_report_enabled="${DAILY_REPORT_ENABLED:-1}"
local cfg_daily_report_hour="${DAILY_REPORT_HOUR}"
local cfg_daily_report_top_ips="${DAILY_REPORT_TOP_IPS}"
local cfg_daily_report_tz="${DAILY_REPORT_TZ}"
local cfg_notify_tz="${NOTIFY_TZ:-}"
local cfg_server_display_name="${SERVER_DISPLAY_NAME:-}"
local cfg_ssh_accept_notify_dedup_sec="${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}"
local cfg_last_ssh_check_file="${LAST_SSH_CHECK_FILE}"
local cfg_last_sudo_check_file="${LAST_SUDO_CHECK_FILE}"
local cfg_last_security_events_file="${LAST_SECURITY_EVENTS_FILE}"
local cfg_last_logind_check_file="${LAST_LOGIND_CHECK_FILE}"
local cfg_enable_logind_monitor="${ENABLE_LOGIND_MONITOR}"
local cfg_logind_notify_new="${LOGIND_NOTIFY_NEW}"
local cfg_logind_notify_removed="${LOGIND_NOTIFY_REMOVED}"
local cfg_logind_notify_failed="${LOGIND_NOTIFY_FAILED}"
local cfg_logind_skip_remote="${LOGIND_SKIP_REMOTE}"
local cfg_prometheus_textfile_dir="${PROMETHEUS_TEXTFILE_DIR}"
local cfg_healthcheck_status_file="${HEALTHCHECK_STATUS_FILE}"
local cfg_brute_window_sec="${BRUTE_WINDOW_SEC}"
local cfg_brute_min_fails="${BRUTE_MIN_FAILS}"
local cfg_brute_notify_cooldown_sec="${BRUTE_NOTIFY_COOLDOWN_SEC}"
local cfg_enable_ip_ban="${ENABLE_IP_BAN:-1}"
local cfg_ban_time="${BAN_TIME}"
local cfg_max_attempts="${MAX_ATTEMPTS}"
local cfg_ban_check_interval="${BAN_CHECK_INTERVAL}"
local cfg_monitor_interval="${MONITOR_INTERVAL}"
local cfg_ban_list_file="${BAN_LIST_FILE}"
local cfg_ipset_name_v4="${IPSET_NAME_V4:-ssh_monitor_ban}"
local cfg_ipset_name_v6="${IPSET_NAME_V6:-ssh_monitor_ban_v6}"
local cfg_ipset_maxelem="${IPSET_MAXELEM:-1048576}"
local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}"
local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}"
local cfg_notify_order="${NOTIFY_ORDER:-}"
local cfg_use_sac="${UseSAC:-off}"
local cfg_sac_url="${SAC_URL:-}"
local cfg_sac_api_key="${SAC_API_KEY:-}"
local cfg_sac_spool_dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}"
local cfg_sac_timeout_sec="${SAC_TIMEOUT_SEC:-12}"
local cfg_mail_smtp_host="${MAIL_SMTP_HOST:-}"
local cfg_mail_smtp_port="${MAIL_SMTP_PORT:-587}"
local cfg_mail_smtp_user="${MAIL_SMTP_USER:-}"
local cfg_mail_smtp_password="${MAIL_SMTP_PASSWORD:-}"
local cfg_mail_from="${MAIL_FROM:-}"
local cfg_mail_to="${MAIL_TO:-}"
local cfg_mail_smtp_starttls="${MAIL_SMTP_STARTTLS:-1}"
local cfg_mail_smtp_ssl="${MAIL_SMTP_SSL:-0}"
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
else
printf 'WARN: Конфиг %s не найден, использую значения по умолчанию\n' "$CONFIG_FILE" >&2
fi
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-$cfg_telegram_bot_token}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-$cfg_telegram_chat_id}"
BACKUP_WEBHOOK_URL="${BACKUP_WEBHOOK_URL:-$cfg_backup_webhook_url}"
LOG_FILE="${LOG_FILE:-$cfg_log_file}"
LAST_HEARTBEAT_FILE="${LAST_HEARTBEAT_FILE:-$cfg_last_heartbeat_file}"
LAST_REPORT_FILE="${LAST_REPORT_FILE:-$cfg_last_report_file}"
LAST_SSH_CHECK_FILE="${LAST_SSH_CHECK_FILE:-$cfg_last_ssh_check_file}"
LAST_SUDO_CHECK_FILE="${LAST_SUDO_CHECK_FILE:-$cfg_last_sudo_check_file}"
LAST_SECURITY_EVENTS_FILE="${LAST_SECURITY_EVENTS_FILE:-$cfg_last_security_events_file}"
LAST_LOGIND_CHECK_FILE="${LAST_LOGIND_CHECK_FILE:-$cfg_last_logind_check_file}"
ENABLE_LOGIND_MONITOR="${ENABLE_LOGIND_MONITOR:-$cfg_enable_logind_monitor}"
LOGIND_NOTIFY_NEW="${LOGIND_NOTIFY_NEW:-$cfg_logind_notify_new}"
LOGIND_NOTIFY_REMOVED="${LOGIND_NOTIFY_REMOVED:-$cfg_logind_notify_removed}"
LOGIND_NOTIFY_FAILED="${LOGIND_NOTIFY_FAILED:-$cfg_logind_notify_failed}"
LOGIND_SKIP_REMOTE="${LOGIND_SKIP_REMOTE:-$cfg_logind_skip_remote}"
PROMETHEUS_TEXTFILE_DIR="${PROMETHEUS_TEXTFILE_DIR:-$cfg_prometheus_textfile_dir}"
HEALTHCHECK_STATUS_FILE="${HEALTHCHECK_STATUS_FILE:-$cfg_healthcheck_status_file}"
BAN_LIST_FILE="${BAN_LIST_FILE:-$cfg_ban_list_file}"
IPSET_NAME_V4="${IPSET_NAME_V4:-$cfg_ipset_name_v4}"
IPSET_NAME_V6="${IPSET_NAME_V6:-$cfg_ipset_name_v6}"
IPSET_MAXELEM="${IPSET_MAXELEM:-$cfg_ipset_maxelem}"
DAILY_REPORT_ENABLED="$(trim_value "${DAILY_REPORT_ENABLED:-$cfg_daily_report_enabled}")"
DAILY_REPORT_HOUR="${DAILY_REPORT_HOUR:-$cfg_daily_report_hour}"
DAILY_REPORT_TOP_IPS="${DAILY_REPORT_TOP_IPS:-$cfg_daily_report_top_ips}"
DAILY_REPORT_TZ="$(trim_value "${DAILY_REPORT_TZ:-$cfg_daily_report_tz}")"
NOTIFY_TZ="$(trim_value "${NOTIFY_TZ:-$cfg_notify_tz}")"
SERVER_DISPLAY_NAME="$(trim_value "${SERVER_DISPLAY_NAME:-$cfg_server_display_name}")"
SSH_ACCEPT_NOTIFY_DEDUP_SEC="$(trim_value "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-$cfg_ssh_accept_notify_dedup_sec}")"
if ! [[ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" =~ ^[0-9]+$ ]]; then
SSH_ACCEPT_NOTIFY_DEDUP_SEC=5
fi
BRUTE_WINDOW_SEC="${BRUTE_WINDOW_SEC:-$cfg_brute_window_sec}"
BRUTE_MIN_FAILS="${BRUTE_MIN_FAILS:-$cfg_brute_min_fails}"
BRUTE_NOTIFY_COOLDOWN_SEC="${BRUTE_NOTIFY_COOLDOWN_SEC:-$cfg_brute_notify_cooldown_sec}"
ENABLE_IP_BAN="${ENABLE_IP_BAN:-$cfg_enable_ip_ban}"
BAN_TIME="${BAN_TIME:-$cfg_ban_time}"
MAX_ATTEMPTS="${MAX_ATTEMPTS:-$cfg_max_attempts}"
BAN_CHECK_INTERVAL="${BAN_CHECK_INTERVAL:-$cfg_ban_check_interval}"
MONITOR_INTERVAL="${MONITOR_INTERVAL:-$cfg_monitor_interval}"
local whitelist_ips_csv="${WHITELIST_IPS:-$cfg_whitelist_ips_csv}"
local whitelist_subnets_csv="${WHITELIST_SUBNETS:-$cfg_whitelist_subnets_csv}"
csv_to_array "$whitelist_ips_csv" WHITELIST_IPS
csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS
NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")"
UseSAC="$(trim_value "${UseSAC:-$cfg_use_sac}")"
SAC_URL="$(trim_value "${SAC_URL:-$cfg_sac_url}")"
SAC_API_KEY="$(trim_value "${SAC_API_KEY:-$cfg_sac_api_key}")"
SAC_SPOOL_DIR="$(trim_value "${SAC_SPOOL_DIR:-$cfg_sac_spool_dir}")"
SAC_TIMEOUT_SEC="$(trim_value "${SAC_TIMEOUT_SEC:-$cfg_sac_timeout_sec}")"
MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")"
MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}"
MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")"
MAIL_SMTP_PASSWORD="${MAIL_SMTP_PASSWORD:-$cfg_mail_smtp_password}"
MAIL_FROM="$(trim_value "${MAIL_FROM:-$cfg_mail_from}")"
MAIL_TO="$(trim_value "${MAIL_TO:-$cfg_mail_to}")"
MAIL_SMTP_STARTTLS="${MAIL_SMTP_STARTTLS:-$cfg_mail_smtp_starttls}"
MAIL_SMTP_SSL="${MAIL_SMTP_SSL:-$cfg_mail_smtp_ssl}"
local _eib
_eib="$(trim_value "${ENABLE_IP_BAN:-1}")"
case "${_eib,,}" in
0 | no | off | false) ENABLE_IP_BAN=0 ;;
*) ENABLE_IP_BAN=1 ;;
esac
}
# Дата/время для ежедневного отчёта (час DAILY_REPORT_HOUR и «сегодня» в LAST_REPORT_FILE).
report_date() {
if [ -n "${DAILY_REPORT_TZ:-}" ]; then
TZ="$DAILY_REPORT_TZ" date "$@"
else
date "$@"
fi
}
# Время в текстах уведомлений (Telegram и др.): явная зона, иначе как у ежедневного отчёта, иначе как у процесса.
notification_date() {
if [ -n "${NOTIFY_TZ:-}" ]; then
TZ="$NOTIFY_TZ" date "$@"
elif [ -n "${DAILY_REPORT_TZ:-}" ]; then
TZ="$DAILY_REPORT_TZ" date "$@"
else
date "$@"
fi
}
is_telegram_configured() {
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]
}
is_email_configured() {
[ -n "${MAIL_SMTP_HOST:-}" ] && [ -n "${MAIL_FROM:-}" ] && [ -n "${MAIL_TO:-}" ] && command -v python3 >/dev/null 2>&1
}
# Заполняет NOTIFY_CHAIN: при пустом NOTIFY_ORDER — только реально настроенные каналы (telegram → email).
# При непустом NOTIFY_ORDER — ровно указанный список (имена каналов), без добавления «лишних»; notify_send шлёт во ВСЕ каналы из списка.
refresh_notify_chain() {
NOTIFY_CHAIN=()
local order_raw
order_raw="$(trim_value "${NOTIFY_ORDER:-}")"
if [ -z "$order_raw" ]; then
if is_telegram_configured; then
NOTIFY_CHAIN+=(telegram)
fi
if is_email_configured; then
NOTIFY_CHAIN+=(email)
fi
return 0
fi
local -a raw_toks=()
csv_to_array "$order_raw" raw_toks
local tok norm
for tok in "${raw_toks[@]}"; do
norm="${tok,,}"
case "$norm" in
telegram | tg) NOTIFY_CHAIN+=(telegram) ;;
email | mail) NOTIFY_CHAIN+=(email) ;;
*)
printf 'WARN: NOTIFY_ORDER: неизвестный токен «%s», пропускаю\n' "$tok" >&2
;;
esac
done
}
notify_chain_human() {
if [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then
printf '%s' "нет активных каналов"
return 0
fi
(IFS=','; printf '%s' "${NOTIFY_CHAIN[*]}")
}
validate_config() {
validate_report_hour
validate_numeric_or_default "BAN_TIME" "3600"
validate_numeric_or_default "MAX_ATTEMPTS" "3"
validate_numeric_or_default "BAN_CHECK_INTERVAL" "60"
validate_numeric_or_default "MONITOR_INTERVAL" "10"
validate_numeric_or_default "DAILY_REPORT_TOP_IPS" "5"
validate_numeric_or_default "BRUTE_WINDOW_SEC" "900"
validate_numeric_or_default "BRUTE_MIN_FAILS" "30"
validate_numeric_or_default "BRUTE_NOTIFY_COOLDOWN_SEC" "3600"
validate_numeric_or_default "MAIL_SMTP_PORT" "587"
validate_numeric_or_default "IPSET_MAXELEM" "1048576"
if [ "${ENABLE_IP_BAN:-1}" = "1" ] && [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then
if ! command -v ipset >/dev/null 2>&1; then
printf '%s\n' 'Включён автобан IP (ENABLE_IP_BAN=1): требуется утилита ipset (например: apt install ipset)' >&2
exit 1
fi
fi
local sac_mode
sac_mode="$(sac_normalize_mode "${UseSAC:-off}")"
case "$sac_mode" in
off) ;;
exclusive | dual | fallback)
if ! sac_is_configured; then
printf '%s\n' "UseSAC=${sac_mode}: задайте SAC_URL и SAC_API_KEY" >&2
exit 1
fi
;;
*)
printf '%s\n' "Неверный UseSAC=\"${UseSAC:-}\"" >&2
exit 1
;;
esac
refresh_notify_chain
if [ "$sac_mode" = "exclusive" ]; then
if [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${CHECK_SAC_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then
if ! sac_check_health; then
printf '%s\n' 'UseSAC=exclusive: SAC /health недоступен' >&2
exit 1
fi
fi
elif [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then
printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2
exit 1
fi
}
# ============================================
# УТИЛИТЫ: статистика auth.log за 24 часа (Python)
# ============================================
stats_auth_log_24h_json() {
if command -v python3 >/dev/null 2>&1; then
python3 - "$@" <<'PY'
import datetime, gzip, json, re, sys, collections
from pathlib import Path
try:
top_n = int(sys.argv[1])
except Exception:
top_n = 5
NOW = datetime.datetime.now()
CUTOFF = NOW - datetime.timedelta(hours=24)
MONTHS = {
"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12,
}
paths = []
for p in (Path("/var/log/auth.log"), Path("/var/log/auth.log.1")):
if p.is_file():
paths.append(p)
try:
for p in sorted(Path("/var/log").glob("auth.log.*.gz")):
paths.append(p)
except Exception:
pass
accepted = failed = sudo_cmd = 0
failed_by_ip = collections.Counter()
line_re = re.compile(
r"^(?P [A-Za-z]{3})\s+(?P \d{1,2})\s+(?P \d{2}:\d{2}:\d{2})\s+"
r"(?P.+)$"
)
def open_any(path):
if str(path).endswith(".gz"):
return gzip.open(path, "rt", errors="ignore")
return path.open("r", errors="ignore")
def parse_ts(mon: str, day: int, hms: str):
m = MONTHS.get(mon)
if not m:
return None
try:
h, mi, s = [int(x) for x in hms.split(":")]
except Exception:
return None
year = NOW.year
dt = datetime.datetime(year, m, int(day), h, mi, s)
if dt > NOW + datetime.timedelta(days=2):
dt = dt.replace(year=year - 1)
if dt < NOW - datetime.timedelta(days=400):
dt = dt.replace(year=year + 1)
return dt
ip_from_failed = re.compile(r" from ([^ ]+) port ")
ip_from_accepted = re.compile(r" from ([^ ]+) port ")
for path in paths:
try:
with open_any(path) as fh:
for raw in fh:
m = line_re.match(raw)
if not m:
continue
dt = parse_ts(m.group("mon"), int(m.group("day")), m.group("hms"))
if not dt or dt < CUTOFF:
continue
rest = m.group("rest")
if "sshd" in rest and "Accepted" in rest:
accepted += 1
if "sshd" in rest and "Failed password" in rest:
failed += 1
im = ip_from_failed.search(rest)
if im:
failed_by_ip[im.group(1)] += 1
if "sudo" in rest and "COMMAND" in rest:
sudo_cmd += 1
except FileNotFoundError:
continue
except Exception:
continue
top = [f"{ip}:{cnt}" for ip, cnt in failed_by_ip.most_common(top_n)]
print(json.dumps({"accepted": accepted, "failed": failed, "sudo": sudo_cmd, "top": top}))
PY
else
echo '{"accepted":0,"failed":0,"sudo":0,"top":[]}'
fi
}
# ============================================
# ЗАПИСЬ В ЛОГ
# ============================================
write_log() {
local message="$1"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
if [ "${CHECK_CONFIG_ONLY:-0}" = "1" ]; then
printf '%s - %s\n' "$timestamp" "$message"
return 0
fi
if [ "${DRY_RUN:-0}" = "1" ] && [ ! -w "$(dirname "$LOG_FILE")" ] 2>/dev/null; then
printf '%s - %s\n' "$timestamp" "$message"
return 0
fi
echo "${timestamp} - ${message}" >>"$LOG_FILE"
echo "${timestamp} - ${message}"
}
# ============================================
# Подпись сервера в текстах уведомлений
# ============================================
# Для Telegram и отчётов: «hostname (IPv4)» или SERVER_DISPLAY_NAME (+ IPv4); без IPv4 — только имя.
server_display_name_with_ip() {
local hn ip
if [ -n "${SERVER_DISPLAY_NAME:-}" ]; then
hn="${SERVER_DISPLAY_NAME}"
else
hn=$(hostname 2>/dev/null | tr -d '\r\n')
[ -z "$hn" ] && hn="(unknown)"
fi
ip=""
if command -v ip >/dev/null 2>&1; then
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}')
[ -z "$ip" ] && ip=$(ip -4 route get 8.8.8.8 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}')
fi
if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1; then
if hostname -I >/dev/null 2>&1; then
ip=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -Ev '^(127\.|0\.0\.0\.0)$' | head -n1)
fi
fi
if [ -n "$ip" ]; then
printf '%s (%s)' "$hn" "$ip"
else
printf '%s' "$hn"
fi
}
# Вставляет «🖥️ Сервер: …» сразу после первой строки (заголовка), если строки ещё нет.
message_ensure_server_line() {
local msg="$1"
if [[ "$msg" == *'🖥️ Сервер:'* ]]; then
printf '%s' "$msg"
return 0
fi
local first rest
first="${msg%%$'\n'*}"
if [ "$first" = "$msg" ]; then
printf '%s\n🖥️ Сервер: %s' "$first" "$(server_display_name_with_ip)"
else
rest="${msg#*$'\n'}"
printf '%s\n🖥️ Сервер: %s\n%s' "$first" "$(server_display_name_with_ip)" "$rest"
fi
}
# ============================================
# УВЕДОМЛЕНИЯ: telegram / email — по NOTIFY_CHAIN во все перечисленные каналы; BACKUP_WEBHOOK только если все неуспешны.
# ============================================
send_email_raw() {
local message="$1"
[ -n "$message" ] || return 1
is_email_configured || return 1
local use_ssl use_starttls
use_ssl="${MAIL_SMTP_SSL:-0}"
use_starttls="${MAIL_SMTP_STARTTLS:-1}"
if MAIL_BODY="$message" \
MAIL_SMTP_HOST="$MAIL_SMTP_HOST" \
MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-587}" \
MAIL_SMTP_USER="${MAIL_SMTP_USER:-}" \
MAIL_SMTP_PASSWORD="${MAIL_SMTP_PASSWORD:-}" \
MAIL_FROM="$MAIL_FROM" \
MAIL_TO="$MAIL_TO" \
MAIL_USE_SSL="$use_ssl" \
MAIL_USE_STARTTLS="$use_starttls" \
python3 - <<'PY'
import os, smtplib, ssl, sys, socket
from email.message import EmailMessage
host = (os.environ.get("MAIL_SMTP_HOST") or "").strip()
if not host:
sys.exit(1)
try:
port = int(os.environ.get("MAIL_SMTP_PORT") or "587")
except ValueError:
port = 587
from_addr = (os.environ.get("MAIL_FROM") or "").strip()
to_raw = (os.environ.get("MAIL_TO") or "").strip()
if not from_addr or not to_raw:
sys.exit(1)
recipients = [x.strip() for x in to_raw.split(",") if x.strip()]
if not recipients:
sys.exit(1)
user = (os.environ.get("MAIL_SMTP_USER") or "").strip()
password = os.environ.get("MAIL_SMTP_PASSWORD") or ""
use_ssl = (os.environ.get("MAIL_USE_SSL") or "0").strip() in ("1", "true", "yes", "on")
use_starttls = (os.environ.get("MAIL_USE_STARTTLS") or "1").strip() in ("1", "true", "yes", "on")
body = os.environ.get("MAIL_BODY") or ""
msg = EmailMessage()
msg["Subject"] = "[ssh-monitor] " + socket.gethostname()
msg["From"] = from_addr
msg["To"] = ", ".join(recipients)
msg.set_content(body, charset="utf-8")
ctx = ssl.create_default_context()
try:
if use_ssl:
with smtplib.SMTP_SSL(host, port, context=ctx, timeout=30) as s:
if user:
s.login(user, password)
s.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=30) as s:
s.ehlo()
if use_starttls:
s.starttls(context=ctx)
s.ehlo()
if user:
s.login(user, password)
s.send_message(msg)
except Exception:
sys.exit(1)
sys.exit(0)
PY
then
return 0
fi
return 1
}
send_telegram_raw() {
local message="$1"
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
return 1
fi
local url="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"
local body
if ! body=$(curl -fsS -X POST "$url" \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${message}" \
--data-urlencode "parse_mode=HTML" \
--max-time 10 2>/dev/null); then
return 1
fi
if echo "$body" | grep -q '"ok":true'; then
return 0
fi
return 1
}
send_backup_webhook_raw() {
local message="$1"
if [ -z "${BACKUP_WEBHOOK_URL:-}" ]; then
return 1
fi
if ! command -v python3 >/dev/null 2>&1; then
return 1
fi
local plain="$message"
local payload
payload="$(BACKUP_MSG="$plain" python3 - <<'PY'
import json, os
print(json.dumps({"text": os.environ.get("BACKUP_MSG", "")}))
PY
)"
if curl -fsS -m 12 -X POST "$BACKUP_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "$payload" >/dev/null 2>&1; then
return 0
fi
return 1
}
sac_mirror_notify_message() {
local message="$1"
local mode
mode="$(sac_normalize_mode "${UseSAC:-off}")"
[ "$mode" = "off" ] && return 0
local summary
summary="$(printf '%s' "$message" | tr '\n' ' ' | head -c 400)"
sac_send_event "agent.notification" "info" "SSH Monitor" "$summary" "" || return 1
}
notify_send() {
local message="$1"
message="$(message_ensure_server_line "$message")"
if [ "${DRY_RUN:-0}" = "1" ]; then
printf '[DRY-RUN notify] %s\n' "$(printf '%s' "$message" | tr '\n' ' ')" >&2
return 0
fi
local sac_mode
sac_mode="$(sac_normalize_mode "${UseSAC:-off}")"
if [ "${NOTIFY_SKIP_SAC_MIRROR:-0}" != "1" ]; then
case "$sac_mode" in
exclusive)
if sac_mirror_notify_message "$message"; then
return 0
fi
write_log "WARN: UseSAC=exclusive: SAC не принял событие"
return 1
;;
dual)
sac_mirror_notify_message "$message" || true
;;
fallback)
if sac_mirror_notify_message "$message"; then
return 0
fi
;;
esac
fi
local any_ok=0
local ch
for ch in "${NOTIFY_CHAIN[@]}"; do
case "$ch" in
telegram)
if send_telegram_raw "$message"; then
any_ok=1
else
write_log "WARN: канал telegram: не удалось отправить уведомление"
fi
;;
email)
if send_email_raw "$message"; then
any_ok=1
else
write_log "WARN: канал email: не удалось отправить уведомление"
fi
;;
esac
done
if [ "$any_ok" -eq 1 ]; then
return 0
fi
if send_backup_webhook_raw "$message"; then
write_log "WARN: все каналы из NOTIFY_CHAIN недоступны, сообщение ушло в BACKUP_WEBHOOK_URL"
return 0
fi
write_log "WARN: не удалось доставить уведомление ни в один канал ($(notify_chain_human); BACKUP_WEBHOOK не задан или недоступен)"
return 1
}
# ============================================
# IP / подсети (IPv4 CIDR; IPv6 — только точное совпадение в WHITELIST_IPS)
# ============================================
ip_to_num() {
local ip="$1"
local a b c d
IFS=. read -r a b c d <<<"$ip"
echo "$((a * 256 ** 3 + b * 256 ** 2 + c * 256 + d))"
}
ip_in_subnet() {
local ip="$1"
local subnet="$2"
[[ "$ip" == *:* ]] && return 1
local network="${subnet%/*}"
local mask="${subnet#*/}"
local ip_num
local network_num
ip_num=$(ip_to_num "$ip")
network_num=$(ip_to_num "$network")
local mask_num=$((0xffffffff << (32 - mask) & 0xffffffff))
if [ $((ip_num & mask_num)) -eq $((network_num & mask_num)) ]; then
return 0
fi
return 1
}
is_whitelisted() {
local ip="$1"
for whitelist_ip in "${WHITELIST_IPS[@]}"; do
if [ "$ip" = "$whitelist_ip" ]; then
return 0
fi
done
[[ "$ip" == *:* ]] && return 1
for subnet in "${WHITELIST_SUBNETS[@]}"; do
if ip_in_subnet "$ip" "$subnet"; then
return 0
fi
done
return 1
}
# ============================================
# ipset + одно правило iptables/ip6tables (без per-IP правил)
# ============================================
# Таймаут бана задаётся на элементе ipset: ядро само удаляет IP по истечении
# времени. Файл BAN_LIST_FILE — для отчётов и восстановления после перезагрузки.
is_ipv6() {
[[ "$1" == *:* ]]
}
# Верхняя граница timeout для ipset (секунды), см. ipset(8).
IPSET_TIMEOUT_MAX=2147483
run_priv() {
if [ "${DRY_RUN:-0}" = "1" ]; then
write_log "DRY-RUN: $*"
return 0
fi
if [ "$EUID" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
ipset_timeout_clamp() {
local t="${1:-$BAN_TIME}"
if ! [[ "$t" =~ ^[0-9]+$ ]]; then
t="$BAN_TIME"
fi
if [ "$t" -gt "$IPSET_TIMEOUT_MAX" ]; then
echo "$IPSET_TIMEOUT_MAX"
else
echo "$t"
fi
}
ensure_ipset_and_iptables() {
if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then
return 0
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
return 0
fi
if ! command -v ipset >/dev/null 2>&1; then
write_log "CRIT: ipset не найден; установите: apt install ipset"
return 1
fi
local maxt="$IPSET_TIMEOUT_MAX"
run_priv ipset -exist create "$IPSET_NAME_V4" hash:ip timeout "$maxt" maxelem "$IPSET_MAXELEM" || {
write_log "CRIT: не удалось создать ipset $IPSET_NAME_V4"
return 1
}
if command -v ip6tables >/dev/null 2>&1; then
run_priv ipset -exist create "$IPSET_NAME_V6" hash:ip family inet6 timeout "$maxt" maxelem "$IPSET_MAXELEM" || \
write_log "WARN: не удалось создать ipset $IPSET_NAME_V6 (IPv6 бан отключён)"
fi
if ! run_priv iptables -C INPUT -m set --match-set "$IPSET_NAME_V4" src -j DROP 2>/dev/null; then
run_priv iptables -I INPUT 1 -m set --match-set "$IPSET_NAME_V4" src -j DROP
write_log "Добавлено iptables: -m set --match-set $IPSET_NAME_V4 src -j DROP"
fi
if command -v ip6tables >/dev/null 2>&1 && ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then
if ! run_priv ip6tables -C INPUT -m set --match-set "$IPSET_NAME_V6" src -j DROP 2>/dev/null; then
run_priv ip6tables -I INPUT 1 -m set --match-set "$IPSET_NAME_V6" src -j DROP
write_log "Добавлено ip6tables: -m set --match-set $IPSET_NAME_V6 src -j DROP"
fi
fi
save_firewall_state
return 0
}
save_firewall_state() {
if [ "${DRY_RUN:-0}" = "1" ]; then
return 0
fi
if [ ! -d /etc/iptables ]; then
return 0
fi
if command -v ipset >/dev/null 2>&1; then
run_priv ipset save -f /etc/iptables/ipset.rules 2>/dev/null || true
fi
if command -v iptables-save >/dev/null 2>&1; then
run_priv iptables-save >/etc/iptables/rules.v4 2>/dev/null || true
fi
if command -v ip6tables-save >/dev/null 2>&1; then
run_priv ip6tables-save >/etc/iptables/rules.v6 2>/dev/null || true
fi
}
do_ipset_add_ban() {
local ip="$1"
local t
t=$(ipset_timeout_clamp "${2:-$BAN_TIME}")
if is_ipv6 "$ip"; then
if ! ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then
write_log "WARN: IPv6 $ip — набор $IPSET_NAME_V6 недоступен, пропуск"
return 1
fi
run_priv ipset -exist del "$IPSET_NAME_V6" "$ip" 2>/dev/null || true
run_priv ipset add "$IPSET_NAME_V6" "$ip" timeout "$t" || return 1
else
run_priv ipset -exist del "$IPSET_NAME_V4" "$ip" 2>/dev/null || true
run_priv ipset add "$IPSET_NAME_V4" "$ip" timeout "$t" || return 1
fi
return 0
}
do_ipset_del_ban() {
local ip="$1"
if is_ipv6 "$ip"; then
run_priv ipset -exist del "$IPSET_NAME_V6" "$ip" 2>/dev/null || true
else
run_priv ipset -exist del "$IPSET_NAME_V4" "$ip" 2>/dev/null || true
fi
}
is_ip_blocked() {
local ip="$1"
if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then
return 1
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
return 1
fi
if is_ipv6 "$ip"; then
ipset list "$IPSET_NAME_V6" >/dev/null 2>&1 || return 1
run_priv ipset test "$IPSET_NAME_V6" "$ip" >/dev/null 2>&1
else
run_priv ipset test "$IPSET_NAME_V4" "$ip" >/dev/null 2>&1
fi
}
block_ip() {
local ip="$1"
local attempts="$2"
if is_whitelisted "$ip"; then
write_log "Попытка блокировки whitelist IP $ip - игнорируем"
return 0
fi
if is_ip_blocked "$ip"; then
return 0
fi
if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then
local ban_time_hours=$((BAN_TIME / 3600))
local message=" ⚠️ ЛИМИТ НЕУДАЧНЫХ SSH (автобан отключён в конфиге) "$'\n'
message="${message}🌐 IP: ${ip}"$'\n'
message="${message}👤 Попыток: ${attempts} (порог ${MAX_ATTEMPTS}; ENABLE_IP_BAN=0)"$'\n'
message="${message}️ ipset/firewall не меняются. При ENABLE_IP_BAN=1 бан был бы ~${ban_time_hours} ч."$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" SAC_D_MAX="$MAX_ATTEMPTS" \
notify_or_sac "ssh.ip.bruteforce.threshold" "warning" "Лимит SSH без бана" "${ip}: ${attempts} попыток" "$message"
write_log "Порог неудачных SSH для $ip ($attempts попыток), автобан отключён (ENABLE_IP_BAN=0)"
return 0
fi
local ban_until=$(($(date '+%s') + BAN_TIME))
local t_use
t_use=$(ipset_timeout_clamp "$BAN_TIME")
if ! do_ipset_add_ban "$ip" "$t_use"; then
write_log "CRIT: не удалось добавить $ip в ipset"
return 1
fi
echo "$ip|$ban_until|$attempts|$(date '+%Y-%m-%d %H:%M:%S')" >>"$BAN_LIST_FILE"
save_firewall_state
local ban_time_hours=$((BAN_TIME / 3600))
local message=" 🚫 IP ЗАБЛОКИРОВАН АВТОМАТИЧЕСКИ "$'\n'
message="${message}🌐 IP: ${ip}"$'\n'
message="${message}📊 Неудачных попыток: ${attempts}"$'\n'
message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов) (ipset timeout)"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" \
notify_or_sac "ssh.ip.banned" "high" "IP заблокирован" "${ip} (${attempts} попыток)" "$message"
write_log "AUTO-BAN: IP $ip в ipset на $ban_time_hours ч. ($attempts попыток)"
# Один IP — одна строка: после истечения ipset-таймаута is_ip_blocked=0, а в файле
# бан ещё «действует» → повторный бан иначе дублировал бы строки.
ban_list_rewrite_deduped 0
}
# Ядро само удаляет элемент ipset по timeout; файл — учёт. Дубликаты возникают,
# если ipset уже сбросил IP, а ban_until в файле ещё в будущем (повторный бан).
# По каждому IP оставляем одну запись с максимальным ban_until; просроченные убираем.
# $1=1 — не писать в лог (внутренний вызов из цикла проверки).
ban_list_rewrite_deduped() {
local suppress_log="${1:-0}"
local current_time
current_time=$(date '+%s')
if [ ! -f "$BAN_LIST_FILE" ]; then
return 0
fi
declare -A best_until=()
declare -A best_line=()
local ip ban_until attempts ban_time
while IFS='|' read -r ip ban_until attempts ban_time || [ -n "${ip:-}" ]; do
[ -z "${ip:-}" ] || [ -z "${ban_until:-}" ] && continue
[ "$current_time" -ge "$ban_until" ] && continue
if [ -z "${best_until[$ip]:-}" ] || [ "$ban_until" -gt "${best_until[$ip]}" ]; then
best_until[$ip]=$ban_until
best_line[$ip]="${ip}|${ban_until}|${attempts}|${ban_time}"
fi
done <"$BAN_LIST_FILE"
local temp_file
temp_file=$(mktemp)
local sorted_ips k
sorted_ips=$(printf '%s\n' "${!best_line[@]}" | LC_ALL=C sort)
while IFS= read -r k; do
[ -z "$k" ] && continue
echo "${best_line[$k]}" >>"$temp_file"
done <<<"$sorted_ips"
if [ ! -s "$temp_file" ]; then
if [ -s "$BAN_LIST_FILE" ]; then
: >"$BAN_LIST_FILE"
if [ "$suppress_log" != "1" ]; then
write_log "Бан-лист: все записи просрочены — файл очищен"
fi
fi
rm -f "$temp_file"
return 0
fi
if cmp -s "$BAN_LIST_FILE" "$temp_file" 2>/dev/null; then
rm -f "$temp_file"
return 0
fi
# Тот же набор активных банов, но другой порядок строк — без лишней перезаписи/лога.
if cmp -s <(LC_ALL=C sort "$BAN_LIST_FILE") <(LC_ALL=C sort "$temp_file") 2>/dev/null; then
rm -f "$temp_file"
return 0
fi
local old_lines new_lines
old_lines=$(wc -l <"$BAN_LIST_FILE" | tr -d ' ' || echo 0)
new_lines=$(wc -l <"$temp_file" | tr -d ' ' || echo 0)
mv "$temp_file" "$BAN_LIST_FILE"
if [ "$suppress_log" != "1" ]; then
write_log "Бан-лист нормализован: строк ${old_lines}${new_lines} (дубликаты IP объединены, просроченные удалены)"
fi
return 0
}
# Ядро само удаляет элемент ipset по timeout; здесь только подчистка файла учёта
# (без Telegram на каждый IP — иначе спам).
unblock_expired_ips() {
[ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0
ban_list_rewrite_deduped 0
}
load_existing_bans() {
[ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0
if [ ! -f "$BAN_LIST_FILE" ]; then
return
fi
local current_time
current_time=$(date '+%s')
declare -A best_until=()
declare -A best_line=()
local ip ban_until attempts ban_time
while IFS='|' read -r ip ban_until attempts ban_time || [ -n "${ip:-}" ]; do
[ -z "${ip:-}" ] || [ -z "${ban_until:-}" ] && continue
[ "$current_time" -lt "$ban_until" ] || continue
if [ -z "${best_until[$ip]:-}" ] || [ "$ban_until" -gt "${best_until[$ip]}" ]; then
best_until[$ip]=$ban_until
best_line[$ip]="${ip}|${ban_until}|${attempts}|${ban_time}"
fi
done <"$BAN_LIST_FILE"
local temp_file
temp_file=$(mktemp)
local sorted_ips k remain t_use
sorted_ips=$(printf '%s\n' "${!best_line[@]}" | LC_ALL=C sort)
while IFS= read -r k; do
[ -z "$k" ] && continue
IFS='|' read -r ip ban_until attempts ban_time <<<"${best_line[$k]}"
remain=$((ban_until - current_time))
t_use=$(ipset_timeout_clamp "$remain")
if ! is_ip_blocked "$ip"; then
do_ipset_add_ban "$ip" "$t_use" || true
write_log "Восстановлен ipset-бан $ip на $((remain / 60)) мин (после перезапуска)"
fi
echo "${best_line[$k]}" >>"$temp_file"
done <<<"$sorted_ips"
mv "$temp_file" "$BAN_LIST_FILE"
save_firewall_state
}
verify_ban_list_vs_ipset() {
[ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0
[ ! -f "$BAN_LIST_FILE" ] && return 0
local current_epoch
current_epoch=$(date '+%s')
while IFS='|' read -r ip ban_until _rest; do
[ -z "$ip" ] || [ -z "$ban_until" ] && continue
if [ "$current_epoch" -lt "$ban_until" ]; then
if ! is_ip_blocked "$ip"; then
write_log "AUDIT: рассинхрон — IP $ip в бан-листе, но нет в ipset; восстанавливаю"
local remain=$((ban_until - current_epoch))
do_ipset_add_ban "$ip" "$(ipset_timeout_clamp "$remain")" || true
fi
fi
done <"$BAN_LIST_FILE"
}
ipset_active_ban_count() {
local n n6
n=$(ipset list "$IPSET_NAME_V4" 2>/dev/null | awk '/^Number of entries:/{print $4; exit}')
n="${n:-0}"
n6=0
if ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then
n6=$(ipset list "$IPSET_NAME_V6" 2>/dev/null | awk '/^Number of entries:/{print $4; exit}')
fi
n6="${n6:-0}"
echo $((n + n6))
}
load_whitelist_from_file() {
local whitelist_file="/etc/ssh_monitor_whitelist.txt"
if [ -f "$whitelist_file" ]; then
write_log "Загрузка белого списка из $whitelist_file"
while IFS= read -r line || [ -n "$line" ]; do
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
if [[ "$line" == *"/"* ]]; then
WHITELIST_SUBNETS+=("$line")
write_log "Добавлена подсеть в белый список: $line"
else
WHITELIST_IPS+=("$line")
write_log "Добавлен IP в белый список: $line"
fi
done <"$whitelist_file"
fi
}
# ============================================
# Активные SSH-сессии (IPv4/IPv6 в скобках)
# ============================================
get_active_ssh_sessions() {
local out=""
local line user tty login_time ip
while IFS= read -r line; do
[[ -z "$line" ]] && continue
[[ "$line" != *"("* ]] && continue
user=$(awk '{print $1}' <<<"$line")
tty=$(awk '{print $2}' <<<"$line")
login_time=$(awk '{print $3, $4}' <<<"$line")
ip=$(sed -nE 's/.*\(([^)]+)\).*/\1/p' <<<"$line")
ip="${ip:-local}"
out+=" 👤 ${user} | ${tty} | с ${login_time} | 🌐 ${ip}"$'\n'
done < <(who 2>/dev/null || true)
printf '%s' "$out"
}
extract_ssh_client_ip() {
local line="$1"
local ip=""
ip=$(sed -nE 's/.* from ([^ ]+) port .*/\1/p' <<<"$line")
if [ -z "$ip" ]; then
ip=$(sed -nE 's/.* from ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+).*/\1/p' <<<"$line")
fi
printf '%s' "$ip"
}
# ============================================
# Мониторинг SSH
# ============================================
monitor_ssh() {
local last_check="0"
if [ -f "$LAST_SSH_CHECK_FILE" ]; then
last_check="$(<"$LAST_SSH_CHECK_FILE")"
fi
local current_time
current_time=$(date '+%s')
local ssh_logs=""
if command -v journalctl >/dev/null 2>&1; then
# -u ssh и -u sshd вместе часто дают две одинаковые строки Accepted; _COMM=sshd — один поток.
ssh_logs=$(journalctl _COMM=sshd --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true)
if [ -z "$ssh_logs" ]; then
ssh_logs=$(journalctl -u sshd --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true)
fi
if [ -z "$ssh_logs" ]; then
ssh_logs=$(journalctl -u ssh --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true)
fi
else
ssh_logs=$(grep -E "sshd.*(Accepted|Failed|Disconnected)" /var/log/auth.log 2>/dev/null | sort -u || true)
fi
declare -A SSH_ACCEPT_NOTIFY_KEYS
if [ -n "$ssh_logs" ]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
if echo "$line" | grep -q "Accepted"; then
local user ip
user=$(sed -nE 's/.* for ([^ ]+) from .*/\1/p' <<<"$line")
ip="$(extract_ssh_client_ip "$line")"
if [ -n "$user" ]; then
local dedupe_port dedupe_key
dedupe_port=$(sed -nE 's/.* from [^ ]+ port ([0-9]+).*/\1/p' <<<"$line")
dedupe_key="${user}|${ip:-}|${dedupe_port:-}"
if [[ -n "${SSH_ACCEPT_NOTIFY_KEYS[$dedupe_key]:-}" ]]; then
write_log "SSH Accepted: дубль в журнале (user=${user} ip=${ip:-} port=${dedupe_port:-?}), второе уведомление не отправляем"
continue
fi
local dedupe_net_key last_nf now_ded delta
dedupe_net_key="${user}|${ip:-}"
if [[ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" =~ ^[0-9]+$ ]] && [ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" -gt 0 ]; then
now_ded=$(date '+%s')
last_nf="${SSH_ACCEPT_LAST_NOTIFY[$dedupe_net_key]:-0}"
if [ "${last_nf:-0}" -gt 0 ]; then
delta=$((now_ded - last_nf))
if [ "$delta" -ge 0 ] && [ "$delta" -lt "$SSH_ACCEPT_NOTIFY_DEDUP_SEC" ]; then
write_log "SSH Accepted: повтор для user=${user} ip=${ip:-} через ${delta}s (< ${SSH_ACCEPT_NOTIFY_DEDUP_SEC}s), второе Telegram не отправляем"
continue
fi
fi
fi
SSH_ACCEPT_NOTIFY_KEYS[$dedupe_key]=1
SSH_ACCEPT_LAST_NOTIFY[$dedupe_net_key]=$(date '+%s')
local message=" ✅ УСПЕШНОЕ SSH ПОДКЛЮЧЕНИЕ "$'\n'
message="${message}👤 Пользователь: ${user}"$'\n'
message="${message}🌐 IP адрес: ${ip:-local}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_USER="$user" SAC_D_IP="${ip:-}" \
notify_or_sac "ssh.login.success" "info" "Успешное SSH" "${user} с ${ip:-local}" "$message"
write_log "SSH успешный вход: $user с IP ${ip:-local}"
if [ "$user" = "root" ]; then
local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n'
rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n'
rmsg="${rmsg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_USER="root" SAC_D_IP="${ip:-}" \
notify_or_sac "ssh.login.success" "warning" "SSH вход под root" "root с ${ip:-local}" "$rmsg"
fi
fi
elif echo "$line" | grep -q "Failed password"; then
local user ip
user=$(sed -nE 's/.* for (invalid user )?([^ ]+) from .*/\2/p' <<<"$line")
ip="$(extract_ssh_client_ip "$line")"
if [ -n "$ip" ]; then
local current_attempts=1
if [ -n "${FAIL_COUNTS[$ip]:-}" ]; then
current_attempts=${FAIL_COUNTS[$ip]}
current_attempts=$((current_attempts + 1))
fi
FAIL_COUNTS[$ip]=$current_attempts
if is_ip_blocked "$ip"; then
write_log "Попытка входа с заблокированного IP $ip (пользователь: $user)"
else
if [ "$current_attempts" -ge "$MAX_ATTEMPTS" ]; then
block_ip "$ip" "$current_attempts"
unset "FAIL_COUNTS[$ip]"
else
if [ "$current_attempts" -ge 2 ]; then
local message=" ❌ НЕУДАЧНАЯ ПОПЫТКА SSH "$'\n'
message="${message}👤 Пользователь: ${user}"$'\n'
message="${message}🌐 IP адрес: ${ip}"$'\n'
message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_USER="$user" SAC_D_IP="$ip" SAC_D_ATTEMPT="$current_attempts" SAC_D_MAX="$MAX_ATTEMPTS" \
notify_or_sac "ssh.login.failed" "warning" "Неудачная SSH" "${user} с ${ip} (${current_attempts}/${MAX_ATTEMPTS})" "$message"
fi
write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)"
fi
fi
fi
fi
done <<<"$ssh_logs"
fi
echo "$current_time" >"$LAST_SSH_CHECK_FILE"
}
# ============================================
# Мониторинг sudo
# ============================================
# Строка успешного sudo с перечислением окружения (TTY, PWD, USER, COMMAND).
# В journal идентификатор часто НЕ равен "sudo", поэтому не используем только journalctl -t sudo.
is_sudo_command_audit_line() {
[[ "$1" == *COMMAND=* ]] || return 1
grep -Eq 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' <<<"$1" 2>/dev/null || \
grep -Eq 'sudo:[[:space:]].*COMMAND=' <<<"$1" 2>/dev/null
}
parse_sudo_audit_line() {
local line="$1"
local -n out_user="$2"
local -n out_cmd="$3"
local -n out_pwd="$4"
local -n out_runuser="$5"
out_user=""
out_cmd=""
out_pwd=""
out_runuser=""
if [[ "$line" =~ sudo\[[0-9]+\]:[[:space:]]([^[:space:]]+)[[:space:]]+:[[:space:]] ]]; then
out_user="${BASH_REMATCH[1]}"
elif [[ "$line" =~ sudo:[[:space:]]([^[:space:]]+)[[:space:]]+:[[:space:]] ]]; then
out_user="${BASH_REMATCH[1]}"
fi
if [[ "$line" =~ USER=([^[:space:];]+) ]]; then
out_runuser="${BASH_REMATCH[1]}"
fi
if [[ "$line" =~ PWD=([^;]+) ]]; then
out_pwd="${BASH_REMATCH[1]}"
out_pwd="${out_pwd%"${out_pwd##*[![:space:]]}"}"
fi
if [[ "$line" =~ COMMAND=(.*) ]]; then
out_cmd="${BASH_REMATCH[1]}"
out_cmd="${out_cmd%"${out_cmd##*[![:space:]]}"}"
fi
}
monitor_sudo() {
local last_check="0"
if [ -f "$LAST_SUDO_CHECK_FILE" ]; then
last_check="$(<"$LAST_SUDO_CHECK_FILE")"
fi
if ! [[ "$last_check" =~ ^[0-9]+$ ]]; then
last_check=0
fi
local current_time
current_time=$(date '+%s')
local since_journal
if [ "$last_check" -eq 0 ]; then
since_journal="--since=-30m"
else
since_journal="--since=@${last_check}"
fi
local sudo_logs=""
if command -v journalctl >/dev/null 2>&1; then
sudo_logs=$(journalctl $since_journal --until="now" --no-pager 2>/dev/null \
| grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' || true)
elif [ -f /var/log/auth.log ]; then
sudo_logs=$(grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' /var/log/auth.log 2>/dev/null | tail -n 400 || true)
elif [ -f /var/log/secure ]; then
sudo_logs=$(grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' /var/log/secure 2>/dev/null | tail -n 400 || true)
else
sudo_logs=""
fi
declare -A SUDO_DEDUP_BATCH
if [ -n "$sudo_logs" ]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
is_sudo_command_audit_line "$line" || continue
local fp=""
if command -v sha256sum >/dev/null 2>&1; then
fp=$(printf '%s' "$line" | sha256sum | awk '{print $1}')
else
fp=$(printf '%s' "$line" | cksum | awk '{print $1}')
fi
[[ -n "$fp" && -n "${SUDO_DEDUP_BATCH[$fp]:-}" ]] && continue
[[ -n "$fp" ]] && SUDO_DEDUP_BATCH[$fp]=1
local user sudo_cmd pwd run_as
parse_sudo_audit_line "$line" user sudo_cmd pwd run_as
if [ -n "$user" ] && [ -n "${sudo_cmd:-}" ]; then
local message=" ⚠️ ИСПОЛЬЗОВАНИЕ SUDO "$'\n'
message="${message}👤 Пользователь: ${user}"$'\n'
if [ -n "$run_as" ]; then
message="${message}🔑 От имени USER: ${run_as}"$'\n'
fi
message="${message}💻 Команда: ${sudo_cmd}"$'\n'
message="${message}📁 Директория: ${pwd:-unknown}"$'\n'
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
local sac_summary="${user}: ${sudo_cmd}"
SAC_D_USER="$user" SAC_D_COMMAND="$sudo_cmd" SAC_D_RUN_AS="${run_as:-}" SAC_D_PWD="${pwd:-}" \
notify_or_sac "privilege.sudo.command" "warning" "Использование sudo" "$sac_summary" "$message"
write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}"
fi
done <<<"$sudo_logs"
fi
echo "$current_time" >"$LAST_SUDO_CHECK_FILE"
}
# ============================================
# Ключевые события безопасности + брутфорс
# ============================================
monitor_security_events() {
local last_check="0"
if [ -f "$LAST_SECURITY_EVENTS_FILE" ]; then
last_check="$(<"$LAST_SECURITY_EVENTS_FILE")"
fi
if ! [[ "$last_check" =~ ^[0-9]+$ ]]; then
last_check=0
fi
if [ "$last_check" -eq 0 ]; then
last_check=$(($(date '+%s') - 3600))
fi
local now
now=$(date '+%s')
local chunk=""
if command -v journalctl >/dev/null 2>&1; then
chunk=$(journalctl --since="@${last_check}" --until="now" 2>/dev/null | grep -Ei \
"REMOTE HOST IDENTIFICATION HAS CHANGED|POSSIBLE BREAK-IN ATTEMPT|reverse mapping checking getaddrinfo|Failed password" \
|| true)
fi
if [ -n "$chunk" ]; then
if echo "$chunk" | grep -Eqi "REMOTE HOST IDENTIFICATION HAS CHANGED"; then
local hk
hk="$(echo "$chunk" | grep -Ei "REMOTE HOST IDENTIFICATION HAS CHANGED" | head -n 3)"
local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n'
m="${m}$(printf '%s\n' "$hk")"
m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_or_sac "agent.notification" "high" "SSH host key changed" "MITM?" "$m"
write_log "SECURITY: обнаружено сообщение об изменении host key"
fi
fi
if command -v journalctl >/dev/null 2>&1; then
local brute_lines brute_ip count nowc lastn
brute_lines=$(journalctl -u ssh -u sshd --since="${BRUTE_WINDOW_SEC} seconds ago" 2>/dev/null | grep "Failed password" || true)
if [ -n "$brute_lines" ]; then
while IFS= read -r ip; do
[ -z "$ip" ] && continue
count=$(printf '%s\n' "$brute_lines" | grep -F "from ${ip} port" | wc -l | tr -d ' ')
if [ "${count:-0}" -ge "$BRUTE_MIN_FAILS" ]; then
nowc=$(date '+%s')
lastn="${BRUTE_LAST_NOTIFY[$ip]:-0}"
if [ $((nowc - lastn)) -ge "$BRUTE_NOTIFY_COOLDOWN_SEC" ]; then
BRUTE_LAST_NOTIFY[$ip]=$nowc
local bm=" 🧨 ВОЗМОЖНЫЙ МАССОВЫЙ БРУТФОРС SSH "$'\n'
bm="${bm}🌐 IP: ${ip}"$'\n'
bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n'
bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_IP="$ip" SAC_D_ATTEMPT="$count" \
notify_or_sac "ssh.bruteforce.mass" "high" "Массовый брутфорс" "${ip}: ${count} за ${BRUTE_WINDOW_SEC}s" "$bm"
write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s"
fi
fi
done < <(printf '%s\n' "$brute_lines" | sed -nE 's/.* from ([^ ]+) port .*/\1/p' | sort -u)
fi
fi
echo "$now" >"$LAST_SECURITY_EVENTS_FILE"
}
# ============================================
# Локальные сессии (systemd-logind, journalctl)
# ============================================
# Возврат 0 = не слать Telegram по logind (дубль с monitor_ssh): Type=ssh или Service=*sshd*.
# Пустой ответ loginctl — короткие повторы (гонка с journal).
logind_session_is_ssh_duplicate_for_notify() {
local sid="$1"
[ -z "$sid" ] && return 1
if ! command -v loginctl >/dev/null 2>&1; then
return 1
fi
local attempt st svc svc_lc
for attempt in 1 2 3 4 5; do
local props
props=$(loginctl show-session "$sid" -p Type -p Service 2>/dev/null || true)
st=$(printf '%s\n' "$props" | sed -n 's/^Type=//p' | head -n1 | tr -d '[:space:]')
svc=$(printf '%s\n' "$props" | sed -n 's/^Service=//p' | head -n1 | tr -d '[:space:]')
svc_lc=$(printf '%s' "$svc" | tr '[:upper:]' '[:lower:]')
if [ "$st" = "ssh" ] || [[ "$svc_lc" == *sshd* ]]; then
return 0
fi
if [ -n "$st" ] || [ -n "$svc" ]; then
return 1
fi
if [ "$attempt" -lt 5 ]; then
sleep 0.2
fi
done
return 1
}
parse_logind_new_session() {
local line="$1"
local -n out_sid="$2"
local -n out_user="$3"
out_sid=""
out_user=""
if [[ "$line" =~ [Nn]ew\ session[[:space:]]+([^[:space:]]+)[[:space:]]+of\ user\ \'([^\']+)\' ]]; then
out_sid="${BASH_REMATCH[1]}"
out_user="${BASH_REMATCH[2]}"
return 0
fi
if [[ "$line" =~ [Nn]ew\ session[[:space:]]+([^[:space:]]+)[[:space:]]+of\ user\ ([^[:space:].]+)\.? ]]; then
out_sid="${BASH_REMATCH[1]}"
out_user="${BASH_REMATCH[2]}"
return 0
fi
return 1
}
monitor_logind() {
if [ "${ENABLE_LOGIND_MONITOR:-1}" != "1" ]; then
return 0
fi
if ! command -v journalctl >/dev/null 2>&1; then
return 0
fi
local last_check="0"
if [ -f "$LAST_LOGIND_CHECK_FILE" ]; then
last_check="$(<"$LAST_LOGIND_CHECK_FILE")"
fi
if ! [[ "$last_check" =~ ^[0-9]+$ ]]; then
last_check=0
fi
local since_journal
if [ "$last_check" -eq 0 ]; then
since_journal="--since=-30m"
else
since_journal="--since=@${last_check}"
fi
local current_time
current_time=$(date '+%s')
local logind_lines
logind_lines=$(journalctl -u systemd-logind $since_journal --until="now" -o cat --no-pager 2>/dev/null || true)
if [ -n "$logind_lines" ]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local low
low=$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')
if [[ "$low" == *"new session"* ]]; then
if [ "${LOGIND_NOTIFY_NEW:-1}" = "1" ]; then
local sid="" user=""
if parse_logind_new_session "$line" sid user; then
if [ "${LOGIND_SKIP_REMOTE:-1}" = "1" ] && [ -n "$sid" ]; then
if logind_session_is_ssh_duplicate_for_notify "$sid"; then
write_log "LOGIND: новая сессия (SSH уже в monitor_ssh, Telegram пропущен): ${line}"
continue
fi
fi
local msg=" 🖥️ НОВАЯ СЕССИЯ (systemd-logind) "$'\n'
msg="${msg}👤 Пользователь: ${user:-неизвестно}"$'\n'
msg="${msg}🆔 Сессия: ${sid:-?}"$'\n'
msg="${msg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
SAC_D_USER="${user:-}" \
notify_or_sac "session.logind.new" "info" "Новая сессия logind" "${user:-?} sid=${sid:-?}" "$msg"
write_log "LOGIND: новая сессия: ${line}"
else
write_log "LOGIND: новая сессия (разбор user/sid не удался): ${line}"
fi
fi
elif [[ "$low" == *"removed session"* ]]; then
write_log "LOGIND: сессия завершена: $line"
if [ "${LOGIND_NOTIFY_REMOVED:-0}" = "1" ]; then
local sess_r=""
if [[ "$line" =~ [Rr]emoved\ session[[:space:]]+([^[:space:].]+) ]]; then
sess_r="${BASH_REMATCH[1]}"
fi
local msg_r=" 🚪 СЕССИЯ ЗАКРЫТА (systemd-logind) "$'\n'
msg_r="${msg_r}🆔 Сессия: ${sess_r:-?}"$'\n'
msg_r="${msg_r}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_or_sac "session.logind.removed" "info" "Сессия закрыта" "sid=${sess_r:-?}" "$msg_r"
fi
elif [[ "$low" == *"failed"* ]]; then
if [ "${LOGIND_NOTIFY_FAILED:-1}" = "1" ]; then
local msg_f=" ❌ СБОЙ (systemd-logind) "$'\n'
msg_f="${msg_f}📝 ${line}"$'\n'
msg_f="${msg_f}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
notify_or_sac "session.logind.failed" "warning" "Сбой logind" "logind failed" "$msg_f"
write_log "LOGIND: сбой: $line"
fi
fi
done <<<"$logind_lines"
fi
echo "$current_time" >"$LAST_LOGIND_CHECK_FILE"
}
# ============================================
# Ежедневный отчёт
# ============================================
journal_count_since_24h() {
local pattern="$1"
local c
if command -v journalctl >/dev/null 2>&1; then
c=$(journalctl -u ssh -u sshd --since="24 hours ago" 2>/dev/null | grep -F -c "$pattern" || true)
echo "${c:-0}"
else
echo "0"
fi
}
journal_sudo_count_24h() {
local c
if command -v journalctl >/dev/null 2>&1; then
c=$(journalctl --since="24 hours ago" --no-pager 2>/dev/null \
| grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' | wc -l | tr -d ' ' || true)
echo "${c:-0}"
else
echo "0"
fi
}
top_failed_ips_journal_24h() {
local n="$1"
journalctl -u ssh -u sshd --since="24 hours ago" 2>/dev/null \
| grep "Failed password" \
| sed -nE 's/.* from ([^ ]+) port .*/\1/p' \
| sort | uniq -c | sort -nr | head -n "$n" \
| awk '{gsub(/^ +/,"",$1); print $2 ":" $1}' || true
}
send_daily_report() {
local current_time
current_time=$(report_date '+%d.%m.%Y %H:%M:%S')
local successful_logins failed_logins sudo_commands
local top_lines=""
local json_stats=""
if command -v journalctl >/dev/null 2>&1; then
successful_logins=$(journal_count_since_24h "Accepted")
failed_logins=$(journal_count_since_24h "Failed password")
sudo_commands=$(journal_sudo_count_24h)
top_lines=$(top_failed_ips_journal_24h "${DAILY_REPORT_TOP_IPS:-5}" | sed 's/^/ • /; s/:/ — /' || true)
else
json_stats="$(stats_auth_log_24h_json "${DAILY_REPORT_TOP_IPS:-5}")"
successful_logins=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['accepted'])" "$json_stats" 2>/dev/null || echo "0")
failed_logins=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['failed'])" "$json_stats" 2>/dev/null || echo "0")
sudo_commands=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['sudo'])" "$json_stats" 2>/dev/null || echo "0")
top_lines=$(printf '%s' "$json_stats" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(chr(10).join(' • '+t.replace(':',' — ',1) for t in d.get('top',[])))" 2>/dev/null || true)
fi
local active_sessions
active_sessions=$(get_active_ssh_sessions)
local active_count
active_count=$(who 2>/dev/null | grep -c "." || echo "0")
local active_bans=0
local ban_line
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then
if command -v ipset >/dev/null 2>&1; then
active_bans=$(ipset_active_ban_count)
else
local current_epoch
current_epoch=$(date '+%s')
if [ -f "$BAN_LIST_FILE" ]; then
while IFS='|' read -r ip ban_until attempts ban_time; do
if [ -n "$ip" ] && [ -n "$ban_until" ] && [ "$current_epoch" -lt "$ban_until" ]; then
active_bans=$((active_bans + 1))
fi
done <"$BAN_LIST_FILE"
fi
fi
ban_line="🚫 Активных банов (ipset): ${active_bans}"
else
ban_line="🚫 Автобан IP: отключён (ENABLE_IP_BAN=0)"
fi
local message=""
message+="📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"$'\n'
message+="🖥️ Сервер: $(server_display_name_with_ip)"$'\n'
message+="🕐 Время отчета: ${current_time}"$'\n'
message+=""$'\n'
message+="📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:"$'\n'
message+=" ✅ Успешных SSH подключений: ${successful_logins:-0}"$'\n'
message+=" ❌ Неудачных попыток SSH: ${failed_logins:-0}"$'\n'
message+=" ⚠️ Команд через sudo: ${sudo_commands:-0}"$'\n'
message+=" ${ban_line}"$'\n'
message+=""$'\n'
message+="🧾 ТОП-${DAILY_REPORT_TOP_IPS:-5} IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"$'\n'
if [ -n "$top_lines" ]; then
message+="${top_lines}"$'\n'
else
message+=" (нет данных)"$'\n'
fi
message+=""$'\n'
message+="👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (${active_count}):"$'\n'
if [ -n "$active_sessions" ]; then
message+="${active_sessions}"$'\n'
else
message+=" (нет данных)"$'\n'
fi
local active_users_json
active_users_json="$(printf '%s' "$active_sessions" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin.read().splitlines() if l.strip()]))' 2>/dev/null || echo '[]')"
local sac_summary sac_details_json
sac_summary="SSH 24ч: успех ${successful_logins:-0}, неудач ${failed_logins:-0}, sudo ${sudo_commands:-0}"
sac_details_json="$(
SAC_REPORT_BODY="$message" \
SAC_OK="${successful_logins:-0}" \
SAC_FAIL="${failed_logins:-0}" \
SAC_SUDO="${sudo_commands:-0}" \
SAC_BANS="${active_bans:-0}" \
SAC_ACTIVE="${active_count:-0}" \
SAC_ACTIVE_USERS="$active_users_json" \
python3 <<'PY'
import html
import json
import os
body = os.environ.get("SAC_REPORT_BODY", "")
active_users = json.loads(os.environ.get("SAC_ACTIVE_USERS", "[]") or "[]")
ok = int(os.environ.get("SAC_OK", "0") or 0)
fail = int(os.environ.get("SAC_FAIL", "0") or 0)
stats = {
"platform": "ssh",
"successful_logins": ok,
"failed_logins": fail,
"successful_ssh": ok,
"failed_ssh": fail,
"sudo_commands": int(os.environ.get("SAC_SUDO", "0") or 0),
"active_bans": int(os.environ.get("SAC_BANS", "0") or 0),
"active_sessions": int(os.environ.get("SAC_ACTIVE", "0") or 0),
"active_users": active_users,
"generated_by": "agent",
}
escaped = html.escape(body)
report_html = '<div class="agent-report">' + escaped.replace("\n", "<br>") + "</div>"
print(
json.dumps(
{
"stats": stats,
"report_body": body,
"report_format": "plain",
"report_html": report_html,
},
ensure_ascii=False,
)
)
PY
)" || sac_details_json=""
notify_or_sac "report.daily.ssh" "info" "Ежедневный отчёт SSH" "$sac_summary" "$message" "$sac_details_json"
write_log "Ежедневный отчет отправлен"
echo "$(report_date '+%F')" >"$LAST_REPORT_FILE"
}
read_last_report_date() {
if [ ! -f "$LAST_REPORT_FILE" ]; then
echo ""
return
fi
local raw
raw="$(tr -d ' \t\r\n' <"$LAST_REPORT_FILE" || true)"
if [[ "$raw" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "$raw"
return
fi
if [[ "$raw" =~ ^[0-9]+$ ]]; then
report_date -d "@$raw" '+%F' 2>/dev/null || echo ""
return
fi
echo ""
}
check_daily_report() {
if [ "${DAILY_REPORT_ENABLED:-1}" != "1" ]; then
return 0
fi
local today
today=$(report_date '+%F')
local last_date
last_date="$(read_last_report_date)"
if [ "$last_date" = "$today" ]; then
return 0
fi
local cur_h cur_m cur_mins rep_mins
cur_h=$(report_date '+%-H')
cur_m=$(report_date '+%-M')
cur_mins=$((10#$cur_h * 60 + 10#$cur_m))
rep_mins=$((10#$DAILY_REPORT_HOUR * 60 + 0))
if [ "$cur_mins" -ge "$rep_mins" ]; then
send_daily_report
fi
}
# ============================================
# Heartbeat
# ============================================
send_heartbeat() {
local timestamp
timestamp=$(date '+%s')
local timestamp_human
timestamp_human=$(notification_date '+%d.%m.%Y %H:%M:%S')
local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n'
message="${message}🕐 Время: ${timestamp_human}"
notify_or_sac "agent.heartbeat" "info" "Heartbeat" "ssh-monitor alive" "$message"
echo "$timestamp" >"$LAST_HEARTBEAT_FILE"
write_log "Heartbeat отправлен"
}
check_heartbeat() {
local last_heartbeat_epoch=0
if [ -f "$LAST_HEARTBEAT_FILE" ]; then
last_heartbeat_epoch=$(cat "$LAST_HEARTBEAT_FILE" 2>/dev/null || echo "0")
fi
local current_epoch
current_epoch=$(date '+%s')
local diff=$((current_epoch - last_heartbeat_epoch))
if [ "$last_heartbeat_epoch" -eq 0 ] || [ "$diff" -ge 43200 ]; then
send_heartbeat
fi
}
# ============================================
# Эксплуатация: health + Prometheus textfile
# ============================================
write_health_and_metrics() {
local ts
ts=$(date '+%s')
if [ -n "${HEALTHCHECK_STATUS_FILE:-}" ]; then
mkdir -p "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || true
printf '{"ts":%s,"hostname":"%s","dry_run":%s}\n' "$ts" "$(hostname | sed 's/"/\\"/g')" "$([[ "${DRY_RUN:-0}" = "1" ]] && echo true || echo false)" >"${HEALTHCHECK_STATUS_FILE}.tmp"
mv "${HEALTHCHECK_STATUS_FILE}.tmp" "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true
fi
if [ -n "${PROMETHEUS_TEXTFILE_DIR:-}" ]; then
mkdir -p "$PROMETHEUS_TEXTFILE_DIR" 2>/dev/null || true
local f="$PROMETHEUS_TEXTFILE_DIR/ssh_monitor.prom"
{
echo "# HELP ssh_monitor_last_loop_unixtime Unix time последней итерации цикла ssh-monitor"
echo "# TYPE ssh_monitor_last_loop_unixtime gauge"
printf 'ssh_monitor_last_loop_unixtime %s\n' "$ts"
} >"${f}.tmp"
mv "${f}.tmp" "$f" 2>/dev/null || true
fi
}
# ============================================
# Старт / стоп
# ============================================
send_startup_notification() {
local timestamp
timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S')
local whitelist_count=$((${#WHITELIST_IPS[@]} + ${#WHITELIST_SUBNETS[@]}))
local message=" ✅ СКРИПТ МОНИТОРИНГА ЗАПУЩЕН "$'\n'
message="${message}📌 Версия: ${SSH_MONITOR_VERSION:-?}"$'\n'
message="${message}🕐 Время запуска: ${timestamp}"$'\n'
message="${message}📊 Мониторинг SSH, SUDO и systemd-logind активирован"$'\n'
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then
message="${message}🚫 Автоблокировка (ipset): после ${MAX_ATTEMPTS} попыток, timeout ${BAN_TIME} с ($IPSET_NAME_V4)"$'\n'
else
message="${message}🚫 Автоблокировка IP: выключена (ENABLE_IP_BAN=0), только уведомления"$'\n'
fi
message="${message}⚪ Белый список: ${whitelist_count} IP/подсетей"$'\n'
message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"$'\n'
message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)"
notify_or_sac "agent.lifecycle" "info" "Мониторинг запущен" "ssh-monitor started" "$message"
write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)"
}
send_shutdown_notification() {
[ "${SHUTDOWN_NOTIFIED:-0}" = "1" ] && return 0
SHUTDOWN_NOTIFIED=1
local timestamp
timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S')
local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n'
message="${message}🕐 Время остановки: ${timestamp}"
notify_or_sac "agent.lifecycle" "warning" "Мониторинг остановлен" "ssh-monitor stopped" "$message"
write_log "Скрипт мониторинга остановлен"
}
main() {
if [ "$EUID" -ne 0 ] && [ "${DRY_RUN:-0}" != "1" ]; then
echo "Пожалуйста, запустите скрипт от имени root (sudo $0)"
exit 1
fi
load_whitelist_from_file
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then
ensure_ipset_and_iptables || write_log "WARN: ensure_ipset_and_iptables завершился с ошибкой"
load_existing_bans
else
write_log "ENABLE_IP_BAN=0: мониторинг и уведомления без ipset/автобана"
fi
send_startup_notification
local last_ban_check
last_ban_check=$(date '+%s')
while true; do
monitor_ssh
monitor_sudo
monitor_security_events
monitor_logind
check_daily_report
check_heartbeat
sac_flush_spool 20 2>/dev/null || true
write_health_and_metrics
local current_time
current_time=$(date '+%s')
if [ "${ENABLE_IP_BAN:-1}" = "1" ] && [ $((current_time - last_ban_check)) -ge "$BAN_CHECK_INTERVAL" ]; then
unblock_expired_ips
verify_ban_list_vs_ipset
last_ban_check=$current_time
fi
sleep "$MONITOR_INTERVAL"
done
}
run_check_config() {
printf 'Проверка конфигурации ssh-monitor\n'
printf 'SSH_MONITOR_VERSION=%s\n' "${SSH_MONITOR_VERSION:-}"
printf 'CONFIG_FILE=%s\n' "$CONFIG_FILE"
printf 'NOTIFY_ORDER=%s (пусто = авто: только настроенные; при событии — отправка во все каналы списка)\n' "${NOTIFY_ORDER:-}"
printf 'NOTIFY_CHAIN (эффективная очередь)=%s\n' "$(notify_chain_human)"
printf 'TELEGRAM: %s\n' "$([ -n "$TELEGRAM_BOT_TOKEN" ] && echo настроен || echo НЕ настроен)"
printf 'BACKUP_WEBHOOK_URL: %s\n' "$([ -n "${BACKUP_WEBHOOK_URL:-}" ] && echo настроен || echo не задан)"
printf 'LOG_FILE=%s\n' "$LOG_FILE"
printf 'DAILY_REPORT_HOUR=%s\n' "$DAILY_REPORT_HOUR"
printf 'DAILY_REPORT_TZ=%s (пусто = зона процесса/date)\n' "${DAILY_REPORT_TZ:-}"
printf 'NOTIFY_TZ=%s (пусто = DAILY_REPORT_TZ или зона процесса; см. README)\n' "${NOTIFY_TZ:-}"
printf 'SERVER_DISPLAY_NAME=%s (пусто = hostname; эффективно: %s)\n' "${SERVER_DISPLAY_NAME:-}" "$(server_display_name_with_ip)"
printf 'SSH_ACCEPT_NOTIFY_DEDUP_SEC=%s\n' "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}"
printf 'DAILY_REPORT_TOP_IPS=%s\n' "$DAILY_REPORT_TOP_IPS"
printf 'BRUTE_WINDOW_SEC=%s BRUTE_MIN_FAILS=%s\n' "$BRUTE_WINDOW_SEC" "$BRUTE_MIN_FAILS"
printf 'ENABLE_IP_BAN=%s (1=мониторинг+бан ipset, 0=только мониторинг и уведомления)\n' "${ENABLE_IP_BAN:-1}"
printf 'IPSET_NAME_V4=%s IPSET_NAME_V6=%s IPSET_MAXELEM=%s\n' "$IPSET_NAME_V4" "$IPSET_NAME_V6" "$IPSET_MAXELEM"
printf 'PROMETHEUS_TEXTFILE_DIR=%s\n' "${PROMETHEUS_TEXTFILE_DIR:-}"
printf 'HEALTHCHECK_STATUS_FILE=%s\n' "${HEALTHCHECK_STATUS_FILE:-}"
printf 'ENABLE_LOGIND_MONITOR=%s LAST_LOGIND_CHECK_FILE=%s\n' "${ENABLE_LOGIND_MONITOR:-}" "${LAST_LOGIND_CHECK_FILE:-}"
printf 'LOGIND_NOTIFY_NEW=%s LOGIND_NOTIFY_REMOVED=%s LOGIND_NOTIFY_FAILED=%s LOGIND_SKIP_REMOTE=%s\n' \
"${LOGIND_NOTIFY_NEW:-}" "${LOGIND_NOTIFY_REMOVED:-}" "${LOGIND_NOTIFY_FAILED:-}" "${LOGIND_SKIP_REMOTE:-}"
printf 'MAIL_SMTP: host=%s port=%s from=%s to=%s STARTTLS=%s SSL=%s\n' \
"${MAIL_SMTP_HOST:-}" "${MAIL_SMTP_PORT:-}" "${MAIL_FROM:-}" "${MAIL_TO:-}" \
"${MAIL_SMTP_STARTTLS:-}" "${MAIL_SMTP_SSL:-}"
printf 'MAIL_SMTP_USER: %s\n' "$([ -n "${MAIL_SMTP_USER:-}" ] && echo задан || echo не задан)"
printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)"
printf 'UseSAC=%s SAC_URL=%s SAC_API_KEY=%s\n' "${UseSAC:-off}" "${SAC_URL:-}" \
"$([ -n "${SAC_API_KEY:-}" ] && echo задан || echo не задан)"
if bash -n "$0" 2>/dev/null; then
printf 'bash -n: OK\n'
else
printf 'bash -n: ОШИБКА\n' >&2
return 1
fi
return 0
}
parse_args() {
for a in "$@"; do
case "$a" in
--check-config) CHECK_CONFIG_ONLY=1 ;;
--check-sac) CHECK_SAC_ONLY=1 ;;
--dry-run) DRY_RUN=1 ;;
esac
done
}
# ============================================
# ОБРАБОТЧИК ОСТАНОВКИ СКРИПТА
# ============================================
parse_args "$@"
load_config
validate_config
if [ "$CHECK_CONFIG_ONLY" = "1" ]; then
run_check_config
exit $?
fi
if [ "${CHECK_SAC_ONLY:-0}" = "1" ]; then
run_check_sac
exit $?
fi
trap send_shutdown_notification EXIT INT TERM
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
write_log "Конфигурация загружена из $CONFIG_FILE (или дефолты), версия ${SSH_MONITOR_VERSION:-?}, очередь оповещений: $(notify_chain_human)"
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then
write_log "Режим: мониторинг + автобан IP (ENABLE_IP_BAN=1)"
else
write_log "Режим: только мониторинг и уведомления (ENABLE_IP_BAN=0)"
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then
write_log "Режим DRY-RUN: ipset/iptables не меняются, уведомления печатаются в stderr"
else
write_log "Режим DRY-RUN: уведомления печатаются в stderr"
fi
fi
main