#!/usr/bin/env bash # Telegram SSH Notifications Script # Version: 3.0.0 # Author: System Administrator # Description: Уведомления о SSH-входах и попытках взлома через Telegram set -euo pipefail IFS=$'\n\t' # ============================================================================ # CONSTANTS # ============================================================================ readonly SCRIPT_NAME="$(basename "${0}")" readonly SCRIPT_VERSION="3.0.0" readonly CONFIG_FILE="/etc/telegram-ssh-notify.conf" readonly DEFAULT_LOG_FILE="/var/log/telegram-ssh-notify.log" readonly FAILED_LOGINS_DIR="/var/log/ssh_failed_logins" readonly IP_CACHE_DIR="/tmp/telegram-ssh-notify-cache" readonly LOCK_FILE="/var/run/telegram-ssh-notify.lock" # ============================================================================ # DEFAULT CONFIGURATION (может быть переопределено в файле или env) # ============================================================================ declare -gA CONFIG=( [TELEGRAM_TOKEN]="" [TELEGRAM_CHAT_ID]="" [TELEGRAM_TOPIC_ID]="" [MAX_ATTEMPTS_BEFORE_CRITICAL]="20" [CRITICAL_TIME_WINDOW]="300" # секунд [CLEANUP_DAYS]="7" [IP_API_TIMEOUT]="3" [ENABLE_GEOIP]="true" [ENABLE_NOTIFICATIONS]="true" [LOG_FILE]="${DEFAULT_LOG_FILE}" [DEBUG]="false" [AUTO_BLOCK_CRITICAL]="false" # автоматически блокировать IP при критической атаке [BLOCK_COMMAND]="iptables -A INPUT -s {ip} -j DROP" [WHITELIST_IPS]="" # разделённые пробелом IP или подсети [BLACKLIST_IPS]="" # сразу блокировать эти IP [RATE_LIMIT_SEC]="60" # минимальный интервал между одинаковыми уведомлениями [USE_JOURNAL]="false" # использовать journalctl вместо файлов логов ) # Эмодзи для сообщений declare -gA EMOJI=( [SUCCESS]="✅" [INFO]="ℹ️" [WARNING]="⚠️" [DANGER]="🔴" [CRITICAL]="🚨" [FIRE]="🔥" [LOCK]="🔒" [GLOBE]="🌍" [CITY]="🏙️" [BUILDING]="🏢" [CLOCK]="🕐" [MAP]="🗺️" [SERVER]="🖥️" [USER]="👤" [IP]="🔗" [DOOR]="🚪" [BELL]="🔔" [SHIELD]="🛡️" [MAGNIFYING_GLASS]="🔍" ) # Флаги стран (сокращённо) declare -gA COUNTRY_FLAGS=( [RU]="🇷🇺" [US]="🇺🇸" [DE]="🇩🇪" [CN]="🇨🇳" [UA]="🇺🇦" [BY]="🇧🇾" [KZ]="🇰🇿" [FR]="🇫🇷" [GB]="🇬🇧" [JP]="🇯🇵" ) # ============================================================================ # LOGGING # ============================================================================ log() { local level="$1" local message="$2" local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" local log_file="${CONFIG[LOG_FILE]}" # Создаём каталог для лога, если нужно [[ -n "$log_file" ]] && mkdir -p "$(dirname "$log_file")" 2>/dev/null || true case "$level" in DEBUG) [[ "${CONFIG[DEBUG]}" == "true" ]] && echo "[$timestamp] [DEBUG] $message" >> "$log_file" ;; INFO) echo "[$timestamp] [INFO] $message" >> "$log_file" ;; WARN) echo "[$timestamp] [WARN] $message" >> "$log_file" ;; ERROR) echo "[$timestamp] [ERROR] $message" >> "$log_file" echo "[$timestamp] [ERROR] $message" >&2 ;; esac } # ============================================================================ # UTILITY FUNCTIONS # ============================================================================ command_exists() { command -v "$1" >/dev/null 2>&1 } validate_ip() { local ip="$1" # IPv4 if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then local IFS='.' read -r -a octets <<< "$ip" for octet in "${octets[@]}"; do if (( octet < 0 || octet > 255 )); then return 1 fi done return 0 fi # IPv6 просто проверяем формат if [[ "$ip" =~ ^[0-9a-fA-F:]+$ ]]; then return 0 fi return 1 } is_private_ip() { local ip="$1" [[ "$ip" =~ ^10\. ]] && return 0 [[ "$ip" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. ]] && return 0 [[ "$ip" =~ ^192\.168\. ]] && return 0 [[ "$ip" =~ ^127\. ]] && return 0 [[ "$ip" =~ ^169\.254\. ]] && return 0 [[ "$ip" =~ ^fc[0-9a-f]{2}: ]] && return 0 [[ "$ip" =~ ^fd[0-9a-f]{2}: ]] && return 0 [[ "$ip" =~ ^fe80: ]] && return 0 [[ "$ip" == "::1" ]] && return 0 return 1 } sanitize() { echo "$1" | tr -d '\000-\037' | cut -c1-200 } # Проверка, входит ли IP в список (поддерживаются CIDR) ip_in_list() { local ip="$1" local list="$2" [[ -z "$list" ]] && return 1 for entry in $list; do if [[ "$entry" == */* ]]; then # CIDR if command_exists ipcalc; then if ipcalc -c "$ip" "$entry" >/dev/null 2>&1; then return 0 fi else # грубая проверка: если IP начинается с той же подсети if [[ "$ip" == "${entry%/*}"* ]]; then return 0 fi fi else [[ "$ip" == "$entry" ]] && return 0 fi done return 1 } # Rate limiting: проверяем, можно ли отправить уведомление по ключу (например, IP+user) check_rate_limit() { local key="$1" local limit_sec="${CONFIG[RATE_LIMIT_SEC]}" [[ "$limit_sec" -le 0 ]] && return 0 local cache_file="${IP_CACHE_DIR}/rate_$(echo -n "$key" | md5sum | cut -d' ' -f1)" mkdir -p "$IP_CACHE_DIR" if [[ -f "$cache_file" ]]; then local last last="$(cat "$cache_file")" local now now="$(date +%s)" if (( now - last < limit_sec )); then return 1 fi fi date +%s > "$cache_file" return 0 } # ============================================================================ # CONFIGURATION LOADING # ============================================================================ load_config() { # Сначала загружаем переменные окружения (приоритет выше) local env_vars=( TELEGRAM_TOKEN TELEGRAM_CHAT_ID TELEGRAM_TOPIC_ID MAX_ATTEMPTS_BEFORE_CRITICAL CRITICAL_TIME_WINDOW CLEANUP_DAYS IP_API_TIMEOUT ENABLE_GEOIP ENABLE_NOTIFICATIONS DEBUG AUTO_BLOCK_CRITICAL WHITELIST_IPS BLACKLIST_IPS RATE_LIMIT_SEC ) for var in "${env_vars[@]}"; do if [[ -n "${!var:-}" ]]; then CONFIG["$var"]="${!var}" log "DEBUG" "Loaded from env: $var=${!var}" fi done # Затем из конфигурационного файла (переопределяет, если нет в env) if [[ -f "$CONFIG_FILE" ]]; then log "INFO" "Loading configuration from $CONFIG_FILE" local line key value while IFS='=' read -r key value; do key="${key// /}" value="${value// /}" [[ -z "$key" || "$key" =~ ^# ]] && continue if [[ -n "${CONFIG[$key]:-}" ]]; then CONFIG["$key"]="$value" log "DEBUG" "Config: $key=$value" fi done < "$CONFIG_FILE" else log "WARN" "Configuration file not found, using defaults/env" fi # Валидация обязательных параметров if [[ -z "${CONFIG[TELEGRAM_TOKEN]}" || -z "${CONFIG[TELEGRAM_CHAT_ID]}" ]]; then log "ERROR" "TELEGRAM_TOKEN and TELEGRAM_CHAT_ID must be set" exit 1 fi # Создаём необходимые каталоги mkdir -p "$FAILED_LOGINS_DIR" "$IP_CACHE_DIR" "$(dirname "${CONFIG[LOG_FILE]}")" } # ============================================================================ # GEOIP FUNCTIONS # ============================================================================ get_ip_info() { local ip="$1" local cache_file="${IP_CACHE_DIR}/geo_$(echo -n "$ip" | md5sum | cut -d' ' -f1)" local cache_age=3600 # Проверка кэша if [[ -f "$cache_file" ]]; then local file_time now file_time="$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file")" now="$(date +%s)" if (( now - file_time < cache_age )); then cat "$cache_file" return 0 fi fi # Если GeoIP отключён if [[ "${CONFIG[ENABLE_GEOIP]}" != "true" ]]; then echo "🌐 *Страна:* отключено" echo "🏙️ *Город:* отключено" return 0 fi # Приватный IP if is_private_ip "$ip"; then { echo "🌐 *Страна:* частная сеть" echo "🏙️ *Город:* локальный адрес" } > "$cache_file" cat "$cache_file" return 0 fi # Попытка использовать mmdblookup (MaxMind GeoIP2) если установлен if command_exists mmdblookup && [[ -f /usr/share/GeoIP/GeoLite2-City.mmdb ]]; then local country city country="$(mmdblookup --file /usr/share/GeoIP/GeoLite2-City.mmdb --ip "$ip" country names en 2>/dev/null | head -1 | tr -d '"')" city="$(mmdblookup --file /usr/share/GeoIP/GeoLite2-City.mmdb --ip "$ip" city names en 2>/dev/null | head -1 | tr -d '"')" { echo "🌐 *Страна:* ${country:-Неизвестно}" echo "🏙️ *Город:* ${city:-Неизвестно}" } > "$cache_file" cat "$cache_file" return 0 fi # Резервный API ip-api.com local response response="$(curl -s --max-time "${CONFIG[IP_API_TIMEOUT]}" "http://ip-api.com/json/$ip?fields=status,country,city")" || true if [[ -n "$response" ]] && command_exists jq; then local status country city status="$(jq -r '.status' <<<"$response")" if [[ "$status" == "success" ]]; then country="$(jq -r '.country' <<<"$response")" city="$(jq -r '.city' <<<"$response")" { echo "🌐 *Страна:* ${country:-Неизвестно}" echo "🏙️ *Город:* ${city:-Неизвестно}" } > "$cache_file" cat "$cache_file" return 0 fi fi # Если ничего не сработало echo "🌐 *Страна:* не определена" echo "🏙️ *Город:* не определён" return 1 } # ============================================================================ # IP FILTERING (WHITELIST/BLACKLIST) # ============================================================================ check_ip_filter() { local ip="$1" if ip_in_list "$ip" "${CONFIG[BLACKLIST_IPS]}"; then log "INFO" "IP $ip is blacklisted, blocking immediately" auto_block_ip "$ip" "blacklist" return 1 fi if ip_in_list "$ip" "${CONFIG[WHITELIST_IPS]}"; then log "DEBUG" "IP $ip is whitelisted, skipping notifications" return 2 fi return 0 } # ============================================================================ # AUTO BLOCK IP # ============================================================================ auto_block_ip() { local ip="$1" local reason="$2" if [[ "${CONFIG[AUTO_BLOCK_CRITICAL]}" != "true" ]]; then return 0 fi local cmd="${CONFIG[BLOCK_COMMAND]//\{ip\}/$ip}" log "WARN" "Auto-blocking IP $ip (reason: $reason) with command: $cmd" eval "$cmd" || log "ERROR" "Failed to block IP $ip" } # ============================================================================ # FAILED LOGIN TRACKING # ============================================================================ track_failed_attempt() { local ip="$1" local user="$2" local timestamp timestamp="$(date +%s)" local date_str date_str="$(date '+%Y-%m-%d %H:%M:%S')" local safe_ip safe_ip="$(echo "$ip" | tr './:' '_')" local ip_file="$FAILED_LOGINS_DIR/$safe_ip" # Читаем предыдущие данные local attempts=1 last_attempt=0 old_user="$user" if [[ -f "$ip_file" ]]; then IFS='|' read -r attempts last_attempt old_user _ < "$ip_file" attempts=$((attempts + 1)) # Сбрасываем счётчик, если прошло много времени if (( timestamp - last_attempt > 600 )); then attempts=1 fi fi # Сохраняем echo "$attempts|$timestamp|$user|$date_str" > "$ip_file" log "INFO" "Failed attempt #$attempts from $ip as $user" # Проверяем условия для уведомления local notify=false local alert_level="info" local time_window_diff=$((timestamp - last_attempt)) # Критическая атака if (( attempts >= ${CONFIG[MAX_ATTEMPTS_BEFORE_CRITICAL]} )) && \ (( time_window_diff < ${CONFIG[CRITICAL_TIME_WINDOW]} )); then notify=true alert_level="critical" auto_block_ip "$ip" "critical_attack" elif (( attempts == 1 || attempts == 3 || attempts == 5 || attempts % 10 == 0 )); then notify=true alert_level="warning" fi if [[ "$notify" == "true" ]]; then send_failed_login_alert "$ip" "$user" "$attempts" "$date_str" "$alert_level" fi return "$attempts" } # ============================================================================ # TELEGRAM SENDER # ============================================================================ send_telegram_message() { local message="$1" local disable_notification="${2:-false}" local parse_mode="${3:-markdown}" if [[ "${CONFIG[ENABLE_NOTIFICATIONS]}" != "true" ]]; then log "INFO" "Notifications disabled, message not sent" return 0 fi local url="https://api.telegram.org/bot${CONFIG[TELEGRAM_TOKEN]}/sendMessage" local response_file="/tmp/tg_response_$$.json" local curl_cmd=(curl -s -X POST "$url" -F "chat_id=${CONFIG[TELEGRAM_CHAT_ID]}" -F "text=$message" -F "parse_mode=$parse_mode" -F "disable_notification=$disable_notification") if [[ -n "${CONFIG[TELEGRAM_TOPIC_ID]}" ]]; then curl_cmd+=(-F "message_thread_id=${CONFIG[TELEGRAM_TOPIC_ID]}") fi local http_code http_code="$("${curl_cmd[@]}" -w "%{http_code}" -o "$response_file" 2>/dev/null || echo "000")" if [[ "$http_code" -eq 200 ]]; then log "DEBUG" "Telegram message sent" else log "ERROR" "Telegram send failed (HTTP $http_code). Response: $(cat "$response_file" 2>/dev/null)" fi rm -f "$response_file" } send_failed_login_alert() { local ip="$1" local user="$2" local attempts="$3" local date_str="$4" local alert_level="$5" # Rate limiting local rate_key="failed_${ip}_${user}" check_rate_limit "$rate_key" || return 0 local ip_info ip_info="$(get_ip_info "$ip")" local danger_emoji title case "$alert_level" in critical) danger_emoji="${EMOJI[FIRE]}${EMOJI[CRITICAL]}" title="*КРИТИЧЕСКАЯ АТАКА!*" ;; warning) if (( attempts >= 10 )); then danger_emoji="${EMOJI[DANGER]}⚠️" title="*ВЫСОКАЯ АКТИВНОСТЬ*" else danger_emoji="${EMOJI[WARNING]}" title="" fi ;; *) danger_emoji="❌" title="" ;; esac local message message="$(cat << EOF ${danger_emoji} *НЕУДАЧНАЯ ПОПЫТКА SSH* ${danger_emoji} ${title} ⚠️ *Попытка:* #${attempts} 👤 *Пользователь:* \`${user}\` 🖥️ *Сервер:* \`${HOSTNAME:-$(hostname)}\` 🔗 *IP атакующего:* \`${ip}\` *Геоинформация:* ${ip_info} 🕐 *Время:* ${date_str} 📊 *Всего попыток с IP:* ${attempts} #SSH #FailedLogin #SecurityAlert EOF )" send_telegram_message "$message" "false" } send_successful_login() { local ip="$1" local user="$2" local rate_key="success_${ip}_${user}" check_rate_limit "$rate_key" || return 0 local ip_info ip_info="$(get_ip_info "$ip")" local message message="$(cat << EOF ✅ *УСПЕШНЫЙ ВХОД SSH* 👤 *Пользователь:* \`${user}\` 🖥️ *Сервер:* \`${HOSTNAME:-$(hostname)}\` 🔗 *IP адрес:* \`${ip}\` *Информация о подключении:* ${ip_info} 🕐 *Время входа:* $(date '+%d %b %Y, %H:%M:%S') 📡 *Сервис:* ${PAM_SERVICE:-ssh} #SSH #Login #Successful EOF )" send_telegram_message "$message" "true" } send_logout_notification() { local ip="$1" local user="$2" local session_start="${3:-}" local duration="?" if [[ -n "$session_start" ]]; then local start_sec end_sec diff start_sec="$(date -d "$session_start" +%s 2>/dev/null || echo 0)" end_sec="$(date +%s)" if (( start_sec > 0 )); then diff=$((end_sec - start_sec)) duration="$(printf '%02d:%02d:%02d' $((diff/3600)) $(((diff%3600)/60)) $((diff%60)))" fi fi local message message="$(cat << EOF 🚪 *ВЫХОД ИЗ SSH СЕССИИ* 👤 *Пользователь:* \`${user}\` 🖥️ *Сервер:* \`${HOSTNAME:-$(hostname)}\` 🔗 *IP адрес:* \`${ip}\` 🕐 *Время выхода:* $(date '+%d %b %Y, %H:%M:%S') ⏱️ *Длительность:* $duration #SSH #Logout EOF )" send_telegram_message "$message" "true" } # ============================================================================ # LOG MONITORING (режим демона) # ============================================================================ monitor_logs() { log "INFO" "Starting log monitor" local use_journal="${CONFIG[USE_JOURNAL]}" if [[ "$use_journal" == "true" ]] && command_exists journalctl; then journalctl -f -n0 -u ssh.service -o cat | while read -r line; do process_log_line "$line" done else local auth_logs=("/var/log/auth.log" "/var/log/secure") local log_file="" for f in "${auth_logs[@]}"; do if [[ -f "$f" ]]; then log_file="$f" break fi done if [[ -z "$log_file" ]]; then log "ERROR" "No auth log file found" return 1 fi log "INFO" "Monitoring $log_file" tail -Fn0 "$log_file" | while read -r line; do process_log_line "$line" done fi } process_log_line() { local line="$1" local ip user # Failed password if [[ "$line" =~ Failed\ password\ for\ (.+)\ from\ ([0-9\.]+) ]]; then user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" check_ip_filter "$ip" || return 0 track_failed_attempt "$ip" "$user" # Invalid user elif [[ "$line" =~ Invalid\ user\ ([^\ ]+)\ from\ ([0-9\.]+) ]]; then user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" check_ip_filter "$ip" || return 0 track_failed_attempt "$ip" "$user" # Successful login elif [[ "$line" =~ Accepted\ password\ for\ ([^\ ]+)\ from\ ([0-9\.]+) ]]; then user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" check_ip_filter "$ip" || return 0 send_successful_login "$ip" "$user" fi } # ============================================================================ # PAM HANDLER (вызывается из PAM) # ============================================================================ handle_pam() { local pam_type="${PAM_TYPE:-}" local pam_user="${PAM_USER:-}" local pam_rhost="${PAM_RHOST:-}" local pam_service="${PAM_SERVICE:-}" [[ -z "$pam_type" || -z "$pam_user" || -z "$pam_rhost" ]] && return 0 # Фильтрация check_ip_filter "$pam_rhost" || return 0 log "INFO" "PAM event: $pam_type from $pam_rhost as $pam_user" case "$pam_type" in open_session) send_successful_login "$pam_rhost" "$pam_user" ;; close_session) send_logout_notification "$pam_rhost" "$pam_user" ;; auth) # Обрабатывается через логи, игнорируем ;; *) log "DEBUG" "Unknown PAM type: $pam_type" ;; esac } # ============================================================================ # MAINTENANCE # ============================================================================ cleanup() { log "INFO" "Cleaning up old files" find "$FAILED_LOGINS_DIR" -type f -mtime "+${CONFIG[CLEANUP_DAYS]}" -delete find "$IP_CACHE_DIR" -type f -mtime +1 -delete log "INFO" "Cleanup done" } status() { echo "=== Telegram SSH Notifier Status ===" echo "Version: $SCRIPT_VERSION" echo "Log file: ${CONFIG[LOG_FILE]}" echo "Failed logins dir: $FAILED_LOGINS_DIR" echo "Notifications: ${CONFIG[ENABLE_NOTIFICATIONS]}" echo "GeoIP: ${CONFIG[ENABLE_GEOIP]}" echo "Auto-block: ${CONFIG[AUTO_BLOCK_CRITICAL]}" echo "" local count count="$(find "$FAILED_LOGINS_DIR" -type f 2>/dev/null | wc -l)" echo "Tracked IPs with failed attempts: $count" echo "" echo "Recent failed attempts (last 10):" find "$FAILED_LOGINS_DIR" -type f -exec ls -lt {} + 2>/dev/null | head -10 | while read -r line; do local file file="$(echo "$line" | awk '{print $NF}')" if [[ -f "$file" ]]; then IFS='|' read -r attempts _ user date < "$file" local ip_name ip_name="$(basename "$file" | tr '_' '.')" echo " $ip_name: $attempts attempts, last: $date, user: $user" fi done } # ============================================================================ # COMMAND LINE PARSING # ============================================================================ usage() { cat << EOF Telegram SSH Notifier v$SCRIPT_VERSION Использование: $SCRIPT_NAME [КОМАНДА] Команды: monitor Запустить мониторинг логов (демон) cleanup Очистить старые файлы и кэш status Показать статус и статистику test Отправить тестовое сообщение --help, -h Показать эту справку --version, -v Показать версию Без аргументов работает в режиме PAM (для интеграции с /etc/pam.d/sshd). Конфигурация: $CONFIG_FILE Лог: ${CONFIG[LOG_FILE]} EOF } # ============================================================================ # MAIN # ============================================================================ main() { # Предотвращаем повторный запуск в режиме monitor if [[ "${1:-}" == "monitor" ]]; then if [[ -f "$LOCK_FILE" ]]; then if kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then log "ERROR" "Another monitor process is already running (PID $(cat "$LOCK_FILE")). Exiting." exit 1 else rm -f "$LOCK_FILE" fi fi echo $$ > "$LOCK_FILE" trap 'rm -f "$LOCK_FILE"' EXIT fi load_config case "${1:-}" in monitor) monitor_logs ;; cleanup) cleanup ;; status) status ;; test) send_telegram_message "*Тестовое сообщение от SSH Notifier*" "true" echo "Тестовое сообщение отправлено." ;; --help|-h) usage ;; --version|-v) echo "$SCRIPT_VERSION" ;; "") # PAM mode handle_pam ;; *) echo "Неизвестная команда: $1" usage exit 1 ;; esac } # Защита от source if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then trap 'log "ERROR" "Script failed at line $LINENO"' ERR main "$@" fi